Files
MathApp/content/management/commands/seed_content.py
Zareian 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

144 lines
7.3 KiB
Python

"""
بالا آوردنِ محتوای پایه از فایل‌هایی که در خودِ ریپو کامیت شده‌اند.
چرا: تا امروز تنها راهِ داشتنِ محتوا روی یک سرورِ تازه، اجرای
`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',
)