- 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>
- 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>
GET /api/support/tickets returns the caller's last 20 tickets with their
support-reply count, on the read throttle so listing never spends the
30/hour create quota. Before it the app kept ticket ids only in one
browser's localStorage, so a ticket and its answer vanished with the device.
Completion notifications now use Persian digits and drop the
'with 0 stars ... 0 points' sentence when nothing was earned.
.env.example: NUM_PROXIES=2 (ArvanCloud + nginx), KAVENEGAR_OTP_TEMPLATE,
BAZAAR_PISHKHAN_API_SECRET, sandbox off by default; removed variables the
code no longer reads.
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>
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>
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>
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>
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>
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>
`.env.example` روی هر دو ماشین کپی میشود — لپتاپِ توسعهدهنده و سرور — و
مقدارش 1 بود. همان فایل روی سرورِ عمومی نشست و ماهها آنجا ماند، با کدِ
ورودِ 0000 باز برای هر شمارهای.
پیشفرض باید حالتی باشد که فراموش کردنش بیخطر بماند: 0 روی لپتاپ فقط یعنی
خطاها خلاصهترند، ولی 1 روی سرور یعنی هر کسی میتواند بهجای هر کاربری وارد
شود. هزینهٔ این دو اشتباه قابلِ مقایسه نیست.
DEBUG جای دیگری هم تعریف نشده: `DJANGO_DEBUG` فقط از محیط خوانده میشود و
`.env` در gitignore است، پس هیچ کامیتی حالتِ هیچ ماشینی را عوض نمیکند.
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>
دو فایلِ نمونه یعنی دو جا که باید همزمان بهروز بمانند، و همان چیزی بود که
قبلاً باعث شد نمونه عقب بماند و کسی که سرور را بالا میآورد نداند چه بنویسد.
حالا یک فایل، هم برای ماشینِ محلی هم برای سرور.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Admin login (and any POST form) fails with "Origin checking failed"
behind a proxy that terminates TLS, since Django had no trusted-origins
list configured at all.
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>
rag/services/tutor.py
- deleted: answer_question() had no callers (AskAIView calls
retrieve_context + generate_answer directly) and carried an unused
import.
rag/services/config.py
- docstring: drop the claim that the lesson-filter key is settings-driven
(it is auto-selected per subject in known_collections.py).
.env.example
- drop the leftover note about the removed RAG_QDRANT_LESSON_FILTER_KEY.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
settings.py
- DATABASES.default.CONN_MAX_AGE from DB_CONN_MAX_AGE env (default 60);
harmless on SQLite, saves a TCP+auth handshake per request on Postgres.
- DEFAULT_RENDERER_CLASSES: BrowsableAPIRenderer only when DEBUG — no
HTML API browser or its overhead in production.
- DEFAULT_THROTTLE_RATES['read'] = READ_THROTTLE_RATE env (default
120/min): a cheap ceiling for the hot GET endpoints.
- AUTH_USER_CACHE_TTL env (default 0 = off): TTL for the per-request
user-row cache added in a later commit; 0 keeps current behaviour.
.env.example
- document DB_CONN_MAX_AGE / AUTH_USER_CACHE_TTL / READ_THROTTLE_RATE.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
settings.py
- load .env via python-dotenv (override=False) so local runs and the RAG
keys work without `export`; production/docker env still wins.
- add 'rag' to INSTALLED_APPS.
- new RAG / AI provider block: OPENAI_API_KEY / OPENAI_BASE_URL (GapGPT)
/ OPENAI_CHAT_MODEL / OPENAI_VISION_MODEL / OPENAI_EMBEDDING_MODEL /
OPENAI_TIMEOUT_SECONDS, QDRANT_URL / QDRANT_API_KEY,
RAG_COLLECTION_NAME_TEMPLATE / RAG_GRADE_TITLE_OVERRIDES /
RAG_RETRIEVAL_TOP_K / RAG_FORCE_MOCK / RAG_ANSWER_CACHE_TTL.
- add 'rag_ingest' to DEFAULT_THROTTLE_RATES.
- under `manage.py test`: force RAG_FORCE_MOCK on and RAG_ANSWER_CACHE_TTL
to 0 so no test hits OpenAI/Qdrant or a warm cache.
urls.py
- mount rag.urls under /api/.
requirements.txt
- python-dotenv; openai, qdrant-client; pymupdf / pdf2image / pillow
(optional, only for PDF ingestion).
.env.example
- document the OPENAI_* / QDRANT_* / RAG_* keys, the discovered Qdrant
collection names, and the parked OPENAI_API_KEY_OPENAI_DIRECT slot
(no provider-switch logic by design).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>