fix sms and ticket user frindly
This commit is contained in:
1 parent
1ee83374fd
commit
b8b3aba855
30 files changed
+2220
-119
No files matched your search
@@ -1,7 +1,7 @@
|
||||
from django.contrib import admin
|
||||
from .models import NotificationJob,NotificationPolicy,SMSTemplate
|
||||
@admin.register(NotificationJob)
|
||||
class NotificationJobAdmin(admin.ModelAdmin): list_display=("channel","event_type","status","attempts","provider_message_id","created_at"); list_filter=("channel","status"); readonly_fields=("provider_message_id","dedupe_key","created_at","sent_at")
|
||||
class NotificationJobAdmin(admin.ModelAdmin): list_display=("channel","event_type","status","attempts","provider_status_code","delivery_status","provider_message_id","created_at"); list_filter=("channel","status","delivery_status"); readonly_fields=("provider_message_id","provider_status_code","delivery_status_code","delivery_description","delivery_checked_at","dedupe_key","created_at","sent_at")
|
||||
@admin.register(SMSTemplate)
|
||||
class SMSTemplateAdmin(admin.ModelAdmin):
|
||||
list_display=("event_type","unit","title","is_active","updated_at")
|
||||
|
||||
@@ -6,6 +6,7 @@ import jdatetime
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.accounts.models import TicketMagicLink
|
||||
@@ -130,6 +131,103 @@ def _prepare_customer_reply_bale(job):
|
||||
locked.save(update_fields=["status","next_attempt_at","last_error"]); return False
|
||||
return True
|
||||
|
||||
|
||||
def _record_failure(job, error, provider_code=""):
|
||||
"""Persist a provider failure using the channel-specific retry policy."""
|
||||
job.attempts += 1
|
||||
job.last_error = str(error)[:1000]
|
||||
job.provider_status_code = str(provider_code or "")[:50]
|
||||
job.provider_message_id = ""
|
||||
if job.channel == NotificationJob.Channel.SMS:
|
||||
if job.attempts < 1 + settings.SMS_MAX_RETRIES:
|
||||
job.status = NotificationJob.Status.RETRYING
|
||||
job.next_attempt_at = timezone.now() + timedelta(seconds=settings.SMS_RETRY_DELAY_SECONDS)
|
||||
else:
|
||||
job.status = NotificationJob.Status.FAILED
|
||||
else:
|
||||
job.status = NotificationJob.Status.FAILED
|
||||
job.next_attempt_at = timezone.now() + timedelta(seconds=min(3600, 2**job.attempts * 10))
|
||||
|
||||
|
||||
def _record_success(job, result):
|
||||
job.attempts += 1
|
||||
job.status = NotificationJob.Status.SENT
|
||||
job.sent_at = timezone.now()
|
||||
job.last_error = ""
|
||||
job.provider_message_id = getattr(result, "provider_id", "") if result is not None else ""
|
||||
job.provider_status_code = getattr(result, "provider_code", "") if result is not None else ""
|
||||
if job.channel == NotificationJob.Channel.SMS and job.provider_message_id:
|
||||
job.delivery_status = NotificationJob.DeliveryStatus.PENDING
|
||||
job.delivery_next_check_at = timezone.now() + timedelta(seconds=settings.SMS_DELIVERY_CHECK_DELAY_SECONDS)
|
||||
|
||||
|
||||
def _process_delivery_checks(options):
|
||||
if options.get("channel") not in (None, NotificationJob.Channel.SMS):
|
||||
return
|
||||
now = timezone.now()
|
||||
with transaction.atomic():
|
||||
pollable_statuses = [
|
||||
NotificationJob.DeliveryStatus.PENDING,
|
||||
NotificationJob.DeliveryStatus.CHECK_FAILED,
|
||||
NotificationJob.DeliveryStatus.UNKNOWN,
|
||||
NotificationJob.DeliveryStatus.UNDELIVERED,
|
||||
]
|
||||
# Resume provisional results created by older worker versions, but do
|
||||
# not revive messages beyond the configured 45-minute monitoring span.
|
||||
monitor_cutoff = now - timedelta(seconds=settings.SMS_DELIVERY_CHECK_DELAY_SECONDS * settings.SMS_DELIVERY_MAX_CHECKS)
|
||||
NotificationJob.objects.filter(
|
||||
channel=NotificationJob.Channel.SMS,
|
||||
status=NotificationJob.Status.SENT,
|
||||
delivery_status__in=[NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.UNDELIVERED],
|
||||
delivery_next_check_at__isnull=True,
|
||||
delivery_check_attempts__lt=settings.SMS_DELIVERY_MAX_CHECKS,
|
||||
sent_at__gte=monitor_cutoff,
|
||||
).update(delivery_next_check_at=now)
|
||||
queryset = NotificationJob.objects.select_for_update(skip_locked=True).filter(
|
||||
channel=NotificationJob.Channel.SMS,
|
||||
status=NotificationJob.Status.SENT,
|
||||
delivery_status__in=pollable_statuses,
|
||||
delivery_next_check_at__lte=now,
|
||||
)
|
||||
if options.get("job_id"):
|
||||
queryset = queryset.filter(pk=options["job_id"])
|
||||
elif options.get("min_id"):
|
||||
queryset = queryset.filter(pk__gte=options["min_id"])
|
||||
jobs = list(queryset.order_by("delivery_next_check_at")[:20])
|
||||
# Lease each status check so parallel workers cannot query it twice.
|
||||
NotificationJob.objects.filter(pk__in=[job.pk for job in jobs]).update(
|
||||
delivery_next_check_at=now + timedelta(minutes=10),
|
||||
)
|
||||
provider = get_sms_provider()
|
||||
for job in jobs:
|
||||
result = provider.get_delivery_status(job.provider_message_id)
|
||||
if result is None:
|
||||
job.delivery_status = ""
|
||||
job.delivery_next_check_at = None
|
||||
job.delivery_description = "این ارائهدهنده از استعلام دلیوری پشتیبانی نمیکند."
|
||||
else:
|
||||
job.delivery_check_attempts += 1
|
||||
job.delivery_checked_at = timezone.now()
|
||||
job.delivery_status_code = result.provider_code[:50]
|
||||
job.delivery_description = result.description[:500]
|
||||
if result.is_final:
|
||||
job.delivery_status = result.status
|
||||
job.delivery_next_check_at = None
|
||||
elif job.delivery_check_attempts < settings.SMS_DELIVERY_MAX_CHECKS:
|
||||
job.delivery_status = result.status
|
||||
job.delivery_next_check_at = timezone.now() + timedelta(seconds=settings.SMS_DELIVERY_CHECK_DELAY_SECONDS)
|
||||
else:
|
||||
job.delivery_status = result.status if result.status in NotificationJob.DeliveryStatus.values else NotificationJob.DeliveryStatus.ERROR
|
||||
job.delivery_next_check_at = None
|
||||
job.delivery_description = f"{result.description} نتیجه پس از {job.delivery_check_attempts} نوبت استعلام در بازه پایش نهایی شد."[:500]
|
||||
job.save(update_fields=[
|
||||
"delivery_status", "delivery_status_code", "delivery_description",
|
||||
"delivery_checked_at", "delivery_check_attempts", "delivery_next_check_at",
|
||||
])
|
||||
if job.ticket_id and job.ticket.group_dispatch_id:
|
||||
from apps.tickets.services import refresh_group_dispatch_status
|
||||
refresh_group_dispatch_status(job.ticket.group_dispatch_id)
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Process notification outbox"
|
||||
def add_arguments(self, parser):
|
||||
@@ -140,9 +238,15 @@ class Command(BaseCommand):
|
||||
def handle(self, *args, **options):
|
||||
while True:
|
||||
stale = timezone.now() - timedelta(minutes=10)
|
||||
NotificationJob.objects.filter(status="PROCESSING", next_attempt_at__lt=stale).update(status="FAILED", next_attempt_at=timezone.now(), last_error="Recovered stale processing job")
|
||||
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")
|
||||
with transaction.atomic():
|
||||
queryset = NotificationJob.objects.select_for_update(skip_locked=True).filter(status__in=["PENDING", "FAILED"], attempts__lt=settings.MAX_NOTIFICATION_ATTEMPTS, next_attempt_at__lte=timezone.now())
|
||||
sms_limit = 1 + settings.SMS_MAX_RETRIES
|
||||
queryset = NotificationJob.objects.select_for_update(skip_locked=True).filter(
|
||||
Q(channel=NotificationJob.Channel.SMS, attempts__lt=sms_limit, status__in=[NotificationJob.Status.PENDING, NotificationJob.Status.RETRYING])
|
||||
| Q(channel=NotificationJob.Channel.BALE, attempts__lt=settings.MAX_NOTIFICATION_ATTEMPTS, status__in=[NotificationJob.Status.PENDING, NotificationJob.Status.FAILED]),
|
||||
next_attempt_at__lte=timezone.now(),
|
||||
)
|
||||
if options.get("channel"):
|
||||
queryset = queryset.filter(channel=options["channel"])
|
||||
if options.get("job_id"):
|
||||
@@ -160,15 +264,22 @@ class Command(BaseCommand):
|
||||
continue
|
||||
if job.channel == NotificationJob.Channel.SMS: result, issued_link = _send_sms(job)
|
||||
else: result = _send_bale(job)
|
||||
if result is not None and not result.success: raise RuntimeError(result.error or "Provider rejected notification")
|
||||
job.attempts += 1; job.status = "SENT"; job.sent_at = timezone.now(); job.last_error = ""; job.provider_message_id = getattr(result,"provider_id","") if result is not None else ""
|
||||
if result is not None and not result.success:
|
||||
_record_failure(job, result.error or "ارائهدهنده پیام را نپذیرفت.", getattr(result, "provider_code", ""))
|
||||
if issued_link:
|
||||
issued_link.revoked_at = timezone.now()
|
||||
issued_link.save(update_fields=["revoked_at"])
|
||||
else:
|
||||
_record_success(job, result)
|
||||
except Exception as exc:
|
||||
if issued_link:
|
||||
issued_link.revoked_at = timezone.now(); issued_link.save(update_fields=["revoked_at"])
|
||||
job.attempts += 1; job.last_error = str(exc)[:1000]; job.provider_message_id = ""; job.status = "FAILED"; job.next_attempt_at = timezone.now() + timedelta(seconds=min(3600, 2**job.attempts * 10)); logger.exception("notification failed id=%s", job.pk)
|
||||
_record_failure(job, exc)
|
||||
logger.exception("notification failed id=%s", job.pk)
|
||||
job.save()
|
||||
if job.ticket_id and job.ticket.group_dispatch_id:
|
||||
from apps.tickets.services import refresh_group_dispatch_status
|
||||
refresh_group_dispatch_status(job.ticket.group_dispatch_id)
|
||||
_process_delivery_checks(options)
|
||||
if options["once"] or options.get("job_id"): break
|
||||
time.sleep(5)
|
||||
@@ -0,0 +1,32 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("notifications", "0010_use_actual_unit_in_staff_ticket_sms")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="notificationjob",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("PENDING", "در صف"),
|
||||
("PROCESSING", "در حال ارسال"),
|
||||
("RETRYING", "در انتظار تلاش مجدد"),
|
||||
("SENT", "ارسالشده"),
|
||||
("FAILED", "ناموفق"),
|
||||
("CANCELLED", "لغوشده"),
|
||||
],
|
||||
db_index=True,
|
||||
default="PENDING",
|
||||
max_length=15,
|
||||
),
|
||||
),
|
||||
migrations.AddField(model_name="notificationjob", name="provider_status_code", field=models.CharField(blank=True, max_length=50)),
|
||||
migrations.AddField(model_name="notificationjob", name="delivery_status", field=models.CharField(blank=True, choices=[("PENDING", "در انتظار استعلام"), ("DELIVERED", "رسیده به گوشی"), ("UNDELIVERED", "نرسیده به گوشی"), ("EXPIRED", "منقضیشده"), ("BLOCKED", "مسدود توسط مخاطب"), ("UNKNOWN", "نامشخص"), ("ERROR", "خطای دلیوری"), ("CHECK_FAILED", "خطای موقت استعلام")], db_index=True, max_length=20)),
|
||||
migrations.AddField(model_name="notificationjob", name="delivery_status_code", field=models.CharField(blank=True, max_length=50)),
|
||||
migrations.AddField(model_name="notificationjob", name="delivery_description", field=models.CharField(blank=True, max_length=500)),
|
||||
migrations.AddField(model_name="notificationjob", name="delivery_checked_at", field=models.DateTimeField(blank=True, null=True)),
|
||||
migrations.AddField(model_name="notificationjob", name="delivery_check_attempts", field=models.PositiveSmallIntegerField(default=0)),
|
||||
migrations.AddField(model_name="notificationjob", name="delivery_next_check_at", field=models.DateTimeField(blank=True, db_index=True, null=True)),
|
||||
]
|
||||
@@ -44,7 +44,23 @@ class NotificationPolicy(models.Model):
|
||||
|
||||
class NotificationJob(models.Model):
|
||||
class Channel(models.TextChoices): SMS="SMS","پیامک"; BALE="BALE","بله"
|
||||
class Status(models.TextChoices): PENDING="PENDING","در صف"; PROCESSING="PROCESSING","در حال ارسال"; SENT="SENT","ارسالشده"; FAILED="FAILED","ناموفق"; CANCELLED="CANCELLED","لغوشده"
|
||||
class Status(models.TextChoices): PENDING="PENDING","در صف"; PROCESSING="PROCESSING","در حال ارسال"; RETRYING="RETRYING","در انتظار تلاش مجدد"; SENT="SENT","ارسالشده"; FAILED="FAILED","ناموفق"; CANCELLED="CANCELLED","لغوشده"
|
||||
class DeliveryStatus(models.TextChoices):
|
||||
PENDING="PENDING","در انتظار استعلام"
|
||||
DELIVERED="DELIVERED","رسیده به گوشی"
|
||||
UNDELIVERED="UNDELIVERED","نرسیده به گوشی"
|
||||
EXPIRED="EXPIRED","منقضیشده"
|
||||
BLOCKED="BLOCKED","مسدود توسط مخاطب"
|
||||
UNKNOWN="UNKNOWN","نامشخص"
|
||||
ERROR="ERROR","خطای دلیوری"
|
||||
CHECK_FAILED="CHECK_FAILED","خطای موقت استعلام"
|
||||
channel=models.CharField(max_length=10,choices=Channel.choices); event_type=models.CharField(max_length=50); customer=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE,null=True,blank=True,related_name="customer_notifications"); staff_user=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE,null=True,blank=True,related_name="staff_notifications"); ticket=models.ForeignKey("tickets.Ticket",on_delete=models.CASCADE,null=True,blank=True); payload=models.JSONField(default=dict); 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); last_error=models.TextField(blank=True); provider_message_id=models.CharField(max_length=500,blank=True); dedupe_key=models.CharField(max_length=200,unique=True); created_at=models.DateTimeField(auto_now_add=True); sent_at=models.DateTimeField(null=True,blank=True)
|
||||
provider_status_code=models.CharField(max_length=50,blank=True)
|
||||
delivery_status=models.CharField(max_length=20,choices=DeliveryStatus.choices,blank=True,db_index=True)
|
||||
delivery_status_code=models.CharField(max_length=50,blank=True)
|
||||
delivery_description=models.CharField(max_length=500,blank=True)
|
||||
delivery_checked_at=models.DateTimeField(null=True,blank=True)
|
||||
delivery_check_attempts=models.PositiveSmallIntegerField(default=0)
|
||||
delivery_next_check_at=models.DateTimeField(null=True,blank=True,db_index=True)
|
||||
class Meta:
|
||||
indexes=[models.Index(fields=["status","next_attempt_at"],name="notif_due_idx"),models.Index(fields=["ticket","channel","status"],name="notif_ticket_chan_idx")]
|
||||
Reference in new issue
Block a user