Files
MathApp/accounts/admin.py
Zareian 39247f6d53 senior's post-demo changes: parent report in the student's own account, Jalali birth date, invoices, math-only tutor, priced quotas
- remove the family app from the root (separate parent accounts, invite codes, parental
  settings); progress.0006 drops its tables, permissions and migration history, and
  Profile.parent_phone, the two family notification types and ?userId= on
  /api/progress/nodes go with it
- GET /api/user/weekly-report: last 7 days next to the 7 before (estimated minutes,
  questions, accuracy, islands, tutor questions) for the "parent report" page
- profile birthDate in Jalali (accounts/jalali.py), ages 5-99, birthYear derived from it
- GET /api/payment/invoices; orders now snapshot list price and plan title
- tutor answers only math: server pre-filter, scope rule in the prompt (tutor-v11),
  refusal marker replaced with fixed text; self-harm gets a help line instead
- streamed answers now record token usage (it was always 0)
- daily tutor quota: AI_DAILY_QUOTA_FREE (default 3) vs Plan.ai_daily_quota
- delete_users management command for wiping test accounts across every app
- docs/ai-cost-and-guardrails.md: measured cost per question and guardrail results

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 02:38:43 +03:30

219 lines
9.7 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', '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']: بدونِ این، کاربرِ فقط-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', 'device_id', 'expires_at', 'revoked')
list_filter = ('revoked',)
@admin.register(OTPCode)
class OTPCodeAdmin(admin.ModelAdmin):
list_display = ('id', 'phone', 'code', 'expires_at', 'is_used')
list_filter = ('is_used',)
# صفحه‌ی «ناظر» (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