Files
MathApp/content/admin.py
Zareian 8cb1ecb85e Subjects per grade (math, calculus, geometry, stats, discrete), 5 new textbooks, 202 new islands, long islands split, hand-written figures and lesson texts for the new books
- content.Subject + Unit.subject, /api/home?subject=, tutor grounded in the chosen book
- 5 new books ingested; ~1150 hand-written questions, islands named by hand
- node_split: islands over 7 lessons split into parts of <=6 (lesson ids and progress kept)
- Lesson.figures hand-authored and visually checked for 653 lessons of the new books;
  gen_lesson_figures got --subject and survives network errors
- seed files re-exported

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 05:35:18 +03:30

262 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, Subject, 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(Subject)
class SubjectAdmin(admin.ModelAdmin):
"""کتاب‌های ریاضی (ریاضی، حسابان، هندسه، …) که نوارِ جابه‌جاییِ اپ از آن‌ها ساخته می‌شود."""
list_display = ('order', 'title', 'key', 'icon', 'color')
ordering = ('order',)
@admin.register(Unit)
class UnitAdmin(admin.ModelAdmin):
list_display = ('id', 'title', 'grade', 'subject', 'order')
list_filter = ('grade', 'subject')
# فیلترِ «فصل» در صفحه‌ی سوال‌ها هم از همین ترتیب می‌آید: پایه‌به‌پایه، نه
# همه‌ی «فصل ۱»ها پشتِ هم.
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)