import json from django.contrib import admin, messages 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 = ('id', 'title', '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): 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',) @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): 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' 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='علامت زدن به‌عنوانِ بررسی‌شده') def mark_resolved(self, request, queryset): updated = queryset.update(status='resolved', resolved_at=timezone.now()) self.message_user(request, f'{updated} گزارش بررسی‌شده علامت خورد.', messages.SUCCESS)