first
This commit is contained in:
commit
fa9d154db9
260 files changed
+17643
No files matched your search
@@ -0,0 +1,207 @@
|
||||
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})
|
||||
Reference in new issue
Block a user