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

+12
View File
@@ -7,6 +7,14 @@ class SMSResult:
success: bool
provider_id: str = ""
error: str = ""
provider_code: str = ""
@dataclass(frozen=True, slots=True)
class SMSDeliveryResult:
status: str
provider_code: str
description: str
is_final: bool = True
def webotp_message(code):
host=settings.WEBOTP_HOST or urlparse(settings.PUBLIC_BASE_URL).hostname
if not host: raise ValueError("WEBOTP_HOST یا PUBLIC_BASE_URL معتبر تنظیم نشده است.")
@@ -16,3 +24,7 @@ class SMSProvider(ABC):
def send_otp(self,mobile,code): return self.send_message(mobile,webotp_message(code))
@abstractmethod
def send_message(self,mobile,message): raise NotImplementedError
def get_delivery_status(self, provider_id):
"""Return None when a provider does not support delivery tracking."""
return None
+2 -2
View File
@@ -1,7 +1,7 @@
"""Backward-compatible public facade for the SMS integration."""
from .base import SMSProvider,SMSResult,webotp_message
from .base import SMSDeliveryResult,SMSProvider,SMSResult,webotp_message
from .behin import BehinSMSProvider
from .console import ConsoleSMSProvider
from .factory import get_sms_provider,reset_sms_provider
from .http import HTTPSMSProvider
__all__=["SMSProvider","SMSResult","webotp_message","BehinSMSProvider","ConsoleSMSProvider","HTTPSMSProvider","get_sms_provider","reset_sms_provider"]
__all__=["SMSProvider","SMSResult","SMSDeliveryResult","webotp_message","BehinSMSProvider","ConsoleSMSProvider","HTTPSMSProvider","get_sms_provider","reset_sms_provider"]
+154 -18
View File
@@ -1,36 +1,172 @@
import logging
import re
import requests
import zeep
from django.conf import settings
from zeep.transports import Transport
from apps.accounts.models import normalize_mobile
from .base import SMSProvider, SMSResult
from .base import SMSDeliveryResult, SMSProvider, SMSResult
logger = logging.getLogger(__name__)
# Source: the vendor's "کدهای خطا وب سرویس" table. Keeping the mapping here
# makes worker logs and the staff UI report the same exact reason.
SEND_RESULT_MESSAGES = {
"2": "ارسال با موفقیت انجام شد.",
"-3": "نام کاربری و کلمه عبور با هم تطابق ندارند؛ لطفاً با پشتیبانی 0098SMS تماس بگیرید.",
"10": "نام کاربری یا کلمه عبور اشتباه است؛ لطفاً با پشتیبانی 0098SMS تماس بگیرید.",
"11": "در متن پیامک کاراکتر غیرمجاز وجود دارد.",
"-17": "متن پیامک خالی است.",
"-18": "خطای شارژ رخ داده است؛ لطفاً با پشتیبانی 0098SMS تماس بگیرید.",
"-19": "شارژ پنل برای ارسال کافی نیست؛ لطفاً پنل را شارژ کنید.",
"-22": "شماره موبایل گیرنده صحیح نیست.",
"66": "نام کاربری و کلمه عبور با هم تطابق ندارند؛ لطفاً با پشتیبانی 0098SMS تماس بگیرید.",
"1111": "در متن پیامک کاراکتر غیرمجاز وجود دارد.",
"hang": "حساب کاربری مسدود است؛ لطفاً با پشتیبانی 0098SMS تماس بگیرید.",
"no doc": "مرحله دوم ثبت‌نام انجام نشده است؛ لطفاً ثبت‌نام را کامل کنید.",
"-23": "شماره پنل مجوز ارسال از لینک را ندارد.",
"-24": "در شماره پنل خطا وجود دارد؛ لطفاً با پشتیبانی 0098SMS تماس بگیرید.",
"-25": "اعتبار شماره پنل به پایان رسیده است و باید تمدید شود.",
"-26": "تنظیمات کد OTP انجام نشده است.",
"-27": "فرمت کد OTP صحیح نیست.",
"-28": "تنظیمات کد OTP توسط ادمین تأیید نشده است.",
"wrong timestamp": "مقدار timestamp ارسال‌شده صحیح نیست.",
"wrong time": "زمان ارسال خارج از بازه مجاز است.",
"-29": "فرمت کد OTP صحیح نیست.",
"-31": "شناسه پیامک (smsid) یافت نشد.",
"-32": "شناسه پیامک (smsid) مطابقت ندارد.",
"13": "ارسال از IP غیرمجاز انجام شده است.",
}
DELIVERY_RESULTS = {
"4": ("BLOCKED", "مخاطب دریافت پیامک‌های تبلیغاتی را مسدود کرده است.", True),
"5": ("DELIVERED", "پیامک به گوشی گیرنده رسیده است.", True),
# 0098SMS can initially return 6 or 8 and publish the definitive result
# much later. Keep polling these states instead of freezing a stale result.
"6": ("UNDELIVERED", "هنوز تحویل پیامک به گوشی تأیید نشده است.", False),
"7": ("EXPIRED", "مهلت دریافت پیامک منقضی شده است.", True),
"8": ("UNKNOWN", "وضعیت تحویل هنوز توسط اپراتور مشخص نشده است.", False),
"9": ("ERROR", "اطلاعات مالک شماره پیامک ثبت یا تأیید نشده است.", True),
"12": ("ERROR", "هنوز اجازه ارسال با این شماره پنل داده نشده است.", True),
"13": ("ERROR", "ارسال از IP غیرمجاز انجام شده است.", True),
}
def _scalar(value):
if isinstance(value, (list, tuple)) and value:
value = value[0]
return str(value if value is not None else "").strip()
class ZeroZeroNinetyEightSMSProvider(SMSProvider):
"""Adapter for the 0098SMS HTTP gateway."""
"""SOAP adapter for 0098SMS (0098vip-compatible web service)."""
def __init__(self):
self.username = settings.SMS0098_USERNAME
self.password = settings.SMS0098_PASSWORD
self.sender = settings.SMS0098_FROM
self.api_url = settings.SMS0098_API_URL
self.wsdl_url = settings.SMS0098_WSDL_URL
self._client = None
def _configuration_error(self):
missing = [name for name, value in (
("SMS0098_USERNAME", self.username),
("SMS0098_PASSWORD", self.password),
("SMS0098_FROM", self.sender),
) if not value]
return f"تنظیمات 0098SMS ناقص است: {', '.join(missing)}" if missing else ""
@property
def client(self):
if self._client is None:
timeout = settings.INTEGRATION_HTTP_TIMEOUT_SECONDS
self._client = zeep.Client(
wsdl=self.wsdl_url,
transport=Transport(timeout=timeout, operation_timeout=timeout),
)
return self._client
def _safe_exception(self, exc):
message = str(exc)
for secret in (self.username, self.password):
if secret:
message = message.replace(secret, "***")
return message[:500]
def _call(self, operation, parameters):
method = getattr(self.client.service, operation)
# The live 0098sms.com WSDL exposes separate scalar arguments. This
# deliberately follows the WSDL signature instead of the outdated
# dict-shaped invocation printed in the vendor PDF sample.
return method(**parameters)
def _parse_send_result(self, response):
raw = _scalar(response)
normalized = raw.casefold()
if re.fullmatch(r"\d{9,}", raw):
return SMSResult(True, provider_id=raw, provider_code=raw)
# 2 is the SOAP success code. 0 is retained only for compatibility
# with the vendor's older link endpoint.
if raw in {"0", "2"}:
return SMSResult(True, provider_code=raw)
reason = SEND_RESULT_MESSAGES.get(normalized)
if reason:
return SMSResult(False, error=f"0098SMS [کد {raw}]: {reason}", provider_code=raw)
shown = raw[:120] or "پاسخ خالی"
return SMSResult(False, error=f"0098SMS پاسخ ناشناخته برگرداند: {shown}", provider_code=raw[:50])
def send_message(self, mobile, message):
missing = [name for name, value in (("SMS0098_USERNAME", self.username), ("SMS0098_PASSWORD", self.password), ("SMS0098_FROM", self.sender)) if not value]
if missing:
return SMSResult(False, error=f"تنظیمات 0098SMS ناقص است: {', '.join(missing)}")
error = self._configuration_error()
if error:
return SMSResult(False, error=error, provider_code="CONFIG")
try:
recipient = int(normalize_mobile(str(mobile)).removeprefix("+98"))
response = requests.get(self.api_url, params={"USERNAME": self.username, "PASSWORD": self.password, "FROM": self.sender, "TO": recipient, "TEXT": str(message)}, timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS)
response.raise_for_status()
# This legacy endpoint may append an HTML page containing the
# original query string. Persist only its first scalar result so
# credentials can never leak into notification logs.
provider_id = response.text.strip().splitlines()[0][:100]
return SMSResult(True, provider_id=provider_id)
except (ValueError, requests.RequestException) as exc:
logger.exception("0098SMS delivery failed")
return SMSResult(False, error=str(exc)[:1000])
recipient = normalize_mobile(str(mobile)).removeprefix("+98")
if not recipient.startswith("0"):
recipient = f"0{recipient}"
response = self._call("SendSMSWithID", {
"username": self.username,
"password": self.password,
"mobileno": recipient,
"pnlno": str(self.sender),
"text": str(message),
})
return self._parse_send_result(response)
except Exception as exc:
logger.exception("0098SMS send failed")
return SMSResult(
False,
error=f"خطا در ارتباط با وب‌سرویس 0098SMS: {self._safe_exception(exc)}",
provider_code="CONNECTION",
)
def get_delivery_status(self, provider_id):
error = self._configuration_error()
if error:
return SMSDeliveryResult("ERROR", "CONFIG", error)
if not re.fullmatch(r"\d{9,}", str(provider_id or "")):
return SMSDeliveryResult("ERROR", "INVALID_ID", "شناسه رهگیری 0098SMS معتبر نیست.")
try:
response = self._call("smsdeliveryState", {
"username": self.username,
"password": self.password,
"smsid": str(provider_id),
})
code = _scalar(response)
mapped = DELIVERY_RESULTS.get(code)
if mapped:
status, description, is_final = mapped
return SMSDeliveryResult(status, code, f"0098SMS [کد {code}]: {description}", is_final=is_final)
reason = SEND_RESULT_MESSAGES.get(code.casefold())
if reason:
return SMSDeliveryResult("ERROR", code, f"0098SMS [کد {code}]: {reason}")
return SMSDeliveryResult("UNKNOWN", code[:50], f"0098SMS وضعیت دلیوری ناشناخته برگرداند: {code[:120] or 'پاسخ خالی'}")
except Exception as exc:
logger.exception("0098SMS delivery status lookup failed")
return SMSDeliveryResult(
"CHECK_FAILED",
"CONNECTION",
f"خطا در استعلام دلیوری 0098SMS: {self._safe_exception(exc)}",
is_final=False,
)
+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")]
+6 -5
View File
@@ -1,7 +1,7 @@
from datetime import timedelta
from django.core.exceptions import PermissionDenied,ValidationError
from django.db import transaction
from django.db.models import Count
from django.db.models import Count,Q
from django.utils import timezone
from apps.attachments.services import save_attachments
from apps.core.persian_text import normalize_persian_text
@@ -100,11 +100,12 @@ def refresh_group_dispatch_status(dispatch_id):
"""Persist delivery state independently of whether a report page is opened."""
dispatch=GroupDispatch.objects.filter(pk=dispatch_id).first()
if not dispatch: return None
counts={row["status"]:row["count"] for row in NotificationJob.objects.filter(ticket__group_dispatch_id=dispatch_id,channel=NotificationJob.Channel.SMS,event_type="STAFF_TICKET_CREATED").values("status").annotate(count=Count("id"))}
jobs=NotificationJob.objects.filter(ticket__group_dispatch_id=dispatch_id,channel=NotificationJob.Channel.SMS,event_type="STAFF_TICKET_CREATED")
counts={row["status"]:row["count"] for row in jobs.values("status").annotate(count=Count("id"))}
sent=counts.get(NotificationJob.Status.SENT,0)
failed=counts.get(NotificationJob.Status.FAILED,0)
if dispatch.recipient_count and sent>=dispatch.recipient_count: status=GroupDispatch.Status.SENT
elif failed: status=GroupDispatch.Status.PARTIAL
failed=jobs.filter(Q(status=NotificationJob.Status.FAILED) | Q(status=NotificationJob.Status.SENT,delivery_status__in=[NotificationJob.DeliveryStatus.UNDELIVERED,NotificationJob.DeliveryStatus.EXPIRED,NotificationJob.DeliveryStatus.BLOCKED,NotificationJob.DeliveryStatus.UNKNOWN,NotificationJob.DeliveryStatus.ERROR],delivery_next_check_at__isnull=True)).count()
if failed: status=GroupDispatch.Status.PARTIAL
elif dispatch.recipient_count and sent>=dispatch.recipient_count: status=GroupDispatch.Status.SENT
else: status=GroupDispatch.Status.QUEUED
if dispatch.status!=status:
GroupDispatch.objects.filter(pk=dispatch_id).update(status=status,updated_at=timezone.now())
+62 -8
View File
@@ -395,8 +395,9 @@ def staff_detail(request,public_id):
mark_messages_read(conversation,request.user)
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=ticket.notificationjob_set.filter(channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("-created_at")
latest=conversation[-1] if conversation else None
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),"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,"viewer_user":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),"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,"viewer_user":request.user,"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else ""})
@staff_required
def staff_messages(request,public_id):
ticket=get_object_or_404(Ticket,public_id=public_id)
@@ -559,19 +560,59 @@ def staff_dispatch_detail(request, public_id):
if not DispatchPolicy.can_view(request.user, dispatch):
raise PermissionDenied
tickets = list(dispatch.tickets.select_related("customer__customer_profile").prefetch_related("magic_links", "ticket_messages", "notificationjob_set").order_by("customer_id"))
delivery_failures = {
NotificationJob.DeliveryStatus.UNDELIVERED,
NotificationJob.DeliveryStatus.EXPIRED,
NotificationJob.DeliveryStatus.BLOCKED,
NotificationJob.DeliveryStatus.UNKNOWN,
NotificationJob.DeliveryStatus.ERROR,
}
now = timezone.now()
for ticket in tickets:
ticket.sms_job = next((job for job in ticket.notificationjob_set.all() if job.channel == NotificationJob.Channel.SMS), None)
ticket.sms_job = next((job for job in ticket.notificationjob_set.all() if job.channel == NotificationJob.Channel.SMS and job.event_type == SMSTemplate.Event.STAFF_TICKET_CREATED), None)
ticket.sms_retryable = bool(ticket.sms_job and (ticket.sms_job.status == NotificationJob.Status.FAILED or (ticket.sms_job.delivery_status in delivery_failures and not ticket.sms_job.delivery_next_check_at)))
ticket.latest_customer_response = next((message for message in reversed(list(ticket.ticket_messages.all())) if message.author_id == ticket.customer_id), None)
sms_jobs = NotificationJob.objects.filter(ticket__group_dispatch=dispatch, channel=NotificationJob.Channel.SMS)
links = list(ticket.magic_links.all())
ticket.magic_total_views = sum(link.view_count for link in links)
ticket.magic_link_count = len(links)
ticket.magic_active_count = sum(not link.revoked_at and link.expires_at > now for link in links)
ticket.magic_last_used_at = max((link.used_at for link in links if link.used_at), default=None)
job = ticket.sms_job
if not job:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در صف ثبت نشده", "برای این مخاطب job پیامک پیدا نشد.", "neutral"
elif job.status == NotificationJob.Status.FAILED:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "ارسال ناموفق", f"ارسال خودکار پس از {1 + settings.SMS_MAX_RETRIES} تلاش متوقف شده است.", "danger"
elif job.status == NotificationJob.Status.RETRYING:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در انتظار تلاش مجدد", "worker پیامک را در موعد بعدی دوباره ارسال می‌کند.", "warning"
elif job.status in (NotificationJob.Status.PENDING, NotificationJob.Status.PROCESSING):
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در صف ارسال", "پیامک هنوز به درگاه تحویل نشده است.", "info"
elif job.delivery_status == NotificationJob.DeliveryStatus.DELIVERED:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "تحویل به گوشی تأیید شد", "اپراتور دریافت پیامک توسط مخاطب را تأیید کرده است.", "success"
elif job.delivery_status in (NotificationJob.DeliveryStatus.PENDING, NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.UNDELIVERED, NotificationJob.DeliveryStatus.CHECK_FAILED) and job.delivery_next_check_at:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در حال پیگیری تحویل", "نتیجه فعلی قطعی نیست و worker استعلام را ادامه می‌دهد.", "warning"
elif job.delivery_status == NotificationJob.DeliveryStatus.BLOCKED:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "دریافت پیامک مسدود است", "شماره مخاطب دریافت پیامک تبلیغاتی را بسته است.", "danger"
elif job.delivery_status == NotificationJob.DeliveryStatus.EXPIRED:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "مهلت تحویل پایان یافت", "اپراتور پیامک را در بازه مجاز تحویل نداده است.", "danger"
elif job.delivery_status in (NotificationJob.DeliveryStatus.ERROR, NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.UNDELIVERED):
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "تحویل تأیید نشد", "بازه پیگیری پایان یافته یا درگاه خطای قطعی برگردانده است.", "danger"
else:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "پذیرفته‌شده توسط درگاه", "شناسه ارسال ثبت شده و در انتظار نتیجه دلیوری است.", "info"
sms_jobs = NotificationJob.objects.filter(ticket__group_dispatch=dispatch, channel=NotificationJob.Channel.SMS, event_type=SMSTemplate.Event.STAFF_TICKET_CREATED)
sms_counts = {row["status"]: row["count"] for row in sms_jobs.values("status").annotate(count=Count("id"))}
sent = sms_counts.get(NotificationJob.Status.SENT, 0)
failed = sms_counts.get(NotificationJob.Status.FAILED, 0)
failed = sms_jobs.filter(Q(status=NotificationJob.Status.FAILED) | Q(status=NotificationJob.Status.SENT, delivery_status__in=delivery_failures, delivery_next_check_at__isnull=True)).count()
delivered = sms_jobs.filter(delivery_status=NotificationJob.DeliveryStatus.DELIVERED).count()
retrying = sms_counts.get(NotificationJob.Status.RETRYING, 0)
tracking = sms_jobs.filter(delivery_next_check_at__isnull=False).count()
opened = TicketMagicLink.objects.filter(ticket__group_dispatch=dispatch, view_count__gt=0).values("ticket_id").distinct().count()
total_views = TicketMagicLink.objects.filter(ticket__group_dispatch=dispatch).aggregate(total=Sum("view_count"))["total"] or 0
replied = dispatch.tickets.filter(last_customer_message_at__isnull=False).count()
expected = dispatch.recipient_count
refresh_group_dispatch_status(dispatch.pk); dispatch.refresh_from_db(fields=["status"])
metrics = {"sent": sent, "failed": failed, "pending": max(expected - sent - failed, 0), "opened": opened, "replied": replied}
return render(request, "tickets/staff_dispatch_detail.html", {"dispatch": dispatch, "tickets": tickets, "metrics": metrics, "active_staff_section": "dispatches"})
pending = sum(sms_counts.get(status, 0) for status in (NotificationJob.Status.PENDING, NotificationJob.Status.PROCESSING, NotificationJob.Status.RETRYING))
delivery_percent = round((delivered / dispatch.recipient_count) * 100) if dispatch.recipient_count else 0
metrics = {"sent": sent, "delivered": delivered, "failed": failed, "retrying": retrying, "tracking": tracking, "pending": pending, "opened": opened, "not_opened": max(dispatch.recipient_count-opened,0), "total_views": total_views, "replied": replied, "delivery_percent": delivery_percent}
return render(request, "tickets/staff_dispatch_detail.html", {"dispatch": dispatch, "tickets": tickets, "metrics": metrics, "sms_max_attempts": 1 + settings.SMS_MAX_RETRIES, "active_staff_section": "dispatches"})
@staff_required
@@ -579,7 +620,20 @@ def staff_dispatch_detail(request, public_id):
def staff_dispatch_retry(request, public_id):
dispatch=get_object_or_404(GroupDispatch,public_id=public_id)
if not DispatchPolicy.can_view(request.user,dispatch) or not DispatchPolicy.can_create(request.user,dispatch.unit,dispatch.operation_type): raise PermissionDenied
count=NotificationJob.objects.filter(ticket__group_dispatch=dispatch,channel=NotificationJob.Channel.SMS,status=NotificationJob.Status.FAILED).update(status=NotificationJob.Status.PENDING,attempts=0,next_attempt_at=timezone.now(),last_error="")
retryable_delivery = [NotificationJob.DeliveryStatus.UNDELIVERED, NotificationJob.DeliveryStatus.EXPIRED, NotificationJob.DeliveryStatus.BLOCKED, NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.ERROR]
jobs=NotificationJob.objects.filter(ticket__group_dispatch=dispatch,channel=NotificationJob.Channel.SMS,event_type=SMSTemplate.Event.STAFF_TICKET_CREATED).filter(Q(status=NotificationJob.Status.FAILED) | Q(status=NotificationJob.Status.SENT,delivery_status__in=retryable_delivery,delivery_next_check_at__isnull=True))
job_id=request.POST.get("job_id","").strip()
if job_id:
if not job_id.isdigit():
messages.error(request,"شناسه پیامک برای تلاش مجدد معتبر نیست.")
return redirect("staff_tickets:dispatch_detail",public_id=public_id)
jobs=jobs.filter(pk=int(job_id))
count=jobs.update(
status=NotificationJob.Status.PENDING, attempts=0, next_attempt_at=timezone.now(),
last_error="", provider_message_id="", provider_status_code="", sent_at=None,
delivery_status="", delivery_status_code="", delivery_description="",
delivery_checked_at=None, delivery_check_attempts=0, delivery_next_check_at=None,
)
refresh_group_dispatch_status(dispatch.pk)
messages.success(request,f"{count} ارسال ناموفق دوباره در صف قرار گرفت." if count else "ارسال ناموفقی برای تلاش مجدد وجود ندارد.")
return redirect("staff_tickets:dispatch_detail",public_id=public_id)