Files
MathApp/accounts/admin.py
Zareian 7ecb337263 Finishing a lesson or island needs recorded answers and an unlocked island; auto DB backup before migrate; throttles on 15 more routes
- content: POST nodes/{id}/quiz/complete and lessons/{id}/complete refuse a locked island
  (403 node_locked, same rule as /api/home) and a user who has not answered every question
  (409 answers_missing). Before, only a user with zero hearts and no answers was refused, so
  anyone else finished any island, locked or not, and finishing a locked island's lessons
  unlocked it. A node or lesson with no questions still passes when unlocked, with no XP.
  The client can no longer understate totalQuestions to buy stars.
- throttle_scope on 15 routes that had none (question bank without login, intro, hearts,
  notifications, tickets, plans, subscription status, orders, offers, affiliate, assistant
  history and feedback, logout).
- admin: OTPCode and Session can only be added/changed by a superuser.
- deploy.sh: integrity-checked DB backup before migrate (keeps the last 2); no migrate without it.
- docs and CLAUDE.md updated to say what is verified and what is not.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 13:30:24 +03:30

287 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
required=False,
)
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)
class UserAdmin(admin.ModelAdmin):
list_display = ('id', 'public_id', 'name', 'phone', 'grade_id', 'created_at')
search_fields = ('name', 'phone')
@admin.display(description='شناسه‌ی نمایشی')
def public_id(self, obj):
from .public_id import encode
return encode(obj.pk)
def get_search_results(self, request, queryset, search_term):
"""«User482917» (همان که کاربر در پروفایلش می‌بیند) هم کاربر را پیدا می‌کند."""
from .public_id import decode
results, may_have_duplicates = super().get_search_results(request, queryset, search_term)
user_id = decode(search_term)
if user_id:
results |= queryset.filter(pk=user_id)
return results, may_have_duplicates
actions = ['send_sms_to_selected']
def get_readonly_fields(self, request, obj=None):
# شماره کلیدِ ورود است: کارمندی با change_user که شماره‌ی کاربری را به شماره‌ی خودش عوض
# کند، کد می‌گیرد و واردِ همان حساب می‌شود؛ پس فقط ابرکاربر آن را عوض می‌کند.
fields = tuple(super().get_readonly_fields(request, obj))
return fields if request.user.is_superuser else fields + ('phone',)
# permissions=['change']: بدونِ این، کاربرِ فقط-view (گروهِ Supervisor)
# می‌تونست با POSTِ مستقیم این اکشن رو صدا بزنه بدونِ دیدنِ دکمه در UI؛
# این پارامتر باعث می‌شه ModelAdmin فقط برای change_user در دسترس بذارتش.
@admin.action(description='ارسال پیامک به کاربران انتخاب‌شده', permissions=['change'])
def send_sms_to_selected(self, request, queryset):
"""اکشنِ ادمین برای ارسالِ پیامک به کاربرانِ انتخاب‌شده یا غیرفعال؛
گیرنده با فیلدِ «target» انتخاب می‌شه (cutoffِ «غیرفعال» رو خودِ ادمین
در فرم وارد می‌کنه، چیزی hardcode نیست).
ارسال کاملاً synchronous و توی همون requestه (نه صفِ پس‌زمینه)، پس
اگر گیرنده‌ها از settings.ADMIN_SMS_MAX_RECIPIENTS بیشتر باشن، اکشن
هیچ پیامکی نمی‌فرسته تا request تایم‌اوت نکنه یا provider overload نشه.
شکستِ ارسال برای یک کاربر بقیه را متوقف نمی‌کنه.
"""
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) چون 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,
)
# سقوط به رندرِ مشترکِ پایین (نه return None)، هم‌الگو با
# شاخه‌ی cap-exceeded.
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)
class ProfileAdmin(admin.ModelAdmin):
list_display = ('id', 'user', 'birth_date', 'birth_year')
@admin.register(Session)
class SessionAdmin(admin.ModelAdmin):
list_display = ('id', 'user', 'expires_at', 'revoked')
list_filter = ('revoked',)
# ساختن/ویرایشِ دستیِ نشست یعنی ساختنِ ورود برای هر کاربر؛ فقط ابرکاربر.
def has_add_permission(self, request):
return request.user.is_superuser
def has_change_permission(self, request, obj=None):
return request.user.is_superuser
@admin.register(OTPCode)
class OTPCodeAdmin(admin.ModelAdmin):
list_display = ('id', 'phone', 'code', 'expires_at', 'is_used')
list_filter = ('is_used',)
# ردیفِ OTP که کارمند می‌سازد = ورود بی‌پیامک به هر شماره (راهِ تستر)؛ فقط ابرکاربر.
def has_add_permission(self, request):
return request.user.is_superuser
def has_change_permission(self, request, obj=None):
return request.user.is_superuser
# صفحه‌ی «ناظر» (Supervisor): یک ایندکسِ read-only از لینک‌ها به
# changelistهای موجود (کاربران/پرداخت‌ها/اشتراک‌ها)، داخلِ همون AdminSite
# پیش‌فرض — نه AdminSite جدا و بدون هیچ ModelAdmin جدید.
#
# read-only بودن با پنهون‌کردنِ لینک تضمین نمی‌شه، بلکه با پرمیشنِ استانداردِ
# جنگو: هر لینک فقط با view_<model> نشون داده می‌شه و حتی دسترسیِ مستقیم به
# URLِ change/add/delete هم توسطِ پرمیشنِ پیش‌فرضِ همون ModelAdminها رد می‌شه
# (گروهِ Supervisor فقط view_* داره). is_staff به‌تنهایی برای ورود کافی
# نیست؛ عضویت در گروهِ Supervisor هم چک می‌شه، وگرنه PermissionDenied.
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
# حساب‌های کارمندانِ پنل (auth.User/Group جنگو، جدا از accounts.User). در پیش‌فرضِ جنگو کارمندی با
# change_group همه‌ی اجازه‌ها را به گروهِ خودش می‌دهد و با change_user (auth) تیکِ ابرکاربر را برای
# خودش می‌زند. ساختن/تغییر/حذفِ این دو فقط برای ابرکاربر است؛ دیدن همان اجازه‌ی view را می‌خواهد.
from django.contrib.auth import get_user_model # noqa: E402
from django.contrib.auth.admin import GroupAdmin as _DjangoGroupAdmin # noqa: E402
from django.contrib.auth.admin import UserAdmin as _DjangoUserAdmin # noqa: E402
from django.contrib.auth.models import Group # noqa: E402
class _SuperuserWritesOnly:
def has_add_permission(self, request):
return request.user.is_superuser
def has_change_permission(self, request, obj=None):
return request.user.is_superuser
def has_delete_permission(self, request, obj=None):
return request.user.is_superuser
class StaffUserAdmin(_SuperuserWritesOnly, _DjangoUserAdmin):
pass
class StaffGroupAdmin(_SuperuserWritesOnly, _DjangoGroupAdmin):
pass
admin.site.unregister(get_user_model())
admin.site.register(get_user_model(), StaffUserAdmin)
admin.site.unregister(Group)
admin.site.register(Group, StaffGroupAdmin)