This commit is contained in:
javad committed 2026-10-10 14:21:04 +03:30
1 parent 250cdbab7f
commit 5c6069c3ef
87 files changed
+29209 -3697

No files matched your search

+314
View File
@@ -0,0 +1,314 @@
"""مدل‌های زیرسیستم پیامک.
شامل:
- تنظیمات سامانه پیامک (singleton) با URL/نام‌کاربری/رمز قابل ویرایش از Django Admin.
- قالب متن هر نوع پیامک (قابل ویرایش از پنل).
- تنظیم فعال/غیرفعال هر نوع پیامک به‌ازای هر مشتری.
- صف ارسال (outbox) و لاگ تلاش‌های ارسال.
"""
from django.conf import settings
from django.db import models
class SMSType(models.TextChoices):
"""انواع پیامک‌های سیستم."""
WELCOME = "welcome", "خوش‌آمدگویی (ایجاد مشتری)"
CHEQUE_DUE = "cheque_due", "یادآوری سررسید چک"
NESIYE_DUE = "nesiye_due", "یادآوری سررسید نسیه"
CHEQUE_REGISTRATION = "cheque_registration", "یادآوری تأمین موجودی چک"
DEBT_DUE = "debt_due", "یادآوری بدهی (سررسید وصول مطالبات)"
VISIT = "visit", "یادآوری بازدید خدمات"
SURVEY = "survey", "نظرسنجی پس از فاکتور"
CUSTOM = "custom", "پیام سفارشی"
class SMSProviderConfig(models.Model):
"""تنظیمات سامانه پیامک؛ تک‌رکوردی (pk=1) و قابل ویرایش از Django Admin."""
class Meta:
verbose_name = "تنظیمات سامانه پیامک"
verbose_name_plural = "تنظیمات سامانه پیامک"
permissions = [
# نام نمایشی مطابق قرارداد سایر پرمیشن‌های پروژه (ViewStore/AddCustomer/...)
# تا در فهرست پرمیشن‌های Django Admin به‌سادگی پیدا شود.
("ManageSMS", "ManageSMS"),
]
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} شماره ردیف چک، {owner_name} نام مالک چک.",
)
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})"