سوالها از قبل فلهای وارد میشدند ولی دو شکاف مانده بود: افزودنِ تکی عملاً غیرقابلِ استفاده بود، و خودِ برنامهی درسی هیچ راهی جز ترمینالِ سرور نداشت. افزودنِ تکیِ سوال: انتخابِ درس یک دراپداونِ ۱۳۰۷تایی بود. حالا autocomplete است و بر اساسِ عنوانِ درس، فصل و پایه جستجو میشود. برنامهی درسی (Grade → Unit → Node → Lesson) حالا از /admin/content/grade/ وارد و خارج میشود. تا امروز رساندنش به سرور یعنی اجرای rag_import_collection و بعد seed_curriculum_from_textbook روی همان سرور — یعنی وابستگی به Qdrant و به آدمی با دسترسیِ SSH. حالا یکبار لوکال ساخته میشود و فایلش بالا میرود. همسانسازی با عنوان است نه با id: idها بینِ دو دیتابیس یکی نیستند و تکیه بر آنها یعنی وارد کردن روی سرور محتوای بیربط را بازنویسی میکند. وارد کردنِ برنامهی درسی عمداً چیزی پاک نمیکند و idempotent است — یک فایلِ ناقص نباید محتوای سرور را از بین ببرد، و اجرای دوباره نباید چیزی را دو بار بسازد. (وارد کردنِ سوالها اما replace دارد، چون جایگزینیِ سوالهای یک درس کارِ رایجی است.) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
185 lines
7.6 KiB
Python
185 lines
7.6 KiB
Python
import json
|
|
|
|
from django.contrib import admin, messages
|
|
from django.http import HttpResponse
|
|
from django.shortcuts import redirect, render
|
|
from django.urls import path, reverse
|
|
from django.utils.html import format_html
|
|
|
|
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, 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')
|
|
list_filter = ('type', 'lesson')
|
|
inlines = [ChoiceInline]
|
|
actions = ['export_selected']
|
|
change_list_template = 'admin/content/question/change_list.html'
|
|
# جستجوی درس هنگامِ افزودنِ تکیِ سوال، بهجای دراپداونِ کامل.
|
|
autocomplete_fields = ['lesson']
|
|
search_fields = ('prompt',)
|
|
|
|
def get_urls(self):
|
|
return [
|
|
path(
|
|
'import/',
|
|
self.admin_site.admin_view(self.import_view),
|
|
name='content_question_import',
|
|
),
|
|
] + 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',)
|