177 lines
8.3 KiB
Python
177 lines
8.3 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=""):
|
|
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):
|
|
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).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
|