151 lines
10 KiB
Python
151 lines
10 KiB
Python
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", "customer_mobile", "category_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","پاسخ کارشناس"
|
|
PAYMENT_NOTICE_CREATED="PAYMENT_NOTICE_CREATED","صدور اعلامیه واریز"
|
|
PAYMENT_RECEIPT_APPROVED="PAYMENT_RECEIPT_APPROVED","تأیید نهایی فیش واریز"
|
|
PAYMENT_RECEIPT_REJECTED="PAYMENT_RECEIPT_REJECTED","رد فیش واریز"
|
|
UNIT_SUPERVISOR_NEW_TICKET="UNIT_SUPERVISOR_NEW_TICKET","اطلاع تیکت جدید به سرپرستان واحد"
|
|
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} برای تیکت و {payment_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()
|
|
@classmethod
|
|
def tags_for_event(cls, event_type):
|
|
if event_type in {'PAYMENT_NOTICE_CREATED', 'PAYMENT_RECEIPT_APPROVED', 'PAYMENT_RECEIPT_REJECTED'}:
|
|
from apps.payments.notifications import TAGS
|
|
return list(TAGS)
|
|
tags = sms_template_tags()
|
|
return [tag for tag in tags if tag != 'magic_link'] if event_type == 'UNIT_SUPERVISOR_NEW_TICKET' else tags
|
|
|
|
@property
|
|
def supported_tags(self):
|
|
return ', '.join('{' + tag + '}' for tag in self.tags_for_event(self.event_type))
|
|
|
|
def save(self, *args, **kwargs):
|
|
self.allowed_tags = self.tags_for_event(self.event_type)
|
|
if kwargs.get('update_fields') is not None:
|
|
kwargs['update_fields'] = set(kwargs['update_fields']) | {'allowed_tags'}
|
|
return super().save(*args, **kwargs)
|
|
|
|
def clean(self):
|
|
self.allowed_tags = self.tags_for_event(self.event_type)
|
|
from string import Formatter
|
|
try:
|
|
tags={field for _,field,_,_ in Formatter().parse(self.body) if field}
|
|
except ValueError:
|
|
raise ValidationError({'body': 'ساختار تگ معتبر نیست؛ هر تگ باید بین { و } قرار بگیرد.'})
|
|
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","در حال ارسال"; RETRYING="RETRYING","در انتظار تلاش مجدد"; SENT="SENT","ارسالشده"; FAILED="FAILED","ناموفق"; CANCELLED="CANCELLED","لغوشده"
|
|
class DeliveryStatus(models.TextChoices):
|
|
PENDING="PENDING","در انتظار استعلام"
|
|
DELIVERED="DELIVERED","رسیده به گوشی"
|
|
UNDELIVERED="UNDELIVERED","نرسیده به گوشی"
|
|
EXPIRED="EXPIRED","منقضیشده"
|
|
BLOCKED="BLOCKED","مسدود توسط مخاطب"
|
|
UNKNOWN="UNKNOWN","نامشخص"
|
|
ERROR="ERROR","خطای دلیوری"
|
|
CHECK_FAILED="CHECK_FAILED","خطای موقت استعلام"
|
|
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)
|
|
provider_status_code=models.CharField(max_length=50,blank=True)
|
|
delivery_status=models.CharField(max_length=20,choices=DeliveryStatus.choices,blank=True,db_index=True)
|
|
delivery_status_code=models.CharField(max_length=50,blank=True)
|
|
delivery_description=models.CharField(max_length=500,blank=True)
|
|
delivery_checked_at=models.DateTimeField(null=True,blank=True)
|
|
delivery_check_attempts=models.PositiveSmallIntegerField(default=0)
|
|
delivery_next_check_at=models.DateTimeField(null=True,blank=True,db_index=True)
|
|
delivery_is_final=models.BooleanField(default=False)
|
|
delivery_retry_source=models.OneToOneField(
|
|
"self",on_delete=models.CASCADE,null=True,blank=True,
|
|
related_name="delivery_retry_job",
|
|
)
|
|
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")]
|
|
|
|
|
|
class AnnouncementJob(models.Model):
|
|
"""Durable automatic or staff-requested prerecorded call."""
|
|
class Source(models.TextChoices):
|
|
AUTOMATIC = "AUTOMATIC", "خودکار"
|
|
MANUAL = "MANUAL", "فوری توسط کارشناس"
|
|
|
|
class Status(models.TextChoices):
|
|
PENDING = "PENDING", "در صف"
|
|
PROCESSING = "PROCESSING", "در حال تماس"
|
|
SENT = "SENT", "ارسالشده"
|
|
FAILED = "FAILED", "ناموفق"
|
|
CANCELLED = "CANCELLED", "لغوشده"
|
|
|
|
source = models.CharField(max_length=12, choices=Source.choices, default=Source.AUTOMATIC)
|
|
message = models.OneToOneField("messaging.Message", on_delete=models.CASCADE, related_name="announcement_job", null=True, blank=True)
|
|
ticket = models.ForeignKey("tickets.Ticket", on_delete=models.CASCADE, related_name="manual_announcement_jobs", null=True, blank=True)
|
|
requested_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="requested_announcement_jobs")
|
|
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)
|
|
idempotency_key = models.CharField(max_length=100, unique=True)
|
|
last_error = models.TextField(blank=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="announce_due_idx")]
|
|
|
|
@property
|
|
def status_label(self):
|
|
if self.status == self.Status.SENT:
|
|
return "درخواست تماس پذیرفته شد"
|
|
if self.status == self.Status.PENDING and self.attempts:
|
|
return "در انتظار تلاش مجدد"
|
|
return self.get_status_display()
|
|
|
|
@property
|
|
def detail_label(self):
|
|
return {
|
|
"customer_read": "مشتری پیام را مشاهده کرد.",
|
|
"customer_replied": "مشتری پس از پیام پاسخ داد.",
|
|
"message_unavailable": "پیام برای اطلاعرسانی در دسترس نیست.",
|
|
"customer_mobile_unavailable": "شماره همراه مشتری در دسترس نیست.",
|
|
"announcement_not_configured": "تنظیمات تماس اطلاعرسانی کامل نیست.",
|
|
"service_timeout": "سرویس تماس در مهلت مقرر پاسخ نداد.",
|
|
"service_unreachable": "ارتباط با سرویس تماس برقرار نشد.",
|
|
"service_http_error": "سرویس تماس درخواست را با خطا پاسخ داد.",
|
|
"service_rejected": "سرویس تماس درخواست را نپذیرفت.",
|
|
"invalid_service_response": "پاسخ سرویس تماس معتبر نبود.",
|
|
"unexpected_error": "خطای غیرمنتظره در پردازش درخواست تماس رخ داد.",
|
|
}.get(self.last_error, "وضعیت درخواست تماس نیاز به بررسی دارد." if self.last_error else "")
|