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