Compare commits

..

2 Commits

Author SHA1 Message Date
ca983d237f give every topic a starter question so the app can be clicked through
تا امروز کوریکولوم بود و سوال نبود: هر ۱۴ سوالِ دیتابیسِ لوکال روی ردیف‌های
[DEMO] نشسته بودند و در export کنار گذاشته می‌شدند. یعنی سینیور می‌توانست در
مرورگرِ محتوا پایین برود ولی هیچ درسی آزمونی نداشت — که از «محتوا نداریم»
بدتر است، چون شبیهِ خرابی به‌نظر می‌رسد.

حالا هر ۱۶۸ مبحث دستِ‌کم یک سوالِ چهارگزینه‌ای دارد: ۲۳ برای پایه نهم، ۴۶ ششم،
۴۶ هفتم، ۵۳ سوم. هر سوال به درسی وصل است که عنوانش همان موضوع را می‌گوید، نه
به یک درسِ دلخواه.

جای گزینهٔ درست با seed ثابت جابه‌جا شده (۴۴/۴۹/۳۵/۴۰ در چهار موقعیت). اگر
جوابِ درست همیشه گزینهٔ اول باشد، آزمون دیگر چیزی را نمی‌سنجد و آمارِ
«درصدِ غلط» هم بی‌معنا می‌شود. یک تست همین را نگه می‌دارد.

این‌ها نمونه‌اند نه محتوای نهایی: یک سوال برای هر مبحث فقط نشان می‌دهد مسیرِ
درس → آزمون → XP کار می‌کند. سوال‌های واقعیِ هر درس کارِ
`generate_questions_from_chunks` است که به اعتبارِ AI نیاز دارد، و از پنلِ
ادمین هم می‌شود دستی اضافه کرد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:42:14 +03:30
9c3db68cbb 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>
2026-09-14 20:20:26 +03:30
6 changed files with 9766 additions and 3 deletions

View File

@@ -0,0 +1,143 @@
"""
بالا آوردنِ محتوای پایه از فایل‌هایی که در خودِ ریپو کامیت شده‌اند.
چرا: تا امروز تنها راهِ داشتنِ محتوا روی یک سرورِ تازه، اجرای
`rag_import_collection` و بعد `seed_curriculum_from_textbook` روی همان سرور
بود — یعنی وابستگی به Qdrant، به اعتبارِ AI، و به آدمی با دسترسیِ SSH. نتیجه
این شد که production ماه‌هاست `{"grades": []}` برمی‌گرداند و هیچ‌کس نمی‌تواند
چیزی را تست کند.
محتوا یک‌بار لوکال ساخته می‌شود، با `--export` در ریپو می‌نشیند، و از آن پس هر
دیپلوی خودش آن را بالا می‌آورد. سرورِ تازه نه Qdrant می‌خواهد نه ترمینال.
python manage.py seed_content # وارد کردن (در deploy.sh)
python manage.py seed_content --dry-run # فقط گزارش، بدونِ نوشتن
python manage.py seed_content --export # گرفتنِ خروجی از دیتابیسِ فعلی
ایمن برای اجرای تکراری: کوریکولوم با عنوان هم‌سان‌سازی می‌شود و سوالِ تکراری
رد می‌شود. هیچ‌چیز پاک نمی‌شود — روی سروری که کاربرِ واقعی دارد، دیپلوی نباید
بتواند محتوا یا پاسخی را از بین ببرد.
"""
import json
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from content import curriculum_io, question_io
from content.models import Grade, Lesson, Question
# ردیف‌های نمونه هرگز نباید در فایلِ ریپو بنشینند: این فایل مستقیم روی
# production لود می‌شود و «[DEMO] پایهٔ نمونه» همان چیزی است که در
# `/api/grades` جلوِ چشمِ کاربرِ واقعی می‌آید. purge_demo_data دقیقاً برای
# پاک کردنشان نوشته شد؛ اینجا از همان ابتدا بیرون گذاشته می‌شوند.
DEMO_PREFIXES = ('[SEED]', '[DEMO]')
SEED_DIR = Path(__file__).resolve().parents[2] / 'seed'
CURRICULUM_FILE = SEED_DIR / 'curriculum.json'
QUESTIONS_FILE = SEED_DIR / 'questions.json'
class Command(BaseCommand):
help = 'وارد کردنِ محتوای پایه از content/seed/ (ایدمپوتنت، بدونِ حذف).'
def add_arguments(self, parser):
parser.add_argument('--export', action='store_true',
help='به‌جای وارد کردن، از دیتابیسِ فعلی خروجی بگیر.')
parser.add_argument('--dry-run', action='store_true',
help='فقط گزارش بده؛ چیزی ننویس.')
def handle(self, *args, **options):
if options['export']:
return self._export()
return self._load(dry_run=options['dry_run'])
# ─── خروجی گرفتن ────────────────────────────────────────────────────────
def _export(self):
SEED_DIR.mkdir(exist_ok=True)
grades = Grade.objects.order_by('pk')
questions = Question.objects.order_by('pk')
for prefix in DEMO_PREFIXES:
grades = grades.exclude(title__startswith=prefix)
questions = questions.exclude(
lesson__node__unit__grade__title__startswith=prefix
)
curriculum = curriculum_io.export_curriculum(grades)
rows = question_io.export_questions_by_path(questions)
_write_json(CURRICULUM_FILE, curriculum)
_write_json(QUESTIONS_FILE, {'questions': rows})
lessons = Lesson.objects.filter(node__unit__grade__in=grades).count()
self.stdout.write(self.style.SUCCESS(
f'نوشته شد: {CURRICULUM_FILE.name} '
f'({len(curriculum["grades"])} پایه، {lessons} درس) '
f'و {QUESTIONS_FILE.name} ({len(rows)} سوال)'
))
skipped = Grade.objects.count() - grades.count()
if skipped:
self.stdout.write(
f' {skipped} پایه‌ی نمونه ([DEMO]/[SEED]) کنار گذاشته شد.'
)
if not rows:
self.stdout.write(self.style.WARNING(
' هشدار: هیچ سوالِ واقعی‌ای برای خروجی نبود — '
'برنامه‌ی درسی بدونِ سوال منتشر می‌شود.'
))
# ─── وارد کردن ──────────────────────────────────────────────────────────
def _load(self, *, dry_run: bool):
if not CURRICULUM_FILE.exists():
raise CommandError(
f'{CURRICULUM_FILE} نیست. اول با --export خروجی بگیرید.'
)
try:
grades = curriculum_io.parse(CURRICULUM_FILE.read_bytes())
except curriculum_io.CurriculumError as exc:
raise CommandError(f'{CURRICULUM_FILE.name}: {exc}')
# dry-run با rollback انجام می‌شود، نه با شمارشِ تقریبی: تنها چیزی که
# واقعاً می‌گوید چه اتفاقی می‌افتد، خودِ اتفاق است.
try:
with transaction.atomic():
counts = curriculum_io.import_curriculum(grades)
question_counts = self._load_questions()
if dry_run:
transaction.set_rollback(True)
except (curriculum_io.CurriculumError, question_io.ImportError_) as exc:
raise CommandError(str(exc))
prefix = 'اگر اجرا شود: ' if dry_run else ''
self.stdout.write(self.style.SUCCESS(
f'{prefix}'
f'{counts["grades"]} پایه، {counts["units"]} فصل، '
f'{counts["nodes"]} مبحث، {counts["lessons"]} درسِ تازه '
f'({counts["updated"]} درس به‌روز شد)؛ '
f'{question_counts["created"]} سوالِ تازه '
f'({question_counts["skipped"]} سوال از قبل بود)'
))
def _load_questions(self) -> dict:
if not QUESTIONS_FILE.exists():
return {'created': 0, 'skipped': 0}
raw = json.loads(QUESTIONS_FILE.read_text(encoding='utf-8'))
rows = raw.get('questions', raw) if isinstance(raw, dict) else raw
if not rows:
return {'created': 0, 'skipped': 0}
# مسیرِ عنوان → lessonId، سپس همان اعتبارسنجیِ همیشگیِ question_io.
resolved = question_io.resolve_paths(rows)
parsed = question_io.parse(json.dumps(resolved).encode('utf-8'))
return question_io.import_questions(parsed, skip_existing=True)
def _write_json(path: Path, payload) -> None:
# ensure_ascii=False تا فارسی در دیفِ گیت خوانا بماند، و newline انتهایی
# تا هر بار export یک دیفِ تمیز بدهد نه یک خطِ تغییرکرده‌ی الکی.
path.write_text(
json.dumps(payload, ensure_ascii=False, indent=1) + '\n',
encoding='utf-8',
)

View File

@@ -91,12 +91,19 @@ def parse(raw: bytes) -> list[dict]:
@transaction.atomic @transaction.atomic
def import_questions(rows: list[dict], *, replace: bool = False) -> dict: def import_questions(
rows: list[dict], *, replace: bool = False, skip_existing: bool = False,
) -> dict:
""" """
همه‌ی ردیف‌ها یا با هم نوشته می‌شوند یا هیچ‌کدام — نیمه‌کاره نمی‌ماند. همه‌ی ردیف‌ها یا با هم نوشته می‌شوند یا هیچ‌کدام — نیمه‌کاره نمی‌ماند.
replace=True یعنی سوال‌های قبلیِ همان درس‌ها اول پاک شوند؛ بدونِ آن replace=True یعنی سوال‌های قبلیِ همان درس‌ها اول پاک شوند؛ بدونِ آن
سوال‌ها اضافه می‌شوند. سوال‌ها اضافه می‌شوند.
skip_existing=True یعنی سوالی که همین صورت را در همین درس دارد دوباره
ساخته نشود. این چیزی است که وارد کردنِ فایلِ ثابتِ ریپو را در هر دیپلوی
بی‌خطر می‌کند: بدونِ آن، دیپلویِ دوم همه‌ی سوال‌ها را تکراری می‌سازد، و با
replace به‌جایش پاسخ‌های کاربران هم آبشاری پاک می‌شوند.
""" """
lesson_ids = {row['lesson_id'] for row in rows} lesson_ids = {row['lesson_id'] for row in rows}
existing = set(Lesson.objects.filter(pk__in=lesson_ids).values_list('pk', flat=True)) existing = set(Lesson.objects.filter(pk__in=lesson_ids).values_list('pk', flat=True))
@@ -114,8 +121,18 @@ def import_questions(rows: list[dict], *, replace: bool = False) -> dict:
_, per_model = Question.objects.filter(lesson_id__in=lesson_ids).delete() _, per_model = Question.objects.filter(lesson_id__in=lesson_ids).delete()
removed = per_model.get('content.Question', 0) removed = per_model.get('content.Question', 0)
created = 0 already = set()
if skip_existing:
already = set(
Question.objects.filter(lesson_id__in=lesson_ids)
.values_list('lesson_id', 'prompt')
)
created = skipped = 0
for row in rows: for row in rows:
if (row['lesson_id'], row['prompt']) in already:
skipped += 1
continue
question = Question.objects.create( question = Question.objects.create(
lesson_id=row['lesson_id'], type=row['type'], prompt=row['prompt'], lesson_id=row['lesson_id'], type=row['type'], prompt=row['prompt'],
) )
@@ -124,8 +141,14 @@ def import_questions(rows: list[dict], *, replace: bool = False) -> dict:
for i, value in enumerate(row['choices']) for i, value in enumerate(row['choices'])
]) ])
created += 1 created += 1
if skip_existing:
# ردیفِ تکراری در خودِ فایل هم نباید دو بار ساخته شود.
already.add((row['lesson_id'], row['prompt']))
return {'created': created, 'removed': removed, 'lessons': len(lesson_ids)} return {
'created': created, 'removed': removed,
'lessons': len(lesson_ids), 'skipped': skipped,
}
def export_questions(queryset) -> list[dict]: def export_questions(queryset) -> list[dict]:
@@ -142,3 +165,73 @@ def export_questions(queryset) -> list[dict]:
'correct': correct, 'correct': correct,
}) })
return out return out
# ─── سوال‌ها بر پایه‌ی مسیرِ عنوان ───────────────────────────────────────────
#
# `lessonId` برای کارِ روزمره‌ی ادمین خوب است (کاربر درس را از همان صفحه
# برمی‌دارد و id جلوِ چشمش است)، ولی برای فایلی که در ریپو کامیت می‌شود و روی
# سرورِ دیگری لود خواهد شد اشتباهِ خطرناکی است: idها بینِ دو دیتابیس یکی
# نیستند. لوکالِ ما ۱۳۰۷ درس دارد با idهای ۳ تا ۲۰۰۵ (جای خالی، از پاک شدنِ
# دیتای دمو)، و همان کوریکولوم روی یک دیتابیسِ خالی idهای ۱ تا ۱۳۰۷ می‌گیرد.
# پس سوالِ درسِ ۳ روی سرور به درسِ بی‌ربطی می‌چسبد — بدونِ هیچ خطایی.
#
# مسیرِ عنوان (پایه › فصل › مبحث › درس) همان چیزی است که curriculum_io هم با
# آن هم‌سان‌سازی می‌کند، پس هر دو فایل به یک زبان حرف می‌زنند.
def export_questions_by_path(queryset) -> list[dict]:
"""همان قالبِ export_questions، ولی با مسیرِ عنوان به‌جای lessonId."""
out = []
for question in queryset.prefetch_related('choices').select_related(
'lesson__node__unit__grade'
):
choices = list(question.choices.all())
correct = next((i for i, c in enumerate(choices) if c.is_correct), 0)
lesson = question.lesson
node = lesson.node
out.append({
'path': [node.unit.grade.title, node.unit.title, node.title, lesson.title],
'prompt': question.prompt,
'type': question.type,
'choices': [c.value for c in choices],
'correct': correct,
})
return out
def resolve_paths(rows: list[dict]) -> list[dict]:
"""
مسیرهای عنوان را به lesson_id تبدیل می‌کند.
اگر مسیری پیدا نشود خطا می‌دهد و هیچ ردیفی برنمی‌گرداند: مسیرِ گم‌شده یعنی
فایلِ سوال‌ها و فایلِ کوریکولوم با هم نمی‌خوانند، و وارد کردنِ بقیه یعنی
محتوایی ناقص که کسی متوجهش نمی‌شود.
"""
lessons = {
(lesson.node.unit.grade.title, lesson.node.unit.title,
lesson.node.title, lesson.title): lesson.pk
for lesson in Lesson.objects.select_related('node__unit__grade')
}
resolved, missing = [], []
for index, row in enumerate(rows):
path = row.get('path')
if not isinstance(path, list) or len(path) != 4:
raise ImportError_(
f'ردیف {index + 1}: «path» باید چهار عنوان باشد '
'(پایه، فصل، مبحث، درس).'
)
key = tuple(str(part).strip() for part in path)
lesson_id = lessons.get(key)
if lesson_id is None:
missing.append(' › '.join(key))
continue
resolved.append({**{k: v for k, v in row.items() if k != 'path'},
'lessonId': lesson_id})
if missing:
raise ImportError_(
'این درس‌ها در برنامه‌ی درسی پیدا نشدند (اول کوریکولوم را وارد کنید): '
+ '؛ '.join(missing[:5]) + ('…' if len(missing) > 5 else '')
)
return resolved

6426
content/seed/curriculum.json Normal file

File diff suppressed because it is too large Load Diff

2860
content/seed/questions.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,231 @@
"""
تست‌های `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) # مسیرِ حل‌نشده خطا می‌دهد
def test_committed_questions_are_well_formed(self):
rows = json.loads(
seed_content.QUESTIONS_FILE.read_text(encoding='utf-8'))['questions']
if not rows:
self.skipTest('هنوز سوالِ واقعی‌ای در ریپو نیست.')
for index, row in enumerate(rows):
where = f'سوال {index + 1}'
self.assertTrue(row['prompt'].strip(), where)
self.assertEqual(len(row['choices']), 4, where)
self.assertEqual(len(set(row['choices'])), 4, where)
self.assertIn(row['correct'], range(4), where)
def test_every_topic_has_at_least_one_question(self):
"""
هدفِ این مجموعه «نمونه در چند جا» نیست: هر مبحث باید دستِ‌کم یک سوال
داشته باشد، وگرنه ادمین در مرورگرِ محتوا به ستون‌های خالی می‌رسد و
نمی‌داند محتوا نیست یا چیزی خراب است.
"""
call_command('seed_content')
empty = Node.objects.filter(lessons__questions__isnull=True).distinct()
empty = [node for node in empty if not Question.objects.filter(
lesson__node=node).exists()]
self.assertEqual(empty, [], f'{len(empty)} مبحث بدونِ سوال ماند.')
def test_the_right_answer_is_not_always_the_first_choice(self):
"""جوابِ درست اگر همیشه گزینهٔ اول باشد، آزمون سنجشِ چیزی نیست."""
rows = json.loads(
seed_content.QUESTIONS_FILE.read_text(encoding='utf-8'))['questions']
if len(rows) < 10:
self.skipTest('برای این بررسی سوالِ کافی نیست.')
positions = {row['correct'] for row in rows}
self.assertGreater(len(positions), 2)

View File

@@ -114,6 +114,16 @@ PYCHECK
step "اعمالِ مهاجرت‌های دیتابیس" step "اعمالِ مهاجرت‌های دیتابیس"
python manage.py migrate --noinput || fail "migrate شکست خورد؛ سرویس ری‌استارت نشد." python manage.py migrate --noinput || fail "migrate شکست خورد؛ سرویس ری‌استارت نشد."
# محتوا هم مثلِ کد از ریپو می‌آید (content/seed/). بدونِ این، یک سرورِ تازه
# دیتابیسِ خالی دارد و `/api/grades` جوابِ `{"grades": []}` می‌دهد — یعنی هر
# صفحه‌ی اپ خالی بالا می‌آید و به‌نظر خراب می‌رسد.
#
# ایدمپوتنت و بدونِ حذف است، پس اجرای هر باره بی‌خطر است. شکستش دیپلوی را
# نمی‌خواباند: کدِ تازه بهتر از کدِ قدیمی است حتی اگر سید نخورده باشد.
step "محتوای پایه"
python manage.py seed_content 2>&1 | sed 's/^/ /' \
|| warn "seed_content شکست خورد؛ کد به‌روز شد ولی محتوا دست‌نخورده ماند."
# ─── ۵. فایل‌های استاتیک ─────────────────────────────────────────────────── # ─── ۵. فایل‌های استاتیک ───────────────────────────────────────────────────
# فقط در حالتِ پروداکشن لازم است: با DEBUG=1 خودِ whitenoise از finders می‌خواند. # فقط در حالتِ پروداکشن لازم است: با DEBUG=1 خودِ whitenoise از finders می‌خواند.
step "جمع‌آوریِ فایل‌های استاتیک" step "جمع‌آوریِ فایل‌های استاتیک"