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