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","پاسخ کارشناس" 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} در متن قرار دهید.") 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","در حال ارسال"; 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): """One durable call reminder for each public message written by staff.""" class Status(models.TextChoices): PENDING = "PENDING", "در صف" PROCESSING = "PROCESSING", "در حال تماس" SENT = "SENT", "ارسال‌شده" FAILED = "FAILED", "ناموفق" CANCELLED = "CANCELLED", "لغوشده" message = models.OneToOneField("messaging.Message", on_delete=models.CASCADE, related_name="announcement_job") 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 "")