پیدا کردنِ «سوالهای فلان مبحث» در فهرستِ پیشفرضِ جنگو عملاً ممکن نبود: باید
بینِ چند صفحهی جدا با دراپداونهای هزارتایی بالا و پایین میرفتی.
/admin/content/question/browse/ چهار ستونِ آبشاری دارد — پایه → فصل → درس →
مبحث — و هر ستون فقط وقتی پر میشود که قبلی انتخاب شده باشد، پس هیچوقت
فهرستِ هزارتایی جلوی چشم نیست. تهاش سوالهای همان مبحث با گزینههایشان
میآیند و از همانجا ویرایش یا اضافه میشوند.
کنارِ هر سوال دو عددِ تصمیمساز: گزارشهای بازِ کاربران، و درصدِ پاسخِ غلط.
درصد زیرِ ۵ پاسخ نشان داده نمیشود — یک نمونهی کوچک سوالِ سالم را
«مشکلدار» جلوه میدهد. همین دو ستون به فهرستِ سوالها هم اضافه شدند.
content.QuestionReport و POST /api/questions/{id}/report تازهاند: تنها
بازخوردی که تا امروز جمع میشد مالِ چتِ دستیار بود، و روی سوالهای مسیرِ
آموزشی هیچ راهی نبود که کاربر بگوید گزینهی درست غلط است. گزارشِ بازِ تکراریِ
همان کاربر ردیفِ تازه نمیسازد تا دابلکلیک آمار را بههم نزند.
وابستگیِ تازهای اضافه نشد: چند اندپوینتِ کوچکِ JSON و JavaScript ساده.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
242 lines
11 KiB
Python
242 lines
11 KiB
Python
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('<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):
|
||
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)
|