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