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

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

View File

@@ -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_<model> مربوطه رو داشته باشه (request.user.has_perm(...)).
# - حتی اگه کسی مستقیماً URL تغییر/افزودن/حذف رو حدس بزنه، خودِ
# changelist/change/add/delete viewهای اون ModelAdminها (که اینجا هیچ
# override‌ای نشدن) طبقِ has_view_permission/has_change_permission/
# has_add_permission/has_delete_permission پیش‌فرضِ ModelAdmin — که
# مستقیماً از Permission مدلِ کاربر می‌خونن — چک می‌کنن؛ یعنی گروهِ
# Supervisor (که با دستورِ مدیریتیِ setup_supervisor_group فقط پرمیشنِ
# view_* می‌گیره) حتی با دسترسیِ مستقیم به URL هم نمی‌تونه add/change/
# delete کنه.
# - خودِ صفحه هم مثلِ هر صفحه‌ی دیگه‌ی Admin با admin.site.admin_view
# wrap شده، یعنی همون احرازِ هویت/is_staff استانداردِ Admin رو داره؛
# ولی is_staff به‌تنهایی برای *ورود به خودِ صفحه* کافی نیست — پایین‌تر
# قبل از هر کارِ دیگه‌ای عضویتِ کاربر در گروهِ Supervisor هم چک می‌شه
# (request.user.groups.filter(name=SUPERVISOR_GROUP_NAME))، وگرنه
# PermissionDenied (۴۰۳) بالا می‌ره. این یک سیستمِ پرمیشنِ جدید نیست؛
# فقط از همون گروهِ Supervisorِ موجود (که setup_supervisor_group
# می‌سازه) برای گیت‌کیپینگِ ورود به صفحه استفاده می‌کنه.
# ─────────────────────────────────────────────────────────────────────────
SUPERVISOR_GROUP_NAME = 'Supervisor'
def supervisor_dashboard(request):
from payment.models import Order, Transaction
from subscription.models import Subscription
if not request.user.groups.filter(name=SUPERVISOR_GROUP_NAME).exists():
raise PermissionDenied
def _link(label, model):
opts = model._meta
return {
'label': label,
'url_name': f'admin:{opts.app_label}_{opts.model_name}_changelist',
'has_perm': request.user.has_perm(f'{opts.app_label}.view_{opts.model_name}'),
}
sections = [
{'title': 'کاربران', 'links': [_link('کاربران', User)]},
{'title': 'پرداخت‌ها', 'links': [_link('سفارش‌ها', Order), _link('تراکنش‌ها', Transaction)]},
{'title': 'اشتراک‌ها', 'links': [_link('اشتراک‌ها', Subscription)]},
]
context = {
**admin.site.each_context(request),
'title': 'ناظر (Supervisor)',
'sections': sections,
}
return render(request, 'admin/supervisor_dashboard.html', context)
_original_admin_get_urls = admin.site.get_urls
def _admin_get_urls_with_supervisor():
from django.urls import path
custom_urls = [
path('supervisor/', admin.site.admin_view(supervisor_dashboard), name='supervisor-dashboard'),
]
return custom_urls + _original_admin_get_urls()
admin.site.get_urls = _admin_get_urls_with_supervisor

View File

View File

View File

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

View File

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

View File

@@ -17,6 +17,10 @@ class User(models.Model):
is_guest = models.BooleanField(default=True)
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']

View File

@@ -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()

View File

@@ -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)

View File

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

View File

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

View File

@@ -1,14 +1,20 @@
from unittest.mock import Mock, patch
from 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)

View File

@@ -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'),
]

View File

@@ -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)