share ticket and ticket message

This commit is contained in:
javad committed 2026-10-04 16:42:19 +03:30
1 parent 39e64854b8
commit 79fe1cddbb
35 files changed
+1742 -95

No files matched your search

+56 -2
View File
@@ -1,3 +1,57 @@
from django import forms
from django.contrib import admin
from .models import Attachment,AttachmentPolicy
admin.site.register([Attachment,AttachmentPolicy])
from .models import Attachment, AttachmentPolicy
class AttachmentPolicyAdminForm(forms.ModelForm):
class Meta:
model = AttachmentPolicy
fields = "__all__"
labels = {
"attachment_type": "نوع پیوست (Attachment type)",
"is_enabled": "فعال است",
"max_file_size": "حداکثر حجم هر فایل (بایت)",
"max_files_per_message": "حداکثر تعداد فایل از این نوع در هر پیام",
"allowed_extensions": "پسوندهای مجاز (Allowed extensions)",
"allowed_mime_types": "نوع محتوای مجاز (Allowed mime types)",
"sort_order": "ترتیب نمایش",
}
help_texts = {
"attachment_type": (
"شناسه نوع پیوست را با حروف بزرگ وارد کنید: IMAGE، VIDEO، VOICE، PDF، "
"WORD، EXCEL، ZIP، RAR یا OTHER. هر نوع فقط یک سیاست دارد."
),
"is_enabled": "با غیرفعال کردن، آپلود جدید این نوع فایل برای مشتری و کارکنان رد می‌شود.",
"max_file_size": (
"واحد بایت است و این سقف برای هر فایل جداگانه اعمال می‌شود، نه مجموع پیوست‌ها. "
"برای تبدیل حجم بر حسب مِبی‌بایت (MiB) به بایت، عدد را در ۱۰۲۴ × ۱۰۲۴ ضرب کنید؛ "
"مثلاً ۳۰ × ۱۰۲۴ × ۱۰۲۴ = 31457280 بایت. فقط عدد نهایی را وارد کنید."
),
"max_files_per_message": (
"سقف تعداد پیوست‌های همین نوع در یک پیام است؛ "
"مثلاً مقدار ۱۰ برای PDF یعنی حداکثر ۱۰ فایل PDF در هر پیام."
),
"allowed_extensions": (
'فهرست JSON با نقل‌قول دوتایی، نقطه و حروف کوچک وارد کنید؛ '
'برای PDF: [".pdf"] و برای تصویر، مثلاً: [".jpg", ".jpeg", ".png"]. '
"این فیلد پسوند نام فایل را کنترل می‌کند. فرم ادمین فهرست خالی را نمی‌پذیرد."
),
"allowed_mime_types": (
'فهرست JSON از نوع محتوای واقعی فایل وارد کنید؛ برای PDF: ["application/pdf"] '
'و برای تصویر، مثلاً: ["image/jpeg", "image/png"]. '
"نوع محتوا از خود فایل تشخیص داده می‌شود و باید دقیقاً در این فهرست باشد؛ "
"مقادیر کلی مثل image/* پشتیبانی نمی‌شوند. هر دو فهرست پسوند و محتوا باید "
"با فایل سازگار باشند. این سیاست برای مشتری و کارکنان مشترک است؛ "
"فرم ادمین فهرست خالی را نمی‌پذیرد."
),
"sort_order": "عدد صحیح نامنفی برای ثبت ترتیب سیاست؛ مثلاً ۰، ۱ یا ۲.",
}
@admin.register(AttachmentPolicy)
class AttachmentPolicyAdmin(admin.ModelAdmin):
form = AttachmentPolicyAdminForm
admin.site.register(Attachment)
+4
View File
@@ -8,6 +8,10 @@ def attachment_path(instance,filename):
return f"tickets/{t.created_at:%Y/%m}/{t.public_id}/{m.public_id}/{uuid.uuid4().hex}{ext}"
class AttachmentPolicy(models.Model):
attachment_type=models.CharField(max_length=20,unique=True); is_enabled=models.BooleanField(default=True); max_file_size=models.PositiveBigIntegerField(); max_files_per_message=models.PositiveSmallIntegerField(default=10); allowed_extensions=models.JSONField(default=list); allowed_mime_types=models.JSONField(default=list); sort_order=models.PositiveIntegerField(default=0); updated_at=models.DateTimeField(auto_now=True)
def __str__(self):
return self.attachment_type
class Attachment(PublicIdModel,models.Model):
class Type(models.TextChoices): IMAGE="IMAGE","تصویر"; VIDEO="VIDEO","ویدئو"; VOICE="VOICE","صدا"; PDF="PDF","PDF"; WORD="WORD","Word"; EXCEL="EXCEL","Excel"; ZIP="ZIP","ZIP"; RAR="RAR","RAR"; OTHER="OTHER","سایر"
message=models.ForeignKey("messaging.Message",on_delete=models.PROTECT,related_name="attachments"); file=models.FileField(upload_to=attachment_path,max_length=500); original_name=models.CharField(max_length=255); stored_name=models.CharField(max_length=255); relative_path=models.CharField(max_length=500); attachment_type=models.CharField(max_length=20,choices=Type.choices); mime_type=models.CharField(max_length=150); extension=models.CharField(max_length=20); file_size=models.PositiveBigIntegerField(); uploaded_by=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.PROTECT); created_at=models.DateTimeField(auto_now_add=True)
+2 -1
View File
@@ -56,10 +56,11 @@ def staff_operations_access(request):
if not getattr(user, "is_authenticated", False) or getattr(user, "account_type", "") != "STAFF":
return {}
from apps.tickets.policies import TicketPolicy
from apps.tickets.staff_conversation import unread_shares
memberships = list(user.unit_memberships.filter(is_active=True, unit__is_active=True).select_related("unit"))
def allowed(permission):
return user.is_superuser or user.has_perm(permission) or any(TicketPolicy.has_unit_permission(user, item.unit, permission) for item in memberships)
return {"staff_operations_access": {
return {"staff_unread_shares": unread_shares(user), "staff_operations_access": {
"create_ticket": user.has_perm("tickets.add_ticket") or allowed("tickets.add_ticket"),
"contacts": True,
"contact_groups": allowed("customers.view_contactgroup") or allowed("customers.add_contactgroup"),
+14
View File
@@ -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
View File
@@ -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")]
+7 -4
View File
@@ -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 -3
View File
@@ -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(
+10 -5
View File
@@ -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})
+176
View File
@@ -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
+101
View File
@@ -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
+83
View File
@@ -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
+12 -1
View File
@@ -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
View File
@@ -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