From a9200772a3fd769efac6f5fdf11222a5f9b0de5c Mon Sep 17 00:00:00 2001 From: Zareian Date: Tue, 25 Aug 2026 03:39:41 +0330 Subject: [PATCH] Add campaigns/ Add supervisor/ Add messaging features/ Separate SMS --- README.md | 7 +- accounts/admin.py | 245 +++++++- accounts/management/__init__.py | 0 accounts/management/commands/__init__.py | 0 .../commands/setup_supervisor_group.py | 68 +++ .../migrations/0003_user_last_activity_at.py | 18 + accounts/models.py | 4 + accounts/serializers.py | 13 + accounts/services.py | 179 ++++-- .../templates/accounts/send_sms_form.html | 24 + .../templates/admin/supervisor_dashboard.html | 28 + accounts/tests.py | 570 +++++++++++++++++- accounts/urls.py | 2 + accounts/views.py | 47 +- campaigns/__init__.py | 0 campaigns/admin.py | 68 +++ campaigns/apps.py | 6 + campaigns/migrations/0001_initial.py | 28 + campaigns/migrations/0002_campaigndelivery.py | 29 + ...igndelivery_one_active_per_user_channel.py | 30 + campaigns/migrations/__init__.py | 0 campaigns/models.py | 125 ++++ campaigns/services.py | 244 ++++++++ campaigns/tests.py | 495 +++++++++++++++ config/settings.py | 12 + notifications/services.py | 61 +- notifications/tests.py | 83 ++- 27 files changed, 2337 insertions(+), 49 deletions(-) create mode 100644 accounts/management/__init__.py create mode 100644 accounts/management/commands/__init__.py create mode 100644 accounts/management/commands/setup_supervisor_group.py create mode 100644 accounts/migrations/0003_user_last_activity_at.py create mode 100644 accounts/templates/accounts/send_sms_form.html create mode 100644 accounts/templates/admin/supervisor_dashboard.html create mode 100644 campaigns/__init__.py create mode 100644 campaigns/admin.py create mode 100644 campaigns/apps.py create mode 100644 campaigns/migrations/0001_initial.py create mode 100644 campaigns/migrations/0002_campaigndelivery.py create mode 100644 campaigns/migrations/0003_campaigndelivery_one_active_per_user_channel.py create mode 100644 campaigns/migrations/__init__.py create mode 100644 campaigns/models.py create mode 100644 campaigns/services.py create mode 100644 campaigns/tests.py diff --git a/README.md b/README.md index 547a09d..983d607 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ push می‌کنه ← Grafana از Loki می‌خونه. جزئیاتِ بیش - بین اپ‌ها به‌جای FK واقعی از ارجاع سبک (`user_id`, `object_id` + `label`) استفاده شده تا هر اپ در آینده قابل جدا شدن باشد. - `POST /api/ai/ask` فعلاً پاسخ stub برمی‌گرداند؛ محل اتصال مدل واقعی در `ai_tutor/views.py` (تابع `generate_tutor_answer`) مشخص شده. - `DEV_MASTER_OTP` (کد `0000`) فقط برای تست در توسعه است — **پیش از دیپلوی پروداکشن باید حذف یا به `DEBUG` مقیّد شود.** +- **لاگِ ساختاریافته**: همه‌ی درخواست‌های `/api/` به‌صورتِ JSON لاگ می‌شن (متد/مسیر/وضعیت/مدت/کاربر، بدونِ بدنه/هدر/سکرت)؛ قابلِ مشاهده و جست‌وجو در Grafana (بخشِ Docker بالا). ## قبل از دیپلوی پروداکشن @@ -100,8 +101,10 @@ push می‌کنه ← Grafana از Loki می‌خونه. جزئیاتِ بیش `DEBUG` روی `False` است) - [x] `DEV_MASTER_OTP` و برگردوندنِ `devOtp` توی پاسخ، فقط وقتی `DEBUG=True` فعالن؛ در پروداکشن غیرفعالن -- [x] rate limit (`ScopedRateThrottle`) روی `auth/otp/request` (۵/ساعت) و - `auth/login` (۱۰/ساعت) اضافه شد +- [x] rate limit (throttle) روی endpointهای احرازهویت و بقیه‌ی endpointهای + حساسِ پروژه (پرداخت، خرید اشتراک، تخفیف، AI، محتوا، خانواده، + پشتیبانی، affiliate، …) پیاده‌سازی شده؛ برای کاربرهای مهمان بر + اساسِ IP کلید می‌خوره تا چرخشِ حساب مهمان دورش نزنه - [x] IDOR در `GET /api/home` و `GET /api/progress/nodes` اصلاح شد: قبلاً بدون هیچ توکنی، با فرستادنِ `userId` دلخواه می‌شد آمار/پیشرفتِ هر کاربرِ دیگه‌ای رو خوند؛ حالا auth اجباریه و فقط خودِ کاربر یا والدِ diff --git a/accounts/admin.py b/accounts/admin.py index c349a5a..b118a9c 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -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 .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) @@ -8,6 +53,124 @@ class UserAdmin(admin.ModelAdmin): list_display = ('id', 'name', 'phone', 'device_id', 'is_guest', 'grade_id', 'created_at') search_fields = ('name', 'phone', 'device_id') 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) @@ -25,3 +188,83 @@ class SessionAdmin(admin.ModelAdmin): class OTPCodeAdmin(admin.ModelAdmin): list_display = ('id', 'phone', 'code', 'expires_at', '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_ مربوطه رو داشته باشه (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 diff --git a/accounts/management/__init__.py b/accounts/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/management/commands/__init__.py b/accounts/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/management/commands/setup_supervisor_group.py b/accounts/management/commands/setup_supervisor_group.py new file mode 100644 index 0000000..a653b7b --- /dev/null +++ b/accounts/management/commands/setup_supervisor_group.py @@ -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) + ) diff --git a/accounts/migrations/0003_user_last_activity_at.py b/accounts/migrations/0003_user_last_activity_at.py new file mode 100644 index 0000000..f1d1456 --- /dev/null +++ b/accounts/migrations/0003_user_last_activity_at.py @@ -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), + ), + ] diff --git a/accounts/models.py b/accounts/models.py index a404c10..2963425 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -17,6 +17,10 @@ class User(models.Model): is_guest = models.BooleanField(default=True) grade_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Grade created_at = models.DateTimeField(auto_now_add=True) + # آخرین زمانِ فعالیتِ کاربر (لاگین موفق، رفرش توکن موفق، یا سشنِ + # مهمانِ واقعی). فقط برای هدف‌گیریِ آینده‌ی کمپین‌های پیامی نگه‌داری + # می‌شه؛ در حال حاضر هیچ منطقِ دیگه‌ای به این فیلد وابسته نیست. + last_activity_at = models.DateTimeField(null=True, blank=True) class Meta: ordering = ['-created_at'] diff --git a/accounts/serializers.py b/accounts/serializers.py index 5e97b6e..81d2e43 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -67,3 +67,16 @@ class RequestOTPResponseSerializer(serializers.Serializer): success = serializers.BooleanField() devOtp = 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() diff --git a/accounts/services.py b/accounts/services.py index c0eb6f1..5de5651 100644 --- a/accounts/services.py +++ b/accounts/services.py @@ -5,8 +5,12 @@ import logging import requests from django.conf import settings +from django.db import models +from django.utils import timezone from rest_framework.exceptions import APIException +from .models import User + logger = logging.getLogger(__name__) @@ -26,7 +30,19 @@ def _phone_suffix(phone: str) -> str: 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 یک استاب بود که فقط لاگ می‌کرد و همیشه (بی‌قیدوشرط) به RequestOTPView «موفقیت» برمی‌گردوند؛ یعنی در پروداکشن @@ -36,44 +52,37 @@ class OTPDeliveryError(APIException): پیکربندیِ سرویسِ پیامک ناقص باشه یا خودِ درخواست به ارائه‌دهنده شکست بخوره، همین استثنا raise می‌شه و RequestOTPView یک خطای صریح (نه یک پاسخِ ساختگیِ موفق) برمی‌گردونه. + + این کلاس زیرکلاسِ SMSDeliveryError است تا رفتار/نوعِ استثنایی که + RequestOTPView (و تست‌ها) انتظارش رو دارن دقیقاً حفظ بشه، در حالی که + منطقِ واقعیِ ارتباط با ارائه‌دهنده در send_sms (عمومی) پیاده شده. """ - status_code = 503 default_detail = 'در حال حاضر امکان ارسال پیامک OTP وجود ندارد؛ لطفاً بعداً دوباره تلاش کنید.' 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) — دقیقاً همون - ارائه‌دهنده‌ای که در نسخه‌ی قبلی (استاب) به‌عنوان نمونه اشاره شده بود. - کلید API هیچ‌وقت داخل کد نیست؛ همیشه از env خونده می‌شه - (settings.KAVENEGAR_API_KEY). دو حالت: + ⚙️ Reusable هلپر: تنها بخشِ «ارتباط با ارائه‌دهنده» (کاوه‌نگار، + kavenegar.com) که قبلاً داخلِ send_otp_sms با منطقِ OTP قاطی شده بود، + به این تابعِ عمومی منتقل شده — بدونِ هیچ تغییری در رفتار/پیام‌های + provider. هر فیچرِ آینده (مثلاً notifications) هم می‌تونه بدونِ + تکرارِ کدِ ارائه‌دهنده، مستقیماً از همین تابع برای ارسالِ پیامک استفاده + کنه. - - DEBUG=True (dev/test): دقیقاً مثل قبل، فقط لاگ می‌کنه و هیچ پیامکِ - واقعی‌ای نمی‌فرسته — چون در این محیط، RequestOTPView خودِ کد رو در - فیلدِ devOtp پاسخ برمی‌گردونه و SMS واقعی لازم نیست (رفتار قبلی dev - حفظ شده). - - DEBUG=False (production): اگه KAVENEGAR_API_KEY تنظیم نشده باشه، یا - درخواست به کاوه‌نگار fail بشه/کدِ غیر ۲xx برگردونه، به‌جای گزارشِ - خاموشِ «موفقیت»، OTPDeliveryError raise می‌شه (کاربر پاسخ ۵۰۳ صریح - می‌گیره، نه یک success دروغین). + این تابع هیچ منطقِ مخصوصِ OTP (dev-mode short-circuit، تمپلیتِ پیامک، + یا OTPDeliveryError) نداره؛ فقط payload می‌سازه، به کاوه‌نگار درخواست + می‌زنه، و پاسخ رو اعتبارسنجی می‌کنه. کلیدِ API هیچ‌وقت داخل کد نیست؛ + همیشه از env خونده می‌شه (settings.KAVENEGAR_API_KEY). """ - 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: 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), ) - raise OTPDeliveryError('پیکربندیِ سرویسِ پیامک ناقص است.') + raise SMSDeliveryError('پیکربندیِ سرویسِ پیامک ناقص است.') - message = settings.OTP_SMS_TEMPLATE.format(code=code) payload = {'receptor': phone, 'message': message} if settings.KAVENEGAR_SENDER: payload['sender'] = settings.KAVENEGAR_SENDER @@ -85,37 +94,127 @@ def send_otp_sms(*, phone: str, code: str) -> None: timeout=5, ) except requests.RequestException: - logger.exception('OTP SMS request to provider failed: phone_suffix=%s', _phone_suffix(phone)) - raise OTPDeliveryError('ارسال پیامک OTP با خطا مواجه شد.') + logger.exception('SMS request to provider failed: phone_suffix=%s', _phone_suffix(phone)) + raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.') if response.status_code != 200: 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, ) - raise OTPDeliveryError('ارسال پیامک OTP با خطا مواجه شد.') + raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.') - # ⚠️ اصلاح: کاوه‌نگار حتی برای شکست‌های سطحِ اپلیکیشن (کلیدِ نامعتبر، - # اعتبار ناکافی، گیرنده‌ی نامعتبر و…) HTTP 200 برمی‌گردونه؛ نتیجه‌ی - # واقعی توی بدنه‌ی JSON، زیرِ return.status هست (200 یعنی موفق). قبلاً - # همینِ status_code == 200 به‌تنهایی «موفقیت» در نظر گرفته می‌شد، یعنی - # یک رد شدنِ واقعی از طرفِ کاوه‌نگار هم success=True دروغین برمی‌گردوند. - # حالا بدنه parse می‌شه و فقط return.status == 200 موفقیت محسوب می‌شه؛ - # هر پاسخِ ناقص/غیرمنتظره هم شکستِ تحویل در نظر گرفته می‌شه (نه موفقیت). + # ⚠️ کاوه‌نگار حتی برای شکست‌های سطحِ اپلیکیشن (کلیدِ نامعتبر، اعتبار + # ناکافی، گیرنده‌ی نامعتبر و…) HTTP 200 برمی‌گردونه؛ نتیجه‌ی واقعی توی + # بدنه‌ی JSON، زیرِ return.status هست (200 یعنی موفق). صرفِ + # status_code == 200 «موفقیت» در نظر گرفته نمی‌شه؛ بدنه parse می‌شه و + # فقط return.status == 200 موفقیت محسوب می‌شه؛ هر پاسخِ ناقص/غیرمنتظره + # هم شکستِ تحویل در نظر گرفته می‌شه (نه موفقیت). try: body = response.json() provider_status = body['return']['status'] except (ValueError, KeyError, TypeError): 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, ) - raise OTPDeliveryError('ارسال پیامک OTP با خطا مواجه شد.') + raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.') if provider_status != 200: 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', _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) diff --git a/accounts/templates/accounts/send_sms_form.html b/accounts/templates/accounts/send_sms_form.html new file mode 100644 index 0000000..c2b7532 --- /dev/null +++ b/accounts/templates/accounts/send_sms_form.html @@ -0,0 +1,24 @@ +{% extends "admin/base_site.html" %} +{% load i18n %} + +{% block content %} +

{{ users|length }} کاربر انتخاب شده‌اند. متنِ پیامک را وارد کنید:

+ + + +
+ {% csrf_token %} + {% for user in users %} + + {% endfor %} + + + {{ form.as_p }} + + +
+{% endblock %} diff --git a/accounts/templates/admin/supervisor_dashboard.html b/accounts/templates/admin/supervisor_dashboard.html new file mode 100644 index 0000000..6619d88 --- /dev/null +++ b/accounts/templates/admin/supervisor_dashboard.html @@ -0,0 +1,28 @@ +{% extends "admin/base_site.html" %} +{% load i18n %} + +{% block content %} +

+ این صفحه فقط یک ایندکسِ خواندنی (read-only) از صفحاتِ Adminِ موجود است؛ + هر بخش دقیقاً همون changelist همیشگی رو باز می‌کنه، بدون هیچ صفحه‌ی + موازیِ جدید. دسترسیِ افزودن/ویرایش/حذف، مستقل از این صفحه، بر اساسِ + پرمیشن‌های شما محدود شده است. +

+ + {% for section in sections %} +
+ + + + {% for link in section.links %} + {% if link.has_perm %} + + + + {% endif %} + {% endfor %} + +
{{ section.title }}
{{ link.label }}
+
+ {% endfor %} +{% endblock %} diff --git a/accounts/tests.py b/accounts/tests.py index 5c43b20..0558ab7 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -1,14 +1,20 @@ from unittest.mock import Mock, patch 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.management import call_command from django.test import TestCase, TransactionTestCase, override_settings +from django.urls import reverse from django.utils import timezone from rest_framework.test import APIClient from rest_framework.throttling import ScopedRateThrottle -from .models import OTPCode, Session, User -from .services import OTPDeliveryError, send_otp_sms +from .authentication import issue_access_token +from .models import OTPCode, Profile, Session, User +from .services import OTPDeliveryError, SMSDeliveryError, send_otp_sms class SendOtpSmsTests(TestCase): @@ -867,3 +873,563 @@ class GuestSessionThrottleTests(TestCase): format='json', ) 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) diff --git a/accounts/urls.py b/accounts/urls.py index 577255b..2414dd3 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -6,6 +6,7 @@ from .views import ( RefreshTokenView, RequestOTPView, SetUserGradeView, + UserProfileView, ) urlpatterns = [ @@ -14,4 +15,5 @@ urlpatterns = [ path('auth/login', LoginView.as_view(), name='auth-login'), path('auth/refresh', RefreshTokenView.as_view(), name='auth-refresh'), path('user/grade', SetUserGradeView.as_view(), name='user-grade'), + path('user/profile', UserProfileView.as_view(), name='user-profile'), ] diff --git a/accounts/views.py b/accounts/views.py index 1797ded..95da3f4 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -15,13 +15,14 @@ from rest_framework.throttling import ScopedRateThrottle from rest_framework.views import APIView 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 .serializers import ( GuestSessionRequestSerializer, GuestSessionResponseSerializer, LoginRequestSerializer, LoginResponseSerializer, + ProfileResponseSerializer, RefreshRequestSerializer, RefreshResponseSerializer, RequestOTPRequestSerializer, @@ -79,6 +80,12 @@ class GuestSessionView(APIView): 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) data = { @@ -285,6 +292,10 @@ class LoginView(APIView): user.is_guest = False user.save(update_fields=['is_guest']) + # ثبتِ فعالیتِ کاربر برای ورودِ موفق. + user.last_activity_at = timezone.now() + user.save(update_fields=['last_activity_at']) + if created: # آفرهای ثبت‌نام (مثل یک هفته رایگان) از طریق discount/receivers.py روی این سیگنال اعمال می‌شوند. from .signals import user_registered @@ -340,6 +351,9 @@ class RefreshTokenView(APIView): 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_session = Session.objects.create( user_id=old_session.user_id, @@ -384,3 +398,34 @@ class SetUserGradeView(APIView): data = {'success': True, 'gradeId': grade_id} 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) diff --git a/campaigns/__init__.py b/campaigns/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/campaigns/admin.py b/campaigns/admin.py new file mode 100644 index 0000000..6ba7c30 --- /dev/null +++ b/campaigns/admin.py @@ -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 diff --git a/campaigns/apps.py b/campaigns/apps.py new file mode 100644 index 0000000..0cab42c --- /dev/null +++ b/campaigns/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CampaignsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'campaigns' diff --git a/campaigns/migrations/0001_initial.py b/campaigns/migrations/0001_initial.py new file mode 100644 index 0000000..6cab42b --- /dev/null +++ b/campaigns/migrations/0001_initial.py @@ -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'], + }, + ), + ] diff --git a/campaigns/migrations/0002_campaigndelivery.py b/campaigns/migrations/0002_campaigndelivery.py new file mode 100644 index 0000000..90f099b --- /dev/null +++ b/campaigns/migrations/0002_campaigndelivery.py @@ -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'], + }, + ), + ] diff --git a/campaigns/migrations/0003_campaigndelivery_one_active_per_user_channel.py b/campaigns/migrations/0003_campaigndelivery_one_active_per_user_channel.py new file mode 100644 index 0000000..e605ba8 --- /dev/null +++ b/campaigns/migrations/0003_campaigndelivery_one_active_per_user_channel.py @@ -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', + ), + ), + ] diff --git a/campaigns/migrations/__init__.py b/campaigns/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/campaigns/models.py b/campaigns/models.py new file mode 100644 index 0000000..1a45441 --- /dev/null +++ b/campaigns/models.py @@ -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}" diff --git a/campaigns/services.py b/campaigns/services.py new file mode 100644 index 0000000..29702da --- /dev/null +++ b/campaigns/services.py @@ -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": } + {"type": "inactive_since_days", "days": } + {"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}") diff --git a/campaigns/tests.py b/campaigns/tests.py new file mode 100644 index 0000000..f743bf2 --- /dev/null +++ b/campaigns/tests.py @@ -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) diff --git a/config/settings.py b/config/settings.py index 7803bc4..dd9ce08 100644 --- a/config/settings.py +++ b/config/settings.py @@ -80,6 +80,9 @@ INSTALLED_APPS = [ # Tadrisino apps (سیستم پرداخت بازار + آفر/تخفیف + لاگ عمومی تغییر Status) 'statuslog', + + # Tadrisino apps (مدلِ مینیمالِ ذخیره‌سازیِ کمپین‌های پیامی، بدونِ منطقِ ارسال) + 'campaigns', ] MIDDLEWARE = [ @@ -278,6 +281,15 @@ SPECTACULAR_SETTINGS = { ACCESS_TOKEN_TTL_SECONDS = 3600 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. OTP_LOGIN_PHONE_MAX_ATTEMPTS = 10 OTP_LOGIN_PHONE_WINDOW_SECONDS = 3600 diff --git a/notifications/services.py b/notifications/services.py index ba473e7..d9ebf32 100644 --- a/notifications/services.py +++ b/notifications/services.py @@ -6,18 +6,75 @@ از این ماژول import و صدا می‌زنن — دقیقاً همون الگوی call_payment_gateway در 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 +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 notify(user_id=order.user_id, type='order_paid', 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( user_id=user_id, type=type, diff --git a/notifications/tests.py b/notifications/tests.py index a39b155..7c3b315 100644 --- a/notifications/tests.py +++ b/notifications/tests.py @@ -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)