Files
customerTicket/apps/tickets/services.py
T

280 lines
18 KiB
Python

from datetime import timedelta
from django.core.exceptions import PermissionDenied,ValidationError
from django.db import transaction
from django.db.models import Count,Q
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,enqueue_unit_supervisor_ticket_sms,schedule_announcement,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)
schedule_announcement(message)
else:
enqueue(NotificationJob.Channel.SMS,"CUSTOMER_TICKET_CREATED",ticket,f"ticket:{ticket.pk}:customer-created",customer=customer)
if owning_unit and owning_unit.bale_chat_id:
enqueue(NotificationJob.Channel.BALE,"NEW_CUSTOMER_TICKET",ticket,f"ticket:{ticket.pk}:bale-group",payload={"ticket_number":ticket.ticket_number})
enqueue_unit_supervisor_ticket_sms(ticket)
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("گروه انتخاب‌شده مخاطب فعال دارای شماره همراه ندارد.")
from .models import DispatchResponseControl
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),
)
DispatchResponseControl.objects.create(dispatch=dispatch, mode="SEALED",
notify_sms=len(contacts) <= 15,
recipient_count_snapshot=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
@transaction.atomic
def create_group_dispatch(*, response_mode="SEALED", notify_opening_sms=True, **dispatch_fields):
"""Configure responses without changing the existing creation API."""
from .models import DispatchResponseControl
if response_mode not in DispatchResponseControl.Mode.values:
raise ValidationError("روش مشاهده پاسخ‌ها معتبر نیست.")
dispatch, tickets = dispatch_group_tickets(**dispatch_fields)
control = DispatchResponseControl.objects.get(dispatch=dispatch)
control.mode = response_mode
control.notify_sms = bool(notify_opening_sms and response_mode == "SEALED" and control.recipient_count_snapshot <= 15)
control.save(update_fields=["mode", "notify_sms"])
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
jobs=NotificationJob.objects.filter(ticket__group_dispatch_id=dispatch_id,channel=NotificationJob.Channel.SMS,event_type="STAFF_TICKET_CREATED")
counts={row["status"]:row["count"] for row in jobs.values("status").annotate(count=Count("id"))}
sent=counts.get(NotificationJob.Status.SENT,0)
failed=jobs.filter(Q(status=NotificationJob.Status.FAILED) | Q(status=NotificationJob.Status.SENT,delivery_status__in=[NotificationJob.DeliveryStatus.UNDELIVERED,NotificationJob.DeliveryStatus.EXPIRED,NotificationJob.DeliveryStatus.BLOCKED,NotificationJob.DeliveryStatus.UNKNOWN,NotificationJob.DeliveryStatus.ERROR],delivery_next_check_at__isnull=True)).count()
if failed: status=GroupDispatch.Status.PARTIAL
elif dispatch.recipient_count and sent>=dispatch.recipient_count: status=GroupDispatch.Status.SENT
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 resolve_group_dispatch_tickets(*, dispatch, user):
"""Resolve every non-final ticket created by one group dispatch."""
dispatch = GroupDispatch.objects.select_for_update().select_related("unit").get(pk=dispatch.pk)
permitted = user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(
user, dispatch.unit, "tickets.change_ticket"
)
if not (DispatchPolicy.can_view(user, dispatch) and permitted):
raise PermissionDenied("اجازه حل‌کردن تیکت‌های این ارسال گروهی را ندارید.")
now = timezone.now()
tickets = list(
dispatch.tickets.select_for_update().exclude(
status__in=(Ticket.Status.RESOLVED, Ticket.Status.CLOSED)
)
)
if not tickets:
return 0
previous_statuses = {ticket.pk: ticket.status for ticket in tickets}
for ticket in tickets:
ticket.status = Ticket.Status.RESOLVED
ticket.resolved_at = now
ticket.updated_at = now
Ticket.objects.bulk_update(tickets, ["status", "resolved_at", "updated_at"])
TicketEvent.objects.bulk_create([
TicketEvent(
ticket=ticket,
event_type=TicketEvent.Type.STATUS_CHANGED,
actor=user,
metadata={
"from": previous_statuses[ticket.pk],
"to": Ticket.Status.RESOLVED,
"group_dispatch_id": dispatch.pk,
},
)
for ticket in tickets
])
return len(tickets)
@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
from .sharing import log_share
grants=list(ticket.access_grants.select_for_update().filter(user=target,revoked_at__isnull=True))
for grant in grants:
grant.revoked_at=timezone.now()
grant.revoked_by=user
grant.save(update_fields=["revoked_at","revoked_by"])
log_share(grant,user,TicketEvent.Type.ACCESS_REVOKED,"revoked")
return len(grants)
@transaction.atomic
def reply_ticket(*,ticket_id,user,body="",files=(),internal=False,mentioned_users=()):
from .dispatch_responses import lock_dispatch_for_ticket
control = lock_dispatch_for_ticket(ticket_id)
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‌شده به تیکت دسترسی ندارد.")
excerpt = "پاسخ محرمانه ثبت شد." if control and control.mode == "SEALED" and not control.opened_at and user.pk == ticket.customer_id else body[:150]
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":excerpt})
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:
if ticket.owning_unit_id and ticket.owning_unit.bale_chat_id:
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)
schedule_announcement(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):
if not TicketPolicy.can_operate(user,ticket): 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):
if not TicketPolicy.can_operate(user,ticket): 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})