Files
customerTicket/apps/tickets/models.py
T
2026-10-13 16:09:45 +03:30

231 lines
16 KiB
Python

import uuid
import secrets
import string
from datetime import timedelta
from django.conf import settings
from django.core.validators import MinLengthValidator
from django.db import models
from django.db.models import Q
from django.utils import timezone
from apps.core.models import PersianTextNormalizationMixin, PublicIdModel, TimeStampedModel
def staff_ticket_link_token():
return "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(10))
def staff_ticket_link_expiry():
return timezone.now() + timedelta(days=7)
class StaffTicketShortLink(TimeStampedModel):
"""A short navigation URL; it never authenticates or grants ticket access."""
ticket = models.OneToOneField("Ticket", on_delete=models.CASCADE, related_name="staff_short_link")
token = models.CharField(max_length=10, unique=True, default=staff_ticket_link_token, editable=False)
expires_at = models.DateTimeField(default=staff_ticket_link_expiry)
class Meta:
verbose_name = "لینک کوتاه تیکت کارشناسان"
verbose_name_plural = "لینک‌های کوتاه تیکت کارشناسان"
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"),
]