diff --git a/apps/notifications/admin.py b/apps/notifications/admin.py index dc1ccd6..044776d 100644 --- a/apps/notifications/admin.py +++ b/apps/notifications/admin.py @@ -5,10 +5,10 @@ class NotificationJobAdmin(admin.ModelAdmin): list_display=("channel","event_typ @admin.register(AnnouncementJob) class AnnouncementJobAdmin(admin.ModelAdmin): - list_display = ("message", "status", "attempts", "next_attempt_at", "sent_at") - list_filter = ("status",) - search_fields = ("message__ticket__ticket_number", "message__ticket__customer__mobile") - readonly_fields = ("message", "status", "attempts", "next_attempt_at", "idempotency_key", "last_error", "created_at", "sent_at") + list_display = ("source", "ticket", "message", "requested_by", "status", "attempts", "next_attempt_at", "sent_at") + list_filter = ("source", "status") + search_fields = ("ticket__ticket_number", "message__ticket__ticket_number", "message__ticket__customer__mobile") + readonly_fields = ("source", "ticket", "message", "requested_by", "status", "attempts", "next_attempt_at", "idempotency_key", "last_error", "created_at", "sent_at") def has_add_permission(self, request): return False diff --git a/apps/notifications/announcement_worker.py b/apps/notifications/announcement_worker.py index 7da9ad4..7f341fd 100644 --- a/apps/notifications/announcement_worker.py +++ b/apps/notifications/announcement_worker.py @@ -4,6 +4,7 @@ from datetime import timedelta import requests from django.db import transaction +from django.db.models import Case, IntegerField, Value, When from django.utils import timezone from apps.core.models import VoipConfiguration @@ -30,18 +31,31 @@ def _failure_code(exc): def _cancellation_reason(job, configuration): - message = job.message - customer = message.ticket.customer + if job.source == AnnouncementJob.Source.MANUAL: + if not job.ticket_id: + return "message_unavailable" + customer = job.ticket.customer + else: + if job.message is None: + return "message_unavailable" + customer = job.message.ticket.customer + reason = _automatic_cancellation_reason(job.message, customer) + if reason: + return reason + if not customer.is_active or not customer.local_mobile: + return "customer_mobile_unavailable" + if not configuration.announcement_url or not configuration.announcement_id: + return "announcement_not_configured" + return "" + + +def _automatic_cancellation_reason(message, customer): if message.is_internal or message.deleted_at or message.author.account_type != "STAFF": return "message_unavailable" if MessageReadReceipt.objects.filter(message=message, user=customer).exists(): return "customer_read" if message.ticket.ticket_messages.filter(author=customer, created_at__gt=message.created_at, is_internal=False).exists(): return "customer_replied" - if not customer.is_active or not customer.local_mobile: - return "customer_mobile_unavailable" - if not configuration.announcement_url or not configuration.announcement_id: - return "announcement_not_configured" return "" @@ -55,7 +69,7 @@ def _deliver(job): return place_announcement_call( configuration, - mobile=job.message.ticket.customer.local_mobile, + mobile=(job.ticket.customer if job.source == AnnouncementJob.Source.MANUAL else job.message.ticket.customer).local_mobile, idempotency_key=job.idempotency_key, ) except Exception as exc: @@ -82,8 +96,9 @@ def process_due_announcements(): jobs = list( AnnouncementJob.objects.select_for_update(skip_locked=True) .filter(status=AnnouncementJob.Status.PENDING, next_attempt_at__lte=now) - .select_related("message__ticket__customer") - .order_by("next_attempt_at", "pk")[:20] + .select_related("message__ticket__customer", "ticket__customer") + .annotate(priority=Case(When(source=AnnouncementJob.Source.MANUAL, then=Value(0)), default=Value(1), output_field=IntegerField())) + .order_by("priority", "next_attempt_at", "pk")[:20] ) AnnouncementJob.objects.filter(pk__in=[job.pk for job in jobs]).update( status=AnnouncementJob.Status.PROCESSING, next_attempt_at=now, diff --git a/apps/notifications/management/commands/process_notifications.py b/apps/notifications/management/commands/process_notifications.py index 4764b57..6658730 100644 --- a/apps/notifications/management/commands/process_notifications.py +++ b/apps/notifications/management/commands/process_notifications.py @@ -301,6 +301,8 @@ class Command(BaseCommand): parser.add_argument("--channel", choices=NotificationJob.Channel.values, help="Process only one notification channel") def handle(self, *args, **options): while True: + if not options.get("job_id") and not options.get("channel") and not options.get("min_id"): + process_due_announcements() stale = timezone.now() - timedelta(minutes=10) NotificationJob.objects.filter(status="PROCESSING", next_attempt_at__lt=stale, channel=NotificationJob.Channel.SMS).update(status=NotificationJob.Status.RETRYING, next_attempt_at=timezone.now(), last_error="بازیابی تلاش ارسال متوقف‌شده") NotificationJob.objects.filter(status="PROCESSING", next_attempt_at__lt=stale, channel=NotificationJob.Channel.BALE).update(status=NotificationJob.Status.FAILED, next_attempt_at=timezone.now(), last_error="Recovered stale processing job") diff --git a/apps/notifications/migrations/0016_announcementjob_requested_by_announcementjob_source_and_more.py b/apps/notifications/migrations/0016_announcementjob_requested_by_announcementjob_source_and_more.py new file mode 100644 index 0000000..a1b5a29 --- /dev/null +++ b/apps/notifications/migrations/0016_announcementjob_requested_by_announcementjob_source_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 5.1.1 on 2026-09-28 16:45 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('messaging', '0003_messagereadreceipt'), + ('notifications', '0015_announcementjob'), + ('tickets', '0016_scope_legacy_public_tickets_to_units'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='announcementjob', + name='requested_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='requested_announcement_jobs', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='announcementjob', + name='source', + field=models.CharField(choices=[('AUTOMATIC', 'خودکار'), ('MANUAL', 'فوری توسط کارشناس')], default='AUTOMATIC', max_length=12), + ), + migrations.AddField( + model_name='announcementjob', + name='ticket', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='manual_announcement_jobs', to='tickets.ticket'), + ), + migrations.AlterField( + model_name='announcementjob', + name='message', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='announcement_job', to='messaging.message'), + ), + ] diff --git a/apps/notifications/models.py b/apps/notifications/models.py index 40c21df..ce9c729 100644 --- a/apps/notifications/models.py +++ b/apps/notifications/models.py @@ -73,7 +73,11 @@ class NotificationJob(models.Model): class AnnouncementJob(models.Model): - """One durable call reminder for each public message written by staff.""" + """Durable automatic or staff-requested prerecorded call.""" + class Source(models.TextChoices): + AUTOMATIC = "AUTOMATIC", "خودکار" + MANUAL = "MANUAL", "فوری توسط کارشناس" + class Status(models.TextChoices): PENDING = "PENDING", "در صف" PROCESSING = "PROCESSING", "در حال تماس" @@ -81,7 +85,10 @@ class AnnouncementJob(models.Model): FAILED = "FAILED", "ناموفق" CANCELLED = "CANCELLED", "لغوشده" - message = models.OneToOneField("messaging.Message", on_delete=models.CASCADE, related_name="announcement_job") + source = models.CharField(max_length=12, choices=Source.choices, default=Source.AUTOMATIC) + message = models.OneToOneField("messaging.Message", on_delete=models.CASCADE, related_name="announcement_job", null=True, blank=True) + ticket = models.ForeignKey("tickets.Ticket", on_delete=models.CASCADE, related_name="manual_announcement_jobs", null=True, blank=True) + requested_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="requested_announcement_jobs") status = models.CharField(max_length=15, choices=Status.choices, default=Status.PENDING, db_index=True) attempts = models.PositiveSmallIntegerField(default=0) next_attempt_at = models.DateTimeField(db_index=True) diff --git a/apps/notifications/services.py b/apps/notifications/services.py index 78e8938..3b50cf7 100644 --- a/apps/notifications/services.py +++ b/apps/notifications/services.py @@ -17,6 +17,30 @@ def schedule_announcement(message): }, )[0] + +def schedule_immediate_announcement(ticket, requested_by): + """Queue an urgent call, coalescing repeated clicks for one minute.""" + from django.db import transaction + from apps.tickets.models import Ticket + + with transaction.atomic(): + Ticket.objects.select_for_update().get(pk=ticket.pk) + now = timezone.now() + recent = AnnouncementJob.objects.filter( + ticket=ticket, + source=AnnouncementJob.Source.MANUAL, + created_at__gte=now - timedelta(minutes=1), + ).order_by("-created_at").first() + if recent: + return recent, False + return AnnouncementJob.objects.create( + source=AnnouncementJob.Source.MANUAL, + ticket=ticket, + requested_by=requested_by, + next_attempt_at=now, + idempotency_key=f"manual-ticket-{uuid4().hex}", + ), True + def enqueue_unit_supervisor_ticket_sms(ticket): """Queue one concise new-ticket SMS for each configured unit supervisor.""" unit = ticket.owning_unit diff --git a/apps/tickets/staff_urls.py b/apps/tickets/staff_urls.py index db5975f..dcfebf0 100644 --- a/apps/tickets/staff_urls.py +++ b/apps/tickets/staff_urls.py @@ -1,4 +1,4 @@ from django.urls import path from . import 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//edit/",views.staff_contact_edit,name="contact_edit"),path("contacts//call/",views.staff_contact_call,name="contact_call"),path("contact-groups/",views.staff_groups,name="groups"),path("contact-groups//",views.staff_groups,name="group_edit"),path("magic-links/",views.staff_magic_audit,name="magic_audit"),path("magic-links//revoke/",views.staff_magic_revoke,name="magic_revoke"),path("sms-templates/",views.staff_sms_templates,name="sms_templates"),path("sms-templates//",views.staff_sms_templates,name="sms_template_edit"),path("dispatches/",views.staff_dispatch_create,name="dispatch_create"),path("dispatches//",views.staff_dispatch_detail,name="dispatch_detail"),path("dispatches//retry/",views.staff_dispatch_retry,name="dispatch_retry"),path("dispatches//resolve/",views.staff_dispatch_resolve,name="dispatch_resolve"),path("experts//avatar/",views.expert_avatar,name="expert_avatar"),path("tickets/",views.staff_list,name="tickets"),path("tickets/new/",views.staff_create,name="create"),path("tickets//messages/older/",views.staff_messages,name="messages"),path("tickets//messages/poll/",views.staff_poll,name="poll"),path("tickets//sms//retry/",views.staff_sms_retry,name="sms_retry"),path("tickets//",views.staff_detail,name="detail"),path("tickets//claim/",views.staff_claim,name="claim"),path("tickets//reply/",views.staff_reply,name="reply"),path("tickets//note/",lambda r,public_id:views.staff_reply(r,public_id,note=True),name="note"),path("tickets//transfer/",views.staff_transfer,name="transfer"),path("tickets//share/",views.staff_share,name="share"),path("tickets//revoke//",views.staff_revoke,name="revoke"),path("tickets//status/",views.staff_status,name="status"),path("tickets//priority/",views.staff_priority,name="priority"),path("tickets//delete/",views.staff_delete,name="delete")] +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//edit/",views.staff_contact_edit,name="contact_edit"),path("contacts//call/",views.staff_contact_call,name="contact_call"),path("contact-groups/",views.staff_groups,name="groups"),path("contact-groups//",views.staff_groups,name="group_edit"),path("magic-links/",views.staff_magic_audit,name="magic_audit"),path("magic-links//revoke/",views.staff_magic_revoke,name="magic_revoke"),path("sms-templates/",views.staff_sms_templates,name="sms_templates"),path("sms-templates//",views.staff_sms_templates,name="sms_template_edit"),path("dispatches/",views.staff_dispatch_create,name="dispatch_create"),path("dispatches//",views.staff_dispatch_detail,name="dispatch_detail"),path("dispatches//retry/",views.staff_dispatch_retry,name="dispatch_retry"),path("dispatches//resolve/",views.staff_dispatch_resolve,name="dispatch_resolve"),path("experts//avatar/",views.expert_avatar,name="expert_avatar"),path("tickets/",views.staff_list,name="tickets"),path("tickets/new/",views.staff_create,name="create"),path("tickets//messages/older/",views.staff_messages,name="messages"),path("tickets//messages/poll/",views.staff_poll,name="poll"),path("tickets//sms//retry/",views.staff_sms_retry,name="sms_retry"),path("tickets//announcement/now/",views.staff_announcement_now,name="announcement_now"),path("tickets//",views.staff_detail,name="detail"),path("tickets//claim/",views.staff_claim,name="claim"),path("tickets//reply/",views.staff_reply,name="reply"),path("tickets//note/",lambda r,public_id:views.staff_reply(r,public_id,note=True),name="note"),path("tickets//transfer/",views.staff_transfer,name="transfer"),path("tickets//share/",views.staff_share,name="share"),path("tickets//revoke//",views.staff_revoke,name="revoke"),path("tickets//status/",views.staff_status,name="status"),path("tickets//priority/",views.staff_priority,name="priority"),path("tickets//delete/",views.staff_delete,name="delete")] diff --git a/apps/tickets/views.py b/apps/tickets/views.py index 614a9e7..84bb898 100644 --- a/apps/tickets/views.py +++ b/apps/tickets/views.py @@ -24,6 +24,8 @@ from apps.accounts.magic_access import get_ticket_magic_access from apps.customers.models import ContactGroup, CustomerProfile from apps.messaging.models import Message,MessageReadReceipt from apps.notifications.models import AnnouncementJob, NotificationJob, SMSTemplate +from apps.notifications.services import schedule_immediate_announcement +from apps.core.models import VoipConfiguration from apps.core.iran_locations import location_data from apps.core.persian_text import normalize_persian_text from apps.integrations.voip import place_voip_call @@ -444,15 +446,35 @@ def staff_detail(request,public_id): last=conversation[-1] if conversation else None; TicketReadState.objects.update_or_create(ticket=ticket,user=request.user,defaults={"last_read_at":timezone.now(),"last_read_message":last}) history=Ticket.objects.filter(customer=ticket.customer).exclude(pk=ticket.pk).select_related("owner__staff_profile","selected_expert__staff_profile").order_by("-last_activity_at")[:5] sms_jobs=list(ticket.notificationjob_set.filter(channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("-created_at")) - announcement_jobs=list(AnnouncementJob.objects.filter(message__ticket=ticket).select_related("message").order_by("-message__created_at", "-pk")) + announcement_jobs=list(AnnouncementJob.objects.filter(Q(message__ticket=ticket) | Q(ticket=ticket)).select_related("message").order_by("-created_at", "-pk")) can_retry_sms=TicketPolicy.can_reply(request.user,ticket) + can_request_announcement=request.user.account_type==User.AccountType.STAFF and can_retry_sms + configuration=VoipConfiguration.load() if can_request_announcement else None + announcement_ready=bool(configuration and configuration.announcement_url and configuration.announcement_id and ticket.customer.is_active and ticket.customer.local_mobile) for sms_job in sms_jobs: 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 can_resolve_dispatch = bool(group_dispatch and DispatchPolicy.can_view(request.user, group_dispatch) and (request.user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(request.user, group_dispatch.unit, "tickets.change_ticket"))) group_open_count = group_dispatch.tickets.exclude(status__in=(Ticket.Status.RESOLVED, Ticket.Status.CLOSED)).count() if group_dispatch else 0 - return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":ShareForm(),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"can_resolve_dispatch":can_resolve_dispatch,"group_open_count":group_open_count,"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"sms_jobs":sms_jobs,"announcement_jobs":announcement_jobs,"viewer_user":request.user,"voip_call_methods":_voip_call_methods(request.user),"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else ""}) + return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":ShareForm(),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"can_resolve_dispatch":can_resolve_dispatch,"group_open_count":group_open_count,"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"sms_jobs":sms_jobs,"announcement_jobs":announcement_jobs,"can_request_announcement":can_request_announcement,"announcement_ready":announcement_ready,"viewer_user":request.user,"voip_call_methods":_voip_call_methods(request.user),"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else ""}) + + +@staff_required +@require_POST +def staff_announcement_now(request, public_id): + ticket=get_object_or_404(Ticket.objects.select_related("customer"), public_id=public_id) + if request.user.account_type!=User.AccountType.STAFF or not TicketPolicy.can_reply(request.user,ticket): + raise PermissionDenied + configuration=VoipConfiguration.load() + if not configuration.announcement_url or not configuration.announcement_id: + messages.error(request,"تنظیمات اطلاع‌رسانی تلفنی کامل نیست.") + elif not ticket.customer.is_active or not ticket.customer.local_mobile: + messages.error(request,"شماره همراه مخاطب برای تماس در دسترس نیست.") + else: + _,created=schedule_immediate_announcement(ticket,request.user) + messages.success(request,"درخواست اطلاع‌رسانی تلفنی فوری در صف قرار گرفت." if created else "درخواست اطلاع‌رسانی تلفنی این تیکت در دقیقهٔ گذشته ثبت شده است.") + return redirect("staff_tickets:detail",public_id=public_id) @staff_required @require_POST def staff_sms_retry(request,public_id,job_id): diff --git a/static/css/operations.css b/static/css/operations.css index fde4b23..edb67e5 100644 --- a/static/css/operations.css +++ b/static/css/operations.css @@ -1,4 +1,15 @@ /* Staff organizational operations — intentionally scoped to avoid regressions. */ +.immediate-announcement-card{display:grid;gap:.7rem;border:1px solid #c9e4df;background:#f8fcfb} +.immediate-announcement-head{display:flex;align-items:center;gap:.65rem} +.immediate-announcement-head h3{margin:0;color:#164b47;font-size:.86rem;line-height:1.6} +.immediate-announcement-icon{display:grid;place-items:center;width:2rem;height:2rem;flex:none;border-radius:.65rem;background:#e2f3ef;color:#0f766e;font-size:1.05rem} +.immediate-announcement-card p{margin:0;color:#45605f;font-size:.73rem;line-height:1.85} +.immediate-announcement-card form{margin:0} +.immediate-announcement-button{display:flex;align-items:center;justify-content:center;gap:.5rem;width:100%;min-height:2.75rem;border:1px solid #0f766e;border-radius:.7rem;background:#0f766e;color:#fff;font-size:.76rem;font-weight:800;cursor:pointer;transition:background .15s ease,box-shadow .15s ease} +.immediate-announcement-button:hover:not(:disabled){background:#0b5f59;box-shadow:0 5px 15px -10px #0b5f59} +.immediate-announcement-button:focus-visible{outline:2px solid #0f766e;outline-offset:3px} +.immediate-announcement-button:disabled{border-color:#aebfc0;background:#aebfc0;cursor:not-allowed} +.immediate-announcement-card small{color:#607574;font-size:.65rem;line-height:1.7} .operations-page{display:grid;gap:1rem;min-width:0} .operations-page .staff-page-head{margin-bottom:0} .operations-page .ticket-stream,.operations-page .filters{border:1px solid #d9e4ee;background:#fff;box-shadow:0 18px 45px -38px rgba(8,55,107,.55)} diff --git a/templates/tickets/_announcement_status.html b/templates/tickets/_announcement_status.html index 783f8eb..714094f 100644 --- a/templates/tickets/_announcement_status.html +++ b/templates/tickets/_announcement_status.html @@ -5,10 +5,10 @@
{% for announcement_job in announcement_jobs %}
- پیام کارشناس · {{ announcement_job.message.created_at|jalali_datetime }}{{ announcement_job.status_label }}i + {% if announcement_job.source == 'MANUAL' %}درخواست فوری · {{ announcement_job.created_at|jalali_datetime }}{% else %}پیام کارشناس · {{ announcement_job.message.created_at|jalali_datetime }}{% endif %}{{ announcement_job.status_label }}i
    -
  1. ثبت پیام کارشناس
  2. -
  3. {% if announcement_job.attempts and announcement_job.status == 'PENDING' %}تلاش بعدی برای تماس{% else %}موعد بررسی پیام خوانده‌نشده{% endif %}
  4. +
  5. {% if announcement_job.source == 'MANUAL' %}ثبت درخواست فوری توسط کارشناس{% else %}ثبت پیام کارشناس{% endif %}{% if announcement_job.source == 'MANUAL' %}{% else %}{% endif %}
  6. +
  7. {% if announcement_job.attempts and announcement_job.status == 'PENDING' %}تلاش بعدی برای تماس{% elif announcement_job.source == 'MANUAL' %}پردازش درخواست فوری{% else %}موعد بررسی پیام خوانده‌نشده{% endif %}
  8. پذیرش درخواست توسط سرویس تماس{% if announcement_job.sent_at %}{% else %}{{ announcement_job.status_label }}{% endif %}
{% if announcement_job.detail_label %}

{{ announcement_job.detail_label }}

{% endif %} diff --git a/templates/tickets/_immediate_announcement.html b/templates/tickets/_immediate_announcement.html new file mode 100644 index 0000000..d176edc --- /dev/null +++ b/templates/tickets/_immediate_announcement.html @@ -0,0 +1,13 @@ +{% if can_request_announcement %} +
+

اطلاع‌رسانی تلفنی فوری

+

این دکمه درخواست تماس با مخاطب و پخش یک پیام اطلاع‌رسانی از پیش ضبط‌شده را ثبت می‌کند.

+
+ {% csrf_token %} + +
+ {% if announcement_ready %}درخواست در صف تماس ثبت می‌شود؛ نتیجه را در وضعیت اطلاع‌رسانی‌های تلفنی ببینید.{% else %}شماره مخاطب یا تنظیمات سرویس تماس در دسترس نیست.{% endif %} +
+{% endif %} diff --git a/templates/tickets/staff_detail.html b/templates/tickets/staff_detail.html index d55d903..5d1e21c 100644 --- a/templates/tickets/staff_detail.html +++ b/templates/tickets/staff_detail.html @@ -17,6 +17,7 @@