"""مدل‌های زیرسیستم پیامک. شامل: - تنظیمات سامانه پیامک (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", "ثبت بازدید (اقدام از نوع بازدید)" 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})"