Files
customerTicket/apps/tickets/services.py
T
2026-09-10 11:34:19 +03:30

208 lines
15 KiB
Python

from datetime import timedelta
from django.core.exceptions import PermissionDenied,ValidationError
from django.db import transaction
from django.db.models import Count
from django.utils import timezone
from apps.attachments.services import save_attachments
from apps.core.persian_text import normalize_persian_text
from apps.messaging.models import Message,MessageMention
from apps.notifications.models import NotificationJob
from apps.notifications.services import cancel_staff_reply_sms,enqueue,schedule_customer_reply_bale,schedule_staff_reply_sms
from .models import GroupDispatch,SLAConfig,Ticket,TicketAccessGrant,TicketEvent
from .policies import DispatchPolicy, TicketPolicy
def _primary_unit(user):
profile = getattr(user, "staff_profile", None)
if profile and profile.primary_unit_id:
return profile.primary_unit
membership = user.unit_memberships.filter(is_active=True, unit__is_active=True).order_by("-is_primary", "id").select_related("unit").first()
return membership.unit if membership else None
@transaction.atomic
def create_ticket(*,customer,category,subject,selected_expert=None,body="",files=(),province="",city="",created_by=None,unit=None,group_dispatch=None,tags=()):
subject=normalize_persian_text(subject)
body=normalize_persian_text(body,multiline=True)
province=normalize_persian_text(province)
city=normalize_persian_text(city)
if not body.strip() and not files: raise ValidationError("متن یا فایل پیام اولیه الزامی است.")
now=timezone.now(); sla=SLAConfig.objects.filter(priority=Ticket.Priority.NORMAL,is_active=True).first(); actor=created_by or customer
staff_initiated=getattr(actor,"account_type","")=="STAFF"
owning_unit = unit or category.unit or (_primary_unit(actor) if staff_initiated else _primary_unit(selected_expert) if selected_expert else None)
if category.unit_id and (not owning_unit or category.unit_id != owning_unit.pk): raise ValidationError("دسته‌بندی انتخاب‌شده متعلق به این واحد سازمانی نیست.")
if not staff_initiated and owning_unit and (not owning_unit.is_active or not owning_unit.accepts_external_tickets or not owning_unit.show_in_customer_form): raise ValidationError("این واحد در حال حاضر پذیرش مستقیم تیکت ندارد.")
if not staff_initiated and not category.show_in_customer_form: raise ValidationError("این دسته‌بندی در فرم مشتری قابل انتخاب نیست.")
ticket=Ticket(customer=customer,created_by=actor,owning_unit=owning_unit,assigned_unit=owning_unit,category=category,subject=subject,province=province,city=city,selected_expert=None if staff_initiated else selected_expert,owner=actor if staff_initiated else None,status=Ticket.Status.WAITING_CUSTOMER if staff_initiated else Ticket.Status.NEW,origin_visibility=Ticket.Visibility.SELECTED if staff_initiated or selected_expert else Ticket.Visibility.PUBLIC,delivery_type=Ticket.DeliveryType.GROUP if group_dispatch else Ticket.DeliveryType.INDIVIDUAL,group_dispatch=group_dispatch,last_activity_at=now,last_customer_message_at=None if staff_initiated else now,last_staff_message_at=now if staff_initiated else None,first_response_at=now if staff_initiated else None)
if sla: ticket.first_response_due_at=now+timedelta(minutes=sla.first_response_minutes); ticket.resolution_due_at=now+timedelta(minutes=sla.resolution_minutes)
ticket.full_clean(); ticket.save(); ticket.tags.set(tags); message=Message.objects.create(ticket=ticket,author=actor,body=body); save_attachments(message,files,actor); TicketEvent.objects.create(ticket=ticket,event_type=TicketEvent.Type.CREATED,actor=actor,metadata={"staff_initiated":staff_initiated,"group_dispatch_id":group_dispatch.pk if group_dispatch else None,"unit_id":owning_unit.pk if owning_unit else None})
if staff_initiated:
enqueue(NotificationJob.Channel.SMS,"STAFF_TICKET_CREATED",ticket,f"ticket:{ticket.pk}:staff-created",customer=customer)
else:
enqueue(NotificationJob.Channel.SMS,"CUSTOMER_TICKET_CREATED",ticket,f"ticket:{ticket.pk}:customer-created",customer=customer)
enqueue(NotificationJob.Channel.BALE,"NEW_CUSTOMER_TICKET",ticket,f"ticket:{ticket.pk}:bale-group",payload={"ticket_number":ticket.ticket_number})
return ticket
@transaction.atomic
def dispatch_group_tickets(*, user, unit, contact_group, category, subject, body, tags=(), files=(), operation_type=GroupDispatch.OperationType.BULK):
"""Create one private ticket per contact and queue one SMS per ticket."""
if contact_group.unit_id != unit.pk:
raise ValidationError("گروه مخاطبان متعلق به واحد انتخاب‌شده نیست.")
if category.unit_id and category.unit_id != unit.pk:
raise ValidationError("دسته‌بندی متعلق به واحد انتخاب‌شده نیست.")
if operation_type not in GroupDispatch.OperationType.values or not DispatchPolicy.can_create(user, unit, operation_type):
raise PermissionDenied("اجازه ایجاد این نوع عملیات در این واحد را ندارید.")
contacts = list(
contact_group.contacts.filter(user__is_active=True, user__mobile__isnull=False)
.exclude(user__mobile="")
.select_related("user")
.order_by("user_id")
)
if not contacts:
raise ValidationError("گروه انتخاب‌شده مخاطب فعال دارای شماره همراه ندارد.")
dispatch = GroupDispatch.objects.create(
unit=unit,
operation_type=operation_type,
contact_group=contact_group,
created_by=user,
category=category,
subject=normalize_persian_text(subject),
body=normalize_persian_text(body, multiline=True),
status=GroupDispatch.Status.QUEUED,
recipient_count=len(contacts),
)
tickets=[]
for contact in contacts:
for uploaded in files:
if hasattr(uploaded,"seek"): uploaded.seek(0)
tickets.append(create_ticket(
customer=contact.user,
created_by=user,
unit=unit,
category=category,
subject=subject,
body=body,
group_dispatch=dispatch,
tags=tags,
files=files,
))
# Delivery is asynchronous. The reporting view promotes this to SENT only
# after every SMS job has actually succeeded.
dispatch.status = GroupDispatch.Status.QUEUED
dispatch.dispatched_at = timezone.now()
dispatch.save(update_fields=["status", "dispatched_at", "updated_at"])
return dispatch, tickets
def refresh_group_dispatch_status(dispatch_id):
"""Persist delivery state independently of whether a report page is opened."""
dispatch=GroupDispatch.objects.filter(pk=dispatch_id).first()
if not dispatch: return None
counts={row["status"]:row["count"] for row in NotificationJob.objects.filter(ticket__group_dispatch_id=dispatch_id,channel=NotificationJob.Channel.SMS,event_type="STAFF_TICKET_CREATED").values("status").annotate(count=Count("id"))}
sent=counts.get(NotificationJob.Status.SENT,0)
failed=counts.get(NotificationJob.Status.FAILED,0)
if dispatch.recipient_count and sent>=dispatch.recipient_count: status=GroupDispatch.Status.SENT
elif failed: status=GroupDispatch.Status.PARTIAL
else: status=GroupDispatch.Status.QUEUED
if dispatch.status!=status:
GroupDispatch.objects.filter(pk=dispatch_id).update(status=status,updated_at=timezone.now())
dispatch.status=status
return dispatch
@transaction.atomic
def claim_ticket(ticket_id,user):
ticket=Ticket.objects.select_for_update().get(pk=ticket_id)
if not TicketPolicy.can_claim(user,ticket):
owner=getattr(getattr(ticket.owner,"staff_profile",None),"display_name","") if ticket.owner else "کارشناس دیگری"
raise PermissionDenied(f"این تیکت لحظاتی قبل توسط «{owner}» برداشته شد.")
ticket.owner=user
if ticket.status==Ticket.Status.NEW: ticket.status=Ticket.Status.OPEN
ticket.save(update_fields=["owner","status","updated_at"]); TicketEvent.objects.create(ticket=ticket,event_type=TicketEvent.Type.CLAIMED,actor=user); return ticket
@transaction.atomic
def transfer_ticket(ticket_id,user,new_owner):
ticket=Ticket.objects.select_for_update().get(pk=ticket_id)
if not TicketPolicy.can_transfer(user,ticket): raise PermissionDenied
old=ticket.owner or ticket.selected_expert; ticket.owner=new_owner; ticket.selected_expert=None; ticket.save(update_fields=["owner","selected_expert","updated_at"]); ticket.access_grants.filter(user=old,revoked_at__isnull=True).update(revoked_at=timezone.now()) if old else None
TicketEvent.objects.create(ticket=ticket,event_type=TicketEvent.Type.TRANSFERRED,actor=user,metadata={"from":old.pk if old else None,"to":new_owner.pk}); enqueue(NotificationJob.Channel.BALE,"TRANSFER",ticket,f"ticket:{ticket.pk}:transfer:{new_owner.pk}:{timezone.now().timestamp()}",staff_user=new_owner); return ticket
@transaction.atomic
def share_ticket(ticket,user,target,level=TicketAccessGrant.Level.FULL):
if not TicketPolicy.can_share(user,ticket): raise PermissionDenied
grant=ticket.access_grants.filter(user=target,revoked_at__isnull=True).first()
if grant:
grant.permission_level=level; grant.granted_by=user; grant.save(update_fields=["permission_level","granted_by"])
else:
grant=TicketAccessGrant.objects.create(ticket=ticket,user=target,granted_by=user,permission_level=level)
TicketEvent.objects.create(ticket=ticket,event_type=TicketEvent.Type.SHARED,actor=user,metadata={"user":target.pk,"level":level}); enqueue(NotificationJob.Channel.BALE,"SHARE",ticket,f"ticket:{ticket.pk}:share:{target.pk}:{grant.pk}",staff_user=target); return grant
@transaction.atomic
def revoke_access(ticket,user,target):
if not TicketPolicy.can_share(user,ticket): raise PermissionDenied
count=ticket.access_grants.filter(user=target,revoked_at__isnull=True).update(revoked_at=timezone.now()); TicketEvent.objects.create(ticket=ticket,event_type=TicketEvent.Type.ACCESS_REVOKED,actor=user,metadata={"user":target.pk}); return count
@transaction.atomic
def reply_ticket(*,ticket_id,user,body="",files=(),internal=False,mentioned_users=()):
ticket=Ticket.objects.select_for_update().get(pk=ticket_id)
if not TicketPolicy.can_reply(user,ticket): raise PermissionDenied
if internal and (TicketPolicy.is_customer(user) or not user.has_perm("messaging.add_internal_note")): raise PermissionDenied
body=normalize_persian_text(body,multiline=True)
if not body and not files: raise ValidationError("متن یا فایل پیام الزامی است.")
msg=Message.objects.create(ticket=ticket,author=user,body=body,message_type=Message.Type.INTERNAL_NOTE if internal else Message.Type.PUBLIC,is_internal=internal); save_attachments(msg,files,user)
for target in mentioned_users:
if not TicketPolicy.can_view(target,ticket):
if TicketPolicy.can_share(user,ticket): share_ticket(ticket,user,target)
else: raise PermissionDenied("کارشناس Mention‌شده به تیکت دسترسی ندارد.")
MessageMention.objects.get_or_create(message=msg,mentioned_user=target); enqueue(NotificationJob.Channel.BALE,"MENTION",ticket,f"message:{msg.pk}:mention:{target.pk}",staff_user=target,payload={"excerpt":body[:150]})
now=timezone.now(); ticket.last_activity_at=now
if not internal:
if TicketPolicy.is_customer(user):
cancel_staff_reply_sms(ticket,"customer_replied")
ticket.last_customer_message_at=now
reopened=ticket.status in [Ticket.Status.RESOLVED,Ticket.Status.CLOSED]
if reopened: ticket.status=Ticket.Status.REOPENED; ticket.reopened_at=now; TicketEvent.objects.create(ticket=ticket,event_type=TicketEvent.Type.REOPENED,actor=user)
elif ticket.status != Ticket.Status.NEW: ticket.status=Ticket.Status.WAITING_STAFF
if reopened:
enqueue(NotificationJob.Channel.BALE,"CUSTOMER_TICKET_REOPENED",ticket,f"message:{msg.pk}:bale-reopened",customer=ticket.customer,payload={"message_id":msg.pk,"message_count":1})
else:
schedule_customer_reply_bale(ticket,msg)
else:
ticket.last_staff_message_at=now; ticket.status=Ticket.Status.WAITING_CUSTOMER
if not ticket.first_response_at: ticket.first_response_at=now
schedule_staff_reply_sms(ticket,msg)
ticket.save(); return msg
@transaction.atomic
def delete_ticket(*, ticket_id, user):
"""Permanently remove a ticket, its conversation, and stored attachments."""
ticket = Ticket.objects.select_for_update().get(pk=ticket_id)
if not TicketPolicy.can_delete(user, ticket):
raise PermissionDenied("اجازه حذف کامل این تیکت را ندارید.")
attachments = []
for message in ticket.ticket_messages.prefetch_related("attachments"):
attachments.extend((attachment.file.storage, attachment.file.name) for attachment in message.attachments.all() if attachment.file.name)
from apps.attachments.models import Attachment
Attachment.objects.filter(message__ticket=ticket).delete()
ticket.ticket_messages.all().delete()
ticket.delete()
for storage, name in attachments:
transaction.on_commit(lambda storage=storage, name=name: storage.delete(name))
@transaction.atomic
def change_status(ticket,user,status):
permitted=user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(user,ticket.owning_unit,"tickets.change_ticket")
if not (TicketPolicy.can_view(user,ticket) and permitted): raise PermissionDenied
if status not in Ticket.Status.values: raise ValidationError("وضعیت نامعتبر است.")
old=ticket.status; now=timezone.now(); ticket.status=status
if status==Ticket.Status.RESOLVED: ticket.resolved_at=now
if status==Ticket.Status.CLOSED: ticket.closed_at=now
ticket.save(); TicketEvent.objects.create(ticket=ticket,event_type=TicketEvent.Type.STATUS_CHANGED,actor=user,metadata={"from":old,"to":status})
@transaction.atomic
def change_priority(ticket,user,priority):
permitted=user.has_perm("tickets.change_ticket_priority") or TicketPolicy.has_unit_permission(user,ticket.owning_unit,"tickets.change_ticket_priority")
if not (TicketPolicy.can_view(user,ticket) and permitted): raise PermissionDenied
if priority not in Ticket.Priority.values: raise ValidationError("اولویت نامعتبر است.")
old=ticket.priority; ticket.priority=priority; ticket.save(update_fields=["priority","updated_at"])
TicketEvent.objects.create(ticket=ticket,event_type=TicketEvent.Type.PRIORITY_CHANGED,actor=user,metadata={"from":old,"to":priority})