diff --git a/README.md b/README.md index 92cc8b1..ec2db41 100644 --- a/README.md +++ b/README.md @@ -151,14 +151,30 @@ curl https:///api/health ### مدیریتِ محتوا بدونِ ترمینال -سوال‌ها از پنلِ ادمین وارد و خارج می‌شوند، نه با دستور روی سرور: +هیچ محتوایی لازم نیست با دستور روی سرور گذاشته شود. -- `/admin/content/question/` → دکمه‌ی **«وارد کردنِ سوال‌ها از فایل»** -- برای گرفتنِ نمونه: چند سوال را انتخاب کنید → اکشنِ **«دریافتِ فایلِ سوال‌های - انتخاب‌شده»**. خروجی دقیقاً همان قالبی‌ست که ورودی می‌پذیرد. +| کار | کجا | +|---|---| +| افزودن/ویرایشِ **یک** سوال | `/admin/content/question/` → «افزودن» (انتخابِ درس با جستجو) | +| وارد کردنِ **فله‌ای** سوال | همان صفحه → «وارد کردنِ سوال‌ها از فایل» | +| گرفتنِ خروجیِ سوال‌ها | چند سوال را انتخاب کنید → اکشنِ «دریافتِ فایل» | +| وارد کردنِ **برنامه‌ی درسی** (پایه/فصل/درس) | `/admin/content/grade/` → «وارد کردنِ برنامه‌ی درسی از فایل» | +| گرفتنِ خروجیِ برنامه‌ی درسی | پایه‌ها را انتخاب کنید → اکشنِ «دریافتِ فایلِ برنامه‌ی درسی» | -فایلِ نامعتبر **هیچ چیزی نمی‌نویسد** — یا همه‌ی سوال‌ها وارد می‌شوند یا هیچ‌کدام، -و خطا می‌گوید کدام ردیف ایراد دارد. +خروجیِ هر کدام دقیقاً همان قالبی‌ست که ورودی‌اش می‌پذیرد، پس چرخه‌ی +«بگیر → ویرایش کن → برگردان» بسته است و برای گرفتنِ نمونه لازم نیست کسی +مستندات بخواند. + +دو تضمین: +- **سوال‌ها:** فایلِ نامعتبر هیچ چیزی نمی‌نویسد — یا همه وارد می‌شوند یا هیچ‌کدام. +- **برنامه‌ی درسی:** چیزی پاک نمی‌شود. هر چه در فایل هست ساخته/به‌روز می‌شود و + هر چه نیست دست‌نخورده می‌ماند، پس یک فایلِ ناقص محتوای سرور را از بین نمی‌برد. + +### داده‌ی تستی روی سرور + +`seed_test_data` فقط برای محیطِ لوکال است و **نباید** روی سروری که کاربرِ واقعی +دارد اجرا شود؛ ردیف‌هایی با پیشوندِ `[SEED]`/`[DEMO]` می‌سازد که در API دیده +می‌شوند. محتوای واقعی از مسیرِ جدولِ بالا می‌رود، نه از این دستور. ### متغیرهای محیطی موردنیاز diff --git a/content/admin.py b/content/admin.py index 15789a5..4db1946 100644 --- a/content/admin.py +++ b/content/admin.py @@ -6,6 +6,8 @@ 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 ( @@ -20,7 +22,58 @@ class ChoiceInline(admin.TabularInline): @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) @@ -33,12 +86,16 @@ class UnitAdmin(admin.ModelAdmin): 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) @@ -56,6 +113,9 @@ class QuestionAdmin(admin.ModelAdmin): 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 [ diff --git a/content/curriculum_io.py b/content/curriculum_io.py new file mode 100644 index 0000000..bb216f6 --- /dev/null +++ b/content/curriculum_io.py @@ -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} diff --git a/content/templates/admin/content/grade/change_list.html b/content/templates/admin/content/grade/change_list.html new file mode 100644 index 0000000..7885e9b --- /dev/null +++ b/content/templates/admin/content/grade/change_list.html @@ -0,0 +1,7 @@ +{% extends "admin/change_list.html" %} +{% block object-tools-items %} +
  • + وارد کردنِ برنامه‌ی درسی از فایل +
  • + {{ block.super }} +{% endblock %} diff --git a/content/templates/admin/content/grade/import.html b/content/templates/admin/content/grade/import.html new file mode 100644 index 0000000..475658e --- /dev/null +++ b/content/templates/admin/content/grade/import.html @@ -0,0 +1,45 @@ +{% extends "admin/base_site.html" %} +{% block content %} +
    +

    وارد کردنِ برنامه‌ی درسی

    + +

    کلِ ساختارِ پایه → فصل → درس → نوعِ مسئله را از یک فایلِ JSON می‌سازد. + برای گرفتنِ نمونه، از فهرستِ پایه‌ها یکی را انتخاب کنید و اکشنِ + «دریافتِ فایلِ برنامه‌ی درسی» را بزنید — خروجی دقیقاً همین قالب است.

    + +
    {
    +  "grades": [
    +    {
    +      "title": "پایه نهم",
    +      "icon": "grade-9",
    +      "color": "#FF9800",
    +      "units": [
    +        {
    +          "title": "فصل ۴: توان و ریشه",
    +          "order": 4,
    +          "nodes": [
    +            {
    +              "title": "توان صحیح",
    +              "order": 1,
    +              "lessons": [
    +                { "title": "ضرب توان‌ها", "introText": "..." }
    +              ]
    +            }
    +          ]
    +        }
    +      ]
    +    }
    +  ]
    +}
    + +

    چیزی پاک نمی‌شود. هر چه در فایل هست ساخته یا به‌روز می‌شود؛ هر چه + در فایل نیست دست‌نخورده می‌ماند. اجرای دوباره‌ی همان فایل هم چیزی را دو بار + نمی‌سازد.

    + +
    + {% csrf_token %} +

    +

    +
    +
    +{% endblock %} diff --git a/content/tests_question_io.py b/content/tests_question_io.py index c3b39db..f8d0e1e 100644 --- a/content/tests_question_io.py +++ b/content/tests_question_io.py @@ -94,3 +94,89 @@ class HealthEndpointTests(TestCase): def test_needs_no_authentication(self): self.assertEqual(self.client.get('/api/health').status_code, 200) + + +class CurriculumImportTests(TestCase): + """ + برنامه‌ی درسی باید بدونِ Qdrant و بدونِ ترمینال به سرور برسد. + + مهم‌ترین خاصیتش این است که چیزی پاک نمی‌کند: یک فایلِ ناقص نباید محتوای + سرور را از بین ببرد. + """ + + SAMPLE = { + 'grades': [{ + 'title': 'پایه نهم', 'icon': 'grade-9', 'color': '#FF9800', + 'units': [{ + 'title': 'فصل ۴: توان و ریشه', 'order': 4, + 'nodes': [{ + 'title': 'توان صحیح', 'order': 1, + 'lessons': [{'title': 'ضرب توان‌ها', 'introText': 'پایه‌ها برابرند.'}], + }], + }], + }], + } + + def _raw(self, payload=None): + return json.dumps(payload or self.SAMPLE, ensure_ascii=False).encode() + + def test_builds_the_whole_tree(self): + from content.curriculum_io import import_curriculum + from content.curriculum_io import parse as parse_curriculum + + counts = import_curriculum(parse_curriculum(self._raw())) + self.assertEqual((counts['grades'], counts['units'], counts['nodes'], counts['lessons']), + (1, 1, 1, 1)) + self.assertEqual(Lesson.objects.get().intro_text, 'پایه‌ها برابرند.') + + def test_running_it_twice_creates_nothing_new(self): + from content.curriculum_io import import_curriculum + from content.curriculum_io import parse as parse_curriculum + + import_curriculum(parse_curriculum(self._raw())) + counts = import_curriculum(parse_curriculum(self._raw())) + self.assertEqual(counts['lessons'], 0) + self.assertEqual(Lesson.objects.count(), 1) + + def test_a_partial_file_never_deletes_what_is_already_there(self): + from content.curriculum_io import import_curriculum + from content.curriculum_io import parse as parse_curriculum + + import_curriculum(parse_curriculum(self._raw())) + smaller = json.loads(json.dumps(self.SAMPLE)) + smaller['grades'][0]['units'] = [] + import_curriculum(parse_curriculum(self._raw(smaller))) + self.assertEqual(Lesson.objects.count(), 1, 'محتوای موجود نباید پاک شود') + + def test_a_missing_title_is_rejected_with_a_readable_message(self): + from content.curriculum_io import CurriculumError + from content.curriculum_io import parse as parse_curriculum + + broken = json.loads(json.dumps(self.SAMPLE)) + broken['grades'][0]['units'][0]['nodes'][0]['title'] = '' + with self.assertRaises(CurriculumError) as ctx: + parse_curriculum(self._raw(broken)) + self.assertIn('درس', str(ctx.exception)) + + def test_export_round_trips_back_through_import(self): + from content.curriculum_io import export_curriculum, import_curriculum + from content.curriculum_io import parse as parse_curriculum + + import_curriculum(parse_curriculum(self._raw())) + exported = export_curriculum(Grade.objects.all()) + Grade.objects.all().delete() + import_curriculum(parse_curriculum(json.dumps(exported, ensure_ascii=False).encode())) + self.assertEqual(Lesson.objects.get().title, 'ضرب توان‌ها') + + +class HealthWarningTests(TestCase): + def test_debug_mode_is_reported_as_a_warning(self): + with self.settings(DEBUG=True, DEV_MASTER_OTP='0000'): + body = self.client.get('/api/health').json() + joined = ' '.join(body['warnings']) + self.assertIn('DEBUG', joined) + self.assertIn('0000', joined) + + def test_allowed_origins_include_the_deployed_frontend(self): + body = self.client.get('/api/health').json() + self.assertIn('https://math.tadrisino2.ir', body['allowedOrigins'])