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('{}',
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('{}٪ از {}',
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//', admin_browser.units_json,
name='content_browse_units'),
path('browse/nodes//', admin_browser.nodes_json,
name='content_browse_nodes'),
path('browse/lessons//', admin_browser.lessons_json,
name='content_browse_lessons'),
path('browse/questions//', 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)