Add campaigns/ Add supervisor/ Add messaging features/ Separate SMS

This commit is contained in:
2026-08-25 03:39:41 +03:30
parent 80298b47b1
commit a9200772a3
27 changed files with 2337 additions and 49 deletions

0
campaigns/__init__.py Normal file
View File

68
campaigns/admin.py Normal file
View File

@@ -0,0 +1,68 @@
"""
ثبتِ MessageCampaign و CampaignDelivery در Django Admin.
این ماژول صرفاً برای مشاهده/مدیریتِ دستیِ کمپین‌ها و بررسیِ وضعیتِ
تحویل‌هاست؛ هیچ منطقِ اجرای کمپین (execute_campaign) اینجا صدا زده
نمی‌شه — طبق MessageCampaign.__doc__ و CampaignDelivery.__doc__، اجرا
و پنلِ ادمینِ مربوط به execution فیچرِ بعدیه.
- MessageCampaignAdmin: امکانِ ساخت/ویرایش/حذفِ کمپین (فیلدهای معمولی،
بدون منطقِ اجرا) رو با ModelAdmin پیش‌فرضِ Django فراهم می‌کنه.
- CampaignDeliveryAdmin: صرفاً read-only. CampaignDelivery یک لاگِ
وضعیتِ تولیدشده توسطِ execute_campaign()‌ه (طبق docstring خودش)؛
دستکاریِ دستیِ status/error_message از ادمین می‌تونه این لاگ رو با
چیزی که واقعاً برای کاربر اتفاق افتاده (یا نیفتاده) ناسازگار کنه و
رفتارِ idempotency/unique-constraint که در campaigns.services روی
همین رکوردها تکیه می‌کنه رو دور بزنه. به همین دلیل add/change/delete
همه غیرفعالن؛ تنها راهِ دیدنِ رکوردها لیست/دیتیلِ read-only‌ه.
"""
from django.contrib import admin
from .models import CampaignDelivery, MessageCampaign
@admin.register(MessageCampaign)
class MessageCampaignAdmin(admin.ModelAdmin):
list_display = ('id', 'title', 'channel', 'created_at')
list_filter = ('channel',)
search_fields = ('title', 'body')
readonly_fields = ('created_at',)
fields = ('title', 'body', 'channel', 'target_filter', 'created_at')
@admin.register(CampaignDelivery)
class CampaignDeliveryAdmin(admin.ModelAdmin):
"""
نمایشِ read-only: لیست و صفحه‌ی جزئیاتِ هر رکورد همیشه قابلِ
مشاهده‌ست (has_view_permission صریحاً True)، ولی هیچ راهی برای
افزودن، ویرایش یا حذفِ رکورد از ادمین وجود نداره تا وضعیتِ تحویل
(که فقط باید توسطِ campaigns.services.execute_campaign آپدیت بشه)
از ادمین دست‌کاری نشه.
"""
list_display = ('id', 'campaign', 'user_id', 'channel', 'status', 'created_at')
list_filter = ('channel', 'status')
search_fields = ('user_id', 'error_message')
fields = ('campaign', 'user_id', 'channel', 'status', 'error_message', 'created_at')
readonly_fields = fields
def has_view_permission(self, request, obj=None):
# صریحاً True: نباید به fallbackِ مبتنی‌بر codename پرمیشنِ
# پیش‌فرضِ Django (has_perm('view_...') or has_perm('change_...'))
# تکیه کنیم. بدونِ این override، یک کاربرِ staff که پرمیشنِ
# view/change روی این مدل رو نداره، حتی برای دیدنِ صفحه‌ی
# جزئیاتِ read-only هم 403 می‌گیره — یعنی مدل عملاً "view-only"
# نیست بلکه کاملاً غیرقابلِ‌دسترسه. این متد مستقل از
# has_change_permission (که پایین همیشه False برمی‌گردونه)
# تضمین می‌کنه که دیدن همیشه مجازه، صرفِ‌نظر از پرمیشن‌های
# دیتابیسی.
return True
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False

6
campaigns/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class CampaignsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'campaigns'

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.2.17 on 2026-08-24 16:52
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='MessageCampaign',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=150)),
('body', models.TextField()),
('channel', models.CharField(choices=[('sms', 'پیامک'), ('in_app', 'اعلان داخل\u200cاپ')], max_length=10)),
('target_filter', models.JSONField(blank=True, default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['-created_at'],
},
),
]

View File

@@ -0,0 +1,29 @@
# Generated by Django 5.2.17 on 2026-08-24 00:00
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('campaigns', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='CampaignDelivery',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.IntegerField(db_index=True)),
('channel', models.CharField(choices=[('sms', 'پیامک'), ('in_app', 'اعلان داخل\u200cاپ')], max_length=10)),
('status', models.CharField(choices=[('pending', 'در انتظار'), ('sent', 'ارسال شد'), ('failed', 'ناموفق')], default='pending', max_length=10)),
('error_message', models.CharField(blank=True, max_length=300)),
('created_at', models.DateTimeField(auto_now_add=True)),
('campaign', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deliveries', to='campaigns.messagecampaign')),
],
options={
'ordering': ['-created_at'],
},
),
]

View File

@@ -0,0 +1,30 @@
# Generated manually for the campaign-delivery concurrency fix.
#
# اضافه‌کردنِ یک partial/conditional unique index روی
# CampaignDelivery(campaign, user_id, channel) که فقط رکوردهایی با
# status در ('pending', 'sent') رو محدود می‌کنه. رکوردهای failed از
# این constraint مستثنا هستن تا رفتارِ retryِ فعلی (ساختنِ رکوردِ
# pendingِ جدید بعد از یک تلاشِ ناموفق) بدونِ تغییر بمونه.
#
# این migration فقط یک constraint اضافه می‌کنه؛ هیچ فیلد/مدلِ جدیدی
# نمی‌سازه و به دیتای موجود دست نمی‌زنه.
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('campaigns', '0002_campaigndelivery'),
]
operations = [
migrations.AddConstraint(
model_name='campaigndelivery',
constraint=models.UniqueConstraint(
condition=models.Q(status__in=['pending', 'sent']),
fields=('campaign', 'user_id', 'channel'),
name='campaigns_one_active_delivery_per_user_channel',
),
),
]

View File

125
campaigns/models.py Normal file
View 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}"

244
campaigns/services.py Normal file
View File

@@ -0,0 +1,244 @@
"""
اجرای یک MessageCampaign: صرفاً «چسباندنِ» سه چیزِ از قبل موجود به هم —
هدف‌گیریِ کاربر (accounts.services)، ارسالِ پیامک (accounts.services.send_sms)
و ثبتِ اعلانِ داخل‌اپ (notifications.services.notify). این ماژول هیچ منطقِ
جدیدِ ارسال/اعلان نمی‌سازه، هیچ صف/جاب پس‌زمینه‌ای نداره، و هیچ زمان‌بندی،
retry یا پیگیریِ تحویلی انجام نمی‌ده — طبق MessageCampaign.__doc__، اونا
مسئولیتِ فیچرهای بعدی هستن.
فراخوانی همیشه sync و بلاکینگ‌ه (برای هر کاربر یک send_sms/notify)؛
مناسبِ لیست‌های کوچیک یا اجرای دستی/تست، نه کمپین‌های بزرگ در پروداکشن.
"""
from django.db import IntegrityError, transaction
from accounts import services as account_services
from accounts.services import SMSDeliveryError
from notifications.services import notify
from .models import CampaignDelivery, MessageCampaign
# نوعِ نوتیفیکیشنِ ثبت‌شده برای اعلان‌های ناشی از کمپین. عمداً در
# notifications.models.Notification.TYPE_CHOICES اضافه نشده چون آن
# choices فقط رویدادهایی‌ان که notifications به‌عنوانِ event-listener
# بهشون گوش می‌ده (طبق داکیومنتِ همون فایل)؛ این‌جا notify() مستقیماً و
# بدونِ رویداد صدا زده می‌شه، پس تغییرِ آن مدل لازم نیست — Notification.type
# یک CharField معمولیه و مقداردهی‌اش خارج از choices هم مجاز است.
CAMPAIGN_NOTIFICATION_TYPE = 'message_campaign'
class UnsupportedTargetFilter(ValueError):
"""target_filter کمپین با هیچ‌کدوم از حالت‌های فعلاً پشتیبانی‌شده مطابقت نداره."""
def _resolve_target_users(target_filter: dict):
"""
target_filter رو به یک QuerySet از accounts.User تبدیل می‌کنه، فقط با
استفاده از هلپرهای موجودِ accounts.services (بدونِ کوئریِ جدید).
حالت‌های فعلاً پشتیبانی‌شده:
{"type": "all"}
{"type": "grade", "grade_id": <int>}
{"type": "inactive_since_days", "days": <int>}
{"type": "active_subscription"}
"""
target_filter = target_filter or {}
filter_type = target_filter.get('type')
if filter_type == 'all':
return account_services.get_all_users()
if filter_type == 'grade':
return account_services.get_users_by_grade(target_filter['grade_id'])
if filter_type == 'inactive_since_days':
from datetime import timedelta
from django.utils import timezone
since = timezone.now() - timedelta(days=target_filter['days'])
return account_services.get_inactive_users(since)
if filter_type == 'active_subscription':
return account_services.get_users_with_active_subscription()
raise UnsupportedTargetFilter(
f"Unsupported target_filter type: {filter_type!r}"
)
def _mark_delivery_result(delivery: CampaignDelivery, *, error: Exception | None) -> None:
"""رکوردِ pending‌ای که قبل از تلاشِ ارسال ساخته شده رو به sent/failed آپدیت می‌کنه."""
if error is None:
delivery.status = CampaignDelivery.STATUS_SENT
delivery.save(update_fields=['status'])
return
delivery.status = CampaignDelivery.STATUS_FAILED
delivery.error_message = str(error)[:300]
delivery.save(update_fields=['status', 'error_message'])
def _claim_delivery(campaign: MessageCampaign, user_id: int) -> CampaignDelivery | None:
"""
رکوردِ pendingِ یک CampaignDelivery رو برای این کاربر «claim» می‌کنه،
یا اگه یک اجرای هم‌زمانِ دیگرِ همین campaign همین کاربر رو زودتر
claim کرده باشه، None برمی‌گردونه (بدونِ ساختنِ رکورد و بدونِ
فراخوانیِ send_sms/notify).
چکِ already_sent_user_ids قبل از شروعِ حلقه یک‌بار انجام می‌شه، پس
بینِ اون چک و رسیدنِ نوبتِ این کاربر در حلقه، ممکنه یک اجرای هم‌زمانِ
دیگر (پراسسِ دیگر/دابل‌کلیکِ اجرا) دقیقاً همین کاربر رو claim کرده و
حتی به sent رسونده باشه (TOCTOU). این تابع همون کاری‌ه که چکِ
اپلیکیشن‌لایه فرض می‌کرد ولی گارانتی نمی‌داد: با تکیه به
CampaignDelivery.Meta.constraints (یک رکوردِ فعال/موفق به ازای هر
campaign+user_id+channel)، اگه یک رکوردِ pending یا sent از قبل
برای همین کاربر وجود داشته باشه (چه توسطِ این اجرا چه توسطِ اجرای
هم‌زمانِ دیگر)، create() با IntegrityError رد می‌شه و این تابع
None برمی‌گردونه — یعنی کاربر به‌جای duplicate delivery، skip می‌شه.
"""
try:
with transaction.atomic():
return CampaignDelivery.objects.create(
campaign=campaign, user_id=user_id, channel=campaign.channel,
)
except IntegrityError:
return None
def _already_sent_user_ids(campaign: MessageCampaign) -> set[int]:
"""
user_id‌هایی که برای همین campaign+channel از قبل یک CampaignDelivery
با status=sent دارن — یعنی تحویلِ واقعی قبلاً با موفقیت انجام شده.
این استعلام دقیقاً همون کاری‌ه که پرامپت به‌عنوانِ جهتِ حل پیشنهاد
داده: «استفاده از رکوردهای CampaignDelivery موجود برای جلوگیری از
ارسالِ دوباره‌ی تحویل‌هایی که قبلاً کامل شدن». عمداً روی pending/failed
فیلتر نمی‌کنه: pending یعنی تلاشِ قبلی هیچ‌وقت به نتیجه نرسیده (مثلاً
اجرا وسط کار قطع شده) و failed یعنی واقعاً تحویل داده نشده — در هر دو
حالت، تلاشِ دوباره «duplicate delivery» نیست، بلکه اولین تحویلِ واقعیِ
موفقه؛ پس این کاربرها باید دوباره امتحان بشن.
"""
return set(
CampaignDelivery.objects.filter(
campaign=campaign,
channel=campaign.channel,
status=CampaignDelivery.STATUS_SENT,
).values_list('user_id', flat=True)
)
def execute_campaign(campaign: MessageCampaign) -> None:
"""
یک MessageCampaign رو اجرا می‌کنه: کاربرانِ هدف رو resolve می‌کنه و به
ازای هر کدوم، بسته به campaign.channel، یا اعلانِ داخل‌اپ ثبت می‌کنه
(notify) یا پیامک می‌فرسته (send_sms). به ازای هر تلاشِ واقعیِ ارسال،
یک CampaignDelivery ساخته می‌شه (ابتدا pending) و بعد از تلاش به
sent/failed آپدیت می‌شه — بدونِ retry، صفِ پس‌زمینه یا زمان‌بندی.
Idempotency: اگه execute_campaign() دوباره برای همون campaign صدا زده
بشه (مثلاً به‌خاطرِ اجرای دستیِ تکراری یا ری‌استارتِ فرآیند وسطِ کار)،
کاربرهایی که از قبل یک CampaignDelivery با status=sent برای همین
campaign+channel دارن، دوباره skip می‌شن — نه CampaignDelivery‌ی
جدیدی براشون ساخته می‌شه و نه send_sms/notify دوباره صداشون می‌زنه.
این چک قبل از شروعِ حلقه، یک‌بار برای کلِ کمپین انجام می‌شه (نه به
ازای هر کاربر) تا کوئریِ اضافه نداشته باشیم. کاربرهایی که رکوردشون
pending یا failed مونده، عمداً دوباره امتحان می‌شن؛ چون براشون هنوز
هیچ تحویلِ موفقی ثبت نشده.
Concurrency: چکِ بالا خودش TOCTOU‌ه (یک‌بار قبل از حلقه محاسبه
می‌شه)، پس اگه دو اجرای execute_campaign() برای همون campaign
هم‌زمان راه بیفتن، ممکنه هر دو یک کاربر رو «هنوز sent نیست» ببینن.
محافظتِ واقعی در _claim_delivery() و در سطحِ دیتابیسه: هر کاربر قبل
از notify/send_sms باید یک رکوردِ CampaignDelivery رو claim کنه، و
یک unique constraint روی مدل (به ازای هر campaign+user_id+channel
حداکثر یک رکوردِ pending/sent) تضمین می‌کنه که فقط یکی از اون دو
اجرای هم‌زمان می‌تونه claim موفق داشته باشه؛ اون یکی دیگه بدونِ
ساختنِ رکورد و بدونِ صدازدنِ notify/send_sms، آن کاربر رو skip
می‌کنه.
کاربرانِ مهمان بدونِ شماره‌تلفن، برای کانالِ sms به‌سادگی رد (skip)
می‌شن و هیچ CampaignDelivery‌ای براشون ساخته نمی‌شه — چون این‌جا
اصلاً تلاشی برای ارسال انجام نشده؛ accounts.services.send_sms برای
شماره‌ی خالی معنی نداره و هیچ صف/ارسالِ جایگزینی هم براشون نیست.
شکستِ تحویلِ sms (SMSDeliveryError) دیگه کلِ اجرای کمپین رو متوقف
نمی‌کنه؛ فقط همون رکورد failed می‌شه و اجرا برای بقیه‌ی کاربران ادامه
پیدا می‌کنه.
Consistency (مارک‌کردنِ sent بعد از تلاشِ موفق): برای in_app، هم
notify() (یک Notification.objects.create ساده) و هم آپدیتِ نهاییِ
CampaignDelivery به sent، هر دو نوشتنِ دیتابیسیِ ساده روی همین
دیتابیسن — پس هر دو تویِ یک `transaction.atomic()` انجام می‌شن. اگه
notify() خودش شکست بخوره یا اگه بعد از notify() موفق، خودِ نوشتنِ
«sent» به هر دلیلی (مثلاً یک خطای دیتابیسِ گذرا) شکست بخوره، کل
تراکنش rollback می‌شه — یعنی Notification‌ای هم که notify() نوشته
بود هیچ‌وقت persist نمی‌شه، و بعدش رکورد به failed آپدیت می‌شه (بیرونِ
همون تراکنشِ rollback‌شده، با یک نوشتنِ مستقلِ جدید) و exception
دوباره بالا می‌ره. نتیجه: هیچ‌وقت به وضعیتِ ناسازگارِ «Notification
واقعاً وجود داره ولی CampaignDelivery رو pendingِ گیرکرده مونده»
نمی‌رسیم؛ رکورد یا واقعاً sent می‌شه (با Notificationِ واقعی) یا
failed می‌شه (بدونِ هیچ Notificationِ persist‌شده‌ای) — و چون failed
از unique constraint مستثناست، برای اجرای بعدی قابلِ retry می‌مونه.
برای sms این کار عمداً انجام نشده و نمی‌تونه انجام بشه: send_sms()
یک تماسِ خارجی با provider‌ه، نه یک نوشتنِ دیتابیسی — یک
transaction.atomic() فقط نوشتن‌های همین دیتابیس رو rollback می‌کنه؛
پیامکی که provider واقعاً فرستاده رو نمی‌شه با rollbackِ دیتابیس
«پس گرفت». اگه بعد از یک send_sms() موفق، خودِ نوشتنِ «sent» شکست
بخوره، exception (بدونِ catch/hide) بالا می‌ره و رکورد pending
می‌مونه — دقیقاً همون رفتارِ قبلی. این pendingِ گیرکرده باعثِ
duplicate SMS در اجرای بعدی نمی‌شه (چون unique constraint یک
claimِ دوباره برای همون کاربر رو رد می‌کنه)، ولی اون یک رکوردِ
خاص هم دیگه هیچ‌وقت خودکار به sent/failed نمی‌رسه — نیازمندِ
بررسیِ دستیه، که پیامدِ ذاتیِ غیرقابل‌اجتنابِ غیرترانزکشنال‌بودنِ
provider خارجیه، نه یک باگِ قابل‌رفعِ کوچیک.
"""
users = _resolve_target_users(campaign.target_filter)
already_sent_user_ids = _already_sent_user_ids(campaign)
if campaign.channel == 'in_app':
for user in users.only('id').iterator():
if user.id in already_sent_user_ids:
continue
delivery = _claim_delivery(campaign, user.id)
if delivery is None:
continue
try:
with transaction.atomic():
notify(
user_id=user.id,
type=CAMPAIGN_NOTIFICATION_TYPE,
title=campaign.title,
body=campaign.body,
related_id=campaign.id,
)
_mark_delivery_result(delivery, error=None)
except Exception as exc:
# چه notify() خودش شکست بخوره چه فقط نوشتنِ نهاییِ sent
# (که تویِ همون atomic block بالاست)، هر دو حالت اینجا
# می‌رسه: تراکنش rollback شده (پس هیچ Notificationِ
# persist‌شده‌ای نمونده)، و این save یک نوشتنِ جدید و
# مستقله که رکورد رو به failed می‌بره تا برای اجرای بعدی
# گیر نکنه. exception عمداً catch/hide نمی‌شه و بالا می‌ره.
_mark_delivery_result(delivery, error=exc)
raise
return
if campaign.channel == 'sms':
for user in users.only('id', 'phone').iterator():
if user.id in already_sent_user_ids:
continue
if not user.phone:
continue
delivery = _claim_delivery(campaign, user.id)
if delivery is None:
continue
try:
account_services.send_sms(phone=user.phone, message=campaign.body)
except SMSDeliveryError as exc:
_mark_delivery_result(delivery, error=exc)
else:
_mark_delivery_result(delivery, error=None)
return
raise ValueError(f"Unsupported campaign channel: {campaign.channel!r}")

495
campaigns/tests.py Normal file
View File

@@ -0,0 +1,495 @@
import threading
from unittest.mock import patch
from django.contrib import admin
from django.contrib.auth import get_user_model
from django.db import IntegrityError, OperationalError, connection, transaction
from django.test import TestCase, TransactionTestCase
from django.urls import reverse
from accounts.models import User
from accounts.services import SMSDeliveryError
from notifications.models import Notification
from .admin import CampaignDeliveryAdmin, MessageCampaignAdmin
from .models import CampaignDelivery, MessageCampaign
from .services import execute_campaign
class MessageCampaignChannelValidationTests(TestCase):
"""
اطمینان از این‌که مقدارِ نامعتبر برای channel اصلاً ذخیره نمی‌شه (نه
این‌که فقط بعداً موقعِ execute_campaign() شکست بخوره)، و مقادیرِ
معتبرِ فعلی (sms, in_app) بدون تغییر همچنان کار می‌کنن.
"""
def test_valid_channels_still_save(self):
for channel in ('sms', 'in_app'):
campaign = MessageCampaign.objects.create(
title=f'title-{channel}', body='body', channel=channel,
)
self.assertEqual(campaign.channel, channel)
def test_invalid_channel_rejected_on_create(self):
with self.assertRaises(ValueError):
MessageCampaign.objects.create(
title='bad', body='body', channel='email',
)
self.assertEqual(MessageCampaign.objects.count(), 0)
def test_invalid_channel_rejected_on_save(self):
campaign = MessageCampaign(title='bad', body='body', channel='whatsapp')
with self.assertRaises(ValueError):
campaign.save()
self.assertEqual(MessageCampaign.objects.count(), 0)
class ExecuteCampaignIdempotencyTests(TestCase):
"""
اجرای دوباره‌ی execute_campaign() برای همون campaign نباید کاربرهایی
که قبلاً یک CampaignDelivery با status=sent دارن رو دوباره تحویل
بده (نه CampaignDelivery تکراری، نه notify/send_sms تکراری).
"""
def setUp(self):
self.user1 = User.objects.create(name='u1', is_guest=False, phone='0910000001')
self.user2 = User.objects.create(name='u2', is_guest=False, phone='0910000002')
def test_in_app_second_run_skips_already_sent_users(self):
campaign = MessageCampaign.objects.create(
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
)
execute_campaign(campaign)
self.assertEqual(CampaignDelivery.objects.filter(campaign=campaign).count(), 2)
self.assertEqual(Notification.objects.count(), 2)
self.assertEqual(
set(CampaignDelivery.objects.filter(campaign=campaign).values_list('status', flat=True)),
{CampaignDelivery.STATUS_SENT},
)
# اجرای دوباره‌ی همون کمپین: نباید هیچ CampaignDelivery یا
# Notification جدیدی ساخته بشه، چون هر دو کاربر از قبل sent هستن.
execute_campaign(campaign)
self.assertEqual(CampaignDelivery.objects.filter(campaign=campaign).count(), 2)
self.assertEqual(Notification.objects.count(), 2)
@patch('campaigns.services.account_services.send_sms')
def test_sms_second_run_skips_already_sent_users(self, mock_send_sms):
campaign = MessageCampaign.objects.create(
title='t', body='b', channel='sms', target_filter={'type': 'all'},
)
execute_campaign(campaign)
self.assertEqual(mock_send_sms.call_count, 2)
self.assertEqual(CampaignDelivery.objects.filter(campaign=campaign).count(), 2)
execute_campaign(campaign)
# هیچ فراخوانیِ اضافه‌ای برای send_sms نباید انجام بشه.
self.assertEqual(mock_send_sms.call_count, 2)
self.assertEqual(CampaignDelivery.objects.filter(campaign=campaign).count(), 2)
@patch('campaigns.services.account_services.send_sms')
def test_sms_failed_delivery_is_retried_not_treated_as_duplicate(self, mock_send_sms):
"""
رکوردهای failed نشونه‌ی «تحویلِ قبلاً کامل‌شده» نیستن؛ پس اجرای
دوباره باید دوباره امتحانشون کنه، نه skip.
"""
mock_send_sms.side_effect = SMSDeliveryError('boom')
campaign = MessageCampaign.objects.create(
title='t', body='b', channel='sms', target_filter={'type': 'all'},
)
execute_campaign(campaign)
self.assertEqual(mock_send_sms.call_count, 2)
self.assertEqual(
set(CampaignDelivery.objects.filter(campaign=campaign).values_list('status', flat=True)),
{CampaignDelivery.STATUS_FAILED},
)
mock_send_sms.side_effect = None
execute_campaign(campaign)
self.assertEqual(mock_send_sms.call_count, 4)
self.assertEqual(CampaignDelivery.objects.filter(campaign=campaign).count(), 4)
self.assertEqual(
CampaignDelivery.objects.filter(
campaign=campaign, status=CampaignDelivery.STATUS_SENT,
).count(),
2,
)
@patch('campaigns.services.notify')
def test_in_app_unexpected_exception_marks_delivery_failed_and_reraises(self, mock_notify):
"""
اگه notify() یک استثنای غیرمنتظره بده، اون خطا نباید catch/hide
بشه (باید بالا بره، مثلِ قبل)، ولی رکوردِ pendingِ همون کاربر
باید قبل از re-raise به failed آپدیت بشه — وگرنه به‌خاطرِ
unique constraintِ CampaignDelivery، این کاربر برای همیشه گیر
می‌کنه و هیچ اجرای بعدی‌ای نمی‌تونه دوباره claim کنه.
"""
mock_notify.side_effect = RuntimeError('boom')
campaign = MessageCampaign.objects.create(
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
)
with self.assertRaises(RuntimeError):
execute_campaign(campaign)
# فقط یک کاربر (اولین موردِ resolve‌شده) رکورد گرفته، چون خطا
# بلافاصله بعد از notify() اول بالا رفته و حلقه متوقف شده —
# دقیقاً همون رفتاری که قبل از این تغییر هم وجود داشت.
deliveries = CampaignDelivery.objects.filter(campaign=campaign)
self.assertEqual(deliveries.count(), 1)
self.assertEqual(deliveries.get().status, CampaignDelivery.STATUS_FAILED)
self.assertEqual(Notification.objects.count(), 0)
@patch('campaigns.services.notify')
def test_in_app_failed_delivery_after_exception_can_be_retried(self, mock_notify):
"""
بعد از اینکه یک اجرا با notify() شکست‌خورده رکوردِ failed ساخته،
اجرای بعدی نباید به‌خاطرِ constraint گیر کنه — باید بتونه دوباره
برای همون کاربر claim کنه و این‌بار اگه notify() موفق باشه، به
sent برسه.
"""
mock_notify.side_effect = RuntimeError('boom')
campaign = MessageCampaign.objects.create(
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
)
with self.assertRaises(RuntimeError):
execute_campaign(campaign)
self.assertEqual(
CampaignDelivery.objects.filter(campaign=campaign).count(), 1,
)
mock_notify.side_effect = None
execute_campaign(campaign)
# هر دو کاربر باید حالا sent باشن: کاربرِ اول (که بارِ قبل failed
# شده بود) با یک رکوردِ جدید retry شده، و کاربرِ دوم هم برای
# اولین‌بار موفق بوده.
self.assertEqual(CampaignDelivery.objects.filter(campaign=campaign).count(), 3)
self.assertEqual(
CampaignDelivery.objects.filter(
campaign=campaign, status=CampaignDelivery.STATUS_SENT,
).count(),
2,
)
class CampaignDeliveryConcurrencyTests(TestCase):
"""
محافظتِ سطحِ دیتابیس در برابرِ race condition بینِ دو اجرای هم‌زمانِ
execute_campaign() برای همون campaign: هر (campaign, user_id,
channel) حداکثر یک رکوردِ «فعال» (pending یا sent) می‌تونه داشته
باشه. رکوردهای failed مستثنا هستن تا رفتارِ retryِ فعلی دست‌نخورده
بمونه (پوشش داده‌شده در ExecuteCampaignIdempotencyTests بالا).
"""
def setUp(self):
self.user = User.objects.create(name='u', is_guest=False, phone='0910000003')
self.campaign = MessageCampaign.objects.create(
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
)
def test_second_pending_delivery_for_same_user_is_rejected(self):
CampaignDelivery.objects.create(
campaign=self.campaign, user_id=self.user.id, channel='in_app',
)
with self.assertRaises(IntegrityError):
with transaction.atomic():
CampaignDelivery.objects.create(
campaign=self.campaign, user_id=self.user.id, channel='in_app',
)
def test_second_sent_delivery_for_same_user_is_rejected(self):
first = CampaignDelivery.objects.create(
campaign=self.campaign, user_id=self.user.id, channel='in_app',
)
first.status = CampaignDelivery.STATUS_SENT
first.save(update_fields=['status'])
with self.assertRaises(IntegrityError):
with transaction.atomic():
CampaignDelivery.objects.create(
campaign=self.campaign, user_id=self.user.id, channel='in_app',
)
def test_failed_delivery_does_not_block_retry(self):
first = CampaignDelivery.objects.create(
campaign=self.campaign, user_id=self.user.id, channel='in_app',
)
first.status = CampaignDelivery.STATUS_FAILED
first.save(update_fields=['status'])
# نباید IntegrityError بده: رکوردِ failed دیگه «فعال» حساب
# نمی‌شه، پس جای رکوردِ جدید رو برای retry باز می‌ذاره.
second = CampaignDelivery.objects.create(
campaign=self.campaign, user_id=self.user.id, channel='in_app',
)
self.assertIsNotNone(second.pk)
@patch('campaigns.services.notify')
def test_execute_campaign_skips_user_claimed_by_concurrent_run(self, mock_notify):
"""
شبیه‌سازیِ اجرای هم‌زمان: یک رکوردِ pending از قبل برای این کاربر
وجود داره (انگار یک اجرای دیگرِ همین campaign همین الان داره
روش کار می‌کنه). execute_campaign نباید notify رو صدا بزنه یا
رکوردِ دیگه‌ای برای این کاربر بسازه.
"""
CampaignDelivery.objects.create(
campaign=self.campaign, user_id=self.user.id, channel='in_app',
)
execute_campaign(self.campaign)
mock_notify.assert_not_called()
self.assertEqual(
CampaignDelivery.objects.filter(campaign=self.campaign).count(), 1,
)
class ExecuteCampaignRealConcurrencyTests(TransactionTestCase):
"""
برخلافِ CampaignDeliveryConcurrencyTests (که وضعیتِ «قبلاً claim‌شده»
رو با ساختنِ دستیِ یک رکوردِ pending شبیه‌سازی می‌کنه)، این تست دو
execute_campaign() واقعی رو در دو تردِ جداگانه — هرکدوم با کانکشنِ
دیتابیسِ خودش — دقیقاً هم‌زمان (با threading.Barrier) اجرا می‌کنه.
از TransactionTestCase استفاده شده (نه TestCase) چون TestCase کلِ
تست رو در یک تراکنشِ واحد rollback می‌کنه و بینِ کانکشن‌ها ایزوله
نیست — یعنی race واقعیِ بینِ دو تراکنشِ مستقل رو نشون نمی‌ده.
"""
def setUp(self):
self.user = User.objects.create(name='u', is_guest=False, phone='0910000004')
self.campaign = MessageCampaign.objects.create(
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
)
def test_two_concurrent_runs_deliver_notification_exactly_once(self):
barrier = threading.Barrier(2)
errors = []
def run():
try:
barrier.wait(timeout=5)
execute_campaign(self.campaign)
except Exception as exc: # pragma: no cover - surfaced via `errors`
errors.append(exc)
finally:
connection.close()
threads = [threading.Thread(target=run) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
self.assertEqual(errors, [])
self.assertEqual(
CampaignDelivery.objects.filter(
campaign=self.campaign, status=CampaignDelivery.STATUS_SENT,
).count(),
1,
)
self.assertEqual(Notification.objects.count(), 1)
class InAppDeliveryConsistencyTests(TestCase):
"""
برای in_app، notify() و آپدیتِ نهاییِ CampaignDelivery به sent هر دو
تویِ یک transaction.atomic() هستن. این تست ثابت می‌کنه که اگه خودِ
نوشتنِ «sent» (نه notify()) به یک خطای دیتابیسی بخوره، کل تراکنش
rollback می‌شه — یعنی Notificationِ ساخته‌شده توسطِ notify() هم
persist نمی‌مونه — و رکورد به‌جای اینکه برای همیشه pending گیر کنه،
failed می‌شه (و در نتیجه در اجرای بعدی قابلِ retry‌ه).
"""
def setUp(self):
self.user = User.objects.create(name='u', is_guest=False, phone='0910000005')
self.campaign = MessageCampaign.objects.create(
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
)
def test_failed_status_update_rolls_back_notification_and_marks_failed(self):
original_save = CampaignDelivery.save
def flaky_save(self_delivery, *args, **kwargs):
# فقط دقیقاً همون save‌ای که داره سعی می‌کنه رکورد رو به sent
# ببره شکست می‌خوره (نه save‌ی claim که status هنوز pendingه،
# و نه save‌ی مسیرِ failedِ بعدی که این تابع دیگه patch نشده).
if self_delivery.status == CampaignDelivery.STATUS_SENT:
raise OperationalError('simulated transient db failure')
return original_save(self_delivery, *args, **kwargs)
with patch.object(CampaignDelivery, 'save', flaky_save):
with self.assertRaises(OperationalError):
execute_campaign(self.campaign)
# notify() واقعاً صدا زده شده بود (نه mock)، ولی چون تویِ همون
# atomic-ی بود که بعداً rollback شد، هیچ Notificationِ persist‌
# شده‌ای نباید وجود داشته باشه.
self.assertEqual(Notification.objects.count(), 0)
delivery = CampaignDelivery.objects.get(campaign=self.campaign, user_id=self.user.id)
self.assertEqual(delivery.status, CampaignDelivery.STATUS_FAILED)
# حالا که رکورد failed شده (نه pendingِ گیرکرده)، اجرای بعدی
# باید بتونه دوباره claim کنه و این‌بار (بدونِ patch) موفق بشه.
execute_campaign(self.campaign)
delivery.refresh_from_db()
self.assertEqual(delivery.status, CampaignDelivery.STATUS_FAILED)
self.assertEqual(
CampaignDelivery.objects.filter(
campaign=self.campaign, user_id=self.user.id,
status=CampaignDelivery.STATUS_SENT,
).count(),
1,
)
self.assertEqual(Notification.objects.count(), 1)
class CampaignsAdminRegistrationTests(TestCase):
"""
اطمینان از این‌که MessageCampaign و CampaignDelivery در Django Admin
ثبت شدن و یک ادمینِ staff می‌تونه changelist هرکدوم رو ببینه.
توجه: get_user_model() اینجا مدلِ auth.User جنگو (برای لاگین به
/admin/) هست، کاملاً جدا از accounts.models.User که مدلِ بیزنسیِ
این پروژه‌ست و در CampaignDelivery.user_id به‌صورت سبک ارجاع
می‌شه.
"""
def setUp(self):
AuthUser = get_user_model()
self.staff_user = AuthUser.objects.create_superuser(
username='admin', email='admin@example.com', password='pass12345',
)
self.client.force_login(self.staff_user)
def test_message_campaign_registered(self):
self.assertIn(MessageCampaign, admin.site._registry)
def test_campaign_delivery_registered(self):
self.assertIn(CampaignDelivery, admin.site._registry)
def test_message_campaign_changelist_accessible(self):
url = reverse('admin:campaigns_messagecampaign_changelist')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_campaign_delivery_changelist_accessible(self):
url = reverse('admin:campaigns_campaigndelivery_changelist')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
class MessageCampaignAdminManagementTests(TestCase):
"""
MessageCampaign باید از ادمین قابلِ ساخت/ویرایش/حذف باشه (بدونِ هیچ
منطقِ اجرا) — یعنی رفتارِ پیش‌فرضِ ModelAdmin دست‌نخورده مونده.
"""
def setUp(self):
AuthUser = get_user_model()
self.staff_user = AuthUser.objects.create_superuser(
username='admin2', email='admin2@example.com', password='pass12345',
)
self.client.force_login(self.staff_user)
def test_add_permission_allowed(self):
request = type('Req', (), {'user': self.staff_user})()
model_admin = MessageCampaignAdmin(MessageCampaign, admin.site)
self.assertTrue(model_admin.has_add_permission(request))
def test_can_create_campaign_via_admin(self):
url = reverse('admin:campaigns_messagecampaign_add')
response = self.client.post(url, {
'title': 'admin-created',
'body': 'body text',
'channel': 'sms',
'target_filter': '{}',
})
# ریدایرکتِ موفق بعدِ ساختِ رکورد
self.assertEqual(response.status_code, 302)
self.assertTrue(MessageCampaign.objects.filter(title='admin-created').exists())
def test_can_delete_campaign_via_admin(self):
campaign = MessageCampaign.objects.create(title='to-delete', body='b', channel='sms')
url = reverse('admin:campaigns_messagecampaign_delete', args=[campaign.pk])
response = self.client.post(url, {'post': 'yes'})
self.assertEqual(response.status_code, 302)
self.assertFalse(MessageCampaign.objects.filter(pk=campaign.pk).exists())
class CampaignDeliveryAdminReadOnlyTests(TestCase):
"""
CampaignDelivery نباید از ادمین قابلِ افزودن/ویرایش/حذفِ دستی باشه؛
فقط باید قابلِ مشاهده باشه. این دقیقاً همون چیزیه که
campaigns.services.execute_campaign برای idempotency/unique
constraint روش تکیه می‌کنه — دستکاریِ دستیِ status می‌تونه اون
تضمین‌ها رو دور بزنه.
"""
def setUp(self):
AuthUser = get_user_model()
self.staff_user = AuthUser.objects.create_superuser(
username='admin3', email='admin3@example.com', password='pass12345',
)
self.client.force_login(self.staff_user)
self.user = User.objects.create(name='u', is_guest=False, phone='0910000009')
self.campaign = MessageCampaign.objects.create(title='t', body='b', channel='sms')
self.delivery = CampaignDelivery.objects.create(
campaign=self.campaign, user_id=self.user.id, channel='sms',
)
def test_permission_methods_deny_add_change_delete_but_allow_view(self):
request = type('Req', (), {'user': self.staff_user})()
model_admin = CampaignDeliveryAdmin(CampaignDelivery, admin.site)
self.assertFalse(model_admin.has_add_permission(request))
self.assertFalse(model_admin.has_change_permission(request, self.delivery))
self.assertFalse(model_admin.has_delete_permission(request, self.delivery))
self.assertTrue(model_admin.has_view_permission(request, self.delivery))
def test_change_detail_view_is_viewable(self):
"""
صفحه‌ی جزئیاتِ رکورد باید قابلِ مشاهده باشه (GET -> 200، به‌صورتِ
read-only)، حتی اگه has_change_permission همیشه False باشه؛
دیدن نباید به fallbackِ codename پرمیشنِ پیش‌فرضِ Django وابسته
بمونه (رجوع به has_view_permission بالا).
"""
url = reverse('admin:campaigns_campaigndelivery_change', args=[self.delivery.pk])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_all_fields_are_readonly(self):
model_admin = CampaignDeliveryAdmin(CampaignDelivery, admin.site)
self.assertEqual(set(model_admin.readonly_fields), set(model_admin.fields))
def test_add_view_forbidden(self):
url = reverse('admin:campaigns_campaigndelivery_add')
response = self.client.get(url)
self.assertEqual(response.status_code, 403)
def test_delete_view_forbidden(self):
url = reverse('admin:campaigns_campaigndelivery_delete', args=[self.delivery.pk])
response = self.client.get(url)
self.assertEqual(response.status_code, 403)
def test_change_via_post_does_not_modify_status(self):
"""
حتی اگه کسی مستقیماً POST بزنه به change URL، چون
has_change_permission همیشه False برمی‌گردونه، جنگو باید 403
بده و status رکورد نباید تغییر کنه.
"""
url = reverse('admin:campaigns_campaigndelivery_change', args=[self.delivery.pk])
response = self.client.post(url, {
'campaign': self.campaign.pk,
'user_id': self.user.id,
'channel': 'sms',
'status': CampaignDelivery.STATUS_SENT,
'error_message': '',
})
self.assertEqual(response.status_code, 403)
self.delivery.refresh_from_db()
self.assertEqual(self.delivery.status, CampaignDelivery.STATUS_PENDING)