fix sms and ticket user frindly

This commit is contained in:
javad committed 2026-09-14 15:48:13 +03:30
1 parent 1ee83374fd
commit b8b3aba855
30 files changed
+2220 -119

No files matched your search

+1 -1
View File
@@ -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)),
]
+17 -1
View File
@@ -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")]