sms
This commit is contained in:
1 parent
250cdbab7f
commit
5c6069c3ef
87 files changed
+29209
-3697
No files matched your search
@@ -17,3 +17,4 @@ from .factors import * # noqa: F401,F403
|
||||
from .finance import * # noqa: F401,F403
|
||||
from .actions import * # noqa: F401,F403
|
||||
from .system import * # noqa: F401,F403
|
||||
from .sms import * # noqa: F401,F403
|
||||
@@ -11,6 +11,12 @@ class Customer(models.Model):
|
||||
]
|
||||
name_family = models.CharField(max_length=200)
|
||||
address = models.CharField(max_length=200)
|
||||
# توضیحات آزاد مشتری؛ متنهای غیرشمارهای که از فیلدهای تلفن پاکسازی میشوند
|
||||
# اینجا نگه داشته میشوند تا هیچ دادهای از سیستم حذف نشود.
|
||||
notes = models.TextField(
|
||||
blank=True, default="", verbose_name="توضیحات",
|
||||
help_text="یادداشت آزاد دربارهٔ مشتری؛ متنهای حذفشدهٔ فیلدهای تلفن هم اینجا ثبت میشوند.",
|
||||
)
|
||||
phone1 = models.CharField(max_length=20, default='')
|
||||
phone2 = models.CharField(max_length=20, default='', null=True, blank=True)
|
||||
phone3 = models.CharField(max_length=20, default='', null=True, blank=True)
|
||||
@@ -23,6 +29,16 @@ class Customer(models.Model):
|
||||
# فیلد جدید برای حذف نرم (بدون تداخل با دادههای قبلی)
|
||||
is_active = models.BooleanField(default=True, null=True, blank=True, help_text="وضعیت فعالیت مشتری")
|
||||
|
||||
# شمارههای «اصلی» گیرندهٔ پیامک، بهصورت مقدارِ خودِ شماره (نرمالشده، جدا با کاما).
|
||||
# چرا مقدار و نه کلیدِ رکورد؟ چون دادهٔ مشتریان دائماً افزوده/ویرایش/حذف میشود و
|
||||
# کلید رکورد در بازسازیها از بین میرود؛ ولی شماره پایدار است. اگر شمارهای حذف یا
|
||||
# ویرایش شود، این انتخاب خودبهخود بیاثر میشود و سیستم به شمارهٔ اول برمیگردد.
|
||||
sms_primary_phones = models.CharField(
|
||||
max_length=100, blank=True, default="",
|
||||
verbose_name="شمارههای اصلی پیامک",
|
||||
help_text="شمارههای نرمالشده (+98…) جداشده با کاما؛ خالی = شمارهٔ اول مشتری.",
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name_family
|
||||
|
||||
@@ -37,6 +53,8 @@ class PhoneNumber(models.Model):
|
||||
customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name='phones')
|
||||
title = models.CharField(max_length=50, default='تلفن', blank=True, null=True, help_text="عنوان شماره (مثل: موبایل پدر)")
|
||||
phone_number = models.CharField(max_length=20)
|
||||
# توجه: «شمارهٔ اصلی پیامک» روی خودِ Customer.sms_primary_phones نگه داشته میشود
|
||||
# (کلیدش مقدار شماره است، نه رکورد) تا با افزودن/حذف/ویرایش مداوم مشتریان از بین نرود.
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title}: {self.phone_number}"
|
||||
@@ -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})"
|
||||
Reference in new issue
Block a user