Files

154 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):
DISPATCH_RESPONSES_OPENED="DISPATCH_RESPONSES_OPENED","بازگشایی پاسخ‌های ارسال گروهی"
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 == "DISPATCH_RESPONSES_OPENED":
return []
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 "")