Files
customerTicket/apps/tickets/dispatch_responses.py
T

140 lines
6.2 KiB
Python

"""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