# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Django + DRF backend for Tadrisino, an educational app (Persian/Farsi). Codebase and API responses are in Persian; comments in source are Persian too. ## Commands ```bash source venv/bin/activate python manage.py runserver python manage.py migrate python manage.py test # all tests python manage.py test accounts # single app python manage.py test accounts.tests.SomeTestCase.test_something # single test ``` Local dev requires `DJANGO_DEBUG=1` (enables Swagger auth via `DEV_MASTER_OTP`/`devOtp` in responses). `DEBUG` defaults to `False`. Production also requires `DJANGO_SECRET_KEY`, `DJANGO_ALLOWED_HOSTS` (comma-separated), and `CACHE_URL` (Redis — required whenever `DEBUG=False` since rate limiting needs a shared cache; raises `ImproperlyConfigured` otherwise). No pytest; uses Django's built-in test runner. No linter/formatter configured. Docker (`docker compose up --build`) adds a local Loki+Grafana Alloy+Grafana log stack; not a production setup (still `runserver`, not gunicorn). See comments in `docker-compose.yml`, `docker/alloy/config.alloy`, and `LOG_DIR` in `config/settings.py`. API docs: Swagger `/api/docs/`, Redoc `/api/redoc/`, admin `/admin/`. ## Architecture **Apps** (each owns its own models, one responsibility): | app | responsibility | key models | |---|---|---| | `accounts` | identity, sessions, OTP auth | `User`, `Profile`, `Session`, `OTPCode` | | `content` | lessons/curriculum | `Grade`, `Unit`, `Node`, `Lesson`, `Question`, `Choice`, `NodeProgress` | | `progress` | stats, leaderboard, streaks, gamification characters | `UserStats`, `LeaderboardEntry`, `StreakRecord`, `UserHearts`, `Character` | | `ai_tutor` | AI assistant chat | `Conversation`, `Message`, `MessageFeedback` | | `subscription` | plans | `Plan`, `Subscription` | | `payment` | orders/transactions (Zarinpal gateway + Cafe Bazaar IAP) | `Order`, `Transaction` | | `discount` | discount codes/offers | `DiscountCode`, `Redemption`, `Offer`, `UserOffer` | | `notifications` | notifications | `Notification` | | `affiliate` | referral program | `AffiliateAccount`, `Conversion` | | `support` | tickets/bug reports | `BugReport`, `Ticket`, `TicketMessage` | | `family` | parental control | `FamilyLink`, `ParentalSettings`, `WeeklyReportLog` | | `statuslog` | generic cross-app status-change log | `StatusChangeLog` | | `campaigns` | messaging campaign storage only, no send logic yet | `MessageCampaign` | | `config` | project settings, root urls, middleware | — | **Auth is custom, not Django's.** `accounts.User` is deliberately *not* `AUTH_USER_MODEL` — it's an independent business model. Auth is a stateless signed bearer token (`django.core.signing`, no JWT library) issued/verified in `accounts/authentication.py` (`TadrisinoTokenAuthentication`, header `Authorization: Bearer `), backed by `accounts.Session`. Missing/invalid header → anonymous request, not a hard failure; each view's permission class decides if auth is required. **Apps are decoupled by convention**: instead of real FKs across app boundaries, code uses loose refs (`user_id`, `object_id` + `label`) so any app can be split out later. Don't add cross-app FKs — follow the existing loose-reference pattern. **Throttling**: DRF `ScopedRateThrottle` with per-endpoint `throttle_scope` strings, rates centralized in `REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']` in `config/settings.py`. Guest users are throttled by IP (`NUM_PROXIES` setting controls how many XFF hops are trusted — must match real proxy count in prod or IP spoofing/throttle-bypass is possible). Adding a new sensitive endpoint means adding both a `throttle_scope` on the view and a matching rate entry in settings. **`POST /api/ai/ask`** currently returns a stub; real model integration point is `generate_tutor_answer` in `ai_tutor/views.py`. **Structured logging**: `config.middleware.APIRequestLoggingMiddleware` (last in `MIDDLEWARE`, closest to the view) logs every `/api/` request as JSON (method/path/status/duration/user, no body/headers/secrets) via `config/log_formatters.py`. **SQLite** is current DB (`OPTIONS.timeout=20` to tolerate concurrent-write locks, e.g. campaigns tests); README notes Postgres migration is still pending. **SMS** goes through Kavenegar (`KAVENEGAR_API_KEY`/`KAVENEGAR_SENDER`); failures return explicit 503, not a fake success. **Gamification characters** (`progress.Character`, Duolingo-mascot-style): an admin-managed catalog (like `subscription.Plan` — created via Django admin or `seed_test_data`, not by users). Unlocking is currently just `UserStats.xp >= Character.unlock_xp` (no separate ownership table); `UserStats.selected_character_id` is the user's active pick, set via `POST /api/characters/select` after that check. First real content beyond the model/plumbing (art assets, more characters, purchase-with-coins, etc.) is expected to land incrementally later. **Payment (Zarinpal)**: `create_order`/`CreateOrderView` never call Zarinpal directly — `paymentUrl` points at our own `GET /api/payment/orders/{id}/pay` (`PaymentRedirectView`), which calls Zarinpal's PaymentRequest API on first visit (storing `Order.gateway_authority`) and redirects to StartPay; this keeps order creation from depending on Zarinpal's uptime. Zarinpal redirects the user back to `GET /api/payment/zarinpal/callback` (`ZarinpalCallbackView`), which always re-verifies server-to-server via `payment/zarinpal.py::verify_payment` before marking an order paid — a forged `Status=OK` in the query string alone does nothing. Both `payment/zarinpal.py` and `payment/bazaar.py` fail closed (503 / invalid) when their credentials aren't configured, never a fake success. ## Where this is heading (the owner's intent) The owner writes this backend; another developer writes the frontend; a third person administers the server. None of them share a machine, and until recently the two halves were built in parallel without a shared contract — which is why reconciling them cost rework. The goal is not to merge the repos. **Two repos, one API contract** is the intended shape, and the contract lives in this repo's OpenAPI schema (`/api/docs/`), not in anyone's head. What the owner is steering toward, in rough order: 1. **Both halves running against each other**, on real content, with nobody hand-editing files on the server. Most of the plumbing for this now exists. 2. **Nothing manual in a release.** Adding a dependency, a setting or a service must not add a step someone has to remember. When a change would, fold it into `deploy.sh` or make startup fail loudly and legibly instead. 3. **Content editable without a developer** — questions, curriculum, plan copy, all from the admin panel. A management command run over SSH is a last resort, not a workflow. 4. **Answer quality measurable**, not assumed: per-question reports, wrong-answer rates, prompt-version feedback. The senior's stated priority is proving the AI actually teaches, especially where students get stuck. 5. **Scale without drama** — thousands of concurrent users should be a non-event. Prefer the boring fix (an index, a cache, a cheap guard) over new infrastructure. The owner is early in their career and learning the collaboration workflow as they go. Explain the *why* alongside the *what*, flag when something is a product decision rather than a coding one, and never assume a step was done just because it was described. ## Frontend ↔ backend (read before wiring the two together) The app is two repos and two domains: the React frontend lives in `Tadrisino/MathApp_frontend` (deployed at `math.tadrisino2.ir`, owned by another developer) and this backend serves `api.math.tadrisino2.ir`. Our typed client for them is `src/api/` inside their repo, delivered by pull request — we have read-only Gitea access there, so changes go via a fork + PR, never a direct push. **The one trap that keeps biting.** Their `App.tsx` calls relative paths (`fetch('/api/auth/login')`). Those resolve against the *frontend* origin, where nginx answers any unknown path with `index.html` — so the response is `200` with an HTML body and the code dies on `res.json()` with `Unexpected token '<'`. Verify with `curl https://math.tadrisino2.ir/api/grades`: HTTP 200, HTML. Two fixes exist, both shipped in PR #3: a `vite` dev proxy (dev works unchanged) and an exported `apiUrl()` for builds. **The better long-term fix is infrastructure, not code** — proxy `/api` on the frontend domain to this backend, which makes everything same-origin and removes CORS entirely. That needs the server admin, not us. **Order matters when bringing the stack up:** backend first, then frontend. If the frontend is tested while `api.math.tadrisino2.ir` is down, every screen fails and it looks like the client is broken. **Never hand-write the client from memory.** Three of fifteen calls were silently wrong the first time. Dump the real contract with `python manage.py spectacular --file schema.yaml --format openapi`, then drive the actual client module against a running server (`runserver` on a spare port + `npx tsx`) before sending anything. `content/tests_frontend_contract.py` is the backend-side guard. **Content the frontend expects but nobody has filled in yet:** `Unit.blurb`, `Plan.features`/`badge`, `Lesson.example`/`steps`. All optional, all editable from the admin panel. `Node.icon`/`color` fall back to a deterministic palette so cards are never colourless. ## Operations `./deploy.sh` is the whole release: pull, install, migrate, collectstatic, restart, then it polls `/api/health` and prints the likely cause if the service does not come back. Settings boot with working defaults (hosts, CSRF, CORS, secret key, cache), so a stale or partial `.env` on the server no longer breaks startup — env values are *merged with* those defaults, not replaced by them. `GET /api/health` is the outside view: database, unapplied migrations, cache, content counts, security warnings, and recent traffic. It is how you check whether anyone has actually connected without asking a human. ## Content: where it lives and how it gets to a server `content/seed/` holds the whole curriculum and question bank as committed JSON — `curriculum.json` (6 grades, 48 units, 230 nodes, 1,928 lessons) and `questions.json` (1,441 multiple-choice questions). `deploy.sh` runs `seed_content` on every release, so a fresh server fills itself with one command: **no Qdrant, no AI credit, no SSH session.** `seed_content` is idempotent and never deletes. Questions bind to lessons **by title path** (grade › unit › node › lesson), never by primary key — ids differ between any two databases, and binding by id silently attaches questions to the wrong lesson. `seed_content --export` writes the files back from the local DB. It refuses to overwrite a non-empty `questions.json` from a DB that has no questions (that used to wipe the file silently); use `--export --only curriculum` when you only mean the curriculum. ## Answer quality: the verification layer Books are read by a **vision model from page images**, so extraction errors enter there — a misread digit, a dropped decimal separator, a solution attached to the neighbouring problem. These are invisible in retrieval and only surface when something trusts the text. `rag_verify_solutions` re-solves every extracted problem independently and compares. `LessonChunk.solution_verdict` is three-state — `agrees` / `disagrees` / `unjudgeable` — and the verdict also rides in the Qdrant payload under a key `retrieval._payload_text` does not read, so the tutor's context is unchanged. Current state: 1,112 agree, 123 disagree, 881 unjudgeable. **Question generation only ever reads `agrees` chunks.** Two things that were learned the expensive way and are already encoded in the prompts: - A boolean verdict is wrong. With two states every "I can't tell" becomes "wrong", which reported ~3× the real rate. Read samples before trusting any bulk run. - `۳/۱۰۴` in these books is the decimal 3.104, not the fraction 3÷104. `solution` itself is never rewritten — the extracted text stays as the record, and the independent answer is stored separately in `checked_answer`. ## The figure gap (largest open data issue) 881 chunks (~42%) reference a figure or table that is not in the text, so they cannot be answered or turned into questions. **The vision model saw the figure; it was simply never asked to write it down.** `rag/services/pdf.py` now carries a self-contained rule telling it to inline measurements and labels into the problem text — books ingested before that rule predate it. Recovering them means re-ingesting those pages (~$2.7 of API credit). ## Quiz assembly The app plays at **node** level (the island on the map) but questions hang off **lessons**, and a lesson here is the finest grain — one problem type from the book, not a class session. `GET /api/nodes/{id}/quiz` assembles a quiz from a node's lessons, at most one question per lesson. Shuffle is seeded on `nodeId:userId`, so a refresh returns the same quiz; `questionCount` is reported separately from `requested` so a thin node cannot look like an error. 172 of 230 nodes currently hold a full four-question quiz. ## AI provider notes `OPENAI_BASE_URL` points at GapGPT (an OpenAI proxy reachable from Iran) and it exposes ~127 models, including reasoning ones. Two failures look alike and must not be treated alike — `is_account_limit()` in `rag_verify_solutions` is the shared guard: - `429 initial_usage_limit_exceeded` — account cap, hours long. Retrying **extends** it. - `403 insufficient user quota` — credit exhausted. Note the spaces: an earlier guard matched `insufficient_quota` with an underscore, missed it, and burned 770 doomed calls. `/dashboard/billing/usage` returns cents and is the only reliable balance signal; the `hard_limit_usd` in `/dashboard/billing/subscription` is a placeholder, not a balance. **The key is currently out of credit.** Bulk work since then runs on Claude Code subagents instead. Sonnet handles question authoring well (~88% yield); Haiku does not — it cannot reliably pull the final answer out of a solution and produced ~40% broken output. ## Known gaps (per README pre-deploy checklist) - Still on SQLite, not Postgres. - Throttle rates need periodic review against real traffic. - `DEV_MASTER_OTP` (`0000`) must stay gated behind `DEBUG` — never enabled in prod. - No video content exists yet, though `Lesson.kind`/`video_url` can express it. - 58 of 230 nodes still hold fewer than four questions; their verified chunks ran out. - Kavenegar is unconfigured, so `POST /api/auth/otp/request` returns 503. With `DEBUG=0` there is no way to log into the API without creating an `OTPCode` row in the admin (`/admin/accounts/otpcode/add/`) — this is how to hand a tester a working login. ## State as of 2026-09-17 — read this first **Backend is done and verified; nothing is blocked on us.** The full game loop was driven end to end against a fresh database with `DEBUG=0`: grades → login → set grade → home → node quiz → submit answer → CORS header for the frontend origin. `main` is pushed and clean at `16d3d9f`; 435 tests green. Two things are blocked on other people, and both are worth checking before assuming a bug: 1. **The server still runs pre-16d3d9f code with an empty database.** `git pull && ./deploy.sh` brings both code and content. Verify with `curl /api/health` — it should report `questions: 1441`. A 404 there means the pull never landed. 2. **The frontend is not wired to any API.** `src/api/` was merged (PR #1) but is imported nowhere; `QuestionScreen` in `App.tsx` still renders four hardcoded multiplication questions. **PR #3** (`zareian/api-client-2`, 3 commits) is still open on upstream. Until someone connects the screens, none of the 1,441 questions are visible. The frontend's `Question` type carries a `type` field that nothing reads, and `count_objects` in `Question.TYPE_CHOICES` is an empty shell — no rows, no serializer branch, no rendering anywhere. Do not build content for it without asking first.