Commit Graph

21 Commits

Author SHA1 Message Date
d10e3e4c9f deploy.sh: backup retention used ls on a glob that can be empty, killing the deploy under pipefail
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:53:57 +03:30
a9cccc6550 Live off-site Postgres copy on 247 replaces the nightly pg_dump; backup retention in deploy.sh
- ops/pgbackup: SSH tunnel + pg_receivewal (slot zareian_247) + daily verified pg_basebackup (keep 7)
  and a restore check, all run on 78.38.35.247 as systemd units
- /api/health reports offsiteBackup (slot active/lag/lost) and warns; OFFSITE_WAL_SLOT setting
- removed statuslog backup_db/backup_verify and their health warnings
- deploy.sh keeps 3 pre-deploy dumps, 3 local patches, drops manual backups after 30 days
- docs: operations backup section, security item 14 closed, CLAUDE.md status

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:53:10 +03:30
042df25738 Postgres support (DB_* env), LoginEvent audit table, Choice.value 300, deploy.sh pg_dump backup
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 11:48:48 +03:30
4c601087dc deploy.sh: health check through the gunicorn socket, optional DEPLOY_BUNDLE when Gitea is unreachable
From the server the public URL goes through ArvanCloud and returned 504/000 (2026-10-07), so the
final health step failed even for a healthy service. It now asks the gunicorn socket (path read from
the unit) with our Host header first. DEPLOY_BUNDLE=<file> fetches from a git bundle instead of Gitea,
only when set explicitly; without it an unreachable Gitea still stops the deploy.

Tested: probe_health against a throwaway unix-socket server and a dead URL (bash -n clean).
Not tested: the whole script end to end, until the next deploy.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 13:57:50 +03:30
7ecb337263 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>
2026-10-07 13:30:24 +03:30
645baf846b Signed payment links, hashed refresh tokens, login-only leaderboard, load test tools
- /api/payment/orders/{id}/pay requires the short-lived signature that paymentUrl carries;
  without it (or with another order's) it is the same 404 as an unknown order
- Session.refresh_token stores the SHA-256 of the token; migration accounts.0007 hashes the
  existing rows so tokens clients already hold keep working
- /api/leaderboard requires a token (guests have one); names of children were scrapable
- RAG mock only for `manage.py test`, not any command with an argument named "test"
- tools/loadtest: user factory, load generator, fake GapGPT+Qdrant, with method notes
- security-before-production.md lists only open items; closed ones become a guards table
  with the tests that hold them. Comments describe current behaviour, not history

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:12:28 +03:30
2cf2d14dd4 Security re-check and load test 2026-10-04: 32 threads, data file permissions, patched deps
- gunicorn gthread 8 -> 32 threads per worker: each assistant stream holds a thread for
  the whole answer; with 3x8, about 20 concurrent asks already queued ordinary page loads
  for seconds (up to 23 s at 50 asks locally, 7 s at 20 asks on the live server). With 3x32,
  75 concurrent asks kept page loads under 110 ms (laptop, simulated model and Qdrant)
- deploy.sh: new step closes the project dir to other accounts and sets the database and
  backups to 0600/0700 on every release; user `nobody` on the shared server could read
  db.sqlite3 and every backup
- requirements: python-dotenv 1.2.2, urllib3>=2.8.0 (known CVEs in the installed versions),
  gunicorn pinned to the server's version (was only installed by hand)
- security-before-production.md: items 19-23 (file permissions, DEBUG=1 still in the systemd
  unit, dependencies, frontend headers, guest AI cap per shared IP) and the reproduced
  X-Forwarded-For spoof through the origin IP
- model-prices.md: 0.183 cents per answer from 50 answers in the live load test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:39:11 +03:30
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
4b246e0c70 make the domain configuration, not code: two env vars move everything
A domain change is coming (the Zarinpal terminal rejects the current callback
domain). ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS, CORS defaults, the Bazaar
redirect URI and deploy.sh's public health URL each spelled the domain out.
They now derive from PUBLIC_BASE_URL and FRONTEND_URL, which keep today's
domain as their default; env values for the three lists are still added on
top, so the old domain can stay allowed while DNS moves.

ZARINPAL_CALLBACK_URL used os.environ.get(key, default), so an empty key in
.env produced an empty callback and every payment would fail closed. It now
uses the same `.strip() or` fallback as its neighbours; the new test caught it.

config/tests_domains.py loads settings in a subprocess with a new domain and
fails if any derived value still mentions the old one. docs/operations.md
gains the step-by-step domain move (DNS, .env, Zarinpal/Bazaar panels,
frontend config.js, verification). CLAUDE.md records that the frontend is
now ours and where the rebuild stands. 624 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:46:25 +03:30
8fce3b9548 give the leaderboard and notifications a real producer
Three gaps found while seeding test data for the frontend, all of the same
kind: a contract that looks complete from the outside and is empty underneath.

Nothing in the codebase ever wrote a LeaderboardEntry. Not a signal, not a
service — only the admin form and seed_test_data. So /api/leaderboard returned
`entries: []` forever on any real server, and any row put there was a frozen
number that never tracked activity. rebuild_leaderboard now derives both
periods from real XP: all_time from UserStats.xp, weekly from a sum over
content.UserLessonCompletion in a window. It deliberately does not count
UserAnswer.xp_earned — that field is written but never added to UserStats, so
summing both would report roughly double. deploy.sh runs it every release;
a cron is the real answer once traffic is real, and the script says so.

'lesson_completed' was declared in Notification.TYPE_CHOICES but nothing
called notify() with it, so finishing a lesson produced no notification at
all. LessonCompleteView now emits one, on first completion only (the same
condition that grants xp/coins), inside the completion transaction so a lost
notification cannot leave a silent half-done state.

The notification list serializer returned only id/type/title/isRead. body and
created_at were on the model from the start but never exposed, so the frontend
had neither the text of a notification nor its time. Both added; the change is
purely additive, so the existing client keeps working.

472 tests green, up from 458. makemigrations --check, check and spectacular
are all clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 13:30:57 +03:30
8eedbdeac0 let a deploy repair the server instead of stopping at it
تا حالا اگر کسی روی سرور فایلی را دستی عوض کرده بود، `git pull --ff-only`
شکست می‌خورد و کلِ اسکریپت همان‌جا می‌ایستاد. چون هیچ‌کدام از مراحلِ بعدی
اجرا نمی‌شد، سرور با کدِ قدیمی می‌ماند — بی‌صدا. یک بار همین دو روز طول کشید.

حالا مرحله‌ی اول fetch می‌کند، اگر تغییرِ دستی (یا کامیتِ محلی) باشد آن را در
.deploy-backups/*.patch نگه می‌دارد، و با `git reset --hard origin/<branch>`
کنارش می‌گذارد و ادامه می‌دهد. سرور در هر وضعیتی باشد، یک اجرا دقیقاً همان
چیزی می‌شود که روی gitea است.

`git clean` عمداً زده نمی‌شود و نباید اضافه شود: db.sqlite3 و .env و
.django_secret_key ردیابی‌نشده و داخلِ همین پوشه‌اند.

با مخزنِ ساختگی هر سه حالت آزموده شد: تغییرِ دستی، درختِ تمیز، و کامیتِ محلیِ
واگرا. در هر سه، .env و db.sqlite3 دست‌نخورده ماندند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 00:54:06 +03:30
6ec82081b5 stop deploy.sh from pulling a new copy of itself out from under bash
bash اسکریپت را یک‌جا نمی‌خواند؛ حین اجرا از روی دیسک ادامه می‌دهد. پس وقتی
`git pull` همین فایل را عوض می‌کند، ادامه‌ی اجرا مخلوطی از نسخه‌ی قدیم و جدید
است. یک بار همین اتفاق افتاد و گران تمام شد: نسخه‌ی قدیمی که در حال اجرا بود
سرویسِ تیمِ دیگری را ری‌استارت کرد، در حالی که نسخه‌ی اصلاح‌شده همان لحظه روی
دیسک نشسته بود.

حالا امضای فایل قبل و بعدِ pull مقایسه می‌شود و اگر عوض شده باشد، نسخه‌ی تازه
از اول اجرا می‌شود (با SKIP_PULL=1 و یک نگهبان در برابرِ حلقه‌ی بی‌پایان).

و یک حفره‌ی کوچک‌ترِ هم‌خانواده: اگر زمانِ بالا آمدنِ سرویس اصلاً خوانده نشود،
دیگر موفقیت فرض نمی‌شود. «نمی‌دانم» همان چیزی بود که دو روز پنهانش کرد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 00:41:11 +03:30
700b2e4c7f give our service a name nobody can confuse with someone else's
روی این سرور یک `gunicorn.service` هست که مالِ تیمِ دیگری است. هر کسی که
عجله داشته باشد و `systemctl restart gunicorn` بزند، سرویسِ آن‌ها را
می‌خواباند و سرویسِ ما اصلاً ری‌استارت نمی‌شود — یعنی دیپلوی بی‌صدا اعمال
نمی‌شود. همین دیشب یک بار اتفاق افتاد.

حالا همان یونیت با نامِ `zareian-mathapp` هم صدا زده می‌شود (symlink در
/etc/systemd/system). نامِ قبلی هم کار می‌کند، پس چیزی نمی‌شکند.

خط تیره و نه نقطه: systemd آخرین نقطه را جداکننده‌ی نوعِ یونیت می‌داند، پس
`zareian.mathapp` را باید هر بار کامل با `.service` نوشت.

deploy.sh هم اول دنبالِ همین نام می‌گردد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 00:14:46 +03:30
de118db58f make a deploy fail loudly when the service never restarted
سرور دو روز کدِ قدیمی را سرو کرد و هیچ‌کس نفهمید. علتش این بود که
`systemctl restart` شکست خورده بود ولی پروسه‌ی قبلی هنوز بالا بود و همان
۲۰۰ را می‌داد — پس اسکریپت «✓ سرویس سالم بالا آمد» چاپ کرد و با کدِ صفر
تمام شد. محتوا و فایل‌های استاتیک هم روی دیسک بودند و سرو نمی‌شدند.

حالا زمانِ بالا آمدنِ سرویس قبل و بعد مقایسه می‌شود. اگر عوض نشده باشد،
۲۰۰ گرفتن دیگر موفقیت حساب نمی‌شود و اسکریپت با خطا تمام می‌کند.

نامِ عامِ `gunicorn` هم از فهرستِ حدسِ سرویس برداشته شد: روی سرورِ واقعی یک
`gunicorn.service` هست که مالِ تیمِ دیگری است، و حدس زدنش یعنی ری‌استارت
کردنِ سرویسِ یک نفرِ دیگر. نامِ درستِ ما `gunicorn-mathapp` است.

`api/health/` هم کنارِ `api/health` ثبت شد. پایشِ سرور شکلِ اسلش‌دار را صدا
می‌زد و چون ثبت نبود، کسی روی سرور `config/urls.py` را دستی ویرایش کرده
بود — و از آن به بعد هر `git pull` روی همان فایل گیر می‌کرد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 22:56:51 +03:30
4857f49f92 collect static files even when DEBUG is on
پنلِ ادمین روی سرور بدونِ CSS بالا آمد و هیچ خطایی هم در لاگ نبود.

علت ترتیبِ کارها بود: این مرحله وقتی DEBUG روشن بود رد می‌شد، چون در آن حالت
whitenoise از finders می‌خواند و جمع‌آوری لازم نیست. کسی با DEBUG روشن دیپلوی
کرد و بعد DEBUG را خاموش کرد — کارِ درستی هم بود — و از آن لحظه جنگو دنبالِ
فایل‌هایی می‌گشت که هیچ‌وقت جمع نشده بودند.

یعنی درستیِ دیپلوی به ترتیبِ کارِ یک آدم گره خورده بود. چند ثانیه صرفه‌جویی،
به قیمتِ شکستی که فقط در مرورگر دیده می‌شود. حالا بی‌قید و شرط اجرا می‌شود؛
idempotent است و در هر دو حالت درست کار می‌کند.

staticfiles/ هم به gitignore اضافه شد: حالا که این مرحله همیشه اجرا می‌شود،
روی ماشینِ هر توسعه‌دهنده‌ای ساخته می‌شود و ۴٫۴ مگابایت فایلِ تولیدی نباید
با یک `git add .` واردِ مخزن شود.

بررسی شد که با DEBUG=False کلِ مسیر کار می‌کند: استاتیک ۲۰۰، صفحه‌ی ادمین
۲۰۰، API ۲۰۰، و ورود با OTP از پنل. کدِ 0000 همان‌طور که باید، رد می‌شود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:53:55 +03:30
9c3db68cbb carry the curriculum in the repo so a fresh server is not empty
سرورِ production از روزِ اول `{"grades": []}` برگردانده: دیتابیس gitignore است
(درست هم هست) و تنها راهِ پر کردنش اجرای `rag_import_collection` و بعد
`seed_curriculum_from_textbook` روی همان سرور بود — یعنی وابستگی به Qdrant، به
اعتبارِ AI که تمام شده، و به کسی که SSH دارد. نتیجه این شد که هیچ‌کس نتوانست
چیزی را تست کند.

حالا برنامه‌ی درسی در `content/seed/curriculum.json` کامیت می‌شود (۴ پایه، ۳۱
فصل، ۱۶۸ مبحث، ۱۳۰۰ درس، ۵۲۷ کیلوبایت متن) و `deploy.sh` خودش بالا می‌آوردش.
سرورِ تازه نه Qdrant می‌خواهد نه ترمینال.

`db.sqlite3` عمداً کامیت نمی‌شود: باینری است، دیف نمی‌شود، و هر دیپلوی محتوای
سرور — و فردا پاسخ‌های کاربران — را با نسخه‌ی لوکالِ یک نفر جایگزین می‌کند.

سوال‌ها با مسیرِ عنوان ذخیره می‌شوند نه با lessonId. این تصمیمِ اصلیِ کامیت
است: لوکالِ ما ۱۳۰۷ درس دارد با idهای ۳ تا ۲۰۰۵ (جای خالی، از پاک شدنِ دیتای
دمو)، و همان کوریکولوم روی دیتابیسِ خالی idهای ۱ تا ۱۳۰۷ می‌گیرد. با id،
سوالِ درسِ ۳ روی سرور به درسِ بی‌ربطی می‌چسبید — بدونِ هیچ خطایی. تست
`test_question_follows_the_title_path_not_the_id` همین را نگه می‌دارد.

`seed_content` ایدمپوتنت است و هیچ‌چیز پاک نمی‌کند: کوریکولوم با عنوان
هم‌سان‌سازی می‌شود و سوالِ تکراری رد می‌شود (`skip_existing`). روی سروری که
کاربرِ واقعی دارد، دیپلوی نباید بتواند محتوا یا پاسخی را از بین ببرد — و
ادمین باید بتواند سوالِ سیدشده را اصلاح کند بی‌آنکه دیپلویِ بعدی برش گرداند.

ردیف‌های `[DEMO]`/`[SEED]` موقعِ export کنار گذاشته می‌شوند؛ آن‌ها همان‌هایی‌اند
که `purge_demo_data` برای پاک کردنشان نوشته شد و جایشان در فایلی که مستقیم روی
production لود می‌شود نیست.

هنوز صفر سوالِ واقعی داریم: هر ۱۴ سوالِ لوکال رویِ ردیف‌های دمو بودند. مسیرِ
سوال‌ها ساخته و تست شده، ولی تا وقتی اعتبارِ AI شارژ نشود فایلش خالی می‌ماند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:20:26 +03:30
39a9f435ac make the deploy script survive an unfamiliar server
دو حالتی که فقط روی سرورِ واقعی پیش می‌آیند، نه اینجا.

اسکریپت‌های قدیمیِ activate به PS1 بدونِ محافظ دست می‌زنند و زیرِ set -u با
«unbound variable» کلِ دیپلوی را می‌خوابانند — venv اینجا سالم است ولی venv
سرور لزوماً نه، پس set -u دورِ همان یک خط موقتاً خاموش می‌شود.

بررسیِ سلامت فقط 127.0.0.1:8000 را می‌زد. اگر gunicorn روی سوکت یا پورتِ
دیگری باشد، دیپلویِ کاملاً موفق هم با پیامِ خطا تمام می‌شد. حالا آدرسِ
عمومی هم امتحان می‌شود و در پایان می‌گوید کدام آدرس‌ها را زده.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:53:43 +03:30
3dacf3a50f fix two checks that only broke in the case they exist for
بازبینیِ کارِ امروز، سه مورد پیدا کرد.

/api/health وقتی مهاجرتِ اعمال‌نشده وجود داشت می‌شکست: کدِ فهرست‌کردنِ
مهاجرت‌ها به migration.key تکیه می‌کرد که اصلاً وجود ندارد (فیلدها app_label
و name هستند). یعنی دقیقاً در همان حالتی که این بررسی برایش ساخته شده،
به‌جای نامِ مهاجرت‌ها فقط AttributeError برمی‌گرداند. حالا تستی دارد که
مهاجرتِ معلق را شبیه‌سازی می‌کند — چیزی که نبودش باعث شد اولین بار غلط بماند.

وارد کردنِ سوال با replace عددِ غلط گزارش می‌کرد: delete() در جنگو مجموعِ
همه‌ی ردیف‌های آبشاری را می‌دهد، پس «۵ سوال پاک شد» در واقع گزینه‌ها و
پاسخ‌ها را هم می‌شمرد.

deploy.sh بررسیِ متغیرهای محیطی‌اش با تنظیماتِ جدید ناسازگار شده بود:
متغیرهایی را اجباری می‌دانست که حالا پیش‌فرض دارند، و فقط شل را می‌خواند نه
.env — یعنی روی سرورِ درست هم دیپلوی را رد می‌کرد. حالا به‌جای تکرارِ منطقِ
تنظیمات، از خودِ جنگو می‌پرسد چه چیزی مؤثر است و گزارش می‌دهد.

کوچک‌تر: بررسیِ سلامت یک درخواست می‌زند نه دو تا، و شکستِ ری‌استارت دیگر
اسکریپت را بی‌صدا تمام نمی‌کند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:42:36 +03:30
e5d2427560 keep one env example instead of two
دو فایلِ نمونه یعنی دو جا که باید هم‌زمان به‌روز بمانند، و همان چیزی بود که
قبلاً باعث شد نمونه عقب بماند و کسی که سرور را بالا می‌آورد نداند چه بنویسد.
حالا یک فایل، هم برای ماشینِ محلی هم برای سرور.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:15:42 +03:30
eaa4b761dd stop a deploy from silently blocking the frontend or leaving 0000 open
سه حالتِ خراب که هیچ‌کدام در لاگِ سرور ردی نمی‌گذارند و فقط وقتی کشف می‌شدند
که یک آدم از بیرون اپ را دستی امتحان کند.

CORS_ALLOWED_ORIGINS فقط از env خوانده می‌شد و با DEBUG روشن به localhost
محدود می‌ماند. یعنی بک‌اندِ کاملاً سالم، ولی مرورگر همه‌ی درخواست‌های
math.tadrisino2.ir را بلاک می‌کند — خطایی که سمتِ سرور دیده نمی‌شود و فقط در
کنسولِ مرورگر پیداست. origin های شناخته‌شده حالا پیش‌فرضِ داخلِ کدند تا یک
متغیرِ فراموش‌شده اتصال را نشکند؛ env همچنان بازنویسی‌شان می‌کند.

/api/health حالا warnings هم می‌دهد. مهم‌ترینش: با DEBUG روشن روی سرورِ عمومی،
DEV_MASTER_OTP یعنی هر کسی با کدِ 0000 می‌تواند به‌جای هر شماره‌ای وارد شود.
این تا امروز فقط با خواندنِ تنظیماتِ سرور معلوم می‌شد، و ما به سرور دسترسی
نداریم. allowedOrigins هم برگردانده می‌شود تا مشکلِ CORS از بیرون قابلِ
تشخیص باشد.

deploy.sh قبل از دست زدن به دیتابیس متغیرهای لازم را بررسی می‌کند و با پیامِ
روشن می‌ایستد، به‌جای اینکه وسطِ کار بشکند و 502 بدهد.
.env.production.example همان‌ها را یک‌جا مستند می‌کند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:46:23 +03:30
cbe9b63dc4 make a deploy observable and one command instead of a checklist
بالا آوردنِ نسخه‌ی جدید تا الان چند کارِ دستی به ترتیبِ درست بود (pull، نصبِ
وابستگی‌ها، مهاجرت، استاتیک، ری‌استارت) و فراموش شدنِ هر کدام اپ را می‌خواباند
با خطایی که از بیرون فقط «502 Bad Gateway» دیده می‌شد. همین الان هم سرور
دقیقاً به همین دلیل خوابیده: کامیتِ ac1b1af پکیجِ django-cors-headers را اضافه
کرد، و بدونِ pip install جنگو با ModuleNotFoundError بالا نمی‌آید.

deploy.sh همه را به ترتیب و idempotent انجام می‌دهد. pip install همیشه اجرا
می‌شود، نه فقط وقتی requirements عوض شده — تشخیصِ «عوض شده یا نه» خودش یک جای
فراموش‌شدنیِ دیگر است. آخرِ کار خودش سلامت را بررسی می‌کند و اگر بالا نیامده
باشد علت‌های رایج را چاپ می‌کند، به‌جای اینکه کسی بعداً از بیرون کشفش کند.

GET /api/health وضعیت را بدونِ احراز و بدونِ SSH بیرون می‌دهد: دیتابیس،
مهاجرت‌های اعمال‌نشده، کش، و شمارشِ پایه/درس/سوال روی همان سرور. مهم‌ترینش
مهاجرتِ اعمال‌نشده است — حالتی که اپ ظاهراً بالا می‌آید و بعد زیرِ بارِ واقعی
با خطای ستونِ ناموجود می‌شکند. عمداً هیچ سکرتی برنمی‌گرداند، فقط بله/خیرِ
«تنظیم شده».

۵۰۳ برمی‌گرداند وقتی دیتابیس/کش/مهاجرت خراب است تا پایشِ خودکار بتواند رویش
حساب کند؛ نبودِ محتوا خرابی حساب نمی‌شود ولی گزارش می‌شود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:28:59 +03:30