259 lines
17 KiB
Python
259 lines
17 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("گروه انتخابشده مخاطب فعال دارای شماره همراه ندارد.")
|
|
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
|
|
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=()):
|
|
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:
|
|
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})
|