Files
customerTicket/apps/tickets/sharing.py
T

181 lines
8.5 KiB
Python

"""Staff sharing and audit services, isolated from customer workflows."""
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction
from django.utils import timezone
from apps.staff.models import StaffProfile
from .models import MessageShare, Ticket, TicketAccessGrant, TicketEvent
from .policies import TicketPolicy
def staff_name(user):
profile = getattr(user, "staff_profile", None)
return str(profile) if profile else user.username
def share_recipients(actor):
return StaffProfile.objects.filter(is_active=True, user__is_active=True, user__account_type="STAFF").exclude(user=actor).select_related("user", "primary_unit")
def require_share_authority(actor, ticket):
if actor.account_type != "STAFF" or not actor.is_active or not TicketPolicy.can_share(actor, ticket):
raise PermissionDenied
def validate_recipient(actor, target):
if not share_recipients(actor).filter(user=target).exists():
raise ValidationError("گیرنده باید یک کارشناس فعال دیگر باشد.")
def log_share(grant, actor, event_type, action):
is_message = isinstance(grant, MessageShare)
ticket = grant.message.ticket if is_message else grant.ticket
metadata = {"scope": "message" if is_message else "ticket", "grant_id": grant.pk,
"user": grant.user_id, "recipient_name": staff_name(grant.user),
"action": action, "note": grant.note}
if is_message:
metadata["message_public_id"] = str(grant.message.public_id)
else:
metadata["level"] = grant.permission_level
return TicketEvent.objects.create(ticket=ticket, actor=actor, event_type=event_type, metadata=metadata)
@transaction.atomic
def share_staff_ticket(ticket, actor, target, level, note=""):
ticket = Ticket.objects.select_for_update().get(pk=ticket.pk)
require_share_authority(actor, ticket)
validate_recipient(actor, target)
if level not in TicketAccessGrant.Level.values or len(note) > 500:
raise ValidationError("سطح دسترسی یا توضیح اشتراک نامعتبر است.")
grant = ticket.access_grants.filter(user=target, revoked_at__isnull=True).first()
if grant and grant.permission_level == level and grant.note == note:
return grant
action = "updated" if grant else "created"
if grant:
grant.permission_level, grant.note = level, note
grant.save(update_fields=["permission_level", "note"])
else:
grant = TicketAccessGrant.objects.create(ticket=ticket, user=target, granted_by=actor, permission_level=level, note=note)
event = log_share(grant, actor, TicketEvent.Type.SHARED, action)
# Preserve the existing staff ticket-sharing notification behavior.
from apps.notifications.models import NotificationJob
from apps.notifications.services import enqueue
enqueue(NotificationJob.Channel.BALE, "SHARE", ticket, f"ticket:{ticket.pk}:share-event:{event.pk}", staff_user=target)
return grant
@transaction.atomic
def share_staff_message(message, actor, target, note=""):
from .dispatch_responses import can_read_response
if not can_read_response(actor, message):
raise PermissionDenied("پاسخ تا بازگشایی قابل اشتراک نیست.")
ticket = Ticket.objects.select_for_update().get(pk=message.ticket_id)
require_share_authority(actor, ticket)
validate_recipient(actor, target)
message.refresh_from_db()
if message.deleted_at or len(note) > 500:
raise ValidationError("پیام حذف‌شده قابل اشتراک نیست یا توضیح بیش از حد طولانی است.")
grant = MessageShare.objects.filter(message=message, user=target, revoked_at__isnull=True).first()
if grant and grant.note == note:
return grant
action = "updated" if grant else "created"
if grant:
grant.note = note
grant.save(update_fields=["note"])
else:
grant = MessageShare.objects.create(message=message, user=target, granted_by=actor, note=note)
log_share(grant, actor, TicketEvent.Type.MESSAGE_SHARED, action)
return grant
@transaction.atomic
def revoke_staff_share(grant, actor):
ticket_id = grant.message.ticket_id if isinstance(grant, MessageShare) else grant.ticket_id
ticket = Ticket.objects.select_for_update().get(pk=ticket_id)
require_share_authority(actor, ticket)
grant.refresh_from_db()
if grant.revoked_at:
return False
if isinstance(grant, TicketAccessGrant):
from .services import revoke_access
return bool(revoke_access(ticket, actor, grant.user))
grant.revoked_at, grant.revoked_by = timezone.now(), actor
grant.save(update_fields=["revoked_at", "revoked_by"])
log_share(grant, actor, TicketEvent.Type.MESSAGE_SHARE_REVOKED, "revoked")
return True
@transaction.atomic
def mark_share_opened(grant, actor):
grant = type(grant).objects.select_for_update().get(pk=grant.pk)
if grant.user_id != actor.pk or grant.revoked_at or (isinstance(grant, MessageShare) and grant.message.deleted_at):
raise PermissionDenied
now = timezone.now()
if grant.first_viewed_at is None:
grant.first_viewed_at = now
event_type = TicketEvent.Type.MESSAGE_SHARE_OPENED if isinstance(grant, MessageShare) else TicketEvent.Type.SHARE_OPENED
log_share(grant, actor, event_type, "opened")
grant.last_viewed_at = now
grant.view_count += 1
grant.save(update_fields=["first_viewed_at", "last_viewed_at", "view_count"])
return grant
def mark_ticket_share_opened(ticket, actor):
grant = ticket.access_grants.filter(user=actor, revoked_at__isnull=True).first()
if grant:
mark_share_opened(grant, actor)
def staff_message_context(ticket, actor, conversation=()):
can_share = TicketPolicy.can_share(actor, ticket)
context = {"can_share_message": can_share, "share_experts": share_recipients(actor) if can_share else []}
if not can_share:
return context
selected = {item.pk: item for item in conversation}
for item in selected.values():
item.staff_message_shares = []
item.staff_share_events = []
if not selected:
return context
grants = MessageShare.objects.filter(message__ticket=ticket, message_id__in=selected).select_related(
"message", "user__staff_profile", "granted_by__staff_profile", "revoked_by__staff_profile")
for grant in grants:
selected[grant.message_id].staff_message_shares.append(grant)
by_public_id = {str(item.public_id): item for item in selected.values() if item.staff_message_shares}
if by_public_id:
for event in sharing_events(ticket, message_ids=list(by_public_id)):
by_public_id[event.metadata["message_public_id"]].staff_share_events.append(event)
return context
def sharing_context(ticket, actor):
from .dispatch_responses import sealed_message_q
if not TicketPolicy.can_share(actor, ticket):
return {}
return {
"grants": ticket.access_grants.select_related("user__staff_profile", "granted_by__staff_profile", "revoked_by__staff_profile").order_by("-created_at", "-pk"),
"message_shares": MessageShare.objects.filter(message__ticket=ticket).exclude(sealed_message_q("message__")).select_related("message", "user__staff_profile", "granted_by__staff_profile", "revoked_by__staff_profile"),
"sharing_events": sharing_events(ticket),
}
def sharing_events(ticket, message_ids=None):
event_types = [TicketEvent.Type.SHARED, TicketEvent.Type.ACCESS_REVOKED, TicketEvent.Type.SHARE_OPENED,
TicketEvent.Type.MESSAGE_SHARED, TicketEvent.Type.MESSAGE_SHARE_REVOKED,
TicketEvent.Type.MESSAGE_SHARE_OPENED, TicketEvent.Type.TRANSFERRED]
query = ticket.events.filter(event_type__in=event_types).select_related("actor__staff_profile").order_by("-created_at", "-pk")
if message_ids is not None:
query = query.filter(metadata__message_public_id__in=message_ids)
else:
query = query[:100]
events = list(query)
from apps.accounts.models import User
user_ids = {e.metadata.get(key) for e in events for key in ("user", "from", "to") if e.metadata.get(key)}
names = {u.pk: staff_name(u) for u in User.objects.filter(pk__in=user_ids).select_related("staff_profile")}
for event in events:
event.recipient_name = event.metadata.get("recipient_name") or names.get(event.metadata.get("user") or event.metadata.get("to"), "")
event.previous_owner_name = names.get(event.metadata.get("from"), "")
event.actor_name = staff_name(event.actor) if event.actor else "سامانه"
return events