fix button force anoncecall

This commit is contained in:
javad committed 2026-09-28 21:13:19 +03:30
1 parent 9af91941d9
commit 4525ec3e6a
9 files changed
+99 -48

No files matched your search

+5 -3
View File
@@ -85,7 +85,7 @@ def _deliver(job):
job.last_error = ""
def process_due_announcements():
def process_due_announcements(*, job_id=None):
now = timezone.now()
# A crashed worker can safely retry with the same provider idempotency key.
AnnouncementJob.objects.filter(
@@ -93,13 +93,15 @@ def process_due_announcements():
next_attempt_at__lt=now - timedelta(minutes=10),
).update(status=AnnouncementJob.Status.PENDING, next_attempt_at=now)
with transaction.atomic():
jobs = list(
queryset = (
AnnouncementJob.objects.select_for_update(skip_locked=True)
.filter(status=AnnouncementJob.Status.PENDING, next_attempt_at__lte=now)
.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]
)
if job_id is not None:
queryset = queryset.filter(pk=job_id)
jobs = list(queryset.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,
)
@@ -251,9 +251,19 @@ def _process_delivery_checks(options):
NotificationJob.objects.filter(pk__in=[job.pk for job in jobs]).update(
delivery_next_check_at=now + timedelta(minutes=10),
)
provider = get_sms_provider()
if not jobs:
return
try:
provider = get_sms_provider()
except Exception:
logger.exception("SMS delivery provider initialization failed")
return
for job in jobs:
result = provider.get_delivery_status(job.provider_message_id)
try:
result = provider.get_delivery_status(job.provider_message_id)
except Exception:
logger.exception("SMS delivery check failed for job %s", job.pk)
continue
if result is None:
job.delivery_status = ""
job.delivery_next_check_at = None
+13 -2
View File
@@ -472,8 +472,19 @@ def staff_announcement_now(request, public_id):
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 "درخواست اطلاع‌رسانی تلفنی این تیکت در دقیقهٔ گذشته ثبت شده است.")
job,created=schedule_immediate_announcement(ticket,request.user)
if created:
from apps.notifications.announcement_worker import process_due_announcements
process_due_announcements(job_id=job.pk)
job.refresh_from_db()
if job.status == AnnouncementJob.Status.SENT:
messages.success(request,"درخواست تماس توسط سرویس پذیرفته شد.")
elif job.attempts:
messages.warning(request,"سرویس تماس پاسخ نداد؛ تلاش مجدد در صف قرار گرفت.")
else:
messages.info(request,"درخواست تماس در حال پردازش است.")
else:
messages.info(request,"درخواست اطلاع‌رسانی تلفنی این تیکت در دقیقهٔ گذشته ثبت شده است.")
return redirect("staff_tickets:detail",public_id=public_id)
@staff_required
@require_POST
+3 -7
View File
@@ -1,15 +1,11 @@
/* 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{display:grid;gap:.3rem;padding:.65rem;border:1px solid #c9e4df;background:#f8fcfb}
.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{display:flex;align-items:center;justify-content:center;gap:.4rem;width:100%;min-height:2.25rem;border:1px solid #0f766e;border-radius:.55rem;background:#0f766e;color:#fff;font-size:.73rem;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}
.immediate-announcement-card small{color:#607574;font-size:.62rem;line-height:1.45;text-align:center}
.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)}
@@ -1,13 +1,11 @@
{% 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> ارسال اطلاع‌رسانی تلفنی فوری
<span aria-hidden="true">☎</span> اطلاع‌رسانی تلفنی فوری
</button>
</form>
<small>{% if announcement_ready %}درخواست در صف تماس ثبت می‌شود؛ نتیجه را در وضعیت اطلاع‌رسانی‌های تلفنی ببینید.{% else %}شماره مخاطب یا تنظیمات سرویس تماس در دسترس نیست.{% endif %}</small>
<small>{% if announcement_ready %}پخش پیام صوتی از پیش ضبط‌شده برای مخاطب{% else %}شماره مخاطب یا تنظیمات تماس در دسترس نیست.{% endif %}</small>
</section>
{% endif %}
+1 -1
View File
@@ -17,8 +17,8 @@
<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/_immediate_announcement.html' %}
{% 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>
{% if can_delete %}<section class="info-card danger-zone"><h3>حذف کامل تیکت</h3><p class="muted">تمام پیام‌ها، فایل‌ها و سوابق تیکت برای همیشه حذف می‌شوند.</p><form method="post" action="{% url 'staff_tickets:delete' ticket.public_id %}" onsubmit="return confirm('آیا از حذف کامل و غیرقابل‌بازگشت این تیکت مطمئن هستید؟')">{% csrf_token %}<button type="submit" class="danger-button">حذف کامل تیکت</button></form></section>{% endif %}
+3 -3
View File
@@ -11,12 +11,12 @@ class TicketConversationPage(BasePage):
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(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_text("درخواست تماس توسط سرویس پذیرفته شد.")).to_be_visible()
expect(self.page.get_by_role("region", name="وضعیت اطلاع‌رسانی‌های تلفنی")).to_contain_text("درخواست فوری")
def send_reply(self, body):
+4 -1
View File
@@ -1,4 +1,5 @@
import base64
from unittest.mock import Mock, patch
import pytest
from django.contrib.auth.models import Permission
@@ -41,7 +42,9 @@ def test_staff_can_request_immediate_announcement_from_ticket_sidebar(live_serve
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()
with patch("apps.integrations.announcements.requests.post", return_value=Mock(content=b"")) as post:
conversation.request_immediate_announcement()
post.assert_called_once()
def test_staff_operations_pages_are_authorized_and_visually_styled(live_server, browser_page):
+56 -25
View File
@@ -11,7 +11,7 @@ from apps.accounts.models import User
from apps.core.admin import VoipConfigurationAdmin
from apps.core.models import VoipConfiguration
from apps.messaging.models import MessageReadReceipt
from apps.notifications.models import AnnouncementJob
from apps.notifications.models import AnnouncementJob, NotificationJob
from apps.staff.models import OrganizationalUnit
from apps.tickets.models import TicketAccessGrant, TicketCategory
from apps.tickets.services import create_ticket, reply_ticket
@@ -34,7 +34,7 @@ def announcement_people(db):
@pytest.fixture
def announcement_data(announcement_catalog, announcement_people):
configuration = VoipConfiguration.objects.create(
announcement_url="http://192.168.138.215:8000/v1/announcements",
announcement_url="http://37.255.224.143:8009/v1/announcements",
announcement_id="custom/fereshte1_G711",
announcement_delay_minutes=90,
)
@@ -236,28 +236,26 @@ def test_staff_can_queue_immediate_call_and_see_it_in_ticket(client, announcemen
detail = client.get(detail_url).content.decode()
assert call_url in detail
assert "اطلاع‌رسانی تلفنی فوری" in detail
assert "پیام اطلاع‌رسانی از پیش ضبط‌شده" in detail
assert "پیام صوتی از پیش ضبط‌شده" in detail
assert detail.index("وضعیت پیامک‌های مشتری") < detail.index('aria-label="اطلاع‌رسانی تلفنی فوری"')
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()
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.status == AnnouncementJob.Status.SENT
assert automatic.status == AnnouncementJob.Status.PENDING
assert "درخواست فوری" in response.content.decode()
assert AnnouncementJob.objects.get(pk=automatic.pk).status == AnnouncementJob.Status.PENDING
post.assert_called_once()
assert post.call_args.args == (configuration.announcement_url,)
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
assert post.call_args.kwargs["headers"] == {"Idempotency-Key": job.idempotency_key}
def test_immediate_call_coalesces_repeated_clicks(client, announcement_data):
@@ -265,10 +263,12 @@ def test_immediate_call_coalesces_repeated_clicks(client, 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)
with patch("apps.integrations.announcements.requests.post", return_value=Mock(content=b"")) as post:
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
post.assert_called_once()
def test_immediate_call_requires_staff_reply_access_and_configuration(client, announcement_data):
@@ -300,11 +300,14 @@ def test_immediate_call_is_processed_before_older_scheduled_calls(client, announ
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]))
with patch("apps.integrations.announcements.requests.post", return_value=Mock(content=b"")) as immediate_post:
client.post(reverse("staff_tickets:announcement_now", args=[ticket.public_id]))
immediate = AnnouncementJob.objects.get(ticket=ticket, source=AnnouncementJob.Source.MANUAL)
assert immediate.status == AnnouncementJob.Status.SENT
immediate_post.assert_called_once()
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
assert all(call.kwargs["headers"]["Idempotency-Key"] != immediate.idempotency_key for call in post.call_args_list)
def test_immediate_call_retries_provider_outage_without_breaking_ticket(client, announcement_data):
@@ -312,11 +315,10 @@ def test_immediate_call_retries_provider_outage_without_breaking_ticket(client,
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()
result = client.post(url, follow=True)
job = AnnouncementJob.objects.get(ticket=ticket, source=AnnouncementJob.Source.MANUAL)
assert "تلاش مجدد در صف قرار گرفت" in result.content.decode()
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
@@ -327,3 +329,32 @@ def test_immediate_call_retries_provider_outage_without_breaking_ticket(client,
job.refresh_from_db()
assert job.status == AnnouncementJob.Status.SENT
assert retry.call_args.kwargs["headers"]["Idempotency-Key"] == job.idempotency_key
@pytest.mark.django_db
def test_worker_skips_sms_provider_when_no_delivery_check_is_due():
with patch("apps.notifications.management.commands.process_notifications.get_sms_provider", side_effect=RuntimeError("SMS provider unavailable")) as sms_provider:
call_command("process_notifications", once=True)
sms_provider.assert_not_called()
def test_sms_delivery_provider_error_does_not_stop_worker(announcement_data):
category, staff, customer, _ = announcement_data
ticket = create_ticket(customer=customer, created_by=staff, category=category, subject="تماس فوری", body="پیام")
sms_job = NotificationJob.objects.create(
channel=NotificationJob.Channel.SMS,
event_type="CUSTOMER_TICKET_CREATED",
ticket=ticket,
customer=customer,
status=NotificationJob.Status.SENT,
next_attempt_at=timezone.now(),
delivery_status=NotificationJob.DeliveryStatus.PENDING,
delivery_next_check_at=timezone.now() - timedelta(seconds=1),
provider_message_id="provider-id",
dedupe_key="sms-delivery-failure-does-not-stop-worker",
)
with patch("apps.notifications.management.commands.process_notifications.get_sms_provider", side_effect=RuntimeError("provider unavailable")):
call_command("process_notifications", once=True)
sms_job.refresh_from_db()
assert sms_job.delivery_status == NotificationJob.DeliveryStatus.PENDING
assert sms_job.delivery_next_check_at > timezone.now()