- seed_content no longer undoes admin work: a SeedRecord ledger (content/seed_sync.py) stops deleted/renamed/re-worded content from being recreated and keeps admin-edited lesson text; new rows in the seed files still arrive (migration content.0010) - plan list cache is invalidated on Plan save (receiver used a stale 'v1' key) - token refresh per-IP cap 30/hour -> 600/hour (TOKEN_REFRESH_IP_RATE) - a failed gateway result only changes a pending order (forged NOK no longer overwrites refunded/canceled) - tutor gets the correct answer only after the student has answered - support reply notification moved to a TicketMessage post_save signal - global daily cap on paid AI questions (AI_GLOBAL_DAILY_MAX, default 3000) - Bazaar verify uses only the order's SKU; bazaar orders require a SKU - /api/health warns on the committed signing key; deploy.sh saves local commits; 400 for bad ?unit= / ?period=; account delete keeps the referrer's Conversion - earlier today: no stub answers outside DEBUG, ?grade= removed, logged fallbacks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
170 lines
9.0 KiB
Python
170 lines
9.0 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, 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',
|
|
)
|