126 lines
7.4 KiB
Python
126 lines
7.4 KiB
Python
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}"
|