""" وارد/خارج کردنِ کلِ برنامه‌ی درسی (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 ''), 'example': str(lesson.get('example') or ''), 'steps': [str(x) for x in (lesson.get('steps') or []) if str(x).strip()][:12], } for li, lesson in enumerate(lessons) ], }) clean_units.append({ 'title': _text(unit.get('title'), 'title', uwhere), 'blurb': str(unit.get('blurb') or '')[:120], '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 # متنِ ادمین برنده است: فقط خانه‌ی خالی از فایل پر می‌شود if unit_row.get('blurb') and not unit.blurb: unit.blurb = unit_row['blurb'] unit.save(update_fields=['blurb']) 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']}, ) changed = [] 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'] changed.append('intro_text') # مثال و قدم‌ها فقط وقتی از فایل می‌آیند که در دیتابیس خالی‌اند؛ آنچه # کسی از پنلِ ادمین نوشته با دیپلوی بازنویسی نمی‌شود. if lesson_row.get('example') and not lesson.example: lesson.example = lesson_row['example'] changed.append('example') if lesson_row.get('steps') and not lesson.steps: lesson.steps = lesson_row['steps'] changed.append('steps') if changed: lesson.save(update_fields=changed) if not created: 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, **({'blurb': unit.blurb} if unit.blurb else {}), 'nodes': [ { 'title': node.title, 'order': node.order, 'lessons': [ { 'title': lesson.title, 'introText': lesson.intro_text, **({'example': lesson.example} if lesson.example else {}), **({'steps': lesson.steps} if lesson.steps else {}), } for lesson in node.lessons.all() ], } for node in unit.nodes.all() ], } for unit in grade.units.all() ], }) return {'grades': out}