327 lines
18 KiB
Python
327 lines
18 KiB
Python
"""مدلهای زیرسیستم پیامک.
|
|
|
|
شامل:
|
|
- تنظیمات سامانه پیامک (singleton) با URL/نامکاربری/رمز قابل ویرایش از Django Admin.
|
|
- قالب متن هر نوع پیامک (قابل ویرایش از پنل).
|
|
- تنظیم فعال/غیرفعال هر نوع پیامک بهازای هر مشتری.
|
|
- صف ارسال (outbox) و لاگ تلاشهای ارسال.
|
|
"""
|
|
from django.conf import settings
|
|
from django.db import models
|
|
|
|
|
|
class SMSType(models.TextChoices):
|
|
"""انواع پیامکهای سیستم (دقیقاً ۸ نوع، به ترتیب چرخهٔ کاری).
|
|
|
|
ترتیب تعریف = ترتیب نمایش در پنل. هر نوع یا از یک رویداد (view) یا از یک
|
|
اسکنر دورهای در صف قرار میگیرد؛ نگاشت آن در ``admin_panel/sms/triggers.py``
|
|
و ``admin_panel/sms/scanner.py`` است.
|
|
"""
|
|
|
|
WELCOME = "welcome", "خوشآمدگویی (ایجاد مشتری)"
|
|
SURVEY = "survey", "نظرسنجی پس از ثبت فاکتور"
|
|
CHEQUE_REGISTRATION = "cheque_registration", "یادآوری تأمین موجودی چک"
|
|
VISIT = "visit", "یادآوری بازدید خدمات دستگاه"
|
|
VISIT_CREATED = "visit_created", "ثبت بازدید (اقدام از نوع بازدید)"
|
|
INSTALL_CREATED = "install_created", "ثبت نصب (اقدام از نوع نصب)"
|
|
PAYMENT_THANKS = "payment_thanks", "تشکر از پرداخت"
|
|
NESIYE_DUE = "nesiye_due", "یادآوری سررسید نسیه"
|
|
|
|
|
|
class SMSProviderConfig(models.Model):
|
|
"""تنظیمات سامانه پیامک؛ تکرکوردی (pk=1) و قابل ویرایش از Django Admin."""
|
|
|
|
class Meta:
|
|
verbose_name = "تنظیمات سامانه پیامک"
|
|
verbose_name_plural = "تنظیمات سامانه پیامک"
|
|
permissions = [
|
|
# نام نمایشی مطابق قرارداد سایر پرمیشنهای پروژه (ViewStore/AddCustomer/...)
|
|
# تا در فهرست پرمیشنهای Django Admin بهسادگی پیدا شود.
|
|
("ManageSMS", "ManageSMS"),
|
|
# سطح دسترسی جداگانه برای صفحات «تنظیمات» و «قالبها»: مدیر میتواند فقط
|
|
# به کاربران مشخصی اجازهٔ دیدن/ویرایش اینها را بدهد (نه هر کاربر پیامک).
|
|
("ManageSMSSettings", "ManageSMSSettings"),
|
|
]
|
|
|
|
ProviderChoices = [
|
|
("sms0098", "0098SMS (سرویس لینکی HTTP)"),
|
|
("sms0098ws", "0098SMS (وبسرویس + استعلام دلیوری)"),
|
|
("http", "HTTP عمومی"),
|
|
("console", "Console (تست/دیباگ)"),
|
|
]
|
|
provider = models.CharField(
|
|
max_length=20, choices=ProviderChoices, default="sms0098",
|
|
verbose_name="سرویسدهنده پیامک",
|
|
)
|
|
is_enabled = models.BooleanField(
|
|
default=True, verbose_name="فعال بودن ارسال پیامک",
|
|
help_text="در صورت غیرفعال بودن، worker هیچ پیامکی ارسال نمیکند.",
|
|
)
|
|
api_url = models.CharField(
|
|
max_length=500, blank=True,
|
|
default="http://www.0098sms.com/sendsmslink.aspx",
|
|
verbose_name="آدرس سرویس پیامک (URL)",
|
|
help_text="برای 0098SMS همان آدرس لینکی ارسال است؛ این سرویس WSDL/SOAP ندارد.",
|
|
)
|
|
username = models.CharField(max_length=100, blank=True, default="", verbose_name="نام کاربری")
|
|
password = models.CharField(max_length=200, blank=True, default="", verbose_name="رمز عبور")
|
|
sender = models.CharField(max_length=50, blank=True, default="", verbose_name="شماره خط فرستنده")
|
|
webservice_url = models.CharField(
|
|
max_length=500, blank=True,
|
|
default="https://webservice.0098sms.com/service.asmx",
|
|
verbose_name="آدرس وبسرویس (SOAP/WSDL)",
|
|
help_text="فقط برای provider «وبسرویس» لازم است؛ ارسال با شناسه (smsid) و استعلام "
|
|
"دلیوری از همین آدرس انجام میشود.",
|
|
)
|
|
panel_no = models.CharField(
|
|
max_length=50, blank=True, default="",
|
|
verbose_name="شماره پنل (PnlNo)",
|
|
help_text="شمارهٔ اختصاصی پنل در وبسرویس؛ اگر خالی بماند از «شماره خط فرستنده» "
|
|
"استفاده میشود.",
|
|
)
|
|
api_token = models.CharField(max_length=255, blank=True, default="", verbose_name="توکن API (HTTP)")
|
|
test_message = models.TextField(
|
|
blank=True,
|
|
default="پیامک آزمایشی؛ جهت بررسی صحت ارسال از پنل فرستاده شده است.",
|
|
verbose_name="متن پیامک آزمایشی",
|
|
help_text="متنی که با دکمهٔ «ارسال پیامک آزمایشی» فرستاده میشود؛ قابل ویرایش است.",
|
|
)
|
|
timeout_seconds = models.PositiveIntegerField(default=30, verbose_name="مهلت ارتباط (ثانیه)")
|
|
|
|
# --- کنترل جریان ارسال (جلوگیری از سیل پیامک) ---
|
|
scan_since = models.DateTimeField(
|
|
null=True, blank=True,
|
|
verbose_name="تاریخ شروع دوره",
|
|
help_text="فیلتر شروع دوره: فقط مواردی که «پنجرهٔ یادآوری»شان از این لحظه به بعد باز "
|
|
"میشود در صف قرار میگیرند؛ موارد قدیمیتر (مثل مشتریان خیلی قدیمی که "
|
|
"مدتهاست بازدید نشدهاند) نادیده گرفته میشوند. از پنل به شکل تاریخ شمسی "
|
|
"ویرایش میشود و روی «آغاز همان روز» تنظیم میگردد. اگر خالی باشد، در "
|
|
"نخستین اجرا خودکار روی «همین حالا» تنظیم میشود.",
|
|
)
|
|
allow_backlog = models.BooleanField(
|
|
default=False,
|
|
verbose_name="اجازهٔ ارسال موارد قدیمی",
|
|
help_text="اگر فعال باشد، مواردی که پیش از «تاریخ شروع دوره» واجد شرط شدهاند هم "
|
|
"(بهاندازهٔ سقف زیر، در هر اجرا) ارسال میشوند. بهصورت پیشفرض خاموش است "
|
|
"تا مشتریان قدیمی مشمول این فیلتر شوند.",
|
|
)
|
|
backlog_max = models.PositiveIntegerField(
|
|
default=0, verbose_name="حداکثر موارد قدیمی در هر اجرا",
|
|
help_text="فقط وقتی «اجازهٔ ارسال موارد قدیمی» روشن است معنا دارد. صفر = هیچ.",
|
|
)
|
|
max_per_run = models.PositiveIntegerField(
|
|
default=200, verbose_name="حداکثر پیامک هر اسکنر در هر اجرا",
|
|
help_text="سقف کلی صفکردن برای هر نوع پیامک در هر اجرای اسکنر.",
|
|
)
|
|
send_interval_seconds = models.PositiveIntegerField(
|
|
default=3, verbose_name="فاصلهٔ بین هر پیامک (ثانیه)",
|
|
help_text="worker پس از هر ارسال این مدت صبر میکند تا سرویسدهنده زیر بار نرود.",
|
|
)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
def __str__(self):
|
|
return f"تنظیمات پیامک ({self.get_provider_display()})"
|
|
|
|
def save(self, *args, **kwargs):
|
|
# سینگلتون: همیشه فقط یک رکورد با pk=1.
|
|
self.pk = 1
|
|
# نخستین بارگذاری = لحظهٔ فعالسازی؛ خط شروع روی «همین حالا» مهر میشود تا
|
|
# موارد قدیمی موجود در دیتابیس یکباره در صف قرار نگیرند.
|
|
if self.scan_since is None:
|
|
from django.utils import timezone
|
|
self.scan_since = timezone.now()
|
|
super().save(*args, **kwargs)
|
|
|
|
@classmethod
|
|
def load(cls):
|
|
obj, _ = cls.objects.get_or_create(pk=1)
|
|
if obj.scan_since is None:
|
|
obj.save(update_fields=["scan_since"])
|
|
return obj
|
|
|
|
|
|
class SMSTemplate(models.Model):
|
|
"""قالب متن هر نوع پیامک. متن قابل ویرایش از پنل است."""
|
|
|
|
class Meta:
|
|
verbose_name = "قالب پیامک"
|
|
verbose_name_plural = "قالبهای پیامک"
|
|
|
|
sms_type = models.CharField(
|
|
max_length=40, choices=SMSType.choices, unique=True, verbose_name="نوع پیامک",
|
|
)
|
|
title = models.CharField(max_length=100, verbose_name="عنوان")
|
|
text = models.TextField(
|
|
verbose_name="متن پیامک",
|
|
help_text="متغیرهای قابل استفاده: {name} نام مشتری، {amount} مبلغ، "
|
|
"{amount_sep} مبلغ با جداکننده، {date} تاریخ جلالی، {days} تعداد روز، "
|
|
"{check_row} شماره ردیف چک، {sayyad_id} شناسه صیاد چک، "
|
|
"{owner_name} نام مالک چک، {balance} مانده حساب مشتری، "
|
|
"{balance_sep} مانده حساب با جداکننده. "
|
|
"(توجه: {balance} مانده کل حساب است و با مبلغ سررسیدشدهٔ چک/نسیه "
|
|
"که در {amount} میآید تفاوت دارد.)",
|
|
)
|
|
is_enabled = models.BooleanField(default=True, verbose_name="فعال")
|
|
auto_send = models.BooleanField(
|
|
default=False,
|
|
verbose_name="ارسال خودکار (بدون تأیید مدیر)",
|
|
help_text="اگر خاموش باشد (پیشفرض)، پیامکهای این نوع در وضعیت «در انتظار تأیید» "
|
|
"در صف میمانند تا مدیر آنها را تأیید کند. با روشنکردن، پیامکِ ساختهشده "
|
|
"توسط اسکنر مستقیماً تأیید و بلافاصله توسط worker ارسال میشود.",
|
|
)
|
|
offset_days = models.PositiveSmallIntegerField(
|
|
default=0,
|
|
verbose_name="فاصلهٔ زمانی (روز)",
|
|
help_text="معنای این عدد برای هر نوع پیامک متفاوت است و در پنل توضیح داده میشود؛ "
|
|
"مثلاً «چند روز قبل از سررسید» یا «چند روز بعد از صدور فاکتور».",
|
|
)
|
|
repeat_interval_days = models.PositiveSmallIntegerField(
|
|
null=True, blank=True,
|
|
verbose_name="فاصله تکرار (روز)",
|
|
help_text="خالی بگذارید برای ارسال یکباره؛ مقدار بدهید برای ارسال تکرارشونده.",
|
|
)
|
|
repeat_interval_months = models.PositiveSmallIntegerField(
|
|
null=True, blank=True,
|
|
verbose_name="دورهٔ تکرار (ماه)",
|
|
help_text="برای «یادآوری بازدید»: چند ماه یکبار بازدید تکرار شود. خالی = پیشفرض سامانه (۹ ماه).",
|
|
)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
def __str__(self):
|
|
return self.title
|
|
|
|
|
|
class CustomerSMSSetting(models.Model):
|
|
"""تنظیم فعال/غیرفعال هر نوع پیامک برای یک مشتری (مدل opt-out).
|
|
|
|
نبودِ رکورد برای یک (مشتری، نوع) یعنی فعال. تنها تغییرهای صریح ذخیره میشوند.
|
|
"""
|
|
|
|
class Meta:
|
|
verbose_name = "تنظیم پیامک مشتری"
|
|
verbose_name_plural = "تنظیمات پیامک مشتریان"
|
|
unique_together = ("customer", "sms_type")
|
|
|
|
customer = models.ForeignKey(
|
|
"Customer", on_delete=models.CASCADE, related_name="sms_settings",
|
|
)
|
|
sms_type = models.CharField(max_length=40, choices=SMSType.choices, verbose_name="نوع پیامک")
|
|
is_enabled = models.BooleanField(default=True, verbose_name="فعال")
|
|
|
|
def __str__(self):
|
|
state = "فعال" if self.is_enabled else "غیرفعال"
|
|
return f"{self.customer} - {self.get_sms_type_display()} ({state})"
|
|
|
|
|
|
class SMSQueue(models.Model):
|
|
"""صف دیتابیسی ارسال پیامک (outbox). توسط worker تخلیه میشود."""
|
|
|
|
class Status(models.TextChoices):
|
|
PENDING = "pending", "در انتظار تأیید" # در صف بازبینی مدیر
|
|
APPROVED = "approved", "تأییدشده" # تأیید مدیر؛ آمادهٔ ارسال توسط worker
|
|
PROCESSING = "processing", "در حال پردازش"
|
|
SENT = "sent", "ارسال شده"
|
|
FAILED = "failed", "ناموفق"
|
|
CANCELLED = "cancelled", "لغو شده"
|
|
|
|
class DeliveryStatus(models.TextChoices):
|
|
"""وضعیت تحویل واقعی به گوشی (از استعلام web service، فقط اگر smsid داشته باشیم)."""
|
|
PENDING = "pending", "در انتظار استعلام"
|
|
DELIVERED = "delivered", "رسیده به گوشی"
|
|
NOT_DELIVERED = "not_delivered", "نرسیده به گوشی"
|
|
EXPIRED = "expired", "منقضیشده"
|
|
UNKNOWN = "unknown", "نامشخص"
|
|
|
|
customer = models.ForeignKey(
|
|
"Customer", on_delete=models.SET_NULL, null=True, blank=True,
|
|
related_name="sms_queue", verbose_name="مشتری",
|
|
)
|
|
sms_type = models.CharField(
|
|
max_length=40, choices=SMSType.choices, blank=True, default="", verbose_name="نوع",
|
|
)
|
|
phone_number = models.CharField(max_length=20, verbose_name="شماره گیرنده")
|
|
message = models.TextField(verbose_name="متن")
|
|
status = models.CharField(
|
|
max_length=12, choices=Status.choices, default=Status.PENDING,
|
|
db_index=True, verbose_name="وضعیت",
|
|
)
|
|
attempts = models.PositiveSmallIntegerField(default=0, verbose_name="تعداد تلاش")
|
|
max_attempts = models.PositiveSmallIntegerField(default=5, verbose_name="حداکثر تلاش")
|
|
next_retry_at = models.DateTimeField(null=True, blank=True, verbose_name="زمان تلاش بعدی")
|
|
last_error = models.TextField(blank=True, default="", verbose_name="آخرین خطا")
|
|
provider_id = models.CharField(max_length=100, blank=True, default="")
|
|
provider_code = models.CharField(max_length=50, blank=True, default="")
|
|
dedup_key = models.CharField(
|
|
max_length=120, unique=True, null=True, blank=True,
|
|
help_text="کلید یکتاسازی برای جلوگیری از ارسال تکراری.",
|
|
)
|
|
created_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True,
|
|
)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
sent_at = models.DateTimeField(null=True, blank=True)
|
|
# --- رهگیری تحویل (فقط برای providerهایی که شناسهٔ پیامک برمیگردانند) ---
|
|
delivery_status = models.CharField(
|
|
max_length=16, choices=DeliveryStatus.choices, blank=True, default="",
|
|
db_index=True, verbose_name="وضعیت تحویل",
|
|
help_text="خالی = بدون پیگیری. مقدار از استعلام smsdeliveryState پر میشود.",
|
|
)
|
|
delivery_checked_at = models.DateTimeField(
|
|
null=True, blank=True, verbose_name="زمان آخرین استعلام دلیوری",
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = "صف پیامک"
|
|
verbose_name_plural = "صف پیامکها"
|
|
ordering = ["-created_at"]
|
|
indexes = [
|
|
models.Index(fields=["status", "next_retry_at"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.get_sms_type_display() or self.sms_type} → {self.phone_number} [{self.status}]"
|
|
|
|
|
|
class SMSDedup(models.Model):
|
|
"""دفتر کلیدهای یکتاسازیِ *همیشهثبتشده*.
|
|
|
|
``SMSQueue.dedup_key`` تنها تا وقتی جلوی تکرار را میگیرد که رکورد صف باقی
|
|
باشد؛ با حذف رکورد از صف (رد کردن پیامک توسط مدیر) کلید هم پاک میشد و اسکنر
|
|
در اجرای بعدی همان پیامک را دوباره میساخت. این دفتر مستقل از صف است و هرگز
|
|
حذف نمیشود، پس پیامکِ حذفشده دیگر بازنمیگردد.
|
|
"""
|
|
|
|
class Meta:
|
|
verbose_name = "کلید پردازششدهٔ پیامک"
|
|
verbose_name_plural = "کلیدهای پردازششدهٔ پیامک"
|
|
|
|
key = models.CharField(max_length=120, unique=True)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
def __str__(self):
|
|
return self.key
|
|
|
|
|
|
class SMSLog(models.Model):
|
|
"""لاگ سطح-تلاش برای هر بار ارسال توسط worker."""
|
|
|
|
class Meta:
|
|
verbose_name = "لاگ پیامک"
|
|
verbose_name_plural = "لاگهای پیامک"
|
|
ordering = ["-created_at"]
|
|
|
|
queue = models.ForeignKey(
|
|
SMSQueue, on_delete=models.CASCADE, related_name="logs", verbose_name="صف",
|
|
)
|
|
attempt_no = models.PositiveSmallIntegerField(default=1, verbose_name="شماره تلاش")
|
|
success = models.BooleanField(default=False, verbose_name="موفق")
|
|
provider_id = models.CharField(max_length=100, blank=True, default="")
|
|
provider_code = models.CharField(max_length=50, blank=True, default="")
|
|
error = models.TextField(blank=True, default="", verbose_name="خطا")
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
def __str__(self):
|
|
state = "موفق" if self.success else "ناموفق"
|
|
return f"#{self.queue_id} تلاش {self.attempt_no} ({state})"
|