put the whole content pipeline behind the admin panel

سوال‌ها از قبل فله‌ای وارد می‌شدند ولی دو شکاف مانده بود: افزودنِ تکی عملاً
غیرقابلِ استفاده بود، و خودِ برنامه‌ی درسی هیچ راهی جز ترمینالِ سرور نداشت.

افزودنِ تکیِ سوال: انتخابِ درس یک دراپ‌داونِ ۱۳۰۷تایی بود. حالا 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>
This commit is contained in:
2026-09-13 20:46:50 +03:30
parent eaa4b761dd
commit 9cee574fa9
6 changed files with 409 additions and 6 deletions

189
content/curriculum_io.py Normal file
View File

@@ -0,0 +1,189 @@
"""
وارد/خارج کردنِ کلِ برنامه‌ی درسی (Grade → Unit → Node → Lesson) به‌صورتِ فایل.
چرا جدا از question_io: سوال‌ها برگ‌های درخت‌اند، ولی خودِ درخت هم باید به
سرور برسد و تا امروز تنها راهش اجرای چند دستور روی همان سرور بود
(rag_import_collection، سپس seed_curriculum_from_textbook) — که هر دو به
Qdrant و به آدمی با دسترسیِ SSH وابسته‌اند.
با این فایل، برنامه‌ی درسی یک‌بار و لوکال ساخته می‌شود، خروجی گرفته می‌شود، و
از پنلِ ادمینِ سرور بالا می‌رود. سرور نه به Qdrant نیاز دارد نه به ترمینال.
قالب، همان ساختارِ تو‌در‌توی خودِ درس‌ها است تا خواندنش برای آدم آسان باشد:
{
"grades": [
{
"title": "پایه نهم", "icon": "grade-9", "color": "#FF9800",
"units": [
{
"title": "فصل ۴: توان و ریشه", "order": 4,
"nodes": [
{
"title": "توان صحیح", "order": 1,
"lessons": [
{"title": "ضرب توان‌ها", "introText": "..."}
]
}
]
}
]
}
]
}
هم‌سان‌سازی با عنوان انجام می‌شود، نه با id: idها بینِ دو دیتابیس یکی نیستند و
تکیه بر آن‌ها یعنی وارد کردن روی سرور محتوای بی‌ربط را بازنویسی می‌کند.
"""
import json
from django.db import transaction
from .models import Grade, Lesson, Node, Unit
class CurriculumError(Exception):
"""خطای قابلِ نمایش به کاربر (نه باگ)."""
def _text(value, field: str, where: str) -> str:
if not isinstance(value, str) or not value.strip():
raise CurriculumError(f'{where}: «{field}» خالی یا نامعتبر است.')
return value.strip()
def parse(raw: bytes) -> list[dict]:
try:
data = json.loads(raw.decode('utf-8'))
except UnicodeDecodeError:
raise CurriculumError('فایل باید UTF-8 باشد.')
except json.JSONDecodeError as exc:
raise CurriculumError(f'JSON نامعتبر است: {exc.msg} (خط {exc.lineno})')
grades = data.get('grades') if isinstance(data, dict) else data
if not isinstance(grades, list) or not grades:
raise CurriculumError('فایل باید فهرستی از پایه‌ها زیرِ کلیدِ «grades» داشته باشد.')
cleaned = []
for gi, grade in enumerate(grades):
gwhere = f'پایه {gi + 1}'
if not isinstance(grade, dict):
raise CurriculumError(f'{gwhere}: باید شیء باشد.')
units = grade.get('units') or []
if not isinstance(units, list):
raise CurriculumError(f'{gwhere}: «units» باید فهرست باشد.')
clean_units = []
for ui, unit in enumerate(units):
uwhere = f'{gwhere} › فصل {ui + 1}'
nodes = unit.get('nodes') or []
if not isinstance(nodes, list):
raise CurriculumError(f'{uwhere}: «nodes» باید فهرست باشد.')
clean_nodes = []
for ni, node in enumerate(nodes):
nwhere = f'{uwhere} › درس {ni + 1}'
lessons = node.get('lessons') or []
if not isinstance(lessons, list):
raise CurriculumError(f'{nwhere}: «lessons» باید فهرست باشد.')
clean_nodes.append({
'title': _text(node.get('title'), 'title', nwhere),
'order': int(node.get('order') or ni + 1),
'lessons': [
{
'title': _text(lesson.get('title'), 'title', f'{nwhere} › نوع {li + 1}'),
'intro_text': (lesson.get('introText') or lesson.get('intro_text') or ''),
}
for li, lesson in enumerate(lessons)
],
})
clean_units.append({
'title': _text(unit.get('title'), 'title', uwhere),
'order': int(unit.get('order') or ui + 1),
'nodes': clean_nodes,
})
cleaned.append({
'title': _text(grade.get('title'), 'title', gwhere),
'icon': (grade.get('icon') or ''),
'color': (grade.get('color') or ''),
'units': clean_units,
})
return cleaned
@transaction.atomic
def import_curriculum(grades: list[dict]) -> dict:
"""
idempotent: اجرای دوباره‌ی همان فایل چیزی را دو بار نمی‌سازد.
چیزی را هم پاک نمی‌کند — درسی که در فایل نیست دست‌نخورده می‌ماند. پاک‌کردن
عمداً اینجا نیست: یک فایلِ ناقص نباید محتوای سرور را از بین ببرد.
"""
counts = {'grades': 0, 'units': 0, 'nodes': 0, 'lessons': 0, 'updated': 0}
for grade_row in grades:
grade, created = Grade.objects.get_or_create(
title=grade_row['title'],
defaults={'icon': grade_row['icon'], 'color': grade_row['color']},
)
counts['grades'] += 1 if created else 0
for unit_row in grade_row['units']:
unit, created = Unit.objects.get_or_create(
grade=grade, title=unit_row['title'],
defaults={'order': unit_row['order']},
)
counts['units'] += 1 if created else 0
for node_row in unit_row['nodes']:
node, created = Node.objects.get_or_create(
unit=unit, title=node_row['title'],
defaults={'order': node_row['order']},
)
counts['nodes'] += 1 if created else 0
for lesson_row in node_row['lessons']:
lesson, created = Lesson.objects.get_or_create(
node=node, title=lesson_row['title'],
defaults={'intro_text': lesson_row['intro_text']},
)
if created:
counts['lessons'] += 1
elif lesson_row['intro_text'] and lesson.intro_text != lesson_row['intro_text']:
lesson.intro_text = lesson_row['intro_text']
lesson.save(update_fields=['intro_text'])
counts['updated'] += 1
return counts
def export_curriculum(grades_queryset) -> dict:
"""خروجی دقیقاً همان قالبی‌ست که import می‌خواند."""
out = []
for grade in grades_queryset.prefetch_related('units__nodes__lessons'):
out.append({
'title': grade.title,
'icon': grade.icon,
'color': grade.color,
'units': [
{
'title': unit.title,
'order': unit.order,
'nodes': [
{
'title': node.title,
'order': node.order,
'lessons': [
{'title': lesson.title, 'introText': lesson.intro_text}
for lesson in node.lessons.all()
],
}
for node in unit.nodes.all()
],
}
for unit in grade.units.all()
],
})
return {'grades': out}