estelam ticket, varizha , others
This commit is contained in:
1 parent
35a1ec1794
commit
6b546aad76
71 files changed
+2865
-191
No files matched your search
@@ -54,4 +54,5 @@ class AttachmentPolicyAdmin(admin.ModelAdmin):
|
||||
form = AttachmentPolicyAdminForm
|
||||
|
||||
|
||||
admin.site.register(Attachment)
|
||||
from apps.tickets.response_admin import RelatedResponseContentAdmin
|
||||
admin.site.register(Attachment, RelatedResponseContentAdmin)
|
||||
@@ -19,3 +19,16 @@ def signed_media_redirect(relative_path, *, download=False, lifetime=300):
|
||||
if download:
|
||||
query["download"] = "1"
|
||||
return HttpResponseRedirect(f"{uri}?{urlencode(query)}")
|
||||
|
||||
|
||||
def attachment_response(attachment, *, download=False):
|
||||
"""Deliver authorized bytes; confidential responses never use bearer URLs."""
|
||||
import mimetypes
|
||||
from django.http import FileResponse
|
||||
if settings.USE_NGINX_MEDIA and not attachment.relative_path.startswith("dispatch-responses/"):
|
||||
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
|
||||
@@ -5,6 +5,10 @@ from django.db import models
|
||||
from apps.core.models import PublicIdModel
|
||||
def attachment_path(instance,filename):
|
||||
ext=Path(filename).suffix.lower(); t=instance.message.ticket; m=instance.message
|
||||
from apps.tickets.dispatch_responses import response_control
|
||||
control = response_control(t.group_dispatch) if t.group_dispatch_id else None
|
||||
if m.author_id == t.customer_id and control and control.mode == "SEALED":
|
||||
return f"dispatch-responses/{t.public_id}/{m.public_id}/{uuid.uuid4().hex}{ext}"
|
||||
return f"tickets/{t.created_at:%Y/%m}/{t.public_id}/{m.public_id}/{uuid.uuid4().hex}{ext}"
|
||||
class AttachmentPolicy(models.Model):
|
||||
attachment_type=models.CharField(max_length=20,unique=True); is_enabled=models.BooleanField(default=True); max_file_size=models.PositiveBigIntegerField(); max_files_per_message=models.PositiveSmallIntegerField(default=10); allowed_extensions=models.JSONField(default=list); allowed_mime_types=models.JSONField(default=list); sort_order=models.PositiveIntegerField(default=0); updated_at=models.DateTimeField(auto_now=True)
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
import mimetypes
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import FileResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from apps.tickets.policies import TicketPolicy
|
||||
from apps.accounts.magic_access import get_ticket_magic_access
|
||||
from .delivery import signed_media_redirect
|
||||
from .delivery import attachment_response
|
||||
from .models import Attachment
|
||||
from django.views.decorators.cache import never_cache
|
||||
def _authorized_attachment(request,public_id):
|
||||
attachment=get_object_or_404(Attachment.objects.select_related("message__ticket"),public_id=public_id); ticket=attachment.message.ticket
|
||||
magic_link = None if request.user.is_authenticated else get_ticket_magic_access(request,ticket)
|
||||
if not TicketPolicy.can_view(request.user,ticket) and not magic_link: raise PermissionDenied
|
||||
from apps.tickets.dispatch_responses import can_read_response
|
||||
actor = ticket.customer if magic_link else request.user
|
||||
if not can_read_response(actor, attachment.message): raise PermissionDenied
|
||||
if attachment.message.is_internal and (magic_link or getattr(request.user,"account_type",None)=="CUSTOMER"): raise PermissionDenied
|
||||
if attachment.message.deleted_at and not request.user.has_perm("messaging.view_deleted_message_content"): raise PermissionDenied
|
||||
return attachment
|
||||
@never_cache
|
||||
def view(request,public_id):
|
||||
attachment=_authorized_attachment(request,public_id)
|
||||
if settings.USE_NGINX_MEDIA:
|
||||
return signed_media_redirect(attachment.relative_path)
|
||||
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)
|
||||
response["Content-Disposition"]=f'inline; filename="{attachment.original_name}"'
|
||||
response["X-Content-Type-Options"]="nosniff"
|
||||
return response
|
||||
return attachment_response(_authorized_attachment(request, public_id))
|
||||
@never_cache
|
||||
def download(request,public_id):
|
||||
attachment=_authorized_attachment(request,public_id)
|
||||
if settings.USE_NGINX_MEDIA:
|
||||
return signed_media_redirect(attachment.relative_path, download=True)
|
||||
return FileResponse(attachment.file.open("rb"),as_attachment=True,filename=attachment.original_name)
|
||||
return attachment_response(_authorized_attachment(request, public_id), download=True)
|
||||
@@ -64,7 +64,7 @@ def staff_operations_access(request):
|
||||
"create_ticket": user.has_perm("tickets.add_ticket") or allowed("tickets.add_ticket"),
|
||||
"contacts": True,
|
||||
"contact_groups": allowed("customers.view_contactgroup") or allowed("customers.add_contactgroup"),
|
||||
"dispatches": allowed("tickets.create_bulk_dispatch") or allowed("tickets.create_inquiry") or allowed("tickets.dispatch_group_tickets"),
|
||||
"dispatches": any(allowed(permission) for permission in ("tickets.create_bulk_dispatch", "tickets.create_inquiry", "tickets.dispatch_group_tickets", "tickets.view_all_bulk_dispatches", "tickets.view_all_inquiries")),
|
||||
"templates": allowed("notifications.view_smstemplate") or allowed("notifications.add_smstemplate"),
|
||||
"audit": user.has_perm("accounts.view_all_magic_links") or allowed("accounts.view_ticketmagiclink"),
|
||||
}}
|
||||
@@ -16,6 +16,14 @@ from apps.core.production_seed import sha256_file
|
||||
from apps.tickets.models import Ticket
|
||||
|
||||
|
||||
def _load_seed_data(bundle_dir):
|
||||
# Only the pristine-target guard in handle authorizes this replacement.
|
||||
call_command("flush", interactive=False, verbosity=0)
|
||||
call_command("loaddata", str(bundle_dir / "data.json.gz"), verbosity=1)
|
||||
from apps.notifications.dispatch_opening import ensure_opening_template
|
||||
ensure_opening_template()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Import a verified production seed bundle into a pristine PostgreSQL database."
|
||||
|
||||
@@ -80,10 +88,7 @@ class Command(BaseCommand):
|
||||
copied_files = []
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# Migrations contain defaults. Flush those rows before loading
|
||||
# the authoritative SQLite snapshot; schema/migration history remains.
|
||||
call_command("flush", interactive=False, verbosity=0)
|
||||
call_command("loaddata", str(bundle_dir / "data.json.gz"), verbosity=1)
|
||||
_load_seed_data(bundle_dir)
|
||||
# Old SQLite snapshots may predate unit-scoped queues. A
|
||||
# category already belongs to exactly one unit, so it is a
|
||||
# deterministic and safe source for repairing those rows.
|
||||
|
||||
@@ -16,6 +16,7 @@ SEED_MODEL_LABELS = (
|
||||
"tickets.SLAConfig",
|
||||
"tickets.TicketTag",
|
||||
"tickets.GroupDispatch",
|
||||
"tickets.DispatchResponseControl",
|
||||
"tickets.Ticket",
|
||||
"tickets.TicketAccessGrant",
|
||||
"tickets.TicketEvent",
|
||||
|
||||
@@ -46,6 +46,14 @@ def jalali_date(value):
|
||||
return _persian_digits(converted.strftime("%d %B %Y")) if converted else ""
|
||||
|
||||
|
||||
@register.filter
|
||||
def jalali_datetime_precise(value):
|
||||
converted = _jalali(value)
|
||||
if not isinstance(converted, jdatetime.datetime):
|
||||
return ""
|
||||
return _persian_digits(converted.strftime("%d %B %Y، ساعت %H:%M:%S"))
|
||||
|
||||
|
||||
@register.filter
|
||||
def persian_time(value):
|
||||
"""Display local Iran time using Persian digits."""
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from django.contrib import admin
|
||||
from .models import Message,MessageMention,MessageRevision
|
||||
admin.site.register([Message,MessageMention,MessageRevision])
|
||||
from apps.tickets.response_admin import ResponseContentAdmin, RelatedResponseContentAdmin
|
||||
admin.site.register(Message, ResponseContentAdmin)
|
||||
admin.site.register([MessageMention, MessageRevision], RelatedResponseContentAdmin)
|
||||
@@ -6,6 +6,8 @@ from apps.tickets.policies import TicketPolicy
|
||||
from .models import Message,MessageRevision
|
||||
@transaction.atomic
|
||||
def edit_message(message_id,user,body):
|
||||
from apps.tickets.dispatch_responses import lock_dispatch_for_ticket
|
||||
lock_dispatch_for_ticket(Message.objects.values_list("ticket_id", flat=True).get(pk=message_id))
|
||||
msg=Message.objects.select_for_update().select_related("ticket").get(pk=message_id)
|
||||
if msg.author_id!=user.id or not TicketPolicy.can_view(user,msg.ticket): raise PermissionDenied
|
||||
if not (user.account_type=="CUSTOMER" or user.has_perm("messaging.edit_own_message")): raise PermissionDenied
|
||||
@@ -15,6 +17,8 @@ def edit_message(message_id,user,body):
|
||||
MessageRevision.objects.create(message=msg,body=msg.body,edited_by=user); msg.body=body; msg.edited_at=timezone.now(); msg.save(update_fields=["body","edited_at","updated_at"]); return msg
|
||||
@transaction.atomic
|
||||
def soft_delete_message(message_id,user):
|
||||
from apps.tickets.dispatch_responses import lock_dispatch_for_ticket
|
||||
lock_dispatch_for_ticket(Message.objects.values_list("ticket_id", flat=True).get(pk=message_id))
|
||||
msg=Message.objects.select_for_update().select_related("ticket").get(pk=message_id)
|
||||
may_delete=user.account_type=="CUSTOMER" or user.has_perm("messaging.delete_own_message")
|
||||
if msg.author_id!=user.id or not may_delete or not TicketPolicy.can_view(user,msg.ticket): raise PermissionDenied
|
||||
|
||||
@@ -23,6 +23,8 @@ def delete(request,public_id):
|
||||
msg=get_object_or_404(Message,public_id=public_id); soft_delete_message(msg.pk,request.user); return redirect(_safe_next(request,"/staff/"))
|
||||
@login_required
|
||||
def revisions(request,public_id):
|
||||
from apps.tickets.dispatch_responses import can_read_response
|
||||
msg=get_object_or_404(Message,public_id=public_id)
|
||||
if not can_read_response(request.user, msg): raise PermissionDenied
|
||||
if not request.user.has_perm("messaging.view_message_revisions") or not TicketPolicy.can_view(request.user,msg.ticket): raise PermissionDenied
|
||||
return render(request,"messaging/revisions.html",{"message":msg,"revisions":msg.revisions.select_related("edited_by")})
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Isolated worker handler; existing events keep their delivery flow."""
|
||||
from django.core.exceptions import ValidationError
|
||||
from apps.tickets.models import DispatchResponseControl
|
||||
|
||||
|
||||
DEFAULT_OPENING_SMS = (
|
||||
"لوله گستر گلپایگان با سلام؛ پاسخ استعلام شما بازگشایی شد. جهت صیانت از رقابت عادلانه، تمامی پیشنهادات تا این لحظه کاملاً محرمانه بوده و هیچکس دسترسی به مشاهده آنها نداشته است. اکنون تمامی پاسخها بهصورت همزمان بازگشایی شدند تا سلامت فرآیند تضمین گردد.\n"
|
||||
"از همراهی و اعتماد شما سپاسگزاریم. واحد فناوری اطلاعات لوله گستر گلپایگان"
|
||||
)
|
||||
|
||||
|
||||
def ensure_opening_template():
|
||||
"""Legacy seed bundles may predate this event; never replace an existing body."""
|
||||
from .models import SMSTemplate
|
||||
return SMSTemplate.objects.get_or_create(unit=None, event_type="DISPATCH_RESPONSES_OPENED", defaults={
|
||||
"title": "بازگشایی پاسخهای ارسال گروهی", "body": DEFAULT_OPENING_SMS, "allowed_tags": [], "is_active": True,
|
||||
})[0]
|
||||
|
||||
|
||||
def send_opening_sms(job, provider_factory):
|
||||
control = DispatchResponseControl.objects.get(dispatch_id=job.payload.get("dispatch_id"))
|
||||
permitted = (control.mode == "SEALED" and control.notify_sms
|
||||
and control.recipient_count_snapshot <= 15 and control.opened_at
|
||||
and job.ticket_id and job.ticket.group_dispatch_id == control.dispatch_id
|
||||
and job.customer_id == job.ticket.customer_id
|
||||
and str(job.customer.public_id) in control.respondents_snapshot)
|
||||
if not permitted or job.payload.get("body") != control.sms_body_snapshot:
|
||||
raise ValidationError("Opening notification does not match its authorized snapshot.")
|
||||
return provider_factory().send_message(job.customer.mobile, control.sms_body_snapshot)
|
||||
|
||||
|
||||
def send_dispatch_reply_bale(job, text, provider_factory):
|
||||
"""Dispatch metadata goes to its creator's private destination, never a unit room."""
|
||||
from apps.tickets.policies import DispatchPolicy
|
||||
dispatch = job.ticket.group_dispatch
|
||||
owner = dispatch.created_by
|
||||
profile = getattr(owner, "staff_profile", None)
|
||||
if not owner.is_active or not profile or not profile.is_active or not profile.bale_notifications_enabled:
|
||||
return None
|
||||
if not profile.bale_chat_id.strip() or not DispatchPolicy.can_view(owner, dispatch):
|
||||
return None
|
||||
return provider_factory().send_message(profile.bale_chat_id.strip(), text)
|
||||
@@ -58,6 +58,9 @@ def _bale_ticket_text(ticket, message=None, *, is_reply=False, message_count=1,
|
||||
message = message or ticket.ticket_messages.filter(is_internal=False).order_by("created_at", "id").first()
|
||||
raw_excerpt = " ".join((message.body if message else "").split())
|
||||
excerpt = f"{raw_excerpt[:277].rstrip()}…" if len(raw_excerpt)>280 else raw_excerpt or "بدون متن"
|
||||
from apps.tickets.dispatch_responses import response_control
|
||||
if message and message.author_id == ticket.customer_id and ticket.group_dispatch_id and response_control(ticket.group_dispatch):
|
||||
excerpt = "پاسخ ثبت شده است؛ محتوا فقط در پنل و با رعایت مجوز بازگشایی قابل مشاهده است."
|
||||
local_created = timezone.localtime(message.created_at if message else ticket.created_at)
|
||||
jalali = jdatetime.datetime.fromgregorian(datetime=local_created, locale=jdatetime.FA_LOCALE).strftime("%d %B، %H:%M")
|
||||
lines=[
|
||||
@@ -85,6 +88,9 @@ def _prepare_sms(job):
|
||||
|
||||
|
||||
def _send_sms(job):
|
||||
if job.event_type == "DISPATCH_RESPONSES_OPENED":
|
||||
from apps.notifications.dispatch_opening import send_opening_sms
|
||||
return send_opening_sms(job, get_sms_provider), None
|
||||
if job.event_type == 'PAYMENT_RECEIPT_RECEIVED':
|
||||
from apps.payments.receipt_alerts import send_receipt_alert
|
||||
return send_receipt_alert(job, sms_provider_factory=get_sms_provider), None
|
||||
@@ -109,6 +115,19 @@ def _send_sms(job):
|
||||
raise ValueError(f"Unsupported SMS event: {job.event_type}")
|
||||
return get_sms_provider().send_message(job.customer.mobile, message), issued
|
||||
|
||||
def _send_customer_reply_bale(job, unit_chat_id):
|
||||
message = job.ticket.ticket_messages.filter(pk=job.payload.get("message_id"), is_internal=False).first()
|
||||
if not message:
|
||||
raise ValueError("Customer reply message no longer exists")
|
||||
text = _bale_ticket_text(job.ticket, message, is_reply=True,
|
||||
message_count=job.payload.get("message_count", 1), reopened=job.event_type == "CUSTOMER_TICKET_REOPENED")
|
||||
from apps.tickets.dispatch_responses import response_control
|
||||
if job.ticket.group_dispatch_id and response_control(job.ticket.group_dispatch):
|
||||
from apps.notifications.dispatch_opening import send_dispatch_reply_bale
|
||||
return send_dispatch_reply_bale(job, text, get_bale_provider)
|
||||
return get_bale_provider().send_message(unit_chat_id, text) if unit_chat_id else None
|
||||
|
||||
|
||||
def _send_bale(job):
|
||||
if job.event_type == 'PAYMENT_RECEIPT_RECEIVED':
|
||||
from apps.payments.receipt_alerts import send_receipt_alert
|
||||
@@ -117,9 +136,7 @@ def _send_bale(job):
|
||||
if job.event_type == "NEW_CUSTOMER_TICKET":
|
||||
return get_bale_provider().send_message(unit_chat_id, _bale_ticket_text(job.ticket)) if unit_chat_id else None
|
||||
if job.event_type in ["CUSTOMER_TICKET_REPLY","CUSTOMER_TICKET_REOPENED"]:
|
||||
message = job.ticket.ticket_messages.filter(pk=job.payload.get("message_id"), is_internal=False).first()
|
||||
if not message: raise ValueError("Customer reply message no longer exists")
|
||||
return get_bale_provider().send_message(unit_chat_id, _bale_ticket_text(job.ticket,message,is_reply=True,message_count=job.payload.get("message_count",1),reopened=job.event_type=="CUSTOMER_TICKET_REOPENED")) if unit_chat_id else None
|
||||
return _send_customer_reply_bale(job, unit_chat_id)
|
||||
if not job.staff_user or not hasattr(job.staff_user, "staff_profile"): raise ValueError("Bale recipient is not configured")
|
||||
profile = job.staff_user.staff_profile
|
||||
if not profile.bale_notifications_enabled: return None
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.1.1 on 2026-10-13 09:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def seed_opening_template(apps, schema_editor):
|
||||
apps.get_model('notifications', 'SMSTemplate').objects.using(schema_editor.connection.alias).get_or_create(
|
||||
unit=None, event_type='DISPATCH_RESPONSES_OPENED', defaults={
|
||||
'title': 'بازگشایی پاسخهای ارسال گروهی', 'is_active': True, 'allowed_tags': [],
|
||||
'body': 'لوله گستر گلپایگان با سلام؛ پاسخ استعلام شما بازگشایی شد. جهت صیانت از رقابت عادلانه، تمامی پیشنهادات تا این لحظه کاملاً محرمانه بوده و هیچکس دسترسی به مشاهده آنها نداشته است. اکنون تمامی پاسخها بهصورت همزمان بازگشایی شدند تا سلامت فرآیند تضمین گردد.\nاز همراهی و اعتماد شما سپاسگزاریم. واحد فناوری اطلاعات لوله گستر گلپایگان',
|
||||
})
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('notifications', '0020_receipt_rejection_template'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.SeparateDatabaseAndState(state_operations=[migrations.AlterField(
|
||||
model_name='smstemplate',
|
||||
name='event_type',
|
||||
field=models.CharField(choices=[('DISPATCH_RESPONSES_OPENED', 'بازگشایی پاسخ\u200cهای ارسال گروهی'), ('CUSTOMER_TICKET_CREATED', 'ثبت تیکت توسط مشتری'), ('STAFF_TICKET_CREATED', 'ثبت تیکت توسط کارشناس'), ('STAFF_REPLY', 'پاسخ کارشناس'), ('PAYMENT_NOTICE_CREATED', 'صدور اعلامیه واریز'), ('PAYMENT_RECEIPT_APPROVED', 'تأیید نهایی فیش واریز'), ('PAYMENT_RECEIPT_REJECTED', 'رد فیش واریز'), ('UNIT_SUPERVISOR_NEW_TICKET', 'اطلاع تیکت جدید به سرپرستان واحد')], max_length=50),
|
||||
)], database_operations=[]),
|
||||
migrations.RunPython(seed_opening_template, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -10,6 +10,7 @@ def sms_template_tags():
|
||||
class SMSTemplate(PersianTextNormalizationMixin, models.Model):
|
||||
persian_text_fields={"title":False,"body":True}
|
||||
class Event(models.TextChoices):
|
||||
DISPATCH_RESPONSES_OPENED="DISPATCH_RESPONSES_OPENED","بازگشایی پاسخهای ارسال گروهی"
|
||||
CUSTOMER_TICKET_CREATED="CUSTOMER_TICKET_CREATED","ثبت تیکت توسط مشتری"
|
||||
STAFF_TICKET_CREATED="STAFF_TICKET_CREATED","ثبت تیکت توسط کارشناس"
|
||||
STAFF_REPLY="STAFF_REPLY","پاسخ کارشناس"
|
||||
@@ -29,6 +30,8 @@ class SMSTemplate(PersianTextNormalizationMixin, models.Model):
|
||||
def __str__(self): return self.get_event_type_display()
|
||||
@classmethod
|
||||
def tags_for_event(cls, event_type):
|
||||
if event_type == "DISPATCH_RESPONSES_OPENED":
|
||||
return []
|
||||
if event_type in {'PAYMENT_NOTICE_CREATED', 'PAYMENT_RECEIPT_APPROVED', 'PAYMENT_RECEIPT_REJECTED'}:
|
||||
from apps.payments.notifications import TAGS
|
||||
return list(TAGS)
|
||||
|
||||
@@ -28,7 +28,9 @@ def receipt_alert_context(revision):
|
||||
|
||||
|
||||
def queue_receipt_alerts(revision, actor):
|
||||
if actor.account_type != 'CUSTOMER' or actor.pk != revision.receipt.customer_id:
|
||||
# Submission/revision services authorize staff before reaching this queue.
|
||||
# Keep customer submissions restricted to their own receipt.
|
||||
if actor.account_type != 'STAFF' and (actor.account_type != 'CUSTOMER' or actor.pk != revision.receipt.customer_id):
|
||||
return
|
||||
configuration = PaymentReceiptAlertConfiguration.objects.filter(pk=1).first()
|
||||
if not configuration or not (configuration.bale_enabled or configuration.sms_enabled) or (revision.version > 1 and not configuration.notify_revisions):
|
||||
|
||||
@@ -1,8 +1,46 @@
|
||||
"""Bounded-query projections use only the current evidence revision for financial totals."""
|
||||
from datetime import datetime, time, timedelta
|
||||
|
||||
from django.urls import reverse
|
||||
from django.db.models import BigIntegerField, F, OuterRef, Subquery, Sum, Value
|
||||
from django.db.models import BigIntegerField, Count, F, OuterRef, Q, Subquery, Sum, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from .models import PaymentReceiptRevision
|
||||
from django.utils import timezone
|
||||
from .models import PaymentReceipt, PaymentReceiptRevision
|
||||
|
||||
|
||||
def daily_payment_summary(receipts, notices):
|
||||
"""Summarize permission-scoped, unfiltered querysets in two aggregate queries.
|
||||
|
||||
Submission means initial receipt creation. Final outcomes use the current
|
||||
revision's finalization time, including receipts submitted on earlier days.
|
||||
Superseded revisions and partial votes never count as final outcomes.
|
||||
"""
|
||||
local_timezone = timezone.get_default_timezone()
|
||||
today = timezone.localdate(timezone=local_timezone)
|
||||
start = timezone.make_aware(datetime.combine(today, time.min), local_timezone)
|
||||
end = timezone.make_aware(datetime.combine(today + timedelta(days=1), time.min), local_timezone)
|
||||
current = PaymentReceiptRevision.objects.filter(
|
||||
receipt_id=OuterRef('pk'), version=OuterRef('current_version'))
|
||||
receipts = receipts.annotate(
|
||||
summary_amount=Subquery(current.values('amount')[:1]),
|
||||
summary_finalized_at=Subquery(current.values('cycle__finalized_at')[:1]))
|
||||
submitted = Q(created_at__gte=start, created_at__lt=end)
|
||||
finalized = Q(summary_finalized_at__gte=start, summary_finalized_at__lt=end)
|
||||
approved = finalized & Q(status=PaymentReceipt.Status.APPROVED)
|
||||
rejected = finalized & Q(status=PaymentReceipt.Status.REJECTED)
|
||||
pending = Q(status__in=[PaymentReceipt.Status.PENDING, PaymentReceipt.Status.PARTIAL])
|
||||
summary = receipts.aggregate(
|
||||
submitted_count=Count('pk', filter=submitted),
|
||||
submitted_amount=Sum('summary_amount', filter=submitted, default=0),
|
||||
approved_count=Count('pk', filter=approved),
|
||||
approved_amount=Sum('summary_amount', filter=approved, default=0),
|
||||
rejected_count=Count('pk', filter=rejected),
|
||||
rejected_amount=Sum('summary_amount', filter=rejected, default=0),
|
||||
pending_count=Count('pk', filter=pending),
|
||||
pending_amount=Sum('summary_amount', filter=pending, default=0))
|
||||
summary['notice_count'] = notices.filter(created_at__gte=start, created_at__lt=end).count()
|
||||
summary['date'] = today
|
||||
return summary
|
||||
|
||||
|
||||
def notice_sms_history(notice):
|
||||
|
||||
@@ -279,6 +279,7 @@ def desk(request):
|
||||
mine = tab == 'receipts' and (request.GET.get('mine') == '1' or ('tab' not in request.GET and 'mine' not in request.GET))
|
||||
receipts = selectors.receipts_with_current_values(policies.visible(PaymentReceipt.objects.select_related('customer__customer_profile', 'notice__created_by__staff_profile'), request.user))
|
||||
notice_items = selectors.notices_with_totals(policies.visible(PaymentNotice.objects.select_related('customer__customer_profile', 'created_by__staff_profile', 'account'), request.user))
|
||||
daily_summary = selectors.daily_payment_summary(receipts, notice_items)
|
||||
if tab == 'notices':
|
||||
items = filtered(notice_items, request)
|
||||
state = request.GET.get('notice_state', '')
|
||||
@@ -318,7 +319,8 @@ def desk(request):
|
||||
else:
|
||||
query.pop('mine', None)
|
||||
return render(request, 'payments/desk.html', staff_context(page_obj=page, tab=tab, mine=mine, statuses=PaymentReceipt.Status.choices,
|
||||
can_create=policies.can_create(request.user), banks=CompanyBankAccount.objects.all(), query_string=query.urlencode()))
|
||||
can_create=policies.can_create(request.user), banks=CompanyBankAccount.objects.all(), query_string=query.urlencode(),
|
||||
daily_summary=daily_summary))
|
||||
|
||||
|
||||
@financial_required(staff=True)
|
||||
|
||||
+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