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): 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","لغو دسترسی"; 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 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")]