Add campaigns/ Add supervisor/ Add messaging features/ Separate SMS
This commit is contained in:
@@ -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 اجباریه و فقط خودِ کاربر یا والدِ
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
0
accounts/management/__init__.py
Normal file
0
accounts/management/__init__.py
Normal file
0
accounts/management/commands/__init__.py
Normal file
0
accounts/management/commands/__init__.py
Normal file
68
accounts/management/commands/setup_supervisor_group.py
Normal file
68
accounts/management/commands/setup_supervisor_group.py
Normal 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)
|
||||||
|
)
|
||||||
18
accounts/migrations/0003_user_last_activity_at.py
Normal file
18
accounts/migrations/0003_user_last_activity_at.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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']
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
24
accounts/templates/accounts/send_sms_form.html
Normal file
24
accounts/templates/accounts/send_sms_form.html
Normal 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 %}
|
||||||
28
accounts/templates/admin/supervisor_dashboard.html
Normal file
28
accounts/templates/admin/supervisor_dashboard.html
Normal 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 %}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
0
campaigns/__init__.py
Normal file
68
campaigns/admin.py
Normal file
68
campaigns/admin.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""
|
||||||
|
ثبتِ MessageCampaign و CampaignDelivery در Django Admin.
|
||||||
|
|
||||||
|
این ماژول صرفاً برای مشاهده/مدیریتِ دستیِ کمپینها و بررسیِ وضعیتِ
|
||||||
|
تحویلهاست؛ هیچ منطقِ اجرای کمپین (execute_campaign) اینجا صدا زده
|
||||||
|
نمیشه — طبق MessageCampaign.__doc__ و CampaignDelivery.__doc__، اجرا
|
||||||
|
و پنلِ ادمینِ مربوط به execution فیچرِ بعدیه.
|
||||||
|
|
||||||
|
- MessageCampaignAdmin: امکانِ ساخت/ویرایش/حذفِ کمپین (فیلدهای معمولی،
|
||||||
|
بدون منطقِ اجرا) رو با ModelAdmin پیشفرضِ Django فراهم میکنه.
|
||||||
|
- CampaignDeliveryAdmin: صرفاً read-only. CampaignDelivery یک لاگِ
|
||||||
|
وضعیتِ تولیدشده توسطِ execute_campaign()ه (طبق docstring خودش)؛
|
||||||
|
دستکاریِ دستیِ status/error_message از ادمین میتونه این لاگ رو با
|
||||||
|
چیزی که واقعاً برای کاربر اتفاق افتاده (یا نیفتاده) ناسازگار کنه و
|
||||||
|
رفتارِ idempotency/unique-constraint که در campaigns.services روی
|
||||||
|
همین رکوردها تکیه میکنه رو دور بزنه. به همین دلیل add/change/delete
|
||||||
|
همه غیرفعالن؛ تنها راهِ دیدنِ رکوردها لیست/دیتیلِ read-onlyه.
|
||||||
|
"""
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import CampaignDelivery, MessageCampaign
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(MessageCampaign)
|
||||||
|
class MessageCampaignAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('id', 'title', 'channel', 'created_at')
|
||||||
|
list_filter = ('channel',)
|
||||||
|
search_fields = ('title', 'body')
|
||||||
|
readonly_fields = ('created_at',)
|
||||||
|
fields = ('title', 'body', 'channel', 'target_filter', 'created_at')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(CampaignDelivery)
|
||||||
|
class CampaignDeliveryAdmin(admin.ModelAdmin):
|
||||||
|
"""
|
||||||
|
نمایشِ read-only: لیست و صفحهی جزئیاتِ هر رکورد همیشه قابلِ
|
||||||
|
مشاهدهست (has_view_permission صریحاً True)، ولی هیچ راهی برای
|
||||||
|
افزودن، ویرایش یا حذفِ رکورد از ادمین وجود نداره تا وضعیتِ تحویل
|
||||||
|
(که فقط باید توسطِ campaigns.services.execute_campaign آپدیت بشه)
|
||||||
|
از ادمین دستکاری نشه.
|
||||||
|
"""
|
||||||
|
|
||||||
|
list_display = ('id', 'campaign', 'user_id', 'channel', 'status', 'created_at')
|
||||||
|
list_filter = ('channel', 'status')
|
||||||
|
search_fields = ('user_id', 'error_message')
|
||||||
|
fields = ('campaign', 'user_id', 'channel', 'status', 'error_message', 'created_at')
|
||||||
|
readonly_fields = fields
|
||||||
|
|
||||||
|
def has_view_permission(self, request, obj=None):
|
||||||
|
# صریحاً True: نباید به fallbackِ مبتنیبر codename پرمیشنِ
|
||||||
|
# پیشفرضِ Django (has_perm('view_...') or has_perm('change_...'))
|
||||||
|
# تکیه کنیم. بدونِ این override، یک کاربرِ staff که پرمیشنِ
|
||||||
|
# view/change روی این مدل رو نداره، حتی برای دیدنِ صفحهی
|
||||||
|
# جزئیاتِ read-only هم 403 میگیره — یعنی مدل عملاً "view-only"
|
||||||
|
# نیست بلکه کاملاً غیرقابلِدسترسه. این متد مستقل از
|
||||||
|
# has_change_permission (که پایین همیشه False برمیگردونه)
|
||||||
|
# تضمین میکنه که دیدن همیشه مجازه، صرفِنظر از پرمیشنهای
|
||||||
|
# دیتابیسی.
|
||||||
|
return True
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_change_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
6
campaigns/apps.py
Normal file
6
campaigns/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'campaigns'
|
||||||
28
campaigns/migrations/0001_initial.py
Normal file
28
campaigns/migrations/0001_initial.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 5.2.17 on 2026-08-24 16:52
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='MessageCampaign',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('title', models.CharField(max_length=150)),
|
||||||
|
('body', models.TextField()),
|
||||||
|
('channel', models.CharField(choices=[('sms', 'پیامک'), ('in_app', 'اعلان داخل\u200cاپ')], max_length=10)),
|
||||||
|
('target_filter', models.JSONField(blank=True, default=dict)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
29
campaigns/migrations/0002_campaigndelivery.py
Normal file
29
campaigns/migrations/0002_campaigndelivery.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 5.2.17 on 2026-08-24 00:00
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('campaigns', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CampaignDelivery',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('user_id', models.IntegerField(db_index=True)),
|
||||||
|
('channel', models.CharField(choices=[('sms', 'پیامک'), ('in_app', 'اعلان داخل\u200cاپ')], max_length=10)),
|
||||||
|
('status', models.CharField(choices=[('pending', 'در انتظار'), ('sent', 'ارسال شد'), ('failed', 'ناموفق')], default='pending', max_length=10)),
|
||||||
|
('error_message', models.CharField(blank=True, max_length=300)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('campaign', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deliveries', to='campaigns.messagecampaign')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated manually for the campaign-delivery concurrency fix.
|
||||||
|
#
|
||||||
|
# اضافهکردنِ یک partial/conditional unique index روی
|
||||||
|
# CampaignDelivery(campaign, user_id, channel) که فقط رکوردهایی با
|
||||||
|
# status در ('pending', 'sent') رو محدود میکنه. رکوردهای failed از
|
||||||
|
# این constraint مستثنا هستن تا رفتارِ retryِ فعلی (ساختنِ رکوردِ
|
||||||
|
# pendingِ جدید بعد از یک تلاشِ ناموفق) بدونِ تغییر بمونه.
|
||||||
|
#
|
||||||
|
# این migration فقط یک constraint اضافه میکنه؛ هیچ فیلد/مدلِ جدیدی
|
||||||
|
# نمیسازه و به دیتای موجود دست نمیزنه.
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('campaigns', '0002_campaigndelivery'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='campaigndelivery',
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(status__in=['pending', 'sent']),
|
||||||
|
fields=('campaign', 'user_id', 'channel'),
|
||||||
|
name='campaigns_one_active_delivery_per_user_channel',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
campaigns/migrations/__init__.py
Normal file
0
campaigns/migrations/__init__.py
Normal file
125
campaigns/models.py
Normal file
125
campaigns/models.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.db.models import Q, UniqueConstraint
|
||||||
|
|
||||||
|
|
||||||
|
class MessageCampaign(models.Model):
|
||||||
|
"""
|
||||||
|
ذخیرهسازیِ مینیمالِ یک «کمپینِ پیامی» برای فیچرهای ارتباطی آینده.
|
||||||
|
|
||||||
|
این مدل صرفاً یک محلِ ذخیرهست؛ هیچ منطقِ ارسال/زمانبندی/صف
|
||||||
|
پسزمینه/retry/پیگیریِ تحویل اینجا نیست. ارسالِ واقعی (که از
|
||||||
|
accounts.services.send_sms یا notifications.services.notify استفاده
|
||||||
|
خواهد کرد) و انتخابِ کاربرانِ هدف (که از هلپرهای accounts.services،
|
||||||
|
مثلِ get_users_by_grade/get_inactive_users/..., استفاده خواهد کرد) در
|
||||||
|
فیچرهای بعدی روی همین رکورد پیاده میشن.
|
||||||
|
|
||||||
|
- `channel`: کانالِ ارسال (فعلاً پیامک یا اعلانِ داخلاپ).
|
||||||
|
- `target_filter`: تعریفِ هدف/فیلترِ انتخابشده، به همون شکلی که در UI
|
||||||
|
ساخته میشه (مثلاً {"type": "grade", "grade_id": 3} یا
|
||||||
|
{"type": "inactive_since_days", "days": 14} یا {"type": "all"}).
|
||||||
|
عمداً JSON آزاد نگه داشته شده تا با هلپرهای targeting در
|
||||||
|
accounts.services (که هیچکدوم جدولِ جدیدی ندارن) هماهنگ بمونه، بدونِ
|
||||||
|
اینکه این مدل به یک ساختارِ فیلترِ خاص قفل بشه.
|
||||||
|
"""
|
||||||
|
|
||||||
|
CHANNEL_CHOICES = [
|
||||||
|
('sms', 'پیامک'),
|
||||||
|
('in_app', 'اعلان داخلاپ'),
|
||||||
|
]
|
||||||
|
|
||||||
|
title = models.CharField(max_length=150)
|
||||||
|
body = models.TextField()
|
||||||
|
channel = models.CharField(max_length=10, choices=CHANNEL_CHOICES)
|
||||||
|
target_filter = models.JSONField(default=dict, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"campaign-{self.pk}-{self.channel}-{self.title}"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Django `choices=` روی CharField صرفاً برای فرم/ادمینه و بهخودیخود
|
||||||
|
در save() اجرا نمیشه؛ بدونِ این چک، هر مقدارِ دلخواهی برای channel
|
||||||
|
(مثلاً یک typo) میتونه ذخیره بشه و فقط بعداً، وقتِ اجرای
|
||||||
|
execute_campaign()، شکست بخوره. این کمترین اعتبارسنجیِ لازمه تا
|
||||||
|
مقدارِ نامعتبر اصلاً ذخیره نشه — بدونِ full_clean() روی کلِ مدل
|
||||||
|
(که فیلدهای دیگهای مثل title/target_filter رو هم درگیر میکنه و
|
||||||
|
در این پروژه جایی برای مدلها استفاده نشده) و با همون الگوی
|
||||||
|
ValueError که در campaigns.services برای خطاهای مشابه بهکار
|
||||||
|
رفته (مثلِ UnsupportedTargetFilter).
|
||||||
|
"""
|
||||||
|
valid_channels = {choice[0] for choice in self.CHANNEL_CHOICES}
|
||||||
|
if self.channel not in valid_channels:
|
||||||
|
raise ValueError(f"Invalid MessageCampaign channel: {self.channel!r}")
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignDelivery(models.Model):
|
||||||
|
"""
|
||||||
|
رکوردِ مینیمالِ نتیجهی تحویلِ یک MessageCampaign به یک کاربرِ هدف؛
|
||||||
|
همون «پیگیریِ تحویل» که در MessageCampaign.__doc__ بهعنوانِ فیچرِ
|
||||||
|
بعدی اشاره شده بود. این مدل صرفاً یک لاگِ وضعیته — بدونِ retry، صفِ
|
||||||
|
پسزمینه، زمانبندی، API یا پنلِ ادمین. execute_campaign() به ازای هر
|
||||||
|
تلاشِ واقعیِ ارسال (نه هر کاربرِ resolveشده — کاربرانِ skipشده مثلِ
|
||||||
|
مهمانِ بدونِ شماره رکورد نمیگیرن) یک رکورد میسازه و بعد از تلاش،
|
||||||
|
وضعیتش رو بهروزرسانی میکنه.
|
||||||
|
|
||||||
|
- `user_id`: ارجاعِ سبک به accounts.User، همالگو با
|
||||||
|
notifications.models.Notification.user_id (بدونِ FK واقعی).
|
||||||
|
- `status`: هنگامِ ساختِ رکورد pendingه (قبل از تلاش)، بعد از تلاش
|
||||||
|
به sent یا failed آپدیت میشه.
|
||||||
|
- `error_message`: فقط در status=failed پر میشه.
|
||||||
|
"""
|
||||||
|
|
||||||
|
STATUS_PENDING = 'pending'
|
||||||
|
STATUS_SENT = 'sent'
|
||||||
|
STATUS_FAILED = 'failed'
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
(STATUS_PENDING, 'در انتظار'),
|
||||||
|
(STATUS_SENT, 'ارسال شد'),
|
||||||
|
(STATUS_FAILED, 'ناموفق'),
|
||||||
|
]
|
||||||
|
|
||||||
|
campaign = models.ForeignKey(
|
||||||
|
MessageCampaign, on_delete=models.CASCADE, related_name='deliveries',
|
||||||
|
)
|
||||||
|
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
|
||||||
|
channel = models.CharField(max_length=10, choices=MessageCampaign.CHANNEL_CHOICES)
|
||||||
|
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=STATUS_PENDING)
|
||||||
|
error_message = models.CharField(max_length=300, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
constraints = [
|
||||||
|
# جلوگیریِ race condition: اگه execute_campaign() برای همون
|
||||||
|
# campaign همزمان (دو پراسس/ریکوئستِ موازی) اجرا بشه، هر دو
|
||||||
|
# میتونن قبل از اینکه هرکدوم رکوردی بسازن از چکِ
|
||||||
|
# already_sent رد بشن (TOCTOU) و برای یک کاربر دو بار
|
||||||
|
# send_sms/notify صدا زده بشه. این unique constraint دقیقاً
|
||||||
|
# همون چیزی رو در سطحِ دیتابیس تضمین میکنه که چکِ اپلیکیشن
|
||||||
|
# لایه فرض میکرد ولی گارانتی نمیداد: برای هر
|
||||||
|
# (campaign, user_id, channel) در آنِ واحد حداکثر یک رکوردِ
|
||||||
|
# «فعال یا موفق» (pending یا sent) میتونه وجود داشته باشه.
|
||||||
|
# وقتی دو تلاشِ همزمان میخوان برای همون کاربر رکورد pending
|
||||||
|
# بسازن، دومی روی این constraint با IntegrityError شکست
|
||||||
|
# میخوره و در execute_campaign() بهعنوانِ «این کاربر توسطِ
|
||||||
|
# یک اجرای دیگه claim شده» skip میشه — پس send_sms/notify
|
||||||
|
# دوباره صدا زده نمیشه.
|
||||||
|
#
|
||||||
|
# status=failed عمداً از این constraint مستثنا شده تا رفتارِ
|
||||||
|
# retryِ فعلی (ساختنِ یک رکوردِ pending جدید بعد از یک تلاشِ
|
||||||
|
# ناموفق) دستنخورده بمونه: یک رکوردِ failed دیگه «فعال» حساب
|
||||||
|
# نمیشه، پس جای رکوردِ جدید رو برای تلاشِ بعدی باز میذاره.
|
||||||
|
UniqueConstraint(
|
||||||
|
fields=['campaign', 'user_id', 'channel'],
|
||||||
|
condition=Q(status__in=['pending', 'sent']),
|
||||||
|
name='campaigns_one_active_delivery_per_user_channel',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"delivery-{self.pk}-campaign{self.campaign_id}-user{self.user_id}-{self.status}"
|
||||||
244
campaigns/services.py
Normal file
244
campaigns/services.py
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
"""
|
||||||
|
اجرای یک MessageCampaign: صرفاً «چسباندنِ» سه چیزِ از قبل موجود به هم —
|
||||||
|
هدفگیریِ کاربر (accounts.services)، ارسالِ پیامک (accounts.services.send_sms)
|
||||||
|
و ثبتِ اعلانِ داخلاپ (notifications.services.notify). این ماژول هیچ منطقِ
|
||||||
|
جدیدِ ارسال/اعلان نمیسازه، هیچ صف/جاب پسزمینهای نداره، و هیچ زمانبندی،
|
||||||
|
retry یا پیگیریِ تحویلی انجام نمیده — طبق MessageCampaign.__doc__، اونا
|
||||||
|
مسئولیتِ فیچرهای بعدی هستن.
|
||||||
|
|
||||||
|
فراخوانی همیشه sync و بلاکینگه (برای هر کاربر یک send_sms/notify)؛
|
||||||
|
مناسبِ لیستهای کوچیک یا اجرای دستی/تست، نه کمپینهای بزرگ در پروداکشن.
|
||||||
|
"""
|
||||||
|
from django.db import IntegrityError, transaction
|
||||||
|
|
||||||
|
from accounts import services as account_services
|
||||||
|
from accounts.services import SMSDeliveryError
|
||||||
|
from notifications.services import notify
|
||||||
|
|
||||||
|
from .models import CampaignDelivery, MessageCampaign
|
||||||
|
|
||||||
|
# نوعِ نوتیفیکیشنِ ثبتشده برای اعلانهای ناشی از کمپین. عمداً در
|
||||||
|
# notifications.models.Notification.TYPE_CHOICES اضافه نشده چون آن
|
||||||
|
# choices فقط رویدادهاییان که notifications بهعنوانِ event-listener
|
||||||
|
# بهشون گوش میده (طبق داکیومنتِ همون فایل)؛ اینجا notify() مستقیماً و
|
||||||
|
# بدونِ رویداد صدا زده میشه، پس تغییرِ آن مدل لازم نیست — Notification.type
|
||||||
|
# یک CharField معمولیه و مقداردهیاش خارج از choices هم مجاز است.
|
||||||
|
CAMPAIGN_NOTIFICATION_TYPE = 'message_campaign'
|
||||||
|
|
||||||
|
|
||||||
|
class UnsupportedTargetFilter(ValueError):
|
||||||
|
"""target_filter کمپین با هیچکدوم از حالتهای فعلاً پشتیبانیشده مطابقت نداره."""
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_target_users(target_filter: dict):
|
||||||
|
"""
|
||||||
|
target_filter رو به یک QuerySet از accounts.User تبدیل میکنه، فقط با
|
||||||
|
استفاده از هلپرهای موجودِ accounts.services (بدونِ کوئریِ جدید).
|
||||||
|
|
||||||
|
حالتهای فعلاً پشتیبانیشده:
|
||||||
|
{"type": "all"}
|
||||||
|
{"type": "grade", "grade_id": <int>}
|
||||||
|
{"type": "inactive_since_days", "days": <int>}
|
||||||
|
{"type": "active_subscription"}
|
||||||
|
"""
|
||||||
|
target_filter = target_filter or {}
|
||||||
|
filter_type = target_filter.get('type')
|
||||||
|
|
||||||
|
if filter_type == 'all':
|
||||||
|
return account_services.get_all_users()
|
||||||
|
|
||||||
|
if filter_type == 'grade':
|
||||||
|
return account_services.get_users_by_grade(target_filter['grade_id'])
|
||||||
|
|
||||||
|
if filter_type == 'inactive_since_days':
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
since = timezone.now() - timedelta(days=target_filter['days'])
|
||||||
|
return account_services.get_inactive_users(since)
|
||||||
|
|
||||||
|
if filter_type == 'active_subscription':
|
||||||
|
return account_services.get_users_with_active_subscription()
|
||||||
|
|
||||||
|
raise UnsupportedTargetFilter(
|
||||||
|
f"Unsupported target_filter type: {filter_type!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_delivery_result(delivery: CampaignDelivery, *, error: Exception | None) -> None:
|
||||||
|
"""رکوردِ pendingای که قبل از تلاشِ ارسال ساخته شده رو به sent/failed آپدیت میکنه."""
|
||||||
|
if error is None:
|
||||||
|
delivery.status = CampaignDelivery.STATUS_SENT
|
||||||
|
delivery.save(update_fields=['status'])
|
||||||
|
return
|
||||||
|
|
||||||
|
delivery.status = CampaignDelivery.STATUS_FAILED
|
||||||
|
delivery.error_message = str(error)[:300]
|
||||||
|
delivery.save(update_fields=['status', 'error_message'])
|
||||||
|
|
||||||
|
|
||||||
|
def _claim_delivery(campaign: MessageCampaign, user_id: int) -> CampaignDelivery | None:
|
||||||
|
"""
|
||||||
|
رکوردِ pendingِ یک CampaignDelivery رو برای این کاربر «claim» میکنه،
|
||||||
|
یا اگه یک اجرای همزمانِ دیگرِ همین campaign همین کاربر رو زودتر
|
||||||
|
claim کرده باشه، None برمیگردونه (بدونِ ساختنِ رکورد و بدونِ
|
||||||
|
فراخوانیِ send_sms/notify).
|
||||||
|
|
||||||
|
چکِ already_sent_user_ids قبل از شروعِ حلقه یکبار انجام میشه، پس
|
||||||
|
بینِ اون چک و رسیدنِ نوبتِ این کاربر در حلقه، ممکنه یک اجرای همزمانِ
|
||||||
|
دیگر (پراسسِ دیگر/دابلکلیکِ اجرا) دقیقاً همین کاربر رو claim کرده و
|
||||||
|
حتی به sent رسونده باشه (TOCTOU). این تابع همون کاریه که چکِ
|
||||||
|
اپلیکیشنلایه فرض میکرد ولی گارانتی نمیداد: با تکیه به
|
||||||
|
CampaignDelivery.Meta.constraints (یک رکوردِ فعال/موفق به ازای هر
|
||||||
|
campaign+user_id+channel)، اگه یک رکوردِ pending یا sent از قبل
|
||||||
|
برای همین کاربر وجود داشته باشه (چه توسطِ این اجرا چه توسطِ اجرای
|
||||||
|
همزمانِ دیگر)، create() با IntegrityError رد میشه و این تابع
|
||||||
|
None برمیگردونه — یعنی کاربر بهجای duplicate delivery، skip میشه.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
return CampaignDelivery.objects.create(
|
||||||
|
campaign=campaign, user_id=user_id, channel=campaign.channel,
|
||||||
|
)
|
||||||
|
except IntegrityError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _already_sent_user_ids(campaign: MessageCampaign) -> set[int]:
|
||||||
|
"""
|
||||||
|
user_idهایی که برای همین campaign+channel از قبل یک CampaignDelivery
|
||||||
|
با status=sent دارن — یعنی تحویلِ واقعی قبلاً با موفقیت انجام شده.
|
||||||
|
|
||||||
|
این استعلام دقیقاً همون کاریه که پرامپت بهعنوانِ جهتِ حل پیشنهاد
|
||||||
|
داده: «استفاده از رکوردهای CampaignDelivery موجود برای جلوگیری از
|
||||||
|
ارسالِ دوبارهی تحویلهایی که قبلاً کامل شدن». عمداً روی pending/failed
|
||||||
|
فیلتر نمیکنه: pending یعنی تلاشِ قبلی هیچوقت به نتیجه نرسیده (مثلاً
|
||||||
|
اجرا وسط کار قطع شده) و failed یعنی واقعاً تحویل داده نشده — در هر دو
|
||||||
|
حالت، تلاشِ دوباره «duplicate delivery» نیست، بلکه اولین تحویلِ واقعیِ
|
||||||
|
موفقه؛ پس این کاربرها باید دوباره امتحان بشن.
|
||||||
|
"""
|
||||||
|
return set(
|
||||||
|
CampaignDelivery.objects.filter(
|
||||||
|
campaign=campaign,
|
||||||
|
channel=campaign.channel,
|
||||||
|
status=CampaignDelivery.STATUS_SENT,
|
||||||
|
).values_list('user_id', flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def execute_campaign(campaign: MessageCampaign) -> None:
|
||||||
|
"""
|
||||||
|
یک MessageCampaign رو اجرا میکنه: کاربرانِ هدف رو resolve میکنه و به
|
||||||
|
ازای هر کدوم، بسته به campaign.channel، یا اعلانِ داخلاپ ثبت میکنه
|
||||||
|
(notify) یا پیامک میفرسته (send_sms). به ازای هر تلاشِ واقعیِ ارسال،
|
||||||
|
یک CampaignDelivery ساخته میشه (ابتدا pending) و بعد از تلاش به
|
||||||
|
sent/failed آپدیت میشه — بدونِ retry، صفِ پسزمینه یا زمانبندی.
|
||||||
|
|
||||||
|
Idempotency: اگه execute_campaign() دوباره برای همون campaign صدا زده
|
||||||
|
بشه (مثلاً بهخاطرِ اجرای دستیِ تکراری یا ریاستارتِ فرآیند وسطِ کار)،
|
||||||
|
کاربرهایی که از قبل یک CampaignDelivery با status=sent برای همین
|
||||||
|
campaign+channel دارن، دوباره skip میشن — نه CampaignDeliveryی
|
||||||
|
جدیدی براشون ساخته میشه و نه send_sms/notify دوباره صداشون میزنه.
|
||||||
|
این چک قبل از شروعِ حلقه، یکبار برای کلِ کمپین انجام میشه (نه به
|
||||||
|
ازای هر کاربر) تا کوئریِ اضافه نداشته باشیم. کاربرهایی که رکوردشون
|
||||||
|
pending یا failed مونده، عمداً دوباره امتحان میشن؛ چون براشون هنوز
|
||||||
|
هیچ تحویلِ موفقی ثبت نشده.
|
||||||
|
|
||||||
|
Concurrency: چکِ بالا خودش TOCTOUه (یکبار قبل از حلقه محاسبه
|
||||||
|
میشه)، پس اگه دو اجرای execute_campaign() برای همون campaign
|
||||||
|
همزمان راه بیفتن، ممکنه هر دو یک کاربر رو «هنوز sent نیست» ببینن.
|
||||||
|
محافظتِ واقعی در _claim_delivery() و در سطحِ دیتابیسه: هر کاربر قبل
|
||||||
|
از notify/send_sms باید یک رکوردِ CampaignDelivery رو claim کنه، و
|
||||||
|
یک unique constraint روی مدل (به ازای هر campaign+user_id+channel
|
||||||
|
حداکثر یک رکوردِ pending/sent) تضمین میکنه که فقط یکی از اون دو
|
||||||
|
اجرای همزمان میتونه claim موفق داشته باشه؛ اون یکی دیگه بدونِ
|
||||||
|
ساختنِ رکورد و بدونِ صدازدنِ notify/send_sms، آن کاربر رو skip
|
||||||
|
میکنه.
|
||||||
|
|
||||||
|
کاربرانِ مهمان بدونِ شمارهتلفن، برای کانالِ sms بهسادگی رد (skip)
|
||||||
|
میشن و هیچ CampaignDeliveryای براشون ساخته نمیشه — چون اینجا
|
||||||
|
اصلاً تلاشی برای ارسال انجام نشده؛ accounts.services.send_sms برای
|
||||||
|
شمارهی خالی معنی نداره و هیچ صف/ارسالِ جایگزینی هم براشون نیست.
|
||||||
|
|
||||||
|
شکستِ تحویلِ sms (SMSDeliveryError) دیگه کلِ اجرای کمپین رو متوقف
|
||||||
|
نمیکنه؛ فقط همون رکورد failed میشه و اجرا برای بقیهی کاربران ادامه
|
||||||
|
پیدا میکنه.
|
||||||
|
|
||||||
|
Consistency (مارککردنِ sent بعد از تلاشِ موفق): برای in_app، هم
|
||||||
|
notify() (یک Notification.objects.create ساده) و هم آپدیتِ نهاییِ
|
||||||
|
CampaignDelivery به sent، هر دو نوشتنِ دیتابیسیِ ساده روی همین
|
||||||
|
دیتابیسن — پس هر دو تویِ یک `transaction.atomic()` انجام میشن. اگه
|
||||||
|
notify() خودش شکست بخوره یا اگه بعد از notify() موفق، خودِ نوشتنِ
|
||||||
|
«sent» به هر دلیلی (مثلاً یک خطای دیتابیسِ گذرا) شکست بخوره، کل
|
||||||
|
تراکنش rollback میشه — یعنی Notificationای هم که notify() نوشته
|
||||||
|
بود هیچوقت persist نمیشه، و بعدش رکورد به failed آپدیت میشه (بیرونِ
|
||||||
|
همون تراکنشِ rollbackشده، با یک نوشتنِ مستقلِ جدید) و exception
|
||||||
|
دوباره بالا میره. نتیجه: هیچوقت به وضعیتِ ناسازگارِ «Notification
|
||||||
|
واقعاً وجود داره ولی CampaignDelivery رو pendingِ گیرکرده مونده»
|
||||||
|
نمیرسیم؛ رکورد یا واقعاً sent میشه (با Notificationِ واقعی) یا
|
||||||
|
failed میشه (بدونِ هیچ Notificationِ persistشدهای) — و چون failed
|
||||||
|
از unique constraint مستثناست، برای اجرای بعدی قابلِ retry میمونه.
|
||||||
|
|
||||||
|
برای sms این کار عمداً انجام نشده و نمیتونه انجام بشه: send_sms()
|
||||||
|
یک تماسِ خارجی با providerه، نه یک نوشتنِ دیتابیسی — یک
|
||||||
|
transaction.atomic() فقط نوشتنهای همین دیتابیس رو rollback میکنه؛
|
||||||
|
پیامکی که provider واقعاً فرستاده رو نمیشه با rollbackِ دیتابیس
|
||||||
|
«پس گرفت». اگه بعد از یک send_sms() موفق، خودِ نوشتنِ «sent» شکست
|
||||||
|
بخوره، exception (بدونِ catch/hide) بالا میره و رکورد pending
|
||||||
|
میمونه — دقیقاً همون رفتارِ قبلی. این pendingِ گیرکرده باعثِ
|
||||||
|
duplicate SMS در اجرای بعدی نمیشه (چون unique constraint یک
|
||||||
|
claimِ دوباره برای همون کاربر رو رد میکنه)، ولی اون یک رکوردِ
|
||||||
|
خاص هم دیگه هیچوقت خودکار به sent/failed نمیرسه — نیازمندِ
|
||||||
|
بررسیِ دستیه، که پیامدِ ذاتیِ غیرقابلاجتنابِ غیرترانزکشنالبودنِ
|
||||||
|
provider خارجیه، نه یک باگِ قابلرفعِ کوچیک.
|
||||||
|
"""
|
||||||
|
users = _resolve_target_users(campaign.target_filter)
|
||||||
|
already_sent_user_ids = _already_sent_user_ids(campaign)
|
||||||
|
|
||||||
|
if campaign.channel == 'in_app':
|
||||||
|
for user in users.only('id').iterator():
|
||||||
|
if user.id in already_sent_user_ids:
|
||||||
|
continue
|
||||||
|
delivery = _claim_delivery(campaign, user.id)
|
||||||
|
if delivery is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
notify(
|
||||||
|
user_id=user.id,
|
||||||
|
type=CAMPAIGN_NOTIFICATION_TYPE,
|
||||||
|
title=campaign.title,
|
||||||
|
body=campaign.body,
|
||||||
|
related_id=campaign.id,
|
||||||
|
)
|
||||||
|
_mark_delivery_result(delivery, error=None)
|
||||||
|
except Exception as exc:
|
||||||
|
# چه notify() خودش شکست بخوره چه فقط نوشتنِ نهاییِ sent
|
||||||
|
# (که تویِ همون atomic block بالاست)، هر دو حالت اینجا
|
||||||
|
# میرسه: تراکنش rollback شده (پس هیچ Notificationِ
|
||||||
|
# persistشدهای نمونده)، و این save یک نوشتنِ جدید و
|
||||||
|
# مستقله که رکورد رو به failed میبره تا برای اجرای بعدی
|
||||||
|
# گیر نکنه. exception عمداً catch/hide نمیشه و بالا میره.
|
||||||
|
_mark_delivery_result(delivery, error=exc)
|
||||||
|
raise
|
||||||
|
return
|
||||||
|
|
||||||
|
if campaign.channel == 'sms':
|
||||||
|
for user in users.only('id', 'phone').iterator():
|
||||||
|
if user.id in already_sent_user_ids:
|
||||||
|
continue
|
||||||
|
if not user.phone:
|
||||||
|
continue
|
||||||
|
delivery = _claim_delivery(campaign, user.id)
|
||||||
|
if delivery is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
account_services.send_sms(phone=user.phone, message=campaign.body)
|
||||||
|
except SMSDeliveryError as exc:
|
||||||
|
_mark_delivery_result(delivery, error=exc)
|
||||||
|
else:
|
||||||
|
_mark_delivery_result(delivery, error=None)
|
||||||
|
return
|
||||||
|
|
||||||
|
raise ValueError(f"Unsupported campaign channel: {campaign.channel!r}")
|
||||||
495
campaigns/tests.py
Normal file
495
campaigns/tests.py
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
import threading
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import IntegrityError, OperationalError, connection, transaction
|
||||||
|
from django.test import TestCase, TransactionTestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from accounts.models import User
|
||||||
|
from accounts.services import SMSDeliveryError
|
||||||
|
from notifications.models import Notification
|
||||||
|
|
||||||
|
from .admin import CampaignDeliveryAdmin, MessageCampaignAdmin
|
||||||
|
from .models import CampaignDelivery, MessageCampaign
|
||||||
|
from .services import execute_campaign
|
||||||
|
|
||||||
|
|
||||||
|
class MessageCampaignChannelValidationTests(TestCase):
|
||||||
|
"""
|
||||||
|
اطمینان از اینکه مقدارِ نامعتبر برای channel اصلاً ذخیره نمیشه (نه
|
||||||
|
اینکه فقط بعداً موقعِ execute_campaign() شکست بخوره)، و مقادیرِ
|
||||||
|
معتبرِ فعلی (sms, in_app) بدون تغییر همچنان کار میکنن.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_valid_channels_still_save(self):
|
||||||
|
for channel in ('sms', 'in_app'):
|
||||||
|
campaign = MessageCampaign.objects.create(
|
||||||
|
title=f'title-{channel}', body='body', channel=channel,
|
||||||
|
)
|
||||||
|
self.assertEqual(campaign.channel, channel)
|
||||||
|
|
||||||
|
def test_invalid_channel_rejected_on_create(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
MessageCampaign.objects.create(
|
||||||
|
title='bad', body='body', channel='email',
|
||||||
|
)
|
||||||
|
self.assertEqual(MessageCampaign.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_invalid_channel_rejected_on_save(self):
|
||||||
|
campaign = MessageCampaign(title='bad', body='body', channel='whatsapp')
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
campaign.save()
|
||||||
|
self.assertEqual(MessageCampaign.objects.count(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecuteCampaignIdempotencyTests(TestCase):
|
||||||
|
"""
|
||||||
|
اجرای دوبارهی execute_campaign() برای همون campaign نباید کاربرهایی
|
||||||
|
که قبلاً یک CampaignDelivery با status=sent دارن رو دوباره تحویل
|
||||||
|
بده (نه CampaignDelivery تکراری، نه notify/send_sms تکراری).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user1 = User.objects.create(name='u1', is_guest=False, phone='0910000001')
|
||||||
|
self.user2 = User.objects.create(name='u2', is_guest=False, phone='0910000002')
|
||||||
|
|
||||||
|
def test_in_app_second_run_skips_already_sent_users(self):
|
||||||
|
campaign = MessageCampaign.objects.create(
|
||||||
|
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
|
||||||
|
)
|
||||||
|
|
||||||
|
execute_campaign(campaign)
|
||||||
|
self.assertEqual(CampaignDelivery.objects.filter(campaign=campaign).count(), 2)
|
||||||
|
self.assertEqual(Notification.objects.count(), 2)
|
||||||
|
self.assertEqual(
|
||||||
|
set(CampaignDelivery.objects.filter(campaign=campaign).values_list('status', flat=True)),
|
||||||
|
{CampaignDelivery.STATUS_SENT},
|
||||||
|
)
|
||||||
|
|
||||||
|
# اجرای دوبارهی همون کمپین: نباید هیچ CampaignDelivery یا
|
||||||
|
# Notification جدیدی ساخته بشه، چون هر دو کاربر از قبل sent هستن.
|
||||||
|
execute_campaign(campaign)
|
||||||
|
self.assertEqual(CampaignDelivery.objects.filter(campaign=campaign).count(), 2)
|
||||||
|
self.assertEqual(Notification.objects.count(), 2)
|
||||||
|
|
||||||
|
@patch('campaigns.services.account_services.send_sms')
|
||||||
|
def test_sms_second_run_skips_already_sent_users(self, mock_send_sms):
|
||||||
|
campaign = MessageCampaign.objects.create(
|
||||||
|
title='t', body='b', channel='sms', target_filter={'type': 'all'},
|
||||||
|
)
|
||||||
|
|
||||||
|
execute_campaign(campaign)
|
||||||
|
self.assertEqual(mock_send_sms.call_count, 2)
|
||||||
|
self.assertEqual(CampaignDelivery.objects.filter(campaign=campaign).count(), 2)
|
||||||
|
|
||||||
|
execute_campaign(campaign)
|
||||||
|
# هیچ فراخوانیِ اضافهای برای send_sms نباید انجام بشه.
|
||||||
|
self.assertEqual(mock_send_sms.call_count, 2)
|
||||||
|
self.assertEqual(CampaignDelivery.objects.filter(campaign=campaign).count(), 2)
|
||||||
|
|
||||||
|
@patch('campaigns.services.account_services.send_sms')
|
||||||
|
def test_sms_failed_delivery_is_retried_not_treated_as_duplicate(self, mock_send_sms):
|
||||||
|
"""
|
||||||
|
رکوردهای failed نشونهی «تحویلِ قبلاً کاملشده» نیستن؛ پس اجرای
|
||||||
|
دوباره باید دوباره امتحانشون کنه، نه skip.
|
||||||
|
"""
|
||||||
|
mock_send_sms.side_effect = SMSDeliveryError('boom')
|
||||||
|
campaign = MessageCampaign.objects.create(
|
||||||
|
title='t', body='b', channel='sms', target_filter={'type': 'all'},
|
||||||
|
)
|
||||||
|
|
||||||
|
execute_campaign(campaign)
|
||||||
|
self.assertEqual(mock_send_sms.call_count, 2)
|
||||||
|
self.assertEqual(
|
||||||
|
set(CampaignDelivery.objects.filter(campaign=campaign).values_list('status', flat=True)),
|
||||||
|
{CampaignDelivery.STATUS_FAILED},
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_send_sms.side_effect = None
|
||||||
|
execute_campaign(campaign)
|
||||||
|
self.assertEqual(mock_send_sms.call_count, 4)
|
||||||
|
self.assertEqual(CampaignDelivery.objects.filter(campaign=campaign).count(), 4)
|
||||||
|
self.assertEqual(
|
||||||
|
CampaignDelivery.objects.filter(
|
||||||
|
campaign=campaign, status=CampaignDelivery.STATUS_SENT,
|
||||||
|
).count(),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch('campaigns.services.notify')
|
||||||
|
def test_in_app_unexpected_exception_marks_delivery_failed_and_reraises(self, mock_notify):
|
||||||
|
"""
|
||||||
|
اگه notify() یک استثنای غیرمنتظره بده، اون خطا نباید catch/hide
|
||||||
|
بشه (باید بالا بره، مثلِ قبل)، ولی رکوردِ pendingِ همون کاربر
|
||||||
|
باید قبل از re-raise به failed آپدیت بشه — وگرنه بهخاطرِ
|
||||||
|
unique constraintِ CampaignDelivery، این کاربر برای همیشه گیر
|
||||||
|
میکنه و هیچ اجرای بعدیای نمیتونه دوباره claim کنه.
|
||||||
|
"""
|
||||||
|
mock_notify.side_effect = RuntimeError('boom')
|
||||||
|
campaign = MessageCampaign.objects.create(
|
||||||
|
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
execute_campaign(campaign)
|
||||||
|
|
||||||
|
# فقط یک کاربر (اولین موردِ resolveشده) رکورد گرفته، چون خطا
|
||||||
|
# بلافاصله بعد از notify() اول بالا رفته و حلقه متوقف شده —
|
||||||
|
# دقیقاً همون رفتاری که قبل از این تغییر هم وجود داشت.
|
||||||
|
deliveries = CampaignDelivery.objects.filter(campaign=campaign)
|
||||||
|
self.assertEqual(deliveries.count(), 1)
|
||||||
|
self.assertEqual(deliveries.get().status, CampaignDelivery.STATUS_FAILED)
|
||||||
|
self.assertEqual(Notification.objects.count(), 0)
|
||||||
|
|
||||||
|
@patch('campaigns.services.notify')
|
||||||
|
def test_in_app_failed_delivery_after_exception_can_be_retried(self, mock_notify):
|
||||||
|
"""
|
||||||
|
بعد از اینکه یک اجرا با notify() شکستخورده رکوردِ failed ساخته،
|
||||||
|
اجرای بعدی نباید بهخاطرِ constraint گیر کنه — باید بتونه دوباره
|
||||||
|
برای همون کاربر claim کنه و اینبار اگه notify() موفق باشه، به
|
||||||
|
sent برسه.
|
||||||
|
"""
|
||||||
|
mock_notify.side_effect = RuntimeError('boom')
|
||||||
|
campaign = MessageCampaign.objects.create(
|
||||||
|
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
execute_campaign(campaign)
|
||||||
|
self.assertEqual(
|
||||||
|
CampaignDelivery.objects.filter(campaign=campaign).count(), 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_notify.side_effect = None
|
||||||
|
execute_campaign(campaign)
|
||||||
|
|
||||||
|
# هر دو کاربر باید حالا sent باشن: کاربرِ اول (که بارِ قبل failed
|
||||||
|
# شده بود) با یک رکوردِ جدید retry شده، و کاربرِ دوم هم برای
|
||||||
|
# اولینبار موفق بوده.
|
||||||
|
self.assertEqual(CampaignDelivery.objects.filter(campaign=campaign).count(), 3)
|
||||||
|
self.assertEqual(
|
||||||
|
CampaignDelivery.objects.filter(
|
||||||
|
campaign=campaign, status=CampaignDelivery.STATUS_SENT,
|
||||||
|
).count(),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignDeliveryConcurrencyTests(TestCase):
|
||||||
|
"""
|
||||||
|
محافظتِ سطحِ دیتابیس در برابرِ race condition بینِ دو اجرای همزمانِ
|
||||||
|
execute_campaign() برای همون campaign: هر (campaign, user_id,
|
||||||
|
channel) حداکثر یک رکوردِ «فعال» (pending یا sent) میتونه داشته
|
||||||
|
باشه. رکوردهای failed مستثنا هستن تا رفتارِ retryِ فعلی دستنخورده
|
||||||
|
بمونه (پوشش دادهشده در ExecuteCampaignIdempotencyTests بالا).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(name='u', is_guest=False, phone='0910000003')
|
||||||
|
self.campaign = MessageCampaign.objects.create(
|
||||||
|
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_second_pending_delivery_for_same_user_is_rejected(self):
|
||||||
|
CampaignDelivery.objects.create(
|
||||||
|
campaign=self.campaign, user_id=self.user.id, channel='in_app',
|
||||||
|
)
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
with transaction.atomic():
|
||||||
|
CampaignDelivery.objects.create(
|
||||||
|
campaign=self.campaign, user_id=self.user.id, channel='in_app',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_second_sent_delivery_for_same_user_is_rejected(self):
|
||||||
|
first = CampaignDelivery.objects.create(
|
||||||
|
campaign=self.campaign, user_id=self.user.id, channel='in_app',
|
||||||
|
)
|
||||||
|
first.status = CampaignDelivery.STATUS_SENT
|
||||||
|
first.save(update_fields=['status'])
|
||||||
|
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
with transaction.atomic():
|
||||||
|
CampaignDelivery.objects.create(
|
||||||
|
campaign=self.campaign, user_id=self.user.id, channel='in_app',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_failed_delivery_does_not_block_retry(self):
|
||||||
|
first = CampaignDelivery.objects.create(
|
||||||
|
campaign=self.campaign, user_id=self.user.id, channel='in_app',
|
||||||
|
)
|
||||||
|
first.status = CampaignDelivery.STATUS_FAILED
|
||||||
|
first.save(update_fields=['status'])
|
||||||
|
|
||||||
|
# نباید IntegrityError بده: رکوردِ failed دیگه «فعال» حساب
|
||||||
|
# نمیشه، پس جای رکوردِ جدید رو برای retry باز میذاره.
|
||||||
|
second = CampaignDelivery.objects.create(
|
||||||
|
campaign=self.campaign, user_id=self.user.id, channel='in_app',
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(second.pk)
|
||||||
|
|
||||||
|
@patch('campaigns.services.notify')
|
||||||
|
def test_execute_campaign_skips_user_claimed_by_concurrent_run(self, mock_notify):
|
||||||
|
"""
|
||||||
|
شبیهسازیِ اجرای همزمان: یک رکوردِ pending از قبل برای این کاربر
|
||||||
|
وجود داره (انگار یک اجرای دیگرِ همین campaign همین الان داره
|
||||||
|
روش کار میکنه). execute_campaign نباید notify رو صدا بزنه یا
|
||||||
|
رکوردِ دیگهای برای این کاربر بسازه.
|
||||||
|
"""
|
||||||
|
CampaignDelivery.objects.create(
|
||||||
|
campaign=self.campaign, user_id=self.user.id, channel='in_app',
|
||||||
|
)
|
||||||
|
|
||||||
|
execute_campaign(self.campaign)
|
||||||
|
|
||||||
|
mock_notify.assert_not_called()
|
||||||
|
self.assertEqual(
|
||||||
|
CampaignDelivery.objects.filter(campaign=self.campaign).count(), 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecuteCampaignRealConcurrencyTests(TransactionTestCase):
|
||||||
|
"""
|
||||||
|
برخلافِ CampaignDeliveryConcurrencyTests (که وضعیتِ «قبلاً claimشده»
|
||||||
|
رو با ساختنِ دستیِ یک رکوردِ pending شبیهسازی میکنه)، این تست دو
|
||||||
|
execute_campaign() واقعی رو در دو تردِ جداگانه — هرکدوم با کانکشنِ
|
||||||
|
دیتابیسِ خودش — دقیقاً همزمان (با threading.Barrier) اجرا میکنه.
|
||||||
|
از TransactionTestCase استفاده شده (نه TestCase) چون TestCase کلِ
|
||||||
|
تست رو در یک تراکنشِ واحد rollback میکنه و بینِ کانکشنها ایزوله
|
||||||
|
نیست — یعنی race واقعیِ بینِ دو تراکنشِ مستقل رو نشون نمیده.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(name='u', is_guest=False, phone='0910000004')
|
||||||
|
self.campaign = MessageCampaign.objects.create(
|
||||||
|
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_two_concurrent_runs_deliver_notification_exactly_once(self):
|
||||||
|
barrier = threading.Barrier(2)
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
def run():
|
||||||
|
try:
|
||||||
|
barrier.wait(timeout=5)
|
||||||
|
execute_campaign(self.campaign)
|
||||||
|
except Exception as exc: # pragma: no cover - surfaced via `errors`
|
||||||
|
errors.append(exc)
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=run) for _ in range(2)]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join(timeout=10)
|
||||||
|
|
||||||
|
self.assertEqual(errors, [])
|
||||||
|
self.assertEqual(
|
||||||
|
CampaignDelivery.objects.filter(
|
||||||
|
campaign=self.campaign, status=CampaignDelivery.STATUS_SENT,
|
||||||
|
).count(),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
self.assertEqual(Notification.objects.count(), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class InAppDeliveryConsistencyTests(TestCase):
|
||||||
|
"""
|
||||||
|
برای in_app، notify() و آپدیتِ نهاییِ CampaignDelivery به sent هر دو
|
||||||
|
تویِ یک transaction.atomic() هستن. این تست ثابت میکنه که اگه خودِ
|
||||||
|
نوشتنِ «sent» (نه notify()) به یک خطای دیتابیسی بخوره، کل تراکنش
|
||||||
|
rollback میشه — یعنی Notificationِ ساختهشده توسطِ notify() هم
|
||||||
|
persist نمیمونه — و رکورد بهجای اینکه برای همیشه pending گیر کنه،
|
||||||
|
failed میشه (و در نتیجه در اجرای بعدی قابلِ retryه).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(name='u', is_guest=False, phone='0910000005')
|
||||||
|
self.campaign = MessageCampaign.objects.create(
|
||||||
|
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_failed_status_update_rolls_back_notification_and_marks_failed(self):
|
||||||
|
original_save = CampaignDelivery.save
|
||||||
|
|
||||||
|
def flaky_save(self_delivery, *args, **kwargs):
|
||||||
|
# فقط دقیقاً همون saveای که داره سعی میکنه رکورد رو به sent
|
||||||
|
# ببره شکست میخوره (نه saveی claim که status هنوز pendingه،
|
||||||
|
# و نه saveی مسیرِ failedِ بعدی که این تابع دیگه patch نشده).
|
||||||
|
if self_delivery.status == CampaignDelivery.STATUS_SENT:
|
||||||
|
raise OperationalError('simulated transient db failure')
|
||||||
|
return original_save(self_delivery, *args, **kwargs)
|
||||||
|
|
||||||
|
with patch.object(CampaignDelivery, 'save', flaky_save):
|
||||||
|
with self.assertRaises(OperationalError):
|
||||||
|
execute_campaign(self.campaign)
|
||||||
|
|
||||||
|
# notify() واقعاً صدا زده شده بود (نه mock)، ولی چون تویِ همون
|
||||||
|
# atomic-ی بود که بعداً rollback شد، هیچ Notificationِ persist
|
||||||
|
# شدهای نباید وجود داشته باشه.
|
||||||
|
self.assertEqual(Notification.objects.count(), 0)
|
||||||
|
|
||||||
|
delivery = CampaignDelivery.objects.get(campaign=self.campaign, user_id=self.user.id)
|
||||||
|
self.assertEqual(delivery.status, CampaignDelivery.STATUS_FAILED)
|
||||||
|
|
||||||
|
# حالا که رکورد failed شده (نه pendingِ گیرکرده)، اجرای بعدی
|
||||||
|
# باید بتونه دوباره claim کنه و اینبار (بدونِ patch) موفق بشه.
|
||||||
|
execute_campaign(self.campaign)
|
||||||
|
delivery.refresh_from_db()
|
||||||
|
self.assertEqual(delivery.status, CampaignDelivery.STATUS_FAILED)
|
||||||
|
self.assertEqual(
|
||||||
|
CampaignDelivery.objects.filter(
|
||||||
|
campaign=self.campaign, user_id=self.user.id,
|
||||||
|
status=CampaignDelivery.STATUS_SENT,
|
||||||
|
).count(),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
self.assertEqual(Notification.objects.count(), 1)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignsAdminRegistrationTests(TestCase):
|
||||||
|
"""
|
||||||
|
اطمینان از اینکه MessageCampaign و CampaignDelivery در Django Admin
|
||||||
|
ثبت شدن و یک ادمینِ staff میتونه changelist هرکدوم رو ببینه.
|
||||||
|
توجه: get_user_model() اینجا مدلِ auth.User جنگو (برای لاگین به
|
||||||
|
/admin/) هست، کاملاً جدا از accounts.models.User که مدلِ بیزنسیِ
|
||||||
|
این پروژهست و در CampaignDelivery.user_id بهصورت سبک ارجاع
|
||||||
|
میشه.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
AuthUser = get_user_model()
|
||||||
|
self.staff_user = AuthUser.objects.create_superuser(
|
||||||
|
username='admin', email='admin@example.com', password='pass12345',
|
||||||
|
)
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
|
||||||
|
def test_message_campaign_registered(self):
|
||||||
|
self.assertIn(MessageCampaign, admin.site._registry)
|
||||||
|
|
||||||
|
def test_campaign_delivery_registered(self):
|
||||||
|
self.assertIn(CampaignDelivery, admin.site._registry)
|
||||||
|
|
||||||
|
def test_message_campaign_changelist_accessible(self):
|
||||||
|
url = reverse('admin:campaigns_messagecampaign_changelist')
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_campaign_delivery_changelist_accessible(self):
|
||||||
|
url = reverse('admin:campaigns_campaigndelivery_changelist')
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
|
class MessageCampaignAdminManagementTests(TestCase):
|
||||||
|
"""
|
||||||
|
MessageCampaign باید از ادمین قابلِ ساخت/ویرایش/حذف باشه (بدونِ هیچ
|
||||||
|
منطقِ اجرا) — یعنی رفتارِ پیشفرضِ ModelAdmin دستنخورده مونده.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
AuthUser = get_user_model()
|
||||||
|
self.staff_user = AuthUser.objects.create_superuser(
|
||||||
|
username='admin2', email='admin2@example.com', password='pass12345',
|
||||||
|
)
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
|
||||||
|
def test_add_permission_allowed(self):
|
||||||
|
request = type('Req', (), {'user': self.staff_user})()
|
||||||
|
model_admin = MessageCampaignAdmin(MessageCampaign, admin.site)
|
||||||
|
self.assertTrue(model_admin.has_add_permission(request))
|
||||||
|
|
||||||
|
def test_can_create_campaign_via_admin(self):
|
||||||
|
url = reverse('admin:campaigns_messagecampaign_add')
|
||||||
|
response = self.client.post(url, {
|
||||||
|
'title': 'admin-created',
|
||||||
|
'body': 'body text',
|
||||||
|
'channel': 'sms',
|
||||||
|
'target_filter': '{}',
|
||||||
|
})
|
||||||
|
# ریدایرکتِ موفق بعدِ ساختِ رکورد
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertTrue(MessageCampaign.objects.filter(title='admin-created').exists())
|
||||||
|
|
||||||
|
def test_can_delete_campaign_via_admin(self):
|
||||||
|
campaign = MessageCampaign.objects.create(title='to-delete', body='b', channel='sms')
|
||||||
|
url = reverse('admin:campaigns_messagecampaign_delete', args=[campaign.pk])
|
||||||
|
response = self.client.post(url, {'post': 'yes'})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertFalse(MessageCampaign.objects.filter(pk=campaign.pk).exists())
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignDeliveryAdminReadOnlyTests(TestCase):
|
||||||
|
"""
|
||||||
|
CampaignDelivery نباید از ادمین قابلِ افزودن/ویرایش/حذفِ دستی باشه؛
|
||||||
|
فقط باید قابلِ مشاهده باشه. این دقیقاً همون چیزیه که
|
||||||
|
campaigns.services.execute_campaign برای idempotency/unique
|
||||||
|
constraint روش تکیه میکنه — دستکاریِ دستیِ status میتونه اون
|
||||||
|
تضمینها رو دور بزنه.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
AuthUser = get_user_model()
|
||||||
|
self.staff_user = AuthUser.objects.create_superuser(
|
||||||
|
username='admin3', email='admin3@example.com', password='pass12345',
|
||||||
|
)
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
self.user = User.objects.create(name='u', is_guest=False, phone='0910000009')
|
||||||
|
self.campaign = MessageCampaign.objects.create(title='t', body='b', channel='sms')
|
||||||
|
self.delivery = CampaignDelivery.objects.create(
|
||||||
|
campaign=self.campaign, user_id=self.user.id, channel='sms',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_permission_methods_deny_add_change_delete_but_allow_view(self):
|
||||||
|
request = type('Req', (), {'user': self.staff_user})()
|
||||||
|
model_admin = CampaignDeliveryAdmin(CampaignDelivery, admin.site)
|
||||||
|
self.assertFalse(model_admin.has_add_permission(request))
|
||||||
|
self.assertFalse(model_admin.has_change_permission(request, self.delivery))
|
||||||
|
self.assertFalse(model_admin.has_delete_permission(request, self.delivery))
|
||||||
|
self.assertTrue(model_admin.has_view_permission(request, self.delivery))
|
||||||
|
|
||||||
|
def test_change_detail_view_is_viewable(self):
|
||||||
|
"""
|
||||||
|
صفحهی جزئیاتِ رکورد باید قابلِ مشاهده باشه (GET -> 200، بهصورتِ
|
||||||
|
read-only)، حتی اگه has_change_permission همیشه False باشه؛
|
||||||
|
دیدن نباید به fallbackِ codename پرمیشنِ پیشفرضِ Django وابسته
|
||||||
|
بمونه (رجوع به has_view_permission بالا).
|
||||||
|
"""
|
||||||
|
url = reverse('admin:campaigns_campaigndelivery_change', args=[self.delivery.pk])
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_all_fields_are_readonly(self):
|
||||||
|
model_admin = CampaignDeliveryAdmin(CampaignDelivery, admin.site)
|
||||||
|
self.assertEqual(set(model_admin.readonly_fields), set(model_admin.fields))
|
||||||
|
|
||||||
|
def test_add_view_forbidden(self):
|
||||||
|
url = reverse('admin:campaigns_campaigndelivery_add')
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_delete_view_forbidden(self):
|
||||||
|
url = reverse('admin:campaigns_campaigndelivery_delete', args=[self.delivery.pk])
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_change_via_post_does_not_modify_status(self):
|
||||||
|
"""
|
||||||
|
حتی اگه کسی مستقیماً POST بزنه به change URL، چون
|
||||||
|
has_change_permission همیشه False برمیگردونه، جنگو باید 403
|
||||||
|
بده و status رکورد نباید تغییر کنه.
|
||||||
|
"""
|
||||||
|
url = reverse('admin:campaigns_campaigndelivery_change', args=[self.delivery.pk])
|
||||||
|
response = self.client.post(url, {
|
||||||
|
'campaign': self.campaign.pk,
|
||||||
|
'user_id': self.user.id,
|
||||||
|
'channel': 'sms',
|
||||||
|
'status': CampaignDelivery.STATUS_SENT,
|
||||||
|
'error_message': '',
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
self.delivery.refresh_from_db()
|
||||||
|
self.assertEqual(self.delivery.status, CampaignDelivery.STATUS_PENDING)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user