first
This commit is contained in:
commit
fa9d154db9
260 files changed
+17643
No files matched your search
Whitespace-only changes.
@@ -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)
|
||||
@@ -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),
|
||||
]
|
||||
+33
@@ -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.
@@ -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")]
|
||||
@@ -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
|
||||
Reference in new issue
Block a user