share ticket and ticket message
This commit is contained in:
1 parent
39e64854b8
commit
79fe1cddbb
35 files changed
+1742
-95
No files matched your search
@@ -67,6 +67,20 @@ class TransferForm(forms.Form):
|
||||
class ShareForm(TransferForm):
|
||||
permission_level=forms.ChoiceField(label="سطح دسترسی",choices=TicketAccessGrant.Level.choices)
|
||||
|
||||
|
||||
class StaffMessageShareForm(TransferForm):
|
||||
note = forms.CharField(label="توضیح برای گیرنده", required=False, max_length=500, widget=forms.Textarea(attrs={"rows": 2}))
|
||||
|
||||
def __init__(self, *args, actor, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
from .sharing import share_recipients
|
||||
self.fields["expert"].label = "کارشناس گیرنده"
|
||||
self.fields["expert"].queryset = share_recipients(actor)
|
||||
|
||||
|
||||
class StaffTicketShareForm(StaffMessageShareForm):
|
||||
permission_level = forms.ChoiceField(label="سطح دسترسی", choices=TicketAccessGrant.Level.choices, initial=TicketAccessGrant.Level.VIEW_ONLY)
|
||||
|
||||
class StaffCreateTicketForm(forms.Form):
|
||||
unit=forms.ModelChoiceField(label="واحد ایجادکننده",queryset=OrganizationalUnit.objects.none(),required=False,widget=forms.HiddenInput())
|
||||
customer_id=forms.UUIDField(required=False,widget=forms.HiddenInput(attrs={"data-customer-id":""}))
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Generated by Django 5.1.1 on 2026-10-04 07:56
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('messaging', '0003_messagereadreceipt'),
|
||||
('tickets', '0016_scope_legacy_public_tickets_to_units'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='ticketaccessgrant',
|
||||
name='first_viewed_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ticketaccessgrant',
|
||||
name='last_viewed_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ticketaccessgrant',
|
||||
name='note',
|
||||
field=models.CharField(blank=True, max_length=500),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ticketaccessgrant',
|
||||
name='revoked_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='revoked_ticket_grants', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ticketaccessgrant',
|
||||
name='view_count',
|
||||
field=models.PositiveIntegerField(default=0),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='ticketevent',
|
||||
name='event_type',
|
||||
field=models.CharField(choices=[('CREATED', 'ایجاد'), ('CLAIMED', 'برداشت'), ('TRANSFERRED', 'انتقال'), ('SHARED', 'اشتراک تیکت'), ('ACCESS_REVOKED', 'لغو دسترسی تیکت'), ('SHARE_OPENED', 'مشاهده تیکت اشتراکی'), ('MESSAGE_SHARED', 'اشتراک یک پیام'), ('MESSAGE_SHARE_REVOKED', 'لغو اشتراک پیام'), ('MESSAGE_SHARE_OPENED', 'مشاهده پیام اشتراکی'), ('STATUS_CHANGED', 'تغییر وضعیت'), ('PRIORITY_CHANGED', 'تغییر اولویت'), ('REOPENED', 'بازگشایی'), ('RESOLVED', 'حل'), ('CLOSED', 'بستن')], max_length=30),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MessageShare',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('note', models.CharField(blank=True, max_length=500)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('first_viewed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('last_viewed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('view_count', models.PositiveIntegerField(default=0)),
|
||||
('revoked_at', models.DateTimeField(blank=True, null=True)),
|
||||
('granted_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sent_message_shares', to=settings.AUTH_USER_MODEL)),
|
||||
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='staff_shares', to='messaging.message')),
|
||||
('revoked_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='revoked_message_shares', to=settings.AUTH_USER_MODEL)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_message_shares', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at', '-pk'],
|
||||
'constraints': [models.UniqueConstraint(condition=models.Q(('revoked_at__isnull', True)), fields=('message', 'user'), name='unique_active_message_share')],
|
||||
},
|
||||
),
|
||||
]
|
||||
+39
-1
@@ -128,13 +128,51 @@ class Ticket(PersianTextNormalizationMixin, PublicIdModel, TimeStampedModel):
|
||||
profile=getattr(expert,"staff_profile",None) if expert else None
|
||||
return getattr(profile,"display_name","") or "صف عمومی"
|
||||
class TicketAccessGrant(models.Model):
|
||||
note = models.CharField(max_length=500, blank=True)
|
||||
first_viewed_at = models.DateTimeField(null=True, blank=True)
|
||||
last_viewed_at = models.DateTimeField(null=True, blank=True)
|
||||
view_count = models.PositiveIntegerField(default=0)
|
||||
revoked_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True, related_name="revoked_ticket_grants")
|
||||
class Level(models.TextChoices): VIEW_ONLY="VIEW_ONLY","فقط مشاهده"; REPLY="REPLY","پاسخ"; FULL="FULL","کامل"
|
||||
ticket=models.ForeignKey(Ticket,on_delete=models.CASCADE,related_name="access_grants"); user=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE,related_name="ticket_grants"); granted_by=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.PROTECT,related_name="granted_ticket_access"); permission_level=models.CharField(max_length=15,choices=Level.choices,default=Level.FULL); created_at=models.DateTimeField(auto_now_add=True); revoked_at=models.DateTimeField(null=True,blank=True)
|
||||
class Meta:
|
||||
constraints=[models.UniqueConstraint(fields=["ticket","user"],condition=Q(revoked_at__isnull=True),name="unique_active_ticket_grant")]
|
||||
class TicketEvent(models.Model):
|
||||
class Type(models.TextChoices): CREATED="CREATED","ایجاد"; CLAIMED="CLAIMED","برداشت"; TRANSFERRED="TRANSFERRED","انتقال"; SHARED="SHARED","اشتراک"; ACCESS_REVOKED="ACCESS_REVOKED","لغو دسترسی"; STATUS_CHANGED="STATUS_CHANGED","تغییر وضعیت"; PRIORITY_CHANGED="PRIORITY_CHANGED","تغییر اولویت"; REOPENED="REOPENED","بازگشایی"; RESOLVED="RESOLVED","حل"; CLOSED="CLOSED","بستن"
|
||||
class Type(models.TextChoices):
|
||||
CREATED="CREATED","ایجاد"
|
||||
CLAIMED="CLAIMED","برداشت"
|
||||
TRANSFERRED="TRANSFERRED","انتقال"
|
||||
SHARED="SHARED","اشتراک تیکت"
|
||||
ACCESS_REVOKED="ACCESS_REVOKED","لغو دسترسی تیکت"
|
||||
SHARE_OPENED="SHARE_OPENED","مشاهده تیکت اشتراکی"
|
||||
MESSAGE_SHARED="MESSAGE_SHARED","اشتراک یک پیام"
|
||||
MESSAGE_SHARE_REVOKED="MESSAGE_SHARE_REVOKED","لغو اشتراک پیام"
|
||||
MESSAGE_SHARE_OPENED="MESSAGE_SHARE_OPENED","مشاهده پیام اشتراکی"
|
||||
STATUS_CHANGED="STATUS_CHANGED","تغییر وضعیت"
|
||||
PRIORITY_CHANGED="PRIORITY_CHANGED","تغییر اولویت"
|
||||
REOPENED="REOPENED","بازگشایی"
|
||||
RESOLVED="RESOLVED","حل"
|
||||
CLOSED="CLOSED","بستن"
|
||||
ticket=models.ForeignKey(Ticket,on_delete=models.CASCADE,related_name="events"); event_type=models.CharField(max_length=30,choices=Type.choices); actor=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.PROTECT,null=True); metadata=models.JSONField(default=dict,blank=True); created_at=models.DateTimeField(auto_now_add=True)
|
||||
|
||||
|
||||
class MessageShare(PublicIdModel, models.Model):
|
||||
"""A staff-only grant to one message; never confers ticket access."""
|
||||
|
||||
message = models.ForeignKey("messaging.Message", on_delete=models.CASCADE, related_name="staff_shares")
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="received_message_shares")
|
||||
granted_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="sent_message_shares")
|
||||
note = models.CharField(max_length=500, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
first_viewed_at = models.DateTimeField(null=True, blank=True)
|
||||
last_viewed_at = models.DateTimeField(null=True, blank=True)
|
||||
view_count = models.PositiveIntegerField(default=0)
|
||||
revoked_at = models.DateTimeField(null=True, blank=True)
|
||||
revoked_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True, related_name="revoked_message_shares")
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at", "-pk"]
|
||||
constraints = [models.UniqueConstraint(fields=["message", "user"], condition=Q(revoked_at__isnull=True), name="unique_active_message_share")]
|
||||
class TicketReadState(models.Model):
|
||||
ticket=models.ForeignKey(Ticket,on_delete=models.CASCADE,related_name="read_states"); user=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE); last_read_at=models.DateTimeField(); last_read_message=models.ForeignKey("messaging.Message",on_delete=models.SET_NULL,null=True,blank=True)
|
||||
class Meta: constraints=[models.UniqueConstraint(fields=["ticket","user"],name="unique_ticket_read_state")]
|
||||
@@ -4,6 +4,11 @@ from .models import TicketAccessGrant
|
||||
|
||||
|
||||
class TicketPolicy:
|
||||
@classmethod
|
||||
def can_operate(cls, user, ticket):
|
||||
profile = getattr(user, "staff_profile", None)
|
||||
return bool(user.is_authenticated and user.is_active and getattr(user, "account_type", None) == "STAFF"
|
||||
and (profile is None or profile.is_active) and cls.can_view(user, ticket))
|
||||
@staticmethod
|
||||
def is_customer(user): return getattr(user,"account_type",None)=="CUSTOMER"
|
||||
@staticmethod
|
||||
@@ -118,12 +123,10 @@ class TicketPolicy:
|
||||
return permitted and cls.can_view(user, ticket) and ticket.owner_id is None and ticket.selected_expert_id is None
|
||||
@classmethod
|
||||
def can_transfer(cls,user,ticket):
|
||||
permitted=user.has_perm("tickets.transfer_ticket") or cls.has_unit_permission(user,ticket.owning_unit,"tickets.transfer_ticket")
|
||||
return permitted and cls.can_view(user,ticket) and (ticket.owner_id==user.id or (ticket.owner_id is None and ticket.selected_expert_id==user.id) or user.has_perm("tickets.view_all_tickets"))
|
||||
return cls.can_operate(user, ticket)
|
||||
@classmethod
|
||||
def can_share(cls,user,ticket):
|
||||
permitted=user.has_perm("tickets.share_ticket") or cls.has_unit_permission(user,ticket.owning_unit,"tickets.share_ticket")
|
||||
return permitted and cls.can_view(user,ticket) and (ticket.owner_id==user.id or (ticket.owner_id is None and ticket.selected_expert_id==user.id) or user.has_perm("tickets.view_all_tickets"))
|
||||
return cls.can_operate(user, ticket)
|
||||
@classmethod
|
||||
def can_delete(cls, user, ticket):
|
||||
if not user.is_authenticated or cls.is_customer(user):
|
||||
|
||||
@@ -12,7 +12,11 @@ def staff_ticket_rows(user):
|
||||
def filter_staff_tickets(qs, user, params):
|
||||
search=params.get("q", "").strip()
|
||||
if search:
|
||||
qs=qs.filter(Q(ticket_number__icontains=search)|Q(subject__icontains=search)|Q(customer__mobile__icontains=search)|Q(customer__customer_profile__first_name__icontains=search)|Q(customer__customer_profile__last_name__icontains=search))
|
||||
matches=Q(ticket_number__icontains=search)|Q(subject__icontains=search)|Q(customer__mobile__icontains=search)|Q(customer__customer_profile__first_name__icontains=search)|Q(customer__customer_profile__last_name__icontains=search)
|
||||
if params.get("tab") == "shared":
|
||||
from .staff_conversation import received_messages
|
||||
matches |= Q(pk__in=received_messages(user).filter(message__body__icontains=search).values("message__ticket_id"))
|
||||
qs=qs.filter(matches)
|
||||
if params.get("status") in Ticket.Status.values: qs=qs.filter(status=params["status"])
|
||||
if params.get("priority") in Ticket.Priority.values: qs=qs.filter(priority=params["priority"])
|
||||
if params.get("category", "").isdigit(): qs=qs.filter(category_id=params["category"])
|
||||
@@ -41,13 +45,18 @@ def filter_staff_tickets(qs, user, params):
|
||||
if tab=="new": qs=qs.filter(status=Ticket.Status.NEW,owner__isnull=True,selected_expert__isnull=True)
|
||||
elif tab=="selected": qs=qs.filter(selected_expert=user,owner__isnull=True)
|
||||
elif tab=="mine": qs=qs.filter(owner=user)
|
||||
elif tab=="shared": qs=qs.filter(access_grants__user=user,access_grants__revoked_at__isnull=True)
|
||||
elif tab=="shared":
|
||||
from .staff_conversation import received_messages
|
||||
qs=qs.filter(Q(access_grants__user=user,access_grants__revoked_at__isnull=True) | Q(pk__in=received_messages(user).values("message__ticket_id")))
|
||||
elif tab=="waiting_staff": qs=qs.filter(status__in=[Ticket.Status.WAITING_STAFF,Ticket.Status.REOPENED])
|
||||
elif tab=="waiting_customer": qs=qs.filter(status=Ticket.Status.WAITING_CUSTOMER)
|
||||
elif tab=="resolved": qs=qs.filter(status=Ticket.Status.RESOLVED)
|
||||
elif tab=="closed": qs=qs.filter(status=Ticket.Status.CLOSED)
|
||||
if params.get("unread"):
|
||||
qs=qs.filter(Q(read_states__user=user,read_states__last_read_at__lt=F("last_activity_at"))|Q(read_states__isnull=True))
|
||||
if tab == "shared":
|
||||
qs=qs.filter(Q(access_grants__user=user,access_grants__revoked_at__isnull=True,access_grants__first_viewed_at__isnull=True) | Q(pk__in=received_messages(user).filter(first_viewed_at__isnull=True).values("message__ticket_id")))
|
||||
else:
|
||||
qs=qs.filter(Q(read_states__user=user,read_states__last_read_at__lt=F("last_activity_at"))|Q(read_states__isnull=True))
|
||||
qs=qs.distinct()
|
||||
if tab in ("", "waiting_staff"):
|
||||
return qs.annotate(
|
||||
|
||||
@@ -183,7 +183,14 @@ def share_ticket(ticket,user,target,level=TicketAccessGrant.Level.FULL):
|
||||
@transaction.atomic
|
||||
def revoke_access(ticket,user,target):
|
||||
if not TicketPolicy.can_share(user,ticket): raise PermissionDenied
|
||||
count=ticket.access_grants.filter(user=target,revoked_at__isnull=True).update(revoked_at=timezone.now()); TicketEvent.objects.create(ticket=ticket,event_type=TicketEvent.Type.ACCESS_REVOKED,actor=user,metadata={"user":target.pk}); return count
|
||||
from .sharing import log_share
|
||||
grants=list(ticket.access_grants.select_for_update().filter(user=target,revoked_at__isnull=True))
|
||||
for grant in grants:
|
||||
grant.revoked_at=timezone.now()
|
||||
grant.revoked_by=user
|
||||
grant.save(update_fields=["revoked_at","revoked_by"])
|
||||
log_share(grant,user,TicketEvent.Type.ACCESS_REVOKED,"revoked")
|
||||
return len(grants)
|
||||
|
||||
@transaction.atomic
|
||||
def reply_ticket(*,ticket_id,user,body="",files=(),internal=False,mentioned_users=()):
|
||||
@@ -236,8 +243,7 @@ def delete_ticket(*, ticket_id, user):
|
||||
|
||||
@transaction.atomic
|
||||
def change_status(ticket,user,status):
|
||||
permitted=user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(user,ticket.owning_unit,"tickets.change_ticket")
|
||||
if not (TicketPolicy.can_view(user,ticket) and permitted): raise PermissionDenied
|
||||
if not TicketPolicy.can_operate(user,ticket): raise PermissionDenied
|
||||
if status not in Ticket.Status.values: raise ValidationError("وضعیت نامعتبر است.")
|
||||
old=ticket.status; now=timezone.now(); ticket.status=status
|
||||
if status==Ticket.Status.RESOLVED: ticket.resolved_at=now
|
||||
@@ -246,8 +252,7 @@ def change_status(ticket,user,status):
|
||||
|
||||
@transaction.atomic
|
||||
def change_priority(ticket,user,priority):
|
||||
permitted=user.has_perm("tickets.change_ticket_priority") or TicketPolicy.has_unit_permission(user,ticket.owning_unit,"tickets.change_ticket_priority")
|
||||
if not (TicketPolicy.can_view(user,ticket) and permitted): raise PermissionDenied
|
||||
if not TicketPolicy.can_operate(user,ticket): raise PermissionDenied
|
||||
if priority not in Ticket.Priority.values: raise ValidationError("اولویت نامعتبر است.")
|
||||
old=ticket.priority; ticket.priority=priority; ticket.save(update_fields=["priority","updated_at"])
|
||||
TicketEvent.objects.create(ticket=ticket,event_type=TicketEvent.Type.PRIORITY_CHANGED,actor=user,metadata={"from":old,"to":priority})
|
||||
@@ -0,0 +1,176 @@
|
||||
"""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
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Routes available only to staff, including isolated message attachment delivery."""
|
||||
import mimetypes
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.http import FileResponse, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.views.decorators.cache import never_cache
|
||||
from django.views.decorators.http import require_GET, require_POST
|
||||
|
||||
from apps.attachments.delivery import signed_media_redirect
|
||||
from apps.attachments.models import Attachment
|
||||
from .models import MessageShare, Ticket
|
||||
from .staff_conversation import received_messages, unread_shares, visible_messages
|
||||
from .sharing import mark_share_opened, revoke_staff_share
|
||||
from .views import staff_required
|
||||
|
||||
|
||||
def received_message_share(request, share_id):
|
||||
grant = get_object_or_404(MessageShare.objects.select_related("message__author__staff_profile", "granted_by__staff_profile"), public_id=share_id)
|
||||
profile = getattr(request.user, "staff_profile", None)
|
||||
if (grant.user_id != request.user.pk or grant.revoked_at or grant.message.deleted_at
|
||||
or not request.user.is_active or not profile or not profile.is_active):
|
||||
raise PermissionDenied
|
||||
return grant
|
||||
|
||||
|
||||
def submit_share_form(request, form, save, success_message):
|
||||
if not form.is_valid():
|
||||
messages.error(request, "گیرنده، سطح دسترسی یا توضیح اشتراک معتبر نیست.")
|
||||
return
|
||||
try:
|
||||
save()
|
||||
except ValidationError as error:
|
||||
messages.error(request, " ".join(error.messages))
|
||||
else:
|
||||
messages.success(request, success_message)
|
||||
|
||||
|
||||
@staff_required
|
||||
@require_GET
|
||||
@never_cache
|
||||
def inbox(request):
|
||||
from django.urls import reverse
|
||||
return redirect(reverse("staff_tickets:list") + "?tab=shared")
|
||||
|
||||
|
||||
@staff_required
|
||||
@require_POST
|
||||
def revoke_message(request, public_id, share_id):
|
||||
grant = get_object_or_404(MessageShare.objects.select_related("message__ticket"), public_id=share_id, message__ticket__public_id=public_id)
|
||||
revoke_staff_share(grant, request.user)
|
||||
messages.success(request, "دسترسی به پیام اشتراکی لغو شد؛ سابقه آن حفظ شده است.")
|
||||
return redirect("staff_tickets:detail", public_id=public_id)
|
||||
|
||||
|
||||
@staff_required
|
||||
@require_GET
|
||||
@never_cache
|
||||
def shared_message(request, share_id):
|
||||
grant = received_message_share(request, share_id)
|
||||
return redirect("staff_tickets:detail", public_id=grant.message.ticket.public_id)
|
||||
|
||||
|
||||
@staff_required
|
||||
@require_POST
|
||||
@never_cache
|
||||
def read_shared_messages(request, public_id):
|
||||
ticket = get_object_or_404(Ticket, public_id=public_id)
|
||||
allowed = visible_messages(ticket, request.user)
|
||||
ids = request.POST.getlist("message_ids")[:100]
|
||||
# Filter strings without parsing attacker-controlled UUIDs in the ORM.
|
||||
visible = {str(value) for value in allowed.values_list("public_id", flat=True)}
|
||||
accepted = set(ids) & visible
|
||||
grants = received_messages(request.user, ticket).filter(message__public_id__in=accepted)
|
||||
for grant in grants:
|
||||
if grant.first_viewed_at is None:
|
||||
mark_share_opened(grant, request.user)
|
||||
return JsonResponse({"unread_shares": unread_shares(request.user)})
|
||||
|
||||
|
||||
@staff_required
|
||||
@require_GET
|
||||
@never_cache
|
||||
def unread_count(request):
|
||||
return JsonResponse({"unread_shares": unread_shares(request.user)})
|
||||
|
||||
|
||||
@staff_required
|
||||
@require_GET
|
||||
@never_cache
|
||||
def shared_attachment(request, share_id, attachment_id, download=False):
|
||||
grant = received_message_share(request, share_id)
|
||||
attachment = get_object_or_404(Attachment, public_id=attachment_id, message_id=grant.message_id)
|
||||
if settings.USE_NGINX_MEDIA:
|
||||
return signed_media_redirect(attachment.relative_path, download=download)
|
||||
content_type = attachment.mime_type or mimetypes.guess_type(attachment.original_name)[0] or "application/octet-stream"
|
||||
response = FileResponse(attachment.file.open("rb"), content_type=content_type, as_attachment=download, filename=attachment.original_name)
|
||||
response["X-Content-Type-Options"] = "nosniff"
|
||||
return response
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Staff conversation access; message grants never become ticket grants."""
|
||||
import hashlib
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models import Q
|
||||
from django.http import JsonResponse
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
from .models import MessageShare, TicketAccessGrant
|
||||
from .policies import TicketPolicy
|
||||
from .sharing import staff_message_context
|
||||
|
||||
|
||||
def received_messages(user, ticket=None):
|
||||
grants = MessageShare.objects.filter(user=user, revoked_at__isnull=True, message__deleted_at__isnull=True)
|
||||
return grants.filter(message__ticket=ticket) if ticket is not None else grants
|
||||
|
||||
|
||||
def unread_shares(user):
|
||||
profile = getattr(user, "staff_profile", None)
|
||||
if not user.is_authenticated or not user.is_active or user.account_type != "STAFF" or not profile or not profile.is_active:
|
||||
return 0
|
||||
return (received_messages(user).filter(first_viewed_at__isnull=True).count()
|
||||
+ TicketAccessGrant.objects.filter(user=user, revoked_at__isnull=True, first_viewed_at__isnull=True).count())
|
||||
|
||||
|
||||
def visible_messages(ticket, user):
|
||||
profile = getattr(user, "staff_profile", None)
|
||||
if not user.is_active or (profile is not None and not profile.is_active):
|
||||
raise PermissionDenied
|
||||
query = ticket.ticket_messages.all()
|
||||
if TicketPolicy.can_view(user, ticket):
|
||||
return query
|
||||
grants = received_messages(user, ticket)
|
||||
if not grants.exists():
|
||||
raise PermissionDenied
|
||||
return query.filter(pk__in=grants.values("message_id"))
|
||||
|
||||
|
||||
def message_context(ticket, user, conversation):
|
||||
context = staff_message_context(ticket, user, conversation)
|
||||
grants = {g.message_id: g for g in received_messages(user, ticket).select_related("granted_by__staff_profile")}
|
||||
for item in conversation:
|
||||
item.received_share = grants.get(item.pk)
|
||||
context["limited_share"] = not TicketPolicy.can_view(user, ticket)
|
||||
return context
|
||||
|
||||
|
||||
def shared_ticket_rows(user, params):
|
||||
"""All received shares use ordinary ticket metadata and filters."""
|
||||
from .models import Ticket
|
||||
from .selectors import filter_staff_tickets, staff_ticket_rows
|
||||
full = staff_ticket_rows(user)
|
||||
query = Ticket.objects.select_related("customer__customer_profile", "category", "owner__staff_profile", "selected_expert__staff_profile", "owning_unit", "assigned_unit", "group_dispatch").filter(
|
||||
Q(pk__in=full.values("pk")) | Q(pk__in=received_messages(user).values("message__ticket_id")))
|
||||
rows = list(filter_staff_tickets(query, user, params)[:100])
|
||||
full_ids = set(full.filter(pk__in=[ticket.pk for ticket in rows]).values_list("pk", flat=True))
|
||||
for ticket in rows:
|
||||
ticket.limited_share = ticket.pk not in full_ids
|
||||
return rows
|
||||
|
||||
|
||||
def limited_poll(request, ticket):
|
||||
"""Version depends exclusively on authorized messages and their active grants."""
|
||||
from .views import conversation_page
|
||||
query = visible_messages(ticket, request.user)
|
||||
grants = list(received_messages(request.user, ticket).values_list("pk", "note"))
|
||||
source = repr((list(query.order_by("pk").values_list("pk", "updated_at")), grants,
|
||||
ticket.status, ticket.priority, ticket.subject, ticket.province, ticket.city))
|
||||
version = hashlib.sha256(source.encode()).hexdigest()[:20]
|
||||
latest = query.order_by("-created_at", "-pk").first()
|
||||
cursor = str(latest.public_id) if latest else ""
|
||||
data = {"changed": False, "version": version, "cursor": cursor}
|
||||
if request.GET.get("version") != version or request.GET.get("cursor", "") != cursor:
|
||||
conversation, has_older, older_cursor = conversation_page(query.select_related("author").prefetch_related("attachments"))
|
||||
context = {"ticket": ticket, "viewer_user": request.user, "conversation": conversation,
|
||||
"has_older": has_older, "older_cursor": older_cursor,
|
||||
**message_context(ticket, request.user, conversation)}
|
||||
data.update(changed=True, replace=True, staff_limited=True, count=query.count(), status=ticket.get_status_display(),
|
||||
html=render_to_string("tickets/_staff_older_messages.html", context, request=request))
|
||||
response = JsonResponse(data)
|
||||
response["Cache-Control"] = "private, no-store"
|
||||
return response
|
||||
@@ -1,4 +1,15 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
from . import views, sharing_views
|
||||
app_name="staff_tickets"
|
||||
urlpatterns=[path("",views.staff_list,name="list"),path("customers/",views.staff_customers,name="customers"),path("customers/search/",views.staff_customer_search,name="customer_search"),path("contacts/",views.staff_contacts,name="contacts"),path("contacts/new/",views.staff_contact_create,name="contact_create"),path("contacts/<uuid:public_id>/edit/",views.staff_contact_edit,name="contact_edit"),path("contacts/<uuid:public_id>/call/",views.staff_contact_call,name="contact_call"),path("contact-groups/",views.staff_groups,name="groups"),path("contact-groups/<int:group_id>/",views.staff_groups,name="group_edit"),path("magic-links/",views.staff_magic_audit,name="magic_audit"),path("magic-links/<int:link_id>/revoke/",views.staff_magic_revoke,name="magic_revoke"),path("sms-templates/",views.staff_sms_templates,name="sms_templates"),path("sms-templates/<int:template_id>/",views.staff_sms_templates,name="sms_template_edit"),path("dispatches/",views.staff_dispatch_create,name="dispatch_create"),path("dispatches/<uuid:public_id>/",views.staff_dispatch_detail,name="dispatch_detail"),path("dispatches/<uuid:public_id>/retry/",views.staff_dispatch_retry,name="dispatch_retry"),path("dispatches/<uuid:public_id>/resolve/",views.staff_dispatch_resolve,name="dispatch_resolve"),path("experts/<uuid:public_id>/avatar/",views.expert_avatar,name="expert_avatar"),path("tickets/",views.staff_list,name="tickets"),path("tickets/new/",views.staff_create,name="create"),path("tickets/<uuid:public_id>/messages/older/",views.staff_messages,name="messages"),path("tickets/<uuid:public_id>/messages/poll/",views.staff_poll,name="poll"),path("tickets/<uuid:public_id>/sms/<int:job_id>/retry/",views.staff_sms_retry,name="sms_retry"),path("tickets/<uuid:public_id>/announcement/now/",views.staff_announcement_now,name="announcement_now"),path("tickets/<uuid:public_id>/",views.staff_detail,name="detail"),path("tickets/<uuid:public_id>/claim/",views.staff_claim,name="claim"),path("tickets/<uuid:public_id>/reply/",views.staff_reply,name="reply"),path("tickets/<uuid:public_id>/note/",lambda r,public_id:views.staff_reply(r,public_id,note=True),name="note"),path("tickets/<uuid:public_id>/transfer/",views.staff_transfer,name="transfer"),path("tickets/<uuid:public_id>/share/",views.staff_share,name="share"),path("tickets/<uuid:public_id>/revoke/<uuid:user_id>/",views.staff_revoke,name="revoke"),path("tickets/<uuid:public_id>/status/",views.staff_status,name="status"),path("tickets/<uuid:public_id>/priority/",views.staff_priority,name="priority"),path("tickets/<uuid:public_id>/delete/",views.staff_delete,name="delete")]
|
||||
|
||||
urlpatterns += [
|
||||
path("shares/count/", sharing_views.unread_count, name="shares_count"),
|
||||
path("tickets/<uuid:public_id>/shares/read/", sharing_views.read_shared_messages, name="read_shares"),
|
||||
path("shares/", sharing_views.inbox, name="shares"),
|
||||
path("tickets/<uuid:public_id>/messages/<uuid:message_id>/share/", views.staff_share, name="share_message"),
|
||||
path("tickets/<uuid:public_id>/message-shares/<uuid:share_id>/revoke/", sharing_views.revoke_message, name="revoke_message_share"),
|
||||
path("shared-messages/<uuid:share_id>/", sharing_views.shared_message, name="shared_message"),
|
||||
path("shared-messages/<uuid:share_id>/attachments/<uuid:attachment_id>/", sharing_views.shared_attachment, name="shared_message_attachment"),
|
||||
path("shared-messages/<uuid:share_id>/attachments/<uuid:attachment_id>/download/", sharing_views.shared_attachment, {"download": True}, name="shared_message_attachment_download"),
|
||||
]
|
||||
+49
-23
@@ -14,6 +14,7 @@ from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_date
|
||||
from django.views.decorators.http import require_http_methods, require_POST
|
||||
from django.views.decorators.cache import never_cache
|
||||
from uuid import uuid4
|
||||
import hashlib
|
||||
import json
|
||||
@@ -30,11 +31,14 @@ from apps.core.iran_locations import location_data
|
||||
from apps.core.persian_text import normalize_persian_text
|
||||
from apps.integrations.voip import place_voip_call
|
||||
from apps.staff.models import OrganizationalUnit, StaffProfile
|
||||
from .forms import ContactGroupForm,GroupDispatchForm,ReplyForm,ShareForm,StaffContactForm,StaffCreateTicketForm,StaffReplyForm,TicketForm,TransferForm,UnitSMSTemplateForm
|
||||
from .models import GroupDispatch,Ticket,TicketCategory,TicketReadState,TicketTag
|
||||
from .forms import ContactGroupForm,GroupDispatchForm,ReplyForm,StaffContactForm,StaffCreateTicketForm,StaffReplyForm,TicketForm,TransferForm,UnitSMSTemplateForm
|
||||
from .models import GroupDispatch,MessageShare,Ticket,TicketCategory,TicketReadState,TicketTag
|
||||
from .forms import StaffMessageShareForm, StaffTicketShareForm
|
||||
from .sharing import mark_ticket_share_opened, require_share_authority, revoke_staff_share, share_staff_message, share_staff_ticket, sharing_context, staff_message_context
|
||||
from .policies import DispatchPolicy, TicketPolicy
|
||||
from .staff_conversation import limited_poll, message_context, shared_ticket_rows, visible_messages
|
||||
from .selectors import customer_tickets,dashboard_counts,filter_staff_tickets,staff_ticket_rows
|
||||
from .services import change_priority,change_status,claim_ticket,create_ticket,dispatch_group_tickets,refresh_group_dispatch_status,reply_ticket,resolve_group_dispatch_tickets,revoke_access,share_ticket,transfer_ticket
|
||||
from .services import change_priority,change_status,claim_ticket,create_ticket,dispatch_group_tickets,refresh_group_dispatch_status,reply_ticket,resolve_group_dispatch_tickets,transfer_ticket
|
||||
MESSAGE_PAGE_SIZE=50
|
||||
SMS_RETRYABLE_DELIVERY_STATUSES=(
|
||||
NotificationJob.DeliveryStatus.UNDELIVERED,
|
||||
@@ -145,7 +149,12 @@ def conversation_poll_response(request,ticket,actor,include_internal=False):
|
||||
else:
|
||||
conversation=list(messages_qs[:MESSAGE_PAGE_SIZE])
|
||||
if include_internal: mark_messages_read(conversation,actor)
|
||||
html=render_to_string("tickets/_messages.html",{"conversation":conversation,"ticket":ticket,"viewer_user":actor},request=request)
|
||||
context={"conversation":conversation,"ticket":ticket,"viewer_user":actor}
|
||||
template="tickets/_messages.html"
|
||||
if include_internal:
|
||||
context.update(message_context(ticket,actor,conversation))
|
||||
template="tickets/_staff_messages.html"
|
||||
html=render_to_string(template,context,request=request)
|
||||
response=JsonResponse({"changed":True,"version":version,"cursor":str(latest.public_id) if latest else "","replace":replace,"html":html,"count":qs.count(),"status":ticket.get_status_display()})
|
||||
response["Cache-Control"]="private, no-cache, no-store, must-revalidate"
|
||||
return response
|
||||
@@ -343,7 +352,7 @@ def customer_reply(request,public_id):
|
||||
@staff_required
|
||||
def staff_list(request):
|
||||
from apps.staff.models import OrganizationalUnit, StaffProfile
|
||||
qs=filter_staff_tickets(staff_ticket_rows(request.user),request.user,request.GET)
|
||||
qs=shared_ticket_rows(request.user,request.GET) if request.GET.get("tab") == "shared" else filter_staff_tickets(staff_ticket_rows(request.user),request.user,request.GET)
|
||||
tab=request.GET.get("tab","")
|
||||
tab_titles={"":"میز کار","new":"صف جدید","selected":"انتخابی من","mine":"تیکتهای من","shared":"اشتراکها","waiting_staff":"در انتظار اقدام من","waiting_customer":"در انتظار مشتری","resolved":"حلشدهها","closed":"بستهشدهها"}
|
||||
if tab not in tab_titles: tab=""
|
||||
@@ -352,7 +361,7 @@ def staff_list(request):
|
||||
selected_group_ids=[int(value) for value in request.GET.getlist("contact_group") if value.isdigit()]
|
||||
categories=TicketCategory.objects.filter(is_active=True,unit_id__in=unit_ids) if unit_ids else TicketCategory.objects.filter(is_active=True)
|
||||
experts=StaffProfile.objects.filter(is_active=True,user__unit_memberships__unit_id__in=unit_ids,user__unit_memberships__is_active=True).select_related("user").distinct() if unit_ids else StaffProfile.objects.filter(is_active=True).select_related("user")
|
||||
context={"tickets":qs[:100],"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"delivery_types":Ticket.DeliveryType.choices,"stakeholder_types":CustomerProfile.StakeholderType.choices,"categories":categories,"experts":experts,"units":OrganizationalUnit.objects.filter(pk__in=unit_ids),"contact_groups":ContactGroup.objects.filter(unit_id__in=unit_ids,is_active=True),"selected_group_ids":selected_group_ids,"ticket_tags":TicketTag.objects.filter(is_active=True),"selected_tag_ids":selected_tag_ids,"counts":dashboard_counts(request.user),"active_tab":tab,"active_tab_title":tab_titles[tab]}
|
||||
context={"tickets":qs[:100],"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"delivery_types":Ticket.DeliveryType.choices,"stakeholder_types":CustomerProfile.StakeholderType.choices,"categories":categories,"experts":experts,"units":OrganizationalUnit.objects.filter(pk__in=unit_ids),"contact_groups":ContactGroup.objects.filter(unit_id__in=unit_ids,is_active=True),"selected_group_ids":selected_group_ids,"ticket_tags":TicketTag.objects.filter(is_active=True),"selected_tag_ids":selected_tag_ids,"counts":dashboard_counts(request.user),"active_tab":tab,"active_tab_title":tab_titles[tab],"unread_message_shares":MessageShare.objects.filter(user=request.user,revoked_at__isnull=True,first_viewed_at__isnull=True,message__deleted_at__isnull=True).count()}
|
||||
return render(request,"tickets/_staff_ticket_results.html" if request.headers.get("HX-Request") else "tickets/staff_list.html",context)
|
||||
@staff_required
|
||||
def staff_create(request):
|
||||
@@ -433,15 +442,20 @@ def staff_customer_search(request):
|
||||
results.append({"id":str(customer.public_id),"mobile":customer.local_mobile,"name":str(profile) if profile else customer.local_mobile,"first_name":getattr(profile,"first_name","") or "","last_name":getattr(profile,"last_name","") or "","province":getattr(profile,"province","") or "","city":getattr(profile,"city","") or "","customer_type":profile.get_customer_type_display() if profile and profile.customer_type else "ثبت نشده","ticket_count":customer.ticket_count,"active_count":customer.active_count})
|
||||
return JsonResponse({"results":results})
|
||||
@staff_required
|
||||
@never_cache
|
||||
def staff_detail(request,public_id):
|
||||
ticket=get_object_or_404(Ticket.objects.select_related("customer__customer_profile","category","owner__staff_profile","selected_expert__staff_profile","group_dispatch__unit","group_dispatch__contact_group"),public_id=public_id)
|
||||
if not TicketPolicy.can_view(request.user,ticket): raise PermissionDenied
|
||||
conversation,has_older,older_cursor=conversation_page(ticket.ticket_messages.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile"))
|
||||
visible=visible_messages(ticket,request.user)
|
||||
conversation,has_older,older_cursor=conversation_page(visible.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile"))
|
||||
mark_messages_read(conversation,request.user)
|
||||
last=conversation[-1] if conversation else None; TicketReadState.objects.update_or_create(ticket=ticket,user=request.user,defaults={"last_read_at":timezone.now(),"last_read_message":last})
|
||||
mark_ticket_share_opened(ticket,request.user)
|
||||
message_sharing=message_context(ticket,request.user,conversation)
|
||||
if TicketPolicy.can_view(request.user,ticket):
|
||||
last=conversation[-1] if conversation else None
|
||||
TicketReadState.objects.update_or_create(ticket=ticket,user=request.user,defaults={"last_read_at":timezone.now(),"last_read_message":last})
|
||||
history=Ticket.objects.filter(customer=ticket.customer).exclude(pk=ticket.pk).select_related("owner__staff_profile","selected_expert__staff_profile").order_by("-last_activity_at")[:5]
|
||||
sms_jobs=list(ticket.notificationjob_set.filter(channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("-created_at"))
|
||||
announcement_jobs=list(AnnouncementJob.objects.filter(Q(message__ticket=ticket) | Q(ticket=ticket)).select_related("message").order_by("-created_at", "-pk"))
|
||||
announcement_jobs=list(AnnouncementJob.objects.filter(Q(message_id__in=visible.values("pk")) | Q(ticket=ticket)).select_related("message").order_by("-created_at", "-pk"))
|
||||
can_retry_sms=TicketPolicy.can_reply(request.user,ticket)
|
||||
can_request_announcement=request.user.account_type==User.AccountType.STAFF and can_retry_sms
|
||||
configuration=VoipConfiguration.load() if can_request_announcement else None
|
||||
@@ -452,7 +466,7 @@ def staff_detail(request,public_id):
|
||||
group_dispatch = ticket.group_dispatch
|
||||
can_resolve_dispatch = bool(group_dispatch and DispatchPolicy.can_view(request.user, group_dispatch) and (request.user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(request.user, group_dispatch.unit, "tickets.change_ticket")))
|
||||
group_open_count = group_dispatch.tickets.exclude(status__in=(Ticket.Status.RESOLVED, Ticket.Status.CLOSED)).count() if group_dispatch else 0
|
||||
return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":ShareForm(),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"can_resolve_dispatch":can_resolve_dispatch,"group_open_count":group_open_count,"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"sms_jobs":sms_jobs,"announcement_jobs":announcement_jobs,"can_request_announcement":can_request_announcement,"announcement_ready":announcement_ready,"viewer_user":request.user,"voip_call_methods":_voip_call_methods(request.user),"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else ""})
|
||||
return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":StaffTicketShareForm(actor=request.user),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"can_resolve_dispatch":can_resolve_dispatch,"group_open_count":group_open_count,"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"sms_jobs":sms_jobs,"announcement_jobs":announcement_jobs,"can_request_announcement":can_request_announcement,"announcement_ready":announcement_ready,"viewer_user":request.user,"voip_call_methods":_voip_call_methods(request.user),"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else "",**message_sharing,**sharing_context(ticket,request.user)})
|
||||
|
||||
|
||||
@staff_required
|
||||
@@ -493,14 +507,14 @@ def staff_sms_retry(request,public_id,job_id):
|
||||
@staff_required
|
||||
def staff_messages(request,public_id):
|
||||
ticket=get_object_or_404(Ticket,public_id=public_id)
|
||||
if not TicketPolicy.can_view(request.user,ticket): raise PermissionDenied
|
||||
conversation,has_older,older_cursor=conversation_page(ticket.ticket_messages.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile"),request.GET.get("before"))
|
||||
conversation,has_older,older_cursor=conversation_page(visible_messages(ticket,request.user).select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile"),request.GET.get("before"))
|
||||
mark_messages_read(conversation,request.user)
|
||||
return render(request,"tickets/_older_messages.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"older_route":"staff_tickets:messages","viewer_user":request.user})
|
||||
return render(request,"tickets/_staff_older_messages.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"viewer_user":request.user,**message_context(ticket,request.user,conversation)})
|
||||
@staff_required
|
||||
def staff_poll(request,public_id):
|
||||
ticket=get_object_or_404(Ticket,public_id=public_id)
|
||||
if not TicketPolicy.can_view(request.user,ticket): raise PermissionDenied
|
||||
visible_messages(ticket,request.user)
|
||||
if not TicketPolicy.can_view(request.user,ticket): return limited_poll(request,ticket)
|
||||
return conversation_poll_response(request,ticket,request.user,include_internal=True)
|
||||
@staff_required
|
||||
@require_POST
|
||||
@@ -532,19 +546,31 @@ def staff_transfer(request,public_id):
|
||||
return redirect("staff_tickets:detail",public_id=public_id)
|
||||
@staff_required
|
||||
@require_POST
|
||||
def staff_share(request,public_id):
|
||||
ticket=get_object_or_404(Ticket,public_id=public_id); form=ShareForm(request.POST)
|
||||
if form.is_valid():
|
||||
try: share_ticket(ticket,request.user,form.cleaned_data["expert"],form.cleaned_data["permission_level"]); messages.success(request,"دسترسی ایجاد شد.")
|
||||
except PermissionDenied: messages.error(request,"اجازه اشتراک این تیکت را ندارید.")
|
||||
def staff_share(request,public_id,message_id=None):
|
||||
from .sharing_views import submit_share_form
|
||||
ticket=get_object_or_404(Ticket,public_id=public_id)
|
||||
require_share_authority(request.user,ticket)
|
||||
if message_id is not None:
|
||||
selected_message=get_object_or_404(Message,public_id=message_id,ticket=ticket)
|
||||
form=StaffMessageShareForm(request.POST,actor=request.user)
|
||||
save=lambda: share_staff_message(selected_message,request.user,form.cleaned_data["expert"],form.cleaned_data["note"])
|
||||
success="فقط همین پیام و پیوستهای آن در صندوق اشتراکهای کارشناس قرار گرفت."
|
||||
else:
|
||||
form=StaffTicketShareForm(request.POST,actor=request.user)
|
||||
save=lambda: share_staff_ticket(ticket,request.user,form.cleaned_data["expert"],form.cleaned_data["permission_level"],form.cleaned_data["note"])
|
||||
success="اشتراک تیکت ثبت شد؛ وضعیت مشاهده در سوابق قابل پیگیری است."
|
||||
submit_share_form(request,form,save,success)
|
||||
return redirect("staff_tickets:detail",public_id=public_id)
|
||||
|
||||
@staff_required
|
||||
@require_POST
|
||||
def staff_revoke(request,public_id,user_id):
|
||||
ticket=get_object_or_404(Ticket,public_id=public_id); target=get_object_or_404(User,public_id=user_id)
|
||||
try: revoke_access(ticket,request.user,target); messages.success(request,"دسترسی کارشناس لغو شد.")
|
||||
except PermissionDenied: messages.error(request,"اجازه لغو این دسترسی را ندارید.")
|
||||
ticket=get_object_or_404(Ticket,public_id=public_id)
|
||||
require_share_authority(request.user,ticket)
|
||||
grant=ticket.access_grants.filter(user__public_id=user_id,revoked_at__isnull=True).first()
|
||||
if grant:
|
||||
revoke_staff_share(grant,request.user)
|
||||
messages.success(request,"دسترسی اشتراکی لغو شد؛ مجوزهای مستقل کارشناس تغییر نمیکنند.")
|
||||
return redirect("staff_tickets:detail",public_id=public_id)
|
||||
|
||||
@staff_required
|
||||
|
||||
Reference in new issue
Block a user