carry the curriculum in the repo so a fresh server is not empty

سرورِ production از روزِ اول `{"grades": []}` برگردانده: دیتابیس gitignore است
(درست هم هست) و تنها راهِ پر کردنش اجرای `rag_import_collection` و بعد
`seed_curriculum_from_textbook` روی همان سرور بود — یعنی وابستگی به Qdrant، به
اعتبارِ AI که تمام شده، و به کسی که SSH دارد. نتیجه این شد که هیچ‌کس نتوانست
چیزی را تست کند.

حالا برنامه‌ی درسی در `content/seed/curriculum.json` کامیت می‌شود (۴ پایه، ۳۱
فصل، ۱۶۸ مبحث، ۱۳۰۰ درس، ۵۲۷ کیلوبایت متن) و `deploy.sh` خودش بالا می‌آوردش.
سرورِ تازه نه Qdrant می‌خواهد نه ترمینال.

`db.sqlite3` عمداً کامیت نمی‌شود: باینری است، دیف نمی‌شود، و هر دیپلوی محتوای
سرور — و فردا پاسخ‌های کاربران — را با نسخه‌ی لوکالِ یک نفر جایگزین می‌کند.

سوال‌ها با مسیرِ عنوان ذخیره می‌شوند نه با lessonId. این تصمیمِ اصلیِ کامیت
است: لوکالِ ما ۱۳۰۷ درس دارد با idهای ۳ تا ۲۰۰۵ (جای خالی، از پاک شدنِ دیتای
دمو)، و همان کوریکولوم روی دیتابیسِ خالی idهای ۱ تا ۱۳۰۷ می‌گیرد. با id،
سوالِ درسِ ۳ روی سرور به درسِ بی‌ربطی می‌چسبید — بدونِ هیچ خطایی. تست
`test_question_follows_the_title_path_not_the_id` همین را نگه می‌دارد.

`seed_content` ایدمپوتنت است و هیچ‌چیز پاک نمی‌کند: کوریکولوم با عنوان
هم‌سان‌سازی می‌شود و سوالِ تکراری رد می‌شود (`skip_existing`). روی سروری که
کاربرِ واقعی دارد، دیپلوی نباید بتواند محتوا یا پاسخی را از بین ببرد — و
ادمین باید بتواند سوالِ سیدشده را اصلاح کند بی‌آنکه دیپلویِ بعدی برش گرداند.

ردیف‌های `[DEMO]`/`[SEED]` موقعِ export کنار گذاشته می‌شوند؛ آن‌ها همان‌هایی‌اند
که `purge_demo_data` برای پاک کردنشان نوشته شد و جایشان در فایلی که مستقیم روی
production لود می‌شود نیست.

هنوز صفر سوالِ واقعی داریم: هر ۱۴ سوالِ لوکال رویِ ردیف‌های دمو بودند. مسیرِ
سوال‌ها ساخته و تست شده، ولی تا وقتی اعتبارِ AI شارژ نشود فایلش خالی می‌ماند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 20:20:26 +03:30
parent f3be99e4e2
commit 9c3db68cbb
6 changed files with 6873 additions and 3 deletions

View File

@@ -0,0 +1,195 @@
"""
تست‌های `seed_content` — محتوایی که از ریپو روی سرور بالا می‌آید.
چیزی که اینجا محافظت می‌شود، سه شکستِ خاموش است: سوالی که به درسِ اشتباه
بچسبد چون idها بینِ دو دیتابیس یکی نیستند، دیپلویِ دوم که همه‌چیز را تکراری
کند، و ردیفِ نمونه‌ای که از دیتابیسِ لوکالِ ما سر از production دربیاورد.
"""
import json
import tempfile
from pathlib import Path
from unittest import mock
from django.core.management import call_command
from django.core.management.base import CommandError
from django.test import TestCase
from content.management.commands import seed_content
from content.models import Choice, Grade, Lesson, Node, Question, Unit
CURRICULUM = {
'grades': [{
'title': 'پایه نهم', 'icon': 'grade-9', 'color': '#FF9800',
'units': [{
'title': 'فصل ۴: توان و ریشه', 'order': 4,
'nodes': [{
'title': 'توان صحیح', 'order': 1,
'lessons': [
{'title': 'ضرب توان‌ها', 'introText': 'پایه‌ها را نگه دار.'},
{'title': 'تقسیم توان‌ها', 'introText': ''},
],
}],
}],
}],
}
QUESTIONS = {
'questions': [{
'path': ['پایه نهم', 'فصل ۴: توان و ریشه', 'توان صحیح', 'تقسیم توان‌ها'],
'prompt': 'حاصل ۲^۵ ÷ ۲^۳ چند است؟',
'type': 'multiple_choice',
'choices': ['۴', '۸', '۱۶'],
'correct': 0,
}],
}
class SeedContentTests(TestCase):
def setUp(self):
self.dir = Path(tempfile.mkdtemp())
self.curriculum_file = self.dir / 'curriculum.json'
self.questions_file = self.dir / 'questions.json'
self._write(CURRICULUM, QUESTIONS)
patches = [
mock.patch.object(seed_content, 'SEED_DIR', self.dir),
mock.patch.object(seed_content, 'CURRICULUM_FILE', self.curriculum_file),
mock.patch.object(seed_content, 'QUESTIONS_FILE', self.questions_file),
]
for patch in patches:
patch.start()
self.addCleanup(patch.stop)
def _write(self, curriculum, questions):
self.curriculum_file.write_text(
json.dumps(curriculum, ensure_ascii=False), encoding='utf-8')
self.questions_file.write_text(
json.dumps(questions, ensure_ascii=False), encoding='utf-8')
def test_loads_curriculum_and_questions_into_empty_database(self):
call_command('seed_content')
self.assertEqual(Grade.objects.count(), 1)
self.assertEqual(Lesson.objects.count(), 2)
question = Question.objects.get()
self.assertEqual(question.lesson.title, 'تقسیم توان‌ها')
self.assertEqual(question.choices.count(), 3)
self.assertEqual(question.choices.get(is_correct=True).value, '۴')
def test_second_run_changes_nothing(self):
"""دیپلویِ دوم نباید چیزی را تکراری کند — این کلِ ایمنیِ خودکار بودنش است."""
call_command('seed_content')
call_command('seed_content')
self.assertEqual(Grade.objects.count(), 1)
self.assertEqual(Lesson.objects.count(), 2)
self.assertEqual(Question.objects.count(), 1)
self.assertEqual(Choice.objects.count(), 3)
def test_question_follows_the_title_path_not_the_id(self):
"""
بازتولیدِ همان تله‌ای که این فایل برایش ساخته شد: روی سرورِ خالی idها
از ۱ شروع می‌شوند، ولی لوکالِ ما idهای پراکنده دارد. اگر سوال با id
وصل می‌شد، اینجا به درسِ اشتباه می‌چسبید — بی هیچ خطایی.
"""
grade = Grade.objects.create(title='پایه دیگر', icon='', color='')
unit = Unit.objects.create(grade=grade, title='فصل پرت', order=1)
node = Node.objects.create(unit=unit, title='مبحث پرت', order=1)
for index in range(5):
Lesson.objects.create(node=node, title=f'درسِ پرت {index}')
call_command('seed_content')
question = Question.objects.get()
self.assertEqual(question.lesson.title, 'تقسیم توان‌ها')
self.assertEqual(question.lesson.node.unit.grade.title, 'پایه نهم')
def test_unknown_path_fails_loudly_and_writes_nothing(self):
self._write(CURRICULUM, {'questions': [{
**QUESTIONS['questions'][0],
'path': ['پایه نهم', 'فصل ۴: توان و ریشه', 'توان صحیح', 'درسی که نیست'],
}]})
with self.assertRaises(CommandError):
call_command('seed_content')
self.assertEqual(Grade.objects.count(), 0)
self.assertEqual(Question.objects.count(), 0)
def test_dry_run_reports_without_writing(self):
call_command('seed_content', '--dry-run')
self.assertEqual(Grade.objects.count(), 0)
self.assertEqual(Question.objects.count(), 0)
def test_editing_a_seeded_question_survives_the_next_deploy(self):
"""
ادمین باید بتواند سوالِ سیدشده را اصلاح کند بدونِ اینکه دیپلویِ بعدی
نسخه‌ی خودش را برگرداند یا کنارش یک تکراری بسازد.
"""
call_command('seed_content')
question = Question.objects.get()
question.choices.filter(is_correct=True).update(value='چهار')
call_command('seed_content')
self.assertEqual(Question.objects.count(), 1)
self.assertEqual(question.choices.get(is_correct=True).value, 'چهار')
def test_export_leaves_out_demo_rows(self):
"""
[DEMO]/[SEED] در دیتابیسِ لوکال زندگی می‌کنند و نباید در فایلی بنشینند
که مستقیم روی production لود می‌شود.
"""
demo = Grade.objects.create(title='[DEMO] پایهٔ نمونه', icon='', color='')
demo_unit = Unit.objects.create(grade=demo, title='واحد ۱ — جمع', order=1)
demo_node = Node.objects.create(unit=demo_unit, title='جمع تا ۱۰', order=1)
demo_lesson = Lesson.objects.create(node=demo_node, title='گرم‌کردن')
demo_question = Question.objects.create(
lesson=demo_lesson, type='multiple_choice', prompt='۲ + ۳ = ?')
Choice.objects.create(question=demo_question, value='۵', is_correct=True)
Choice.objects.create(question=demo_question, value='۴', is_correct=False)
call_command('seed_content') # یک پایه‌ی واقعی هم وجود داشته باشد
call_command('seed_content', '--export')
written = json.loads(self.curriculum_file.read_text(encoding='utf-8'))
titles = [grade['title'] for grade in written['grades']]
self.assertEqual(titles, ['پایه نهم'])
rows = json.loads(self.questions_file.read_text(encoding='utf-8'))['questions']
self.assertTrue(all(row['path'][0] == 'پایه نهم' for row in rows))
def test_export_then_load_round_trips(self):
call_command('seed_content')
call_command('seed_content', '--export')
Question.objects.all().delete()
Grade.objects.all().delete()
call_command('seed_content')
self.assertEqual(Lesson.objects.count(), 2)
self.assertEqual(Question.objects.get().lesson.title, 'تقسیم توان‌ها')
class SeedFilesInRepoTests(TestCase):
"""فایل‌هایی که واقعاً کامیت شده‌اند باید سالم و بی‌ردیفِ نمونه باشند."""
def test_committed_curriculum_parses_and_has_no_demo_rows(self):
from content import curriculum_io
grades = curriculum_io.parse(seed_content.CURRICULUM_FILE.read_bytes())
self.assertTrue(grades)
for grade in grades:
self.assertFalse(grade['title'].startswith(('[DEMO]', '[SEED]')))
def test_committed_questions_all_point_at_a_real_lesson(self):
from content import question_io
rows = json.loads(
seed_content.QUESTIONS_FILE.read_text(encoding='utf-8'))['questions']
if not rows:
self.skipTest('هنوز سوالِ واقعی‌ای در ریپو نیست.')
call_command('seed_content')
question_io.resolve_paths(rows) # مسیرِ حل‌نشده خطا می‌دهد