140 lines
6.2 KiB
Python
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
|