import uuid from django.conf import settings from django.core.validators import MinLengthValidator from django.db import models from django.db.models import Q from apps.core.models import PersianTextNormalizationMixin, PublicIdModel, TimeStampedModel class TicketCategory(PersianTextNormalizationMixin, TimeStampedModel): persian_text_fields = {"title": False, "description": True, "search_keywords": True} unit=models.ForeignKey("staff.OrganizationalUnit",on_delete=models.PROTECT,null=True,blank=True,related_name="ticket_categories",verbose_name="واحد سازمانی",help_text="برای دسته‌های قدیمی عمومی می‌تواند خالی باشد؛ دسته‌های جدید باید واحد داشته باشند.") title=models.CharField(max_length=100); slug=models.SlugField(unique=True); description=models.TextField(blank=True); search_keywords=models.TextField(blank=True,verbose_name="کلیدواژه‌های جستجو",help_text="مترادف‌ها و عبارت‌های رایج مشتری را وارد کنید."); icon=models.CharField(max_length=50, blank=True); sort_order=models.PositiveIntegerField(default=0,db_index=True,verbose_name="ترتیب نمایش"); is_active=models.BooleanField(default=True); show_in_customer_form=models.BooleanField(default=True,verbose_name="نمایش در فرم مشتری",help_text="در صورت خاموش بودن، این دسته در انتخاب و جستجوی مشتری دیده نمی‌شود.") class Meta: ordering=["unit__sort_order", "unit__name", "sort_order", "title", "pk"] def __str__(self): return f"{self.unit.name} — {self.title}" if self.unit_id else self.title class SLAConfig(models.Model): priority=models.CharField(max_length=15, unique=True); first_response_minutes=models.PositiveIntegerField(); resolution_minutes=models.PositiveIntegerField(); is_active=models.BooleanField(default=True) class TicketTag(PersianTextNormalizationMixin, TimeStampedModel): persian_text_fields = {"name": False} name = models.CharField(max_length=80, unique=True) slug = models.SlugField(max_length=80, unique=True) color = models.CharField(max_length=7, default="#52677d") is_active = models.BooleanField(default=True) class Meta: ordering = ["name"] verbose_name = "برچسب تیکت" verbose_name_plural = "برچسب‌های تیکت" def __str__(self): return self.name class GroupDispatch(PersianTextNormalizationMixin, PublicIdModel, TimeStampedModel): """One bulk operation whose recipients each receive an isolated ticket.""" persian_text_fields = {"subject": False, "body": True} class Status(models.TextChoices): DRAFT = "DRAFT", "پیش‌نویس" QUEUED = "QUEUED", "در صف ارسال" SENT = "SENT", "ارسال‌شده" PARTIAL = "PARTIAL", "ارسال ناقص" CANCELLED = "CANCELLED", "لغوشده" class OperationType(models.TextChoices): BULK = "BULK", "ارسال گروهی تیکت" INQUIRY = "INQUIRY", "استعلام" unit = models.ForeignKey( "staff.OrganizationalUnit", on_delete=models.PROTECT, related_name="group_dispatches", ) operation_type = models.CharField(max_length=12, choices=OperationType.choices, default=OperationType.BULK, db_index=True) contact_group = models.ForeignKey( "customers.ContactGroup", on_delete=models.PROTECT, related_name="dispatches", ) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="created_group_dispatches", ) category = models.ForeignKey(TicketCategory, on_delete=models.PROTECT) subject = models.CharField(max_length=200, validators=[MinLengthValidator(5)]) body = models.TextField() status = models.CharField(max_length=12, choices=Status.choices, default=Status.DRAFT, db_index=True) recipient_count = models.PositiveIntegerField(default=0) dispatched_at = models.DateTimeField(null=True, blank=True) class Meta: ordering = ["-created_at"] permissions = [ ("dispatch_group_tickets", "Can dispatch isolated tickets to a contact group"), ("create_bulk_dispatch", "Can create bulk ticket dispatches"), ("view_all_bulk_dispatches", "Can view all bulk ticket dispatches"), ("create_inquiry", "Can create inquiries"), ("view_all_inquiries", "Can view all inquiries"), ] verbose_name = "ارسال گروهی" verbose_name_plural = "ارسال‌های گروهی" def __str__(self): return f"{self.subject} ({self.contact_group})" class Ticket(PersianTextNormalizationMixin, PublicIdModel, TimeStampedModel): persian_text_fields = {"subject": False, "province": False, "city": False} class Status(models.TextChoices): NEW="NEW","جدید"; OPEN="OPEN","باز"; WAITING_CUSTOMER="WAITING_CUSTOMER","در انتظار مشتری"; WAITING_STAFF="WAITING_STAFF","در انتظار کارشناس"; RESOLVED="RESOLVED","حل‌شده"; CLOSED="CLOSED","بسته"; REOPENED="REOPENED","بازگشایی‌شده" class Priority(models.TextChoices): NORMAL="NORMAL","عادی"; IMPORTANT="IMPORTANT","مهم"; URGENT="URGENT","فوری" class Visibility(models.TextChoices): PUBLIC="PUBLIC","عمومی"; SELECTED="SELECTED","کارشناس انتخابی" class DeliveryType(models.TextChoices): INDIVIDUAL = "INDIVIDUAL", "فردی" GROUP = "GROUP", "ارسال گروهی" ticket_number=models.CharField(max_length=30, unique=True, editable=False) customer=models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="customer_tickets") created_by=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.PROTECT,null=True,blank=True,related_name="created_tickets") owning_unit=models.ForeignKey("staff.OrganizationalUnit",on_delete=models.PROTECT,null=True,blank=True,related_name="owned_tickets") assigned_unit=models.ForeignKey("staff.OrganizationalUnit",on_delete=models.PROTECT,null=True,blank=True,related_name="assigned_tickets") delivery_type=models.CharField(max_length=12,choices=DeliveryType.choices,default=DeliveryType.INDIVIDUAL,db_index=True) group_dispatch=models.ForeignKey(GroupDispatch,on_delete=models.PROTECT,null=True,blank=True,related_name="tickets") tags=models.ManyToManyField(TicketTag,blank=True,related_name="tickets") category=models.ForeignKey(TicketCategory, on_delete=models.PROTECT, related_name="tickets") subject=models.CharField(max_length=200, validators=[MinLengthValidator(5)]) province=models.CharField(max_length=100, blank=True) city=models.CharField(max_length=100, blank=True) status=models.CharField(max_length=25, choices=Status.choices, default=Status.NEW, db_index=True) priority=models.CharField(max_length=15, choices=Priority.choices, default=Priority.NORMAL, db_index=True) selected_expert=models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True, related_name="selected_tickets") owner=models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True, related_name="owned_tickets") origin_visibility=models.CharField(max_length=15, choices=Visibility.choices, default=Visibility.PUBLIC) last_activity_at=models.DateTimeField(db_index=True); first_response_at=models.DateTimeField(null=True, blank=True); last_customer_message_at=models.DateTimeField(null=True, blank=True); last_staff_message_at=models.DateTimeField(null=True, blank=True); first_response_due_at=models.DateTimeField(null=True, blank=True); resolution_due_at=models.DateTimeField(null=True, blank=True); resolved_at=models.DateTimeField(null=True, blank=True); closed_at=models.DateTimeField(null=True, blank=True); reopened_at=models.DateTimeField(null=True, blank=True) class Meta: ordering=["-last_activity_at"] permissions=[("view_all_tickets","Can view all tickets"),("claim_ticket","Can claim ticket"),("transfer_ticket","Can transfer ticket"),("share_ticket","Can share ticket"),("change_ticket_priority","Can change ticket priority"),("view_customer_history","Can view customer history")] indexes=[models.Index(fields=["status","last_activity_at"]), models.Index(fields=["owner","status"]), models.Index(fields=["selected_expert","status"]), models.Index(fields=["owning_unit","status"]), models.Index(fields=["delivery_type","last_activity_at"])] def save(self,*args,**kwargs): if not self.ticket_number: from django.utils import timezone self.ticket_number=f"TK-{timezone.localdate().year}-{uuid.uuid4().hex[:8].upper()}" super().save(*args,**kwargs) def __str__(self): return f"{self.ticket_number} - {self.subject}" @property def assigned_expert_name(self): expert=self.owner or self.selected_expert profile=getattr(expert,"staff_profile",None) if expert else None return getattr(profile,"display_name","") or "صف عمومی" class TicketAccessGrant(models.Model): note = models.CharField(max_length=500, blank=True) first_viewed_at = models.DateTimeField(null=True, blank=True) last_viewed_at = models.DateTimeField(null=True, blank=True) view_count = models.PositiveIntegerField(default=0) revoked_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True, related_name="revoked_ticket_grants") class Level(models.TextChoices): VIEW_ONLY="VIEW_ONLY","فقط مشاهده"; REPLY="REPLY","پاسخ"; FULL="FULL","کامل" ticket=models.ForeignKey(Ticket,on_delete=models.CASCADE,related_name="access_grants"); user=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE,related_name="ticket_grants"); granted_by=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.PROTECT,related_name="granted_ticket_access"); permission_level=models.CharField(max_length=15,choices=Level.choices,default=Level.FULL); created_at=models.DateTimeField(auto_now_add=True); revoked_at=models.DateTimeField(null=True,blank=True) class Meta: constraints=[models.UniqueConstraint(fields=["ticket","user"],condition=Q(revoked_at__isnull=True),name="unique_active_ticket_grant")] class TicketEvent(models.Model): class Type(models.TextChoices): CREATED="CREATED","ایجاد" CLAIMED="CLAIMED","برداشت" TRANSFERRED="TRANSFERRED","انتقال" SHARED="SHARED","اشتراک تیکت" ACCESS_REVOKED="ACCESS_REVOKED","لغو دسترسی تیکت" SHARE_OPENED="SHARE_OPENED","مشاهده تیکت اشتراکی" MESSAGE_SHARED="MESSAGE_SHARED","اشتراک یک پیام" MESSAGE_SHARE_REVOKED="MESSAGE_SHARE_REVOKED","لغو اشتراک پیام" MESSAGE_SHARE_OPENED="MESSAGE_SHARE_OPENED","مشاهده پیام اشتراکی" STATUS_CHANGED="STATUS_CHANGED","تغییر وضعیت" PRIORITY_CHANGED="PRIORITY_CHANGED","تغییر اولویت" REOPENED="REOPENED","بازگشایی" RESOLVED="RESOLVED","حل" CLOSED="CLOSED","بستن" ticket=models.ForeignKey(Ticket,on_delete=models.CASCADE,related_name="events"); event_type=models.CharField(max_length=30,choices=Type.choices); actor=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.PROTECT,null=True); metadata=models.JSONField(default=dict,blank=True); created_at=models.DateTimeField(auto_now_add=True) class MessageShare(PublicIdModel, models.Model): """A staff-only grant to one message; never confers ticket access.""" message = models.ForeignKey("messaging.Message", on_delete=models.CASCADE, related_name="staff_shares") user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="received_message_shares") granted_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="sent_message_shares") note = models.CharField(max_length=500, blank=True) created_at = models.DateTimeField(auto_now_add=True) first_viewed_at = models.DateTimeField(null=True, blank=True) last_viewed_at = models.DateTimeField(null=True, blank=True) view_count = models.PositiveIntegerField(default=0) revoked_at = models.DateTimeField(null=True, blank=True) revoked_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True, related_name="revoked_message_shares") class Meta: ordering = ["-created_at", "-pk"] constraints = [models.UniqueConstraint(fields=["message", "user"], condition=Q(revoked_at__isnull=True), name="unique_active_message_share")] class TicketReadState(models.Model): ticket=models.ForeignKey(Ticket,on_delete=models.CASCADE,related_name="read_states"); user=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE); last_read_at=models.DateTimeField(); last_read_message=models.ForeignKey("messaging.Message",on_delete=models.SET_NULL,null=True,blank=True) class Meta: constraints=[models.UniqueConstraint(fields=["ticket","user"],name="unique_ticket_read_state")] class DispatchResponseControl(models.Model): """Absent for legacy dispatches: their existing visibility is preserved.""" class Mode(models.TextChoices): SEALED = "SEALED", "بازگشایی یکجا" INDIVIDUAL = "INDIVIDUAL", "مشاهده جداگانه" dispatch = models.OneToOneField(GroupDispatch, on_delete=models.PROTECT, related_name="response_control") mode = models.CharField(max_length=12, choices=Mode.choices, default=Mode.SEALED) notify_sms = models.BooleanField(default=True) recipient_count_snapshot = models.PositiveIntegerField() opened_at = models.DateTimeField(null=True, blank=True) opened_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True) sms_body_snapshot = models.TextField(blank=True) respondents_snapshot = models.JSONField(default=list, blank=True) class Meta: verbose_name = "تنظیمات و سابقه بازگشایی ارسال" verbose_name_plural = "تنظیمات و سوابق بازگشایی ارسال‌ها" permissions = [("open_all_dispatch_responses", "Can open responses of all authorized dispatches")] constraints = [ models.CheckConstraint(condition=Q(notify_sms=False) | Q(mode="SEALED", recipient_count_snapshot__lte=15), name="dispatch_opening_sms_limit"), models.CheckConstraint(condition=Q(mode__in=["SEALED", "INDIVIDUAL"]), name="dispatch_response_mode_valid"), models.CheckConstraint(condition=Q(opened_at__isnull=True, opened_by__isnull=True) | Q(mode="SEALED", opened_at__isnull=False, opened_by__isnull=False), name="dispatch_opening_audit_complete"), ]