The API moved to mathapi.tadrisino2.ir on 2026-09-26 (nginx server_name
changed at 23:22) and api.math.tadrisino2.ir no longer resolves. The code
default for PUBLIC_BASE_URL and the docs' curl examples still named the dead
host, so every paymentUrl, the Zarinpal callback and the Bazaar redirect the
backend builds pointed nowhere. Historical status notes in CLAUDE.md keep the
old name on purpose. config/tests.py now reads the API host from
PUBLIC_BASE_URL instead of a literal. 624 tests green.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
The account's line cancels free-text SMS (status 13); the senior confirmed
the account is fine and only the template name was missing. With
KAVENEGAR_OTP_TEMPLATE set, OTP goes to verify/lookup with just the code as
token; without it the old sms/send path is unchanged. Both share one
provider call and response check. 620 tests green.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Read against Bazaar's own docs (wp-json of developers.cafebazaar.ir):
both validation URLs were wrong, so no real purchase could ever verify.
In-app is now api/validate/<pkg>/inapp/<sku>/purchases/<token>/, and
subscriptions add the missing /purchases/ and judge validUntil, since
that response has no purchaseState.
Only 404 means "not purchased"; 401/403/5xx now raise 503 so our own
key or quota trouble cannot mark a paid order failed. The client token
is URL-quoted so it cannot steer the request to another API path.
BAZAAR_PISHKHAN_API_SECRET (the documented new method, a static header
token with no OAuth) takes precedence when set; /api/health gains
integrations.bazaar. Not yet exercised by a real purchase.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/security-before-production.md records, item by item, what is exposed
on purpose while this is an internship demo: the committed token-signing key
still in use on the server, seven fixed-code demo logins, demo users in the
production database, a public /api/health that reports internal state, a
public API schema, a public leaderboard with children's names, /admin on the
public domain, the server answering on port 80 around ArvanCloud, per-worker
rate limits without Redis, guest login by deviceId alone, unhashed refresh
tokens and OTP codes, AI cost exposure through throwaway guests, hearts not
enforced, SQLite without off-box backups, and shared root access to the box.
Each has its risk, why it was left, the fix and how to verify it; the
exposure checks were run against the live server on 2026-09-25.
CLAUDE.md, README and docs/operations.md point to it so no agent mistakes
this deployment for production-ready.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tutor (prompt tutor-v3-stepwise-plain): plain text instead of LaTeX/markdown,
with rag/services/mathtext.py converting whatever still slips through; full
step-by-step solutions with a check before the final answer; three tones by
grade; the last 6 messages of the conversation go to the model and follow-ups
are no longer served from another user's cache; book solutions the checker
disputes are kept out of the context; a questionId gives the tutor the
practice question and the student's wrong answer. /api/health reports when
the AI credit runs out. rag_model_benchmark compares chat models on verified
book problems (run on the server; credit ran out mid-run).
Map: only lessons that have questions count toward finishing an island,
lessonIds[0] / nextLessonId is always the next lesson to play, and a finished
island stays finished. The Seyar characters (sigma/delta/pi) are seeded.
Audit fixes, each reproduced before fixing:
- the plan is stored on the order, so paying a monthly order after starting
a yearly checkout no longer grants a year
- opening checkout no longer demotes an active subscription; renewals extend
from the current expiry; expired subscriptions are not reported active
- one discount code per order and only before the gateway; a 100% code
completes the purchase; lost Zarinpal confirmations are re-verified
- the unused /api/payment/webhook "mark paid" endpoint is removed
- XP counts only answers recorded on the server
- 6-digit OTP, each code dies after 5 wrong guesses; POST /api/auth/logout
- a guest who signs up keeps their account and progress (test phones excluded)
- affiliate commission on the first real payment; guests excluded
- parents with several children no longer get a 500
- SQLite runs transactions IMMEDIATE, since it ignores select_for_update
- admin login is rate-limited; display names drop phone numbers and links;
userQuestion is capped at 2000 characters
- server-side heart enforcement exists behind HEARTS_ENFORCED (off until the
frontend handles 403 no_hearts)
Not done on purpose: rotating the server's secret key and moving to Postgres.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Production is ArvanCloud -> nginx -> gunicorn, but NUM_PROXIES was 1, so DRF
took ArvanCloud's edge IP as the client: every guest and every OTP request
behind one edge shared a single throttle bucket (20 guest sessions an hour for
everyone). Default is now 2, and /api/health reports clientIp so the proxy
count can be checked from outside.
PATCH /api/notifications/read-all replaces the frontend's one-PATCH-per-item
loop, which spent the shared "read" budget 75 requests at a time.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The frontend plays node quizzes, but a node only completed once every one of its
8-13 lessons was completed separately, so students were stuck after the first
island of each unit. POST /api/nodes/{id}/quiz/complete now completes the node
(any score; score only sets stars/xp) and returns nextNodeId.
Also closes the other backend gaps found while auditing the frontend:
- /api/ai/ask without lessonId now picks the book from the user's grade instead
of skipping retrieval, so the "ask Pi" tab is grounded in the textbook.
- guest sessions return a refreshToken; they used to go silently 401 after 1h.
- lesson_complete throttle 10/hour -> 120/hour (a lesson here is 1-2 questions).
- progressPercent is per grade, not over all 2,617 lessons of every grade.
- a one-year plan (299,000 toman) is created by migration when no plan exists.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Grade ids were the order the books were imported in (ninth=1, eighth=5), so
gradeId: 5 meant eighth grade and the admin, the question filter and the API
all listed grades out of order. Grade.number (1-12) is now read from the title,
new grades take it as their id, and migration 0008 moves existing ids plus every
reference to them: Unit, the loose grade_id in accounts/ai_tutor/rag, and the
grade target inside campaign JSON. Unnumbered demo grades move above 100 and the
id sequence is raised past 12 so they never collide with a real grade.
The frontend needs no change: it always reads gradeId from the server.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>