estelam ticket, varizha , others

This commit is contained in:
javad committed 2026-10-13 15:48:24 +03:30
1 parent 35a1ec1794
commit 6b546aad76
71 files changed
+2865 -191

No files matched your search

+40 -1
View File
@@ -1,5 +1,5 @@
from django.contrib import admin
from .models import GroupDispatch,SLAConfig,Ticket,TicketAccessGrant,TicketCategory,TicketEvent,TicketReadState,TicketTag
from .models import DispatchResponseControl,GroupDispatch,SLAConfig,Ticket,TicketAccessGrant,TicketCategory,TicketEvent,TicketReadState,TicketTag
@admin.register(Ticket)
class TicketAdmin(admin.ModelAdmin):
list_display=("ticket_number","subject","owning_unit","delivery_type","status","priority","owner","last_activity_at")
@@ -7,6 +7,15 @@ class TicketAdmin(admin.ModelAdmin):
search_fields=("ticket_number","subject","customer__mobile","customer__customer_profile__organization_name")
filter_horizontal=("tags",)
def get_readonly_fields(self, request, obj=None):
if obj and obj.group_dispatch_id and DispatchResponseControl.objects.filter(dispatch_id=obj.group_dispatch_id).exists():
return ("group_dispatch", "delivery_type", "customer", "created_by")
return super().get_readonly_fields(request, obj)
def get_queryset(self, request):
from .dispatch_responses import managed_ticket_q
return super().get_queryset(request).filter(managed_ticket_q(request.user)).distinct()
class DispatchTicketInline(admin.TabularInline):
model = Ticket
@@ -27,6 +36,36 @@ class GroupDispatchAdmin(admin.ModelAdmin):
readonly_fields = ("recipient_count", "dispatched_at")
inlines = (DispatchTicketInline,)
def get_readonly_fields(self, request, obj=None):
fields = super().get_readonly_fields(request, obj)
if obj and DispatchResponseControl.objects.filter(dispatch_id=obj.pk).exists():
return fields + ("unit", "operation_type", "created_by", "contact_group")
return fields
def get_queryset(self, request):
from .policies import DispatchPolicy
from django.db.models import Q
return super().get_queryset(request).filter(Q(response_control__isnull=True) | Q(pk__in=DispatchPolicy.visible_dispatches(request.user)))
@admin.register(DispatchResponseControl)
class DispatchResponseControlAdmin(admin.ModelAdmin):
list_display = ("dispatch", "mode", "notify_sms", "opened_at", "opened_by")
readonly_fields = tuple(field.name for field in DispatchResponseControl._meta.fields)
def get_queryset(self, request):
from .policies import DispatchPolicy
return super().get_queryset(request).filter(dispatch__in=DispatchPolicy.visible_dispatches(request.user))
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
@admin.register(TicketCategory)
class TicketCategoryAdmin(admin.ModelAdmin):
+35
View File
@@ -0,0 +1,35 @@
"""Read-only option metadata for the staff dispatch composer."""
from django.db.models import Count, Q
from .models import GroupDispatch, TicketCategory
from .policies import DispatchPolicy
def dispatch_form_options(user, units, groups):
"""Expose only permitted units; recipient counts follow the sending service."""
units = list(units)
eligible = (
Q(contacts__user__is_active=True, contacts__user__mobile__isnull=False)
& ~Q(contacts__user__mobile="")
)
return {
"units": [
{
"id": unit.pk,
"name": unit.name,
"operations": [
kind for kind in GroupDispatch.OperationType.values
if DispatchPolicy.can_create(user, unit, kind)
],
}
for unit in units
],
"groups": list(groups.annotate(recipient_count=Count("contacts", filter=eligible)).values(
"id", "name", "unit_id", "recipient_count",
)),
"categories": list(TicketCategory.objects.filter(
Q(unit_id__in=[unit.pk for unit in units]) | Q(unit__isnull=True),
is_active=True,
).order_by("title").values("id", "title", "unit_id")),
}
+45
View File
@@ -0,0 +1,45 @@
from django.core import signing
from django.contrib import messages
from django.core.exceptions import PermissionDenied, ValidationError
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_http_methods
from .dispatch_responses import can_open_responses, open_dispatch_responses, render_opening_sms, response_control
from .models import GroupDispatch
from apps.notifications.models import SMSTemplate
from .views import staff_required
@staff_required
@require_http_methods(["GET", "POST"])
@never_cache
def open_responses(request, public_id):
dispatch = get_object_or_404(GroupDispatch.objects.select_related("unit"), public_id=public_id)
control = response_control(dispatch)
if not control or control.mode != "SEALED" or not can_open_responses(request.user, dispatch):
raise PermissionDenied
if control.opened_at:
return redirect("staff_tickets:dispatch_detail", public_id=public_id)
if request.method == "POST":
try:
proof = signing.loads(request.POST.get("confirmation", ""), salt="dispatch-opening", max_age=600)
if proof["user"] != request.user.pk or proof["dispatch"] != str(public_id):
raise signing.BadSignature
open_dispatch_responses(dispatch, request.user, proof["sms"])
except (signing.BadSignature, KeyError):
messages.error(request, "تأیید معتبر نیست یا مهلت آن پایان یافته؛ دوباره بررسی کنید.")
except (ValidationError, ValueError, SMSTemplate.DoesNotExist) as error:
messages.error(request, str(error))
else:
messages.success(request, "پاسخ‌ها هم‌زمان بازگشایی شدند؛ وضعیت پیامک را در گزارش بررسی کنید.")
return redirect("staff_tickets:dispatch_detail", public_id=public_id)
try:
sms = render_opening_sms(dispatch) if control.notify_sms else ""
except (SMSTemplate.DoesNotExist, ValueError):
messages.error(request, "قالب پیامک بازگشایی معتبر یا فعال نیست؛ تنظیمات قالب را بررسی کنید.")
return redirect("staff_tickets:dispatch_detail", public_id=public_id)
from .dispatch_responses import _respondents
proof = signing.dumps({"user": request.user.pk, "dispatch": str(public_id), "sms": sms}, salt="dispatch-opening")
return render(request, "tickets/dispatch_open_confirm.html", {"dispatch": dispatch, "control": control,
"opening_sms": sms, "respondent_count": len(_respondents(dispatch)), "confirmation": proof})
+139
View File
@@ -0,0 +1,139 @@
"""Response confidentiality is independent of ordinary ticket permissions."""
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction
from django.db.models import Count, F, Max, Min, Q
from django.utils import timezone
from .models import DispatchResponseControl, GroupDispatch
from .policies import DispatchPolicy, TicketPolicy
OPENING_EVENT = "DISPATCH_RESPONSES_OPENED"
def response_control(dispatch):
if dispatch is None:
return None
return DispatchResponseControl.objects.filter(dispatch_id=dispatch.pk).first()
def responses_locked(ticket):
control = response_control(ticket.group_dispatch) if ticket.group_dispatch_id else None
return bool(control and control.mode == control.Mode.SEALED and control.opened_at is None)
def sealed_message_q(prefix=""):
return Q(**{
prefix + "ticket__group_dispatch__response_control__mode": "SEALED",
prefix + "ticket__group_dispatch__response_control__opened_at__isnull": True,
prefix + "author_id": F(prefix + "ticket__customer_id"),
})
def can_read_response(user, message):
if message.author_id != message.ticket.customer_id:
return True
if getattr(user, "account_type", None) == "CUSTOMER" and user.pk == message.author_id:
return True
return not responses_locked(message.ticket)
def filter_response_messages(query, user):
if getattr(user, "account_type", None) == "CUSTOMER":
return query.exclude(sealed_message_q() & ~Q(author_id=user.pk))
return query.exclude(sealed_message_q())
def can_record_ticket_read(ticket, user):
if not TicketPolicy.can_view(user, ticket):
return False
if not responses_locked(ticket):
return True
return not ticket.ticket_messages.filter(author_id=ticket.customer_id, deleted_at__isnull=True).exists()
def can_open_responses(user, dispatch):
if not user.is_authenticated or not user.is_active or not DispatchPolicy.can_view(user, dispatch):
return False
profile = getattr(user, "staff_profile", None)
if getattr(user, "account_type", None) != "STAFF" or (profile and not profile.is_active):
return False
return bool(user.is_superuser or dispatch.created_by_id == user.pk or user.has_perm("tickets.open_all_dispatch_responses"))
def render_opening_sms(dispatch):
from apps.notifications.management.commands.process_notifications import _render_sms
return _render_sms(OPENING_EVENT, {}, dispatch.unit)
def _respondents(dispatch):
# A withdrawn/deleted message is not a current response; attachment-only replies count.
return list(dispatch.tickets.filter(ticket_messages__author_id=F("customer_id"),
ticket_messages__deleted_at__isnull=True).distinct().order_by("pk"))
def _queue_opening_sms(control, tickets):
from apps.notifications.services import enqueue
from apps.notifications.models import NotificationJob
for ticket in tickets:
enqueue(NotificationJob.Channel.SMS, OPENING_EVENT, ticket,
f"dispatch:{control.dispatch_id}:opened:{ticket.customer_id}",
customer=ticket.customer,
payload={"dispatch_id": control.dispatch_id, "body": control.sms_body_snapshot})
@transaction.atomic
def open_dispatch_responses(dispatch, user, expected_sms=""):
# Replies take the same control lock before locking their ticket.
control = DispatchResponseControl.objects.select_for_update(of=("self",)).select_related("dispatch__unit").get(dispatch=dispatch)
dispatch = control.dispatch
if not can_open_responses(user, dispatch):
raise PermissionDenied
if control.mode != control.Mode.SEALED:
raise ValidationError("این ارسال در حالت مشاهده جداگانه است.")
if control.opened_at:
return control, False
text = render_opening_sms(dispatch) if control.notify_sms else ""
if text != expected_sms:
raise ValidationError("قالب پیامک تغییر کرده است؛ متن جدید را بررسی و دوباره تأیید کنید.")
tickets = _respondents(dispatch)
control.opened_at = timezone.now()
control.opened_by = user
control.sms_body_snapshot = text
control.respondents_snapshot = [str(ticket.customer.public_id) for ticket in tickets]
control.save(update_fields=["opened_at", "opened_by", "sms_body_snapshot", "respondents_snapshot"])
if control.notify_sms:
_queue_opening_sms(control, tickets)
return control, True
def lock_dispatch_for_ticket(ticket_id):
"""Must be called inside the caller's transaction, before its ticket lock."""
return DispatchResponseControl.objects.select_for_update(of=("self",)).filter(dispatch__tickets__pk=ticket_id).first()
def managed_ticket_q(user):
"""Explicitly related tickets or dispatch visibility; unit-wide scope alone is insufficient."""
dispatches = DispatchPolicy.visible_dispatches(user)
direct = Q(created_by=user) | Q(owner=user) | Q(selected_expert=user)
direct |= Q(access_grants__user=user, access_grants__revoked_at__isnull=True)
return Q(group_dispatch__response_control__isnull=True) | direct | Q(group_dispatch__in=dispatches)
def populate_report_responses(dispatch, tickets, locked):
"""Load report responses in batches; before opening, select metadata only."""
from apps.messaging.models import Message
responses = Message.objects.filter(ticket__group_dispatch=dispatch, author_id=F("ticket__customer_id"), deleted_at__isnull=True)
metadata = {row["ticket_id"]: row for row in responses.values("ticket_id").annotate(
count=Count("pk"), first=Min("created_at"), last=Max("created_at"))}
by_ticket = {}
if not locked:
for message in responses.order_by("created_at", "pk").prefetch_related("attachments"):
by_ticket.setdefault(message.ticket_id, []).append(message)
for ticket in tickets:
row = metadata.get(ticket.pk, {})
ticket.response_count = row.get("count", 0)
ticket.first_response_at = row.get("first")
ticket.last_response_at = row.get("last")
ticket.report_customer_responses = by_ticket.get(ticket.pk, [])
ticket.latest_customer_response = ticket.report_customer_responses[-1] if ticket.report_customer_responses else None
+7
View File
@@ -140,6 +140,8 @@ class StaffCreateTicketForm(forms.Form):
class GroupDispatchForm(forms.Form):
response_mode = forms.ChoiceField(label="نحوه مشاهده پاسخ‌ها", choices=[("SEALED", "بازگشایی یکجا"), ("INDIVIDUAL", "مشاهده جداگانه")], initial="SEALED", required=False, widget=forms.RadioSelect)
notify_opening_sms = forms.BooleanField(label="اطلاع‌رسانی بازگشایی با پیامک", initial=True, required=False)
operation_type = forms.ChoiceField(label="نوع عملیات", choices=(), required=False)
unit = forms.ModelChoiceField(label="واحد ارسال‌کننده", queryset=OrganizationalUnit.objects.none())
contact_group = forms.ModelChoiceField(label="گروه مخاطبان", queryset=ContactGroup.objects.none())
@@ -177,6 +179,11 @@ class GroupDispatchForm(forms.Form):
data["operation_type"] = GroupDispatch.OperationType.BULK
unit = data.get("unit")
group = data.get("contact_group")
data["response_mode"] = data.get("response_mode") or "SEALED"
if data["response_mode"] == "INDIVIDUAL":
data["notify_opening_sms"] = False
if group and group.contacts.filter(user__is_active=True, user__mobile__isnull=False).exclude(user__mobile="").count() > 15:
data["notify_opening_sms"] = False
if unit and group and group.unit_id != unit.pk:
self.add_error("contact_group", "این گروه متعلق به واحد انتخاب‌شده نیست.")
category=data.get("category")
@@ -0,0 +1,43 @@
# Generated by Django 5.1.1 on 2026-10-13 09:53
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
def preserve_used_response_controls(apps, schema_editor):
from django.db.migrations.exceptions import IrreversibleError
if apps.get_model('tickets', 'DispatchResponseControl').objects.using(schema_editor.connection.alias).exists():
raise IrreversibleError('Response controls are in use; refusing to delete confidentiality and opening history.')
class Migration(migrations.Migration):
dependencies = [
('tickets', '0017_ticketaccessgrant_first_viewed_at_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='DispatchResponseControl',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('mode', models.CharField(choices=[('SEALED', 'بازگشایی یکجا'), ('INDIVIDUAL', 'مشاهده جداگانه')], default='SEALED', max_length=12)),
('notify_sms', models.BooleanField(default=True)),
('recipient_count_snapshot', models.PositiveIntegerField()),
('opened_at', models.DateTimeField(blank=True, null=True)),
('sms_body_snapshot', models.TextField(blank=True)),
('respondents_snapshot', models.JSONField(blank=True, default=list)),
('dispatch', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='response_control', to='tickets.groupdispatch')),
('opened_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'تنظیمات و سابقه بازگشایی ارسال',
'verbose_name_plural': 'تنظیمات و سوابق بازگشایی ارسال‌ها',
'permissions': [('open_all_dispatch_responses', 'Can open responses of all authorized dispatches')],
'constraints': [models.CheckConstraint(condition=models.Q(('notify_sms', False), models.Q(('mode', 'SEALED'), ('recipient_count_snapshot__lte', 15)), _connector='OR'), name='dispatch_opening_sms_limit'), models.CheckConstraint(condition=models.Q(('mode__in', ['SEALED', 'INDIVIDUAL'])), name='dispatch_response_mode_valid'), models.CheckConstraint(condition=models.Q(models.Q(('opened_at__isnull', True), ('opened_by__isnull', True)), models.Q(('mode', 'SEALED'), ('opened_at__isnull', False), ('opened_by__isnull', False)), _connector='OR'), name='dispatch_opening_audit_complete')],
},
),
migrations.RunPython(migrations.RunPython.noop, preserve_used_response_controls),
]
+27
View File
@@ -176,3 +176,30 @@ class MessageShare(PublicIdModel, models.Model):
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")]
class DispatchResponseControl(models.Model):
"""Absent for legacy dispatches: their existing visibility is preserved."""
class Mode(models.TextChoices):
SEALED = "SEALED", "بازگشایی یکجا"
INDIVIDUAL = "INDIVIDUAL", "مشاهده جداگانه"
dispatch = models.OneToOneField(GroupDispatch, on_delete=models.PROTECT, related_name="response_control")
mode = models.CharField(max_length=12, choices=Mode.choices, default=Mode.SEALED)
notify_sms = models.BooleanField(default=True)
recipient_count_snapshot = models.PositiveIntegerField()
opened_at = models.DateTimeField(null=True, blank=True)
opened_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True)
sms_body_snapshot = models.TextField(blank=True)
respondents_snapshot = models.JSONField(default=list, blank=True)
class Meta:
verbose_name = "تنظیمات و سابقه بازگشایی ارسال"
verbose_name_plural = "تنظیمات و سوابق بازگشایی ارسال‌ها"
permissions = [("open_all_dispatch_responses", "Can open responses of all authorized dispatches")]
constraints = [
models.CheckConstraint(condition=Q(notify_sms=False) | Q(mode="SEALED", recipient_count_snapshot__lte=15), name="dispatch_opening_sms_limit"),
models.CheckConstraint(condition=Q(mode__in=["SEALED", "INDIVIDUAL"]), name="dispatch_response_mode_valid"),
models.CheckConstraint(condition=Q(opened_at__isnull=True, opened_by__isnull=True) | Q(mode="SEALED", opened_at__isnull=False, opened_by__isnull=False), name="dispatch_opening_audit_complete"),
]
+46
View File
@@ -70,6 +70,20 @@ class TicketPolicy:
}
@classmethod
def can_view(cls,user,ticket):
from .models import DispatchResponseControl
if ticket.group_dispatch_id and DispatchResponseControl.objects.filter(dispatch_id=ticket.group_dispatch_id).exists():
if not user.is_authenticated:
return False
if cls.is_customer(user):
return ticket.customer_id == user.pk
if DispatchPolicy.can_view(user, ticket.group_dispatch):
return True
return bool(ticket.created_by_id == user.pk or ticket.owner_id == user.pk or ticket.selected_expert_id == user.pk
or ticket.access_grants.filter(user=user, revoked_at__isnull=True).exists())
return cls._legacy_can_view(user, ticket)
@classmethod
def _legacy_can_view(cls,user,ticket):
if not user.is_authenticated: return False
if cls.is_customer(user): return ticket.customer_id==user.id
if user.is_superuser or user.has_perm("tickets.view_all_tickets"): return True
@@ -83,6 +97,11 @@ class TicketPolicy:
return False
@classmethod
def viewable_q(cls, user):
from .dispatch_responses import managed_ticket_q
return (cls._legacy_viewable_q(user) | Q(group_dispatch__in=DispatchPolicy.visible_dispatches(user))) & managed_ticket_q(user)
@classmethod
def _legacy_viewable_q(cls, user):
"""Database equivalent of can_view; use it for every staff ticket collection."""
if user.is_superuser or user.has_perm("tickets.view_all_tickets"):
return Q()
@@ -169,5 +188,32 @@ class DispatchPolicy:
@classmethod
def can_view(cls, user, dispatch):
if dispatch.created_by_id == user.id:
from .models import DispatchResponseControl
if DispatchResponseControl.objects.filter(dispatch_id=dispatch.pk).exists():
return bool(user.is_authenticated and user.is_active and getattr(user, "account_type", None) == "STAFF")
return cls.can_create(user, dispatch.unit, dispatch.operation_type)
return cls.can_view_all(user, dispatch.operation_type, dispatch.unit)
@classmethod
def visible_dispatches(cls, user):
from .models import GroupDispatch
if user.is_superuser:
return GroupDispatch.objects.all()
visibility = Q(created_by=user, response_control__isnull=False)
memberships = list(user.unit_memberships.filter(is_active=True, unit__is_active=True).select_related("unit"))
for operation_type in GroupDispatch.OperationType.values:
if cls.can_view_all(user, operation_type):
visibility |= Q(operation_type=operation_type)
continue
view_units = [item.unit_id for item in memberships if cls.can_view_all(user, operation_type, item.unit)]
create_units = [item.unit_id for item in memberships if cls.can_create(user, item.unit, operation_type)]
visibility |= Q(operation_type=operation_type, unit_id__in=view_units)
visibility |= Q(operation_type=operation_type, unit_id__in=create_units, created_by=user)
return GroupDispatch.objects.filter(visibility)
@classmethod
def can_list(cls, user):
if any(cls.can_view_all(user, kind) for kind in cls.VIEW_ALL_PERMISSIONS):
return True
units = user.unit_memberships.filter(is_active=True, unit__is_active=True).select_related("unit")
return any(cls.can_view_all(user, kind, item.unit) for item in units for kind in cls.VIEW_ALL_PERMISSIONS)
+22
View File
@@ -0,0 +1,22 @@
"""Even superusers use the response-content gate in the application admin."""
from django.contrib import admin
from django.db.models import Q
from .dispatch_responses import sealed_message_q
from .models import Ticket
from .policies import TicketPolicy
class ResponseContentAdmin(admin.ModelAdmin):
message_prefix = ""
def get_queryset(self, request):
query = super().get_queryset(request).exclude(sealed_message_q(self.message_prefix))
ticket_path = self.message_prefix + "ticket"
allowed = Ticket.objects.filter(TicketPolicy.viewable_q(request.user)).values("pk")
return query.filter(Q(**{ticket_path + "__group_dispatch__response_control__isnull": True})
| Q(**{ticket_path + "__in": allowed})).distinct()
class RelatedResponseContentAdmin(ResponseContentAdmin):
message_prefix = "message__"
+22 -1
View File
@@ -63,6 +63,7 @@ def dispatch_group_tickets(*, user, unit, contact_group, category, subject, body
)
if not contacts:
raise ValidationError("گروه انتخاب‌شده مخاطب فعال دارای شماره همراه ندارد.")
from .models import DispatchResponseControl
dispatch = GroupDispatch.objects.create(
unit=unit,
operation_type=operation_type,
@@ -74,6 +75,9 @@ def dispatch_group_tickets(*, user, unit, contact_group, category, subject, body
status=GroupDispatch.Status.QUEUED,
recipient_count=len(contacts),
)
DispatchResponseControl.objects.create(dispatch=dispatch, mode="SEALED",
notify_sms=len(contacts) <= 15,
recipient_count_snapshot=len(contacts))
tickets=[]
for contact in contacts:
for uploaded in files:
@@ -97,6 +101,20 @@ def dispatch_group_tickets(*, user, unit, contact_group, category, subject, body
return dispatch, tickets
@transaction.atomic
def create_group_dispatch(*, response_mode="SEALED", notify_opening_sms=True, **dispatch_fields):
"""Configure responses without changing the existing creation API."""
from .models import DispatchResponseControl
if response_mode not in DispatchResponseControl.Mode.values:
raise ValidationError("روش مشاهده پاسخ‌ها معتبر نیست.")
dispatch, tickets = dispatch_group_tickets(**dispatch_fields)
control = DispatchResponseControl.objects.get(dispatch=dispatch)
control.mode = response_mode
control.notify_sms = bool(notify_opening_sms and response_mode == "SEALED" and control.recipient_count_snapshot <= 15)
control.save(update_fields=["mode", "notify_sms"])
return dispatch, tickets
def refresh_group_dispatch_status(dispatch_id):
"""Persist delivery state independently of whether a report page is opened."""
dispatch=GroupDispatch.objects.filter(pk=dispatch_id).first()
@@ -194,6 +212,8 @@ def revoke_access(ticket,user,target):
@transaction.atomic
def reply_ticket(*,ticket_id,user,body="",files=(),internal=False,mentioned_users=()):
from .dispatch_responses import lock_dispatch_for_ticket
control = lock_dispatch_for_ticket(ticket_id)
ticket=Ticket.objects.select_for_update().get(pk=ticket_id)
if not TicketPolicy.can_reply(user,ticket): raise PermissionDenied
if internal and (TicketPolicy.is_customer(user) or not user.has_perm("messaging.add_internal_note")): raise PermissionDenied
@@ -204,7 +224,8 @@ def reply_ticket(*,ticket_id,user,body="",files=(),internal=False,mentioned_user
if not TicketPolicy.can_view(target,ticket):
if TicketPolicy.can_share(user,ticket): share_ticket(ticket,user,target)
else: raise PermissionDenied("کارشناس Mention‌شده به تیکت دسترسی ندارد.")
MessageMention.objects.get_or_create(message=msg,mentioned_user=target); enqueue(NotificationJob.Channel.BALE,"MENTION",ticket,f"message:{msg.pk}:mention:{target.pk}",staff_user=target,payload={"excerpt":body[:150]})
excerpt = "پاسخ محرمانه ثبت شد." if control and control.mode == "SEALED" and not control.opened_at and user.pk == ticket.customer_id else body[:150]
MessageMention.objects.get_or_create(message=msg,mentioned_user=target); enqueue(NotificationJob.Channel.BALE,"MENTION",ticket,f"message:{msg.pk}:mention:{target.pk}",staff_user=target,payload={"excerpt":excerpt})
now=timezone.now(); ticket.last_activity_at=now
if not internal:
if TicketPolicy.is_customer(user):
+5 -1
View File
@@ -66,6 +66,9 @@ def share_staff_ticket(ticket, actor, target, level, note=""):
@transaction.atomic
def share_staff_message(message, actor, target, note=""):
from .dispatch_responses import can_read_response
if not can_read_response(actor, message):
raise PermissionDenied("پاسخ تا بازگشایی قابل اشتراک نیست.")
ticket = Ticket.objects.select_for_update().get(pk=message.ticket_id)
require_share_authority(actor, ticket)
validate_recipient(actor, target)
@@ -147,11 +150,12 @@ def staff_message_context(ticket, actor, conversation=()):
def sharing_context(ticket, actor):
from .dispatch_responses import sealed_message_q
if not TicketPolicy.can_share(actor, ticket):
return {}
return {
"grants": ticket.access_grants.select_related("user__staff_profile", "granted_by__staff_profile", "revoked_by__staff_profile").order_by("-created_at", "-pk"),
"message_shares": MessageShare.objects.filter(message__ticket=ticket).select_related("message", "user__staff_profile", "granted_by__staff_profile", "revoked_by__staff_profile"),
"message_shares": MessageShare.objects.filter(message__ticket=ticket).exclude(sealed_message_q("message__")).select_related("message", "user__staff_profile", "granted_by__staff_profile", "revoked_by__staff_profile"),
"sharing_events": sharing_events(ticket),
}
+8 -11
View File
@@ -1,17 +1,15 @@
"""Routes available only to staff, including isolated message attachment delivery."""
import mimetypes
from django.conf import settings
from django.contrib import messages
from django.db import transaction
from django.utils import timezone
from django.core.exceptions import PermissionDenied, ValidationError
from django.http import FileResponse, JsonResponse
from django.http import 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.delivery import attachment_response
from apps.attachments.models import Attachment
from .models import MessageShare, Ticket, TicketReadState
from .policies import TicketPolicy
@@ -22,8 +20,11 @@ from .views import staff_required, mark_messages_read
def received_message_share(request, share_id):
from .dispatch_responses import can_read_response
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 not can_read_response(request.user, grant.message):
raise PermissionDenied
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
@@ -72,6 +73,7 @@ def shared_message(request, share_id):
@never_cache
@transaction.atomic
def read_shared_messages(request, public_id):
from .dispatch_responses import can_record_ticket_read
ticket = get_object_or_404(Ticket.objects.select_for_update(), public_id=public_id)
allowed = visible_messages(ticket, request.user)
try:
@@ -86,7 +88,7 @@ def read_shared_messages(request, public_id):
if grant.first_viewed_at is None:
mark_share_opened(grant, request.user)
latest = allowed.filter(deleted_at__isnull=True).order_by('-created_at', '-pk').first()
if latest and latest.public_id in accepted and TicketPolicy.can_view(request.user, ticket):
if latest and latest.public_id in accepted and can_record_ticket_read(ticket, request.user):
TicketReadState.objects.update_or_create(ticket=ticket, user=request.user,
defaults={'last_read_at': timezone.now(), 'last_read_message': latest})
return JsonResponse({"unread_shares": unread_shares(request.user), "read_ids": [str(value) for value in accepted]})
@@ -105,9 +107,4 @@ def unread_count(request):
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
return attachment_response(attachment, download=download)
+4 -1
View File
@@ -12,7 +12,9 @@ from .sharing import staff_message_context
def received_messages(user, ticket=None):
from .dispatch_responses import sealed_message_q
grants = MessageShare.objects.filter(user=user, revoked_at__isnull=True, message__deleted_at__isnull=True)
grants = grants.exclude(sealed_message_q("message__"))
return grants.filter(message__ticket=ticket) if ticket is not None else grants
@@ -28,7 +30,8 @@ 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()
from .dispatch_responses import filter_response_messages
query = filter_response_messages(ticket.ticket_messages.all(), user)
if TicketPolicy.can_view(user, ticket):
return query
grants = received_messages(user, ticket)
+2 -1
View File
@@ -1,9 +1,10 @@
from django.urls import path
from . import views, sharing_views
from . import views, sharing_views, dispatch_response_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("dispatches/<uuid:public_id>/open-responses/", dispatch_response_views.open_responses, name="dispatch_open_responses"),
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"),
+37 -33
View File
@@ -38,7 +38,7 @@ from .sharing import mark_ticket_share_opened, require_share_authority, revoke_s
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,transfer_ticket
from .services import change_priority,change_status,claim_ticket,create_ticket,create_group_dispatch,refresh_group_dispatch_status,reply_ticket,resolve_group_dispatch_tickets,transfer_ticket
MESSAGE_PAGE_SIZE=50
SMS_RETRYABLE_DELIVERY_STATUSES=(
NotificationJob.DeliveryStatus.UNDELIVERED,
@@ -122,11 +122,14 @@ def conversation_version(ticket,latest,include_internal=False):
query=ticket.ticket_messages.all()
if not include_internal: query=query.filter(is_internal=False)
edits=query.aggregate(edited=Max('edited_at'),deleted=Max('deleted_at'))
revision=hashlib.sha256(repr(edits).encode()).hexdigest()[:20]
from .dispatch_responses import response_control
control = response_control(ticket.group_dispatch) if ticket.group_dispatch_id else None
revision=hashlib.sha256(repr((edits, control.opened_at if control else None)).encode()).hexdigest()[:20]
source=f"{ticket.updated_at.isoformat()}:{latest.public_id if latest else ''}:{latest.updated_at.isoformat() if latest else ''}"
return hashlib.sha256(source.encode()).hexdigest()[:20]+':'+revision
def conversation_poll_response(request,ticket,actor,include_internal=False):
qs=ticket.ticket_messages.all()
from .dispatch_responses import filter_response_messages
qs=filter_response_messages(ticket.ticket_messages.all(), actor)
if not include_internal: qs=qs.filter(is_internal=False)
latest=qs.order_by("-created_at","-id").only("id","public_id","updated_at").first()
version=conversation_version(ticket,latest,include_internal=include_internal)
@@ -458,7 +461,7 @@ def staff_detail(request,public_id):
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_ticket_share_opened(ticket,request.user)
message_sharing=message_context(ticket,request.user,conversation)
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]
history=Ticket.objects.filter(TicketPolicy.viewable_q(request.user), customer=ticket.customer).exclude(pk=ticket.pk).select_related("owner__staff_profile","selected_expert__staff_profile").order_by("-last_activity_at").distinct()[: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_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)
@@ -469,6 +472,7 @@ def staff_detail(request,public_id):
sms_job.sms_retryable=can_retry_sms and _sms_job_is_retryable(sms_job)
latest=conversation[-1] if conversation else None
group_dispatch = ticket.group_dispatch
message_sharing["can_view_dispatch"] = bool(group_dispatch and DispatchPolicy.can_view(request.user, 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
customer_notices = payment_selectors.notices_with_totals(
@@ -613,10 +617,19 @@ def staff_delete(request, public_id):
@staff_required
@require_http_methods(["GET", "POST"])
def staff_dispatch_create(request):
from .dispatch_presentation import dispatch_form_options
create_unit_ids = {
unit.pk for unit in OrganizationalUnit.objects.filter(pk__in=TicketPolicy.unit_ids(request.user), is_active=True)
if any(DispatchPolicy.can_create(request.user, unit, kind) for kind in GroupDispatch.OperationType.values)
}
base_dispatches = _visible_dispatches(request.user)
if not create_unit_ids and (request.method == "POST" or not (DispatchPolicy.can_list(request.user) or base_dispatches.exists())):
raise PermissionDenied
form = GroupDispatchForm(request.POST or None, request.FILES or None, user=request.user)
if request.method == "POST" and form.is_valid():
try:
dispatch, _ = dispatch_group_tickets(
dispatch, _ = create_group_dispatch(
user=request.user,
unit=form.cleaned_data["unit"],
contact_group=form.cleaned_data["contact_group"],
@@ -624,6 +637,8 @@ def staff_dispatch_create(request):
subject=form.cleaned_data["subject"],
body=form.cleaned_data["body"],
operation_type=form.cleaned_data["operation_type"],
response_mode=form.cleaned_data["response_mode"],
notify_opening_sms=form.cleaned_data["notify_opening_sms"],
tags=form.cleaned_data["tags"],
files=request.FILES.getlist("files"),
)
@@ -631,13 +646,6 @@ def staff_dispatch_create(request):
return redirect("staff_tickets:dispatch_detail", public_id=dispatch.public_id)
except (PermissionDenied, ValidationError) as exc:
form.add_error(None, str(exc))
create_unit_ids = {
unit.pk for unit in OrganizationalUnit.objects.filter(pk__in=TicketPolicy.unit_ids(request.user), is_active=True)
if any(DispatchPolicy.can_create(request.user, unit, kind) for kind in GroupDispatch.OperationType.values)
}
if not create_unit_ids:
raise PermissionDenied
base_dispatches = _visible_dispatches(request.user)
metrics_row = base_dispatches.aggregate(total=Count("id"), recipients=Sum("recipient_count"))
metrics = {
"total": metrics_row["total"] or 0,
@@ -672,19 +680,27 @@ def staff_dispatch_create(request):
return render(request, "tickets/staff_dispatch_create.html", {
"form": form, "dispatches": page.object_list, "page": page, "metrics": metrics,
"units": units, "groups": groups, "dispatch_statuses": GroupDispatch.Status.choices,
"dispatch_options": dispatch_form_options(request.user, units, groups),
"can_create_dispatch": bool(create_unit_ids),
"history_units": OrganizationalUnit.objects.filter(pk__in=base_dispatches.values("unit_id")),
"history_groups": ContactGroup.objects.filter(pk__in=base_dispatches.values("contact_group_id")),
"active_staff_section": "dispatches", "active_tab_title": "ارسال گروهی و ردیابی",
})
@staff_required
def staff_dispatch_detail(request, public_id):
from .dispatch_responses import response_control, can_open_responses, populate_report_responses
dispatch = get_object_or_404(
GroupDispatch.objects.select_related("unit", "contact_group", "created_by"),
public_id=public_id,
)
if not DispatchPolicy.can_view(request.user, dispatch):
raise PermissionDenied
tickets = list(dispatch.tickets.select_related("customer__customer_profile").prefetch_related("magic_links", "ticket_messages", "notificationjob_set").order_by("customer_id"))
control = response_control(dispatch)
locked = bool(control and control.mode == "SEALED" and not control.opened_at)
tickets = list(dispatch.tickets.select_related("customer__customer_profile").prefetch_related("magic_links", "notificationjob_set").order_by("customer_id"))
populate_report_responses(dispatch, tickets, locked)
delivery_failures = {
NotificationJob.DeliveryStatus.UNDELIVERED,
NotificationJob.DeliveryStatus.EXPIRED,
@@ -696,7 +712,6 @@ def staff_dispatch_detail(request, public_id):
for ticket in tickets:
ticket.sms_job = next((job for job in ticket.notificationjob_set.all() if job.channel == NotificationJob.Channel.SMS and job.event_type == SMSTemplate.Event.STAFF_TICKET_CREATED), None)
ticket.sms_retryable = bool(ticket.sms_job and (ticket.sms_job.status == NotificationJob.Status.FAILED or (ticket.sms_job.delivery_status in delivery_failures and not ticket.sms_job.delivery_next_check_at)))
ticket.latest_customer_response = next((message for message in reversed(list(ticket.ticket_messages.all())) if message.author_id == ticket.customer_id), None)
links = list(ticket.magic_links.all())
ticket.magic_total_views = sum(link.view_count for link in links)
ticket.magic_link_count = len(links)
@@ -704,17 +719,17 @@ def staff_dispatch_detail(request, public_id):
ticket.magic_last_used_at = max((link.used_at for link in links if link.used_at), default=None)
job = ticket.sms_job
if not job:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در صف ثبت نشده", "برای این مخاطب job پیامک پیدا نشد.", "neutral"
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در صف ثبت نشده", "برای این مخاطب سابقه ارسال پیامک ثبت نشده است.", "neutral"
elif job.status == NotificationJob.Status.FAILED:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "ارسال ناموفق", f"ارسال خودکار پس از {1 + settings.SMS_MAX_RETRIES} تلاش متوقف شده است.", "danger"
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "ارسال ناموفق", "ارسال خودکار متوقف شده است؛ علت خطا و امکان تلاش مجدد را در ادامه بررسی کنید.", "danger"
elif job.status == NotificationJob.Status.RETRYING:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در انتظار تلاش مجدد", "worker پیامک را در موعد بعدی دوباره ارسال می‌کند.", "warning"
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در انتظار تلاش مجدد", "سامانه پیامک را در موعد بعدی دوباره ارسال می‌کند.", "warning"
elif job.status in (NotificationJob.Status.PENDING, NotificationJob.Status.PROCESSING):
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در صف ارسال", "پیامک هنوز به درگاه تحویل نشده است.", "info"
elif job.delivery_status == NotificationJob.DeliveryStatus.DELIVERED:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "تحویل به گوشی تأیید شد", "اپراتور دریافت پیامک توسط مخاطب را تأیید کرده است.", "success"
elif job.delivery_status in (NotificationJob.DeliveryStatus.PENDING, NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.UNDELIVERED, NotificationJob.DeliveryStatus.CHECK_FAILED) and job.delivery_next_check_at:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در حال پیگیری تحویل", "نتیجه فعلی قطعی نیست و worker استعلام را ادامه می‌دهد.", "warning"
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در حال پیگیری تحویل", "نتیجه فعلی قطعی نیست؛ پیگیری خودکار تحویل ادامه دارد.", "warning"
elif job.delivery_status == NotificationJob.DeliveryStatus.BLOCKED:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "دریافت پیامک مسدود است", "شماره مخاطب دریافت پیامک تبلیغاتی را بسته است.", "danger"
elif job.delivery_status == NotificationJob.DeliveryStatus.EXPIRED:
@@ -732,14 +747,15 @@ def staff_dispatch_detail(request, public_id):
tracking = sms_jobs.filter(delivery_next_check_at__isnull=False).count()
opened = TicketMagicLink.objects.filter(ticket__group_dispatch=dispatch, view_count__gt=0).values("ticket_id").distinct().count()
total_views = TicketMagicLink.objects.filter(ticket__group_dispatch=dispatch).aggregate(total=Sum("view_count"))["total"] or 0
replied = dispatch.tickets.filter(last_customer_message_at__isnull=False).count()
replied = sum(bool(ticket.response_count) for ticket in tickets)
refresh_group_dispatch_status(dispatch.pk); dispatch.refresh_from_db(fields=["status"])
pending = sum(sms_counts.get(status, 0) for status in (NotificationJob.Status.PENDING, NotificationJob.Status.PROCESSING, NotificationJob.Status.RETRYING))
delivery_percent = round((delivered / dispatch.recipient_count) * 100) if dispatch.recipient_count else 0
metrics = {"sent": sent, "delivered": delivered, "failed": failed, "retrying": retrying, "tracking": tracking, "pending": pending, "opened": opened, "not_opened": max(dispatch.recipient_count-opened,0), "total_views": total_views, "replied": replied, "delivery_percent": delivery_percent}
open_ticket_count = sum(ticket.status not in (Ticket.Status.RESOLVED, Ticket.Status.CLOSED) for ticket in tickets)
can_resolve_dispatch = request.user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(request.user, dispatch.unit, "tickets.change_ticket")
return render(request, "tickets/staff_dispatch_detail.html", {"dispatch": dispatch, "tickets": tickets, "metrics": metrics, "open_ticket_count": open_ticket_count, "can_resolve_dispatch": can_resolve_dispatch, "sms_max_attempts": 1 + settings.SMS_MAX_RETRIES, "active_staff_section": "dispatches"})
opening_jobs = NotificationJob.objects.filter(ticket__group_dispatch=dispatch, event_type="DISPATCH_RESPONSES_OPENED").values("status").annotate(count=Count("pk"))
return render(request, "tickets/staff_dispatch_detail.html", {"dispatch": dispatch, "tickets": tickets, "metrics": metrics, "open_ticket_count": open_ticket_count, "can_resolve_dispatch": can_resolve_dispatch, "sms_max_attempts": 1 + settings.SMS_MAX_RETRIES, "active_staff_section": "dispatches", "response_control": control, "responses_locked": locked, "can_open_responses": can_open_responses(request.user, dispatch), "opening_jobs": list(opening_jobs)})
@staff_required
@@ -782,19 +798,7 @@ def _unit_ids_with_permission(user, permission):
def _visible_dispatches(user):
if user.is_superuser:
return GroupDispatch.objects.all()
visibility = Q(pk__in=[])
memberships = list(user.unit_memberships.filter(is_active=True, unit__is_active=True).select_related("unit"))
for operation_type in GroupDispatch.OperationType.values:
if DispatchPolicy.can_view_all(user, operation_type):
visibility |= Q(operation_type=operation_type)
continue
view_all_units = [item.unit_id for item in memberships if DispatchPolicy.can_view_all(user, operation_type, item.unit)]
create_units = [item.unit_id for item in memberships if DispatchPolicy.can_create(user, item.unit, operation_type)]
visibility |= Q(operation_type=operation_type, unit_id__in=view_all_units)
visibility |= Q(operation_type=operation_type, unit_id__in=create_units, created_by=user)
return GroupDispatch.objects.filter(visibility)
return DispatchPolicy.visible_dispatches(user)
CONTACT_AUDIT_FIELDS = {