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

View File

@@ -151,14 +151,30 @@ curl https://<host>/api/health
### مدیریتِ محتوا بدونِ ترمینال ### مدیریتِ محتوا بدونِ ترمینال
سوال‌ها از پنلِ ادمین وارد و خارج می‌شوند، نه با دستور روی سرور: هیچ محتوایی لازم نیست با دستور روی سرور گذاشته شود.
- `/admin/content/question/` → دکمه‌ی **«وارد کردنِ سوال‌ها از فایل»** | کار | کجا |
- برای گرفتنِ نمونه: چند سوال را انتخاب کنید → اکشنِ **«دریافتِ فایلِ سوال‌های |---|---|
انتخاب‌شده»**. خروجی دقیقاً همان قالبی‌ست که ورودی می‌پذیرد. | افزودن/ویرایشِ **یک** سوال | `/admin/content/question/` → «افزودن» (انتخابِ درس با جستجو) |
| وارد کردنِ **فله‌ای** سوال | همان صفحه → «وارد کردنِ سوال‌ها از فایل» |
| گرفتنِ خروجیِ سوال‌ها | چند سوال را انتخاب کنید → اکشنِ «دریافتِ فایل» |
| وارد کردنِ **برنامه‌ی درسی** (پایه/فصل/درس) | `/admin/content/grade/` → «وارد کردنِ برنامه‌ی درسی از فایل» |
| گرفتنِ خروجیِ برنامه‌ی درسی | پایه‌ها را انتخاب کنید → اکشنِ «دریافتِ فایلِ برنامه‌ی درسی» |
فایلِ نامعتبر **هیچ چیزی نمی‌نویسد** — یا همه‌ی سوال‌ها وارد می‌شوند یا هیچ‌کدام، خروجیِ هر کدام دقیقاً همان قالبی‌ست که ورودی‌اش می‌پذیرد، پس چرخه‌ی
و خطا می‌گوید کدام ردیف ایراد دارد. «بگیر → ویرایش کن → برگردان» بسته است و برای گرفتنِ نمونه لازم نیست کسی
مستندات بخواند.
دو تضمین:
- **سوال‌ها:** فایلِ نامعتبر هیچ چیزی نمی‌نویسد — یا همه وارد می‌شوند یا هیچ‌کدام.
- **برنامه‌ی درسی:** چیزی پاک نمی‌شود. هر چه در فایل هست ساخته/به‌روز می‌شود و
هر چه نیست دست‌نخورده می‌ماند، پس یک فایلِ ناقص محتوای سرور را از بین نمی‌برد.
### داده‌ی تستی روی سرور
`seed_test_data` فقط برای محیطِ لوکال است و **نباید** روی سروری که کاربرِ واقعی
دارد اجرا شود؛ ردیف‌هایی با پیشوندِ `[SEED]`/`[DEMO]` می‌سازد که در API دیده
می‌شوند. محتوای واقعی از مسیرِ جدولِ بالا می‌رود، نه از این دستور.
### متغیرهای محیطی موردنیاز ### متغیرهای محیطی موردنیاز

View File

@@ -6,6 +6,8 @@ from django.shortcuts import redirect, render
from django.urls import path, reverse from django.urls import path, reverse
from django.utils.html import format_html 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 .question_io import ImportError_, export_questions, import_questions, parse
from .models import ( from .models import (
@@ -20,7 +22,58 @@ class ChoiceInline(admin.TabularInline):
@admin.register(Grade) @admin.register(Grade)
class GradeAdmin(admin.ModelAdmin): class GradeAdmin(admin.ModelAdmin):
"""
برنامه‌ی درسی (پایه → فصل → درس → نوعِ مسئله) از همین‌جا فله‌ای وارد و خارج
می‌شود. تا پیش از این تنها راهِ رساندنش به سرور، اجرای چند دستور روی همان
سرور بود که به Qdrant و به دسترسیِ SSH وابسته بودند.
"""
list_display = ('id', 'title', 'icon', 'color') 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) @admin.register(Unit)
@@ -33,12 +86,16 @@ class UnitAdmin(admin.ModelAdmin):
class NodeAdmin(admin.ModelAdmin): class NodeAdmin(admin.ModelAdmin):
list_display = ('id', 'title', 'unit', 'order') list_display = ('id', 'title', 'unit', 'order')
list_filter = ('unit',) list_filter = ('unit',)
search_fields = ('title', 'unit__title')
@admin.register(Lesson) @admin.register(Lesson)
class LessonAdmin(admin.ModelAdmin): class LessonAdmin(admin.ModelAdmin):
list_display = ('id', 'title', 'node') list_display = ('id', 'title', 'node')
list_filter = ('node',) list_filter = ('node',)
# بدونِ این، انتخابِ درس در فرمِ سوال یک دراپ‌داونِ بیش از هزارتایی است
# که عملاً غیرقابلِ استفاده است.
search_fields = ('title', 'node__title', 'node__unit__title', 'node__unit__grade__title')
@admin.register(Question) @admin.register(Question)
@@ -56,6 +113,9 @@ class QuestionAdmin(admin.ModelAdmin):
inlines = [ChoiceInline] inlines = [ChoiceInline]
actions = ['export_selected'] actions = ['export_selected']
change_list_template = 'admin/content/question/change_list.html' change_list_template = 'admin/content/question/change_list.html'
# جستجوی درس هنگامِ افزودنِ تکیِ سوال، به‌جای دراپ‌داونِ کامل.
autocomplete_fields = ['lesson']
search_fields = ('prompt',)
def get_urls(self): def get_urls(self):
return [ return [

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}

View File

@@ -0,0 +1,7 @@
{% extends "admin/change_list.html" %}
{% block object-tools-items %}
<li>
<a href="{% url 'admin:content_grade_import_curriculum' %}" class="addlink">وارد کردنِ برنامه‌ی درسی از فایل</a>
</li>
{{ block.super }}
{% endblock %}

View File

@@ -0,0 +1,45 @@
{% extends "admin/base_site.html" %}
{% block content %}
<div dir="rtl" style="max-width:760px">
<h1>وارد کردنِ برنامه‌ی درسی</h1>
<p>کلِ ساختارِ پایه → فصل → درس → نوعِ مسئله را از یک فایلِ JSON می‌سازد.
برای گرفتنِ نمونه، از فهرستِ پایه‌ها یکی را انتخاب کنید و اکشنِ
«دریافتِ فایلِ برنامه‌ی درسی» را بزنید — خروجی دقیقاً همین قالب است.</p>
<pre style="background:#f6f6f6;padding:12px;border-radius:6px;direction:ltr;text-align:left">{
"grades": [
{
"title": "پایه نهم",
"icon": "grade-9",
"color": "#FF9800",
"units": [
{
"title": "فصل ۴: توان و ریشه",
"order": 4,
"nodes": [
{
"title": "توان صحیح",
"order": 1,
"lessons": [
{ "title": "ضرب توان‌ها", "introText": "..." }
]
}
]
}
]
}
]
}</pre>
<p><b>چیزی پاک نمی‌شود.</b> هر چه در فایل هست ساخته یا به‌روز می‌شود؛ هر چه
در فایل نیست دست‌نخورده می‌ماند. اجرای دوباره‌ی همان فایل هم چیزی را دو بار
نمی‌سازد.</p>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<p><input type="file" name="file" accept=".json,application/json" required></p>
<p><input type="submit" value="وارد کن" class="default"></p>
</form>
</div>
{% endblock %}

View File

@@ -94,3 +94,89 @@ class HealthEndpointTests(TestCase):
def test_needs_no_authentication(self): def test_needs_no_authentication(self):
self.assertEqual(self.client.get('/api/health').status_code, 200) 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'])