هر بار کسی تازه به این پروژه میرسد، همان سه چیز را از نو کشف میکند: اینکه فرانت مسیرهای نسبی صدا میزند و nginx بهجای ۴۰۴ صفحهی اپ را برمیگرداند، اینکه ترتیبِ بالا آوردن مهم است، و اینکه کلاینت را نباید از روی حافظه نوشت. همه در CLAUDE.md نوشته شد، نه در فایلِ تازه — همانجایی که خوانده میشود. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
121 lines
8.8 KiB
Markdown
121 lines
8.8 KiB
Markdown
# 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 <token>`), 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.
|
|
|
|
## 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.
|
|
|
|
## 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.
|
|
- The server runs with `DJANGO_DEBUG=1` on a public domain, so OTP `0000` currently logs
|
|
anyone in as anyone. Harmless until real users exist; `/api/health` warns about it.
|
|
- No video content exists yet, though `Lesson.kind`/`video_url` can express it.
|
|
- Question generation and textbook ingest are blocked on AI API credit.
|