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

@@ -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'])