New progress.Character model: an admin-managed catalog (same pattern as subscription.Plan) of selectable mascots, each unlocked once the user's XP crosses unlock_xp. No separate ownership table yet — unlocking is a pure function of UserStats.xp, kept intentionally minimal since this is the first of an incremental rollout the senior asked for. - GET /api/characters — catalog with per-user unlocked/selected flags - POST /api/characters/select — picks an active character, rejecting ones the user hasn't unlocked yet - UserStats.selected_character_id tracks the pick - seed_test_data now seeds 4 sample characters (owl/fox/turtle/lion) for local/staging testing Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
5.9 KiB
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
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.
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 behindDEBUG— never enabled in prod.