Add campaigns/ Add supervisor/ Add messaging features/ Separate SMS
This commit is contained in:
125
campaigns/models.py
Normal file
125
campaigns/models.py
Normal file
@@ -0,0 +1,125 @@
|
||||
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}"
|
||||
Reference in New Issue
Block a user