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):