- 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>
254 lines
12 KiB
Python
254 lines
12 KiB
Python
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)
|