Files
MathApp/deploy.sh
Zareian ab5e53730a Audit fixes 2026-10-03: seed keeps admin edits, plan cache, refresh limit, payment and tutor guards
- 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>
2026-10-03 16:05:27 +03:30

316 lines
20 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
#
# دیپلویِ تدریسینو — یک دستور، نه یک فهرستِ کار.
#
# ./deploy.sh
#
# چرا این فایل وجود دارد: تا امروز بالا آوردنِ نسخه‌ی جدید یعنی چند کارِ دستی
# به ترتیبِ درست (pull، نصبِ وابستگی‌ها، مهاجرت، فایل‌های استاتیک، ری‌استارت).
# فراموش شدنِ هر کدام، اپ را می‌خواباند و خطایش هم گویا نیست — مثلاً اضافه شدنِ
# یک پکیجِ جدید بدونِ `pip install` باعثِ ModuleNotFoundError موقعِ بالا آمدن
# می‌شود و کاربر فقط «502 Bad Gateway» می‌بیند. این اسکریپت همه را به ترتیب و
# idempotent انجام می‌دهد و آخرش خودش بررسی می‌کند که واقعاً بالا آمده یا نه.
#
# متغیرهای اختیاری:
# TADRISINO_SERVICE نامِ سرویسِ systemd برای ری‌استارت (مثلاً tadrisino)
# TADRISINO_HEALTH_URL آدرسی که در پایان بررسی می‌شود
# SKIP_PULL=1 بدونِ git pull (وقتی کد را دستی گذاشته‌اید)
set -euo pipefail
cd "$(dirname "$0")"
C_OK=$'\033[0;32m'; C_ERR=$'\033[0;31m'; C_WARN=$'\033[0;33m'; C_OFF=$'\033[0m'
step() { printf '\n%s▸ %s%s\n' "$C_OK" "$1" "$C_OFF"; }
warn() { printf '%s⚠ %s%s\n' "$C_WARN" "$1" "$C_OFF"; }
fail() { printf '%s✖ %s%s\n' "$C_ERR" "$1" "$C_OFF" >&2; exit 1; }
# اگر TADRISINO_HEALTH_URL ست نباشد چند آدرس امتحان می‌شود: پورتِ محلی ممکن
# است فرق کند (سوکتِ یونیکس، پورتِ دیگر)، و در آن حالت دیپلویِ موفق هم پیامِ
# خطا می‌گرفت. آدرسِ عمومی نشان می‌دهد سرویس واقعاً از بیرون جواب می‌دهد؛ آن
# را پایین‌تر، بعد از فعال شدنِ venv، از خودِ تنظیماتِ جنگو (PUBLIC_BASE_URL)
# می‌خوانیم تا با عوض شدنِ دامنه این فایل دست نخورد.
if [ -n "${TADRISINO_HEALTH_URL:-}" ]; then
HEALTH_URLS=("$TADRISINO_HEALTH_URL")
else
HEALTH_URLS=(
"http://127.0.0.1:8000/api/health"
"http://127.0.0.1:8000/api/health/"
)
fi
# ─── ۱. کدِ تازه ────────────────────────────────────────────────────────────
if [ "${SKIP_PULL:-0}" != "1" ]; then
step "گرفتنِ کدِ تازه"
# امضای خودِ این فایل قبل از به‌روزرسانی. توضیحش پایین‌تر.
SELF_BEFORE="$(cksum "$0" 2>/dev/null || true)"
# شاخه‌ی فعلی؛ اگر HEAD جدا افتاده باشد main مبنا می‌شود.
BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)"
[ "$BRANCH" = "HEAD" ] && BRANCH=main
git fetch origin "$BRANCH" \
|| fail "به gitea وصل نشد. شبکه یا دسترسیِ مخزن را بررسی کنید."
# اگر کسی روی سرور فایلی را دستی عوض کرده، اول نسخه‌ی پشتیبان، بعد کنار
# گذاشتنش.
#
# چرا خودکار و نه توقف: قبلاً اینجا `git pull --ff-only` بود و با هر تغییرِ
# دستی کلِ دیپلوی همان‌جا می‌ایستاد — و چون بقیه‌ی مراحل اجرا نمی‌شد، سرور
# با کدِ قدیمی می‌ماند بدونِ اینکه کسی بفهمد. دقیقاً همین یک بار دو روز طول
# کشید. حالا تغییرِ دستی مانعِ استقرار نیست؛ ذخیره می‌شود و رد می‌شویم.
if ! git diff --quiet HEAD 2>/dev/null; then
mkdir -p .deploy-backups
PATCH=".deploy-backups/local-changes-$(date +%Y%m%d-%H%M%S).patch"
git diff HEAD > "$PATCH" 2>/dev/null || true
warn "روی سرور تغییرِ دستی پیدا شد. در $PATCH ذخیره شد و کنار گذاشته می‌شود.
برای دیدنش: cat $PATCH
اگر لازم بود: git apply $PATCH"
fi
# کامیتِ محلی روی سرور (جلوتر از origin) را هم reset --hard بی‌صدا دور می‌ریخت؛ تا
# ۲۰۲۶-۱۰-۰۳ فقط تغییرِ کامیت‌نشده ذخیره می‌شد، برخلافِ آن‌چه CLAUDE.md می‌گفت.
if [ -n "$(git log --oneline "origin/$BRANCH..HEAD" 2>/dev/null)" ]; then
mkdir -p .deploy-backups
COMMITS=".deploy-backups/local-commits-$(date +%Y%m%d-%H%M%S).patch"
git format-patch --stdout "origin/$BRANCH..HEAD" > "$COMMITS" 2>/dev/null || true
warn "روی سرور کامیتِ محلی بود. در $COMMITS ذخیره شد و کنار گذاشته می‌شود.
اگر لازم بود: git am $COMMITS"
fi
# ⚠️ اینجا عمداً `git clean` زده نمی‌شود و هرگز هم نباید زده شود:
# db.sqlite3 و .env و .django_secret_key هیچ‌کدام در گیت نیستند و داخلِ همین
# پوشه‌اند. یک `git clean -fd` یعنی نابودیِ دیتابیس، تنظیمات، و کلیدی که
# همه‌ی نشست‌های ورود با آن امضا شده‌اند. reset --hard فقط فایل‌های ردیابی‌شده
# را برمی‌گرداند و به این‌ها کار ندارد.
git reset --hard "origin/$BRANCH" \
|| fail "برگرداندن به origin/$BRANCH نشد؛ هیچ تغییری اعمال نشد."
# اگر همین اسکریپت در pull عوض شد، نسخه‌ی تازه از اول اجرا می‌شود.
#
# چرا لازم است: bash اسکریپت را یک‌جا نمی‌خواند، حین اجرا از روی دیسک ادامه
# می‌دهد. پس وقتی pull همین فایل را زیر پای خودش عوض می‌کند، ادامه‌ی اجرا
# مخلوطی از نسخه‌ی قدیم و جدید می‌شود. یک بار همین اتفاق افتاد: نسخه‌ی قدیمی
# که هنوز در حال اجرا بود سرویسِ تیمِ دیگری را ری‌استارت کرد، در حالی که
# نسخه‌ی اصلاح‌شده همان لحظه روی دیسک نشسته بود.
#
# DEPLOY_REEXEC جلوی حلقه‌ی بی‌پایان را می‌گیرد: نسخه‌ی تازه دیگر خودش را
# صدا نمی‌زند چون بعد از pullِ دوم دیگر چیزی عوض نمی‌شود.
if [ "${DEPLOY_REEXEC:-0}" != "1" ] \
&& [ -n "$SELF_BEFORE" ] \
&& [ "$SELF_BEFORE" != "$(cksum "$0" 2>/dev/null || true)" ]; then
printf ' این اسکریپت خودش به‌روز شد؛ نسخه‌ی تازه از اول اجرا می‌شود.\n'
DEPLOY_REEXEC=1 SKIP_PULL=1 exec "$0" "$@"
fi
else
step "گرفتنِ کد رد شد (SKIP_PULL=1)"
fi
printf ' کامیتِ فعلی: %s\n' "$(git rev-parse --short HEAD) $(git log -1 --format=%s)"
# ─── ۲. محیطِ پایتون ────────────────────────────────────────────────────────
step "فعال‌سازیِ محیطِ مجازی"
if [ -d venv ]; then
# set -u موقتاً خاموش: اسکریپت‌های activate قدیمی به PS1 بدونِ محافظ دست
# می‌زنند و زیرِ set -u با «unbound variable» کلِ دیپلوی را می‌خوابانند.
set +u
# shellcheck disable=SC1091
source venv/bin/activate
set -u
printf ' venv فعال شد\n'
elif [ -n "${VIRTUAL_ENV:-}" ]; then
printf ' از venv فعالِ فعلی استفاده می‌شود: %s\n' "$VIRTUAL_ENV"
else
warn "هیچ venv ای پیدا نشد؛ از پایتونِ سیستم استفاده می‌شود."
fi
# ─── ۳. وابستگی‌ها ──────────────────────────────────────────────────────────
# همیشه اجرا می‌شود، نه فقط وقتی requirements عوض شده: تشخیصِ «عوض شده یا نه»
# خودش یک جای فراموش‌شدنیِ دیگر است، و pip وقتی همه‌چیز نصب است سریع تمام می‌کند.
step "نصبِ وابستگی‌ها"
PIP_ARGS=(-r requirements.txt --disable-pip-version-check)
if [ -n "${PIP_INDEX_URL:-}" ]; then
printf ' از ایندکسِ %s استفاده می‌شود\n' "$PIP_INDEX_URL"
fi
python -m pip install "${PIP_ARGS[@]}" \
|| fail "نصبِ وابستگی‌ها شکست خورد. اگر pypi.org تایم‌اوت می‌دهد، این را امتحان کنید:
PIP_INDEX_URL=https://mirror-pypi.runflare.com/simple ./deploy.sh"
# ─── ۳.۵ تنظیماتِ مؤثر ─────────────────────────────────────────────────────
# اینجا عمداً چیزی را اجباری نمی‌کنیم: تنظیماتِ پروژه خودش پیش‌فرضِ سالم دارد و
# با فایلِ خالی هم بالا می‌آید. تکرارِ آن منطق در این اسکریپت یعنی دو جا که
# باید هم‌زمان به‌روز بمانند — و همان باعث می‌شد دیپلویِ درست هم رد شود.
# پس فقط از خودِ جنگو می‌پرسیم چه چیزی واقعاً فعال است (شاملِ مقادیرِ .env).
step "تنظیماتِ مؤثر"
python - <<'PYCHECK' || fail "تنظیمات اصلاً بار نشد؛ هیچ تغییری اعمال نشد."
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.conf import settings
print(f' DEBUG : {settings.DEBUG}')
print(f' ALLOWED_HOSTS : {", ".join(settings.ALLOWED_HOSTS) or "—"}')
print(f' CORS مجاز : {", ".join(settings.CORS_ALLOWED_ORIGINS) or "—"}')
print(f' کش : {settings.CACHES["default"]["BACKEND"].rsplit(".", 1)[-1]}')
if os.environ.get('DJANGO_SECRET_KEY'):
key_source = 'از متغیرِ محیطی'
elif settings.DEBUG:
key_source = 'کلیدِ ثابتِ حالتِ توسعه (در پروداکشن استفاده نمی‌شود)'
else:
key_source = 'تولیدشده و ذخیره‌شده در .django_secret_key'
print(f' کلیدِ امضا : {key_source}')
if settings.DEBUG:
print()
print(' ⚠ DEBUG روشن است. روی سرورِ عمومی یعنی:')
print(' • صفحه‌های خطا کلِ تنظیمات و traceback را نشان می‌دهند')
print(' • کدِ ورودِ 0000 برای هر شماره‌ای پذیرفته می‌شود')
if 'LocMem' in settings.CACHES['default']['BACKEND'] and not settings.DEBUG:
print(' ⚠ بدونِ Redis محدودیتِ نرخ بینِ workerها مشترک نیست (CACHE_URL).')
PYCHECK
# ─── ۴. دیتابیس ────────────────────────────────────────────────────────────
step "اعمالِ مهاجرت‌های دیتابیس"
python manage.py migrate --noinput || fail "migrate شکست خورد؛ سرویس ری‌استارت نشد."
# محتوا هم مثلِ کد از ریپو می‌آید (content/seed/). بدونِ این، یک سرورِ تازه
# دیتابیسِ خالی دارد و `/api/grades` جوابِ `{"grades": []}` می‌دهد — یعنی هر
# صفحه‌ی اپ خالی بالا می‌آید و به‌نظر خراب می‌رسد.
#
# ایدمپوتنت و بدونِ حذف است، پس اجرای هر باره بی‌خطر است. شکستش دیپلوی را
# نمی‌خواباند: کدِ تازه بهتر از کدِ قدیمی است حتی اگر سید نخورده باشد.
step "محتوای پایه"
python manage.py seed_content 2>&1 | sed 's/^/ /' \
|| warn "seed_content شکست خورد؛ کد به‌روز شد ولی محتوا دست‌نخورده ماند."
# جدولِ لیدربورد کاملاً مشتق‌شده است و تا پیش از این هیچ تولیدکننده‌ای نداشت —
# یعنی /api/leaderboard روی سرورِ واقعی همیشه `entries: []` می‌داد. این مرحله
# آن را در هر ریلیز از روی XPی واقعی از نو می‌سازد.
#
# ⚠️ این «تازه‌سازیِ هر ریلیز» است، نه یک زمان‌بندِ واقعی: بینِ دو دیپلوی،
# رتبه‌ها ثابت می‌مانند. وقتی ترافیک واقعی شد، همین دستور باید در یک cron
# (مثلاً هر ساعت) بنشیند.
#
# ایدمپوتنت است و شکستش دیپلوی را نمی‌خواباند.
step "بازسازیِ لیدربورد"
python manage.py rebuild_leaderboard 2>&1 | sed 's/^/ /' \
|| warn "rebuild_leaderboard شکست خورد؛ رتبه‌ها از اجرای قبلی مانده‌اند."
# ─── ۵. فایل‌های استاتیک ───────────────────────────────────────────────────
# بی‌قید و شرط اجرا می‌شود، حتی وقتی DEBUG روشن است.
#
# قبلاً این مرحله وقتی DEBUG=1 بود رد می‌شد، چون در آن حالت whitenoise از
# finders می‌خواند و جمع‌آوری لازم نبود. ولی این یعنی درستیِ دیپلوی به
# ترتیبِ کارهای یک آدم گره می‌خورد: اگر کسی با DEBUG روشن دیپلوی کند و بعد
# DEBUG را خاموش کند، جنگو دنبالِ فایل‌هایی می‌گردد که هیچ‌وقت جمع نشدند و
# پنلِ ادمین بدونِ CSS بالا می‌آید — بدونِ هیچ خطایی در لاگ. دقیقاً همین
# روی سرور اتفاق افتاد.
#
# هزینه‌اش چند ثانیه است و idempotent هم هست، پس همیشه اجرا می‌شود.
step "جمع‌آوریِ فایل‌های استاتیک"
python manage.py collectstatic --noinput >/dev/null \
|| warn "collectstatic شکست خورد؛ پنلِ ادمین ممکن است بدونِ CSS بالا بیاید."
printf ' انجام شد.\n'
# ─── ۶. بررسی‌های پیش از راه‌اندازی ────────────────────────────────────────
step "بررسیِ تنظیماتِ استقرار"
python manage.py check --deploy 2>&1 | sed 's/^/ /' || true
# ─── ۷. ری‌استارت ──────────────────────────────────────────────────────────
step "ری‌استارتِ سرویس"
SERVICE="${TADRISINO_SERVICE:-}"
if [ -z "$SERVICE" ] && command -v systemctl >/dev/null 2>&1; then
# `systemctl cat` تنها بررسیِ قابلِ اتکاست: list-unit-files حتی وقتی چیزی
# پیدا نکند هم کدِ ۰ برمی‌گرداند.
# عمداً نامِ عامِ `gunicorn` در این فهرست نیست. روی سرورِ واقعی چند اپِ
# مختلف کنارِ هم هستند و یک `gunicorn.service` وجود دارد که مالِ تیمِ
# دیگری است — حدس زدنِ آن یعنی ری‌استارت کردنِ سرویسِ یک نفرِ دیگر. اگر
# نامِ سرویس در این فهرست نبود، TADRISINO_SERVICE را صریح بدهید.
for candidate in zareian-mathapp gunicorn-mathapp tadrisino tadrisino-api; do
if systemctl cat "${candidate}.service" >/dev/null 2>&1; then
SERVICE="$candidate"; break
fi
done
fi
RESTART_OK=0
if [ -n "$SERVICE" ]; then
# زمانِ بالا آمدنِ سرویس قبل و بعد مقایسه می‌شود.
#
# چرا: قبلاً فقط خروجیِ `systemctl restart` نگاه می‌شد و بعد سلامت از روی
# آدرسِ عمومی چک می‌شد. ولی وقتی ری‌استارت شکست می‌خورد، پروسه‌ی *قدیمی*
# هنوز بالا بود و همان ۲۰۰ را می‌داد — یعنی اسکریپت «✓ سالم» می‌گفت در
# حالی که کدِ جدید اصلاً بار نشده بود. روی سرور دو روز همین‌طور ماند: کد و
# محتوا و فایل‌های استاتیک روی دیسک بودند و هیچ‌کدام سرو نمی‌شدند.
BEFORE="$(systemctl show "$SERVICE" -p ActiveEnterTimestamp --value 2>/dev/null || true)"
if sudo systemctl restart "$SERVICE"; then
AFTER="$(systemctl show "$SERVICE" -p ActiveEnterTimestamp --value 2>/dev/null || true)"
if [ -z "$BEFORE" ] || [ -z "$AFTER" ]; then
# نتوانستیم ثابت کنیم ری‌استارت شده. «نمی‌دانم» را موفقیت حساب نمی‌کنیم —
# همین اشتباه بود که دو روز پنهان ماند.
warn "زمانِ بالا آمدنِ $SERVICE خوانده نشد، پس ری‌استارت تأیید نشد.
خودتان بررسی کنید: systemctl status $SERVICE"
elif [ "$BEFORE" = "$AFTER" ]; then
warn "دستورِ ری‌استارت خطا نداد ولی سرویس بالا نیامد (هنوز از $BEFORE در حال اجراست).
یعنی کدِ جدید بار نشده. لاگ: sudo journalctl -u $SERVICE -n 50 --no-pager"
else
RESTART_OK=1
printf ' سرویسِ %s ری‌استارت شد\n' "$SERVICE"
fi
else
warn "ری‌استارتِ $SERVICE شکست خورد (شاید sudo رمز می‌خواهد). خودتان اجرا کنید:
sudo systemctl restart $SERVICE"
fi
else
warn "سرویسِ systemd پیدا نشد؛ خودتان ری‌استارت کنید، مثلاً:
sudo systemctl restart <نامِ سرویس>
و برای اینکه دفعه‌ی بعد خودکار شود:
TADRISINO_SERVICE=<نامِ سرویس> ./deploy.sh"
fi
# ─── ۸. آیا واقعاً بالا آمد؟ ───────────────────────────────────────────────
step "بررسیِ سلامت"
if [ -z "${TADRISINO_HEALTH_URL:-}" ]; then
PUBLIC_URL="$(python -c 'import django, os; os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings"); django.setup(); from django.conf import settings; print(settings.PUBLIC_BASE_URL)' 2>/dev/null | tail -n 1)"
if [ -n "$PUBLIC_URL" ]; then
HEALTH_URLS+=("$PUBLIC_URL/api/health" "$PUBLIC_URL/api/health/")
fi
fi
CODE=000; BODY=""
for attempt in 1 2 3 4 5 6 7 8 9 10; do
for url in "${HEALTH_URLS[@]}"; do
# یک درخواست، نه دو تا: دو فراخوانیِ جدا می‌توانست دو وضعیتِ متفاوت ببیند.
RESPONSE="$(curl -s -m 10 -w '\n%{http_code}' "$url" 2>/dev/null || printf '\n000')"
CODE="${RESPONSE##*$'\n'}"
BODY="${RESPONSE%$'\n'*}"
if [ "$CODE" = "200" ]; then
printf '%s✓ سرویس سالم بالا آمد%s (%s)\n' "$C_OK" "$C_OFF" "$url"
printf '%s\n' "$BODY" | python -m json.tool 2>/dev/null | sed 's/^/ /' || printf ' %s\n' "$BODY"
# ۲۰۰ گرفتن به‌تنهایی یعنی «چیزی جواب می‌دهد»، نه «کدِ جدید بالاست».
if [ "$RESTART_OK" != "1" ]; then
warn "ولی سرویس ری‌استارت نشد — این جوابْ از پروسه‌ی قدیمی است و کدِ
جدید هنوز سرو نمی‌شود. تا ری‌استارت نکنید دیپلوی ناتمام است."
exit 1
fi
exit 0
fi
done
sleep 3
done
printf ' آدرس‌های امتحان‌شده: %s\n' "${HEALTH_URLS[*]}" >&2
printf '%s✖ سرویس بعد از ۳۰ ثانیه هنوز سالم نیست (آخرین کد: %s)%s\n' "$C_ERR" "$CODE" "$C_OFF" >&2
printf '%s\n' "$BODY" | head -c 600 >&2; echo >&2
cat >&2 <<'HINT'
لاگ را ببینید:
sudo journalctl -u <نامِ سرویس> -n 60 --no-pager
رایج‌ترین علت‌ها:
ModuleNotFoundError → وابستگیِ جدید نصب نشده (این اسکریپت باید حلش می‌کرد)
ImproperlyConfigured → متغیرِ محیطیِ لازم ست نیست (SECRET_KEY / CACHE_URL)
502 از nginx → پروسه‌ی جنگو اصلاً بالا نیامده
HINT
exit 1