Files
MathApp/content/admin.py
Zareian a973c41db2 Security review fixes: admin permissions, guest sessions, payment edge cases, assistant leaks
- Admin: refund/cancel/mark-resolved/Bazaar-link actions need change permission; question and
  curriculum import need add/delete; content browser needs view_question; guest device ids are
  shown to superusers only.
- Accounts: registering a guest's phone revokes the guest's earlier sessions; self-deletion keeps
  Redemption, UserOffer and the phone's OTP rows; the public demo guest cannot be deleted
  (DEMO_GUEST_DEVICE_IDS); display-name contact filter covers any separator and social handles.
- Payment: callback without Authority is 400; a second Bazaar purchase on a paid order is 409;
  orders snapshot credits/hearts/days at creation (payment.0011).
- Assistant: a broken diagram no longer makes a streamed answer free; a late off-topic marker
  refuses the answer (not stored, not cached); a disconnect saves the partial answer; only
  KNOWN_COLLECTIONS are searched.
- Infra: secret-key file is created atomically (workers no longer split keys); no default
  INTERNAL_SERVICE_KEY; Kavenegar key and Bazaar token kept out of error logs; Swagger/ReDoc
  pinned; schema throttled; docker compose bound to 127.0.0.1; non-ASCII digit ids are 400;
  ticket text capped at 5000.
- docs/security-before-production.md: remaining open items 24-31 and the guard tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:52:25 +03:30

254 lines
12 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.
import json
from django.contrib import admin, messages
from django.core.exceptions import PermissionDenied
from django.db.models import Count, Q
from django.http import HttpResponse
from django.shortcuts import redirect, render
from django.urls import path, reverse
from django.utils import timezone
from django.utils.html import format_html
from . import admin_browser
from .curriculum_io import CurriculumError, export_curriculum, import_curriculum
from .curriculum_io import parse as parse_curriculum
from .question_io import ImportError_, export_questions, import_questions, parse
from .models import (
Choice, Grade, Lesson, Node, NodeProgress, Question, QuestionReport, Unit, UserAnswer,
UserLessonCompletion,
)
class ChoiceInline(admin.TabularInline):
model = Choice
extra = 1
@admin.register(Grade)
class GradeAdmin(admin.ModelAdmin):
"""
برنامه‌ی درسی (پایه → فصل → درس → نوعِ مسئله) از همین‌جا فله‌ای وارد و خارج
می‌شود. تا پیش از این تنها راهِ رساندنش به سرور، اجرای چند دستور روی همان
سرور بود که به Qdrant و به دسترسیِ SSH وابسته بودند.
"""
list_display = ('number', 'title', 'id', 'icon', 'color')
search_fields = ('title',)
actions = ['export_curriculum_action']
change_list_template = 'admin/content/grade/change_list.html'
def get_urls(self):
return [
path(
'import-curriculum/',
self.admin_site.admin_view(self.import_curriculum_view),
name='content_grade_import_curriculum',
),
] + super().get_urls()
@admin.action(description='دریافتِ فایلِ برنامه‌ی درسیِ پایه‌های انتخاب‌شده')
def export_curriculum_action(self, request, queryset):
payload = json.dumps(export_curriculum(queryset), ensure_ascii=False, indent=2)
response = HttpResponse(payload, content_type='application/json; charset=utf-8')
response['Content-Disposition'] = 'attachment; filename="curriculum.json"'
return response
def import_curriculum_view(self, request):
# admin_view فقط is_staff را می‌سنجد؛ این صفحه پایه/فصل/درس می‌سازد و متنِ درس را عوض می‌کند.
if not (self.has_add_permission(request) and self.has_change_permission(request)):
raise PermissionDenied
if request.method == 'POST':
uploaded = request.FILES.get('file')
if uploaded is None:
self.message_user(request, 'فایلی انتخاب نشده بود.', messages.ERROR)
return redirect(request.path)
try:
counts = import_curriculum(parse_curriculum(uploaded.read()))
except CurriculumError as exc:
self.message_user(request, str(exc), messages.ERROR)
return redirect(request.path)
self.message_user(request, (
f"{counts['grades']} پایه، {counts['units']} فصل، {counts['nodes']} درس و "
f"{counts['lessons']} نوعِ مسئله اضافه شد"
+ (f"؛ {counts['updated']} متنِ درس به‌روز شد" if counts['updated'] else '')
+ '.'
), messages.SUCCESS)
return redirect(reverse('admin:content_grade_changelist'))
return render(request, 'admin/content/grade/import.html', {
**self.admin_site.each_context(request),
'title': 'وارد کردنِ برنامه‌ی درسی',
'opts': self.model._meta,
})
@admin.register(Unit)
class UnitAdmin(admin.ModelAdmin):
list_display = ('id', 'title', 'grade', 'order')
list_filter = ('grade',)
# فیلترِ «فصل» در صفحه‌ی سوال‌ها هم از همین ترتیب می‌آید: پایه‌به‌پایه، نه
# همه‌ی «فصل ۱»ها پشتِ هم.
ordering = ('grade__number', 'grade_id', 'order')
@admin.register(Node)
class NodeAdmin(admin.ModelAdmin):
list_display = ('id', 'title', 'unit', 'order')
list_filter = ('unit',)
search_fields = ('title', 'unit__title')
@admin.register(Lesson)
class LessonAdmin(admin.ModelAdmin):
list_display = ('id', 'title', 'node')
list_filter = ('node',)
# بدونِ این، انتخابِ درس در فرمِ سوال یک دراپ‌داونِ بیش از هزارتایی است
# که عملاً غیرقابلِ استفاده است.
search_fields = ('title', 'node__title', 'node__unit__title', 'node__unit__grade__title')
@admin.register(Question)
class QuestionAdmin(admin.ModelAdmin):
"""
سوال‌ها علاوه بر ویرایشِ تکی، از همین‌جا فله‌ای وارد و خارج می‌شوند.
چرا اینجا و نه در ترمینال: محتوا باید بدونِ دسترسیِ SSH و بدونِ برنامه‌نویس
قابلِ تغییر باشد. خروجیِ «دریافتِ فایل» دقیقاً همان قالبی‌ست که «وارد کردن»
می‌پذیرد، پس چرخه‌ی ویرایش بسته است.
"""
list_display = ('id', 'prompt', 'lesson', 'type', 'open_reports', 'wrong_percent')
list_filter = ('type', 'lesson__node__unit__grade', 'lesson__node__unit')
inlines = [ChoiceInline]
actions = ['export_selected']
change_list_template = 'admin/content/question/change_list.html'
# جستجوی درس هنگامِ افزودنِ تکیِ سوال، به‌جای دراپ‌داونِ کامل.
autocomplete_fields = ['lesson']
search_fields = ('prompt',)
def get_queryset(self, request):
return super().get_queryset(request).annotate(
_open_reports=Count('reports', filter=Q(reports__status='open'), distinct=True),
_total_answers=Count('answers', distinct=True),
_wrong_answers=Count('answers', filter=Q(answers__correct=False), distinct=True),
)
@admin.display(description='گزارشِ باز', ordering='_open_reports')
def open_reports(self, obj):
if not obj._open_reports:
return '—'
url = (reverse('admin:content_questionreport_changelist')
+ f'?question__id__exact={obj.pk}&status__exact=open')
return format_html('<a href="{}" style="color:#b91c1c;font-weight:600">{}</a>',
url, obj._open_reports)
@admin.display(description='٪ غلط', ordering='_wrong_answers')
def wrong_percent(self, obj):
# زیرِ ۵ پاسخ عددی نشان نمی‌دهیم: یک نمونه‌ی کوچک سوالِ سالم را
# «مشکل‌دار» جلوه می‌دهد.
if obj._total_answers < 5:
return '—'
percent = round(100 * obj._wrong_answers / obj._total_answers)
color = '#b91c1c' if percent >= 70 else '#475569'
return format_html('<span style="color:{}">{}٪ از {}</span>',
color, percent, obj._total_answers)
def get_urls(self):
return [
path(
'import/',
self.admin_site.admin_view(self.import_view),
name='content_question_import',
),
# مرورگرِ آبشاریِ محتوا؛ زیرِ همین مدل ثبت می‌شود تا در پنل پیدا باشد.
path('browse/', admin_browser.browser_view, name='content_browse'),
path('browse/units/<int:grade_id>/', admin_browser.units_json,
name='content_browse_units'),
path('browse/nodes/<int:unit_id>/', admin_browser.nodes_json,
name='content_browse_nodes'),
path('browse/lessons/<int:node_id>/', admin_browser.lessons_json,
name='content_browse_lessons'),
path('browse/questions/<int:lesson_id>/', admin_browser.questions_json,
name='content_browse_questions'),
] + super().get_urls()
@admin.action(description='دریافتِ فایلِ سوال‌های انتخاب‌شده')
def export_selected(self, request, queryset):
payload = json.dumps(export_questions(queryset), ensure_ascii=False, indent=2)
response = HttpResponse(payload, content_type='application/json; charset=utf-8')
response['Content-Disposition'] = 'attachment; filename="questions.json"'
return response
def import_view(self, request):
# admin_view فقط is_staff را می‌سنجد؛ «جایگزینی» سوال‌ها و جواب‌های دانش‌آموزان به آن‌ها را پاک می‌کند.
if not self.has_add_permission(request):
raise PermissionDenied
if request.method == 'POST':
uploaded = request.FILES.get('file')
if uploaded is None:
self.message_user(request, 'فایلی انتخاب نشده بود.', messages.ERROR)
return redirect(request.path)
replace = request.POST.get('replace') == 'on'
if replace and not self.has_delete_permission(request):
raise PermissionDenied
try:
rows = parse(uploaded.read())
result = import_questions(rows, replace=replace)
except ImportError_ as exc:
# خطای قابلِ فهم برای کاربر — هیچ چیزی نوشته نشده.
self.message_user(request, str(exc), messages.ERROR)
return redirect(request.path)
note = f"{result['created']} سوال در {result['lessons']} درس اضافه شد"
if result['removed']:
note += f" و {result['removed']} ردیفِ قبلی پاک شد"
self.message_user(request, note + '.', messages.SUCCESS)
return redirect(reverse('admin:content_question_changelist'))
return render(request, 'admin/content/question/import.html', {
**self.admin_site.each_context(request),
'title': 'وارد کردنِ سوال‌ها',
'opts': self.model._meta,
})
@admin.register(UserLessonCompletion)
class UserLessonCompletionAdmin(admin.ModelAdmin):
list_display = ('id', 'user_id', 'lesson', 'correct_count', 'stars', 'xp_earned', 'coins_earned')
list_filter = ('lesson',)
@admin.register(UserAnswer)
class UserAnswerAdmin(admin.ModelAdmin):
list_display = ('id', 'user_id', 'question', 'choice', 'correct', 'xp_earned')
list_filter = ('correct',)
@admin.register(NodeProgress)
class NodeProgressAdmin(admin.ModelAdmin):
list_display = (
'id', 'user_id', 'node', 'status', 'lessons_completed_count',
'started_at', 'last_activity_at', 'completed_at',
)
list_filter = ('status', 'node__unit')
search_fields = ('user_id',)
@admin.register(QuestionReport)
class QuestionReportAdmin(admin.ModelAdmin):
"""گزارشِ کاربران روی سوال‌ها — همان‌جایی که تصمیمِ اصلاحِ محتوا گرفته می‌شود."""
list_display = ('id', 'question', 'reason', 'status', 'created_at')
list_filter = ('status', 'reason', 'question__lesson__node__unit__grade')
search_fields = ('question__prompt', 'note')
autocomplete_fields = ['question']
actions = ['mark_resolved']
@admin.action(description='علامت زدن به‌عنوانِ بررسی‌شده', permissions=['change'])
def mark_resolved(self, request, queryset):
updated = queryset.update(status='resolved', resolved_at=timezone.now())
self.message_user(request, f'{updated} گزارش بررسی‌شده علامت خورد.', messages.SUCCESS)