button force anoncecall

This commit is contained in:
javad committed 2026-09-28 20:35:27 +03:30
1 parent c7993aae44
commit 9af91941d9
15 files changed
+294 -23

No files matched your search

+4 -4
View File
@@ -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
+24 -9
View File
@@ -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,
@@ -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")
@@ -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'),
),
]
+9 -2
View File
@@ -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)
+24
View File
@@ -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
+1 -1
View File
@@ -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/<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>/",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("",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")]
+24 -2
View File
@@ -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):
+11
View File
@@ -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)}
+3 -3
View File
@@ -5,10 +5,10 @@
<div class="sms-delivery-list">
{% for announcement_job in announcement_jobs %}
<details class="sms-delivery-item">
<summary><span class="sms-delivery-event">پیام کارشناس · {{ announcement_job.message.created_at|jalali_datetime }}</span><b>{{ announcement_job.status_label }}</b><span class="sms-delivery-info" aria-label="نمایش جزئیات اطلاع‌رسانی تلفنی" title="نمایش جزئیات">i</span></summary>
<summary><span class="sms-delivery-event">{% if announcement_job.source == 'MANUAL' %}درخواست فوری · {{ announcement_job.created_at|jalali_datetime }}{% else %}پیام کارشناس · {{ announcement_job.message.created_at|jalali_datetime }}{% endif %}</span><b>{{ announcement_job.status_label }}</b><span class="sms-delivery-info" aria-label="نمایش جزئیات اطلاع‌رسانی تلفنی" title="نمایش جزئیات">i</span></summary>
<ol class="sms-delivery-timeline">
<li class="is-complete"><span></span><div><b>ثبت پیام کارشناس</b><time datetime="{{ announcement_job.message.created_at|date:'c' }}">{{ announcement_job.message.created_at|jalali_datetime }}</time></div></li>
<li class="{% if announcement_job.status == 'SENT' or announcement_job.status == 'PROCESSING' %}is-complete{% endif %}"><span></span><div><b>{% if announcement_job.attempts and announcement_job.status == 'PENDING' %}تلاش بعدی برای تماس{% else %}موعد بررسی پیام خوانده‌نشده{% endif %}</b><time datetime="{{ announcement_job.next_attempt_at|date:'c' }}">{{ announcement_job.next_attempt_at|jalali_datetime }}</time></div></li>
<li class="is-complete"><span></span><div><b>{% if announcement_job.source == 'MANUAL' %}ثبت درخواست فوری توسط کارشناس{% else %}ثبت پیام کارشناس{% endif %}</b>{% if announcement_job.source == 'MANUAL' %}<time datetime="{{ announcement_job.created_at|date:'c' }}">{{ announcement_job.created_at|jalali_datetime }}</time>{% else %}<time datetime="{{ announcement_job.message.created_at|date:'c' }}">{{ announcement_job.message.created_at|jalali_datetime }}</time>{% endif %}</div></li>
<li class="{% if announcement_job.status == 'SENT' or announcement_job.status == 'PROCESSING' %}is-complete{% endif %}"><span></span><div><b>{% if announcement_job.attempts and announcement_job.status == 'PENDING' %}تلاش بعدی برای تماس{% elif announcement_job.source == 'MANUAL' %}پردازش درخواست فوری{% else %}موعد بررسی پیام خوانده‌نشده{% endif %}</b><time datetime="{{ announcement_job.next_attempt_at|date:'c' }}">{{ announcement_job.next_attempt_at|jalali_datetime }}</time></div></li>
<li class="{% if announcement_job.sent_at %}is-complete{% endif %}"><span></span><div><b>پذیرش درخواست توسط سرویس تماس</b>{% if announcement_job.sent_at %}<time datetime="{{ announcement_job.sent_at|date:'c' }}">{{ announcement_job.sent_at|jalali_datetime }}</time>{% else %}<small>{{ announcement_job.status_label }}</small>{% endif %}</div></li>
</ol>
{% if announcement_job.detail_label %}<p class="sms-delivery-description">{{ announcement_job.detail_label }}</p>{% endif %}
@@ -0,0 +1,13 @@
{% if can_request_announcement %}
<section class="info-card immediate-announcement-card" aria-label="اطلاع‌رسانی تلفنی فوری">
<div class="immediate-announcement-head"><span class="immediate-announcement-icon" aria-hidden="true">☎</span><h3>اطلاع‌رسانی تلفنی فوری</h3></div>
<p>این دکمه درخواست تماس با مخاطب و پخش یک پیام اطلاع‌رسانی از پیش ضبط‌شده را ثبت می‌کند.</p>
<form method="post" action="{% url 'staff_tickets:announcement_now' ticket.public_id %}">
{% csrf_token %}
<button type="submit" class="immediate-announcement-button" {% if not announcement_ready %}disabled{% endif %}>
<span aria-hidden="true">☎</span> ارسال اطلاع‌رسانی تلفنی فوری
</button>
</form>
<small>{% if announcement_ready %}درخواست در صف تماس ثبت می‌شود؛ نتیجه را در وضعیت اطلاع‌رسانی‌های تلفنی ببینید.{% else %}شماره مخاطب یا تنظیمات سرویس تماس در دسترس نیست.{% endif %}</small>
</section>
{% endif %}
+1
View File
@@ -17,6 +17,7 @@
<aside class="workspace-side actions-side" id="ticket-actions" data-workspace-panel>
<button type="button" class="panel-close" data-close-panel>×</button>
<section class="info-card"><p class="panel-index">وضعیت تیکت</p><div class="info-list"><div class="info-row"><span>وضعیت</span><b>{{ ticket.get_status_display }}</b></div><div class="info-row"><span>اولویت</span><b>{{ ticket.get_priority_display }}</b></div><div class="info-row"><span>دسته</span><b>{{ ticket.category }}</b></div><div class="info-row"><span>مالک</span><b>{{ ticket.assigned_expert_name }}</b></div>{% if ticket.first_response_due_at %}<div class="info-row"><span>مهلت پاسخ</span><b>{{ ticket.first_response_due_at|jalali_datetime }}</b></div>{% endif %}</div></section>
{% include 'tickets/_immediate_announcement.html' %}
{% if sms_jobs %}<section class="info-card sms-delivery-card"><div class="sms-delivery-heading"><div><h3>وضعیت پیامک‌های مشتری</h3><small>خلاصه ارسال و تحویل</small></div><span aria-hidden="true">ⓘ</span></div><div class="sms-delivery-list">{% for sms_job in sms_jobs %}<details class="sms-delivery-item"><summary><span class="sms-delivery-event">{% if sms_job.event_type == 'STAFF_TICKET_CREATED' %}ثبت تیکت توسط کارشناس{% elif sms_job.event_type == 'CUSTOMER_TICKET_CREATED' %}ثبت تیکت مشتری{% elif sms_job.event_type == 'STAFF_REPLY' %}اعلان پاسخ کارشناس{% else %}پیامک سیستمی{% endif %}</span><b>{% if sms_job.delivery_status %}{{ sms_job.get_delivery_status_display }}{% else %}{{ sms_job.get_status_display }}{% endif %}</b><span class="sms-delivery-info" aria-label="نمایش جزئیات زمان‌بندی پیامک" title="نمایش جزئیات">i</span></summary><ol class="sms-delivery-timeline"><li class="is-complete"><span></span><div><b>ثبت درخواست ارسال</b><time datetime="{{ sms_job.created_at|date:'c' }}">{{ sms_job.created_at|jalali_datetime }}</time></div></li><li class="{% if sms_job.sent_at %}is-complete{% endif %}"><span></span><div><b>پذیرش توسط درگاه پیامک</b>{% if sms_job.sent_at %}<time datetime="{{ sms_job.sent_at|date:'c' }}">{{ sms_job.sent_at|jalali_datetime }}</time>{% else %}<small>{% if sms_job.status == 'FAILED' %}ناموفق{% elif sms_job.status == 'CANCELLED' %}لغوشده پیش از ارسال{% else %}در انتظار ارسال{% endif %}</small>{% endif %}</div></li><li class="{% if sms_job.delivery_status == 'DELIVERED' %}is-complete{% endif %}"><span></span><div><b>تأیید رسیدن به گوشی</b>{% if sms_job.delivery_status == 'DELIVERED' and sms_job.delivery_checked_at %}<time datetime="{{ sms_job.delivery_checked_at|date:'c' }}">{{ sms_job.delivery_checked_at|jalali_datetime }}</time>{% elif sms_job.delivery_checked_at %}<small>آخرین استعلام: {{ sms_job.delivery_checked_at|jalali_datetime }}</small>{% else %}<small>{% if sms_job.sent_at %}در انتظار نتیجه اپراتور{% else %}هنوز ارسال نشده{% endif %}</small>{% endif %}</div></li></ol>{% if sms_job.delivery_description %}<p class="sms-delivery-description">{{ sms_job.delivery_description }}</p>{% endif %}{% if sms_job.last_error %}<p class="sms-delivery-error">علت دقیق: {{ sms_job.last_error }}</p>{% endif %}{% if sms_job.provider_message_id %}<small class="sms-delivery-tracking" dir="ltr">شناسه رهگیری: {{ sms_job.provider_message_id }}</small>{% endif %}{% if sms_job.sms_retryable %}<form method="post" action="{% url 'staff_tickets:sms_retry' ticket.public_id sms_job.pk %}" class="inline-retry-form">{% csrf_token %}<button type="submit" aria-label="تلاش مجدد برای ارسال این پیامک" title="تلاش مجدد"><span aria-hidden="true">↻</span> تلاش مجدد</button></form>{% endif %}</details>{% endfor %}</div></section>{% endif %}
{% include 'tickets/_announcement_status.html' %}
<section class="info-card ticket-operations"><h3>عملیات تیکت</h3>{% if perms.tickets.change_ticket %}<form method="post" action="{% url 'staff_tickets:status' ticket.public_id %}">{% csrf_token %}<label>وضعیت<select name="status">{% for value,label in statuses %}<option value="{{ value }}" {% if ticket.status == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><button>ثبت وضعیت</button></form>{% endif %}{% if perms.tickets.change_ticket_priority %}<form method="post" action="{% url 'staff_tickets:priority' ticket.public_id %}">{% csrf_token %}<label>اولویت<select name="priority">{% for value,label in priorities %}<option value="{{ value }}" {% if ticket.priority == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><button class="secondary">ثبت اولویت</button></form>{% endif %}{% if can_transfer %}<details><summary>انتقال تیکت</summary><form method="post" action="{% url 'staff_tickets:transfer' ticket.public_id %}">{% csrf_token %}{{ transfer_form.as_p }}<button>انتقال</button></form></details>{% endif %}{% if can_share %}<details><summary>اشتراک با کارشناس</summary><form method="post" action="{% url 'staff_tickets:share' ticket.public_id %}">{% csrf_token %}{{ share_form.as_p }}<button>ایجاد دسترسی</button></form>{% for grant in grants %}<div class="grant"><span>{{ grant.user.staff_profile.display_name }}</span><form method="post" action="{% url 'staff_tickets:revoke' ticket.public_id grant.user.public_id %}">{% csrf_token %}<button class="danger-button">لغو</button></form></div>{% endfor %}</details>{% endif %}</section>
+11
View File
@@ -8,6 +8,17 @@ from tests.e2e.pages.base import BasePage
class TicketConversationPage(BasePage):
guidance_text = "لطفاً همه توضیحات و سؤال‌های خود را در یک پیام کامل بنویسید و سپس ارسال کنید."
def request_immediate_announcement(self):
card = self.page.get_by_role("region", name="اطلاع‌رسانی تلفنی فوری")
expect(card).to_be_visible()
expect(card).to_contain_text("پیام اطلاع‌رسانی از پیش ضبط‌شده")
button = card.get_by_role("button", name="ارسال اطلاع‌رسانی تلفنی فوری")
expect(button).to_be_enabled()
with self.page.expect_navigation(wait_until="domcontentloaded"):
button.click()
expect(self.page.get_by_text("درخواست اطلاع‌رسانی تلفنی فوری در صف قرار گرفت.")).to_be_visible()
expect(self.page.get_by_role("region", name="وضعیت اطلاع‌رسانی‌های تلفنی")).to_contain_text("درخواست فوری")
def send_reply(self, body):
self.page.locator("[data-composer-toggle]").click()
reply = self.page.locator('form[data-chat-coach] textarea[name="body"]')
+23 -1
View File
@@ -8,7 +8,7 @@ from playwright.sync_api import expect
from apps.accounts.models import TicketMagicLink, User
from apps.customers.models import ContactGroup, CustomerProfile
from apps.core.models import PageTitle, SiteBranding
from apps.core.models import PageTitle, SiteBranding, VoipConfiguration
from apps.messaging.models import Message
from apps.notifications.models import NotificationJob, SMSTemplate
from apps.staff.models import OrganizationalUnit, StaffProfile
@@ -22,6 +22,28 @@ pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
PASSWORD = "E2e-safe-password-2026"
@pytest.fixture
def immediate_announcement_case():
unit = OrganizationalUnit.objects.create(name="واحد تماس فوری", slug="immediate-call-e2e")
category = TicketCategory.objects.create(unit=unit, title="تماس فوری", slug="immediate-call-e2e")
staff = User.objects.create_superuser(username="immediate-call-e2e", password=PASSWORD, account_type=User.AccountType.STAFF)
StaffProfile.objects.create(user=staff, display_name="کارشناس تماس فوری", primary_unit=unit)
customer = User.objects.create_user(username="immediate-call-customer-e2e", mobile="09120009122", account_type=User.AccountType.CUSTOMER)
ticket = create_ticket(customer=customer, created_by=staff, category=category, subject="پیگیری تلفنی فوری", body="پیام نخست")
VoipConfiguration.objects.create(announcement_url="http://127.0.0.1:9000/v1/announcements", announcement_id="custom/test")
return staff, ticket
def test_staff_can_request_immediate_announcement_from_ticket_sidebar(live_server, browser_page, immediate_announcement_case):
staff, ticket = immediate_announcement_case
with browser_page() as page:
AuthenticationPage(page, live_server.url).login_staff(staff, PASSWORD)
conversation = TicketConversationPage(page, live_server.url)
conversation.goto(reverse("staff_tickets:detail", args=[ticket.public_id]))
conversation.request_immediate_announcement()
def test_staff_operations_pages_are_authorized_and_visually_styled(live_server, browser_page):
branding = SiteBranding.load()
branding.staff_brand_title = "مرکز عملیات E2E"
+106 -1
View File
@@ -4,6 +4,7 @@ from unittest.mock import Mock, patch
import pytest
import requests
from django.core.management import call_command
from django.urls import reverse
from django.utils import timezone
from apps.accounts.models import User
@@ -12,7 +13,7 @@ from apps.core.models import VoipConfiguration
from apps.messaging.models import MessageReadReceipt
from apps.notifications.models import AnnouncementJob
from apps.staff.models import OrganizationalUnit
from apps.tickets.models import TicketCategory
from apps.tickets.models import TicketAccessGrant, TicketCategory
from apps.tickets.services import create_ticket, reply_ticket
@@ -222,3 +223,107 @@ def test_staff_ticket_shows_call_history_without_claiming_connection(client, ann
assert "پذیرش درخواست، به‌تنهایی برقرار شدن" in content
assert configuration.announcement_url not in content
assert configuration.announcement_api_key not in content
def test_staff_can_queue_immediate_call_and_see_it_in_ticket(client, announcement_data):
category, staff, customer, configuration = announcement_data
ticket = create_ticket(customer=customer, created_by=staff, category=category, subject="تماس فوری", body="پیام نخست")
automatic = AnnouncementJob.objects.get(message__ticket=ticket)
MessageReadReceipt.objects.create(message=automatic.message, user=customer)
detail_url = reverse("staff_tickets:detail", args=[ticket.public_id])
call_url = reverse("staff_tickets:announcement_now", args=[ticket.public_id])
client.force_login(staff)
detail = client.get(detail_url).content.decode()
assert call_url in detail
assert "اطلاع‌رسانی تلفنی فوری" in detail
assert "پیام اطلاع‌رسانی از پیش ضبط‌شده" in detail
assert client.get(call_url).status_code == 405
response = client.post(call_url, follow=True)
assert response.status_code == 200
assert "درخواست اطلاع‌رسانی تلفنی فوری در صف قرار گرفت." in response.content.decode()
job = AnnouncementJob.objects.get(ticket=ticket, source=AnnouncementJob.Source.MANUAL)
assert job.message is None and job.requested_by == staff
assert job.next_attempt_at <= timezone.now()
assert "درخواست فوری" in response.content.decode()
assert AnnouncementJob.objects.get(pk=automatic.pk).status == AnnouncementJob.Status.PENDING
provider_response = Mock(content=b"{}")
provider_response.json.return_value = {}
with patch("apps.integrations.announcements.requests.post", return_value=provider_response) as post:
call_command("process_notifications", once=True)
job.refresh_from_db()
automatic.refresh_from_db()
assert job.status == AnnouncementJob.Status.SENT
assert automatic.status == AnnouncementJob.Status.PENDING
assert post.call_args.kwargs["json"] == {"customer_number": customer.local_mobile, "announcement_id": configuration.announcement_id}
assert post.call_args.kwargs["headers"]["Idempotency-Key"] == job.idempotency_key
def test_immediate_call_coalesces_repeated_clicks(client, announcement_data):
category, staff, customer, _ = announcement_data
ticket = create_ticket(customer=customer, category=category, subject="تماس فوری", body="پیام مشتری")
url = reverse("staff_tickets:announcement_now", args=[ticket.public_id])
client.force_login(staff)
assert client.post(url).status_code == 302
second = client.post(url, follow=True)
assert "در دقیقهٔ گذشته ثبت شده است" in second.content.decode()
assert AnnouncementJob.objects.filter(ticket=ticket, source=AnnouncementJob.Source.MANUAL).count() == 1
def test_immediate_call_requires_staff_reply_access_and_configuration(client, announcement_data):
category, staff, customer, configuration = announcement_data
ticket = create_ticket(customer=customer, category=category, subject="تماس فوری", body="پیام مشتری")
url = reverse("staff_tickets:announcement_now", args=[ticket.public_id])
outsider = User.objects.create_user(username="announcement-viewer", account_type="STAFF")
TicketAccessGrant.objects.create(ticket=ticket, user=outsider, granted_by=staff, permission_level=TicketAccessGrant.Level.VIEW_ONLY)
client.force_login(outsider)
assert url not in client.get(reverse("staff_tickets:detail", args=[ticket.public_id])).content.decode()
assert client.post(url).status_code == 403
client.force_login(customer)
assert client.post(url).status_code == 403
assert not AnnouncementJob.objects.filter(ticket=ticket).exists()
client.force_login(staff)
configuration.announcement_url = ""
configuration.save()
page = client.get(reverse("staff_tickets:detail", args=[ticket.public_id])).content.decode()
assert "اطلاع‌رسانی تلفنی فوری" in page and "disabled" in page
result = client.post(url, follow=True)
assert "تنظیمات اطلاع‌رسانی تلفنی کامل نیست." in result.content.decode()
assert not AnnouncementJob.objects.filter(ticket=ticket).exists()
def test_immediate_call_is_processed_before_older_scheduled_calls(client, announcement_data):
category, staff, customer, _ = announcement_data
ticket = create_ticket(customer=customer, created_by=staff, category=category, subject="تماس فوری", body="پیام نخست")
for message in ticket.ticket_messages.all():
due(AnnouncementJob.objects.get(message=message))
client.force_login(staff)
client.post(reverse("staff_tickets:announcement_now", args=[ticket.public_id]))
immediate = AnnouncementJob.objects.get(ticket=ticket, source=AnnouncementJob.Source.MANUAL)
with patch("apps.integrations.announcements.requests.post", return_value=Mock(content=b"")) as post:
call_command("process_notifications", once=True)
assert post.call_args_list[0].kwargs["headers"]["Idempotency-Key"] == immediate.idempotency_key
def test_immediate_call_retries_provider_outage_without_breaking_ticket(client, announcement_data):
category, staff, customer, _ = announcement_data
ticket = create_ticket(customer=customer, category=category, subject="تماس فوری", body="پیام مشتری")
url = reverse("staff_tickets:announcement_now", args=[ticket.public_id])
client.force_login(staff)
client.post(url)
job = AnnouncementJob.objects.get(ticket=ticket, source=AnnouncementJob.Source.MANUAL)
with patch("apps.integrations.announcements.requests.post", side_effect=requests.ConnectionError("provider secret")) as post:
call_command("process_notifications", once=True)
job.refresh_from_db()
assert job.status == AnnouncementJob.Status.PENDING
assert job.last_error == "service_unreachable" and job.attempts == 1
assert post.call_args.kwargs["headers"]["Idempotency-Key"] == job.idempotency_key
assert client.get(reverse("staff_tickets:detail", args=[ticket.public_id])).status_code == 200
due(job)
with patch("apps.integrations.announcements.requests.post", return_value=Mock(content=b"")) as retry:
call_command("process_notifications", once=True)
job.refresh_from_db()
assert job.status == AnnouncementJob.Status.SENT
assert retry.call_args.kwargs["headers"]["Idempotency-Key"] == job.idempotency_key