"""Response confidentiality is independent of ordinary ticket permissions.""" from django.core.exceptions import PermissionDenied, ValidationError from django.db import transaction from django.db.models import Count, F, Max, Min, Q from django.utils import timezone from .models import DispatchResponseControl, GroupDispatch from .policies import DispatchPolicy, TicketPolicy OPENING_EVENT = "DISPATCH_RESPONSES_OPENED" def response_control(dispatch): if dispatch is None: return None return DispatchResponseControl.objects.filter(dispatch_id=dispatch.pk).first() def responses_locked(ticket): control = response_control(ticket.group_dispatch) if ticket.group_dispatch_id else None return bool(control and control.mode == control.Mode.SEALED and control.opened_at is None) def sealed_message_q(prefix=""): return Q(**{ prefix + "ticket__group_dispatch__response_control__mode": "SEALED", prefix + "ticket__group_dispatch__response_control__opened_at__isnull": True, prefix + "author_id": F(prefix + "ticket__customer_id"), }) def can_read_response(user, message): if message.author_id != message.ticket.customer_id: return True if getattr(user, "account_type", None) == "CUSTOMER" and user.pk == message.author_id: return True return not responses_locked(message.ticket) def filter_response_messages(query, user): if getattr(user, "account_type", None) == "CUSTOMER": return query.exclude(sealed_message_q() & ~Q(author_id=user.pk)) return query.exclude(sealed_message_q()) def can_record_ticket_read(ticket, user): if not TicketPolicy.can_view(user, ticket): return False if not responses_locked(ticket): return True return not ticket.ticket_messages.filter(author_id=ticket.customer_id, deleted_at__isnull=True).exists() def can_open_responses(user, dispatch): if not user.is_authenticated or not user.is_active or not DispatchPolicy.can_view(user, dispatch): return False profile = getattr(user, "staff_profile", None) if getattr(user, "account_type", None) != "STAFF" or (profile and not profile.is_active): return False return bool(user.is_superuser or dispatch.created_by_id == user.pk or user.has_perm("tickets.open_all_dispatch_responses")) def render_opening_sms(dispatch): from apps.notifications.management.commands.process_notifications import _render_sms return _render_sms(OPENING_EVENT, {}, dispatch.unit) def _respondents(dispatch): # A withdrawn/deleted message is not a current response; attachment-only replies count. return list(dispatch.tickets.filter(ticket_messages__author_id=F("customer_id"), ticket_messages__deleted_at__isnull=True).distinct().order_by("pk")) def _queue_opening_sms(control, tickets): from apps.notifications.services import enqueue from apps.notifications.models import NotificationJob for ticket in tickets: enqueue(NotificationJob.Channel.SMS, OPENING_EVENT, ticket, f"dispatch:{control.dispatch_id}:opened:{ticket.customer_id}", customer=ticket.customer, payload={"dispatch_id": control.dispatch_id, "body": control.sms_body_snapshot}) @transaction.atomic def open_dispatch_responses(dispatch, user, expected_sms=""): # Replies take the same control lock before locking their ticket. control = DispatchResponseControl.objects.select_for_update(of=("self",)).select_related("dispatch__unit").get(dispatch=dispatch) dispatch = control.dispatch if not can_open_responses(user, dispatch): raise PermissionDenied if control.mode != control.Mode.SEALED: raise ValidationError("این ارسال در حالت مشاهده جداگانه است.") if control.opened_at: return control, False text = render_opening_sms(dispatch) if control.notify_sms else "" if text != expected_sms: raise ValidationError("قالب پیامک تغییر کرده است؛ متن جدید را بررسی و دوباره تأیید کنید.") tickets = _respondents(dispatch) control.opened_at = timezone.now() control.opened_by = user control.sms_body_snapshot = text control.respondents_snapshot = [str(ticket.customer.public_id) for ticket in tickets] control.save(update_fields=["opened_at", "opened_by", "sms_body_snapshot", "respondents_snapshot"]) if control.notify_sms: _queue_opening_sms(control, tickets) return control, True def lock_dispatch_for_ticket(ticket_id): """Must be called inside the caller's transaction, before its ticket lock.""" return DispatchResponseControl.objects.select_for_update(of=("self",)).filter(dispatch__tickets__pk=ticket_id).first() def managed_ticket_q(user): """Explicitly related tickets or dispatch visibility; unit-wide scope alone is insufficient.""" dispatches = DispatchPolicy.visible_dispatches(user) direct = Q(created_by=user) | Q(owner=user) | Q(selected_expert=user) direct |= Q(access_grants__user=user, access_grants__revoked_at__isnull=True) return Q(group_dispatch__response_control__isnull=True) | direct | Q(group_dispatch__in=dispatches) def populate_report_responses(dispatch, tickets, locked): """Load report responses in batches; before opening, select metadata only.""" from apps.messaging.models import Message responses = Message.objects.filter(ticket__group_dispatch=dispatch, author_id=F("ticket__customer_id"), deleted_at__isnull=True) metadata = {row["ticket_id"]: row for row in responses.values("ticket_id").annotate( count=Count("pk"), first=Min("created_at"), last=Max("created_at"))} by_ticket = {} if not locked: for message in responses.order_by("created_at", "pk").prefetch_related("attachments"): by_ticket.setdefault(message.ticket_id, []).append(message) for ticket in tickets: row = metadata.get(ticket.pk, {}) ticket.response_count = row.get("count", 0) ticket.first_response_at = row.get("first") ticket.last_response_at = row.get("last") ticket.report_customer_responses = by_ticket.get(ticket.pk, []) ticket.latest_customer_response = ticket.report_customer_responses[-1] if ticket.report_customer_responses else None