Compare commits
2 Commits
f3be99e4e2
...
ca983d237f
| Author | SHA1 | Date | |
|---|---|---|---|
| ca983d237f | |||
| 9c3db68cbb |
143
content/management/commands/seed_content.py
Normal file
143
content/management/commands/seed_content.py
Normal 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',
|
||||||
|
)
|
||||||
@@ -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
6426
content/seed/curriculum.json
Normal file
File diff suppressed because it is too large
Load Diff
2860
content/seed/questions.json
Normal file
2860
content/seed/questions.json
Normal file
File diff suppressed because it is too large
Load Diff
231
content/tests_seed_content.py
Normal file
231
content/tests_seed_content.py
Normal 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)
|
||||||
10
deploy.sh
10
deploy.sh
@@ -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 "جمعآوریِ فایلهای استاتیک"
|
||||||
|
|||||||
Reference in New Issue
Block a user