سرورِ 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>
144 lines
7.3 KiB
Python
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',
|
|
)
|