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