- subscription.0017/0018, payment.0013: pack plans deleted, QuestionCredit/HeartsPass models and Plan.kind /
question_credits / hearts_days / Order.question_credits / hearts_days dropped. The only paid pack order was the
company owner's test account (user 26).
- Assistant: no credit spending; quota is the daily cap only (free 2, every plan 4, gift subscription 4).
- Bazaar: verify_purchase is subscription-only (no inapp guess), start/purchase responses lose productType,
verify refuses an order without a subscription. A paid order with no subscription now logs ERROR.
- Removed POST /api/payment/orders (internal; no caller). Its permission tests stay.
- Pack-based tests rewritten on subscriptions (refund, security, Bazaar token); pack-only test files deleted.
- docs: profit table for 4/day on both payment routes plus free users; handbook, operations, CLAUDE.md current.
949 tests green.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Purchase with paymentMethod=bazaar now works for subscription plans with a bazaar_sku (not only packs);
response carries bazaarProductType. Migration subscription.0014 puts 10343/10344 on the 90/180-day plans.
- Verify asks Bazaar for the right product type (from the order) and stores validUntil (payment.0012).
- POST /api/payment/bazaar/orders/{id}/start: SKU, type, and an HS256 dynamic-price JWT when a discount
code lowered the price (BAZAAR_DYNAMIC_PRICE_KEY); discount on Bazaar orders allowed only with that key
and only before start.
- Auto-renewal: sync_bazaar_renewals re-asks Bazaar when the paid period is near its end (status page and
the paywall via has_subscription_access); each new period becomes a paid order.
- Default BAZAAR_PACKAGE_NAME = com.tadrisino.math.app; health shows bazaarDynamicPrice.
979 tests green. No real Bazaar purchase or renewal tested.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- /api/ai/ask: at most AI_MAX_CONCURRENT_PER_WORKER (12 of 32 threads) answers per worker;
beyond that 503 assistant_busy + Retry-After before any quota is reserved. Without it a
burst of questions (3-40 s each) took every thread and the map and quizzes queued behind.
The slot is held until the stream is actually finished or closed.
- Answer submission 21 -> 14 queries, 15.4 -> 11.0 ms: one query for all choices, locked
get_or_create for node progress and streak, and today's streak recorded once per day
via the cache instead of a transaction per answer.
- /api/home 12.4 -> 8.6 ms: the user's completions are read by user_id instead of an IN list
of ~600 lesson ids; the dict is returned directly (serializer stays the Swagger contract,
HomeMatchesItsSerializerTests keeps them equal).
- otp_login per-IP 30 -> 300/hour: a class of 30 on one school Wi-Fi filled it; guessing is
bounded per code and per phone.
- /api/health aiConcurrency.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Postgres here allows 60 connections, but each of the 96 gunicorn threads held its
own persistent one: at 100 concurrent users (local Postgres with the same limit)
21% of requests were 500 "remaining connection slots are reserved". Now each worker
has a psycopg pool (8), so the same load gives 0 errors and 714 req/s (was 460).
- A full pool answers 503 + Retry-After (DRF handler and middleware), not 500.
- The AI tutor and the OTP SMS call return their connection before the network wait.
- statement_timeout 30 s, idle-in-transaction 60 s; bad pool settings and the old
DB_CONN_MAX_AGE stop startup instead of failing every request.
- /api/health dbPool: per-worker pool stats and workers x pool + 6 vs max_connections.
- Indexes on the columns global counters filter by time (weekly leaderboard,
global SMS cap, global AI cap); the leaderboard recomputes once per expiry.
- Worker count moves into gunicorn.conf.py (4 on a shared 8-core box).
- security doc: Redis item closed (server uses our own Redis since 2026-10-08).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
- content: unit_is_free/has_unit_access; questions, node quiz, answer and lesson/node
complete return 402 subscription_required outside the first unit without an active
subscription. /api/home units carry requiresSubscription.
- subscription.0013: monthly 498k, quarterly 798k, six-month 1,298k toman, unlimited
hearts, 3 AI questions/day; packs stay on sale for more questions.
- AI_DAILY_QUOTA_FREE default 3 -> 2.
- /api/subscription/status adds planTitle, planDurationDays, aiDailyQuota.
- tests: content/tests_paywall.py; existing unlock-order tests get a subscription.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Health: public gets only ok/error; details need X-Internal-Key. Docs/schema: admin session only.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The owner decided (2026-10-07) that we do not connect to anything other teams run on the box, Redis
included. CACHE_URL was emptied on the server and our keys were removed from the shared Redis.
- settings: drop KEY_PREFIX / CACHE_KEY_PREFIX (it only existed because the Redis was shared); comments
say CACHE_URL is intentionally empty and ResilientRedisCache is ready for our own Redis.
- config/cache.py docstring and tests updated accordingly (11 tests, still run against a local redis-server).
- CLAUDE.md: new server rule 8 (touch only our own files and service), status block, historical line
that said "db1 is free" now says not to connect. README, operations, handbook, security doc (item 9
reopened as an open decision) say the same.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Since 2026-10-07 the cache is the shared Redis on the box. Django's RedisCache raises on any network
error and DRF reads the cache for every throttled request, so a Redis outage meant 500 on almost every
route, /admin/login/ and OTP request included (all seven drill tests fail on the stock backend).
config.cache.ResilientRedisCache turns Redis errors into logged (rate-limited ERROR) misses:
get->default, set/delete->ignored, add->True, incr->ValueError. Cache-based caps (DRF throttles, hourly
OTP window, admin-login limiter, payment reconcile lock) are NOT enforced during an outage (fail-open);
DB-backed caps stay. /api/health returns 503 meanwhile. KEY_PREFIX namespaces our keys on the shared
Redis; 1s connect/read timeouts stop a hung Redis from holding requests for gunicorn's 120s.
Tested: 11 tests, incl. a real local redis-server killed mid-test and restarted without restarting the
app. Not tested yet on the server at the time of this commit.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
accounts.0008 drops the columns and refuses to run while guest rows exist.
Docs describe the current state; tests that only proved guest behaviour are gone.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
POST /api/auth/session is gone (404). Existing is_guest rows keep refresh and
upgrade-by-phone until the demo/guest rows are purged; the code that serves
them is listed in docs/security-before-production.md §10. Tests that used the
endpoint as a fixture now use accounts/testing.guest_session.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- Admin: refund/cancel/mark-resolved/Bazaar-link actions need change permission; question and
curriculum import need add/delete; content browser needs view_question; guest device ids are
shown to superusers only.
- Accounts: registering a guest's phone revokes the guest's earlier sessions; self-deletion keeps
Redemption, UserOffer and the phone's OTP rows; the public demo guest cannot be deleted
(DEMO_GUEST_DEVICE_IDS); display-name contact filter covers any separator and social handles.
- Payment: callback without Authority is 400; a second Bazaar purchase on a paid order is 409;
orders snapshot credits/hearts/days at creation (payment.0011).
- Assistant: a broken diagram no longer makes a streamed answer free; a late off-topic marker
refuses the answer (not stored, not cached); a disconnect saves the partial answer; only
KNOWN_COLLECTIONS are searched.
- Infra: secret-key file is created atomically (workers no longer split keys); no default
INTERNAL_SERVICE_KEY; Kavenegar key and Bazaar token kept out of error logs; Swagger/ReDoc
pinned; schema throttled; docker compose bound to 127.0.0.1; non-ASCII digit ids are 400;
ticket text capped at 5000.
- docs/security-before-production.md: remaining open items 24-31 and the guard tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- /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>
- 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>
Plan.kind (pack/subscription) collided with the lesson kind
(quiz/video); drf-spectacular named one KindEd8Enum and warned.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- packs (20 / 60 / 150 questions for 45,000 / 119,000 / 259,000 toman) never expire and
give 7 / 30 / 60 days of unlimited hearts; old subscription plans stay for existing
subscribers but are no longer on sale
- credit ledger (subscription.QuestionCredit): +N per paid pack (once per order), -1 per
tutor question that reaches the API; the free daily questions are spent first; questions
stopped by the server filter, cached answers and failed answers cost nothing
- unlimited hearts from packs live in HeartsPass, so buying a pack never rewrites a
running subscription
- Message.charge records how each question was paid; GET /api/ai/quota and the ask
responses report free-left-today and credits
- HEARTS_ENFORCED now defaults to on: zero hearts stops answering until refill or a pack
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- profile publicId ("User482917", accounts/public_id.py); admin shows and searches it
- plans API reports aiDailyQuota per plan and freeAiDailyQuota; migration 0007 sets the
temporary prices (monthly 99,000 / yearly 990,000 toman, 5 tutor questions a day) unless
an admin already edited the yearly plan
- tutor tone follows the learner's age (18+ gets an adult tone at any grade); notation
still follows the grade's book; answer cache keyed by tone (prompt tutor-v12-age-tone)
- POST /api/user/delete: phone users confirm with a fresh SMS code (same checks as login,
now shared in verify_phone_otp), guests type "حذف"; payment records are kept, demo
accounts refused
- delete_users --skip-paid
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- remove the family app from the root (separate parent accounts, invite codes, parental
settings); progress.0006 drops its tables, permissions and migration history, and
Profile.parent_phone, the two family notification types and ?userId= on
/api/progress/nodes go with it
- GET /api/user/weekly-report: last 7 days next to the 7 before (estimated minutes,
questions, accuracy, islands, tutor questions) for the "parent report" page
- profile birthDate in Jalali (accounts/jalali.py), ages 5-99, birthYear derived from it
- GET /api/payment/invoices; orders now snapshot list price and plan title
- tutor answers only math: server pre-filter, scope rule in the prompt (tutor-v11),
refusal marker replaced with fixed text; self-harm gets a help line instead
- streamed answers now record token usage (it was always 0)
- daily tutor quota: AI_DAILY_QUOTA_FREE (default 3) vs Plan.ai_daily_quota
- delete_users management command for wiping test accounts across every app
- docs/ai-cost-and-guardrails.md: measured cost per question and guardrail results
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
Swagger UI and Redoc lay every description out left-to-right, because they were
built for English. Our text is Persian, and in an LTR block the sentence-final
full stop jumps to the start of the line, table columns come out reversed, and
every English word inside a Persian sentence lands in the wrong place. The
screenshot of /api/docs/ showed all three.
This is not fixable by rewriting the text, because the base direction of the
block is what is wrong. Both views now render through a thin template of ours
that extends the drf-spectacular one via {{ block.super }} and adds scoped CSS:
right-to-left for the blocks that carry rendered Markdown, and an explicit
left-to-right override for code and pre, so that "Authorization: Bearer <token>"
and every JSON sample stay readable. Table cells align right, list bullets move
to the right margin, and the API title keeps its LTR treatment since it is
English.
Writing the description in English instead would have fixed only the header. The
per-endpoint descriptions are Persian across the whole codebase and were broken
the same way, so the shell fixes the entire page rather than one block of it,
and keeps the repo's convention of Persian documentation.
The upstream templates are untouched, so a drf-spectacular upgrade cannot be
broken by this.
Two tests cover it, because this failure is invisible to every other kind of
check: nothing errors, nothing 500s, the page simply reads wrong to a human.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Swagger header was a list of features. That is what the project does, not
what a caller needs. It now opens with the three things anyone integrating
against this API asks first: how do I get a token, what does an error look like,
and when do I get rate limited -- plus the field-naming and date conventions,
and a note that the schema is the contract and clients should be generated from
it rather than written from memory. Swagger UI and Redoc both render Markdown,
so it is structured rather than one long paragraph, which also fixes how badly
the old mixed Persian/Latin line wrapped right-to-left.
Two defects surfaced while checking the rendered schema:
- /api/health was absent from the schema entirely. The trailing-slash alias was
excluded with extend_schema(exclude=True)(HealthView), but that decorator
mutates the class, and both routes pointed at the same class, so the canonical
route disappeared with it. A subclass now carries the exclusion, and the
endpoint is back, exactly once. Endpoint count is 56, not 55.
- One endpoint was tagged 'محتوا (content)' while every other content endpoint
used 'Curriculum (content)', which split the group into two sections.
Tags are now declared with descriptions and ordered by the path a real user
takes, rather than alphabetically.
Comment pass over the codebase, keeping the substance and dropping the noise:
the "⚠️ اضافه شده" prefixes dated comments against a moment no current reader
shares; "فاز ۲ / بخش ۴" referenced a plan that no longer exists and only
repeated the app name on the next line; "فاز اول - ۴ از ۱۱ اپ" had drifted (there
are fourteen). INSTALLED_APPS is now grouped by responsibility. The
TEST_LOGIN_PHONES block in settings shrank from 29 lines to 8 now that the same
explanation lives in docs/operations.md and .env.example -- a comment beside the
code should carry what a code reader needs, not a copy of the runbook.
Checked mechanically that no comment references an identifier that no longer
exists: zero. The dated measurements in rag/services/ingest.py and
generate_questions_from_chunks.py were deliberately left alone -- a measurement
carrying the date it was taken is the useful kind.
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>
فهرستِ ثابتِ CORS (۳۰۰۰ و ۵۱۷۳) روزی شکست که Vite دید ۵۱۷۳ اشغال است و
بیسروصدا رفت روی ۵۱۷۴ — رفتارِ پیشفرضِ خودش. فرانتکار خطای CORS گرفت، از
سمتِ سرور هیچ ردی در هیچ لاگی نبود (خطا فقط در کنسولِ مرورگر دیده میشود)، و
چون همزمان با تغییراتِ بکاند شد به اشتباه گردنِ ما افتاد. هزینهاش یک ویرایشِ
.env و یک ریاستارتِ پروداکشن بود، برای چیزی که اصلاً به پروداکشن ربطی نداشت.
بهجای شمردنِ پورتها، الگوی «هر پورتی روی خودِ ماشینِ توسعهدهنده» پذیرفته شد.
سطحِ حمله اضافه نمیکند: توکنِ ما در هدر است نه کوکی و CORS_ALLOW_CREDENTIALS
خاموش، پس صفحهای روی localhostِ مهاجم هیچ اعتبارنامهای از مرورگرِ قربانی
نمیگیرد؛ CORS هم اصلاً سدِ سرور-به-سرور نیست.
تستها هم پوشش میدهند که localhost.evil.com و math.tadrisino2.ir.evil.com
همچنان رد شوند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
سه کارِ بههمپیوسته که از یک کشف شروع شدند: راهحلهایی که vision از کتاب
درآورده گاهی غلطاند، و این خطا در بازیابی دیده نمیشود — فقط وقتی کسی به
`solution` اعتماد کند خودش را نشان میدهد.
۱) بازبینیِ راهحلها (rag_verify_solutions)
هر chunk دوباره حل میشود و با کتاب مقایسه. نتیجه سهحالته است، نه بله/خیر:
اجرای اول با بولی ۱۹٪ خطا گزارش کرد؛ خواندنِ دستیِ نمونهها نشان داد ۳۵٪ از
آنها مسئلههایی بودند که به شکلِ کتاب ارجاع میدادند و اصلاً قابلِ قضاوت
نبودند، و ۲۴٪ موردی که «۳/۱۰۴» را کسرِ ۳÷۱۰۴ خوانده بود نه عددِ ۳٫۱۰۴. با دو
حالت، «نمیدانم» ناچار به «غلط است» ترجمه میشد.
با سه حالت و پرامپتی که این دو تله را میشناسد: ۲٬۱۱۹ chunk بررسی شد،
۱٬۱۱۲ درست، ۱۲۳ غلط (۵٫۸٪)، ۸۸۱ غیرقابلِ قضاوت. آن ۸۸۱ رایگان برچسب خوردند
چون پیشفیلتر قبل از هر فراخوانی کنارشان گذاشت.
`solution` هرگز بازنویسی نمیشود — کتاب باید کتاب بماند. برچسب در payloadِ
Qdrant هم مینشیند، ولی با نامی که `retrieval._payload_text` نمیخواند: یک
نویسه هم به متنی که به مدل میرسد اضافه نشد.
نمونهی خطاهای واقعیِ کتاب که پیدا شد:
BMI = ۱۰۰/(۲٫۷۴)² کتاب: ۳۳٫۲ درست: ۱۳٫۳
۱۶ آبی + ۲۴ صورتی کتاب: «کل ۵۰» درست: ۴۰
بزرگترین عددِ ۴رقمیِ بیتکرار کتاب: ۸۵۳۲ درست: ۹۸۷۶
g(x) = x³ − ۲ کتاب: «نمایی» درست: چندجملهای
۲) سوالها فقط از راهحلهای تأییدشده
۱٬۱۹۹ سوال در content/seed/questions.json، همه از chunkهایی که برچسبِ
«درست» گرفتهاند. هر شش پایه برنامهی درسی دارند (۱٬۹۲۸ درس) — هشتم و دهم
تازه ساخته شدند.
۳) GET /api/nodes/{id}/quiz
واحدِ بازی در اپ «مبحث» است ولی سوالها به «درس» بستهاند، و درس اینجا
ریزترین دانه است: یک نوعِ مسئله، نه یک جلسه. نتیجهاش این بود که اپ برای یک
آزمونِ چهارسوالی باید دهها درخواست میزد. این اندپوینت از همهی درسهای مبحث
نمونه برمیدارد، حداکثر یکی از هر درس تا آزمون روی یک مهارت گیر نکند.
درهمریزی با بذرِ nodeId:userId پایدار است: رفرشِ صفحه همان آزمون را میدهد،
نه مجموعهی تازهای که پیشرفت را بیمعنا کند. `questionCount` جدا از
`requested` میآید تا مبحثِ کمسوال وانمود نکند کامل است.
──────────
اصلاحاتِ جانبی که سرِ راه لازم شدند:
• QDRANT_TIMEOUT_SECONDS: کلاینت بدونِ timeout ساخته میشد و پیشفرضِ ۵
ثانیهایِ کتابخانه ingestِ کتابِ هشتم را روی اولین فراخوانی کشت.
• پاکسازیِ نقاطِ نیمهنوشته: upsert با wait=True ممکن است نوشته باشد و فقط
جوابش تایماوت شده باشد؛ مسیرِ جبران فقط ردیفهای DB را پس میگرفت و هر
retry یک نسخهی تکراری در Qdrant جا میگذاشت (۸ مورد در پایه نهم، پاک شد).
• تشخیصِ سقفِ حساب: «insufficient user quota» با فاصله میآید نه زیرخط، و
محافظِ اول نمیگرفتش — یک اجرا ۷۷۰ فراخوانیِ محکومبهشکست زد.
• پرامپتِ vision حالا میخواهد محتوای شکل داخلِ متنِ مسئله نوشته شود. ۴۲٪ از
محتوای موجود به شکلی ارجاع میدهد که در متن نیست؛ مدل آن را دیده بود ولی
از او خواسته نشده بود بنویسدش. کتابهای موجود قبل از این قاعده وارد شدهاند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
دو شکاف که وقتی آدمهای دیگر به پروژه وصل میشوند مهمتر میشوند.
کوکیِ نشستِ پنلِ ادمین و توکنِ CSRF بدونِ پرچمِ secure رد و بدل میشدند.
احرازِ هویتِ خودِ API کوکی نیست و اینها فقط برای ادمیناند — یعنی دقیقاً
همانجایی که اعتبارنامهی واقعی وارد میشود. حالا خارج از DEBUG امناند، و
SECURE_PROXY_SSL_HEADER هم اضافه شد چون TLS روی nginx تمام میشود و بدونش
جنگو درخواست را HTTP میبیند و کوکیِ امن اصلاً ست نمیشود.
SECURE_SSL_REDIRECT و HSTS عمداً روشن نشدند: اولی پشتِ پراکسیِ بدتنظیم
حلقهی ریدایرکت میسازد و کلِ سایت را میخواباند، دومی در مرورگرِ کاربر کش
میشود و عملاً برگشتناپذیر است. هر دو تصمیمِ زیرساختیاند نه کد.
/api/health حالا ترافیک را هم گزارش میدهد: تعدادِ کاربر، فعالهای ۲۴ ساعتِ
اخیر، و زمانِ آخرین فعالیت. تا امروز هیچ راهی نبود از بیرون بفهمیم فرانت
واقعاً به سرور رسیده یا نه — باید منتظر میماندیم کسی بگوید. فقط شمارش و
زمان؛ تست جداگانه بررسی میکند هیچ شماره یا نامی بیرون نرود.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
پیشفرضهای دیروز روی یک سرورِ تازه کار میکردند ولی روی سرورِ واقعی نه، و
تستِ با .env واقعی نشانش داد: هر دو حالتِ شایع خنثیشان میکرد.
مقدارِ موجود ولی ناقص: .env سرور از قبل
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 دارد. چون مقدار خالی نبود، کاملاً
جایگزینِ پیشفرض میشد و دامنهی api.math.tadrisino2.ir باز هم غایب میماند —
یعنی همان «Disallowed Host» قبلی، و باز هم ویرایشِ دستیِ فایل. حالا
دامنههای خودمان به مقدارِ محیطی اضافه میشوند نه جایگزینش؛ همین برای
CSRF_TRUSTED_ORIGINS و CORS_ALLOWED_ORIGINS هم اعمال شد.
مقدارِ موجود ولی خالی: PUBLIC_BASE_URL در .env هست و خالی است، و
os.environ.get فقط وقتی کلید *نباشد* پیشفرض میدهد. نتیجهاش paymentUrl
نسبی و callbackِ خالیِ زرینپال بود — خرابیای که فقط موقعِ اولین پرداختِ
واقعی معلوم میشود. حالا مقدارِ خالی هم به پیشفرض میافتد.
این سهتا همانهاییاند که تا امروز مسئولِ سرور باید دستی به .env اضافه
میکرد و چون در .env.example هم نبودند، هر بار از نو کشف میشدند. حالا در
همان فایل مستند شدهاند و تست نگهبانشان است.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
سه بار پشتِ سر هم همین الگو تکرار شد: یک متغیرِ محیطی ست نبود، جنگو موقعِ
بالا آمدن مرد، و از بیرون فقط «502 Bad Gateway» دیده شد. کسی که سرور را بالا
میآورد هیچ سرنخی نداشت، و تشخیص هر بار یک رفتوبرگشتِ یکروزه بود.
ALLOWED_HOSTS و CSRF_TRUSTED_ORIGINS حالا دامنههای شناختهشدهی خودمان را
پیشفرض دارند. CSRF مخصوصاً مهم است: بدونش ورود به پنلِ ادمین از پشتِ پراکسیِ
HTTPS «CSRF verification failed» میدهد و علتش از پیام معلوم نیست.
SECRET_KEY اگر ست نباشد، در پروداکشن یک کلیدِ تصادفیِ واقعی ساخته و در
.django_secret_key نگه داشته میشود (۰۶۰۰، خارج از گیت). همانقدر
غیرقابلِ حدس، بدونِ یک قدمِ دستیِ دیگر؛ و چون روی دیسک میماند، ریاستارت
نشستهای کاربران را باطل نمیکند.
CACHE_URL نبودن دیگر مانعِ بالا آمدن نیست. مشترک نبودنِ کش بینِ workerها یک
ضعفِ واقعیست ولی دلیلِ خوبی برای خوابیدنِ کلِ سرویس نیست؛ حالا بالا میآید و
/api/health صریحاً هشدار میدهد. تستِ مربوطه هم از «باید بالا نیاید» به
«باید بالا بیاید ولی بیصدا نماند» تغییر کرد.
متغیرِ محیطی همچنان بر همهی این پیشفرضها اولویت دارد.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
سه حالتِ خراب که هیچکدام در لاگِ سرور ردی نمیگذارند و فقط وقتی کشف میشدند
که یک آدم از بیرون اپ را دستی امتحان کند.
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>
بالا آوردنِ نسخهی جدید تا الان چند کارِ دستی به ترتیبِ درست بود (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>
سینیور خواست زودتر به فرانتاند وصل شویم تا از آنجا هم تست شود. اینها
سمتِ ما لازم بودند:
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>
Two bugs made self-ingested content useless, both silently:
- _payload wrote `chapter_number` (int) while math retrieval filters on
`chapter` (the full "فصل ۲: کسر" string), so anything we ingest could
never match a search — no error, just permanently invisible content.
- ingest_pdf flattened the vision extraction into one text blob and threw
away type/keywords/generalSolution/problem/solution. LessonChunk is the
source of truth, so rebuilding meant paying for vision again — and the
problem/solution pairs are the input for building lesson questions.
LessonChunk now stores those fields and _payload emits them under the same
keys the other team's collections use, so ours and theirs stay one shape.
Also fixes a regression from the whitenoise commit: the Manifest storage
variant raises when staticfiles.json is absent, which broke all 23 admin
rendering tests (Django forces DEBUG=False in tests, so USE_FINDERS is off)
and would have taken the admin panel down entirely the day DEBUG=0 ships
without a collectstatic run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Admin/DRF/Swagger CSS and JS never load under gunicorn since Django's
staticfiles app only auto-serves through `runserver`. WHITENOISE_USE_FINDERS
is tied to DEBUG so no collectstatic step is needed while DEBUG=1.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>