This commit is contained in:
javad committed 2026-09-10 11:34:19 +03:30
commit fa9d154db9
260 files changed
+17643

No files matched your search

View File
Whitespace-only changes.
+13
View File
@@ -0,0 +1,13 @@
from django.contrib import admin
from .models import NotificationJob,NotificationPolicy,SMSTemplate
@admin.register(NotificationJob)
class NotificationJobAdmin(admin.ModelAdmin): list_display=("channel","event_type","status","attempts","provider_message_id","created_at"); list_filter=("channel","status"); readonly_fields=("provider_message_id","dedupe_key","created_at","sent_at")
@admin.register(SMSTemplate)
class SMSTemplateAdmin(admin.ModelAdmin):
list_display=("event_type","unit","title","is_active","updated_at")
list_filter=("unit","is_active","event_type")
readonly_fields=("allowed_tags",)
@admin.register(NotificationPolicy)
class NotificationPolicyAdmin(admin.ModelAdmin):
list_display=("key","quiet_period_seconds","is_active","updated_at")
def has_add_permission(self,request): return NotificationPolicy.objects.count()<len(NotificationPolicy.Key.values)
+2
View File
@@ -0,0 +1,2 @@
from django.apps import AppConfig
class NotificationsConfig(AppConfig): name="apps.notifications"
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,164 @@
import logging
import time
from datetime import timedelta
import jdatetime
from django.conf import settings
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils import timezone
from apps.accounts.models import TicketMagicLink
from apps.integrations.bale.providers import get_bale_provider
from apps.integrations.sms.providers import get_sms_provider
from apps.messaging.models import MessageReadReceipt
from apps.notifications.models import NotificationJob,NotificationPolicy,SMSTemplate
from apps.notifications.services import customer_reply_bale_policy,staff_reply_policy
from apps.tickets.models import Ticket
logger = logging.getLogger(__name__)
def _magic_link(job, reason):
token,link = TicketMagicLink.issue_with_record(job.ticket, reason=reason, created_by=job.staff_user or job.ticket.owner or job.ticket.created_by)
return link, f"{settings.PUBLIC_BASE_URL}/t/{token}/"
def _sms_context(job,magic_link):
profile=getattr(job.customer,"customer_profile",None)
unit=job.ticket.owning_unit
return {"magic_link":magic_link,"first_name":getattr(profile,"first_name","") or "","last_name":getattr(profile,"last_name","") or "","recipient_name":" ".join(filter(None,[getattr(profile,"first_name","") or "",getattr(profile,"last_name","") or ""])),"unit_name":unit.name if unit else "","company_name":getattr(settings,"COMPANY_NAME","لوله گستر گلپایگان"),"ticket_number":job.ticket.ticket_number,"subject":job.ticket.subject,"province":job.ticket.province or getattr(profile,"province","") or "","city":job.ticket.city or getattr(profile,"city","") or "","expert_name":job.ticket.assigned_expert_name}
def _render_sms(event_type,context,unit=None):
from string import Formatter
template=(SMSTemplate.objects.filter(event_type=event_type,is_active=True,unit=unit).first() if unit else None) or SMSTemplate.objects.get(event_type=event_type,is_active=True,unit__isnull=True)
used={field for _,field,_,_ in Formatter().parse(template.body) if field}
invalid=used-set(template.allowed_tags)
if invalid: raise ValueError(f"SMS template contains unsupported tags: {', '.join(sorted(invalid))}")
missing=used-set(context)
if missing: raise ValueError(f"SMS template context is missing: {', '.join(sorted(missing))}")
return template.body.format_map(context)
def _bale_ticket_text(ticket, message=None, *, is_reply=False, message_count=1, reopened=False):
profile = getattr(ticket.customer, "customer_profile", None)
name = " ".join(x for x in [getattr(profile, "first_name", ""), getattr(profile, "last_name", "")] if x).strip() or "ثبت نشده"
location = "، ".join(x for x in [getattr(profile, "city", "") or ticket.city, getattr(profile, "province", "") or ticket.province] if x)
message = message or ticket.ticket_messages.filter(is_internal=False).order_by("created_at", "id").first()
raw_excerpt = " ".join((message.body if message else "").split())
excerpt = f"{raw_excerpt[:277].rstrip()}…" if len(raw_excerpt)>280 else raw_excerpt or "بدون متن"
local_created = timezone.localtime(message.created_at if message else ticket.created_at)
jalali = jdatetime.datetime.fromgregorian(datetime=local_created, locale=jdatetime.FA_LOCALE).strftime("%d %B، %H:%M")
lines=[
"♻️ بازگشایی تیکت توسط مشتری" if reopened else (f"💬 {message_count} پیام جدید مشتری" if is_reply and message_count>1 else ("💬 پاسخ جدید مشتری" if is_reply else "🆕 تیکت جدید مشتری")),
"────────────",
f"🎫 {ticket.ticket_number}",
f"📌 {ticket.subject}",
f"👤 {name}",
f"📞 {ticket.customer.local_mobile or 'ثبت نشده'}",
]
if location: lines.append(f"📍 {location}")
lines.extend([f"🕒 {jalali}", "", f"📝 {excerpt}", "", "🔗 مشاهده و پاسخ", f"{settings.PUBLIC_BASE_URL}/staff/tickets/{ticket.public_id}/"])
return "\n".join(lines)
def _send_sms(job):
issued = None
if job.event_type == "CUSTOMER_TICKET_CREATED":
issued, url = _magic_link(job, "customer_created_ticket")
message = _render_sms(job.event_type,_sms_context(job,url),job.ticket.owning_unit)
elif job.event_type == "STAFF_TICKET_CREATED":
issued, url = _magic_link(job, "staff_created_ticket"); message = _render_sms(job.event_type,_sms_context(job,url),job.ticket.owning_unit)
elif job.event_type == "STAFF_REPLY":
issued, url = _magic_link(job, "staff_reply"); message = _render_sms(job.event_type,_sms_context(job,url),job.ticket.owning_unit)
else:
raise ValueError(f"Unsupported SMS event: {job.event_type}")
return get_sms_provider().send_message(job.customer.mobile, message), issued
def _send_bale(job):
if job.event_type == "NEW_CUSTOMER_TICKET":
return get_bale_provider().send_message(settings.BALE_GROUP_CHAT_ID, _bale_ticket_text(job.ticket))
if job.event_type in ["CUSTOMER_TICKET_REPLY","CUSTOMER_TICKET_REOPENED"]:
message = job.ticket.ticket_messages.filter(pk=job.payload.get("message_id"), is_internal=False).first()
if not message: raise ValueError("Customer reply message no longer exists")
return get_bale_provider().send_message(settings.BALE_GROUP_CHAT_ID, _bale_ticket_text(job.ticket,message,is_reply=True,message_count=job.payload.get("message_count",1),reopened=job.event_type=="CUSTOMER_TICKET_REOPENED"))
if not job.staff_user or not hasattr(job.staff_user, "staff_profile"): raise ValueError("Bale recipient is not configured")
profile = job.staff_user.staff_profile
if not profile.bale_notifications_enabled: return None
return get_bale_provider().send_message(profile.bale_chat_id, f"رویداد جدید برای تیکت {job.ticket.ticket_number}\n{settings.PUBLIC_BASE_URL}/staff/tickets/{job.ticket.public_id}/")
def _prepare_staff_reply_sms(job):
"""Final transactional guard immediately before the irreversible provider call."""
with transaction.atomic():
ticket=Ticket.objects.select_for_update().get(pk=job.ticket_id)
locked=NotificationJob.objects.select_for_update().get(pk=job.pk)
policy=staff_reply_policy()
message_id=locked.payload.get("latest_message_id")
message=ticket.ticket_messages.filter(pk=message_id,is_internal=False,author__account_type="STAFF").first()
reason=""
if not policy.is_active: reason="policy_disabled"
elif not message: reason="staff_message_missing"
elif MessageReadReceipt.objects.filter(message=message,user_id=locked.customer_id).exists(): reason="customer_read"
elif ticket.ticket_messages.filter(is_internal=False,author_id=locked.customer_id,created_at__gt=message.created_at).exists(): reason="customer_replied"
if reason:
locked.status=NotificationJob.Status.CANCELLED; locked.last_error=reason
locked.save(update_fields=["status","last_error"])
return False
due=ticket.last_activity_at+timedelta(seconds=policy.quiet_period_seconds)
if due>timezone.now():
locked.status=NotificationJob.Status.PENDING; locked.next_attempt_at=due; locked.last_error="conversation_active"
locked.save(update_fields=["status","next_attempt_at","last_error"])
return False
return True
def _prepare_customer_reply_bale(job):
with transaction.atomic():
ticket=Ticket.objects.select_for_update().get(pk=job.ticket_id)
locked=NotificationJob.objects.select_for_update().get(pk=job.pk)
policy=customer_reply_bale_policy()
if not policy.is_active:
locked.status=NotificationJob.Status.CANCELLED; locked.last_error="policy_disabled"; locked.save(update_fields=["status","last_error"]); return False
due=ticket.last_customer_message_at+timedelta(seconds=policy.quiet_period_seconds)
if due>timezone.now():
locked.status=NotificationJob.Status.PENDING; locked.next_attempt_at=due; locked.last_error="customer_chat_active"
locked.save(update_fields=["status","next_attempt_at","last_error"]); return False
return True
class Command(BaseCommand):
help = "Process notification outbox"
def add_arguments(self, parser):
parser.add_argument("--once", action="store_true")
parser.add_argument("--job-id", type=int, help="Process only one notification job")
parser.add_argument("--min-id", type=int, help="Process this job id and newer jobs")
parser.add_argument("--channel", choices=NotificationJob.Channel.values, help="Process only one notification channel")
def handle(self, *args, **options):
while True:
stale = timezone.now() - timedelta(minutes=10)
NotificationJob.objects.filter(status="PROCESSING", next_attempt_at__lt=stale).update(status="FAILED", next_attempt_at=timezone.now(), last_error="Recovered stale processing job")
with transaction.atomic():
queryset = NotificationJob.objects.select_for_update(skip_locked=True).filter(status__in=["PENDING", "FAILED"], attempts__lt=settings.MAX_NOTIFICATION_ATTEMPTS, next_attempt_at__lte=timezone.now())
if options.get("channel"):
queryset = queryset.filter(channel=options["channel"])
if options.get("job_id"):
queryset = queryset.filter(pk=options["job_id"])
elif options.get("min_id"):
queryset = queryset.filter(pk__gte=options["min_id"])
jobs = list(queryset.order_by("created_at")[:20])
NotificationJob.objects.filter(pk__in=[x.pk for x in jobs]).update(status="PROCESSING", next_attempt_at=timezone.now())
for job in jobs:
issued_link = None
try:
if job.channel == NotificationJob.Channel.SMS and job.event_type == "STAFF_REPLY" and not _prepare_staff_reply_sms(job):
continue
if job.channel == NotificationJob.Channel.BALE and job.event_type == "CUSTOMER_TICKET_REPLY" and not _prepare_customer_reply_bale(job):
continue
if job.channel == NotificationJob.Channel.SMS: result, issued_link = _send_sms(job)
else: result = _send_bale(job)
if result is not None and not result.success: raise RuntimeError(result.error or "Provider rejected notification")
job.attempts += 1; job.status = "SENT"; job.sent_at = timezone.now(); job.last_error = ""; job.provider_message_id = getattr(result,"provider_id","") if result is not None else ""
except Exception as exc:
if issued_link:
issued_link.revoked_at = timezone.now(); issued_link.save(update_fields=["revoked_at"])
job.attempts += 1; job.last_error = str(exc)[:1000]; job.provider_message_id = ""; job.status = "FAILED"; job.next_attempt_at = timezone.now() + timedelta(seconds=min(3600, 2**job.attempts * 10)); logger.exception("notification failed id=%s", job.pk)
job.save()
if job.ticket_id and job.ticket.group_dispatch_id:
from apps.tickets.services import refresh_group_dispatch_status
refresh_group_dispatch_status(job.ticket.group_dispatch_id)
if options["once"] or options.get("job_id"): break
time.sleep(5)
@@ -0,0 +1,37 @@
# Generated by Django 5.1.1 on 2026-09-05 07:03
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('tickets', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='NotificationJob',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('channel', models.CharField(choices=[('SMS', 'پیامک'), ('BALE', 'بله')], max_length=10)),
('event_type', models.CharField(max_length=50)),
('payload', models.JSONField(default=dict)),
('status', models.CharField(choices=[('PENDING', 'در صف'), ('PROCESSING', 'در حال ارسال'), ('SENT', 'ارسال\u200cشده'), ('FAILED', 'ناموفق')], db_index=True, default='PENDING', max_length=15)),
('attempts', models.PositiveSmallIntegerField(default=0)),
('next_attempt_at', models.DateTimeField(db_index=True)),
('last_error', models.TextField(blank=True)),
('dedupe_key', models.CharField(max_length=200, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('sent_at', models.DateTimeField(blank=True, null=True)),
('customer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='customer_notifications', to=settings.AUTH_USER_MODEL)),
('staff_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='staff_notifications', to=settings.AUTH_USER_MODEL)),
('ticket', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='tickets.ticket')),
],
),
]
@@ -0,0 +1,40 @@
# Generated by Django 5.1.1 on 2026-09-07 04:56
import apps.core.models
from django.db import migrations, models
TAGS=["magic_link","first_name","last_name","ticket_number","subject","province","city","expert_name"]
DEFAULTS={
"CUSTOMER_TICKET_CREATED":("ثبت تیکت توسط مشتری","{first_name} عزیز، از ثبت درخواست شما متشکریم. تیکت {ticket_number} با موفقیت ثبت شد.\nمشاهده و پیگیری: {magic_link}\nپس از بررسی و پاسخ کارشناس، از طریق پیامک به شما اطلاع خواهیم داد.\nلوله گستر گلپایگان"),
"STAFF_TICKET_CREATED":("ثبت تیکت توسط کارشناس","{first_name} عزیز، تیکت {ticket_number} با موضوع «{subject}» توسط واحد فروش برای شما ثبت شد.\nمشاهده و پاسخ: {magic_link}\nلوله گستر گلپایگان"),
"STAFF_REPLY":("پاسخ کارشناس","{first_name} عزیز، پاسخ جدیدی برای تیکت {ticket_number} ثبت شد.\nمشاهده پاسخ: {magic_link}\nلوله گستر گلپایگان"),
}
def seed_sms_templates(apps,schema_editor):
model=apps.get_model("notifications","SMSTemplate")
for event,(title,body) in DEFAULTS.items(): model.objects.get_or_create(event_type=event,defaults={"title":title,"body":body,"allowed_tags":TAGS})
def remove_sms_templates(apps,schema_editor):
apps.get_model("notifications","SMSTemplate").objects.filter(event_type__in=DEFAULTS).delete()
class Migration(migrations.Migration):
dependencies = [
('notifications', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='SMSTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('event_type', models.CharField(choices=[('CUSTOMER_TICKET_CREATED', 'ثبت تیکت توسط مشتری'), ('STAFF_TICKET_CREATED', 'ثبت تیکت توسط کارشناس'), ('STAFF_REPLY', 'پاسخ کارشناس')], max_length=50, unique=True)),
('title', models.CharField(max_length=120)),
('body', models.TextField(help_text='تگ\u200cها را مانند {magic_link} در متن قرار دهید.')),
('allowed_tags', models.JSONField(blank=True, default=list)),
('is_active', models.BooleanField(default=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
bases=(apps.core.models.PersianTextNormalizationMixin, models.Model),
),
migrations.RunPython(seed_sms_templates,remove_sms_templates),
]
@@ -0,0 +1,31 @@
from django.db import migrations, models
def seed_policy(apps, schema_editor):
apps.get_model("notifications", "NotificationPolicy").objects.get_or_create(
key="STAFF_REPLY_SMS",
defaults={"quiet_period_seconds": 300, "is_active": True},
)
class Migration(migrations.Migration):
dependencies = [("notifications", "0002_smstemplate")]
operations = [
migrations.CreateModel(
name="NotificationPolicy",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("key", models.CharField(choices=[("STAFF_REPLY_SMS", "پیامک پاسخ کارشناس")], max_length=50, unique=True)),
("quiet_period_seconds", models.PositiveIntegerField(default=300, help_text="پس از آخرین فعالیت تیکت، چند ثانیه برای ادامه گفت‌وگو صبر شود.")),
("is_active", models.BooleanField(default=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={"verbose_name": "سیاست اعلان", "verbose_name_plural": "سیاست‌های اعلان"},
),
migrations.AlterField(
model_name="notificationjob",
name="status",
field=models.CharField(choices=[("PENDING", "در صف"), ("PROCESSING", "در حال ارسال"), ("SENT", "ارسال‌شده"), ("FAILED", "ناموفق"), ("CANCELLED", "لغوشده")], db_index=True, default="PENDING", max_length=15),
),
migrations.RunPython(seed_policy, migrations.RunPython.noop),
]
@@ -0,0 +1,27 @@
from django.db import migrations, models
def seed_policy(apps, schema_editor):
apps.get_model("notifications", "NotificationPolicy").objects.get_or_create(
key="CUSTOMER_REPLY_BALE",
defaults={"quiet_period_seconds": 600, "is_active": True},
)
class Migration(migrations.Migration):
dependencies = [("notifications", "0003_notificationpolicy_notificationjob_status")]
operations = [
migrations.AlterField(
model_name="notificationpolicy",
name="key",
field=models.CharField(
choices=[
("STAFF_REPLY_SMS", "پیامک پاسخ کارشناس"),
("CUSTOMER_REPLY_BALE", "اعلان پاسخ مشتری در بله"),
],
max_length=50,
unique=True,
),
),
migrations.RunPython(seed_policy, migrations.RunPython.noop),
]
@@ -0,0 +1,33 @@
# Generated by Django 5.1.1 on 2026-09-08 09:57
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('notifications', '0004_customer_reply_bale_policy'),
('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'),
]
operations = [
migrations.AddField(
model_name='smstemplate',
name='unit',
field=models.ForeignKey(blank=True, help_text='خالی یعنی الگوی عمومی و fallback.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sms_templates', to='staff.organizationalunit'),
),
migrations.AlterField(
model_name='smstemplate',
name='event_type',
field=models.CharField(choices=[('CUSTOMER_TICKET_CREATED', 'ثبت تیکت توسط مشتری'), ('STAFF_TICKET_CREATED', 'ثبت تیکت توسط کارشناس'), ('STAFF_REPLY', 'پاسخ کارشناس')], max_length=50),
),
migrations.AddConstraint(
model_name='smstemplate',
constraint=models.UniqueConstraint(fields=('unit', 'event_type'), name='unique_sms_template_per_unit'),
),
migrations.AddConstraint(
model_name='smstemplate',
constraint=models.UniqueConstraint(condition=models.Q(('unit__isnull', True)), fields=('event_type',), name='unique_global_sms_template_event'),
),
]
@@ -0,0 +1,29 @@
# Generated by Django 5.1.1 on 2026-09-08 10:05
import apps.notifications.models
from django.db import migrations, models
TAGS = ["magic_link", "first_name", "last_name", "recipient_name", "unit_name", "company_name", "ticket_number", "subject", "province", "city", "expert_name"]
def add_supported_tags(apps, schema_editor):
template_model = apps.get_model("notifications", "SMSTemplate")
for template in template_model.objects.all().iterator():
template.allowed_tags = list(dict.fromkeys([*template.allowed_tags, *TAGS]))
template.save(update_fields=["allowed_tags"])
class Migration(migrations.Migration):
dependencies = [
('notifications', '0005_smstemplate_unit_alter_smstemplate_event_type_and_more'),
]
operations = [
migrations.AlterField(
model_name='smstemplate',
name='allowed_tags',
field=models.JSONField(blank=True, default=apps.notifications.models.sms_template_tags),
),
migrations.RunPython(add_supported_tags, migrations.RunPython.noop),
]
@@ -0,0 +1,24 @@
# Generated by Django 5.1.1 on 2026-09-08 15:32
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('notifications', '0006_alter_smstemplate_allowed_tags'),
('tickets', '0006_assign_legacy_contacts_to_sales'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddIndex(
model_name='notificationjob',
index=models.Index(fields=['status', 'next_attempt_at'], name='notif_due_idx'),
),
migrations.AddIndex(
model_name='notificationjob',
index=models.Index(fields=['ticket', 'channel', 'status'], name='notif_ticket_chan_idx'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2026-09-08 15:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('notifications', '0007_notificationjob_notif_due_idx_and_more'),
]
operations = [
migrations.AddField(
model_name='notificationjob',
name='provider_message_id',
field=models.CharField(blank=True, max_length=500),
),
]
@@ -0,0 +1,20 @@
from django.db import migrations
DEFAULTS = {
"CUSTOMER_TICKET_CREATED": ("ثبت تیکت توسط مشتری", "{first_name} عزیز، تیکت {ticket_number} ثبت شد.\nمشاهده و پیگیری: {magic_link}\nلوله گستر گلپایگان"),
"STAFF_TICKET_CREATED": ("ثبت تیکت توسط کارشناس", "{first_name} عزیز، تیکت {ticket_number} با موضوع «{subject}» برای شما ثبت شد.\nمشاهده و پاسخ: {magic_link}\nلوله گستر گلپایگان"),
"STAFF_REPLY": ("پاسخ کارشناس", "{first_name} عزیز، پاسخ جدیدی برای تیکت {ticket_number} ثبت شد.\nمشاهده پاسخ: {magic_link}\nلوله گستر گلپایگان"),
}
TAGS = ["magic_link", "first_name", "last_name", "recipient_name", "unit_name", "company_name", "ticket_number", "subject", "province", "city", "expert_name"]
def ensure_defaults(apps, schema_editor):
model = apps.get_model("notifications", "SMSTemplate")
for event_type, (title, body) in DEFAULTS.items():
model.objects.get_or_create(unit=None, event_type=event_type, defaults={"title": title, "body": body, "allowed_tags": TAGS, "is_active": True})
class Migration(migrations.Migration):
dependencies = [("notifications", "0008_notificationjob_provider_message_id")]
operations = [migrations.RunPython(ensure_defaults, migrations.RunPython.noop)]
Whitespace-only changes.
+50
View File
@@ -0,0 +1,50 @@
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from apps.core.models import PersianTextNormalizationMixin
def sms_template_tags():
return ["magic_link", "first_name", "last_name", "recipient_name", "unit_name", "company_name", "ticket_number", "subject", "province", "city", "expert_name"]
class SMSTemplate(PersianTextNormalizationMixin, models.Model):
persian_text_fields={"title":False,"body":True}
class Event(models.TextChoices):
CUSTOMER_TICKET_CREATED="CUSTOMER_TICKET_CREATED","ثبت تیکت توسط مشتری"
STAFF_TICKET_CREATED="STAFF_TICKET_CREATED","ثبت تیکت توسط کارشناس"
STAFF_REPLY="STAFF_REPLY","پاسخ کارشناس"
unit=models.ForeignKey("staff.OrganizationalUnit",on_delete=models.CASCADE,null=True,blank=True,related_name="sms_templates",help_text="خالی یعنی الگوی عمومی و fallback.")
event_type=models.CharField(max_length=50,choices=Event.choices)
title=models.CharField(max_length=120)
body=models.TextField(help_text="تگ‌ها را مانند {magic_link} در متن قرار دهید.")
allowed_tags=models.JSONField(default=sms_template_tags,blank=True)
is_active=models.BooleanField(default=True)
updated_at=models.DateTimeField(auto_now=True)
class Meta:
constraints=[models.UniqueConstraint(fields=["unit","event_type"],name="unique_sms_template_per_unit"),models.UniqueConstraint(fields=["event_type"],condition=models.Q(unit__isnull=True),name="unique_global_sms_template_event")]
def __str__(self): return self.get_event_type_display()
def clean(self):
from string import Formatter
tags={field for _,field,_,_ in Formatter().parse(self.body) if field}
invalid=tags-set(self.allowed_tags)
if invalid: raise ValidationError({"body":f"تگ‌های غیرمجاز: {', '.join(sorted(invalid))}"})
class NotificationPolicy(models.Model):
class Key(models.TextChoices):
STAFF_REPLY_SMS="STAFF_REPLY_SMS","پیامک پاسخ کارشناس"
CUSTOMER_REPLY_BALE="CUSTOMER_REPLY_BALE","اعلان پاسخ مشتری در بله"
key=models.CharField(max_length=50,choices=Key.choices,unique=True)
quiet_period_seconds=models.PositiveIntegerField(default=300,help_text="پس از آخرین فعالیت تیکت، چند ثانیه برای ادامه گفت‌وگو صبر شود.")
is_active=models.BooleanField(default=True)
updated_at=models.DateTimeField(auto_now=True)
class Meta:
verbose_name="سیاست اعلان"
verbose_name_plural="سیاست‌های اعلان"
def __str__(self): return self.get_key_display()
class NotificationJob(models.Model):
class Channel(models.TextChoices): SMS="SMS","پیامک"; BALE="BALE","بله"
class Status(models.TextChoices): PENDING="PENDING","در صف"; PROCESSING="PROCESSING","در حال ارسال"; SENT="SENT","ارسال‌شده"; FAILED="FAILED","ناموفق"; CANCELLED="CANCELLED","لغوشده"
channel=models.CharField(max_length=10,choices=Channel.choices); event_type=models.CharField(max_length=50); customer=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE,null=True,blank=True,related_name="customer_notifications"); staff_user=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE,null=True,blank=True,related_name="staff_notifications"); ticket=models.ForeignKey("tickets.Ticket",on_delete=models.CASCADE,null=True,blank=True); payload=models.JSONField(default=dict); status=models.CharField(max_length=15,choices=Status.choices,default=Status.PENDING,db_index=True); attempts=models.PositiveSmallIntegerField(default=0); next_attempt_at=models.DateTimeField(db_index=True); last_error=models.TextField(blank=True); provider_message_id=models.CharField(max_length=500,blank=True); dedupe_key=models.CharField(max_length=200,unique=True); created_at=models.DateTimeField(auto_now_add=True); sent_at=models.DateTimeField(null=True,blank=True)
class Meta:
indexes=[models.Index(fields=["status","next_attempt_at"],name="notif_due_idx"),models.Index(fields=["ticket","channel","status"],name="notif_ticket_chan_idx")]
+53
View File
@@ -0,0 +1,53 @@
from datetime import timedelta
from uuid import uuid4
from django.utils import timezone
from .models import NotificationJob,NotificationPolicy
def enqueue(channel,event_type,ticket,dedupe_key,payload=None,customer=None,staff_user=None):
return NotificationJob.objects.get_or_create(dedupe_key=dedupe_key,defaults={"channel":channel,"event_type":event_type,"ticket":ticket,"payload":payload or {},"customer":customer,"staff_user":staff_user,"next_attempt_at":timezone.now()})[0]
def staff_reply_policy():
return NotificationPolicy.objects.get_or_create(key=NotificationPolicy.Key.STAFF_REPLY_SMS,defaults={"quiet_period_seconds":300})[0]
def customer_reply_bale_policy():
return NotificationPolicy.objects.get_or_create(key=NotificationPolicy.Key.CUSTOMER_REPLY_BALE,defaults={"quiet_period_seconds":600})[0]
def cancel_staff_reply_sms(ticket,reason):
return NotificationJob.objects.filter(ticket=ticket,channel=NotificationJob.Channel.SMS,event_type="STAFF_REPLY",status__in=[NotificationJob.Status.PENDING,NotificationJob.Status.FAILED]).update(status=NotificationJob.Status.CANCELLED,last_error=reason)
def schedule_staff_reply_sms(ticket,message):
"""Debounce a chat burst into one SMS; caller already locks the ticket."""
policy=staff_reply_policy()
if not policy.is_active:
cancel_staff_reply_sms(ticket,"policy_disabled")
return None,False
due=timezone.now()+timedelta(seconds=policy.quiet_period_seconds)
job=NotificationJob.objects.filter(ticket=ticket,customer=ticket.customer,channel=NotificationJob.Channel.SMS,event_type="STAFF_REPLY",status__in=[NotificationJob.Status.PENDING,NotificationJob.Status.FAILED]).order_by("-created_at").first()
payload={"latest_message_id":message.pk}
if job:
job.payload=payload; job.next_attempt_at=due; job.status=NotificationJob.Status.PENDING; job.attempts=0; job.last_error=""
job.save(update_fields=["payload","next_attempt_at","status","attempts","last_error"])
return job,False
return NotificationJob.objects.create(channel=NotificationJob.Channel.SMS,event_type="STAFF_REPLY",ticket=ticket,customer=ticket.customer,payload=payload,next_attempt_at=due,dedupe_key=f"ticket:{ticket.pk}:staff-reply:{uuid4().hex}"),True
def cancel_staff_reply_sms_if_read(ticket,customer):
jobs=NotificationJob.objects.filter(ticket=ticket,customer=customer,channel=NotificationJob.Channel.SMS,event_type="STAFF_REPLY",status__in=[NotificationJob.Status.PENDING,NotificationJob.Status.FAILED])
cancelled=0
for job in jobs:
message_id=job.payload.get("latest_message_id")
if message_id and customer.message_read_receipts.filter(message_id=message_id).exists():
job.status=NotificationJob.Status.CANCELLED; job.last_error="customer_read"; job.save(update_fields=["status","last_error"]); cancelled+=1
return cancelled
def schedule_customer_reply_bale(ticket,message):
"""Debounce consecutive customer chat messages into one Bale group alert."""
policy=customer_reply_bale_policy()
if not policy.is_active: return None,False
due=timezone.now()+timedelta(seconds=policy.quiet_period_seconds)
job=NotificationJob.objects.filter(ticket=ticket,channel=NotificationJob.Channel.BALE,event_type="CUSTOMER_TICKET_REPLY",status__in=[NotificationJob.Status.PENDING,NotificationJob.Status.FAILED]).order_by("-created_at").first()
count=int((job.payload if job else {}).get("message_count",0))+1
payload={"message_id":message.pk,"message_count":count}
if job:
job.payload=payload; job.next_attempt_at=due; job.status=NotificationJob.Status.PENDING; job.attempts=0; job.last_error=""
job.save(update_fields=["payload","next_attempt_at","status","attempts","last_error"])
return job,False
return NotificationJob.objects.create(channel=NotificationJob.Channel.BALE,event_type="CUSTOMER_TICKET_REPLY",ticket=ticket,customer=ticket.customer,payload=payload,next_attempt_at=due,dedupe_key=f"ticket:{ticket.pk}:customer-reply-bale:{uuid4().hex}"),True