""" بالا آوردنِ محتوای پایه از فایل‌هایی که در خودِ ریپو کامیت شده‌اند. چرا: تا امروز تنها راهِ داشتنِ محتوا روی یک سرورِ تازه، اجرای `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, seed_sync 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('--only', choices=('curriculum', 'questions'), default=None, help='فقط یکی از دو فایل را خروجی بگیر.') parser.add_argument('--dry-run', action='store_true', help='فقط گزارش بده؛ چیزی ننویس.') def handle(self, *args, **options): self.only = options.get('only') 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) # محافظ در برابرِ پاک شدنِ خاموشِ سوال‌ها: فایلِ سوال‌ها دست‌نویس است و # ممکن است در دیتابیسِ این ماشین نباشد (مثلاً وقتی فقط کوریکولوم را # محلی ساخته‌ایم). یک export بی‌احتیاط آن را با آرایه‌ی خالی بازنویسی # می‌کرد و کلِ کار از بین می‌رفت — بدونِ خطا. only = self.only if only != 'questions': _write_json(CURRICULUM_FILE, curriculum) if only != 'curriculum': if not rows and _has_questions(QUESTIONS_FILE): raise CommandError( f'{QUESTIONS_FILE.name} سوال دارد ولی دیتابیسِ این ماشین ندارد؛ ' 'بازنویسی‌اش یعنی پاک شدنشان. اگر کوریکولوم را می‌خواهید: ' '--export --only curriculum' ) _write_json(QUESTIONS_FILE, {'questions': rows}) lessons = Lesson.objects.filter(node__unit__grade__in=grades).count() written = [] if only != 'questions': written.append(f'{CURRICULUM_FILE.name} ' f'({len(curriculum["grades"])} پایه، {lessons} درس)') if only != 'curriculum': written.append(f'{QUESTIONS_FILE.name} ({len(rows)} سوال)') self.stdout.write(self.style.SUCCESS('نوشته شد: ' + ' و '.join(written))) skipped = Grade.objects.count() - grades.count() if skipped: self.stdout.write( f' {skipped} پایه‌ی نمونه ([DEMO]/[SEED]) کنار گذاشته شد.' ) if not rows and only != 'curriculum': 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 انجام می‌شود، نه با شمارشِ تقریبی: تنها چیزی که # واقعاً می‌گوید چه اتفاقی می‌افتد، خودِ اتفاق است. # seed_sync کارِ ادمین (حذف، تغییرِ نام، اصلاحِ متن) را برنمی‌گرداند؛ شرح در خودِ ماژول. try: with transaction.atomic(): counts = seed_sync.sync(grades, self._question_rows()) 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'{counts["created"]} سوالِ تازه ' f'({counts["skipped"]} سوال از قبل بود)' )) if counts['retired'] or counts['kept_admin_text']: self.stdout.write( f' {counts["retired"]} موردِ فایل ساخته نشد چون ادمین پاکش کرده یا نامش را عوض کرده؛ ' f'{counts["kept_admin_text"]} متنِ درس که ادمین ویرایش کرده دست نخورد.' ) def _question_rows(self) -> list: if not QUESTIONS_FILE.exists(): return [] raw = json.loads(QUESTIONS_FILE.read_text(encoding='utf-8')) return (raw.get('questions', raw) if isinstance(raw, dict) else raw) or [] def _has_questions(path: Path) -> bool: if not path.exists(): return False try: data = json.loads(path.read_text(encoding='utf-8')) except json.JSONDecodeError: return False return bool(data.get('questions') if isinstance(data, dict) else data) 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', )