Add campaigns/ Add supervisor/ Add messaging features/ Separate SMS
This commit is contained in:
0
campaigns/__init__.py
Normal file
0
campaigns/__init__.py
Normal file
68
campaigns/admin.py
Normal file
68
campaigns/admin.py
Normal 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
6
campaigns/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CampaignsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'campaigns'
|
||||
28
campaigns/migrations/0001_initial.py
Normal file
28
campaigns/migrations/0001_initial.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
29
campaigns/migrations/0002_campaigndelivery.py
Normal file
29
campaigns/migrations/0002_campaigndelivery.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
),
|
||||
]
|
||||
0
campaigns/migrations/__init__.py
Normal file
0
campaigns/migrations/__init__.py
Normal file
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}"
|
||||
244
campaigns/services.py
Normal file
244
campaigns/services.py
Normal 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
495
campaigns/tests.py
Normal 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)
|
||||
Reference in New Issue
Block a user