A mechanical cross-check of os.environ.get() in settings against .env.example
found twenty variables the app reads that were written down nowhere. Most have
harmless defaults, but three would have cost someone real time:
- NUM_PROXIES decides how many X-Forwarded-For hops are trusted, and guest rate
limiting is keyed on the resulting IP. Set too high, a client can forge the
header and walk past the limit; too low and everyone collapses onto the proxy's
own IP. CLAUDE.md warns about it; the file an operator actually edits did not.
- KAVENEGAR_SENDER is read alongside the API key, so someone configuring SMS from
the README alone would have set only half of it.
- INTERNAL_SERVICE_KEY and PAYMENT_WEBHOOK_SECRET are empty under DEBUG=0 by
design, which quietly keeps the ingest and webhook routes shut. That is the
right behaviour and exactly the kind of thing that reads as a bug when it is
not written down.
The whole Cafe Bazaar block was undocumented too.
Three variables in .env.example are not read by settings and were left alone:
GRAFANA_ADMIN_PASSWORD is consumed by docker-compose, TADRISINO_SERVICE by
deploy.sh, and OPENAI_API_KEY_OPENAI_DIRECT is a documented parking spot.
CLAUDE.md's current-state section was a day stale (458 tests, now 483) and said
nothing about the documentation restructure, the RTL docs shell, the test-login
allowlist or the frontend test account, so anyone picking this up would have
rediscovered all four. It now also records that backups are still manual and
that sqlite3 is not installed on the server.
Also verified mechanically, with no findings: no docstring references an
endpoint absent from the OpenAPI schema, no docstring names a setting or
constant that does not exist, and every internal documentation link resolves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ExecuteCampaignRealConcurrencyTests had been written off as a known flake
for weeks — about 45% red on a full-suite run. It was not timing noise.
It was reporting a real defect, and the defect is in production code.
execute_campaign looped with .iterator(), which keeps the read cursor on
the user table open for the whole loop — a loop that writes a
CampaignDelivery per user and, on the sms branch, makes a network call to
Kavenegar in between. On SQLite that open cursor locks the table against
any concurrent writer, and the lock is not released until the loop ends.
So _retry_on_sqlite_lock could never recover from it no matter how long
it waited: widening its window from 0.5s to 2s (also done here, since the
linear backoff was too tight regardless) moved the failure rate not at
all on its own.
Both branches now materialise the ids before the first write. After the
change: 20 consecutive runs of the test itself and 3 full-suite runs, all
green.
This is not only a test fix. While we are on SQLite the same pattern
produces the same lock in production, where it surfaces as "database is
locked" on unrelated requests that happen to land during a campaign run.
Also in this pass
-----------------
- CLAUDE.md carried three sections each claiming to be the current state,
with three different question counts (1,441 / 1,783 / reality 2,463).
There is now one current-state section and the older two are labelled
as history, so whoever picks this up next is not reading stale numbers
as fact. Three other figures that had drifted are corrected against the
database: the verdict split, the figure-gap share (881/~42% is now
508/~14% after the five books were re-read), and the node coverage.
- Dropped four genuinely unused imports. The remaining pyflakes hits are
the signal registrations in apps.py, which are imported for effect.
- .env.example suggested gpt-4o for vision; gpt-4.1-mini measured both
cheaper and more accurate and is what the server actually runs.
Audited all 2,463 questions across every grade: no question with the
wrong number of options, none with zero or two correct answers, no empty
or duplicated option, no orphan lesson or node, no duplicate question
inside a lesson.
458 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Content
-------
Every node in grades 8, 9, 10, 11 and 12 now holds a full four-question
quiz — 121 more questions, filling the two حسابان دوازدهم nodes in
فصل ۵: کاربردهای مشتق that were empty and the two ریاضی هشتم nodes that
were short. The repo's own seed guard was already failing on those two
empty nodes; it passes now.
Grades 3, 6 and 7 are the other team's books and were left alone. 39 of
their 145 nodes still hold fewer than four questions, but none is empty,
so every node on the map still plays.
An audit over all 1,593 questions in grades 8–12 found no structural
fault: no question with the wrong number of options, no question with
zero or two correct answers, no empty or duplicated option, no leftover
LaTeX, and no two identical questions inside one lesson.
Contract
--------
Three endpoints were missing or malformed in the OpenAPI schema, which
matters more than it looks: that schema is the contract the frontend
generates its client from, so a view missing there is a view the other
repo cannot call.
- NotificationMarkReadView was dropped from the schema entirely, because
spectacular could not guess a request body for its PATCH and gives up
on the whole view rather than the one field. It declares request=None.
- PaymentRedirectView was dropped for the same reason; it answers with a
302 and no JSON body, which it now says.
- /api/health and /api/health/ collided on operationId and were emitted
as health_retrieve and health_retrieve_2. The slash variant exists only
for a monitor that calls it that way, so it is excluded from the schema.
`spectacular` now runs clean — no errors, no warnings.
Tidying
-------
Condensed the two longest comment blocks (18 and 14 lines) in
rag_verify_solutions and settings, keeping the measurement and the rule
and dropping the retelling. README gains the bulk question-authoring
workflow; the playbook's subagent section now points at the real tool
instead of describing the steps by hand.
458 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both books were already complete on Qdrant and answering /api/ai/ask, but
had no Grade/Unit/Node/Lesson rows, so none of their problems could ever
reach a quiz. The owner made the product call, so the curriculum is now
seeded from the books' own tables of contents (free, no API credit), and
559 four-option questions were authored from the verified chunks.
Coverage: all 21 nodes of grade 11 hold a full four-question quiz, and 10
of 12 in grade 12. The two that do not are both in فصل ۵: کاربردهای مشتق
and are short only because the weekly model limit landed mid-run — their
(lesson, problem) pairs are already exported and waiting.
Three supporting changes:
- generate_questions_from_chunks grows --export DIR, which writes the
(lesson, verified problem) pairs as JSON batches and makes no paid call
at all. The subagent route was the documented way to author questions
but had no tool behind it; now it does, and it emits the same PROMPT
the paid path uses so the two branches cannot drift apart in quality.
- _targets now skips as many chunks as a lesson already has questions
for. Re-running with a higher --per-lesson used to hand back the very
same chunk, producing a near-duplicate question — silently, because the
prompt gets rewritten each time, so neither the run nor question_io's
validation could catch it.
- GRADE_STYLE gains یازدهم and دوازدهم. Without them both grades fell to
the grey fallback; they are deliberately cool colours, separate from the
six general grades, because حسابان is a ریاضیفیزیک-only book.
458 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
تا حالا اگر کسی روی سرور فایلی را دستی عوض کرده بود، `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>
روی این سرور یک `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>
روشی که این جلسه استفاده شد حالا نوشته شده تا هر کسی (آدم یا ایجنت) همان را
تکرار کند: رمز در یک فایلِ chmod 600 در پوشهی موقت، ورود با sshpass -f و
sudo -S از ورودی، و shred -u بلافاصله بعدِ کار.
دلیلِ هر قدم هم نوشته شد، چون بدونِ دلیل قدمها تشریفات بهنظر میرسند و کنار
گذاشته میشوند. مهمترینش: رمز هیچوقت در خطِ فرمان نیاید — روی این سرور چند
تیمِ دیگر حساب دارند و ps دستورِ هر پروسه را به همه نشان میدهد.
و صریح نوشته شد که رمز فقط از خودِ کاربر در همان جلسه میآید؛ جایی ذخیره
نمیشود و نباید بشود. اگر در گفتگو نبود، باید پرسید نه گشت.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ویو `title` را به کانتکست میدهد و admin/base.html خودش آن را هم در مسیرِ
راهنما و هم بهعنوان <h1> صفحه رندر میکند. قالب یک <h1> دستی هم داشت، پس
عنوان دو بار پشتِ هم چاپ میشد. دستی برداشته شد؛ عنوانِ تبِ مرورگر و مسیرِ
راهنما دستنخورده میمانند چون هر دو از همان `title` میآیند.
بخشِ وضعیت در CLAUDE.md هم بهروز شد: میگفت سرور کدِ قدیمی و دیتابیسِ خالی
دارد، که دیگر درست نیست. بهجایش چیزی نوشته شد که دفعهی بعد به درد بخورد —
اینکه فایلِ روی دیسک یعنی فایلِ مستقرشده نیست، و چه چیزهایی عمداً باز مانده.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
سرور دو روز کدِ قدیمی را سرو کرد و هیچکس نفهمید. علتش این بود که
`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>
CLAUDE.md تنها جایی است که هر جلسهی تازه اول میخواندش، و چیزهایی که این دوره
یاد گرفتیم جایی ثبت نشده بود. چهار بخش اضافه شد و «وضعیتِ فعلی» به بالا آمد:
• محتوا حالا در content/seed/ کامیت میشود و deploy خودش بالا میآوردش — بدونِ
Qdrant، بدونِ اعتبارِ AI، بدونِ SSH. و اینکه سوال با مسیرِ عنوان به درس وصل
میشود نه با id، چون idها بینِ دو دیتابیس یکی نیستند.
• لایهی راستیآزمایی: کتاب از روی تصویر خوانده میشود، پس خطا همانجا وارد
میشود. برچسب سهحالته است و سوال فقط از agreesها ساخته میشود.
• دو درسی که گران تمام شدند: نتیجهی دوحالته نرخ را سه برابر نشان میدهد، و
«۳/۱۰۴» در این کتابها عددِ اعشاری است نه کسر.
• شکافِ شکلها (۴۲٪ محتوا) و اینکه اصلاحش در پرامپت هست ولی اجرا نشده.
• تفاوتِ ۴۲۹ سقفِ حساب با ۴۰۳ اتمامِ اعتبار — و اینکه متنِ دومی فاصله دارد نه
زیرخط، که یک بار ۷۷۰ فراخوانیِ بیفایده به بار آورد.
«وضعیت تا ۲۰۲۶-۰۹-۱۷» هم اضافه شد: بکاند آماده و تستشده است، و دو مانعِ
باقیمانده دستِ ما نیست — سرور روی کدِ قدیمی، و فرانتی که به هیچ APIی وصل نیست.
هر دو قبل از اینکه کسی دنبالِ باگ بگردد باید چک شوند.
هیچ فایلِ تازهای اضافه نشد؛ همان فایلِ موجود بهروز شد.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
هر ایجنتِ تازه ساختارِ کد را میبیند ولی نمیداند صاحبِ پروژه دنبالِ چیست:
دو ریپو و یک قراردادِ API (نه ادغامِ ریپوها)، حذفِ هر قدمِ دستی از انتشار،
محتوای قابلِ ویرایش بدونِ برنامهنویس، و سنجشپذیر بودنِ کیفیتِ پاسخِ AI.
بدونِ این، پیشنهادها به سمتی میروند که با مسیرِ پروژه نمیخواند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
هر بار کسی تازه به این پروژه میرسد، همان سه چیز را از نو کشف میکند: اینکه
فرانت مسیرهای نسبی صدا میزند و nginx بهجای ۴۰۴ صفحهی اپ را برمیگرداند،
اینکه ترتیبِ بالا آوردن مهم است، و اینکه کلاینت را نباید از روی حافظه نوشت.
همه در CLAUDE.md نوشته شد، نه در فایلِ تازه — همانجایی که خوانده میشود.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
New progress.Character model: an admin-managed catalog (same pattern as
subscription.Plan) of selectable mascots, each unlocked once the user's
XP crosses unlock_xp. No separate ownership table yet — unlocking is a
pure function of UserStats.xp, kept intentionally minimal since this is
the first of an incremental rollout the senior asked for.
- GET /api/characters — catalog with per-user unlocked/selected flags
- POST /api/characters/select — picks an active character, rejecting
ones the user hasn't unlocked yet
- UserStats.selected_character_id tracks the pick
- seed_test_data now seeds 4 sample characters (owl/fox/turtle/lion)
for local/staging testing
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
payment.services.call_payment_gateway was a stub returning a fake
payment.gateway/pay/{id} URL. It's replaced with a real Zarinpal
integration (payment/zarinpal.py, Payment API v4):
- create_order/CreateOrderView still never call Zarinpal directly —
paymentUrl points at our own GET /api/payment/orders/{id}/pay
(PaymentRedirectView), which requests the real Zarinpal authority on
first visit and redirects to StartPay. This keeps order creation from
depending on Zarinpal's uptime/latency, and means none of the existing
purchase/idempotency tests needed to change.
- GET /api/payment/zarinpal/callback (ZarinpalCallbackView) is where
Zarinpal redirects the user back to; it always re-verifies
server-to-server via zarinpal.verify_payment before marking an order
paid, so a forged Status=OK in the query string alone does nothing.
- zarinpal.py fails closed (503 / invalid) when ZARINPAL_MERCHANT_ID
isn't configured, matching payment/bazaar.py's existing convention —
never a fake successful payment.
Adds Order.gateway_authority, PUBLIC_BASE_URL/ZARINPAL_* settings, and
a payment_pay throttle scope.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>