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