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_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) 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 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: 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) 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})