Files
MathApp/campaigns/models.py

114 lines
6.2 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 هم‌زمان اجرا بشه، این constraint تضمین می‌کنه حداکثر
# یک رکوردِ «فعال یا موفق» (pending/sent) به‌ازای هر
# (campaign, user_id, channel) باشه؛ تلاشِ دومِ هم‌زمان با
# IntegrityError شکست می‌خوره و به‌عنوانِ claim‌شده skip می‌شه.
# status=failed عمداً مستثناست تا retry (ساختِ pendingِ جدید
# بعد از شکست) دست‌نخورده بمونه.
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}"