Finishing a lesson or island needs recorded answers and an unlocked island; auto DB backup before migrate; throttles on 15 more routes

- content: POST nodes/{id}/quiz/complete and lessons/{id}/complete refuse a locked island
  (403 node_locked, same rule as /api/home) and a user who has not answered every question
  (409 answers_missing). Before, only a user with zero hearts and no answers was refused, so
  anyone else finished any island, locked or not, and finishing a locked island's lessons
  unlocked it. A node or lesson with no questions still passes when unlocked, with no XP.
  The client can no longer understate totalQuestions to buy stars.
- throttle_scope on 15 routes that had none (question bank without login, intro, hearts,
  notifications, tickets, plans, subscription status, orders, offers, affiliate, assistant
  history and feedback, logout).
- admin: OTPCode and Session can only be added/changed by a superuser.
- deploy.sh: integrity-checked DB backup before migrate (keeps the last 2); no migrate without it.
- docs and CLAUDE.md updated to say what is verified and what is not.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 13:30:24 +03:30
parent 7e3854098f
commit 7ecb337263
26 changed files with 688 additions and 69 deletions

View File

@@ -188,6 +188,67 @@ if [ -d .deploy-backups ]; then
fi
printf ' دیتابیس و بکاپ‌ها فقط برای %s خواندنی‌اند.\n' "$(id -un)"
# ─── ۳.۷ بکاپِ دیتابیس پیش از migrate ──────────────────────────────────────
# migrate تنها مرحله‌ای است که داده را برنمی‌گشت‌پذیر عوض می‌کند، پس درست پیش از آن بکاپ
# می‌گیریم؛ تا اینجا بکاپ دستی بود و در یک انتشارِ بی‌بکاپ هیچ راهی به عقب نبود.
#
# با sqlite3.Connection.backup() نه cp: کپیِ ساده وسطِ یک نوشتن فایلِ نیمه‌کاره می‌دهد (sqlite3
# به‌عنوانِ فرمان روی سرور نیست). اگر بکاپ نشد، migrate اجرا نمی‌شود: دیپلوی بی‌بکاپ را
# عمداً نمی‌پذیریم. فقط ۲ بکاپِ خودکارِ آخر (db-before-deploy-*: همین انتشار و یکی قبل‌تر) نگه داشته می‌شود تا دیسکِ
# مشترک پر نشود؛ بکاپ‌های دستی با نامِ دیگر دست نمی‌خورند.
# آزموده‌نشده: Postgres (بعد از مهاجرت باید pg_dump جایگزین شود؛ اینجا فقط هشدار می‌دهد)،
# و بکاپ از بیرونِ ماشین که هنوز نیست (بند ۱۷ در چک‌لیستِ لانچ).
step "بکاپِ دیتابیس"
python - <<'PYBACKUP' || fail "بکاپِ دیتابیس نشد؛ migrate اجرا نشد و هیچ تغییری روی داده اعمال نشد."
import datetime
import glob
import os
import shutil
import sqlite3
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.conf import settings
db = settings.DATABASES['default']
if db['ENGINE'] != 'django.db.backends.sqlite3':
print(f" ⚠ موتورِ دیتابیس {db['ENGINE']} است؛ بکاپِ خودکار فقط SQLite را پوشش می‌دهد. خودتان بکاپ بگیرید.")
raise SystemExit(0)
src_path = str(db['NAME'])
if not os.path.exists(src_path):
print(' دیتابیس هنوز وجود ندارد (نصبِ تازه)؛ چیزی برای بکاپ نیست.')
raise SystemExit(0)
os.makedirs('.deploy-backups', mode=0o700, exist_ok=True)
need = os.path.getsize(src_path) * 2
free = shutil.disk_usage('.deploy-backups').free
if free < need:
raise SystemExit(f' فضای خالی ({free // 2**20} MB) کمتر از دو برابرِ دیتابیس ({need // 2**20} MB) است.')
dst = '.deploy-backups/db-before-deploy-%s.sqlite3' % datetime.datetime.now().strftime('%Y%m%d-%H%M%S')
src, out = sqlite3.connect(src_path), sqlite3.connect(dst)
with out:
src.backup(out)
src.close()
out.close()
os.chmod(dst, 0o600)
check = sqlite3.connect(dst)
result = check.execute('PRAGMA integrity_check').fetchone()[0]
check.close()
if result != 'ok':
raise SystemExit(f' بکاپ ساخته شد ولی integrity_check «{result}» داد: {dst}')
print(f' {dst} ({os.path.getsize(dst) // 2**20} MB، integrity_check: ok)')
old = sorted(glob.glob('.deploy-backups/db-before-deploy-*.sqlite3'))[:-2]
for path in old:
os.remove(path)
if old:
print(f' {len(old)} بکاپِ خودکارِ قدیمی‌تر پاک شد (۲ تای آخر می‌ماند).')
PYBACKUP
# ─── ۴. دیتابیس ────────────────────────────────────────────────────────────
step "اعمالِ مهاجرت‌های دیتابیس"
python manage.py migrate --noinput || fail "migrate شکست خورد؛ سرویس ری‌استارت نشد."