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:
@@ -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 [
|
||||
|
||||
189
content/curriculum_io.py
Normal file
189
content/curriculum_io.py
Normal 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}
|
||||
7
content/templates/admin/content/grade/change_list.html
Normal file
7
content/templates/admin/content/grade/change_list.html
Normal 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 %}
|
||||
45
content/templates/admin/content/grade/import.html
Normal file
45
content/templates/admin/content/grade/import.html
Normal 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 %}
|
||||
@@ -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'])
|
||||
|
||||
Reference in New Issue
Block a user