Files
MathApp/content/curriculum_io.py
Zareian 9cee574fa9 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>
2026-09-13 20:46:50 +03:30

190 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
وارد/خارج کردنِ کلِ برنامه‌ی درسی (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}