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
|
||||
|
||||
Reference in new issue
Block a user