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>
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>
The Bazaar account owner registered /api/payment/bazaar/verify as the
redirect URI. POST stays the app's purchase check; GET now takes Bazaar's
?code=, exchanges it for a refresh token and stores it in the new
BazaarCredential row, so connecting needs no SSH or .env edit.
The GET is unauthenticated, so it only acts inside a window opened from
the admin (or bazaar_authorize_link): 72 h by default, closed by the first
successful exchange, state checked when Bazaar echoes it. Authorize/token
URL shapes follow the known devapi v2 flow; not checked against Bazaar's
current docs, which did not load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
102 unique textbook questions, 1,287 full model answers, per-answer cost and
the hand grades for both rounds. Book excerpts (retrieval context) are left out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two benchmark rounds (14 models on 30 problems, then gpt-4o-mini vs gpt-4.1-mini
on 102 problems under three prompt versions, hand-graded): gpt-4.1-mini scored
95-96/102 against 85-90 for gpt-4o-mini, at 0.12 vs 0.04 cents per answer.
The default now lives in settings so the deploy needs no .env edit.
The context note "in the book / is a decimal point, not a fraction" was written
for the upper-grade books but went to every grade, so gpt-4.1-mini computed
1/7 + 1/8 as 3.5. For primary grades the note now says / can be either, and a
reading rule sits next to the question itself (the same rule at the end of
the system prompt had no effect). Slash problems went from 11 to 14 of 17;
three are still wrong.
generate_answer now reports the provider's exact per-call cost, reasoning
tokens and whether the raw output broke the plain-text rule. The benchmark
fixes primary grades being sent with no book context, caches retrieval so
runs compare on identical context, and retries Qdrant timeouts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Kavenegar is being switched on, so every OTP request now costs credit:
- phone numbers are normalised to 09XXXXXXXXX everywhere (login, OTP,
family invite, TEST_LOGIN_PHONES keys) and existing rows are migrated;
non-Iranian and landline numbers are rejected before any SMS
- cooldown and a daily per-phone cap are counted in the database, so the
three gunicorn workers no longer triple them
- a global daily OTP SMS cap (default 300) stops SMS pumping
- per-IP rates widened for CGNAT / school Wi-Fi (env-overridable)
- provider message id is logged; SMS text warns not to share the code
Second audit round: an offer no longer shortens a longer paid plan,
Zarinpal error bodies no longer 500, island XP reaches the parent report,
a non-numeric questionId no longer 500s after the answer is generated.
596 tests green.
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>
Each heart used to take 24 hours, so a student who lost all five waited up to
five days. Now one heart returns every 5 hours, and 24 hours after the first
heart was lost they all come back at once (the owner's call). /api/user/hearts
reports both times (nextRefillAt, fullRefillAt); an existing timer longer than
5 hours is shortened the first time it is read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found in a deeper pass over what the frontend's flow actually touches:
- Nothing in the app ever wrote StreakRecord: "days in a row" was 0 forever
for every real user. Answers and lesson/island completions now record the
day (Tehran time); a broken chain shows 0. A streak error never fails the
answer itself.
- /api/leaderboard only changed when someone ran rebuild_leaderboard by
hand. It is now computed live (60s cache); weekly counts island XP too.
- gunicorn ran 3 sync workers with a 30s timeout, so every "ask Pi" stream
pinned a whole worker (~10s, GapGPT buffers the answer) and three at once
queued the entire API. gunicorn.conf.py (read automatically from the
working directory) switches to gthread, 8 threads, 120s timeout.
- Every /api/ response now says Cache-Control: private, no-store, so turning
on caching in the ArvanCloud panel can never serve one student another's data.
- /api/user/summary named no active plan ever ("subscription app not
implemented yet" had long stopped being true).
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>
ZARINPAL_REDIRECT_URL was empty on the server, so after a real (non-sandbox)
payment the callback answered with {"orderId","status"} on the API domain and
the student was stranded there. It now defaults to FRONTEND_URL
(https://math.tadrisino2.ir/) with ?orderId=&status=; reopening a finished
order's pay link goes back to the app the same way. "-" keeps the old JSON.
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 shared guest account was hand-seeded into the production DB on 2026-09-22 so
the frontend developer could see the notification and leaderboard screens before
any real traffic exists: deviceId "frontend-test-device", user 6, with 24
Notification rows and 20 LeaderboardEntry rows across both periods, ordered
differently per period so the ?period= parameter is visibly doing something.
None of that was recorded in the repo. db.sqlite3 is gitignored, so the account
existed only in the head of whoever set it up — exactly the kind of thing that
disappears at a handoff. It is now in docs/frontend/README.md, where the
frontend developer looks, and in docs/operations.md, where whoever runs the
server looks.
Verified against production before writing it down: the account still resolves
to user 6 with isNewUser false, notifications return rows, and both leaderboard
periods return six entries. The notifications payload is keyed "items", not
"results", which is noted because it is easy to read as an empty response.
The operations entry carries the warning that matters: this is hand-written data
in a production database, purge_demo_data will not catch it (it only matches
[SEED]/[DEMO] prefixes on Grade/Plan/Character/User), and it has to go before
real users arrive or they will see invented notifications.
The Swagger description now says a deviceId can be any string and that
pre-seeded ones exist on request, but deliberately does not print the specific
value: /api/docs/ is reachable from the public internet, and a public API
reference should not advertise a shared account that anyone can sign into.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The README had grown into a 295-line mixture of onboarding, an ops runbook, a
content-authoring workflow, bulk question generation and a Docker log stack. A
reader arriving at the repo could not tell in ten seconds what this service is,
whether it is live, or where to start, and five of its claims no longer matched
the code:
- it said to export .env values by hand; settings.py has loaded .env through
python-dotenv for a while now
- it listed six variables as "everything the app actually reads"; .env.example
has carried many more than that for some time
- it called DJANGO_SECRET_KEY, DJANGO_ALLOWED_HOSTS and CACHE_URL required in
production; all three now have working defaults, and the app boots without
them on purpose so a partial .env cannot take the service down
- it claimed there is no insecure SECRET_KEY fallback; there is one, under DEBUG
- it required Python 3.12 while the project runs on 3.11 (Docker uses 3.12)
The README now states what the service is, where it runs, how healthy it is and
how to get it running in six commands, then links onward. Procedures moved to
docs/operations.md, docs/content-authoring.md, docs/architecture.md and
docs/docker.md, which is where someone follows a procedure rather than skims it.
Nothing was dropped; the deploy one-liner now includes the separate sudo restart
that deploy.sh cannot perform itself, and backup/restore is written down for the
first time.
Every number in the README was measured rather than quoted: content counts from
the live /api/health and content/seed/curriculum.json, 55 endpoints from the
generated OpenAPI schema, 481 tests from a full run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With DEBUG=0 and Kavenegar unconfigured, POST /api/auth/otp/request returns a
hard 503, so the only way to sign in was for someone with admin access to hand-
create an OTPCode row. That made every frontend developer's login depend on one
person being available.
TEST_LOGIN_PHONES is the pattern real apps use for exactly this (Firebase Auth's
fictional numbers, Apple's App Store demo account): a short allowlist of
phone:code pairs that skip SMS delivery entirely. Unlike DEV_MASTER_OTP it is
not "any phone with one code" and it is not tied to DEBUG -- each number carries
its own code, and everything else takes the normal OTP path untouched.
The code never appears in an API response, so knowing the number is not enough
to log in, and the existing per-phone login attempt limit still applies, so the
code cannot be brute-forced. Malformed configuration raises ImproperlyConfigured
at startup rather than being skipped silently, and /api/health reports how many
test numbers are open (count only) so they cannot be forgotten on a server that
has real users.
The code is capped at six digits because LoginRequestSerializer caps otp at
max_length=6; a longer code would have been rejected before reaching the check
and looked like the feature was broken.
The guest path the frontend uses today is untouched, and leaving the variable
empty reproduces the previous behaviour exactly.
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>
docs/extraction-playbook.md: چهار تصمیمی که تفاوتِ چند برابری در هزینه و کیفیت
ساختند، با عددهای اندازهگیریشدهی واقعی نه تخمین.
سرفصلها: انتخابِ مدل از روی اندازهگیری (gpt-4.1-mini هم دقیقتر از gpt-4o بود
هم ۳٫۵ برابر ارزانتر، و gemini برخلافِ جدولِ قیمت سه برابر گرانتر)، قاعدهی
self-contained، تعمیرِ بکاسلشِ LaTeX، محافظِ شکل، و ساختِ سوال با subagent
بهجای API.
وجهِ مشترکِ هر چهار مورد: هیچکدام خطا تولید نمیکردند — نه در لاگ، نه در تست.
فقط دادهٔ کمتر یا بدتر میساختند. برای همین توصیهی محوریِ فایل این است که قبل
از اعتماد به هر ران انبوه، نمونهای ۴۰-۵۰تایی با چشم خوانده شود.
تستهای rag هم اینجا میآیند: بازخوانی، تعمیرِ JSON، و محافظِ شکل.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
سینیور خواست زودتر به فرانتاند وصل شویم تا از آنجا هم تست شود. اینها
سمتِ ما لازم بودند:
CORS با allowlist صریح (نه ALLOW_ALL): احرازِ هویتِ ما توکنِ Bearer در هدر
است، پس origin ناشناس نباید از مرورگرِ کاربر به API ما بزند.
CORS_ALLOW_CREDENTIALS عمداً خاموش میماند — کوکیای در کار نیست و روشن
کردنش فقط سطحِ حمله اضافه میکند. CorsMiddleware بالاتر از CommonMiddleware
گذاشته شد وگرنه ریدایرکتِ APPEND_SLASH و خطای ۴۰۰ هدرِ CORS نمیگیرند و در
مرورگر بهشکلِ خطای مبهمِ شبکه دیده میشوند.
PATCH /api/user/profile اضافه شد؛ تا پیش از این هیچ راهی برای ثبتِ نامِ
کاربر نبود. GET /api/home بدونِ پارامتر حالا پایهی ذخیرهشدهی کاربر را
برمیگرداند و POST /api/ai/ask فقط با lessonId کار میکند — هر دو برای
اینکه فرانت لازم نباشد پایه را خودش نگه دارد. questionCount به
lessons/{id}/intro اضافه شد.
docs/frontend/api.ts و README.md کلاینتِ تایپدار و راهنمای اتصالاند، از
روی OpenAPI واقعیِ همین سرویس تولید شدهاند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>