estelam ticket, varizha , others
This commit is contained in:
1 parent
35a1ec1794
commit
6b546aad76
71 files changed
+2865
-191
No files matched your search
+40
-1
@@ -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):
|
||||
|
||||
@@ -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")),
|
||||
}
|
||||
@@ -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})
|
||||
@@ -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
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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__"
|
||||
@@ -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):
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user