from django.db import models from django.db.models import Q, UniqueConstraint class MessageCampaign(models.Model): """ ذخیره‌سازیِ مینیمالِ یک «کمپینِ پیامی» برای فیچرهای ارتباطی آینده. این مدل صرفاً یک محلِ ذخیره‌ست؛ هیچ منطقِ ارسال/زمان‌بندی/صف پس‌زمینه/retry/پیگیریِ تحویل اینجا نیست. ارسالِ واقعی (که از accounts.services.send_sms یا notifications.services.notify استفاده خواهد کرد) و انتخابِ کاربرانِ هدف (که از هلپرهای accounts.services، مثلِ get_users_by_grade/get_inactive_users/..., استفاده خواهد کرد) در فیچرهای بعدی روی همین رکورد پیاده می‌شن. - `channel`: کانالِ ارسال (فعلاً پیامک یا اعلانِ داخل‌اپ). - `target_filter`: تعریفِ هدف/فیلترِ انتخاب‌شده، به همون شکلی که در UI ساخته می‌شه (مثلاً {"type": "grade", "grade_id": 3} یا {"type": "inactive_since_days", "days": 14} یا {"type": "all"}). عمداً JSON آزاد نگه داشته شده تا با هلپرهای targeting در accounts.services (که هیچ‌کدوم جدولِ جدیدی ندارن) هماهنگ بمونه، بدونِ اینکه این مدل به یک ساختارِ فیلترِ خاص قفل بشه. """ CHANNEL_CHOICES = [ ('sms', 'پیامک'), ('in_app', 'اعلان داخل‌اپ'), ] title = models.CharField(max_length=150) body = models.TextField() channel = models.CharField(max_length=10, choices=CHANNEL_CHOICES) target_filter = models.JSONField(default=dict, blank=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ['-created_at'] def __str__(self): return f"campaign-{self.pk}-{self.channel}-{self.title}" def save(self, *args, **kwargs): """ Django `choices=` روی CharField صرفاً برای فرم/ادمینه و به‌خودی‌خود در save() اجرا نمی‌شه؛ بدونِ این چک، هر مقدارِ دلخواهی برای channel (مثلاً یک typo) می‌تونه ذخیره بشه و فقط بعداً، وقتِ اجرای execute_campaign()، شکست بخوره. این کمترین اعتبارسنجیِ لازمه تا مقدارِ نامعتبر اصلاً ذخیره نشه — بدونِ full_clean() روی کلِ مدل (که فیلدهای دیگه‌ای مثل title/target_filter رو هم درگیر می‌کنه و در این پروژه جایی برای مدل‌ها استفاده نشده) و با همون الگوی ValueError که در campaigns.services برای خطاهای مشابه به‌کار رفته (مثلِ UnsupportedTargetFilter). """ valid_channels = {choice[0] for choice in self.CHANNEL_CHOICES} if self.channel not in valid_channels: raise ValueError(f"Invalid MessageCampaign channel: {self.channel!r}") super().save(*args, **kwargs) class CampaignDelivery(models.Model): """ رکوردِ مینیمالِ نتیجه‌ی تحویلِ یک MessageCampaign به یک کاربرِ هدف؛ همون «پیگیریِ تحویل» که در MessageCampaign.__doc__ به‌عنوانِ فیچرِ بعدی اشاره شده بود. این مدل صرفاً یک لاگِ وضعیت‌ه — بدونِ retry، صفِ پس‌زمینه، زمان‌بندی، API یا پنلِ ادمین. execute_campaign() به ازای هر تلاشِ واقعیِ ارسال (نه هر کاربرِ resolve‌شده — کاربرانِ skip‌شده مثلِ مهمانِ بدونِ شماره رکورد نمی‌گیرن) یک رکورد می‌سازه و بعد از تلاش، وضعیتش رو به‌روزرسانی می‌کنه. - `user_id`: ارجاعِ سبک به accounts.User، هم‌الگو با notifications.models.Notification.user_id (بدونِ FK واقعی). - `status`: هنگامِ ساختِ رکورد pending‌ه (قبل از تلاش)، بعد از تلاش به sent یا failed آپدیت می‌شه. - `error_message`: فقط در status=failed پر می‌شه. """ STATUS_PENDING = 'pending' STATUS_SENT = 'sent' STATUS_FAILED = 'failed' STATUS_CHOICES = [ (STATUS_PENDING, 'در انتظار'), (STATUS_SENT, 'ارسال شد'), (STATUS_FAILED, 'ناموفق'), ] campaign = models.ForeignKey( MessageCampaign, on_delete=models.CASCADE, related_name='deliveries', ) user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User channel = models.CharField(max_length=10, choices=MessageCampaign.CHANNEL_CHOICES) status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=STATUS_PENDING) error_message = models.CharField(max_length=300, blank=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ['-created_at'] constraints = [ # جلوگیریِ race condition: اگه execute_campaign() برای همون # campaign هم‌زمان (دو پراسس/ریکوئستِ موازی) اجرا بشه، هر دو # می‌تونن قبل از اینکه هرکدوم رکوردی بسازن از چکِ # already_sent رد بشن (TOCTOU) و برای یک کاربر دو بار # send_sms/notify صدا زده بشه. این unique constraint دقیقاً # همون چیزی رو در سطحِ دیتابیس تضمین می‌کنه که چکِ اپلیکیشن‌ # لایه فرض می‌کرد ولی گارانتی نمی‌داد: برای هر # (campaign, user_id, channel) در آنِ واحد حداکثر یک رکوردِ # «فعال یا موفق» (pending یا sent) می‌تونه وجود داشته باشه. # وقتی دو تلاشِ هم‌زمان می‌خوان برای همون کاربر رکورد pending # بسازن، دومی روی این constraint با IntegrityError شکست # می‌خوره و در execute_campaign() به‌عنوانِ «این کاربر توسطِ # یک اجرای دیگه claim شده» skip می‌شه — پس send_sms/notify # دوباره صدا زده نمی‌شه. # # status=failed عمداً از این constraint مستثنا شده تا رفتارِ # retryِ فعلی (ساختنِ یک رکوردِ pending جدید بعد از یک تلاشِ # ناموفق) دست‌نخورده بمونه: یک رکوردِ failed دیگه «فعال» حساب # نمی‌شه، پس جای رکوردِ جدید رو برای تلاشِ بعدی باز می‌ذاره. UniqueConstraint( fields=['campaign', 'user_id', 'channel'], condition=Q(status__in=['pending', 'sent']), name='campaigns_one_active_delivery_per_user_channel', ), ] def __str__(self): return f"delivery-{self.pk}-campaign{self.campaign_id}-user{self.user_id}-{self.status}"