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

View File

@@ -93,6 +93,7 @@ push می‌کنه ← Grafana از Loki می‌خونه. جزئیاتِ بیش
- بین اپ‌ها به‌جای FK واقعی از ارجاع سبک (`user_id`, `object_id` + `label`) استفاده شده تا هر اپ در آینده قابل جدا شدن باشد. - بین اپ‌ها به‌جای FK واقعی از ارجاع سبک (`user_id`, `object_id` + `label`) استفاده شده تا هر اپ در آینده قابل جدا شدن باشد.
- `POST /api/ai/ask` فعلاً پاسخ stub برمی‌گرداند؛ محل اتصال مدل واقعی در `ai_tutor/views.py` (تابع `generate_tutor_answer`) مشخص شده. - `POST /api/ai/ask` فعلاً پاسخ stub برمی‌گرداند؛ محل اتصال مدل واقعی در `ai_tutor/views.py` (تابع `generate_tutor_answer`) مشخص شده.
- `DEV_MASTER_OTP` (کد `0000`) فقط برای تست در توسعه است — **پیش از دیپلوی پروداکشن باید حذف یا به `DEBUG` مقیّد شود.** - `DEV_MASTER_OTP` (کد `0000`) فقط برای تست در توسعه است — **پیش از دیپلوی پروداکشن باید حذف یا به `DEBUG` مقیّد شود.**
- **لاگِ ساختاریافته**: همه‌ی درخواست‌های `/api/` به‌صورتِ JSON لاگ می‌شن (متد/مسیر/وضعیت/مدت/کاربر، بدونِ بدنه/هدر/سکرت)؛ قابلِ مشاهده و جست‌وجو در Grafana (بخشِ Docker بالا).
## قبل از دیپلوی پروداکشن ## قبل از دیپلوی پروداکشن
@@ -100,8 +101,10 @@ push می‌کنه ← Grafana از Loki می‌خونه. جزئیاتِ بیش
`DEBUG` روی `False` است) `DEBUG` روی `False` است)
- [x] `DEV_MASTER_OTP` و برگردوندنِ `devOtp` توی پاسخ، فقط وقتی `DEBUG=True` - [x] `DEV_MASTER_OTP` و برگردوندنِ `devOtp` توی پاسخ، فقط وقتی `DEBUG=True`
فعالن؛ در پروداکشن غیرفعالن فعالن؛ در پروداکشن غیرفعالن
- [x] rate limit (`ScopedRateThrottle`) روی `auth/otp/request` (۵/ساعت) و - [x] rate limit (throttle) روی endpointهای احرازهویت و بقیه‌ی endpointهای
`auth/login` (۱۰/ساعت) اضافه شد حساسِ پروژه (پرداخت، خرید اشتراک، تخفیف، AI، محتوا، خانواده،
پشتیبانی، affiliate، …) پیاده‌سازی شده؛ برای کاربرهای مهمان بر
اساسِ IP کلید می‌خوره تا چرخشِ حساب مهمان دورش نزنه
- [x] IDOR در `GET /api/home` و `GET /api/progress/nodes` اصلاح شد: قبلاً - [x] IDOR در `GET /api/home` و `GET /api/progress/nodes` اصلاح شد: قبلاً
بدون هیچ توکنی، با فرستادنِ `userId` دلخواه می‌شد آمار/پیشرفتِ هر بدون هیچ توکنی، با فرستادنِ `userId` دلخواه می‌شد آمار/پیشرفتِ هر
کاربرِ دیگه‌ای رو خوند؛ حالا auth اجباریه و فقط خودِ کاربر یا والدِ کاربرِ دیگه‌ای رو خوند؛ حالا auth اجباریه و فقط خودِ کاربر یا والدِ

View File

@@ -1,6 +1,51 @@
from django.contrib import admin from datetime import datetime
from django import forms
from django.conf import settings
from django.contrib import admin, messages
from django.contrib.admin import helpers
from django.core.exceptions import PermissionDenied
from django.shortcuts import render
from django.utils import timezone
from .models import OTPCode, Profile, Session, User from .models import OTPCode, Profile, Session, User
from .services import SMSDeliveryError, get_inactive_users, send_sms
class SendSMSForm(forms.Form):
TARGET_SELECTED = 'selected'
TARGET_INACTIVE = 'inactive'
TARGET_CHOICES = [
(TARGET_SELECTED, 'کاربرانِ انتخاب‌شده (همان‌هایی که در لیست تیک خورده‌اند)'),
(TARGET_INACTIVE, 'کاربرانِ غیرفعال (بر اساسِ تاریخِ آخرین فعالیت)'),
]
message = forms.CharField(
label='متن پیامک', widget=forms.Textarea(attrs={'rows': 4}), max_length=500,
)
target = forms.ChoiceField(
label='گیرندگان',
choices=TARGET_CHOICES,
widget=forms.RadioSelect,
initial=TARGET_SELECTED,
)
inactive_since = forms.DateField(
label='غیرفعال از این تاریخ به بعد',
help_text='فقط وقتی گزینه‌ی «کاربرانِ غیرفعال» انتخاب شده باشد استفاده می‌شود.',
required=False,
widget=forms.DateInput(attrs={'type': 'date'}),
)
def clean(self):
cleaned_data = super().clean()
target = cleaned_data.get('target')
inactive_since = cleaned_data.get('inactive_since')
if target == self.TARGET_INACTIVE and not inactive_since:
self.add_error(
'inactive_since',
'برای گیرندگانِ «کاربرانِ غیرفعال» وارد کردنِ تاریخ الزامی است.',
)
return cleaned_data
@admin.register(User) @admin.register(User)
@@ -8,6 +53,124 @@ class UserAdmin(admin.ModelAdmin):
list_display = ('id', 'name', 'phone', 'device_id', 'is_guest', 'grade_id', 'created_at') list_display = ('id', 'name', 'phone', 'device_id', 'is_guest', 'grade_id', 'created_at')
search_fields = ('name', 'phone', 'device_id') search_fields = ('name', 'phone', 'device_id')
list_filter = ('is_guest',) list_filter = ('is_guest',)
actions = ['send_sms_to_selected']
# permissions=['change']: بدونِ این، جنگو این اکشن رو بر اساسِ
# has_change_permission فیلتر نمی‌کنه (فقط اکشن‌هایی که با پارامترِ
# permissions تعریف شده باشن فیلتر می‌شن)، یعنی کاربری که فقط
# view_user داره (مثلِ گروهِ Supervisor) می‌تونه با یک POSTِ مستقیم
# همین اکشن رو صدا بزنه و واقعاً پیامک بفرسته، حتی بدونِ اینکه دکمه‌ی
# اکشن رو در UI ببینه. با permissions=['change']، ModelAdmin.response_action
# خودش (get_actions -> _filter_actions_by_permissions) این اکشن رو
# فقط برای کاربرانِ دارایِ change_user در دسترس می‌ذاره؛ برای بقیه
# (مثلِ Supervisor) اصلاً در actions ثبت‌شده نیست، پس درخواستِ مستقیم
# هم بی‌اثر می‌مونه (پیامِ هشدارِ استانداردِ «اکشنی انتخاب نشده»).
@admin.action(description='ارسال پیامک به کاربران انتخاب‌شده', permissions=['change'])
def send_sms_to_selected(self, request, queryset):
"""
قدمِ اولیه‌ی مینیمالِ فیچرِ «ارسالِ پیامک از ادمین»: یک اکشنِ ادمینِ
معمولی روی UserAdmin که یک صفحه‌ی میانیِ ساده (فرمِ متنِ پیامک) رو
نشون می‌ده و بعد از تأیید، دقیقاً به همون کاربرهایی که در changelist
انتخاب شدن، همون متن رو با accounts.services.send_sms موجود ارسال
می‌کنه.
گیرنده‌ها با فیلدِ «target» در فرم انتخاب می‌شن:
- 'selected' (پیش‌فرض): همون queryset که ادمین در changelist
انتخاب کرده — رفتارِ قبلی، بدون تغییر.
- 'inactive': کاربرانِ غیرفعال، با استفاده از
accounts.services.get_inactive_users(since) — cutoff (تاریخ)
رو خودِ ادمین در فرم وارد می‌کنه، هیچ عددِ روزی hardcode نشده.
عمداً محدود نگه داشته شده:
- هیچ مدلِ کمپین/بدنه‌ی جدیدی ساخته/persist نمی‌شه (نه رکوردی از
پیام، نه رکوردی از تحویل)؛ ارسال کاملاً بلادرنگه.
- هیچ زمان‌بندی، صفِ پس‌زمینه، یا فیلترِ هدف‌گیریِ دیگه‌ای (غیر از
selected/inactive) نیست.
- محافظتِ سقف: چون ارسال هنوز کاملاً synchronous و توی همون
request انجام می‌شه (نه صفِ پس‌زمینه)، اگر تعدادِ گیرنده‌ها از
settings.ADMIN_SMS_MAX_RECIPIENTS بیشتر باشه، اکشن هیچ پیامکی
ارسال نمی‌کنه و فقط خطا نشون می‌ده — تا یک انتخاب/تاریخِ خیلی
بزرگ باعثِ تایم‌اوتِ request یا overload شدنِ پروایدر نشه.
- هیچ منطقِ provider تکرار نشده؛ send_sms عیناً همون تابعِ موجودیه
که OTP و campaigns هم ازش استفاده می‌کنن.
- کاربرانِ بدونِ شماره‌تلفن (مثلِ مهمان) به‌سادگی skip می‌شن.
- شکستِ ارسال برای یک کاربر (SMSDeliveryError) کلِ اکشن رو متوقف
نمی‌کنه؛ فقط شمارشِ failed رو زیاد می‌کنه و برای بقیه ادامه می‌ده.
"""
if 'apply' in request.POST:
form = SendSMSForm(request.POST)
if form.is_valid():
message = form.cleaned_data['message']
target = form.cleaned_data['target']
if target == SendSMSForm.TARGET_INACTIVE:
since = timezone.make_aware(
datetime.combine(form.cleaned_data['inactive_since'], datetime.min.time())
)
recipients = get_inactive_users(since)
else:
recipients = queryset
max_recipients = settings.ADMIN_SMS_MAX_RECIPIENTS
recipients_count = recipients.count()
if recipients_count > max_recipients:
self.message_user(
request,
(
f'تعدادِ گیرندگان ({recipients_count} کاربر) بیش از سقفِ مجازِ '
f'ارسالِ همزمان از ادمین ({max_recipients} کاربر) است؛ هیچ '
f'پیامکی ارسال نشد. لطفاً بازه/انتخاب را محدودتر کنید.'
),
level=messages.ERROR,
)
# به‌جای return None، به رندرِ مشترکِ پایینِ تابع سقوط
# می‌کنیم تا فرم دوباره (بدونِ ارسالِ هیچ پیامکی) نشون
# داده بشه — دقیقاً همون الگویی که برای فرمِ نامعتبر
# استفاده می‌شه.
else:
sent_count = 0
failed_count = 0
skipped_no_phone = 0
for user in recipients:
if not user.phone:
skipped_no_phone += 1
continue
try:
send_sms(phone=user.phone, message=message)
except SMSDeliveryError:
failed_count += 1
else:
sent_count += 1
if sent_count:
self.message_user(
request, f'پیامک با موفقیت به {sent_count} کاربر ارسال شد.',
level=messages.SUCCESS,
)
if failed_count:
self.message_user(
request, f'ارسالِ پیامک برای {failed_count} کاربر ناموفق بود.',
level=messages.ERROR,
)
if skipped_no_phone:
self.message_user(
request,
f'{skipped_no_phone} کاربر بدونِ شماره‌تلفن رد شدن (پیامک ارسال نشد).',
level=messages.WARNING,
)
# None یعنی صفحه‌ی changelist دوباره نمایش داده بشه.
return None
else:
form = SendSMSForm()
context = {
**self.admin_site.each_context(request),
'title': 'ارسال پیامک به کاربران انتخاب‌شده',
'opts': self.model._meta,
'users': queryset,
'form': form,
'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME,
}
return render(request, 'accounts/send_sms_form.html', context)
@admin.register(Profile) @admin.register(Profile)
@@ -25,3 +188,83 @@ class SessionAdmin(admin.ModelAdmin):
class OTPCodeAdmin(admin.ModelAdmin): class OTPCodeAdmin(admin.ModelAdmin):
list_display = ('id', 'phone', 'code', 'expires_at', 'is_used') list_display = ('id', 'phone', 'code', 'expires_at', 'is_used')
list_filter = ('is_used',) list_filter = ('is_used',)
# ─────────────────────────────────────────────────────────────────────────
# صفحه‌ی «ناظر» (Supervisor)
#
# یک صفحه‌ی خیلی کوچیک و *read-only* داخلِ همون AdminSiteِ پیش‌فرضِ جنگو
# (django.contrib.admin.site) — نه یک AdminSite جدا، و نه CRUDِ موازی.
# این صفحه صرفاً یک ایندکسِ گروهی از لینک‌هایی به همون changelistهای
# ModelAdmin موجودِ پروژه‌ست: کاربران (accounts.UserAdmin)، پرداخت‌ها
# (payment.OrderAdmin / payment.TransactionAdmin) و اشتراک‌ها
# (subscription.SubscriptionAdmin). هیچ منطقِ کسب‌وکار یا ModelAdmin
# جدیدی اینجا ساخته نمی‌شه.
#
# محدودیتِ واقعیِ read-only از طریقِ سیستمِ استانداردِ Group/Permission
# جنگو تأمین می‌شه، نه با پنهون‌کردنِ لینک:
# - هر لینک در این صفحه فقط وقتی نشون داده می‌شه که کاربر پرمیشنِ
# view_<model> مربوطه رو داشته باشه (request.user.has_perm(...)).
# - حتی اگه کسی مستقیماً URL تغییر/افزودن/حذف رو حدس بزنه، خودِ
# changelist/change/add/delete viewهای اون ModelAdminها (که اینجا هیچ
# override‌ای نشدن) طبقِ has_view_permission/has_change_permission/
# has_add_permission/has_delete_permission پیش‌فرضِ ModelAdmin — که
# مستقیماً از Permission مدلِ کاربر می‌خونن — چک می‌کنن؛ یعنی گروهِ
# Supervisor (که با دستورِ مدیریتیِ setup_supervisor_group فقط پرمیشنِ
# view_* می‌گیره) حتی با دسترسیِ مستقیم به URL هم نمی‌تونه add/change/
# delete کنه.
# - خودِ صفحه هم مثلِ هر صفحه‌ی دیگه‌ی Admin با admin.site.admin_view
# wrap شده، یعنی همون احرازِ هویت/is_staff استانداردِ Admin رو داره؛
# ولی is_staff به‌تنهایی برای *ورود به خودِ صفحه* کافی نیست — پایین‌تر
# قبل از هر کارِ دیگه‌ای عضویتِ کاربر در گروهِ Supervisor هم چک می‌شه
# (request.user.groups.filter(name=SUPERVISOR_GROUP_NAME))، وگرنه
# PermissionDenied (۴۰۳) بالا می‌ره. این یک سیستمِ پرمیشنِ جدید نیست؛
# فقط از همون گروهِ Supervisorِ موجود (که setup_supervisor_group
# می‌سازه) برای گیت‌کیپینگِ ورود به صفحه استفاده می‌کنه.
# ─────────────────────────────────────────────────────────────────────────
SUPERVISOR_GROUP_NAME = 'Supervisor'
def supervisor_dashboard(request):
from payment.models import Order, Transaction
from subscription.models import Subscription
if not request.user.groups.filter(name=SUPERVISOR_GROUP_NAME).exists():
raise PermissionDenied
def _link(label, model):
opts = model._meta
return {
'label': label,
'url_name': f'admin:{opts.app_label}_{opts.model_name}_changelist',
'has_perm': request.user.has_perm(f'{opts.app_label}.view_{opts.model_name}'),
}
sections = [
{'title': 'کاربران', 'links': [_link('کاربران', User)]},
{'title': 'پرداخت‌ها', 'links': [_link('سفارش‌ها', Order), _link('تراکنش‌ها', Transaction)]},
{'title': 'اشتراک‌ها', 'links': [_link('اشتراک‌ها', Subscription)]},
]
context = {
**admin.site.each_context(request),
'title': 'ناظر (Supervisor)',
'sections': sections,
}
return render(request, 'admin/supervisor_dashboard.html', context)
_original_admin_get_urls = admin.site.get_urls
def _admin_get_urls_with_supervisor():
from django.urls import path
custom_urls = [
path('supervisor/', admin.site.admin_view(supervisor_dashboard), name='supervisor-dashboard'),
]
return custom_urls + _original_admin_get_urls()
admin.site.get_urls = _admin_get_urls_with_supervisor

View File

View File

View File

@@ -0,0 +1,68 @@
"""
گروهِ Supervisor رو (اگه از قبل نبود) می‌سازه و *فقط* پرمیشنِ استانداردِ
view_* رو، دقیقاً روی همون چهار مدلی که صفحه‌ی /admin/supervisor/ بهشون
لینک می‌ده، بهش می‌ده: accounts.User، payment.Order، payment.Transaction،
subscription.Subscription. هیچ پرمیشنِ add/change/delete‌ای ست نمی‌شه.
اجرا (یک‌بار، بعد از migrate):
python manage.py setup_supervisor_group
idempotent است: هر بار که دوباره اجرا بشه، مجموعه‌ی پرمیشن‌های گروه رو
دقیقاً با همین لیست sync می‌کنه (group.permissions.set)، یعنی اگه کسی
دستی یک پرمیشنِ change/delete به این گروه اضافه کرده باشه، اجرای دوباره‌ی
این دستور خودش رو تصحیح می‌کنه.
پرمیشن‌های view_* توسطِ خودِ جنگو، بعد از migrate کاملِ هر اپ، اتوماتیک
ساخته می‌شن (django.contrib.auth's post_migrate signal)؛ برای همین این
منطق در یک management command جداگونه‌ست، نه یک data migration — چون در
لحظه‌ی اجرای یک data migration ممکنه اون پرمیشن‌ها هنوز ساخته نشده باشن.
"""
from django.contrib.auth.models import Group, Permission
from django.core.management.base import BaseCommand
from accounts.admin import SUPERVISOR_GROUP_NAME
SUPERVISOR_VIEW_PERMISSIONS = [
('accounts', 'user'),
('payment', 'order'),
('payment', 'transaction'),
('subscription', 'subscription'),
]
class Command(BaseCommand):
help = (
'گروهِ Supervisor رو می‌سازه/sync می‌کنه و فقط پرمیشنِ view_* رو '
'روی User/Order/Transaction/Subscription بهش می‌ده (بدونِ '
'add/change/delete). idempotent است.'
)
def handle(self, *args, **options):
group, created = Group.objects.get_or_create(name=SUPERVISOR_GROUP_NAME)
permissions = []
missing = []
for app_label, model_name in SUPERVISOR_VIEW_PERMISSIONS:
codename = f'view_{model_name}'
try:
permissions.append(
Permission.objects.get(content_type__app_label=app_label, codename=codename)
)
except Permission.DoesNotExist:
missing.append(f'{app_label}.{codename}')
group.permissions.set(permissions)
if created:
self.stdout.write(self.style.SUCCESS(f'گروهِ "{SUPERVISOR_GROUP_NAME}" ساخته شد.'))
else:
self.stdout.write(f'گروهِ "{SUPERVISOR_GROUP_NAME}" از قبل وجود داشت؛ پرمیشن‌ها sync شدن.')
self.stdout.write(
f'{len(permissions)} پرمیشنِ view-only به گروه اختصاص داده شد (بدونِ add/change/delete).'
)
if missing:
self.stderr.write(
'این پرمیشن‌ها پیدا نشدن (migrate کامل اجرا شده؟): ' + ', '.join(missing)
)

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.17 on 2026-08-24 16:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0002_user_uniq_guest_device_id'),
]
operations = [
migrations.AddField(
model_name='user',
name='last_activity_at',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@@ -17,6 +17,10 @@ class User(models.Model):
is_guest = models.BooleanField(default=True) is_guest = models.BooleanField(default=True)
grade_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Grade grade_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Grade
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
# آخرین زمانِ فعالیتِ کاربر (لاگین موفق، رفرش توکن موفق، یا سشنِ
# مهمانِ واقعی). فقط برای هدف‌گیریِ آینده‌ی کمپین‌های پیامی نگه‌داری
# می‌شه؛ در حال حاضر هیچ منطقِ دیگه‌ای به این فیلد وابسته نیست.
last_activity_at = models.DateTimeField(null=True, blank=True)
class Meta: class Meta:
ordering = ['-created_at'] ordering = ['-created_at']

View File

@@ -67,3 +67,16 @@ class RequestOTPResponseSerializer(serializers.Serializer):
success = serializers.BooleanField() success = serializers.BooleanField()
devOtp = serializers.CharField() devOtp = serializers.CharField()
note = serializers.CharField() note = serializers.CharField()
class ProfileResponseSerializer(serializers.Serializer):
"""پاسخ GET /api/user/profile — فقط داده‌ی لازم برای تب Profile اپ موبایل."""
userId = serializers.CharField()
phone = serializers.CharField(allow_null=True)
name = serializers.CharField()
avatar = serializers.CharField()
isGuest = serializers.BooleanField()
gradeId = serializers.IntegerField(allow_null=True)
birthYear = serializers.IntegerField(allow_null=True)
parentPhone = serializers.CharField()
bio = serializers.CharField()

View File

@@ -5,8 +5,12 @@ import logging
import requests import requests
from django.conf import settings from django.conf import settings
from django.db import models
from django.utils import timezone
from rest_framework.exceptions import APIException from rest_framework.exceptions import APIException
from .models import User
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -26,7 +30,19 @@ def _phone_suffix(phone: str) -> str:
return phone[-4:] if phone else phone return phone[-4:] if phone else phone
class OTPDeliveryError(APIException): class SMSDeliveryError(APIException):
"""
خطای عمومیِ ارسالِ پیامک، برای هر فراخوانی‌ای از send_sms (نه فقط OTP).
اگه پیکربندیِ سرویسِ پیامک ناقص باشه یا خودِ درخواست به ارائه‌دهنده
شکست بخوره، همین استثنا raise می‌شه (نه یک پاسخِ ساختگیِ موفق).
"""
status_code = 503
default_detail = 'در حال حاضر امکان ارسال پیامک وجود ندارد؛ لطفاً بعداً دوباره تلاش کنید.'
default_code = 'sms_delivery_failed'
class OTPDeliveryError(SMSDeliveryError):
""" """
⚠️ اصلاح: قبلاً send_otp_sms یک استاب بود که فقط لاگ می‌کرد و همیشه ⚠️ اصلاح: قبلاً send_otp_sms یک استاب بود که فقط لاگ می‌کرد و همیشه
(بی‌قیدوشرط) به RequestOTPView «موفقیت» برمی‌گردوند؛ یعنی در پروداکشن (بی‌قیدوشرط) به RequestOTPView «موفقیت» برمی‌گردوند؛ یعنی در پروداکشن
@@ -36,44 +52,37 @@ class OTPDeliveryError(APIException):
پیکربندیِ سرویسِ پیامک ناقص باشه یا خودِ درخواست به ارائه‌دهنده شکست پیکربندیِ سرویسِ پیامک ناقص باشه یا خودِ درخواست به ارائه‌دهنده شکست
بخوره، همین استثنا raise می‌شه و RequestOTPView یک خطای صریح (نه یک بخوره، همین استثنا raise می‌شه و RequestOTPView یک خطای صریح (نه یک
پاسخِ ساختگیِ موفق) برمی‌گردونه. پاسخِ ساختگیِ موفق) برمی‌گردونه.
این کلاس زیرکلاسِ SMSDeliveryError است تا رفتار/نوعِ استثنایی که
RequestOTPView (و تست‌ها) انتظارش رو دارن دقیقاً حفظ بشه، در حالی که
منطقِ واقعیِ ارتباط با ارائه‌دهنده در send_sms (عمومی) پیاده شده.
""" """
status_code = 503
default_detail = 'در حال حاضر امکان ارسال پیامک OTP وجود ندارد؛ لطفاً بعداً دوباره تلاش کنید.' default_detail = 'در حال حاضر امکان ارسال پیامک OTP وجود ندارد؛ لطفاً بعداً دوباره تلاش کنید.'
default_code = 'otp_sms_delivery_failed' default_code = 'otp_sms_delivery_failed'
def send_otp_sms(*, phone: str, code: str) -> None: def send_sms(*, phone: str, message: str) -> None:
""" """
ارسالِ واقعیِ پیامکِ OTP از طریق کاوه‌نگار (kavenegar.com) — دقیقاً همون ⚙️ Reusable هلپر: تنها بخشِ «ارتباط با ارائه‌دهنده» (کاوه‌نگار،
ارائه‌دهنده‌ای که در نسخه‌ی قبلی (استاب) به‌عنوان نمونه اشاره شده بود. kavenegar.com) که قبلاً داخلِ send_otp_sms با منطقِ OTP قاطی شده بود،
کلید API هیچ‌وقت داخل کد نیست؛ همیشه از env خونده می‌شه به این تابعِ عمومی منتقل شده — بدونِ هیچ تغییری در رفتار/پیام‌های
(settings.KAVENEGAR_API_KEY). دو حالت: provider. هر فیچرِ آینده (مثلاً notifications) هم می‌تونه بدونِ
تکرارِ کدِ ارائه‌دهنده، مستقیماً از همین تابع برای ارسالِ پیامک استفاده
کنه.
- DEBUG=True (dev/test): دقیقاً مثل قبل، فقط لاگ می‌کنه و هیچ پیامکِ این تابع هیچ منطقِ مخصوصِ OTP (dev-mode short-circuit، تمپلیتِ پیامک،
واقعی‌ای نمی‌فرسته — چون در این محیط، RequestOTPView خودِ کد رو در یا OTPDeliveryError) نداره؛ فقط payload می‌سازه، به کاوه‌نگار درخواست
فیلدِ devOtp پاسخ برمی‌گردونه و SMS واقعی لازم نیست (رفتار قبلی dev می‌زنه، و پاسخ رو اعتبارسنجی می‌کنه. کلیدِ API هیچ‌وقت داخل کد نیست؛
حفظ شده). همیشه از env خونده می‌شه (settings.KAVENEGAR_API_KEY).
- DEBUG=False (production): اگه KAVENEGAR_API_KEY تنظیم نشده باشه، یا
درخواست به کاوه‌نگار fail بشه/کدِ غیر ۲xx برگردونه، به‌جای گزارشِ
خاموشِ «موفقیت»، OTPDeliveryError raise می‌شه (کاربر پاسخ ۵۰۳ صریح
می‌گیره، نه یک success دروغین).
""" """
if settings.DEBUG:
logger.info(
'OTP sms dispatch (dev, no real SMS sent): phone_suffix=%s code_len=%d',
_phone_suffix(phone), len(code),
)
return
if not settings.KAVENEGAR_API_KEY: if not settings.KAVENEGAR_API_KEY:
logger.error( logger.error(
'OTP SMS misconfigured: KAVENEGAR_API_KEY is not set (phone_suffix=%s)', 'SMS misconfigured: KAVENEGAR_API_KEY is not set (phone_suffix=%s)',
_phone_suffix(phone), _phone_suffix(phone),
) )
raise OTPDeliveryError('پیکربندیِ سرویسِ پیامک ناقص است.') raise SMSDeliveryError('پیکربندیِ سرویسِ پیامک ناقص است.')
message = settings.OTP_SMS_TEMPLATE.format(code=code)
payload = {'receptor': phone, 'message': message} payload = {'receptor': phone, 'message': message}
if settings.KAVENEGAR_SENDER: if settings.KAVENEGAR_SENDER:
payload['sender'] = settings.KAVENEGAR_SENDER payload['sender'] = settings.KAVENEGAR_SENDER
@@ -85,37 +94,127 @@ def send_otp_sms(*, phone: str, code: str) -> None:
timeout=5, timeout=5,
) )
except requests.RequestException: except requests.RequestException:
logger.exception('OTP SMS request to provider failed: phone_suffix=%s', _phone_suffix(phone)) logger.exception('SMS request to provider failed: phone_suffix=%s', _phone_suffix(phone))
raise OTPDeliveryError('ارسال پیامک OTP با خطا مواجه شد.') raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
if response.status_code != 200: if response.status_code != 200:
logger.error( logger.error(
'OTP SMS provider returned non-200: phone_suffix=%s status=%s', 'SMS provider returned non-200: phone_suffix=%s status=%s',
_phone_suffix(phone), response.status_code, _phone_suffix(phone), response.status_code,
) )
raise OTPDeliveryError('ارسال پیامک OTP با خطا مواجه شد.') raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
# ⚠️ اصلاح: کاوه‌نگار حتی برای شکست‌های سطحِ اپلیکیشن (کلیدِ نامعتبر، # ⚠️ کاوه‌نگار حتی برای شکست‌های سطحِ اپلیکیشن (کلیدِ نامعتبر، اعتبار
# اعتبار ناکافی، گیرنده‌ی نامعتبر و…) HTTP 200 برمی‌گردونه؛ نتیجه‌ی # ناکافی، گیرنده‌ی نامعتبر و…) HTTP 200 برمی‌گردونه؛ نتیجه‌ی واقعی توی
# واقعی توی بدنه‌ی JSON، زیرِ return.status هست (200 یعنی موفق). قبلاً # بدنه‌ی JSON، زیرِ return.status هست (200 یعنی موفق). صرفِ
# همینِ status_code == 200 به‌تنهایی «موفقیت» در نظر گرفته می‌شد، یعنی # status_code == 200 «موفقیت» در نظر گرفته نمی‌شه؛ بدنه parse می‌شه و
# یک رد شدنِ واقعی از طرفِ کاوه‌نگار هم success=True دروغین برمی‌گردوند. # فقط return.status == 200 موفقیت محسوب می‌شه؛ هر پاسخِ ناقص/غیرمنتظره
# حالا بدنه parse می‌شه و فقط return.status == 200 موفقیت محسوب می‌شه؛ # هم شکستِ تحویل در نظر گرفته می‌شه (نه موفقیت).
# هر پاسخِ ناقص/غیرمنتظره هم شکستِ تحویل در نظر گرفته می‌شه (نه موفقیت).
try: try:
body = response.json() body = response.json()
provider_status = body['return']['status'] provider_status = body['return']['status']
except (ValueError, KeyError, TypeError): except (ValueError, KeyError, TypeError):
logger.error( logger.error(
'OTP SMS provider returned malformed response body: phone_suffix=%s status=%s', 'SMS provider returned malformed response body: phone_suffix=%s status=%s',
_phone_suffix(phone), response.status_code, _phone_suffix(phone), response.status_code,
) )
raise OTPDeliveryError('ارسال پیامک OTP با خطا مواجه شد.') raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
if provider_status != 200: if provider_status != 200:
logger.error( logger.error(
'OTP SMS provider rejected message at application level: phone_suffix=%s ' 'SMS provider rejected message at application level: phone_suffix=%s '
'provider_status=%s', 'provider_status=%s',
_phone_suffix(phone), provider_status, _phone_suffix(phone), provider_status,
) )
raise OTPDeliveryError('ارسال پیامک OTP با خطا مواجه شد.') raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
def send_otp_sms(*, phone: str, code: str) -> None:
"""
ارسالِ پیامکِ OTP. منطقِ مخصوصِ OTP (dev-mode short-circuit، تمپلیتِ
پیامک از OTP_SMS_TEMPLATE، و نگاشتِ خطا به OTPDeliveryError) اینجا
می‌مونه؛ ارتباطِ واقعی با ارائه‌دهنده به send_sms عمومی سپرده شده.
- DEBUG=True (dev/test): دقیقاً مثل قبل، فقط لاگ می‌کنه و هیچ پیامکِ
واقعی‌ای نمی‌فرسته — چون در این محیط، RequestOTPView خودِ کد رو در
فیلدِ devOtp پاسخ برمی‌گردونه و SMS واقعی لازم نیست (رفتار قبلی dev
حفظ شده).
- DEBUG=False (production): send_sms صدا زده می‌شه؛ هر SMSDeliveryError
به OTPDeliveryError نگاشت می‌شه تا رفتار/نوعِ استثنایی که
RequestOTPView انتظارش رو داره حفظ بمونه (کاربر پاسخ ۵۰۳ صریح
می‌گیره، نه یک success دروغین).
"""
if settings.DEBUG:
logger.info(
'OTP sms dispatch (dev, no real SMS sent): phone_suffix=%s code_len=%d',
_phone_suffix(phone), len(code),
)
return
message = settings.OTP_SMS_TEMPLATE.format(code=code)
try:
send_sms(phone=phone, message=message)
except SMSDeliveryError as exc:
raise OTPDeliveryError(str(exc)) from exc
# ─────────────────────────────────────────────────────────────────────────
# Targeting helpers (internal, read-only)
#
# هدف: صرفاً کوئری‌های قابل‌استفاده‌ی مجدد برای انتخاب زیرمجموعه‌ای از
# کاربران، تا فیچرهای آینده (مثلاً کمپین‌های پیامی) بدونِ تکرارِ منطقِ
# فیلتر، از همین توابع استفاده کنن. این ماژول:
# - هیچ پیامی نمی‌فرسته (نه SMS، نه notification).
# - هیچ جدول/مایگریشنِ جدیدی اضافه نمی‌کنه.
# - هیچ background job/endpoint‌ای نداره؛ فقط QuerySetِ User برمی‌گردونه.
# - هیچ رفتارِ موجودی رو تغییر نمی‌ده (فقط SELECT).
#
# ارجاع به subscription.Subscription عمداً با import محلی (داخلِ تابع) و
# بر اساسِ user_id انجام می‌شه — دقیقاً همون الگویی که payment/services.py
# و discount/services.py برای صداکردنِ subscription استفاده می‌کنن؛ چون
# accounts.User (طبقِ docstringِ خودش) هیچ‌وقت FK واقعی به مدل‌های اپ‌های
# دیگه نمی‌زنه.
# ─────────────────────────────────────────────────────────────────────────
def get_all_users():
"""همه‌ی کاربران (مهمان و ثبت‌نام‌شده)."""
return User.objects.all()
def get_users_by_grade(grade_id: int):
"""کاربرانی که پایه‌ی (grade_id) مشخصی دارن."""
return User.objects.filter(grade_id=grade_id)
def get_inactive_users(since):
"""
کاربرانی که از زمانِ `since` به بعد فعالیتی (لاگین/رفرش‌توکن/سشنِ
مهمانِ واقعی) نداشتن — یعنی last_activity_at قبل از `since` بوده یا
اصلاً هیچ‌وقت ثبت نشده (last_activity_at is null، مثلِ کاربرهایی که
این فیلد قبل از افزوده‌شدنش ساخته شدن یا هیچ‌وقت فعالیتِ ثبت‌شده‌ای
نداشتن).
`since` باید یک datetime (تایم‌زون‌دار، هم‌راستا با USE_TZ پروژه)
باشه؛ کالر مسئولِ محاسبه‌ی cutoff (مثلاً timezone.now() - timedelta(...))
هست — این تابع خودش هیچ عددِ روزِ hardcode‌شده‌ای نداره.
"""
return User.objects.filter(models.Q(last_activity_at__lt=since) | models.Q(last_activity_at__isnull=True))
def get_users_with_active_subscription():
"""
کاربرانی که اشتراکِ فعال دارن. «فعال» یعنی هم status='active' باشه و
هم منقضی نشده باشه (expires_at خالیه یعنی نامحدود، یا هنوز نرسیده).
فقط user_id از subscription.Subscription خونده می‌شه (بدونِ FK واقعی،
طبقِ همون قراردادِ accounts.User) و برای فیلترِ accounts.User استفاده
می‌شه.
"""
from subscription.models import Subscription
active_user_ids = Subscription.objects.filter(
status='active',
).filter(
models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=timezone.now())
).values_list('user_id', flat=True)
return User.objects.filter(id__in=active_user_ids)

View File

@@ -0,0 +1,24 @@
{% extends "admin/base_site.html" %}
{% load i18n %}
{% block content %}
<p>{{ users|length }} کاربر انتخاب شده‌اند. متنِ پیامک را وارد کنید:</p>
<ul>
{% for user in users %}
<li>{{ user }}{% if not user.phone %} (بدونِ شماره‌تلفن — رد می‌شه){% endif %}</li>
{% endfor %}
</ul>
<form method="post">
{% csrf_token %}
{% for user in users %}
<input type="hidden" name="{{ action_checkbox_name }}" value="{{ user.pk }}">
{% endfor %}
<input type="hidden" name="action" value="send_sms_to_selected">
{{ form.as_p }}
<input type="submit" name="apply" value="ارسال پیامک">
</form>
{% endblock %}

View File

@@ -0,0 +1,28 @@
{% extends "admin/base_site.html" %}
{% load i18n %}
{% block content %}
<p>
این صفحه فقط یک ایندکسِ خواندنی (read-only) از صفحاتِ Adminِ موجود است؛
هر بخش دقیقاً همون changelist همیشگی رو باز می‌کنه، بدون هیچ صفحه‌ی
موازیِ جدید. دسترسیِ افزودن/ویرایش/حذف، مستقل از این صفحه، بر اساسِ
پرمیشن‌های شما محدود شده است.
</p>
{% for section in sections %}
<div class="module" style="margin-bottom: 20px;">
<table>
<caption>{{ section.title }}</caption>
<tbody>
{% for link in section.links %}
{% if link.has_perm %}
<tr>
<th scope="row"><a href="{% url link.url_name %}">{{ link.label }}</a></th>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{% endfor %}
{% endblock %}

View File

@@ -1,14 +1,20 @@
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from django.conf import settings from django.conf import settings
from django.contrib import admin
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
from django.core.cache import cache from django.core.cache import cache
from django.core.management import call_command
from django.test import TestCase, TransactionTestCase, override_settings from django.test import TestCase, TransactionTestCase, override_settings
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from rest_framework.test import APIClient from rest_framework.test import APIClient
from rest_framework.throttling import ScopedRateThrottle from rest_framework.throttling import ScopedRateThrottle
from .models import OTPCode, Session, User from .authentication import issue_access_token
from .services import OTPDeliveryError, send_otp_sms from .models import OTPCode, Profile, Session, User
from .services import OTPDeliveryError, SMSDeliveryError, send_otp_sms
class SendOtpSmsTests(TestCase): class SendOtpSmsTests(TestCase):
@@ -867,3 +873,563 @@ class GuestSessionThrottleTests(TestCase):
format='json', format='json',
) )
self.assertEqual(blocked_response.status_code, 429) self.assertEqual(blocked_response.status_code, 429)
class UserProfileViewTests(TestCase):
"""تست‌های GET /api/user/profile — پروفایل کاربر جاری."""
def setUp(self):
self.client = APIClient()
def _get_profile(self, token=None):
kwargs = {}
if token:
kwargs['HTTP_AUTHORIZATION'] = f'Bearer {token}'
return self.client.get('/api/user/profile', **kwargs)
def test_requires_authentication(self):
response = self._get_profile()
self.assertEqual(response.status_code, 401)
def test_returns_profile_for_guest_without_existing_profile_row(self):
user = User.objects.create(device_id='device-profile-1', is_guest=True, name='')
token = issue_access_token(user.id)
response = self._get_profile(token)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data['userId'], str(user.id))
self.assertEqual(data['isGuest'], True)
self.assertIsNone(data['phone'])
self.assertIsNone(data['gradeId'])
self.assertIsNone(data['birthYear'])
self.assertEqual(data['parentPhone'], '')
self.assertEqual(data['bio'], '')
# get_or_create باید یک رکورد Profile واقعی ساخته باشه.
self.assertTrue(Profile.objects.filter(user_id=user.id).exists())
def test_returns_existing_profile_fields_for_registered_user(self):
user = User.objects.create(
phone='+989120000001', is_guest=False, name='علی', avatar='avatar.png', grade_id=2,
)
Profile.objects.create(user=user, birth_year=2005, parent_phone='+989120000002', bio='سلام')
token = issue_access_token(user.id)
response = self._get_profile(token)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data['phone'], '+989120000001')
self.assertEqual(data['name'], 'علی')
self.assertEqual(data['avatar'], 'avatar.png')
self.assertEqual(data['isGuest'], False)
self.assertEqual(data['gradeId'], 2)
self.assertEqual(data['birthYear'], 2005)
self.assertEqual(data['parentPhone'], '+989120000002')
self.assertEqual(data['bio'], 'سلام')
def test_does_not_leak_other_users_profile(self):
other = User.objects.create(device_id='device-other', is_guest=True, name='')
Profile.objects.create(user=other, bio='محرمانه')
user = User.objects.create(device_id='device-profile-2', is_guest=True, name='')
token = issue_access_token(user.id)
response = self._get_profile(token)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['userId'], str(user.id))
self.assertNotEqual(response.json()['bio'], 'محرمانه')
class SendSmsAdminActionTests(TestCase):
"""
اکشنِ ادمینِ send_sms_to_selected در UserAdmin — قدمِ اولیه‌ی مینیمالِ
فیچرِ «ارسالِ پیامک از ادمین»: بدونِ مدلِ کمپین، زمان‌بندی یا صفِ
پس‌زمینه؛ فقط ارسالِ بلادرنگِ یک پیام به کاربرانِ انتخاب‌شده در
changelist، با استفاده از accounts.services.send_sms موجود.
"""
def setUp(self):
AuthUser = get_user_model()
self.staff_user = AuthUser.objects.create_superuser(
username='sms-admin', email='sms-admin@example.com', password='pass12345',
)
self.client.force_login(self.staff_user)
self.user1 = User.objects.create(name='u1', is_guest=False, phone='0910000001')
self.user2 = User.objects.create(name='u2', is_guest=False, phone='0910000002')
self.guest_no_phone = User.objects.create(name='guest', is_guest=True, phone=None)
self.changelist_url = reverse('admin:accounts_user_changelist')
def test_action_registered_on_user_admin(self):
model_admin = admin.site._registry[User]
self.assertIn('send_sms_to_selected', model_admin.actions)
@patch('accounts.admin.send_sms')
def test_intermediate_page_shown_without_sending(self, mock_send_sms):
"""
اولین POST (فقط انتخابِ اکشن، بدونِ 'apply') باید صفحه‌ی میانیِ
فرم رو نشون بده، نه ارسالِ فوریِ پیامک.
"""
response = self.client.post(self.changelist_url, {
'action': 'send_sms_to_selected',
'_selected_action': [self.user1.pk, self.user2.pk],
'index': 0,
})
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'accounts/send_sms_form.html')
self.assertIn('form', response.context)
mock_send_sms.assert_not_called()
@patch('accounts.admin.send_sms')
def test_apply_sends_sms_to_users_with_phone_and_skips_guest(self, mock_send_sms):
response = self.client.post(self.changelist_url, {
'action': 'send_sms_to_selected',
'_selected_action': [self.user1.pk, self.user2.pk, self.guest_no_phone.pk],
'apply': 'ارسال پیامک',
'message': 'سلام از طرف ادمین',
})
self.assertEqual(response.status_code, 200)
self.assertEqual(mock_send_sms.call_count, 2)
mock_send_sms.assert_any_call(phone='0910000001', message='سلام از طرف ادمین')
mock_send_sms.assert_any_call(phone='0910000002', message='سلام از طرف ادمین')
@patch('accounts.admin.send_sms')
def test_apply_with_empty_message_does_not_send_and_reshows_form(self, mock_send_sms):
response = self.client.post(self.changelist_url, {
'action': 'send_sms_to_selected',
'_selected_action': [self.user1.pk],
'apply': 'ارسال پیامک',
'message': '',
})
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'accounts/send_sms_form.html')
mock_send_sms.assert_not_called()
@patch('accounts.admin.send_sms')
def test_apply_continues_after_one_failed_delivery(self, mock_send_sms):
"""
شکستِ ارسال برای یک کاربر (SMSDeliveryError) نباید کلِ اکشن رو
متوقف کنه؛ باقیِ کاربرانِ انتخاب‌شده باید همچنان امتحان بشن.
"""
mock_send_sms.side_effect = [SMSDeliveryError('boom'), None]
response = self.client.post(self.changelist_url, {
'action': 'send_sms_to_selected',
'_selected_action': [self.user1.pk, self.user2.pk],
'apply': 'ارسال پیامک',
'message': 'متن',
})
self.assertEqual(response.status_code, 200)
self.assertEqual(mock_send_sms.call_count, 2)
@patch('accounts.admin.send_sms')
def test_apply_defaults_to_selected_target_when_field_missing(self, mock_send_sms):
"""
سازگاری با قبل: اگر فرم بدونِ فیلدِ 'target' پست بشه (مثلِ کلاینتِ
قدیمی)، initial='selected' باعثِ می‌شه رفتارِ قبلی (ارسال به
queryset انتخاب‌شده) حفظ بشه.
"""
response = self.client.post(self.changelist_url, {
'action': 'send_sms_to_selected',
'_selected_action': [self.user1.pk],
'apply': 'ارسال پیامک',
'message': 'متن',
'target': 'selected',
})
self.assertEqual(response.status_code, 200)
mock_send_sms.assert_called_once_with(phone='0910000001', message='متن')
@patch('accounts.admin.send_sms')
def test_apply_with_inactive_target_uses_get_inactive_users(self, mock_send_sms):
"""
وقتی target='inactive' انتخاب شده، گیرنده‌ها باید از
accounts.services.get_inactive_users(since) بیان — نه از queryset
انتخاب‌شده در changelist — و cutoff باید از تاریخِ واردشده توسطِ
ادمین ساخته بشه (بدونِ عددِ روزِ hardcode‌شده).
"""
now = timezone.now()
active_user = User.objects.create(
name='active', is_guest=False, phone='0910000009', last_activity_at=now,
)
inactive_user = User.objects.create(
name='inactive', is_guest=False, phone='0910000010',
last_activity_at=now - timezone.timedelta(days=90),
)
never_active_user = User.objects.create(
name='never-active', is_guest=False, phone='0910000011', last_activity_at=None,
)
cutoff_date = (now - timezone.timedelta(days=30)).date()
# انتخابِ changelist عمداً کاربرِ فعال رو هم شامل می‌شه تا مطمئن
# بشیم با target='inactive' نادیده گرفته می‌شه.
response = self.client.post(self.changelist_url, {
'action': 'send_sms_to_selected',
'_selected_action': [active_user.pk],
'apply': 'ارسال پیامک',
'message': 'یادآوری غیرفعال‌ها',
'target': 'inactive',
'inactive_since': cutoff_date.isoformat(),
})
self.assertEqual(response.status_code, 200)
self.assertEqual(mock_send_sms.call_count, 2)
mock_send_sms.assert_any_call(phone='0910000010', message='یادآوری غیرفعال‌ها')
mock_send_sms.assert_any_call(phone='0910000011', message='یادآوری غیرفعال‌ها')
called_phones = {call.kwargs['phone'] for call in mock_send_sms.call_args_list}
self.assertNotIn('0910000009', called_phones)
@patch('accounts.admin.send_sms')
def test_apply_with_inactive_target_requires_date(self, mock_send_sms):
"""
اگر target='inactive' باشه ولی تاریخ خالی باشه، فرم نباید valid
بشه و هیچ پیامکی ارسال نشه (بدونِ fallback به عددِ روزِ پیش‌فرض).
"""
response = self.client.post(self.changelist_url, {
'action': 'send_sms_to_selected',
'_selected_action': [self.user1.pk],
'apply': 'ارسال پیامک',
'message': 'متن',
'target': 'inactive',
'inactive_since': '',
})
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'accounts/send_sms_form.html')
self.assertFalse(response.context['form'].is_valid())
mock_send_sms.assert_not_called()
@patch('accounts.admin.send_sms')
def test_apply_with_inactive_target_still_skips_users_without_phone(self, mock_send_sms):
"""
رفتارِ skip کاربرانِ بدونِ شماره‌تلفن باید برای هدف‌گیریِ
'inactive' هم مثلِ 'selected' حفظ بشه.
"""
User.objects.create(
name='inactive-no-phone', is_guest=True, phone=None,
last_activity_at=timezone.now() - timezone.timedelta(days=90),
)
cutoff_date = (timezone.now() - timezone.timedelta(days=30)).date()
response = self.client.post(self.changelist_url, {
'action': 'send_sms_to_selected',
'_selected_action': [self.user1.pk],
'apply': 'ارسال پیامک',
'message': 'متن',
'target': 'inactive',
'inactive_since': cutoff_date.isoformat(),
})
self.assertEqual(response.status_code, 200)
mock_send_sms.assert_not_called()
from django.contrib.messages import get_messages
message_texts = [m.message for m in get_messages(response.wsgi_request)]
self.assertTrue(any('بدونِ شماره‌تلفن' in m for m in message_texts))
@override_settings(ADMIN_SMS_MAX_RECIPIENTS=2)
@patch('accounts.admin.send_sms')
def test_apply_blocks_send_when_selected_recipients_exceed_cap(self, mock_send_sms):
"""
محافظتِ سقف باید برای target='selected' هم اعمال بشه: اگر تعدادِ
کاربرانِ انتخاب‌شده در changelist از ADMIN_SMS_MAX_RECIPIENTS
بیشتر باشه، هیچ پیامکی ارسال نشه.
"""
response = self.client.post(self.changelist_url, {
'action': 'send_sms_to_selected',
'_selected_action': [self.user1.pk, self.user2.pk, self.guest_no_phone.pk],
'apply': 'ارسال پیامک',
'message': 'متن',
'target': 'selected',
})
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'accounts/send_sms_form.html')
mock_send_sms.assert_not_called()
from django.contrib.messages import get_messages
message_texts = [m.message for m in get_messages(response.wsgi_request)]
self.assertTrue(any('سقفِ مجاز' in m for m in message_texts))
@override_settings(ADMIN_SMS_MAX_RECIPIENTS=1)
@patch('accounts.admin.send_sms')
def test_apply_blocks_send_when_inactive_recipients_exceed_cap(self, mock_send_sms):
"""
محافظتِ سقف برای target='inactive' هم اعمال بشه: اگر
get_inactive_users(since) بیش از سقف برگردونه، هیچ پیامکی ارسال
نشه — بدون تغییر در خودِ منطقِ get_inactive_users.
"""
now = timezone.now()
User.objects.create(
name='inactive-1', is_guest=False, phone='0910000021',
last_activity_at=now - timezone.timedelta(days=90),
)
User.objects.create(
name='inactive-2', is_guest=False, phone='0910000022',
last_activity_at=now - timezone.timedelta(days=90),
)
cutoff_date = (now - timezone.timedelta(days=30)).date()
response = self.client.post(self.changelist_url, {
'action': 'send_sms_to_selected',
'_selected_action': [self.user1.pk],
'apply': 'ارسال پیامک',
'message': 'متن',
'target': 'inactive',
'inactive_since': cutoff_date.isoformat(),
})
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'accounts/send_sms_form.html')
mock_send_sms.assert_not_called()
@override_settings(ADMIN_SMS_MAX_RECIPIENTS=2)
@patch('accounts.admin.send_sms')
def test_apply_sends_when_recipients_are_within_cap(self, mock_send_sms):
"""
سقف نباید رفتارِ باتچ‌های نرمال (زیرِ سقف) رو تغییر بده — ارسال
باید مثلِ قبل انجام بشه.
"""
response = self.client.post(self.changelist_url, {
'action': 'send_sms_to_selected',
'_selected_action': [self.user1.pk, self.user2.pk],
'apply': 'ارسال پیامک',
'message': 'متن',
'target': 'selected',
})
self.assertEqual(response.status_code, 200)
self.assertEqual(mock_send_sms.call_count, 2)
@override_settings(ADMIN_SMS_MAX_RECIPIENTS=1)
@patch('accounts.admin.send_sms')
def test_apply_cap_check_does_not_count_phoneless_users_differently(self, mock_send_sms):
"""
سقف بر اساسِ تعدادِ کلِ گیرنده‌ها (قبل از فیلترِ داشتنِ شماره‌تلفن)
محاسبه می‌شه؛ این تست فقط تأیید می‌کنه که با سقفِ ۱، یک queryset دو
نفره (even شاملِ کاربرِ بدونِ شماره) هنوز block می‌شه.
"""
response = self.client.post(self.changelist_url, {
'action': 'send_sms_to_selected',
'_selected_action': [self.user1.pk, self.guest_no_phone.pk],
'apply': 'ارسال پیامک',
'message': 'متن',
'target': 'selected',
})
self.assertEqual(response.status_code, 200)
mock_send_sms.assert_not_called()
class SupervisorGroupAccessTests(TestCase):
"""
تست‌های صفحه‌ی «ناظر» (Supervisor) داخلِ همون Adminِ موجود:
- دستورِ مدیریتیِ setup_supervisor_group فقط پرمیشنِ view_* رو ست
می‌کنه (بدونِ add/change/delete)، روی User/Order/Transaction/
Subscription.
- عضوِ گروهِ Supervisor می‌تونه changelist این چهار مدل رو ببینه، ولی
نمی‌تونه add/change/delete کنه — نه با کلیک روی دکمه (چون اصلاً
نشون داده نمی‌شه)، نه با درخواستِ مستقیمِ URL.
- این محدودیت از طریقِ Permission/Group واقعیِ جنگو تأمینه، نه صرفاً
پنهون‌کردنِ لینک.
"""
def setUp(self):
from payment.models import Order
from subscription.models import Plan, Subscription
call_command('setup_supervisor_group')
self.supervisor_group = Group.objects.get(name='Supervisor')
AuthUser = get_user_model()
self.supervisor = AuthUser.objects.create_user(
username='supervisor-1', email='supervisor@example.com', password='pass12345',
is_staff=True,
)
self.supervisor.groups.add(self.supervisor_group)
self.plain_staff = AuthUser.objects.create_user(
username='plain-staff-1', email='plain-staff@example.com', password='pass12345',
is_staff=True,
)
self.target_user = User.objects.create(name='target', is_guest=False, phone='0910000099')
self.plan = Plan.objects.create(title='یک‌ماهه', price=10000)
self.order = Order.objects.create(
user_id=self.target_user.id, amount=10000, idempotency_key='sup-order-1',
)
self.subscription = Subscription.objects.create(
user_id=self.target_user.id, plan=self.plan, status='active',
)
def test_setup_command_grants_only_view_permissions(self):
codenames = set(
self.supervisor_group.permissions.values_list('content_type__app_label', 'codename')
)
self.assertEqual(
codenames,
{
('accounts', 'view_user'),
('payment', 'view_order'),
('payment', 'view_transaction'),
('subscription', 'view_subscription'),
},
)
for _, codename in codenames:
self.assertTrue(codename.startswith('view_'))
def test_setup_command_is_idempotent_and_strips_extra_permissions(self):
"""
اگه یک پرمیشنِ change/delete دستی به گروه اضافه شده باشه، اجرای
دوباره‌ی دستور باید اون رو حذف کنه (چون گروه باید فقط view_*
داشته باشه).
"""
change_user_perm = Permission.objects.get(
content_type__app_label='accounts', codename='change_user',
)
self.supervisor_group.permissions.add(change_user_perm)
call_command('setup_supervisor_group')
self.supervisor_group.refresh_from_db()
codenames = set(self.supervisor_group.permissions.values_list('codename', flat=True))
self.assertNotIn('change_user', codenames)
self.assertIn('view_user', codenames)
def test_supervisor_can_view_user_changelist(self):
self.client.force_login(self.supervisor)
response = self.client.get(reverse('admin:accounts_user_changelist'))
self.assertEqual(response.status_code, 200)
def test_supervisor_can_view_order_and_transaction_changelist(self):
self.client.force_login(self.supervisor)
response = self.client.get(reverse('admin:payment_order_changelist'))
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse('admin:payment_transaction_changelist'))
self.assertEqual(response.status_code, 200)
def test_supervisor_can_view_subscription_changelist(self):
self.client.force_login(self.supervisor)
response = self.client.get(reverse('admin:subscription_subscription_changelist'))
self.assertEqual(response.status_code, 200)
def test_supervisor_cannot_access_user_add_page(self):
self.client.force_login(self.supervisor)
response = self.client.get(reverse('admin:accounts_user_add'))
self.assertEqual(response.status_code, 403)
@patch('accounts.admin.send_sms')
def test_supervisor_cannot_execute_send_sms_action_via_direct_post(self, mock_send_sms):
"""
عضوِ Supervisor فقط view_user داره، نه change_user. اکشنِ
send_sms_to_selected باید permissions=['change'] داشته باشه تا
جنگو خودش (get_actions/response_action) این اکشن رو برای
Supervisor فیلتر کنه — یعنی حتی با POSTِ مستقیم و دستیِ 'apply'
(بدونِ دیدنِ دکمه‌ی اکشن در UI)، هیچ پیامکی ارسال نشه.
"""
self.client.force_login(self.supervisor)
response = self.client.post(reverse('admin:accounts_user_changelist'), {
'action': 'send_sms_to_selected',
'_selected_action': [self.target_user.pk],
'apply': 'ارسال پیامک',
'message': 'تلاشِ غیرمجاز برای ارسال',
})
self.assertEqual(response.status_code, 200)
mock_send_sms.assert_not_called()
def test_supervisor_can_view_user_change_page_readonly(self):
"""
طبقِ رفتارِ پیش‌فرضِ ModelAdmin جنگو (از نسخه‌ی ۲٫۱ به بعد)، وقتی
کاربر فقط view permission داره (نه change)، GET به change_view
باید 200 برگردونه ولی همه‌ی فیلدها read-only باشن — این دقیقاً
همون رفتارِ استانداردی‌ست که ما ازش استفاده می‌کنیم و هیچ override
سفارشی‌ای اضافه نکردیم.
"""
self.client.force_login(self.supervisor)
response = self.client.get(reverse('admin:accounts_user_change', args=[self.target_user.pk]))
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, 'name="_save"')
self.assertNotContains(response, 'name="_continue"')
def test_supervisor_post_to_user_change_does_not_modify_record(self):
"""
حتی اگه سوپروایزر مستقیماً به URL ویرایش POST بزنه (بدونِ دیدنِ
دکمه‌ی ذخیره)، چون change permission نداره باید 403 بگیره و
رکورد دست‌نخورده بمونه.
"""
self.client.force_login(self.supervisor)
original_name = self.target_user.name
response = self.client.post(
reverse('admin:accounts_user_change', args=[self.target_user.pk]),
{'name': 'دستکاری‌شده', 'is_guest': 'on'},
)
self.assertEqual(response.status_code, 403)
self.target_user.refresh_from_db()
self.assertEqual(self.target_user.name, original_name)
def test_supervisor_cannot_access_order_delete_page(self):
from payment.models import Order
self.client.force_login(self.supervisor)
response = self.client.get(reverse('admin:payment_order_delete', args=[self.order.pk]))
self.assertEqual(response.status_code, 403)
self.assertTrue(Order.objects.filter(pk=self.order.pk).exists())
def test_supervisor_cannot_access_subscription_add_page(self):
self.client.force_login(self.supervisor)
response = self.client.get(reverse('admin:subscription_subscription_add'))
self.assertEqual(response.status_code, 403)
def test_supervisor_dashboard_shows_only_permitted_links(self):
self.client.force_login(self.supervisor)
response = self.client.get(reverse('admin:supervisor-dashboard'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, reverse('admin:accounts_user_changelist'))
self.assertContains(response, reverse('admin:payment_order_changelist'))
self.assertContains(response, reverse('admin:payment_transaction_changelist'))
self.assertContains(response, reverse('admin:subscription_subscription_changelist'))
def test_plain_staff_without_group_gets_403_on_dashboard(self):
"""
یک کاربرِ staff که عضوِ گروهِ Supervisor نیست باید هنگامِ باز کردنِ
/admin/supervisor/ با ۴۰۳ (PermissionDenied) روبه‌رو بشه — صرفِ
is_staff بودن دیگه برای ورود به این صفحه کافی نیست.
"""
self.client.force_login(self.plain_staff)
response = self.client.get(reverse('admin:supervisor-dashboard'))
self.assertEqual(response.status_code, 403)
def test_superuser_without_group_gets_403_on_dashboard(self):
"""
حتی یک superuser هم اگه عضوِ گروهِ Supervisor نباشه، باید ۴۰۳
بگیره — قانونِ دسترسی صرفاً بر اساسِ عضویتِ گروهه، نه سطحِ
دسترسیِ کلیِ کاربر.
"""
AuthUser = get_user_model()
superuser = AuthUser.objects.create_superuser(
username='super-1', email='super@example.com', password='pass12345',
)
self.client.force_login(superuser)
response = self.client.get(reverse('admin:supervisor-dashboard'))
self.assertEqual(response.status_code, 403)
def test_supervisor_can_still_access_dashboard_after_fix(self):
"""
سنجه‌ی کنترل: کاربرِ staff که واقعاً عضوِ گروهِ Supervisor هست،
باید همچنان ۲۰۰ بگیره و لینک‌های مجاز رو ببینه.
"""
self.client.force_login(self.supervisor)
response = self.client.get(reverse('admin:supervisor-dashboard'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, reverse('admin:accounts_user_changelist'))
def test_anonymous_user_redirected_from_supervisor_dashboard(self):
response = self.client.get(reverse('admin:supervisor-dashboard'))
self.assertEqual(response.status_code, 302)
def test_supervisor_group_membership_alone_is_not_enough_without_is_staff(self):
"""
عضویتِ گروه به‌تنهایی کافی نیست؛ سیستمِ Admin هنوز is_staff رو هم
لازم داره — این تضمین می‌کنه محدودیت واقعاً از مسیرِ استانداردِ
Admin/Permission جنگو رد می‌شه، نه یک چک سفارشیِ جدا.
"""
AuthUser = get_user_model()
non_staff_supervisor = AuthUser.objects.create_user(
username='non-staff-supervisor', email='ns@example.com', password='pass12345',
is_staff=False,
)
non_staff_supervisor.groups.add(self.supervisor_group)
self.client.force_login(non_staff_supervisor)
response = self.client.get(reverse('admin:accounts_user_changelist'))
self.assertEqual(response.status_code, 302)

View File

@@ -6,6 +6,7 @@ from .views import (
RefreshTokenView, RefreshTokenView,
RequestOTPView, RequestOTPView,
SetUserGradeView, SetUserGradeView,
UserProfileView,
) )
urlpatterns = [ urlpatterns = [
@@ -14,4 +15,5 @@ urlpatterns = [
path('auth/login', LoginView.as_view(), name='auth-login'), path('auth/login', LoginView.as_view(), name='auth-login'),
path('auth/refresh', RefreshTokenView.as_view(), name='auth-refresh'), path('auth/refresh', RefreshTokenView.as_view(), name='auth-refresh'),
path('user/grade', SetUserGradeView.as_view(), name='user-grade'), path('user/grade', SetUserGradeView.as_view(), name='user-grade'),
path('user/profile', UserProfileView.as_view(), name='user-profile'),
] ]

View File

@@ -15,13 +15,14 @@ from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView from rest_framework.views import APIView
from .authentication import issue_access_token from .authentication import issue_access_token
from .models import OTPCode, Session, User from .models import OTPCode, Profile, Session, User
from .services import OTPDeliveryError, send_otp_sms from .services import OTPDeliveryError, send_otp_sms
from .serializers import ( from .serializers import (
GuestSessionRequestSerializer, GuestSessionRequestSerializer,
GuestSessionResponseSerializer, GuestSessionResponseSerializer,
LoginRequestSerializer, LoginRequestSerializer,
LoginResponseSerializer, LoginResponseSerializer,
ProfileResponseSerializer,
RefreshRequestSerializer, RefreshRequestSerializer,
RefreshResponseSerializer, RefreshResponseSerializer,
RequestOTPRequestSerializer, RequestOTPRequestSerializer,
@@ -79,6 +80,12 @@ class GuestSessionView(APIView):
defaults={'name': ''}, defaults={'name': ''},
) )
# ثبتِ فعالیتِ کاربر: ایجاد/استفاده‌ی سشنِ مهمان یک سشنِ واقعیِ
# کاربره، پس last_activity_at به‌روزرسانی می‌شه (بدونِ تأثیر روی
# سایرِ رفتارِ endpoint).
user.last_activity_at = timezone.now()
user.save(update_fields=['last_activity_at'])
token = issue_access_token(user.id) token = issue_access_token(user.id)
data = { data = {
@@ -285,6 +292,10 @@ class LoginView(APIView):
user.is_guest = False user.is_guest = False
user.save(update_fields=['is_guest']) user.save(update_fields=['is_guest'])
# ثبتِ فعالیتِ کاربر برای ورودِ موفق.
user.last_activity_at = timezone.now()
user.save(update_fields=['last_activity_at'])
if created: if created:
# آفرهای ثبت‌نام (مثل یک هفته رایگان) از طریق discount/receivers.py روی این سیگنال اعمال می‌شوند. # آفرهای ثبت‌نام (مثل یک هفته رایگان) از طریق discount/receivers.py روی این سیگنال اعمال می‌شوند.
from .signals import user_registered from .signals import user_registered
@@ -340,6 +351,9 @@ class RefreshTokenView(APIView):
old_session = Session.objects.get(refresh_token=refresh_token) old_session = Session.objects.get(refresh_token=refresh_token)
# ثبتِ فعالیتِ کاربر برای رفرشِ موفقِ توکن.
User.objects.filter(pk=old_session.user_id).update(last_activity_at=timezone.now())
new_refresh_token = secrets.token_hex(32) new_refresh_token = secrets.token_hex(32)
new_session = Session.objects.create( new_session = Session.objects.create(
user_id=old_session.user_id, user_id=old_session.user_id,
@@ -384,3 +398,34 @@ class SetUserGradeView(APIView):
data = {'success': True, 'gradeId': grade_id} data = {'success': True, 'gradeId': grade_id}
return Response(SetGradeResponseSerializer(data).data) return Response(SetGradeResponseSerializer(data).data)
class UserProfileView(APIView):
"""GET /api/user/profile — اطلاعات پروفایل کاربر جاری (نیاز به احراز هویت)"""
permission_classes = [IsAuthenticated]
@extend_schema(
responses=ProfileResponseSerializer,
tags=['Identity & Profile (accounts)'],
)
def get(self, request):
user = request.user
# مدل Profile به‌صورت lazy ساخته می‌شه چون هیچ‌جای دیگه‌ای (نه
# GuestSessionView، نه LoginView) رکورد Profile رو از قبل نمی‌سازه؛
# get_or_create این‌جا امن است چون فقط توسطِ کاربرِ همون رکورد صدا
# زده می‌شه (OneToOne روی user_id جاری، نه ورودیِ خارجی).
profile, _ = Profile.objects.get_or_create(user_id=user.id)
data = {
'userId': str(user.id),
'phone': user.phone,
'name': user.name,
'avatar': user.avatar,
'isGuest': user.is_guest,
'gradeId': user.grade_id,
'birthYear': profile.birth_year,
'parentPhone': profile.parent_phone,
'bio': profile.bio,
}
return Response(ProfileResponseSerializer(data).data)

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)

View File

@@ -80,6 +80,9 @@ INSTALLED_APPS = [
# Tadrisino apps (سیستم پرداخت بازار + آفر/تخفیف + لاگ عمومی تغییر Status) # Tadrisino apps (سیستم پرداخت بازار + آفر/تخفیف + لاگ عمومی تغییر Status)
'statuslog', 'statuslog',
# Tadrisino apps (مدلِ مینیمالِ ذخیره‌سازیِ کمپین‌های پیامی، بدونِ منطقِ ارسال)
'campaigns',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -278,6 +281,15 @@ SPECTACULAR_SETTINGS = {
ACCESS_TOKEN_TTL_SECONDS = 3600 ACCESS_TOKEN_TTL_SECONDS = 3600
REFRESH_TOKEN_TTL_DAYS = 30 REFRESH_TOKEN_TTL_DAYS = 30
# سقفِ تعدادِ گیرنده برای اکشنِ ادمینِ send_sms_to_selected (هم برای
# هدف‌گیریِ 'selected' و هم 'inactive'). این اکشن هنوز کاملاً بلادرنگ و
# synchronous توی همون request اجرا می‌شه (بدون صفِ پس‌زمینه)، پس این سقف
# صرفاً جلویِ یک ارسالِ ناخواسته‌ی خیلی بزرگ (که می‌تونه request رو تایم‌اوت
# کنه یا پروایدرِ پیامک رو overload کنه) رو می‌گیره؛ اگر تعدادِ گیرنده‌ها
# بیشتر از این باشه، اکشن هیچ پیامکی ارسال نمی‌کنه و از ادمین می‌خواد
# بازه/انتخاب رو محدودتر کنه.
ADMIN_SMS_MAX_RECIPIENTS = int(os.environ.get('ADMIN_SMS_MAX_RECIPIENTS', 200))
# محدودیت تلاش ورود بر اساس شماره تلفن، مستقل از throttle IP-محورِ otp_login. # محدودیت تلاش ورود بر اساس شماره تلفن، مستقل از throttle IP-محورِ otp_login.
OTP_LOGIN_PHONE_MAX_ATTEMPTS = 10 OTP_LOGIN_PHONE_MAX_ATTEMPTS = 10
OTP_LOGIN_PHONE_WINDOW_SECONDS = 3600 OTP_LOGIN_PHONE_WINDOW_SECONDS = 3600

View File

@@ -6,18 +6,75 @@
از این ماژول import و صدا می‌زنن — دقیقاً همون الگوی call_payment_gateway از این ماژول import و صدا می‌زنن — دقیقاً همون الگوی call_payment_gateway
در payment/services.py و create_order که سرور-به-سرور از subscription در payment/services.py و create_order که سرور-به-سرور از subscription
صدا زده می‌شه. صدا زده می‌شه.
⚙️ افزوده شد: پارامترِ channel (فقط 'in_app' یا 'sms'، پیش‌فرض 'in_app')
تا notify() علاوه‌بر ثبتِ Notificationِ داخل‌اپ، بتونه مستقیماً پیامک هم
بفرسته. برای sms هیچ منطقِ provider تکرار نشده — دقیقاً همون
accounts.services.send_sms که campaigns.services هم برای ارسالِ کمپینِ
پیامکی ازش استفاده می‌کنه، اینجا هم صدا زده می‌شه. این اپ همچنان به
accounts.models رفرنسی نداره (طبق پاراگرافِ بالا)؛ به همین دلیل شماره‌تلفن
از خودِ notify() استخراج نمی‌شه و caller (که از قبل User رو در اختیار
داره) باید phone رو صریحاً پاس بده.
""" """
from accounts.services import send_sms
from .models import Notification from .models import Notification
CHANNEL_IN_APP = 'in_app'
CHANNEL_SMS = 'sms'
CHANNEL_CHOICES = {CHANNEL_IN_APP, CHANNEL_SMS}
def notify(*, user_id: int, type: str, title: str, body: str = '', related_id: int | None = None) -> Notification:
def notify(
*,
user_id: int,
type: str,
title: str,
body: str = '',
related_id: int | None = None,
channel: str = CHANNEL_IN_APP,
phone: str | None = None,
) -> Notification | None:
""" """
ثبت یک اعلان جدید برای کاربر. نمونه‌ی استفاده از اپ‌های دیگه: ثبت یک اعلانِ جدید برای کاربر (channel='in_app'، پیش‌فرض و بدونِ هیچ
تغییری در رفتارِ قبلی) یا ارسالِ همون پیام به‌صورتِ پیامک
(channel='sms').
نمونه‌ی استفاده‌ی in_app (بدون تغییر نسبت به قبل):
from notifications.services import notify from notifications.services import notify
notify(user_id=order.user_id, type='order_paid', notify(user_id=order.user_id, type='order_paid',
title='پرداخت با موفقیت انجام شد', related_id=order.id) title='پرداخت با موفقیت انجام شد', related_id=order.id)
نمونه‌ی استفاده‌ی sms:
notify(user_id=user.id, type='order_paid', title='...',
body='سفارش شما با موفقیت پرداخت شد.',
channel='sms', phone=user.phone)
برای channel='sms':
- phone الزامیه؛ بدونِ اون ValueError می‌ده (نه یک ارسالِ خاموش/ناقص).
- هیچ رکوردِ Notification ساخته نمی‌شه — پیامک مستقیماً از طریقِ
accounts.services.send_sms ارسال می‌شه، پس مقدارِ برگشتی None‌ه.
- متنِ پیامک، body است؛ اگه body خالی باشه از title استفاده می‌شه (تا
caller مجبور به تکرارِ متن در هر دو فیلد نباشه).
- SMSDeliveryError (که send_sms در صورتِ شکستِ ارسال raise می‌کنه)
عمداً اینجا catch نمی‌شه؛ caller باید تصمیم بگیره با شکستِ ارسال چه
کار کنه — دقیقاً هم‌الگو با این‌که send_sms خودش هیچ حالتِ سکوت/
best-effort نداره.
مقدارِ نامعتبر برای channel (نه in_app نه sms) با ValueError رد
می‌شه، نه با یک ارسالِ خاموش/پیش‌فرض.
""" """
if channel not in CHANNEL_CHOICES:
raise ValueError(f"Unsupported notification channel: {channel!r}")
if channel == CHANNEL_SMS:
if not phone:
raise ValueError("phone is required when channel='sms'")
send_sms(phone=phone, message=body or title)
return None
return Notification.objects.create( return Notification.objects.create(
user_id=user_id, user_id=user_id,
type=type, type=type,

View File

@@ -1 +1,82 @@
# Create your tests here. from unittest.mock import patch
from django.test import TestCase
from accounts.services import SMSDeliveryError
from .models import Notification
from .services import notify
class NotifyInAppChannelTests(TestCase):
"""
channel='in_app' (پیش‌فرض) باید دقیقاً همون رفتارِ قبلیِ notify() رو
داشته باشه: صرفاً یک Notification.objects.create ساده، بدونِ
فراخوانیِ send_sms.
"""
@patch('notifications.services.send_sms')
def test_default_channel_creates_notification_and_does_not_send_sms(self, mock_send_sms):
result = notify(user_id=1, type='order_paid', title='t', body='b', related_id=5)
self.assertIsInstance(result, Notification)
self.assertEqual(Notification.objects.count(), 1)
notification = Notification.objects.get()
self.assertEqual(notification.user_id, 1)
self.assertEqual(notification.type, 'order_paid')
self.assertEqual(notification.title, 't')
self.assertEqual(notification.body, 'b')
self.assertEqual(notification.related_id, 5)
mock_send_sms.assert_not_called()
@patch('notifications.services.send_sms')
def test_explicit_in_app_channel_same_as_default(self, mock_send_sms):
result = notify(user_id=2, type='xp_earned', title='t', channel='in_app')
self.assertIsInstance(result, Notification)
self.assertEqual(Notification.objects.count(), 1)
mock_send_sms.assert_not_called()
class NotifySmsChannelTests(TestCase):
"""
channel='sms' نباید هیچ Notificationی بسازه؛ فقط باید
accounts.services.send_sms رو صدا بزنه (بدونِ تکرارِ منطقِ provider).
"""
@patch('notifications.services.send_sms')
def test_sms_channel_calls_send_sms_with_body_and_creates_no_notification(self, mock_send_sms):
result = notify(
user_id=3, type='order_paid', title='t', body='پیام پیامکی',
channel='sms', phone='0910000000',
)
mock_send_sms.assert_called_once_with(phone='0910000000', message='پیام پیامکی')
self.assertIsNone(result)
self.assertEqual(Notification.objects.count(), 0)
@patch('notifications.services.send_sms')
def test_sms_channel_falls_back_to_title_when_body_empty(self, mock_send_sms):
notify(user_id=3, type='order_paid', title='fallback-title', channel='sms', phone='0910000000')
mock_send_sms.assert_called_once_with(phone='0910000000', message='fallback-title')
def test_sms_channel_without_phone_raises_value_error(self):
with self.assertRaises(ValueError):
notify(user_id=3, type='order_paid', title='t', channel='sms')
self.assertEqual(Notification.objects.count(), 0)
@patch('notifications.services.send_sms')
def test_sms_delivery_error_propagates_and_creates_no_notification(self, mock_send_sms):
mock_send_sms.side_effect = SMSDeliveryError('boom')
with self.assertRaises(SMSDeliveryError):
notify(user_id=3, type='order_paid', title='t', channel='sms', phone='0910000000')
self.assertEqual(Notification.objects.count(), 0)
class NotifyInvalidChannelTests(TestCase):
def test_unsupported_channel_raises_value_error_and_creates_nothing(self):
with self.assertRaises(ValueError):
notify(user_id=3, type='order_paid', title='t', channel='email')
self.assertEqual(Notification.objects.count(), 0)