179 lines
13 KiB
Python
179 lines
13 KiB
Python
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")]
|