button force anoncecall
This commit is contained in:
1 parent
c7993aae44
commit
9af91941d9
15 files changed
+294
-23
No files matched your search
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
+38
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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
@@ -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):
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"]')
|
||||
|
||||
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user