Compare commits

..

199 Commits

Author SHA1 Message Date
92b157eda4 CLAUDE.md: owner's standing server authorization; recovery keys copy is with the owner
What an agent may do on the servers without asking (read the password
file, SSH, deploy and restart on 216, LAN transfers), what still needs a
question (deleting data, changes on 249/247, /etc and .env, keys, firewall,
credit spend), and what is never allowed. SSH example fixed for zsh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:48:48 +03:30
4efe6e3f6b Cut-over to 216: docs, CLAUDE.md and nginx source describe 216 as the live server
Arvan now sends both domains to 216 (2026-10-18). The final 249 dump was
restored on 216 with equal row counts, 249's app is stopped and disabled,
its replication slot dropped and 247's 249-era units disabled. The 249-era
archive on 247 was age-encrypted to 216's key and the plaintext shredded.
qdrant.tadrisino2.ir broke at the same moment (Django 400 via Arvan) and is
recorded as waiting on the senior.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:40:47 +03:30
43e02adf37 216 sshd: LoginGraceTime back to 2m (30s silently cut off people typing a long password)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 00:59:59 +03:30
e25ffb5575 CLAUDE.md: 216 password is the AdminUser-Password line
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 00:48:23 +03:30
eb03b467d4 216: shared admin account mathapp-admin, temp removed, hostname mathapp
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:50:17 +03:30
f2c57c1ad1 docs: step-by-step cut-over to 216 with rollback, what waits on others, after-cut-over list
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:18:40 +03:30
f6d779255d docs: what was tested on 216 (end-to-end user flow, restore from the 247 encrypted copy)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:08:38 +03:30
4cd859b5fa 216 backups encrypted on 216: pgBackRest (AES-256) + age-encrypted payment CSVs, pushed write-only to 247 with rrsync; health watches pg_stat_archiver and OFFSITE_SYNC_STATUS_FILE; 216 scripts/configs in ops/server216; plaintext pg216 pipeline removed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:43:00 +03:30
dd98f16d12 Live backup of 216 on 247 (zareian-pg216-* units, Postgres 16 tools in our dir, SCRAM hash only on 216), backup scripts take PGB_ROOT/PGB_BIN/PGB_PORT with 249 defaults, anti-bot layers documented
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:22:56 +03:30
bd329f83ae Dedicated server 78.38.35.216: state and cut-over order in docs, CLAUDE.md entry, password snippet for two Password lines, archive-app-log.sh finds its project dir
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:06:04 +03:30
670513b462 tools/figures: server_fixups.json (corrections of already-published questions and lessons), doc line
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 03:36:00 +03:30
133233144e Seed: grade 12 questions (every lesson 4+ per lesson, answers computed in code), minus-sign and doubled-x fixes in grade 10 and 11
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 03:35:34 +03:30
a5a93ef6ba Seed: grade 11 questions (every lesson 4+ per lesson, answers computed in code)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 03:27:10 +03:30
5f963e2898 Seed: grade 10 questions (every lesson 4+ per lesson, answers computed in code)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 03:16:51 +03:30
7bd7c637b4 Seed: grade 9 questions (every lesson 4+ per lesson, answers computed in code)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 03:00:27 +03:30
ac96f93abc Seed: grade 8 questions (every lesson 4+ per lesson, answers computed in code)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 02:48:33 +03:30
9903e0ec91 Seed: grade 5 rebuilt (every lesson 4+ questions computed in code), wording fixes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 02:30:33 +03:30
759bb0d575 Seed: grade 4 rebuilt (every lesson 4+ questions computed in code), slash decimals fixed in chapter 5
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 02:06:21 +03:30
f8a93a37c9 Seed: grade 7 rebuilt (every lesson 5+ questions computed in code), lost-exponent questions fixed
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 01:47:44 +03:30
9eae99340d Seed: grade 6 rebuilt (every lesson 5+ questions, answers computed in code), supplementary/complementary text fixed in grades 4/6/7, '/' decimals to '٫', wrong keys fixed
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 01:31:28 +03:30
f57deb9c1d Seed: grade 3 rebuilt (every lesson 5+ questions, wrong figures/lesson text fixed), Persian digits and plain fractions across all grades, apply_fixups gains lesson/question_set, ledger save chunked for SQLite
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:27:22 +03:30
5fbbd371fe Seed: questions for the 499 lessons that had none; every lesson now has questions (5,406/5,406)
998 four-choice questions (2 per lesson), written from each lesson's own text,
worked example and the book's extracted problem. Mostly grades 3, 6, 7 (books
imported before us), plus 4, 5 and a few in 8-12. Hands-on lessons (drawing,
measuring, folding) got text questions that test the same idea.
Checked: structure (4 distinct choices, no LaTeX), a blind re-solve of all 998
against the key (no mismatch), and content.tests_seed_content seeding the full
file. Not checked: an independent second solver.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:15:13 +03:30
82fec6bf75 Admin: PaymentEvent read-only ledger view (was documented but not registered)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 02:18:07 +03:30
8a2bf26b16 Lesson.order from the book (seed sets it, also re-syncs unit/node order); matrix lesson rows as one multi-line step
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 13:53:30 +03:30
3f68abe741 Explicit ordering for Lesson, Question, Choice (+id tie-break on Unit/Node): lessons no longer reshuffle after row updates
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 13:39:42 +03:30
7973bf4100 Raw LaTeX guard: vdots/ddots/sqrt( converted, leftover() sees bare words, matrix lesson and 4 intro texts fixed in seed
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 13:24:11 +03:30
0c6e155232 More figured questions for grade 12 (geometry conics/vectors, calculus graph reading)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 02:26:16 +03:30
e6e983f970 Figures must not give the answer away: blank titles on new figured questions, 7 trivial questions removed, 7 reworded; apply_fixups tool
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 02:21:02 +03:30
733796c25a kind=space (fixed oblique 3D: points, vectors, planes, box) + ~190 figured questions for grade 12 (geometry, discrete, calculus)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 01:04:08 +03:30
998b68e4bc Figured questions for grade-12 geometry (conics, systems, vectors: 50 new); docs: figured questions win in the island quiz
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 22:24:53 +03:30
cd4f2ce0b2 Figure questions for grade-10 geometry (115 new), quiz prefers a figured question per lesson, tools/figures/batch.py
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 20:24:55 +03:30
a4e0c576bb Docs brought to the current state: CLAUDE.md rewritten, README formal, handbook and diagrams redrawn
- CLAUDE.md: 1,225 → ~430 lines, current state only (the dated status log stays in git history).
  Server rules moved to the top: what is ours on 249/247 and what is not, non-negotiable rules,
  announce-before-acting, password handling, and the full "deploy it" procedure with verification.
- README: formal entry page with current numbers (10 grades, 18,522 questions, 61 API operations,
  973 tests, PostgreSQL/Redis).
- Removed docs/frontend/ (Figma-era guide with guest login and its own api.ts; the frontend repo has
  the real client). Swagger's external-docs link now points at the frontend repo.
- architecture/operations/security/content-authoring/loadtest/textbooks: SQLite, guest login,
  demo accounts, question packs and the port-80 note replaced by what is true now.
- Technical handbook text updated throughout; all seven diagrams redrawn (PostgreSQL, phone-only
  login, subscriptions, current data model); PDF rebuilt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:45:51 +03:30
eb95209ea8 deploy.sh: chmod only our own backups; a root-owned nginx backup aborted the release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:59:08 +03:30
1c7c4b8eca API output without Persian diacritics or written ezafe ی (company decision); tutor answers too
plain_persian() strips kasra/fatha/damma/shadda/sukun/hamza-above and the written «ه‌ی» ezafe, keeping tanwin. Applied
at output, not in data: PlainPersianJSONRenderer runs one byte-level pass over every JSON response (~5 ms per MB
locally), and to_plain() cleans tutor answers including the stream. Rewriting titles in the DB/seed would have broken
question binding by title path and SeedRecord keys. Tests updated to expect the cleaned text; config/tests_plain_persian.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:57:17 +03:30
7b241c1aac CLAUDE.md: restore onto 249 was already done (owner)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:16:58 +03:30
e3c8962b7b Load test of 1000 players on the server: CPU is the ceiling (~135 req/s); player-simulation tools
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:48:00 +03:30
ba4ecff29e Server hardening: nginx origin locked to ArvanCloud + CSP, demo logins removed, docs
- ops/nginx/: the two nginx sites as they now run on 249. Only ArvanCloud
  edges reach the app (direct hits on the IP get 403, so Arvan's DDoS/WAF
  can't be skipped); the frontend gets CSP (own sources only, the one inline
  script by hash), X-Frame-Options, nosniff, Referrer-Policy,
  Permissions-Policy, HSTS; API body limit 100M -> 20M plus HSTS. Checked from
  outside and in Chrome (all tabs and an island quiz, logged in): no CSP errors.
- Demo accounts (users 11-15, 25) and TEST_LOGIN_PHONES removed from the server;
  the health warning for fixed-code logins now has a test and says how to
  test instead (an OTPCode made in the admin).
- SMS daily cap on the server is 2000 (launch of ~600 users).
- CLAUDE.md: never run tests with --parallel on macOS, always --noinput
  (both hang forever without an error).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:17:11 +03:30
2a03dc81b5 Docs: X-Forwarded-For spoofing closed; Arvan bypass (no WAF) still open
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:50:47 +03:30
e3bddb3757 Trust X-Forwarded-For only from ArvanCloud edges
nginx on the server also answers on its own IP. A direct request with a fresh
X-Forwarded-For each time got a new throttle identity every time and skipped
every per-IP limit (OTP request/login, admin login): reproduced on the server
2026-10-13, 65 of 65 health requests with rotating XFF got 200.

TrustedProxyMiddleware reads X-Real-IP (set by nginx from the TCP peer) and,
when the peer is not in TRUSTED_PROXY_NETWORKS (default: ArvanCloud's
published list), replaces XFF with the peer address. Logs a WARNING once an
hour per untrusted peer that sent a proxied XFF, so a new Arvan range shows up.

Also ignore the parallel test DB clones (.test-db_N.sqlite3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:45:27 +03:30
78c3669119 Docs: app logs archived nightly and kept forever on the backup server
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:56:12 +03:30
3559c43902 App logs kept forever: nightly gzip on 249 (LOG_FILE_ROTATION=external), hourly pull to 247 via a forced-command key, health warns if the archive stops
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:54:05 +03:30
f4831e93a7 Docs: payment event ledger and its CSV export on the backup server
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:47:24 +03:30
44ad2eabf3 Payment events: one central payment_result row for every path (callback, reconcile, Bazaar, renewal, 100% code), discount_applied, no duplicate order_created; Bazaar token never stored
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:44:50 +03:30
8d8665edf3 Payment event ledger (PaymentEvent) replicated to the backup server, plus CSV export script on 247
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 14:18:47 +03:30
947f946909 Question packs removed at the root; all subscriptions 4 assistant questions a day; Bazaar is subscription-only
- 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>
2026-10-12 20:33:38 +03:30
9c3adc3e60 Subscriptions only: Bazaar panel prices (598/898/1398k), assistant quota 5/3/3 from a cost calculation, packs off sale
- subscription.0016: prices equal to the Bazaar panel (senior), ai_daily_quota 5/3/3, card features; packs on_sale=False
  (one paid pack order, user 26, keeps its credits; pack paths stay for that order and for admin).
- Quota math in docs/ai-cost-and-guardrails.md: ~77% of a Bazaar sale reaches us; worst case (full quota every day)
  the assistant costs 23/27/35% of it.
- Assistant quota message points to the subscription, not packs.
982 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:57:16 +03:30
c0fa46be44 Payment providers as classes (Strategy + factory); 10342 = monthly; health logs a failed Bazaar check
- payment/providers.py: ZarinpalProvider / BazaarProvider with one interface (check_plan, sku_for, payment_url,
  purchase_extras, discount_blocker, reconcile_user); provider_for / provider_for_method pick one; unknown fails.
  create_order, purchase view, discount redeem and reconcile_user_orders use it instead of scattered ifs.
- subscription.0015: Bazaar SKU 10342 on the one-month plan (owner: "probably the monthly one").
- config/health: the Bazaar check no longer swallows its exception without a log.
982 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:21:45 +03:30
76b0ef976e Bazaar: plans cache cleared by migration 0014, enum name override, CLAUDE.md status
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:59:43 +03:30
8077ab4350 Bazaar: subscriptions (10343/10344), dynamic price (discount codes on Bazaar), auto-renewal sync
- 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>
2026-10-12 17:53:11 +03:30
2e0753587c Like/dislike + report under every question and lesson, lesson reports and vote columns in admin; practice islands refresh from file; audit fixes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 12:56:47 +03:30
b075b93f2a Tutor/answers: bare subscripts (S_n, a_1) become Unicode; migration 0022 re-cleans DB text
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 12:18:46 +03:30
e662914efc No LaTeX/markdown anywhere students read (seed, import, DB migration 0021, tutor history, frontend guard); quiz of an island is fixed per user again
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 12:15:28 +03:30
dd10ff83f4 13,689 new quiz questions in 160 practice islands (grades 3-12), Question.figure, replay rotates the quiz, LaTeX removed from lesson/question text
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 11:58:18 +03:30
f9eea56895 CLAUDE.md: tester accounts
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 11:13:51 +03:30
3187f69714 UNLOCK_ALL_PHONES: tester accounts see every island unlocked (map and server rule)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 11:12:13 +03:30
33478179e2 CLAUDE.md: truth tables and island labels checked in the live app
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 10:57:01 +03:30
9c3596a132 CLAUDE.md: what is on the server after the subjects/figures release and what was checked
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 05:44:32 +03:30
8cb1ecb85e Subjects per grade (math, calculus, geometry, stats, discrete), 5 new textbooks, 202 new islands, long islands split, hand-written figures and lesson texts for the new books
- content.Subject + Unit.subject, /api/home?subject=, tutor grounded in the chosen book
- 5 new books ingested; ~1150 hand-written questions, islands named by hand
- node_split: islands over 7 lessons split into parts of <=6 (lesson ids and progress kept)
- Lesson.figures hand-authored and visually checked for 653 lessons of the new books;
  gen_lesson_figures got --subject and survives network errors
- seed files re-exported

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 05:35:18 +03:30
3d51b3b83a CLAUDE.md: capacity under load (measured vs estimated), open launch-day caps, how to load-test the shared server safely
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:27:54 +03:30
98bf05fcbd OTP code requests per IP 20 -> 100/hour so a class on one school Wi-Fi can all log in
SMS cost stays bounded by the per-phone (10/day) and system-wide daily caps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:11:08 +03:30
2c708088e4 Spike readiness: AI gets its own slice of threads, cheaper hot paths, classroom-friendly login
- /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>
2026-10-11 20:55:24 +03:30
feaa5eb540 Postgres connection pool, clean 503 under overload, time indexes, 4 workers
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>
2026-10-11 20:21:35 +03:30
d32d98d3f6 Full lesson audit (grades 3-12): 167 lessons rewritten for wrong or missing facts (terminology, wrong answers, incomplete/ambiguous prompts), 115 final answers added, 11 figures rebuilt and 6 removed to match the new text
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 15:11:11 +03:30
673a9e7e39 Lessons that depended on a missing figure or table now carry their own data (15 rewritten), figures added for 5; fixes wrong answers in 3638 and 3769
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 14:48:51 +03:30
1fe453095f Geometry and measurement lessons: figures for the remaining drawable lessons (shapes without invented numbers), fraction bars and number lines for pure-arithmetic lessons
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 14:41:01 +03:30
4a41def4b2 Lesson figures: 2,466 lessons (2,927 figures) now carry a fixed figure; graphs, tables, trees, number lines, area models, pies
Hand-written and visually checked per grade; no API calls. Server-side: seed_content only fills empty cells, so changed figures need the one-off refresh described in the deploy notes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 13:34:18 +03:30
293b916544 Lesson content complete: examples/steps for all 3,778 lessons, 1,775 lessons with fixed figures, 38 quiz fixes
- curriculum.json: example/steps for every real lesson, figures (2,224) hand-written and visually checked
- questions.json: 3,703 questions (24 added, 14 keys fixed)
- gen_lesson_figures: strict check, thin-figure filter, recheck, --max-usd; API figures limited to plane/fraction/numberline/solid

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 01:29:36 +03:30
4821df3914 CLAUDE.md: pointer to the next-APK notes and why app payments go through pay.html
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:54:12 +03:30
9024c6b8d8 /api/user/summary: streak.activeToday and streak.lost for the app's lit / at-risk / frozen flame
activeToday: activity recorded today (Tehran date). lost: length of a streak that broke (last active day
before yesterday), so the app can show it frozen. Same rule as effective_current_streak; the weekly report
keeps its own two-field streak serializer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:27:14 +03:30
2e87a81cc5 CLAUDE.md: pre-move SQLite copy removed from the server; the old August backup script was never scheduled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:14:42 +03:30
d10e3e4c9f deploy.sh: backup retention used ls on a glob that can be empty, killing the deploy under pipefail
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:53:57 +03:30
a9cccc6550 Live off-site Postgres copy on 247 replaces the nightly pg_dump; backup retention in deploy.sh
- 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>
2026-10-10 16:53:10 +03:30
19072ee534 Fourth companion renamed Tedi -> Omega (ω); slug stays tedi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:42:44 +03:30
a39643b8a3 CLAUDE.md: paywall and subscriptions are live
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:20:51 +03:30
64c4c09326 Paywall: first unit of each grade free; 1/3/6-month subscriptions; AI sold separately
- 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>
2026-10-10 01:19:02 +03:30
7d81d193bd Scheduled Postgres backup, restore check, backup warnings in health, payment result logging
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 17:32:06 +03:30
a5feee053a CLAUDE.md: Bazaar package name now matches the APK (com.tadrisino.math.app)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 14:12:28 +03:30
22c3196343 Close /api/health details and API docs to the public
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>
2026-10-08 13:05:19 +03:30
d6529956e1 Docs: Postgres + own Redis are the server's current state
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 12:43:48 +03:30
c1cc6be0cd User activity file includes failed logins matched by phone
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 12:41:06 +03:30
bda415e579 Admin: per-user activity file (logins, logouts, orders) with CSV export
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 12:17:11 +03:30
dbf0ff52d2 gitignore: server-side Postgres/Redis password files
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 12:02:06 +03:30
92dcce901b Ticket-reply notification receiver skips raw saves (loaddata made 5 duplicate notifications)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 11:59:26 +03:30
ddc6c7c9b7 NodeProgress receivers skip raw saves (loaddata duplicated rows on the Postgres move)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 11:54:20 +03:30
042df25738 Postgres support (DB_* env), LoginEvent audit table, Choice.value 300, deploy.sh pg_dump backup
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 11:48:48 +03:30
e00f6b80eb CLAUDE.md: lesson figures workflow, cost, review lessons, frontend cache and nginx headers
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 19:48:10 +03:30
b93f43ef02 Lesson figures: 1-based step index, smaller graph ranges, no tick marks; regenerated 4 calculus-12 lessons
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 16:47:35 +03:30
25b3d24eb4 gen_lesson_figures prints prompt/completion tokens
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 16:34:22 +03:30
ce629e32e4 Lesson.figures: fixed diagrams per lesson (stored once, served with the lesson); gen_lesson_figures; 3 calculus-12 lessons
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 16:26:10 +03:30
1d7eb829e2 Oversized request body: 413 with a short warning instead of 500 and an ERROR traceback
Found on the live server (3 MB JSON to /api/auth/login answered 500). Django raises
RequestDataTooBig for bodies over DATA_UPLOAD_MAX_MEMORY_SIZE and would answer 400, but our DRF
exception handler turned every unknown exception into 500 + logger.exception, so each large request
also wrote a full ERROR traceback. Other SuspiciousOperation subclasses now get Django's 400 as well.

Tested: 2 tests (the 413 one fails without the change). Verified on the server after deploy.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:48:03 +03:30
ff370112c0 Rebuild technical-handbook.pdf from the updated Markdown
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:41:59 +03:30
d584d62904 Do not connect to the shared Redis: CACHE_URL stays empty, own Redis is an open decision
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>
2026-10-07 14:41:39 +03:30
c129f64180 Docs: Redis cache behaviour during an outage, what was tested live, CACHE_KEY_PREFIX
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:21:31 +03:30
277927046d Redis outage must not 500 the API: ResilientRedisCache, key prefix, 1s socket timeouts
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>
2026-10-07 14:16:01 +03:30
7929f6bd85 Docs and requirements: signing key replaced and shared Redis cache are live; pin dependency versions
- CLAUDE.md: status block for 2026-10-07 (what changed on the server, how it was checked, what was not).
- docs/security-before-production.md: signing key, shared cache and unit DEBUG items moved to the guard table.
- requirements.txt: ranges replaced by the versions running on the server (pip freeze, 2026-10-07).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:02:51 +03:30
4c601087dc deploy.sh: health check through the gunicorn socket, optional DEPLOY_BUNDLE when Gitea is unreachable
From the server the public URL goes through ArvanCloud and returned 504/000 (2026-10-07), so the
final health step failed even for a healthy service. It now asks the gunicorn socket (path read from
the unit) with our Host header first. DEPLOY_BUNDLE=<file> fetches from a git bundle instead of Gitea,
only when set explicitly; without it an unreachable Gitea still stops the deploy.

Tested: probe_health against a throwaway unix-socket server and a dead URL (bash -n clean).
Not tested: the whole script end to end, until the next deploy.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 13:57:50 +03:30
3e63bd0860 Docs: 7ecb337 is live; record the Gitea 504 from the server and the bundle deploy
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 13:48:20 +03:30
7ecb337263 Finishing a lesson or island needs recorded answers and an unlocked island; auto DB backup before migrate; throttles on 15 more routes
- content: POST nodes/{id}/quiz/complete and lessons/{id}/complete refuse a locked island
  (403 node_locked, same rule as /api/home) and a user who has not answered every question
  (409 answers_missing). Before, only a user with zero hearts and no answers was refused, so
  anyone else finished any island, locked or not, and finishing a locked island's lessons
  unlocked it. A node or lesson with no questions still passes when unlocked, with no XP.
  The client can no longer understate totalQuestions to buy stars.
- throttle_scope on 15 routes that had none (question bank without login, intro, hearts,
  notifications, tickets, plans, subscription status, orders, offers, affiliate, assistant
  history and feedback, logout).
- admin: OTPCode and Session can only be added/changed by a superuser.
- deploy.sh: integrity-checked DB backup before migrate (keeps the last 2); no migrate without it.
- docs and CLAUDE.md updated to say what is verified and what is not.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 13:30:24 +03:30
7e3854098f CLAUDE.md: guest login removed, state on the server
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 18:40:26 +03:30
5b7e6fb7d7 Content: 724 questions on lessons that had none (grades 3-11), each solved blind by a second pass
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 18:38:10 +03:30
f22b9f2e88 Calculus 12: 12 more questions on lessons that had none
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 18:20:10 +03:30
663252a260 Remove the guest system entirely: no is_guest/device_id, no guest throttles, phone login is the only way in
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>
2026-10-05 18:20:10 +03:30
8835e01595 Remove guest login: every user registers with a phone number
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>
2026-10-05 17:38:18 +03:30
03e81abe7e docs: security guards table lists the new tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 09:37:11 +03:30
28b8527760 Security review: staff admin lockdown, unicode-safe login/phone/grade inputs, demo guest not upgradable, home cache not floodable
- accounts admin: phone/is_guest read-only for non-superusers, device_id filter lookups refused,
  auth.User/Group admin writable only by superusers
- campaigns: delivery log needs view permission
- phone/OTP/birth date: any Unicode digit normalised or rejected with 400; unknown gradeId is 400
- shared demo guest is never turned into a person's account by logging in
- /api/home with unknown gradeId is no longer cached (LocMemCache flood reset throttle counters)
- /api/progress/nodes: one query, huge unit is 400
- BazaarCredential token filter lookups refused

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 09:34:42 +03:30
fedd10ff22 Money review: Bazaar rejection no longer burns the purchase token, read-only order admin, pack grant from order snapshot
- Bazaar verify: a rejected token is not stored on the order or the failed transaction, so the
  same purchase can still pay the right order; a refunded order refuses a new token (409).
- OrderAdmin: every field read-only, no add/delete; Transaction admin read-only.
- grant_pack uses the order's snapshot when the plan was deleted, logs ERROR when nothing is granted.
- Pay link: authority is stored only if the order amount did not change during PaymentRequest.
- PlanAdmin accepts only IRT (the app always renders toman).
- reconcile_pending_order logs unexpected exceptions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:26:26 +03:30
eb4e97753b CLAUDE.md: security review fixes are deployed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:09:50 +03:30
a973c41db2 Security review fixes: admin permissions, guest sessions, payment edge cases, assistant leaks
- 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>
2026-10-05 04:52:25 +03:30
645baf846b Signed payment links, hashed refresh tokens, login-only leaderboard, load test tools
- /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>
2026-10-04 21:12:28 +03:30
b3f624a512 gunicorn: warm the assistant's Qdrant/OpenAI clients when a worker starts
The first assistant round after a restart waited ~6 s before reaching the model on the
live server (later rounds ~1 s); locally a cold 10-ask burst took 7.5 s vs 6.1 s with
this hook (simulated model, 5.2 s). Workers are recycled by max_requests, so the delay
recurred. A failed warm-up is logged and does not stop the worker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:41:48 +03:30
2cf2d14dd4 Security re-check and load test 2026-10-04: 32 threads, data file permissions, patched deps
- gunicorn gthread 8 -> 32 threads per worker: each assistant stream holds a thread for
  the whole answer; with 3x8, about 20 concurrent asks already queued ordinary page loads
  for seconds (up to 23 s at 50 asks locally, 7 s at 20 asks on the live server). With 3x32,
  75 concurrent asks kept page loads under 110 ms (laptop, simulated model and Qdrant)
- deploy.sh: new step closes the project dir to other accounts and sets the database and
  backups to 0600/0700 on every release; user `nobody` on the shared server could read
  db.sqlite3 and every backup
- requirements: python-dotenv 1.2.2, urllib3>=2.8.0 (known CVEs in the installed versions),
  gunicorn pinned to the server's version (was only installed by hand)
- security-before-production.md: items 19-23 (file permissions, DEBUG=1 still in the systemd
  unit, dependencies, frontend headers, guest AI cap per shared IP) and the reproduced
  X-Forwarded-For spoof through the origin IP
- model-prices.md: 0.183 cents per answer from 50 answers in the live load test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:39:11 +03:30
f441cfa96b CLAUDE.md: grades 4/5 and Bazaar purchase path are deployed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 03:08:29 +03:30
8cb326ba94 Grades 4 and 5, Bazaar purchase from the app, pack SKUs
- rag: MATH_4 / MATH_5 textbooks (TOC and page offsets read from the PDFs)
- content: grades 4 and 5 in the seed files: 7 units / 28 nodes each,
  204 + 194 questions (every node >= 4), all re-solved blind once
- subscription: Plan.bazaar_sku (admin); POST /api/subscription/purchase
  accepts paymentMethod=bazaar for packs, rejects unknown methods (was ignored)
- payment: idempotency key cannot switch provider; discount codes refused
  on Bazaar orders (Bazaar charges the fixed SKU price)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:01:44 +03:30
ab5e53730a Audit fixes 2026-10-03: seed keeps admin edits, plan cache, refresh limit, payment and tutor guards
- 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>
2026-10-03 16:05:27 +03:30
7e027c167c CLAUDE.md: the no-fallbacks fixes are deployed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:03:16 +03:30
7307bfcadb Remove silent fallbacks found in the 2026-10-03 audit
- /api/ai/ask: the stub answer is reachable only under DEBUG or
  RAG_FORCE_MOCK. Without RAG keys on the server it used to return 200 with
  "this is a stub, set OPENAI_API_KEY" and spend the student's quota; now 503.
- /api/home: the `grade` alias is gone and sending it is a 400 instead of a
  silent fallback to the user's own grade. No client sent it.
- activate_subscription logs ERROR when the paid order's plan no longer exists
  (it silently became 30 days).
- resolve_grade_title logs instead of `except: pass`; curriculum_io no longer
  reads an `intro_text` key no file contains.
- CLAUDE.md: "No silent fallbacks" rules for future agents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:38:42 +03:30
7e4b8b54a6 CLAUDE.md: the 2026-10-02 fixes are deployed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:20:27 +03:30
7683920b0b docs: handbook, diagrams and guides brought up to 2026-10-02
- technical-handbook: question packs instead of subscriptions, real
  hearts with the new wait screen (screenshot), math-only tutor and what
  is charged (new 9-7), admin cancel/refund, Bazaar planId, account
  deletion, 59 endpoints, 779 tests, 14 apps, status and open decisions.
- Diagrams redrawn: use-case (buy a pack, refunds), data model
  (QuestionCredit, HeartsPass, Profile.birth_date), pack payment sequence.
- The PDF is now built from the Markdown by docs/build_handbook.py
  (pandoc + Chrome, font in docs/assets); the hand-made HTML it used to
  come from was not in the repo and had been lost.
- architecture, operations, security-before-production (item 13 closed),
  ai-cost-and-guardrails and README follow the same changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:10:50 +03:30
8a4753bd15 CLAUDE.md: what the 2026-10-02 review fixed and what is not deployed yet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:43:50 +03:30
d7d168e82f admin: cancel a pending order, refund a paid one
There was no way to take an order back: staff could only edit the
status by hand, and turning 'paid' into 'failed' left the credits,
heart days or subscription days the order had given. Two OrderAdmin
actions now do it:
- cancel a pending order (a payment that does arrive later still counts);
- refund a paid order, after a confirmation page: takes back the pack's
  unspent credits (never below zero), its heart days, or the plan's
  subscription days, and undoes the referrer's commission.
The money itself is returned in the Zarinpal / Bazaar panel; the action
says so. Status is read-only in the order form. Steps in
docs/operations.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:37:30 +03:30
4700ccbf91 hearts: zero hearts really stops play
- The zero-hearts check moved inside the answer transaction: two wrong
  answers sent at once with one heart left were both accepted (reproduced
  with two threads).
- Finishing a lesson or an island without hearts and without having
  answered its questions is now 403 no_hearts; before, quiz/complete let
  a student move on island after island with zero hearts. Losing the last
  heart on the last question still finishes the island.
- A heart that came back while waiting is now saved before answering, so
  the reply no longer says 0 hearts left (the app then showed the
  no-hearts page to someone who had a heart).
- The 403 also carries fullRefillAt for the waiting screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:31:44 +03:30
0f0b779a4e docs: plan with 700 toman per question; pack profit margins
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:19:06 +03:30
3bafd3d9c4 docs: measured model price ledger (docs/model-prices.md)
What GapGPT actually billed per model, with dates and evidence, so a
later price rise can be compared with what it was. gpt-4.1-mini and
gpt-4o-mini were re-checked from the 852 recorded calls in
docs/benchmarks/2026-09-26-tutor-model: every call equals the official
OpenAI per-token price or less (input cache), none more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:12:49 +03:30
1803c76a8b schema: name the two 'kind' enums so the OpenAPI build is warning-free again
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>
2026-10-02 13:11:22 +03:30
67cbe05609 tests: two questions at once with one credit, with real threads
The claim that the user-row lock plus SQLite IMMEDIATE stops one credit
being spent twice was untested. Two threads on separate connections now
ask at the same time: one answer, one credit spent; the same
Idempotency-Key arriving twice at once also costs one credit. Removing
transaction_mode IMMEDIATE turns both red (500, database is locked).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:07:08 +03:30
fe658c697a bazaar: a pack bought through Bazaar now gives its credits; verify reports the real order status
Bazaar orders are only created through POST /api/payment/orders, which
took no planId, so a pack order had plan_id=None: Bazaar verified the
purchase, the order became paid, and grant_pack never ran. The endpoint
now takes planId (rejected when unknown) and snapshots the plan title.
The verify response also returned the pre-payment status ('pending')
because apply_order_payment_result updates its own copy of the order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:04:41 +03:30
5fb9f4aaa0 guardrails: self-harm caught however it is typed; ZWNJ no longer slips an injection past the screen
The ZWNJ normalisation in screen() replaced ZWNJ with itself, so
'نادیده‌بگیر' passed, and self-harm sentences typed with a space or
without ZWNJ ('می خوام بمیرم', 'دیگه نمیخوام زنده باشم', 'خود کشی')
reached the model, which answered with the plain math-only refusal
(one live call, 0.018 cents). Self-harm is now matched on the text with
all spaces removed; the imperative must end the word, so 'نادیده بگیریم'
in a math sentence is no longer an injection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:02:07 +03:30
4c7ccf10a0 docs: question packs, credit rules, real hearts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:14:45 +03:30
58c8e6d014 question packs instead of monthly/yearly plans; hearts are real
- 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>
2026-10-02 11:41:39 +03:30
2edc56fc08 paid plans: 10 tutor questions a day at 179,000 / 1,790,000 toman
Five a day was too close to the free three. Pricing keeps the tutor under ~40% of net revenue
at typical use (a third of the cap); full numbers in the cost report and docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:11:31 +03:30
b3940e87f7 docs: age tone, public IDs, prices, account deletion and the test-account cleanup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 06:50:33 +03:30
191aba0e25 tutor quota message in Persian digits
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 06:36:03 +03:30
2f1e257533 delete_users: only project models; Django admin history (LogEntry.user_id is staff) was being counted
Account deletion also keeps the status log of the payment records it keeps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 06:20:05 +03:30
63cd1af6f7 public user IDs, plans priced by tutor cost, age-based tutor tone, self-service account deletion
- 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>
2026-10-02 06:10:17 +03:30
e9d676ce68 delete_users --real-phones: every phone user except the demo logins
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 02:41:16 +03:30
39247f6d53 senior's post-demo changes: parent report in the student's own account, Jalali birth date, invoices, math-only tutor, priced quotas
- 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>
2026-10-02 02:38:43 +03:30
d5dbd1b23d keep the Referer on the redirect to Zarinpal so StartPay accepts our domain
A test payment on 2026-10-01 stopped at StartPay with "access from this
domain is not allowed". The app opens /api/payment/orders/{id}/pay, which
302s to Zarinpal, and that response carried Django's default
Referrer-Policy: same-origin, so the browser dropped the Referer on the
cross-site hop and Zarinpal could not see tadrisino2.ir. Only this view now
sends strict-origin-when-cross-origin (origin only, no path or query);
every other response keeps same-origin.

Not yet confirmed by a second real attempt in a browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 08:56:13 +03:30
5e1613e431 docs: technical handbook without a version label, dated only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:11:39 +03:30
a325def504 docs: technical handbook (Markdown + PDF) with use-case and relational models, scenario diagrams
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:38:21 +03:30
e3abd675db let Bazaar verification find out whether a SKU is a one-time product or a subscription
Nobody has told us how the plans are defined in the Bazaar panel. The
verify view always asked the in-app endpoint, so a subscription SKU would
get "Product is not found" and a 503. verify_purchase now defaults to
is_subscription=None: in-app first, and on that exact answer the same SKU
is asked as a subscription. A wrong package name still raises at once.

Seen against the live Bazaar API with a fake SKU and token only; no real
purchase has gone through it yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:31:51 +03:30
5f4d7b1ed4 record the Bazaar connection: company account authorized, package com.tadrisino.app set
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:24:00 +03:30
957c9d0f1e record the 2026-09-29 final pre-presentation pass: what shipped, measured tutor latency, test writes to demo data
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:23:07 +03:30
93b451d422 list a user's own support tickets, Persian digits in completion notifications, rewrite .env.example to match the code
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>
2026-09-29 21:18:12 +03:30
8321dc22c3 record the 2026-09-29 deploy: diagrams live, backup name, test guests to remove
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 08:18:29 +03:30
2c759a7dd7 docs: how tutor diagrams work, measured accuracy and cost
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 06:58:01 +03:30
50df7bd371 diagram kit: triangles and quadrilaterals from problem data, shape patterns, translate points, arithmetic/quote/duplicate-key repairs, clock guard (tutor-v10-shape-anchor)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 06:11:43 +03:30
a8cd681d65 an explicit 'draw it' request without topic words gets the whole diagram catalog (tutor-v8-asked-figure)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 03:34:12 +03:30
731dea9bb1 tutor-v7-diagram-kit: anchor short follow-ups to the previous question, new diagram pieces in the prompt
A real chat answered "what if 6 were 4?" with a semicircle problem from the
retrieved book excerpt instead of the parabola asked a moment before. A short
follow-up now carries the previous user question next to it. The catalog
teaches line equations as written, regularPolygon, exteriorAngle, graphs only
through function items and tangents through tangent; fraction words (یک‌پنجم،
نُه‌دهم) pull the fraction section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 02:37:50 +03:30
14a4d9cbf1 redraw the figure on follow-ups that only change the numbers; add a local fake OpenAI server
"What if 6 were 4?" has no shape word, so the diagram hint never went next to
it and the tutor would answer without a new figure. When the previous user
question in the conversation had one, a soft hint (redraw only if this
continues that problem) and the same catalog sections now go along.

tools/fake_openai.py stands in for GapGPT locally so the whole tutor path can be
exercised without credit: 12 scripted answers across grades 6-12 went through
streaming, block splitting, repair, storage, history and rendering in the real
chat screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 00:43:04 +03:30
0917ab487f diagrams for every grade, sectioned prompt, repair of real model slips; fill lesson texts and thin islands
Diagrams: 14 kinds drawn by the app (plane with constructed points, graphs with
computed roots/extrema/tangents, number lines with intervals, solids, charts,
tables, Venn, mapping, trees, fractions, grids, groups, clock). The catalog is
split by topic and only the matching sections go next to a question (200-1073
tokens instead of 1854). parse() repairs slips seen on 96 real book problems:
Persian digits in JSON numbers, 2*pi, // comments, bare items, Venn with
Persian set names. Measured: 45/48 valid on the second round, 0/8 on plain
arithmetic; visual review of 83: 64 right, 3 weak, 16 wrong.

Content: 82 new questions so no island has fewer than four; fill_lesson_texts
writes Unit.blurb (58) and Lesson.example/steps (590) from verified book
problems, never the lesson's own quiz problem. seed_content only fills empty
fields, so admin edits survive a deploy. Tedi unlocks at 500 xp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:22:13 +03:30
33d8f43d74 draw a diagram in tutor answers: validated JSON from the model, rendered by the app; add the fourth companion tedi
The model ends a shape question with <diagram>{json}</diagram>; rag/services/diagram.py
splits it off before mathtext, validates it against a whitelist and stores it on
Message.diagram (ai_tutor.0006). It reaches the app in the SSE done event and in
conversation history. Prompt tutor-v6-diagrams; a hint next to the question (shape
words only) raised gpt-4.1-mini from 3/12 to 12/12 diagrams, 0/8 on arithmetic.

progress.0005 adds tedi (600 xp) to the companion catalog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:44:44 +03:30
74c3ee5e86 record the Zarinpal fix: shared tadrisino2.ir merchant installed, PaymentRequest now 100
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:25:13 +03:30
4393087fa7 wait up to 10 s to reach Kavenegar and 30 s for its reply
Iranian network paths can be slow; the owner asked for 30 s. The frontend's
requestOtp timeout goes to 45 s in the same change so it outlasts both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:14:23 +03:30
ca46e66024 keep the login code when Kavenegar answers late instead of failing the request
Seen live on 2026-09-27: Kavenegar took 5.4 s, our 5 s timeout fired, the
API answered 503 and deleted the code, but the SMS arrived, so the site
said "service unavailable" while the phone held a code that no longer
worked. A read timeout now means "delivery unknown": the code is kept and
the request succeeds (the cooldown still blocks a second SMS). A failed
connection is still an explicit 503. Read timeout raised to 10 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:06:34 +03:30
9a1ee36c0a record the new Bazaar client and the real cause of Zarinpal -14 (merchant registered for tadrisino.org)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:45:58 +03:30
301a6942da record that the login SMS now arrives: template SMSVerify on the server, status 10
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:45:31 +03:30
c1c2a270c2 record the new Bazaar authorize link for mathapi.tadrisino2.ir
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 02:11:28 +03:30
93334635f2 record the domain move and the live frontend: mathapi live, Zarinpal still -14, demo data intact
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 01:39:56 +03:30
1f38465d88 move the API's own address to mathapi.tadrisino2.ir
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>
2026-09-27 01:06:48 +03:30
8768a2406b docs: the Bazaar redirect URI follows PUBLIC_BASE_URL; re-link after a domain move
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 00:55:48 +03:30
4b246e0c70 make the domain configuration, not code: two env vars move everything
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>
2026-09-26 23:46:25 +03:30
2f8ed50b86 record the Bazaar re-authorization: personal-account token cleared, new link out, 7d205ca live
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 22:21:54 +03:30
7d205caa79 send the Bazaar access token without "Bearer" and cap its cache at an hour
Tried against the real Bazaar API on 2026-09-26 after the first live
authorization: "Authorization: Bearer <t>" gets 403 "Access token is not
valid", the bare token is accepted, even though token_type says Bearer.
The refresh grant also returned expires_in=3600000, which the old code
would have cached for ~41 days; the TTL is now capped at one hour and
401/403 clear it.

404 invalid_value (wrong package or SKU) is our misconfiguration, so it
now raises 503 instead of failing the user's order; only error=not_found
means "not purchased", as Bazaar's docs say.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 22:12:20 +03:30
ff212855a5 send the login code through Kavenegar's approved template when one is configured
The account's line cancels free-text SMS (status 13); the senior confirmed
the account is fine and only the template name was missing. With
KAVENEGAR_OTP_TEMPLATE set, OTP goes to verify/lookup with just the code as
token; without it the old sms/send path is unchanged. Both share one
provider call and response check. 620 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 22:02:01 +03:30
f6cdfddaa9 record the 2026-09-26 Bazaar fix deploy: bdd5aba live, authorize link sent
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 22:01:39 +03:30
bdd5aba985 fix Bazaar purchase validation URLs and add the new pishkhan-token method
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>
2026-09-26 21:44:14 +03:30
64f88f0bce record the 2026-09-26 Bazaar deploy: 8763ffd live, authorize window open 72 h
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 21:33:53 +03:30
8763ffd7bf let the Bazaar verify URL double as the OAuth redirect URI
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>
2026-09-26 21:30:44 +03:30
5d490edaf9 keep the raw benchmark answers so the report's questions and answers never need re-running
102 unique textbook questions, 1,287 full model answers, per-answer cost and
the hand grades for both rounds. Book excerpts (retrieval context) are left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:43:43 +03:30
64cf018e55 record the v6 fraction-wording experiment: notation slightly better, answers slightly worse, not shipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 03:25:43 +03:30
a6f91ea915 record the gpt-4.1-mini deploy: 11bbe1c live, server .env switched, live answer verified
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 03:02:18 +03:30
11bbe1c248 tutor runs on gpt-4.1-mini, and primary-school "/" is read as decimal or fraction by rule
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>
2026-09-26 02:36:54 +03:30
e0176ca0a6 record the live integration check: GapGPT back, Zarinpal -14 domain mismatch, Bazaar unset, test user 18
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 18:48:57 +03:30
4a3ec0280a record the first real SMS test: Kavenegar cancels every send (status 13) from this account's line
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:37:03 +03:30
8b510597a6 record the 2026-09-25 SMS deploy: 0580aec is live, Kavenegar on, test user 17 to purge
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:34:37 +03:30
0580aec7b0 harden OTP for real SMS and close the second audit round
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>
2026-09-25 12:28:06 +03:30
74aa9aa8ca list everything left open for the demo that must be closed before real users
docs/security-before-production.md records, item by item, what is exposed
on purpose while this is an internship demo: the committed token-signing key
still in use on the server, seven fixed-code demo logins, demo users in the
production database, a public /api/health that reports internal state, a
public API schema, a public leaderboard with children's names, /admin on the
public domain, the server answering on port 80 around ArvanCloud, per-worker
rate limits without Redis, guest login by deviceId alone, unhashed refresh
tokens and OTP codes, AI cost exposure through throwaway guests, hearts not
enforced, SQLite without off-box backups, and shared root access to the box.
Each has its risk, why it was left, the fix and how to verify it; the
exposure checks were run against the live server on 2026-09-25.

CLAUDE.md, README and docs/operations.md point to it so no agent mistakes
this deployment for production-ready.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:32:36 +03:30
66066cbe23 record the 2026-09-25 deploy: 42783f9 is live, backup taken, bench folder removed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 06:28:59 +03:30
42783f9ba4 tutor answers step by step in plain text, islands finish, and the audit's money and login holes are closed
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>
2026-09-25 06:24:13 +03:30
fbdb9495ee close out the docs: current counts, the live-streak caveat for demo accounts, CACHE_URL as it really behaves
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 07:47:29 +03:30
81348cee5a drop leftovers from the live leaderboard: no fake model rows, no second sort
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 07:27:45 +03:30
ca6b2ff4c9 record the 2026-09-24 state: frontend audit, what the backend gained, what is left
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 07:00:53 +03:30
96f0204fcf refill hearts like Duolingo: one every 5 hours, all of them after 24
Each heart used to take 24 hours, so a student who lost all five waited up to
five days. Now one heart returns every 5 hours, and 24 hours after the first
heart was lost they all come back at once (the owner's call). /api/user/hearts
reports both times (nextRefillAt, fullRefillAt); an existing timer longer than
5 hours is shortened the first time it is read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 06:58:39 +03:30
701d8badcc make streaks and the leaderboard live, and stop three AI chats from freezing the API
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>
2026-09-24 06:39:50 +03:30
c874c186b1 see the real client IP behind ArvanCloud, and mark all notifications read in one call
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>
2026-09-24 05:03:27 +03:30
147af3ac10 send the student back to the web app after paying, not to a raw JSON page
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>
2026-09-24 03:46:41 +03:30
b32194497c let a finished island quiz open the next island, and ground ask-AI in the student's book
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>
2026-09-24 02:49:02 +03:30
0c6b08c9ff make every grade's id its class number and sort grades by it everywhere
Grade ids were the order the books were imported in (ninth=1, eighth=5), so
gradeId: 5 meant eighth grade and the admin, the question filter and the API
all listed grades out of order. Grade.number (1-12) is now read from the title,
new grades take it as their id, and migration 0008 moves existing ids plus every
reference to them: Unit, the loose grade_id in accounts/ai_tutor/rag, and the
grade target inside campaign JSON. Unnumbered demo grades move above 100 and the
id sequence is raised past 12 so they never collide with a real grade.

The frontend needs no change: it always reads gradeId from the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:47:02 +03:30
d70737dddf record that the server now has RAG on, seven demo logins and demo data
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:07:34 +03:30
7d6abab15c document the twenty environment variables the code reads but never mentioned
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>
2026-09-23 02:11:29 +03:30
5003b8a852 write down the frontend test account, which existed only in one person's notes
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>
2026-09-23 01:55:16 +03:30
ac3999ea92 render the Persian API docs right-to-left
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>
2026-09-23 01:45:37 +03:30
ae0375c26c make the API docs page answer a consumer's first questions, and fix a missing endpoint
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>
2026-09-23 01:28:32 +03:30
5492788bf8 make the README an entry point and move procedures into docs/
The README had grown into a 295-line mixture of onboarding, an ops runbook, a
content-authoring workflow, bulk question generation and a Docker log stack. A
reader arriving at the repo could not tell in ten seconds what this service is,
whether it is live, or where to start, and five of its claims no longer matched
the code:

- it said to export .env values by hand; settings.py has loaded .env through
  python-dotenv for a while now
- it listed six variables as "everything the app actually reads"; .env.example
  has carried many more than that for some time
- it called DJANGO_SECRET_KEY, DJANGO_ALLOWED_HOSTS and CACHE_URL required in
  production; all three now have working defaults, and the app boots without
  them on purpose so a partial .env cannot take the service down
- it claimed there is no insecure SECRET_KEY fallback; there is one, under DEBUG
- it required Python 3.12 while the project runs on 3.11 (Docker uses 3.12)

The README now states what the service is, where it runs, how healthy it is and
how to get it running in six commands, then links onward. Procedures moved to
docs/operations.md, docs/content-authoring.md, docs/architecture.md and
docs/docker.md, which is where someone follows a procedure rather than skims it.
Nothing was dropped; the deploy one-liner now includes the separate sudo restart
that deploy.sh cannot perform itself, and backup/restore is written down for the
first time.

Every number in the README was measured rather than quoted: content counts from
the live /api/health and content/seed/curriculum.json, 55 endpoints from the
generated OpenAPI schema, 481 tests from a full run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 01:12:04 +03:30
5aa10eb7b7 give every developer a way into the API that does not depend on one person
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>
2026-09-23 00:52:57 +03:30
8fce3b9548 give the leaderboard and notifications a real producer
Three gaps found while seeding test data for the frontend, all of the same
kind: a contract that looks complete from the outside and is empty underneath.

Nothing in the codebase ever wrote a LeaderboardEntry. Not a signal, not a
service — only the admin form and seed_test_data. So /api/leaderboard returned
`entries: []` forever on any real server, and any row put there was a frozen
number that never tracked activity. rebuild_leaderboard now derives both
periods from real XP: all_time from UserStats.xp, weekly from a sum over
content.UserLessonCompletion in a window. It deliberately does not count
UserAnswer.xp_earned — that field is written but never added to UserStats, so
summing both would report roughly double. deploy.sh runs it every release;
a cron is the real answer once traffic is real, and the script says so.

'lesson_completed' was declared in Notification.TYPE_CHOICES but nothing
called notify() with it, so finishing a lesson produced no notification at
all. LessonCompleteView now emits one, on first completion only (the same
condition that grants xp/coins), inside the completion transaction so a lost
notification cannot leave a silent half-done state.

The notification list serializer returned only id/type/title/isRead. body and
created_at were on the model from the start but never exposed, so the frontend
had neither the text of a notification nor its time. Both added; the change is
purely additive, so the existing client keeps working.

472 tests green, up from 458. makemigrations --check, check and spectacular
are all clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 13:30:57 +03:30
66364e0e92 fix the campaigns concurrency test by fixing what it was catching
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>
2026-09-22 04:43:44 +03:30
6a6d0200d2 complete the quiz path for grades 8 through 12, and close three gaps in the API contract
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>
2026-09-22 03:49:32 +03:30
82edd0ecb0 bring حسابان 11 and 12 into the game path
Both books were already complete on Qdrant and answering /api/ai/ask, but
had no Grade/Unit/Node/Lesson rows, so none of their problems could ever
reach a quiz. The owner made the product call, so the curriculum is now
seeded from the books' own tables of contents (free, no API credit), and
559 four-option questions were authored from the verified chunks.

Coverage: all 21 nodes of grade 11 hold a full four-question quiz, and 10
of 12 in grade 12. The two that do not are both in فصل ۵: کاربردهای مشتق
and are short only because the weekly model limit landed mid-run — their
(lesson, problem) pairs are already exported and waiting.

Three supporting changes:

- generate_questions_from_chunks grows --export DIR, which writes the
  (lesson, verified problem) pairs as JSON batches and makes no paid call
  at all. The subagent route was the documented way to author questions
  but had no tool behind it; now it does, and it emits the same PROMPT
  the paid path uses so the two branches cannot drift apart in quality.

- _targets now skips as many chunks as a lesson already has questions
  for. Re-running with a higher --per-lesson used to hand back the very
  same chunk, producing a near-duplicate question — silently, because the
  prompt gets rewritten each time, so neither the run nor question_io's
  validation could catch it.

- GRADE_STYLE gains یازدهم and دوازدهم. Without them both grades fell to
  the grey fallback; they are deliberately cool colours, separate from the
  six general grades, because حسابان is a ریاضی‌فیزیک-only book.

458 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 03:35:26 +03:30
367 changed files with 926092 additions and 14504 deletions

View File

@@ -1,119 +1,147 @@
# نمونه‌ی فایلِ تنظیمات — هم برای ماشینِ خودتان، هم برای سرور.
# نمونه‌ی تنظیماتِ بک‌اندِ تدریسینو — برای لپ‌تاپ و سرور.
#
# cp .env.example .env
#
# تقریباً هیچ‌کدام اجباری نیستند. هر چیزی که از قبل قابلِ تصمیم بود در خودِ
# کد پیش‌فرض شده: دامنه‌ها، origin های مجاز، و کلیدِ امضا (اولین اجرا خودش
# یک کلیدِ تصادفی می‌سازد و در .django_secret_key نگه می‌دارد). حتی با یک
# فایلِ خالی هم سرویس بالا می‌آید.
# هیچ‌کدام برای بالا آمدنِ سرویس اجباری نیست: دامنه‌ها، CORS، کلیدِ امضا و کش در
# خودِ config/settings.py پیش‌فرضِ کارا دارند. مقدارهای این فایل با آن پیش‌فرض‌ها
# ادغام می‌شوند، جایگزینشان نمی‌شوند. شرحِ کامل: docs/operations.md.
#
# روی سرور، بعد از یک‌بار ساختنِ این فایل، هر آپدیت فقط:
# git pull && ./deploy.sh
# مقدارِ واقعیِ کلیدها فقط در .env (که در git نیست)، هرگز در این فایل یا در چت.
# -----------------------------------------------------------------------
# پایه
# -----------------------------------------------------------------------
# DEBUG اینجا عمداً 0 است.
#
# این فایل هم روی لپ‌تاپِ توسعه‌دهنده کپی می‌شود هم روی سرور، و حالتِ پیش‌فرض
# باید آنی باشد که اگر کسی فراموشش کند، بی‌خطر بماند. یک بار همین فایل با
# مقدارِ 1 روی سرور نشست و ماه‌ها آنجا ماند.
#
# ⚠️ DEBUG=1 روی سرورِ عمومی دو ریسک دارد:
# • صفحه‌های خطا کلِ تنظیمات و traceback را نشان می‌دهند
# • کدِ ورودِ آزمایشیِ 0000 برای هر شماره‌ای پذیرفته می‌شود، یعنی هر
# کسی می‌تواند به‌جای هر کاربری وارد شود
#
# روی ماشینِ خودتان (و فقط آنجا) این را 1 کنید تا خطاها کامل دیده شوند.
# ═══════════════════════════════════════════════════════════════════════
# ۱) پایه
# ═══════════════════════════════════════════════════════════════════════
# روی لپ‌تاپ 1، روی سرور همیشه 0.
# DEBUG=1 روی سرور یعنی کدِ ورودِ 0000 برای هر شماره‌ای و صفحه‌ی خطا با تنظیمات.
DJANGO_DEBUG=0
# خالی بگذارید تا خودش ساخته و در .django_secret_key ذخیره شود.
# خالی بماند: بارِ اول یک کلیدِ تصادفی ساخته و در .django_secret_key (0600) نگه
# داشته می‌شود. هرگز مقدارِ django-insecure-… داخلِ settings.py را این‌جا نگذارید.
DJANGO_SECRET_KEY=
# بدونِ Redis سرویس بالا می‌آید ولی محدودیتِ نرخ بینِ workerها مشترک نیست؛
# /api/health همین را هشدار می‌دهد. برای پروداکشنِ واقعی پرش کنید.
# Redis برای محدودیتِ نرخِ مشترک بینِ workerها؛ خالی = کشِ حافظه‌ی هر worker
# (/api/health هشدار می‌دهد). مثال: redis://127.0.0.1:6379/1
CACHE_URL=
# نامِ سرویسِ systemd تا deploy.sh خودش ری‌استارت کند (فقط روی سرور).
# TADRISINO_SERVICE=
# Postgres: با ست‌بودنِ DB_NAME برنامه روی Postgres بالا می‌آید (DB_USER و DB_PASSWORD الزامی)؛ خالی = SQLite.
DB_NAME=
DB_USER=
DB_PASSWORD=
DB_HOST=127.0.0.1
DB_PORT=5432
# pool اتصال‌ها (config/settings.py): هر worker حداکثر DB_POOL_MAX_SIZE اتصال؛ بعد از DB_POOL_TIMEOUT ثانیه انتظار ۵۰۳.
# workers × DB_POOL_MAX_SIZE + 6 باید زیرِ max_connectionsِ Postgres بماند (/api/health → dbPool).
# DB_POOL_MAX_SIZE=8
# DB_POOL_TIMEOUT=10
# سقفِ هر کوئری (میلی‌ثانیه)؛ 0 برای فرمانِ مدیریتیِ طولانیِ دستی.
# DB_STATEMENT_TIMEOUT_MS=30000
# اسلاتِ تکثیری که سرورِ بکاپ از آن کپیِ زنده می‌گیرد (ops/pgbackup/). روی سرور: zareian_247.
# خالی روی Postgres = کپیِ بیرونی نداریم و /api/health هشدار می‌دهد. روی لپ‌تاپ خالی.
OFFSITE_WAL_SLOT=
# سرورِ اختصاصی (۲۱۶): فایلی که tadrisino-offsite-sync زمانِ آخرین ارسالِ بکاپِ رمزشده به ۲۴۷ را در آن می‌نویسد.
# روی ۲۱۶: /var/lib/tadrisino-backup/offsite-synced-at (بر OFFSITE_WAL_SLOT مقدم است). روی لپ‌تاپ خالی.
OFFSITE_SYNC_STATUS_FILE=
# -----------------------------------------------------------------------
# آدرسِ سرویس — این چهار تا پیش‌فرضِ درست دارند
# -----------------------------------------------------------------------
# تا پیش از این هر چهارتا باید دستی روی سرور اضافه می‌شدند، و چون در همین
# فایل نبودند هر بار از نو کشف می‌شدند: نبودِ اولی یعنی «Disallowed Host»،
# دومی یعنی ردِ ورود به پنلِ ادمین، سومی یعنی بلاک شدنِ فرانت در مرورگر، و
# چهارمی یعنی شکستنِ بی‌سروصدای مسیرِ پرداخت.
# ═══════════════════════════════════════════════════════════════════════
# ۲) دامنه‌ها — دو آدرس، بقیه از آن‌ها ساخته می‌شوند
# ═══════════════════════════════════════════════════════════════════════
# ALLOWED_HOSTS، CSRF، CORS، callbackِ زرین‌پال، برگشت از پرداخت و redirectِ بازار
# همه از این دو ساخته می‌شوند. پیش‌فرضشان همین دامنه‌های فعلی است.
# جابه‌جاییِ دامنه: docs/operations.md، بخشِ «عوض کردنِ دامنه».
#
# حالا هر چهارتا مقدارِ پیش‌فرضِ دامنه‌ی خودمان را دارند و لازم نیست ست شوند.
# فقط اگر دامنه عوض شد، این‌ها را باز کنید:
# PUBLIC_BASE_URL=https://mathapi.tadrisino2.ir
# FRONTEND_URL=https://math.tadrisino2.ir
#
# DJANGO_ALLOWED_HOSTS=api.math.tadrisino2.ir,math.tadrisino2.ir,localhost,127.0.0.1
# CSRF_TRUSTED_ORIGINS=https://api.math.tadrisino2.ir
# CORS_ALLOWED_ORIGINS=https://math.tadrisino2.ir
# PUBLIC_BASE_URL=https://api.math.tadrisino2.ir
# فقط برای دامنه‌ی *اضافه* (مثلاً دامنه‌ی قبلی در دورانِ جابه‌جایی):
# DJANGO_ALLOWED_HOSTS=old.example.ir
# CSRF_TRUSTED_ORIGINS=https://old.example.ir
# CORS_ALLOWED_ORIGINS=https://old-app.example.ir
# اختیاری — بدونش OTP واقعی ارسال نمی‌شه (فقط خطای صریح ۵۰۳ برمی‌گرده،
# طبق رفتارِ موجودِ accounts/services.py)
# تعدادِ پروکسی‌های جلوی اپ. سرورِ فعلی: ArvanCloud → nginx، یعنی 2.
# بیشتر از واقعیت = جعلِ IP و دور زدنِ محدودیتِ نرخ؛ کمتر = همه‌ی مهمان‌ها یک IP.
NUM_PROXIES=2
# ═══════════════════════════════════════════════════════════════════════
# ۳) پیامکِ کدِ ورود (کاوه‌نگار)
# ═══════════════════════════════════════════════════════════════════════
# بدونِ کلید، درخواستِ کد خطای صریحِ ۵۰۳ می‌دهد (نه موفقیتِ ساختگی).
KAVENEGAR_API_KEY=
# نامِ الگوی تأییدشده در پنلِ کاوه‌نگار (verify/lookup). روی سرورِ فعلی SMSVerify.
# خطِ این حساب پیامکِ متنِ آزاد را لغو می‌کند، پس بدونِ الگو کدی نمی‌رسد.
KAVENEGAR_OTP_TEMPLATE=
# خطِ اختصاصی؛ خالی = خطِ پیش‌فرضِ حساب.
KAVENEGAR_SENDER=
# رمزِ ادمینِ Grafana (کاربرِ admin) — فقط برای دسترسیِ محلی
GRAFANA_ADMIN_PASSWORD=admin
# شماره‌های تستیِ تیم با کدِ ثابت، بدونِ پیامک: «شماره:کد» با کاما.
# کد تصادفی باشد؛ پیش از کاربرِ واقعی خالی شود (/api/health تا آن‌وقت هشدار می‌دهد).
# TEST_LOGIN_PHONES=09120000001:834512,09120000002:110947
TEST_LOGIN_PHONES=
# -----------------------------------------------------------------------
# RAG / AI tutor (اپ rag) — موقعِ تست پر کنید
# -----------------------------------------------------------------------
# بدونِ OPENAI_API_KEY و QDRANT_API_KEY، مسیرِ RAG غیرفعاله و
# POST /api/ai/ask همون پاسخِ stubِ قبلی رو می‌ده (سازگاریِ عقب‌رو).
# فعلاً از GapGPT (پروکسیِ OpenAI برای ایران) استفاده می‌کنیم.
# ═══════════════════════════════════════════════════════════════════════
# ۴) دستیارِ هوش مصنوعی (RAG)
# ═══════════════════════════════════════════════════════════════════════
# بدونِ این دو کلید، POST /api/ai/ask پاسخِ آزمایشیِ ثابت (stub) می‌دهد.
# GapGPT پروکسیِ OpenAI است و فقط از داخلِ ایران جواب می‌دهد.
OPENAI_API_KEY=
OPENAI_BASE_URL=https://api.gapgpt.app/v1
# اگه بعداً خواستی مستقیم OpenAI واقعی رو تست کنی: کلیدِ واقعی رو این پایین
# پارک کن، بعد فقط دو خطِ بالا رو باهاش عوض کن. هیچ منطقِ سوییچی در کد نیست
# تا ساده و بی‌اشتباه بمونه.
# OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_API_KEY_OPENAI_DIRECT=
OPENAI_CHAT_MODEL=gpt-4o-mini
OPENAI_VISION_MODEL=gpt-4o
# این رو تغییر نده؛ باید با مدلی که تیمِ دیگه باهاش collectionها رو ساخته یکی باشه.
OPENAI_EMBEDDING_MODEL=text-embedding-3-small
QDRANT_URL=https://qdrant.tadrisino2.ir:443
QDRANT_API_KEY=
# نامِ collection = f"{subject} {grade}".lower() . collectionهای موجود روی
# سرورِ تیمِ دیگه (بررسی‌شده): «علوم تجربی سوم» ، «مطالعات اجتماعی چهارم» ،
# «مطالعات اجتماعی ششم» ، «ریاضی سوم» ، «ریاضی ششم» ، «ریاضی هفتم».
# یعنی جزءِ گرید فقط «سوم/چهارم/ششم/هفتم» است (بدونِ «دبستان»).
#
# کلیدِ فیلترِ درس دستی تنظیم نمی‌شه — rag/services/known_collections.py خودش
# تشخیص می‌ده: ریاضی → `chapter` (و شماره‌ی فصل رو به رشته‌ی دقیق تبدیل می‌کنه)،
# بقیه → `lesson_number`.
RAG_COLLECTION_NAME_TEMPLATE={subject} {grade}
RAG_RETRIEVAL_TOP_K=4
# RAG_FORCE_MOCK=1 یعنی حتی با وجودِ کلیدها هم مسیرِ RAG خاموش بمونه.
RAG_FORCE_MOCK=
# مدل‌ها؛ پیش‌فرض‌ها در settings.py همین‌اند و سنجیده شده‌اند (docs/architecture.md).
# OPENAI_CHAT_MODEL=gpt-4.1-mini
# OPENAI_VISION_MODEL=gpt-4.1-mini
# مدلِ جدا فقط برای پایه‌های ۱۰ تا ۱۲؛ خالی = همان مدلِ بالا.
# OPENAI_CHAT_MODEL_UPPER=
# embedding را عوض نکنید مگر collectionهای Qdrant هم با همان مدل بازساخته شوند.
# OPENAI_EMBEDDING_MODEL=text-embedding-3-small
# سقفِ سوالِ روزانه از دستیار برای کاربرِ بدونِ اشتراک (پیش‌فرض ۳). سقفِ مشترکان روی
# هر پلن در ادمین است (ai_daily_quota). حسابِ هزینه: docs/ai-cost-and-guardrails.md
# AI_DAILY_QUOTA_FREE=2
# -----------------------------------------------------------------------
# زرین‌پال (اپ payment) — درگاهِ اصلیِ پرداختِ ما
# -----------------------------------------------------------------------
# بدونِ ZARINPAL_MERCHANT_ID، مسیرِ پرداخت fail-closed می‌مونه (خطای صریحِ
# ۵۰۳ به‌جایِ پرداختِ ساختگیِ موفق).
# PUBLIC_BASE_URL بالاتر توضیح داده شده؛ پیش‌فرض دارد.
# ═══════════════════════════════════════════════════════════════════════
# ۵) پرداخت
# ═══════════════════════════════════════════════════════════════════════
# زرین‌پال. بدونِ مرچنت، پرداخت ۵۰۳ می‌دهد. مرچنت باید برای دامنه‌ی PUBLIC_BASE_URL
# ثبت شده باشد، وگرنه زرین‌پال خطای -14 می‌دهد.
ZARINPAL_MERCHANT_ID=
# 1 = محیطِ sandbox زرین‌پال (برای تست بدونِ پرداختِ واقعی)
ZARINPAL_SANDBOX=1
# اختیاری — بعدِ تاییدِ پرداخت، کاربر به این URL (مثلاً یک deep link اپ) با
# ?orderId=...&status=... ریدایرکت می‌شه. خالی = پاسخِ JSON ساده.
ZARINPAL_REDIRECT_URL=
# 1 = محیطِ آزمایشیِ زرین‌پال (بی‌پولِ واقعی). روی سرورِ فعلی خالی است: درگاهِ واقعی.
ZARINPAL_SANDBOX=
# -----------------------------------------------------------------------
# تنظیماتِ مقیاس‌پذیری (همه دیفالتِ امن دارن)
# -----------------------------------------------------------------------
# persistent DB connection (ثانیه). با SQLite بی‌اثر؛ بعد از Postgres مهم.
DB_CONN_MAX_AGE=60
# کشِ کوتاهِ ردیفِ کاربر در auth تا هر request یک SELECT نزنه. 0 = خاموش
# (پیش‌فرض). توصیه‌ی پروداکشن با Redis: 30. با هر save/delete روی User باطل می‌شه.
AUTH_USER_CACHE_TTL=0
# سقفِ ارزانِ خواندن روی endpointهای پرترافیکِ GET (home/leaderboard/summary/...).
READ_THROTTLE_RATE=120/min
# کافه‌بازار (فقط خریدِ درون‌برنامه‌ایِ اپِ اندروید). روشِ ترجیحی توکنِ ثابتِ پیشخان
# است؛ سه متغیرِ OAuth فقط وقتی آن نیست. بدونِ هیچ‌کدام، مسیرِ بازار بسته می‌ماند.
BAZAAR_PISHKHAN_API_SECRET=
BAZAAR_CLIENT_ID=
BAZAAR_CLIENT_SECRET=
BAZAAR_REFRESH_TOKEN=
# BAZAAR_PACKAGE_NAME=com.tadrisino.math.app
# کلیدِ «تخفیف پویا» از پیشخان (سمتِ سرور؛ قیمتِ ادمین و کدِ تخفیف را به بازار می‌گوید).
BAZAAR_DYNAMIC_PRICE_KEY=
# ═══════════════════════════════════════════════════════════════════════
# ۶) اختیاری — پیش‌فرض‌ها معمولاً کافی‌اند
# ═══════════════════════════════════════════════════════════════════════
# کلیدِ مسیرهای داخلیِ سرور-به-سرور؛ خالی = آن مسیرها بسته‌اند (در DEBUG هم پیش‌فرضی ندارد).
# INTERNAL_SERVICE_KEY=
# مهمان‌های نمایشی که با «حذفِ حساب» پاک نمی‌شوند (با کاما). پیش‌فرض: frontend-test-device.
# DEMO_GUEST_DEVICE_IDS=
# با صفر قلب جلوی جواب دادن گرفته شود؟ پیش‌فرض روشن (از ۲۰۲۶-۱۰-۰۲)؛ ۰ خاموشش می‌کند.
# HEARTS_ENFORCED=1
# سقف‌های کدِ ورود: هر شماره در روز، و کلِ سامانه در روز (ضدِ SMS pumping).
# OTP_REQUEST_PHONE_DAILY_MAX=10
# OTP_SMS_GLOBAL_DAILY_MAX=300
# سقفِ خواندن روی اندپوینت‌های پرترافیک، و سقفِ /api/health.
# READ_THROTTLE_RATE=120/min
# HEALTH_THROTTLE_RATE=60/min
# سقفِ تمدیدِ توکن برای هر IP (همه‌ی کاربرانِ پشتِ یک وای‌فای یا CGNAT با هم).
# TOKEN_REFRESH_IP_RATE=600/hour
# سقفِ روزانه‌ی کلِ سامانه برای سوال‌هایی که به مدلِ هوش مصنوعی می‌رسند (ضدِ خرجِ بی‌سقف).
# AI_GLOBAL_DAILY_MAX=3000
# کشِ پاسخِ دستیار (ثانیه)؛ سوالِ تکراری دوباره به مدل نمی‌رود.
# RAG_ANSWER_CACHE_TTL=1800
# 1 = دستیار حتی با کلیدها هم پاسخِ آزمایشی بدهد.
# RAG_FORCE_MOCK=
# لاگ در فایل علاوه بر stdout (فقط داخلِ Docker).
# LOG_DIR=
# رمزِ ادمینِ Grafana در docker compose (فقط محلی).
# GRAFANA_ADMIN_PASSWORD=admin

8
.gitignore vendored
View File

@@ -10,10 +10,16 @@ data/textbooks/*
!data/textbooks/README.md
# کلیدِ امضای تولیدشده‌ی خودکار (هرگز کامیت نشود)
.django_secret_key
.django_secret_key*
# خروجیِ collectstatic — تولیدی است، در هر دیپلوی از نو ساخته می‌شود.
staticfiles/
# نسخه‌ی پشتیبانِ تغییرهای دستیِ روی سرور که deploy.sh کنار می‌گذارد.
.deploy-backups/
# دیتابیسِ فایلیِ تست‌ها (config/settings.py → DATABASES.TEST)
.test-db*.sqlite3*
tools/fake_openai_requests.jsonl
.pg_password
.redis_password

833
CLAUDE.md
View File

@@ -1,388 +1,481 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Guidance for agents working in this repo. **Current state only**: what is true now and the rules
that follow from it. History lives in git (`git log -p -- CLAUDE.md`; the long dated status log that
used to be here ends at `eb95209`) and in the owner's reports (`~/Desktop/گزارشات Tadrisino/`).
When something here stops being true, change the line; do not append a dated paragraph.
## Project
## The project in one paragraph
Django + DRF backend for Tadrisino, an educational app (Persian/Farsi). Codebase and API responses are in Persian; comments in source are Persian too.
Tadrisino is a Duolingo-style math app for Iranian students (grades 3–12): a map of islands per
textbook chapter, short quizzes built from the book's own problems, and an AI tutor («پی») that
answers from the same book. Two repos, one API contract: this Django + DRF backend
(`Tadrisino/MathApp`, live at `https://mathapi.tadrisino2.ir`) and the React frontend
(`Tadrisino/MathApp_frontend`, local clone `~/Desktop/MathApp_frontend`, live at
`https://math.tadrisino2.ir`, with an Android WebView shell in `android/`). The owner
(`AliReza_Zareian`) maintains both. Code, API messages, comments and docs are Persian.
## Where to look
| Need | File |
|---|---|
| Deploy, env vars, backups, nginx, Bazaar sale, admin refunds | `docs/operations.md` |
| Design decisions and why (auth, payment, quiz, hearts, tutor prompt) | `docs/architecture.md` |
| Formal handover document for humans (also as PDF) | `docs/technical-handbook.md` → `python docs/build_handbook.py` |
| What is still open before real users, and the guards that hold what is closed | `docs/security-before-production.md` |
| Editing content, bulk questions, seed rules | `docs/content-authoring.md` |
| Reading a textbook with a vision model (costs, model choice) | `docs/extraction-playbook.md`, `data/textbooks/README.md` |
| Tutor cost per question, subscription margins, guardrails | `docs/ai-cost-and-guardrails.md` |
| Measured model prices (append-only ledger: add a dated row, never overwrite) | `docs/model-prices.md` |
| Load testing (laptop and the server) | `tools/loadtest/README.md` |
| Frontend structure and its deploy | `~/Desktop/MathApp_frontend/README.md` |
| Android build, signing, what the next APK must carry | `~/Desktop/MathApp_frontend/android/README.md` |
Before writing a new doc, check this table: the same fact in three places drifts apart.
---
## ⛔ Working on the servers — read before every SSH session
The owner often says "put it on the server" / "deploy it" and expects you to do it yourself, and
"the server" means **216**. You may. 216 is ours alone, but it is production, and two other machines
we still hold files on (249, 247) are **shared company servers** where our account has full root and
a careless command can take down the whole company's git, mail and other teams' apps. **The only
guard rail is you.**
### Standing authorization (the owner's, permanent: do not ask again)
The owner does not want to repeat this each session. When a task needs a server, start working:
- **Granted without asking:** read the passwords from `~/Desktop/رمز سرور.txt` («The password» below),
SSH to 216/249/247, look at anything of ours (status, logs, disk, DB counts, nginx config); on **216**,
deploy backend and frontend, restart `zareian-mathapp`, reload nginx, run management commands, take
backups, write into our own directories, and install a package from the mirror when a task needs it;
move files between servers over the LAN through 216.
- **Ask first (one Persian line saying what and why, then wait):** anything that deletes or overwrites
data (dropping/restoring a DB, removing backups, `rm` of more than our own temp files), any change on
249 or 247 beyond reading, `.env` and `/etc` edits on 216 (after the backup of rule 5), SSH keys,
`sshd_config`, firewall, users, reboots, and anything that spends GapGPT/SMS credit beyond a single check.
- **Never, even if asked in passing:** touch anything listed as not ours on 249/247, power a machine off,
print a password or secret.
- Every command still follows «Announce before you act» and the rules below. The auto-mode classifier may
block a shared-server action even with this authorization; then stop, say what was blocked, and give the
owner the exact command or ask them to approve it.
### What is ours — and nothing else is
**`78.38.35.216` (hostname `mathapp`, the live server)**: a dedicated VM, the whole machine is ours. Log in
as `zareian` (sudo); the senior and the company owner share `mathapp-admin` (sudo); no other logins. The app
runs as the non-sudo user `mathapp`:
| Ours | Note |
|---|---|
| `/home/mathapp/mathapp-v2` | backend checkout; `.env`, `.django_secret_key`, `.pg_password`, `.redis_password`, `logs/`, `.deploy-backups/` are untracked and live here |
| `/home/mathapp/mathapp_frontend_new` | frontend checkout; nginx serves its `dist/` |
| `gunicorn-mathapp.service` = alias **`zareian-mathapp`** | always type the alias |
| `zareian-redis.service` (6390), `postgresql@16-zareian` (5440, DB `zareian_mathapp`) | |
| `tadrisino-*` timers (pgBackRest, offsite sync, payment CSV, blocklist), `zareian-applog-archive.timer` | sources in `ops/server216/` |
| nginx `sites-enabled/mathapp`, `mathapp-front`, `snippets/zareian-*.conf` | sources in `ops/nginx/` |
| `/home/zareian/stage/` | DB dumps from the cut-over (2026-10-18) |
**`78.38.35.249` (`haghzad`, shared, retired for us since 2026-10-18)**, account `administrator`: our app
there is stopped and disabled, the replication slot is gone, Arvan no longer points at it. Still ours
and left in place as the rollback until about 2026-10-25: `/home/administrator/mathapp-v2`,
`/home/administrator/mathapp_frontend_new`, `gunicorn-mathapp.service` (disabled; the `zareian-mathapp`
alias returns with `enable`), `zareian-redis`, Postgres cluster `14/zareian` (5440, plaintext data as of
the cut-over), `zareian-applog-archive.timer`, our nginx sites and snippets. Removing them is the owner's
call. **Not ours, never touch:** `~/mathapp`, the generic `gunicorn.service`, `gitea`,
`postfix`/`dovecot`, `named`, `mariadb`, `postgresql@14-main` (5432), `redis-server` (6379), `rabbitmq`,
`metabase`, `apirag`, `artinafront`, `metaverse`, `celerybeat`, other people's home directories and every
other nginx site.
**`78.38.35.247` (backup server, shared)**, same account and password as 249: ours is only
`~/zareian-pgbackrest-216/` (216's live backups, encrypted on 216), `~/zareian-pgbackup/` (the 249-era
archive, every file now `age`-encrypted to 216's key; its `zareian-pg-*` / `zareian-applog-pull` units are
disabled), our lines in `~/.ssh/authorized_keys` and `~/.pgpass`, and `~/.ssh/zareian_*`. 247 never
holds a decryption key. It also runs other teams' services and its own `postgresql@14-main`.
On 216 every rule below applies except 4 and 7 (nothing there is shared); on 249 and 247 all of them.
### Rules (not negotiable)
1. **Read-only first.** Before changing anything, look: `systemctl status`, `ls`, `git -C … log -1`,
`df -h /`. Know what is there before you act on it.
2. **No destructive command outside our directories.** No `rm -rf`, `chown -R`, `chmod -R`,
`find … -delete`, `DROP`, `docker system prune`, package upgrades, reboots, firewall changes.
Inside our directories, write paths out in full (`/home/mathapp/mathapp-v2/...`), never
rely on the current directory.
3. **Never `git clean`, never delete and re-clone the project.** `.env`, `.django_secret_key`, logs
and backups are untracked; `git reset --hard origin/main` is the way back to a known tree.
4. **Restart only `zareian-mathapp`.** `systemctl restart gunicorn` is another team's app. nginx is
shared: `sudo nginx -t` then `reload`, never `restart`.
5. **Back up before editing anything under `/etc` or `.env`**, into
`/home/mathapp/mathapp-v2/.deploy-backups/<what>-before-<change>-<YYYYMMDD-HHMMSS>`, and say where it is.
Database: `deploy.sh` already takes a `pg_dump` before `migrate`; for any manual data change take
one yourself first.
6. **Disk is the standing risk.** Check `df -h /` before anything that writes a lot. On 216 a full disk
stops our DB; on 249/247 it takes Gitea and mail down with us.
7. **Do not use other teams' resources**, even "just an empty database" on their Redis or Postgres,
their queues or their nginx sites. Ask the owner; it becomes a request to the server admin.
8. **Secrets stay put.** Read `.env` only through Django (`venv/bin/python manage.py shell -c` printing
booleans), never `cat .env`, never paste values into chat. `DEBUG` must never be `1` on the server
(it enables `DEV_MASTER_OTP=0000`, a login for any phone).
9. **Load tests and bulk jobs** follow `tools/loadtest/README.md`: never touch production throttles,
stay under the connection budget, clean up.
10. **When something is unexpected** (a file you did not expect, a service in a state you cannot
explain, a command that wants to prompt), stop and tell the owner. Do not improvise on a shared box.
### Announce before you act
Before every command that runs on a server, or that deletes anything (even locally), write one
short Persian line saying what it touches, where (local scratchpad or which directory on which
server), and that nothing outside our own files is affected. Never combine server commands and
local cleanup in one call: once, an agent closing its local SSH socket and destroying its local
password copy in the same command looked like deleting files on the server, and the owner (who
answers for that machine) was rightly alarmed. «سرور رو خاموش کن» means end the SSH session and
destroy the password copy, **never** power anything off.
### The password
The owner keeps the passwords in `~/Desktop/رمز سرور.txt`; read them from there, never ask for them in
chat. 216 (`zareian`) uses the `AdminUser-Password` line; 249 and 247 (`administrator`) use the
first `Password` line. Take only the value, everything after the first `: ` (stripping only the label
once sent the whole line and SSH refused it):
```bash
awk '/^AdminUser-Password/{sub(/^[^:]*: */,"");printf "%s",$0;exit}' ~/Desktop/'رمز سرور.txt' | tr -d '\r\n' > "$SCRATCH/.pwn" && chmod 600 "$SCRATCH/.pwn" # 216
sed -n '/^Password/{s/^[^:]*: *//;p;q;}' ~/Desktop/'رمز سرور.txt' | tr -d '\r\n' > "$SCRATCH/.pw" && chmod 600 "$SCRATCH/.pw" # 249/247
# one connection, reused (the shell is zsh: write the -o options out, a "$C" variable does not split)
sshpass -f "$SCRATCH/.pwn" ssh -o ControlMaster=auto -o ControlPath=/tmp/z216-%C -o ControlPersist=30m zareian@78.38.35.216 '<command>'
sshpass -f "$SCRATCH/.pwn" ssh -o ControlPath=/tmp/z216-%C zareian@78.38.35.216 'sudo -S -p "" <command>' < "$SCRATCH/.pwn"
ssh -o ControlPath=/tmp/z216-%C -O exit zareian@78.38.35.216 # when done, in its own call
rm -P "$SCRATCH/.pwn" # as soon as the work is done (macOS has no shred); recreate it next time
```
The laptop's connection is slow: move anything bigger than a few MB **between servers over the LAN**
(216 = `192.168.200.163`, 249 = `.164`, 247 = `.167`), never piped through the laptop. 216 has `sshpass`;
a password copied to 216 for that goes in `/home/zareian/` with mode 600 and is deleted in the same command.
Never put it on a command line (`ps` shows argv to every user on the server), in the repo, a
memory, a commit or a note; never echo it. Many SSH connections in a row can trip a lockout: open
one `ControlMaster` connection (socket under `/tmp`, macOS limits the path to 104 bytes). Do not
create SSH keys, edit `sshd_config` or change the password unless the owner asks for exactly that.
### "Deploy it": the procedure
Backend (commit and push to Gitea first; the server deploys what is on `origin/main`):
```bash
sshpass -f "$SCRATCH/.pwn" ssh zareian@78.38.35.216 'sudo -S -p "" -u mathapp bash -c "cd /home/mathapp/mathapp-v2 && ./deploy.sh"' < "$SCRATCH/.pwn"
sshpass -f "$SCRATCH/.pwn" ssh zareian@78.38.35.216 'sudo -S -p "" systemctl restart zareian-mathapp' < "$SCRATCH/.pwn"
```
`deploy.sh` runs as `mathapp`: it saves any hand edits as a patch, resets to `origin/main`, installs,
backs up the DB, migrates, runs `seed_content`, collectstatic, and checks health through the gunicorn
socket. Its own restart fails (`mathapp` has no sudo) and says so; the restart is the second line. Then
**verify; never assume**:
- `systemctl show zareian-mathapp -p ActiveEnterTimestamp` is after your deploy (a file on disk is not
a deployed file: once the server served two-day-old code while every file was current);
- `sudo -u mathapp git -C /home/mathapp/mathapp-v2 log -1 --format=%h` equals your local `main`;
- `curl https://mathapi.tadrisino2.ir/api/health` → `{"status":"ok"}` (details need `X-Internal-Key`);
- the thing you changed, exercised once from outside.
If the server cannot reach Gitea (`git fetch` 504 from the server, 200 from the laptop), ship a bundle:
`DEPLOY_BUNDLE` in `docs/operations.md` «دیپلوی».
Frontend: push to `upstream` (`Tadrisino/MathApp_frontend`) `main` (the owner authorized pushing and
deploying it without asking), then as `mathapp` `cd /home/mathapp/mathapp_frontend_new && ./deploy.sh` (builds
into `dist.next`, swaps, keeps `dist.prev`). `config.js` and `index.html` are cached by ArvanCloud;
purge them there when they change.
Report back: the commit hashes now live, the backup paths created, what you verified and what you did
not.
---
## How to work in this codebase
**Comments are claims, not evidence.** Most comments were written by earlier agents to justify their
own change. Real bugs have been found directly under comments calling the code "safe" or "race-free".
When asked to find problems, review adversarially and judge by behaviour: write a throwaway test or call
the endpoint. Ask how a cheating student, an attacker or a confused parent would break it. A finding
counts once reproduced; say which ones you only read. When writing comments, report, don't vouch: say
what the code does, why, and what was *not* checked.
**No silent fallbacks, no speculative backward compatibility.** "Helpful" fallbacks (`x or default`,
`except: pass`, accepting an old field name "just in case") caused the hardest bugs here because the
failure never showed anywhere (a stub tutor answer served with 200 in production; a yearly buyer
silently given 30 days).
1. Fail loudly over guessing. A missing setting, row or field in production is an error the user and
`/api/health` can see. A default is fine only when it is a real product choice, and its comment says
why that value is right.
2. Demo/stub paths only behind `DEBUG` or `RAG_FORCE_MOCK`; never reachable on the server.
3. No backward compatibility without a named client. The only API client is the frontend
(`~/Desktop/MathApp_frontend/src/api/`); check what it and the server DB actually send. If nobody
sends it, delete it. If you must keep one, write who uses it and when it can go.
4. Never `except: pass`. At least `logger.exception`, and swallow only where the comment names the
bigger thing it protects (e.g. affiliate commission must not undo a user's payment).
5. Removing a fallback means the old input fails visibly (400/503), not silently routing elsewhere.
Fallbacks that remain on purpose (re-check before relying on them): settings boot with default
hosts/CORS/cache/secret key (`/api/health` warns); `LocMemCache` without `CACHE_URL`; `_annotation`
defaults for answer-cache entries older than a field; Bazaar's OAuth path beside the Pishkhan token
(the server uses OAuth); `birthYear` alone in `PATCH /api/user/profile` (frontend type still allows it);
`totalQuestions` accepted but not trusted (frontend sends it); orders without a `plan_id` or without the
`payment.0011` snapshot fields grant and refund from the plan's current values; a paid order whose plan
was deleted activates for 30 days and logs ERROR.
**The owner** is early in their career: explain the *why* with the *what*, flag product decisions as
product decisions, and never assume a step was done because it was described. **Reply in Persian**, every
sentence including progress lines between tool calls (code, paths and identifiers stay Latin). Keep chat
replies short; depth goes into the deliverable. Docs describe the current state only; reasons and dates belong in
commit messages. Commit in batches, not per small change.
## Commands
```bash
source venv/bin/activate
python manage.py runserver
python manage.py runserver # needs DJANGO_DEBUG=1 locally (devOtp, DEV_MASTER_OTP)
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
python manage.py test --noinput # whole suite (973 tests), serial, ~95 s
python manage.py test --noinput accounts # one app
python manage.py test --noinput accounts.tests.SomeTestCase.test_something
python manage.py spectacular --file schema.yaml --format openapi # the API contract
```
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).
**Never run tests with `--parallel` on this Mac, always pass `--noinput`.** The test DB is a SQLite
*file* (`.test-db.sqlite3`, needed by the real-concurrency tests); parallel workers fail to open it and
the parent waits forever, and a leftover test DB makes Django ask "Type 'yes'" on stdin forever. If a
run prints nothing for ~3 minutes it is stuck: `pkill -f "manage.py test"`. No pytest, no linter.
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/`.
Docker (`docker compose up --build`) adds a local Loki + Alloy + Grafana log stack; dev only
(`docs/docker.md`).
## 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.
## 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
**The whole release, as one line:**
```bash
ssh -t administrator@78.38.35.249 'cd ~/mathapp-v2 && ./deploy.sh; sudo systemctl restart zareian-mathapp'
```
It asks for the password twice — once for ssh, once for sudo. **Wait for each prompt**;
pasting several lines at once feeds the queued text to the password prompt and locks you
out for three tries.
The trailing restart is separate because `sudo` inside `deploy.sh` cannot prompt for a
password. The script no longer pretends that worked: it compares the service's start time
before and after and exits non-zero when the restart did not happen, so a half-finished
deploy can never again look like a successful one. Giving `administrator` a `NOPASSWD`
sudoers line for just `systemctl restart zareian-mathapp` would collapse this to a single
command; that is the server admin's call, not ours.
`./deploy.sh` is the whole release: fetch, reset to `origin/<branch>`, install, migrate,
seed content, collectstatic, restart, then it polls `/api/health` and prints the likely
cause if the service does not come back.
**It repairs the server rather than stopping at it.** A hand-edited file used to make
`git pull --ff-only` fail, which aborted the whole script — and because every later step
was skipped, the server silently stayed on old code. Now local modifications (and local
commits) are saved to a timestamped patch under `.deploy-backups/` and then discarded with
`git reset --hard origin/<branch>`, and the deploy continues. Whatever state the server is
left in, one run puts it back on exactly what Gitea has.
**`git clean` is never run there and must never be added.** `db.sqlite3`, `.env` and
`.django_secret_key` are untracked and live in that directory; `reset --hard` leaves them
alone, `clean -fd` would destroy the database, the config and every signed-in session.
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.
## The server: read this before running anything on it
`api.math.tadrisino2.ir` runs on a **shared company machine** (`administrator@78.38.35.249`,
hostname `haghzad`), not a box that belongs to this project. Our app is one tenant among
many. Also on it, all in production use by other people:
- **Gitea itself** (`gitea.service`) — the company's git server, including this repo's
origin. Breaking it means nobody can push or pull anything, anywhere.
- Mail (`postfix`, `dovecot`), DNS (`named`), `mariadb`, `postgresql@14`, `rabbitmq`,
`metabase`, `redis-server`, `nginx`.
- Other apps: `apirag`, `artinafront`, `metaverse`, `celerybeat`, and a **generic
`gunicorn.service` that is not ours**.
- Other people's home directories: `aria`, `artina`, `behdad`, `zahra`, `git`, `gitea`.
They are mode `0750` — unreadable as `administrator`, trivially readable under `sudo`.
Do not read them. Nothing in this project needs them.
**Our account is `administrator`, and `sudo -l` reports `(ALL : ALL) ALL`.** It is also in
the `docker` and `lxd` groups, each of which is root by another route. There is no
technical guard rail between a careless command and the whole company's data. The only
guard rail is the operator.
Ours is exactly one directory: **`/home/administrator/mathapp-v2`**, served by
**`gunicorn-mathapp.service`** (unix socket, proxied by nginx). Note `~/mathapp` also
exists, is not a git repo, and is not what runs — do not touch it.
The same unit also answers to **`zareian-mathapp`** (a symlink alias in
`/etc/systemd/system/`, added 2026-09-18). Prefer that name in anything a human will type
or copy: `gunicorn` alone is a *different team's* service on this box, and `tadrisino` is
too generic for a machine where other people's projects may claim it later.
```bash
sudo systemctl restart zareian-mathapp # ours, unambiguous
sudo systemctl restart gunicorn # NOT ours — someone else's app
```
Rules that follow from this, and are not negotiable:
1. **Never run a destructive command outside `~/mathapp-v2`.** No `rm -rf`, no
`git clean`, no `chown -R`, no dropping databases, anywhere else. When a command
takes a path, write the path out in full rather than relying on the current directory.
2. **Restart only `gunicorn-mathapp`.** `systemctl restart gunicorn` is a different
team's app. `deploy.sh` no longer guesses that name, and neither should you.
3. **Never delete the project directory or "re-clone fresh".** `db.sqlite3`, `.env` and
`.django_secret_key` live inside it and are all gitignored. A fresh clone silently
destroys the database, the config and every logged-in session. Use
`git reset --hard origin/main` when the tree needs to go back to a known state.
4. **Never `git clean`** there for the same reason.
5. **Read `.env` only through Django.** `python -c` printing `settings.CACHES[...]` and
booleans is fine; `cat .env` is not, and neither is pasting its values into a chat.
6. **Take a timestamped backup before editing anything under `/etc`,** and say where you
put it.
7. **Disk is the standing risk.** It has been sitting at ~95% full (about 6 GB free on a
113 GB volume). Check `df -h /` before anything that writes a lot — a full disk on
this machine takes the mail server and Gitea down with us, not just our app.
### Handling the server password (every agent, every time)
Access is a password for the shared `administrator` account, not a key. Handle it exactly
like this — the steps exist for reasons, not ceremony:
```bash
printf '%s' '<password>' > "$SCRATCH/.pw" # scratchpad, never the repo
chmod 600 "$SCRATCH/.pw"
sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 '<command>'
ssh administrator@78.38.35.249 'sudo -S -p "" <command>' < "$SCRATCH/.pw"
shred -u "$SCRATCH/.pw" # immediately after, not "later"
```
- **Never put the password in the command line.** `ps` on this box shows every process's
full argv to every logged-in user, and several other teams have accounts here.
- **Never write it into the repo, a memory file, a commit, or a note.** The scratchpad
only, and only for as long as the commands take.
- **`shred -u` as soon as the work is done**, not at the end of the session. Recreate it
if more work comes; that costs nothing.
- **Never echo it back into the conversation**, even partially, and never paste `.env`
values either.
- Do not create SSH keys, edit `sshd_config`, or change the password unless the user asks
for that specifically. It is a shared account; changing it locks the admin out.
The password reaches an agent only because the user supplies it in that session. If it is
not in the conversation, it is not available — say so and ask, rather than hunting for it
on disk. If the user wants to stop pasting it into chat, the macOS Keychain is the local
fix (`security add-generic-password -s tadrisino-server -a administrator -w`, then read it
back with `security find-generic-password -s tadrisino-server -w`), and an SSH key is the
real one.
`DEBUG` must never be `1` on this server: it turns on `DEV_MASTER_OTP` (`0000`), which
accepts any phone number as a valid login, and renders tracebacks with settings in them.
`DJANGO_DEBUG` belongs in `.env` only — not in the systemd unit, where it once sat as
`Environment="DJANGO_DEBUG=1"` and was saved only by `.env` happening to override it.
`DJANGO_SECRET_KEY` is what signs our bearer tokens. It must be a real random value that
exists nowhere in git. If the env var is simply absent and `DEBUG=0`, `config/settings.py`
generates a strong one and stores it in `.django_secret_key` (mode `0600`) — that is the
preferred setup, because there is then no key to leak. **Never let the server run on the
`django-insecure-…` fallback from `config/settings.py`: that string is committed here, so
anyone who can read this repo can forge a token for any user.**
## State as of 2026-09-17 (evening) — read this first
**The server is live and running current code.** `main` is at the latest commit, the
service `gunicorn-mathapp` was restarted against it, and `/api/health` reports
`grades: 6, lessons: 1928, questions: 1441`. `/api/nodes/1/quiz` returns a real
four-question quiz. 435 tests green.
What that took, and the lessons worth keeping:
1. **The server ran two-day-old code while every file on disk was current.** The pull and
`seed_content` had happened; the service had simply never been restarted, so gunicorn
still served the code it loaded on Sep 15. WhiteNoise builds its static-file index at
startup too, which is why `collectstatic` had run and the admin panel still had no CSS.
**A file on disk is not a deployed file.** Check
`systemctl show <service> -p ActiveEnterTimestamp` before believing anything.
2. **`deploy.sh` reported success while this was true.** It health-checked a public URL
that the *old* process answered with 200. It now compares the service start time
before and after, and exits non-zero when the restart did not happen.
3. **Two files had been hand-edited on the server** (`config/urls.py`, `deploy.sh`), both
only to add a trailing slash to the health route. That is why `git pull` kept
conflicting. `api/health/` is now registered upstream alongside `api/health`, so the
edits were reverted and are not needed again.
Still open, and deliberately not acted on:
- **`DJANGO_SECRET_KEY` on the server is the `django-insecure-…` fallback committed in
`config/settings.py`.** Auth tokens are signed with it, so anyone who can read this repo
can forge one. Fixing it means removing the key from `.env` *and* from the systemd unit
and letting settings generate one. The owner decided this is the server admin's call,
not ours. `manage.py check --deploy` flags it as `security.W009` on every release.
- `CACHE_URL` is unset, so rate limiting is per-worker (`LocMemCache`). Redis is already
running on the box at `127.0.0.1:6379`; `db0` belongs to another app, `db1` is free.
- The frontend is still 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, and **PR #3** (`zareian/api-client-2`) is still open upstream.
Until someone connects the screens, none of the 1,441 questions are visible to a user.
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.
| `accounts` | identity, OTP login, sessions, profile | `User`, `Profile`, `Session`, `OTPCode`, `LoginEvent` |
| `content` | subjects, curriculum, lessons, questions, quiz, votes/reports | `Grade`, `Subject`, `Unit`, `Node`, `Lesson`, `Question`, `Choice`, `UserAnswer`, `UserNodeCompletion`, `ContentVote`, `SeedRecord` |
| `progress` | stats, streaks, hearts, leaderboard, companions, weekly report | `UserStats`, `StreakRecord`, `UserHearts`, `LeaderboardEntry`, `Character` |
| `ai_tutor` · `rag` | tutor chat; retrieval from Qdrant + generation via GapGPT; textbook ingest | `Conversation`, `Message`, `LessonChunk`, `IngestedPage` (book list: `rag/services/textbooks.py`) |
| `subscription` | plans and subscriptions | `Plan`, `Subscription` |
| `payment` | orders, transactions, Zarinpal, Cafe Bazaar, payment event ledger | `Order`, `Transaction`, `PaymentEvent` |
| `discount` · `affiliate` | discount codes/offers; referral commissions | `DiscountCode`, `Redemption`, `Offer`, `AffiliateAccount`, `Conversion` |
| `notifications` · `support` · `campaigns` | notifications; tickets/bug reports; campaign storage (no sending) | |
| `statuslog` | cross-app status-change log | `StatusChangeLog` |
| `config` | settings, urls, middleware, health, renderers | |
Invariants that are not obvious from one file:
- **Auth is ours, not Django's.** `accounts.User` is deliberately not `AUTH_USER_MODEL`. Access token =
signed `user_id` (1 h, `django.core.signing`, `accounts/authentication.py`); refresh tokens live
(hashed) in `Session`; logout sets `User.tokens_valid_after`. A missing/invalid header means an anonymous
request; each view's permission class decides. Login is phone + SMS code only (6 digits, 5 guesses);
there is no guest login. The signing key is `.django_secret_key` (generated on the server, mode 600);
the `django-insecure-…` fallback in `config/settings.py` must never sign tokens on a server.
- **No cross-app foreign keys.** Apps refer to each other with `user_id` / `object_id` + `label` so any app
can be split out. Follow the pattern.
- **Throttling**: every sensitive view has a `throttle_scope` and a matching rate in
`REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']`. New endpoint = both. Anonymous requests are limited by IP;
`config.middleware.TrustedProxyMiddleware` trusts `X-Forwarded-For` only from ArvanCloud ranges
(`TRUSTED_PROXY_NETWORKS`), and nginx refuses anything not from Arvan.
- **Grade id = grade number** (8 = eighth). Units belong to a `Subject` (ریاضی، حسابان، هندسه، آمار و احتمال،
گسسته); `/api/home?subject=` picks the book, an unrelated subject is 400.
- **Domains are configuration**: only `PUBLIC_BASE_URL` and `FRONTEND_URL`; everything else derives from
them (`config/tests_domains.py`). Domain-move procedure: `docs/operations.md`.
- **Output is plain Persian** (company decision): `config.renderers.PlainPersianJSONRenderer` strips
diacritics and the written ezafe «ی» from every JSON response, and `rag.services.mathtext.to_plain` does
the same for the tutor (stream included). The DB, seed and admin keep the original text, because rewriting
titles would break question→lesson binding by title path. No LaTeX/markdown reaches a student: seed and
import convert (`content/plain_math.py`), the tutor converts, and the frontend's `plainMath.ts` is the
last net; the two converters must behave alike.
- **Structured logging**: `APIRequestLoggingMiddleware` logs every `/api/` request as one JSON line (no
body, headers or secrets). Unexpected errors always return JSON (`config/exceptions.py`).
- **`.iterator()` never wraps a loop that writes or does network I/O**: the open read cursor held locks
for the whole loop (a "flaky" test that was a real bug). Materialize with `list(...)`.
- **Network calls stay outside `transaction.atomic()`**, and the tutor and the OTP SMS call hand their DB
connection back before the network wait (`release_db_connection`).
## Current state, by area
### Infrastructure (server 216)
- Dedicated VMware VM, Ubuntu 24.04, 8 cores (Xeon E5-2690 v3), 12 GB RAM, 97 GB disk; outbound IP
`78.38.35.228`. ufw allows 22/80/443; FireHOL blocklist, `ufw limit 22` and stepped fail2ban against SSH
brute force. apt/pip/npm come from Iranian mirrors (`docs/operations.md` «سرور»).
- Traffic: ArvanCloud → 216:443 for both `math.` and `mathapi.tadrisino2.ir` (origin switched 2026-10-18).
- Postgres 16 cluster `16/zareian`, port 5440, localhost only (`DB_*` in `.env`; without `DB_NAME` the app
uses SQLite, which is what local dev and tests use). `max_connections=60`.
- Connections come from a per-worker pool (`DB_POOL_MAX_SIZE` 8); a full pool answers 503 + `Retry-After`
(`config/db.py`), and `/api/health` → `dbPool` checks `workers × pool + 6 ≤ max_connections`.
`DB_CONN_MAX_AGE` in `.env` stops startup on purpose.
- gunicorn: `gunicorn.conf.py`, 4 `gthread` workers × 32 threads (the unit passes no `--workers`). The tutor
gets at most `AI_MAX_CONCURRENT_PER_WORKER` (12) threads per worker; beyond that `/api/ai/ask` answers 503
`Retry-After: 10` before reserving quota.
- Cache: our own Redis (`zareian-redis`, 6390, password, 256 MB `allkeys-lru`, no persistence) via
`CACHE_URL`. `config.cache.ResilientRedisCache` fails open on a Redis outage (logged, health 503, cache-based
limits stop applying); not tested against a real outage on the server.
- nginx (versions in `ops/nginx/`): only ArvanCloud and the machine itself get through (direct IP → 403);
CSP and security headers on the frontend; API body limit 20 MB; HSTS. Verified in Chrome; **not** verified
in the Android WebView or through `pay.html` to the gateway.
- Capacity: measured only on 249 (shared CPU): 300 players ≈ 88 req/s, median 0.1 s; 600 ≈ 137 req/s,
median 1.4 s; 1,000 hit the ceiling. **216 has not been load-tested yet** (`tools/loadtest/README.md`); with
more cores, 8 workers and `DB_POOL_MAX_SIZE` 6.
- `/api/health`: public answer is only ok/error; full details (DB, migrations, cache, pool, integrations,
`aiProvider`, `offsiteBackup`, warnings, `clientIp`) need `X-Internal-Key`. Swagger/ReDoc/schema need an
admin session.
### Backups and logs (encrypted on 216, copied to 247)
pgBackRest on 216 archives WAL continuously (at most a minute unarchived) with a weekly full and a daily
diff backup, encrypted (`aes-256-cbc`); payment CSVs every 6 hours, encrypted with `age`.
`tadrisino-offsite-sync` copies both to `~/zareian-pgbackrest-216/` on 247 every minute with a write-only
key. 247 holds only ciphertext; the keys are in `/etc/tadrisino-backup/RECOVERY-KEYS` on 216, and the owner
keeps a copy outside the servers (since 2026-10-18); without them 247's copy is unreadable.
`deploy.sh` also takes a `pg_dump -Fc` before `migrate` (last 2 kept). The app log is archived nightly on
216 but **not yet shipped to 247**. `sudo -u postgres tadrisino-restore-check` restores into a temp dir and
compares counts (run after the cut-over: equal). Details: `docs/operations.md` «بکاپ و بازیابی».
### Content
- `content/seed/` is the whole curriculum and question bank as committed JSON: 10 grades (3–12), 5 subjects,
89 units, 1,145 islands, 5,406 lessons (every one has at least one question), 39,156 questions. `deploy.sh` runs `seed_content` on every release.
- `seed_content` is idempotent, never deletes, and does not undo admin work: the `SeedRecord` ledger remembers
what it delivered, so anything the admin deleted or renamed is not recreated; lesson text, `example`,
`steps` and `figures` are only filled when empty. Questions bind to lessons **by title path**
(grade › unit › node › lesson), never by id. Practice islands («تمرین: …») are the exception: their
options are synced from the file in place (`seed_sync._refresh_practice`).
- Content changes reach the server only as committed seed files (`seed_content --export`); pushing code
alone moves no content. Lesson ids differ between laptop and server.
- Islands longer than 7 lessons are split into parts of ≤6 (`content/node_split.py`). 160 practice islands
close each chapter (grades 3–12) with computed-answer questions.
- `Lesson.figures` and `Question.figure` are fixed diagrams (same spec as the tutor's, validated by
`rag.services.diagram.validate`); nothing calls a model at display time. Geometry/statistics/discrete
figures were hand-written and checked by eye; API-generated figures (`gen_lesson_figures`) were about half
wrong on geometry and are only used with visual review (`--dry-run` still calls the API).
- Students vote 👍/👎 and report problems on every question and lesson (`ContentVote`, admin columns and
report queues).
- Not yet filled: `Unit.blurb` for some units, `Plan.features`/`badge`; no video content exists although
`Lesson.kind`/`video_url` can express it.
### Playing: islands, quizzes, hearts
- `GET /api/nodes/{id}/quiz`: at most one question per lesson, shuffled on `nodeId:userId` only, so a student
sees the same quiz every time (owner's decision; `NodeQuizStabilityTests`).
**Figured questions win:** inside a lesson the quiz takes a question with a `figure` over one without
(`NodeQuizPrefersFigureTests`). So adding figured questions to a lesson replaces its figureless ones in
the quiz without deleting anything; the old rows stay (seed never deletes, answers may reference them).
Owner's standing plan (2026-10-14): keep writing more figured questions and re-review the whole bank over
time; this rule is the mechanism, so new figures need no other switch. Write them with
`tools/figures/batch.py` (`docs/content-authoring.md` «سوالِ شکل‌دار»). The lesson-level list
(`/api/lessons/{id}/questions`) does not reorder; only the island quiz does.
**A figure must not give the answer away** (title, labels, or drawing the thing the question asks for); the
rules and the fix-up tool (`tools/figures/apply_fixups.py`) are in `docs/content-authoring.md`.
- `POST /api/nodes/{id}/quiz/complete` completes the island at any score (score sets stars/XP) and returns
`nextNodeId`. It refuses a locked island (403 `node_locked`, same rule as `/api/home`) and a user who has
not answered every question (409 `answers_missing`; required = `max(client totalQuestions, min(4, questions
in node))`). An island with no questions passes when unlocked. `POST /api/lessons/{id}/complete` has the same
rules. XP counts only answers recorded in `UserAnswer`.
- Hearts are enforced (`HEARTS_ENFORCED`): a wrong answer costs one, one returns every 5 h, all return 24 h
after the first loss; zero hearts → 403 `no_hearts`. An active subscription means unlimited hearts.
- `UNLOCK_ALL_PHONES` (server `.env`: the owner's and a tester's numbers, users 34 and 35) removes the island
order lock for testers; their content access comes from a `Subscription` row.
- Streaks and the leaderboard are computed live from activity (Tehran time); companions (sigma, delta, pi,
omega) unlock by XP.
### Paywall, subscriptions, payment
- Only the first unit of **each book** is free. Elsewhere, without an active subscription, lesson questions,
island quizzes, answers and completion return 402 `subscription_required`; `/api/home` marks each unit
`requiresSubscription`.
- Only subscriptions are sold (question packs were removed at the root): 1/3/6 months at
598/898/1,398 thousand toman; the tutor allows 4 questions a day for subscribers and
`AI_DAILY_QUOTA_FREE` (2) for free users; `AI_GLOBAL_DAILY_MAX` 3,000 for the whole system.
- Zarinpal: `paymentUrl` points at our `GET /api/payment/orders/{id}/pay`, which calls PaymentRequest and
redirects; the callback always re-verifies server-to-server. Unverified paid orders are re-checked when the
app reads status and by `payment_reconcile`. The merchant is the one `Tadrisino_backend` uses (registered
for `tadrisino2.ir`); PaymentRequest returns 100. **No real payment has gone through to verify yet.**
- Cafe Bazaar: subscription SKUs `10342`/`10343`/`10344` under package `com.tadrisino.math.app`, set on the
plans in admin; dynamic-price discounts via `BAZAAR_DYNAMIC_PRICE_KEY`; renewals are polled from Bazaar
(`sync_bazaar_renewals`). The server talks to Bazaar through the company account's OAuth. Payment methods
differ only in `payment/providers.py`. **No real Bazaar purchase or renewal has been tested.**
- Every purchase step writes a `PaymentEvent` row. Cancel and refund are admin actions on
`/admin/payment/order/` (the money itself is returned in the gateway's panel).
- Both gateways and SMS fail closed (503 / invalid) without credentials, never a fake success.
### AI tutor
- `POST /api/ai/ask` is RAG: Qdrant retrieval filtered by book and chapter → GapGPT generation
(`OPENAI_BASE_URL`, model `gpt-4.1-mini`, prompt `tutor-v12-age-tone`). `usedChunks` names the book chunks
used. Without `OPENAI_API_KEY`/`QDRANT_API_KEY` and `DEBUG=0` it answers 503, never a stub.
- **Broken since the cut-over (2026-10-18):** `qdrant.tadrisino2.ir` (behind Arvan, origin server not ours
and unknown to us) now answers a Django `400 Bad Request` page, not Qdrant, from 216 and from the laptop;
every retrieval fails and the student sees «سرویس در دسترس نیست». Its Arvan origin has to be put back by
the senior; nothing in our code or `.env` changed.
- Answers are plain text, step by step, math-only (`rag/services/guardrails.py`; self-harm messages get a
kind reply with helpline numbers), tone by age, with an optional `<diagram>` JSON block that the frontend
draws (`rag/services/diagram.py`, frontend `src/diagram/`). Solutions marked `disagrees` never reach the
model. With `questionId` it sees the quiz question and the student's last answer.
- Cost ≈ 0.2 cents per question (plan with ~700 toman). `/api/health` → `aiProvider` shows when GapGPT credit
runs out; every live call spends the same production credit, laptop included.
### Accounts, SMS, users
- Kavenegar sends OTP only through the approved template `SMSVerify` (`verify/lookup`,
`KAVENEGAR_OTP_TEMPLATE`); plain `sms/send` is cancelled on this line, so never unset the template. Every
OTP costs credit. Limits are counted in the DB: per-phone cooldown and 10/day, system-wide
`OTP_SMS_GLOBAL_DAILY_MAX` (2,000 on the server), per-IP 100 requests and 300 logins per hour.
- No fixed-code test logins on the server (`TEST_LOGIN_PHONES` empty; health warns if set) and no demo users.
To test without SMS, a superuser creates an `OTPCode` in `/admin/accounts/otpcode/add/` (random code, a few
minutes), then `POST /api/auth/logout` afterwards. For a browser check, put the tokens in
`localStorage['tadrisino.tokens']` and drive Chrome with `puppeteer-core` from the scratchpad; never print
the access token.
- Profile: Jalali `birthDate`, display id `User482917` (`accounts/public_id.py`), weekly report
(`/api/user/weekly-report`, minutes are estimated), invoices, account deletion with a fresh SMS code
(frontend: «حساب کاربری»).
### Frontend and Android
- Frontend: React 18 + TypeScript (strict) + Vite + Tailwind; the client is `src/api/` and is the only
place that calls the server. Its README is the map. Never write the client from memory: read the schema and
check shapes against a running server (`content/tests_frontend_contract.py` is the backend-side guard).
- Android: WebView shell plus Poolakey for Bazaar. The installed APK lags the code; what the next build must
carry and what to check on a phone is in `android/README.md`. Signing belongs to teammate MH-W90; never
commit a keystore. Until the new APK, card payment from the app goes through the frontend's `pay.html`
(Zarinpal refuses StartPay without our domain as Referer).
### Still open before real users
`docs/security-before-production.md` is the list (public `/admin/`, plaintext OTP in the admin, SMS-cap
exhaustion, shared server account, …). Do not call this deployment production-ready until it is empty. When
one is closed, move it to that file's «حفاظ‌هایی که هست» table with the test that holds it.
---
## Lessons that were expensive
- **GapGPT** (OpenAI-compatible proxy reachable from Iran; it answers 504 from outside Iran). Two failures
look alike: `429 initial_usage_limit_exceeded` is an hours-long account cap that retries *extend*;
`403 insufficient user quota` (with spaces) is exhausted credit. `is_account_limit()` in
`rag_verify_solutions` is the shared guard. `/dashboard/billing/usage` (cents) is the only reliable balance;
probe it before any bulk run. Credit has run dry more than once.
- **GapGPT spend stays at cents per session**: the laptop key is the production tutor's account (another
person uses it too); one benchmark of ~250 calls once emptied it and the live tutor returned 503. Estimate
first, test plumbing with `tools/fake_openai.py`, ask before anything bigger.
- **Measure models; don't read price tables.** GapGPT prices differ from official ones; the judge model in
`rag_model_benchmark` misjudges ~7%, so hand-check samples before deciding. Vision ingest uses
`gpt-4.1-mini` (`docs/extraction-playbook.md`).
- **Bulk content is written by Claude Code subagents, not the paid API**: Sonnet authors questions well
(~88% yield), Haiku does not (~40% broken). The weekly Sonnet limit arrives suddenly: predict the cost and
import results between batches. Every generated batch gets an independent blind re-solve.
- **Verification is three-state** (`agrees`/`disagrees`/`unjudgeable`); a boolean verdict reported ~3× the
real error rate. Question generation reads only `agrees` chunks, and `agrees` is a first filter, not a
guarantee. `solution` is never rewritten; the independent answer is in `checked_answer`. In these books
`۳/۱۰۴` is the decimal 3.104, and in primary books «/» is both decimal separator and fraction bar.
- `rag_verify_solutions --recheck` resends everything; to re-check a subset, clear its verdicts. Never run two
writing management commands at once on local SQLite ("database is locked" left a run half done, silently).
- In shell wait loops use `pgrep -f`, not `ps | grep` (it counts itself).
- A load test or wait loop on the server must not `wait` on a background gunicorn (it hangs forever).

360
README.md
View File

@@ -1,273 +1,139 @@
# Tadrisino API
بک‌اند اپلیکیشن آموزشی Tadrisino — Django + Django REST Framework.
بک‌اندِ **تدریسینو**، اپِ آموزشِ ریاضی به سبکِ دولینگو برای دانش‌آموزانِ پایه‌ی سوم تا دوازدهم. دانش‌آموز روی نقشه‌ای
از جزیره‌ها جلو می‌رود، هر جزیره آزمونی کوتاه از مسئله‌های خودِ کتابِ درسی است، و هر جا گیر کند از دستیارِ هوشِ
مصنوعی می‌پرسد که جوابش را از همان کتاب می‌سازد.
احراز هویت با OTP، محتوای درسی و پیشرفت کاربر، دستیار هوش مصنوعی، اشتراک و پرداخت،
تخفیف، اعلان‌ها، همکاری در فروش، پشتیبانی و حساب خانواده.
این مخزن یک REST API روی Django است: برنامه‌ی درسی و آزمون، پیشرفت و گیمیفیکیشن، دستیارِ مبتنی بر RAG، اشتراک
و پرداخت. فرانت‌اند مخزنِ جداگانه‌ای است ([`Tadrisino/MathApp_frontend`](https://gitea.tadrisino2.ir/Tadrisino/MathApp_frontend)).
**قراردادِ میانِ دو طرف اسکیمای OpenAPIِ همین مخزن است**؛ هر تغییری در API از آن‌جا دیده می‌شود.
| | |
|---|---|
| **سرویسِ زنده** | `https://mathapi.tadrisino2.ir` · اپ: `https://math.tadrisino2.ir` |
| **فناوری** | Python 3.11 · Django 5.2 · Django REST Framework · PostgreSQL 14 · Redis · Qdrant · gunicorn پشتِ nginx و ArvanCloud |
| **API** | ۶۱ عملیات روی ۵۹ مسیر، مستند با OpenAPI (`/api/docs/`) |
| **محتوا** | ۱۰ پایه · ۵ کتاب · ۸۹ فصل · ۱٬۱۴۵ جزیره · ۵٬۴۰۶ درس · ۳۹٬۱۵۶ سوال |
| **تست** | ۹۷۳ تستِ خودکار، همه سبز |
| **سرویس‌های بیرونی** | GapGPT (مدلِ زبانی) · Qdrant (جستجوی کتاب) · کاوه‌نگار (پیامک) · زرین‌پال و کافه‌بازار (پرداخت) |
---
## امکانات
| بخش | چه می‌کند |
|---|---|
| **ورود** | فقط با شماره‌ی موبایل و کدِ پیامکی؛ توکنِ دسترسیِ امضاشده‌ی یک‌ساعته و توکنِ تمدید |
| **برنامه‌ی درسی** | پایه ← کتاب ← فصل ← جزیره ← درس ← سوال؛ کلِ محتوا به‌صورتِ JSONِ کامیت‌شده در `content/seed/` |
| **بازی** | آزمونِ هر جزیره، قفلِ ترتیبی، قلب، ستاره و امتیاز، زنجیره‌ی روزانه، جدولِ رتبه‌بندی، همراه‌ها |
| **دستیار** | پاسخِ گام‌به‌گام از روی همان کتاب (Qdrant + GapGPT)، فقط ریاضی، با شکلِ توصیفی که اپ می‌کشد |
| **اشتراک** | فصلِ اولِ هر کتاب رایگان؛ اشتراکِ یک/سه/شش‌ماهه از زرین‌پال یا کافه‌بازار، با کدِ تخفیف و برنامه‌ی معرفی |
| **پنلِ ادمین** | ویرایشِ محتوا، ورود و خروجِ فله‌ای سوال، پلن‌ها، سفارش‌ها (لغو و بازپرداخت)، تیکت‌ها، گزارش‌های کاربران |
| **پایش** | `/api/health` (دیتابیس، مهاجرت، کش، سرویس‌های بیرونی، بکاپ)، لاگِ JSON برای هر درخواست، دفترِ رویدادِ پرداخت |
---
## شروعِ سریع
```bash
python3.11 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # برای توسعه‌ی محلی DJANGO_DEBUG=1 را ست کنید
python manage.py migrate
python manage.py seed_content # برنامه‌ی درسی و بانکِ سوال از content/seed/
python manage.py createsuperuser # برای /admin/
python manage.py runserver # http://127.0.0.1:8000
```
بدونِ `DB_NAME` در `.env` دیتابیسِ محلی SQLite است و نیازی به نصبِ Postgres نیست. `.env` خودکار خوانده می‌شود.
| آدرس | چیست |
|---|---|
| `/api/docs/` · `/api/redoc/` · `/api/schema/` | مستنداتِ زنده‌ی API (روی سرور فقط با ورود به پنلِ ادمین) |
| `/admin/` | پنلِ ادمین |
| `/api/health` | سلامتِ سرویس |
**اولین درخواست:** با `DJANGO_DEBUG=1`، `POST /api/auth/otp/request` با یک شماره، سپس `POST /api/auth/login` با
کدی که در فیلدِ `devOtp` پاسخ آمده. `token` را در Swagger زیرِ **Authorize** بگذارید.
```bash
python manage.py test --noinput # کلِ تست‌ها (سریال؛ --parallel روی macOS گیر می‌کند)
python manage.py test --noinput content # تست‌های یک اپ
python manage.py spectacular --file schema.yaml --format openapi # خروجیِ قرارداد برای فرانت
```
برای استکِ لاگِ محلی (Loki و Grafana) [`docs/docker.md`](docs/docker.md) را ببینید.
---
## معماری
یک برنامه‌ی جنگو در قالبِ «مونولیتِ ماژولار»: هر اپ یک مسئولیت دارد و مدل‌های خودش را نگه می‌دارد.
| اپ | مسئولیت |
|---|---|
| `accounts` | احراز هویت، session، پروفایل کاربر |
| `content` | پایه‌ها، مباحث، درس‌ها، سوال‌ها، پیشرفت |
| `progress` | آمار، لیدربرد، streak |
| `ai_tutor` | مکالمات دستیار هوش مصنوعی |
| `rag` | لایه‌ی RAG: بازیابی از Qdrant + تولید با GPT، پشتِ `POST /api/ai/ask` |
| `subscription` / `payment` / `discount` | پلن، سفارش، تراکنش، کد تخفیف |
| `notifications`, `affiliate`, `support`, `family` | امکانات جانبی |
| `statuslog` | لاگ عمومی تغییر status، مستقل از بقیه‌ی اپ‌ها |
| `accounts` | هویت، ورود با کدِ پیامکی، نشست، پروفایل |
| `content` | کتاب‌ها، برنامه‌ی درسی، درس، سوال، آزمونِ جزیره، بازخوردِ کاربر |
| `progress` | آمار، زنجیره‌ی روزانه، قلب، رتبه‌بندی، همراه‌ها، گزارشِ هفتگی |
| `ai_tutor` · `rag` | دستیار: گفتگو، بازیابی از Qdrant، تولید با GapGPT، ورودِ کتاب |
| `subscription` · `payment` · `discount` · `affiliate` | پلن و اشتراک، سفارش و تراکنش، کدِ تخفیف، برنامه‌ی معرفی |
| `notifications` · `support` · `campaigns` · `statuslog` | اعلان، پشتیبانی، کمپین، لاگِ تغییرِ وضعیت |
| `config` | تنظیمات، مسیرها، middleware، سلامت |
سه قاعده که از خواندنِ یک فایل پیدا نیست:
- **احرازِ هویت سیستمِ خودِ پروژه است.** `accounts.User` عمداً `AUTH_USER_MODEL` نیست؛ توکن با `django.core.signing`
امضا می‌شود (`accounts/authentication.py`). هدرِ نامعتبر یعنی درخواستِ ناشناس، و هر ویو خودش تصمیم می‌گیرد ورود
لازم است یا نه.
- **اپ‌ها با قرارداد جدا شده‌اند، نه با ForeignKey.** ارجاعِ بینِ اپ‌ها با `user_id` یا `object_id` + `label` است تا
هر اپ بعداً جداشدنی بماند.
- **محدودیتِ نرخ متمرکز است.** هر اندپوینتِ حساس یک `throttle_scope` دارد و نرخش در `config/settings.py` است؛ IPِ
واقعی فقط از پروکسی‌های ArvanCloud پذیرفته می‌شود.
شرحِ کامل و دلیلِ هر تصمیم در [`docs/architecture.md`](docs/architecture.md) و
[`docs/technical-handbook.md`](docs/technical-handbook.md) آمده است.
---
## ۱. راه‌اندازی محیط توسعه
## استقرار
### پیش‌نیازها
- پایتون ۳.۱۲
- pip
### تنظیم محیط
```bash
python3 -m venv venv
source venv/bin/activate # ویندوز: venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env
```
`.env.example` (تمام متغیرهایی که واقعاً توسط اپ خونده می‌شن):
```env
DJANGO_DEBUG=1
DJANGO_SECRET_KEY=
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
CACHE_URL=
KAVENEGAR_API_KEY=
GRAFANA_ADMIN_PASSWORD=admin
```
مقادیر `.env` رو به شل خودتون export کنید (یا از `direnv`/`python-dotenv` استفاده کنید):
```bash
export DJANGO_DEBUG=1 # ویندوز (PowerShell): $env:DJANGO_DEBUG=1
```
> `DEBUG` دیفالتش `False` است. برای فعال‌کردنِ کمک‌کننده‌های dev-only در Swagger
> (`DEV_MASTER_OTP` و فیلدِ `devOtp` توی پاسخ‌ها)، محلی `DJANGO_DEBUG=1` رو ست کنید.
### سریع‌ترین راه اجرای پروژه
```bash
python manage.py migrate
python manage.py runserver
```
اپ روی `http://127.0.0.1:8000` بالا میاد.
### مایگریشن‌ها
```bash
python manage.py migrate
```
### دیتای تستی (Seed)
یک دیتاست کوچک و idempotent می‌سازه که همه‌ی اپ‌ها رو پوشش می‌ده (کاربر، محتوا، پیشرفت،
اشتراک، پرداخت، تخفیف، دستیار هوش مصنوعی، اعلان، افیلیت، پشتیبانی، خانواده). اجرای مکرر
امن است — با `get_or_create` هیچ رکورد تکراری‌ای نمی‌سازه. پیش‌فرض فقط وقتی `DEBUG=True`
اجرا می‌شه، مگر با `--force`.
سرویس روی سرورِ اختصاصیِ `78.38.35.216` اجرا می‌شود؛ اپ با کاربرِ `mathapp` در `/home/mathapp/mathapp-v2` و سرویسِ
`zareian-mathapp`. کلِ انتشار (جزئیات در `docs/operations.md` «دیپلوی»):
```bash
python manage.py seed_test_data # لوکال (DEBUG=True)
python manage.py seed_test_data --force # staging (DEBUG=False)
python manage.py seed_test_data --reset # پاک‌کردنِ دیتای قبلیِ seed و ساختِ دوباره
ssh -t zareian@78.38.35.216 'sudo -u mathapp bash -c "cd /home/mathapp/mathapp-v2 && ./deploy.sh"; sudo systemctl restart zareian-mathapp'
```
### مستندات API
| | |
|---|---|
| Swagger UI | `http://127.0.0.1:8000/api/docs/` |
| Redoc | `http://127.0.0.1:8000/api/redoc/` |
| OpenAPI schema | `http://127.0.0.1:8000/api/schema/` |
| پنل ادمین | `http://127.0.0.1:8000/admin/` |
`deploy.sh` کد را با `origin/main` یکی می‌کند، وابستگی‌ها را نصب می‌کند، پیش از `migrate` از دیتابیس بکاپ می‌گیرد،
محتوا را seed و فایل‌های استاتیک را جمع می‌کند و سلامتِ سرویس را می‌سنجد. سپس از بیرون:
**شروع سریع در Swagger:**
1. `POST /api/auth/session` با `{"type":"guest","deviceId":"d_test1"}` → یک `token` بگیرید.
2. روی دکمه‌ی **Authorize** بزنید و فقط خودِ `token` رو (بدون کلمه‌ی Bearer) وارد کنید.
3. برای تست لاگین با شماره: `POST /api/auth/otp/request` بزنید، یا در محیط توسعه کد **0000**
رو با هر شماره‌ای در `POST /api/auth/login` امتحان کنید (dev-only، نیاز به `DEBUG=True`).
### دستورهای پرکاربرد
```bash
python manage.py createsuperuser # اختیاری، برای /admin
python manage.py test # اجرای کل تست‌سوییت
curl https://mathapi.tadrisino2.ir/api/health # {"status": "ok"}
```
### دستیار هوش مصنوعی (RAG)
`POST /api/ai/ask` پاسخ را از روی محتوای واقعیِ کتاب می‌سازد: embedding سوال →
جستجوی Qdrant محدود به همان درس/فصل → تولید با GPT. تیکه‌های استفاده‌شده در
`usedChunks` برمی‌گردند.
- نیازمندِ `OPENAI_API_KEY` و `QDRANT_API_KEY` در `.env` (اپ `rag`؛ تنظیماتِ
`OPENAI_*` / `RAG_*` در `config/settings.py`). بدونِ کلید، همان پاسخِ stub قبلی.
- تست در Swagger: `POST /api/ai/ask` با
`{"userQuestion":"...","subject":"ریاضی","grade":"ششم","lessonNumber":2}`.
- `"stream": true` پاسخ را به‌صورت SSE توکن‌به‌توکن می‌فرستد (قراردادِ JSON بدونِ این فلگ دست‌نخورده).
- `python manage.py rag_catalog` فهرستِ زنده‌ی collectionهای Qdrant را نشان می‌دهد (فقط‌خواندنی).
- افزودنِ محتوای خودمان: `POST /api/rag/ingest/text` و `/api/rag/ingest/pdf` (داخلی، هدرِ `X-Internal-Key`).
هر تغییرِ دیتابیس لحظه‌ای به سرورِ بکاپِ جداگانه‌ای می‌رود. رویه‌ی کامل (متغیرهای محیطی، بکاپ و بازیابی، nginx،
عوض کردنِ دامنه، فروش در کافه‌بازار): [`docs/operations.md`](docs/operations.md).
---
## ۲. راهنمای دیپلوی پروداکشن
## مستندات
### بالا آوردنِ نسخه‌ی جدید — یک دستور
```bash
./deploy.sh
```
همین. اسکریپت به ترتیب کدِ تازه را می‌گیرد، وابستگی‌ها را نصب می‌کند، مهاجرت‌ها
را اعمال می‌کند، فایل‌های استاتیک را جمع می‌کند، سرویس را ری‌استارت می‌کند، و در
پایان **خودش بررسی می‌کند که واقعاً بالا آمده یا نه**. اگر بالا نیامد، علت‌های
رایج را چاپ می‌کند.
چرا این مهم است: قبلاً این‌ها چند کارِ دستی به ترتیبِ درست بودند و فراموش شدنِ
هر کدام اپ را می‌خواباند، با خطایی که گویا نبود. مثلاً اضافه شدنِ یک پکیجِ جدید
به `requirements.txt` بدونِ `pip install` باعثِ `ModuleNotFoundError` موقعِ بالا
آمدن می‌شود و کاربر فقط `502 Bad Gateway` می‌بیند. اسکریپت همیشه `pip install`
را اجرا می‌کند تا این حالت اصلاً پیش نیاید.
بارِ اول، نامِ سرویس را بدهید تا دفعه‌های بعد خودکار ری‌استارت شود:
```bash
TADRISINO_SERVICE=<نامِ سرویس> ./deploy.sh
```
متغیرهای اختیاری: `SKIP_PULL=1` (بدونِ git pull)، `TADRISINO_HEALTH_URL`،
`PIP_INDEX_URL` (اگر pypi.org تایم‌اوت می‌دهد).
### بررسیِ سلامت از بیرون
```bash
curl https://<host>/api/health
```
بدونِ احرازِ هویت و بدونِ SSH می‌گوید دیتابیس سالم است یا نه، مهاجرتِ
اعمال‌نشده مانده یا نه، کش کار می‌کند یا نه، و چند پایه/درس/سوال روی آن سرور
هست. `200` یعنی سالم، `503` یعنی چیزی واقعاً خراب است. هیچ سکرتی برنمی‌گرداند.
این همان چیزی‌ست که جلوی «کد رفته ولی مهاجرت اجرا نشده» را می‌گیرد — حالتی که
اپ ظاهراً بالا می‌آید و بعد زیرِ بارِ واقعی می‌شکند.
### مدیریتِ محتوا بدونِ ترمینال
هیچ محتوایی لازم نیست با دستور روی سرور گذاشته شود.
| کار | کجا |
| سند | موضوع |
|---|---|
| افزودن/ویرایشِ **یک** سوال | `/admin/content/question/` → «افزودن» (انتخابِ درس با جستجو) |
| وارد کردنِ **فله‌ای** سوال | همان صفحه → «وارد کردنِ سوال‌ها از فایل» |
| گرفتنِ خروجیِ سوال‌ها | چند سوال را انتخاب کنید → اکشنِ «دریافتِ فایل» |
| وارد کردنِ **برنامه‌ی درسی** (پایه/فصل/درس) | `/admin/content/grade/` → «وارد کردنِ برنامه‌ی درسی از فایل» |
| گرفتنِ خروجیِ برنامه‌ی درسی | پایه‌ها را انتخاب کنید → اکشنِ «دریافتِ فایلِ برنامه‌ی درسی» |
| [`docs/technical-handbook.md`](docs/technical-handbook.md) · [PDF](docs/technical-handbook.pdf) | مستندِ فنیِ کامل برای تحویل و پیوستنِ توسعه‌دهنده‌ی تازه |
| [`docs/architecture.md`](docs/architecture.md) | تصمیم‌های معماری و دلیلشان |
| [`docs/operations.md`](docs/operations.md) | استقرار، پیکربندی، سلامت، بکاپ و بازیابی، nginx، پرداخت |
| [`docs/security-before-production.md`](docs/security-before-production.md) | آنچه پیش از کاربرِ واقعی باید بسته شود، و حفاظ‌های موجود |
| [`docs/content-authoring.md`](docs/content-authoring.md) | ویرایشِ محتوا از پنلِ ادمین و ساختِ انبوهِ سوال |
| [`docs/extraction-playbook.md`](docs/extraction-playbook.md) | خواندنِ کتابِ درسی با مدلِ تصویری، هزینه و انتخابِ مدل |
| [`docs/ai-cost-and-guardrails.md`](docs/ai-cost-and-guardrails.md) · [`docs/model-prices.md`](docs/model-prices.md) | هزینه‌ی دستیار، حاشیه‌ی سودِ اشتراک‌ها، دفترِ قیمتِ مدل‌ها |
| [`tools/loadtest/README.md`](tools/loadtest/README.md) | آزمونِ بار |
| [`CLAUDE.md`](CLAUDE.md) | راهنمای agentهای کدنویس، به‌ویژه قواعدِ کار روی سرورِ مشترک |
خروجیِ هر کدام دقیقاً همان قالبی‌ست که ورودی‌اش می‌پذیرد، پس چرخه‌ی
«بگیر → ویرایش کن → برگردان» بسته است و برای گرفتنِ نمونه لازم نیست کسی
مستندات بخواند.
---
دو تضمین:
- **سوال‌ها:** فایلِ نامعتبر هیچ چیزی نمی‌نویسد — یا همه وارد می‌شوند یا هیچ‌کدام.
- **برنامه‌ی درسی:** چیزی پاک نمی‌شود. هر چه در فایل هست ساخته/به‌روز می‌شود و
هر چه نیست دست‌نخورده می‌ماند، پس یک فایلِ ناقص محتوای سرور را از بین نمی‌برد.
## وضعیت
### داده‌ی تستی روی سرور
`seed_test_data` فقط برای محیطِ لوکال است و **نباید** روی سروری که کاربرِ واقعی
دارد اجرا شود؛ ردیف‌هایی با پیشوندِ `[SEED]`/`[DEMO]` می‌سازد که در API دیده
می‌شوند. محتوای واقعی از مسیرِ جدولِ بالا می‌رود، نه از این دستور.
### متغیرهای محیطی موردنیاز
| متغیر | لازم در پروداکشن | توضیح |
|---|---|---|
| `DJANGO_DEBUG` | باید ست‌نشده یا `0` باشه | دیفالت‌های امنیتیِ پروداکشن رو فعال می‌کنه |
| `DJANGO_SECRET_KEY` | **بله** | بدونش وقتی `DEBUG=False` است اپ اصلاً بالا نمیاد |
| `DJANGO_ALLOWED_HOSTS` | **بله** | لیستِ host ها، کاما-جدا |
| `CACHE_URL` | **بله** | آدرسِ Redis، مثلاً `redis://host:6379/0` — برای rate limitِ مشترک وقتی `DEBUG=False` لازمه |
| `KAVENEGAR_API_KEY` | **بله** | ارسال واقعیِ OTP از طریق کاوه‌نگار |
| `GRAFANA_ADMIN_PASSWORD` | فقط اگه از استکِ Grafana استفاده می‌کنید | بخشِ Docker پایین رو ببینید |
### تنظیمات پروداکشن
- `DEBUG` باید ست **نشه** (یا `0` باشه). با `DEBUG=False`:
- `SECRET_KEY` اجباریه — هیچ fallback ناامنی وجود نداره.
- `CACHE_URL` (Redis) اجباریه — `LocMemCache` فقط برای dev هست.
- `DEV_MASTER_OTP` و فیلدِ `devOtp` توی پاسخ‌ها غیرفعال می‌شن.
- برای ترافیک واقعی به‌جای SQLیت پیش‌فرض، از یک دیتابیسِ پروداکشن‌گرید استفاده کنید
(پروژه با همون SQLite شیپ می‌شه؛ عوض‌کردنِ engine یک تصمیمِ زیرساختیه، خارج از این ریپو).
- از طریق یک سرورِ WSGI/ASGI (مثلاً gunicorn) جلوی اپ سرو کنید — تنظیماتِ Docker موجود
همچنان `runserver` اجرا می‌کنه و فقط برای dev/local مستند شده (پایین رو ببینید).
### تنظیمات Docker/دیپلوی
`docker-compose.yml` موجود، اپ رو **به‌همراه** یک استکِ لاگِ متمرکز
(Loki + Grafana Alloy + Grafana) اجرا می‌کنه. این یک تنظیمِ **dev/local** هست، نه دیپلویِ
پروداکشن (سرویسِ `django` همچنان `runserver` اجرا می‌کنه، نه یک سرورِ پروداکشن).
```bash
cp .env.example .env # اختیاری — بدونش compose از دیفالت‌های dev استفاده می‌کنه
docker compose up --build
```
| سرویس | آدرس | توضیح |
|---|---|---|
| اپ Django | `http://localhost:8000` | |
| Grafana | `http://localhost:3000` | دسترسیِ anonymous admin، فقط محیطِ محلی |
| Loki | `http://localhost:3100` | ذخیره‌سازیِ لاگ، در معرضِ کاربر نیست |
| Alloy | `http://localhost:12345` | رابطِ دیباگِ اختیاری |
### ترتیبِ لازمِ بالا آمدن
Compose خودش این رو مدیریت می‌کنه:
1. اول `loki` بالا میاد.
2. `alloy` و `grafana` بعد از `loki` بالا میان (`depends_on`).
3. `django` هیچ وابستگی‌ای به استکِ لاگ نداره — خودش `migrate` بعد `runserver` رو
اجرا می‌کنه و منتظرِ Loki/Alloy/Grafana نمی‌مونه.
### دستورهای manage.py داخلِ Docker
اگه فقط از طریقِ Docker اجرا می‌کنید (بدونِ venv محلی)، `python manage.py ...` رو مستقیم
روی سیستم خودتون اجرا نکنید — باید داخلِ کانتینرِ `django` اجرا بشه:
```bash
docker compose exec django python manage.py createsuperuser
docker compose exec django python manage.py seed_test_data
docker compose exec django python manage.py test
```
(`migrate` نیازی به این نیست — طبقِ بالا، خودکار قبل از `runserver` اجرا می‌شه.)
### مایگریشن‌ها
```bash
python manage.py migrate --noinput
```
در Docker، این به‌صورت خودکار به‌عنوان بخشی از دستورِ سرویسِ `django`، قبل از `runserver` اجرا می‌شه.
### راهنمای seed برای staging/تست
- هرگز `seed_test_data` رو روی یک دیتابیسِ پروداکشنِ واقعی اجرا نکنید.
- روی staging (`DEBUG=False`)، صریحاً با `--force` اجرا کنید:
```bash
python manage.py seed_test_data --force
```
- برای پاک‌کردن و ساختِ دوباره‌ی دیتای seed بین چرخه‌های تست، از `--reset` استفاده کنید.
همه‌ی رکوردهای seed‌شده در فیلدهای متنیِ قابل‌مشاهده با تگِ `[SEED]` مشخص شدن، برای
شناساییِ راحت‌تر و پاک‌سازی.
### چطور API دیپلوی‌شده رو verify کنیم
```bash
curl -i https://<host>/api/schema/ # باید 200 برگردونه (OpenAPI schema)
curl -i https://<host>/api/docs/ # باید 200 برگردونه (Swagger UI)
curl -i -X POST https://<host>/api/auth/session \
-H "Content-Type: application/json" \
-d '{"type":"guest","deviceId":"verify-1"}' # باید یک token برگردونه
```
### نکات مهمِ پروداکشن
- `DEV_MASTER_OTP` (کد `0000`) و فیلدِ `devOtp` توی پاسخ‌ها فقط وقتی `DEBUG=True` وجود
دارن — قبل از رفتن روی محیطِ واقعی مطمئن بشید `DEBUG` خاموشه.
- احراز هویت با Bearer token امضاشده و stateless (`django.core.signing`)، هماهنگ با مدل
`Session` — بدون کتابخانه‌ی JWT خارجی.
- `accounts.User` یک مدل بیزنسیِ مستقله، **نه** `AUTH_USER_MODEL` جنگو.
- همه‌ی لاگ‌های ساختاریافته‌ی درخواست (`method`/`path`/`status`/`duration`/`user`، بدونِ
بدنه/هدر/سکرت) روی stdout و — وقتی `LOG_DIR` ست باشه (مثلِ داخلِ Docker) — در
`/var/log/django/app.log` نوشته می‌شن؛ قابلِ مشاهده در Grafana از طریقِ استکِ بالا.
- `POST /api/ai/ask` با تنظیمِ `OPENAI_API_KEY`/`QDRANT_API_KEY` پاسخِ واقعیِ RAG می‌دهد
(اپ `rag`)؛ بدونِ کلید همان stub. جزئیات: بخشِ «دستیار هوش مصنوعی (RAG)» بالاتر.
- ارسالِ OTP از طریقِ کاوه‌نگار (`KAVENEGAR_API_KEY`) انجام می‌شه؛ شکستِ ارسال به‌جای
موفقیتِ ساختگی، خطای صریحِ `503` برمی‌گردونه.
- هر خطای پیش‌بینی‌نشده (نه فقط خطاهای شناخته‌شده‌ی API) همیشه پاسخِ JSON برمی‌گردونه،
نه صفحه‌ی HTML پیش‌فرضِ جنگو، و کامل با traceback لاگ می‌شه (`config/exceptions.py`).
سرویس زنده است و با حساب‌های آزمونگر سنجیده می‌شود؛ هنوز به روی کاربرِ عمومی باز نشده. آنچه پیش از آن باید بسته
شود در [`docs/security-before-production.md`](docs/security-before-production.md) فهرست شده است. پرداختِ واقعی (زرین‌پال
و کافه‌بازار) تا انتهای مسیر هنوز آزموده نشده است.

45
accounts/activity.py Normal file
View File

@@ -0,0 +1,45 @@
"""
«پرونده‌ی کاربر»: ورود، خروج و خریدهای یک کاربر به ترتیبِ زمان، برای ادمین.
فقط می‌خواند؛ چیزی جدا ذخیره نمی‌کند، پس با جدول‌های اصلی (LoginEvent، Order، StatusChangeLog) هرگز
ناهماهنگ نمی‌شود. تصمیمِ ادغام‌نکردنِ این‌ها در یک جدول: ستون‌هایشان فرق دارد.
آزموده نشده: کاربری با ده‌ها هزار ردیف (همه‌ی ردیف‌ها در حافظه می‌آیند).
"""
from django.db.models import Q
from django.utils import timezone
from payment.models import Order
from statuslog.models import StatusChangeLog
from .models import LoginEvent, User
_STATUS_FA = dict(Order.STATUS_CHOICES)
_EVENT_FA = dict(LoginEvent.EVENT_CHOICES)
COLUMNS = ['زمان', 'نوع', 'شرح', 'مبلغ', 'آی‌پی']
def user_activity(user: User) -> list[dict]:
rows = [{'at': user.created_at, 'kind': 'ثبت‌نام', 'detail': '', 'amount': '', 'ip': ''}]
# ورودِ ناموفق هنوز user_id ندارد (کاربر معلوم نیست)، پس با شماره هم می‌گیریم.
by_user = Q(user_id=user.id) | (Q(phone=user.phone) if user.phone else Q(pk__in=[]))
for e in LoginEvent.objects.filter(by_user):
rows.append({'at': e.created_at, 'kind': _EVENT_FA.get(e.event, e.event),
'detail': e.user_agent[:80], 'amount': '', 'ip': e.ip or ''})
orders = list(Order.objects.filter(user_id=user.id))
for o in orders:
rows.append({
'at': o.created_at, 'kind': 'سفارش',
'detail': f'#{o.id} {o.plan_title or "بدون پلن"} ({o.get_provider_display()})',
'amount': f'{o.amount} {o.currency}', 'ip': '',
})
for c in StatusChangeLog.objects.filter(label='payment.Order', object_id__in=[o.id for o in orders]):
rows.append({
'at': c.created_at, 'kind': 'تغییر وضعیت سفارش',
'detail': f'#{c.object_id}: {_STATUS_FA.get(c.old_status, c.old_status or "—")} ← '
f'{_STATUS_FA.get(c.new_status, c.new_status)} ({c.event})',
'amount': '', 'ip': '',
})
rows.sort(key=lambda r: r['at'])
for r in rows:
r['at_local'] = timezone.localtime(r['at']).strftime('%Y-%m-%d %H:%M:%S')
return rows

View File

@@ -8,7 +8,7 @@ from django.core.exceptions import PermissionDenied
from django.shortcuts import render
from django.utils import timezone
from .models import OTPCode, Profile, Session, User
from .models import LoginEvent, OTPCode, Profile, Session, User
from .services import SMSDeliveryError, get_inactive_users, send_sms
@@ -51,11 +51,74 @@ class SendSMSForm(forms.Form):
@admin.register(User)
class UserAdmin(admin.ModelAdmin):
list_display = ('id', 'name', 'phone', 'device_id', 'is_guest', 'grade_id', 'created_at')
search_fields = ('name', 'phone', 'device_id')
list_filter = ('is_guest',)
list_display = ('id', 'public_id', 'name', 'phone', 'grade_id', 'created_at', 'activity_link')
search_fields = ('name', 'phone')
@admin.display(description='پرونده')
def activity_link(self, obj):
from django.urls import reverse
from django.utils.html import format_html
return format_html('<a href="{}">فعالیت</a>', reverse('admin:accounts_user_activity', args=[obj.pk]))
def get_urls(self):
from django.urls import path
return [
path('<int:user_id>/activity/', self.admin_site.admin_view(self.activity_view),
name='accounts_user_activity'),
] + super().get_urls()
def activity_view(self, request, user_id):
"""ورود/خروج و خریدهای یک کاربر؛ ?format=csv برای اکسل. فقط با اجازه‌ی دیدنِ کاربر و ورود و سفارش."""
import csv
from django.http import Http404, HttpResponse
from django.shortcuts import render
from payment.models import Order
from .activity import COLUMNS, user_activity
from .public_id import encode
perms = ('accounts.view_user', 'accounts.view_loginevent', 'payment.view_order')
if not all(request.user.has_perm(p) for p in perms):
raise PermissionDenied
user = User.objects.filter(pk=user_id).first()
if user is None:
raise Http404
rows = user_activity(user)
if request.GET.get('format') == 'csv':
response = HttpResponse(content_type='text/csv; charset=utf-8')
response['Content-Disposition'] = f'attachment; filename="user-{user.pk}-activity.csv"'
response.write('\ufeff') # BOM: اکسل بی‌آن فارسی را خراب می‌خواند
writer = csv.writer(response)
writer.writerow(COLUMNS)
for r in rows:
writer.writerow([r['at_local'], r['kind'], r['detail'], r['amount'], r['ip']])
return response
return render(request, 'admin/accounts_user_activity.html', {
**self.admin_site.each_context(request), 'target': user, 'public_id': encode(user.pk),
'rows': rows, 'columns': COLUMNS, 'title': 'پرونده‌ی کاربر',
})
@admin.display(description='شناسه‌ی نمایشی')
def public_id(self, obj):
from .public_id import encode
return encode(obj.pk)
def get_search_results(self, request, queryset, search_term):
"""«User482917» (همان که کاربر در پروفایلش می‌بیند) هم کاربر را پیدا می‌کند."""
from .public_id import decode
results, may_have_duplicates = super().get_search_results(request, queryset, search_term)
user_id = decode(search_term)
if user_id:
results |= queryset.filter(pk=user_id)
return results, may_have_duplicates
actions = ['send_sms_to_selected']
def get_readonly_fields(self, request, obj=None):
# شماره کلیدِ ورود است: کارمندی با change_user که شماره‌ی کاربری را به شماره‌ی خودش عوض
# کند، کد می‌گیرد و واردِ همان حساب می‌شود؛ پس فقط ابرکاربر آن را عوض می‌کند.
fields = tuple(super().get_readonly_fields(request, obj))
return fields if request.user.is_superuser else fields + ('phone',)
# permissions=['change']: بدونِ این، کاربرِ فقط-view (گروهِ Supervisor)
# می‌تونست با POSTِ مستقیم این اکشن رو صدا بزنه بدونِ دیدنِ دکمه در UI؛
# این پارامتر باعث می‌شه ModelAdmin فقط برای change_user در دسترس بذارتش.
@@ -146,20 +209,34 @@ class UserAdmin(admin.ModelAdmin):
@admin.register(Profile)
class ProfileAdmin(admin.ModelAdmin):
list_display = ('id', 'user', 'birth_year', 'parent_phone')
list_display = ('id', 'user', 'birth_date', 'birth_year')
@admin.register(Session)
class SessionAdmin(admin.ModelAdmin):
list_display = ('id', 'user', 'device_id', 'expires_at', 'revoked')
list_display = ('id', 'user', 'expires_at', 'revoked')
list_filter = ('revoked',)
# ساختن/ویرایشِ دستیِ نشست یعنی ساختنِ ورود برای هر کاربر؛ فقط ابرکاربر.
def has_add_permission(self, request):
return request.user.is_superuser
def has_change_permission(self, request, obj=None):
return request.user.is_superuser
@admin.register(OTPCode)
class OTPCodeAdmin(admin.ModelAdmin):
list_display = ('id', 'phone', 'code', 'expires_at', 'is_used')
list_filter = ('is_used',)
# ردیفِ OTP که کارمند می‌سازد = ورود بی‌پیامک به هر شماره (راهِ تستر)؛ فقط ابرکاربر.
def has_add_permission(self, request):
return request.user.is_superuser
def has_change_permission(self, request, obj=None):
return request.user.is_superuser
# صفحه‌ی «ناظر» (Supervisor): یک ایندکسِ read-only از لینک‌ها به
# changelistهای موجود (کاربران/پرداخت‌ها/اشتراک‌ها)، داخلِ همون AdminSite
@@ -216,3 +293,55 @@ def _admin_get_urls_with_supervisor():
admin.site.get_urls = _admin_get_urls_with_supervisor
# حساب‌های کارمندانِ پنل (auth.User/Group جنگو، جدا از accounts.User). در پیش‌فرضِ جنگو کارمندی با
# change_group همه‌ی اجازه‌ها را به گروهِ خودش می‌دهد و با change_user (auth) تیکِ ابرکاربر را برای
# خودش می‌زند. ساختن/تغییر/حذفِ این دو فقط برای ابرکاربر است؛ دیدن همان اجازه‌ی view را می‌خواهد.
from django.contrib.auth import get_user_model # noqa: E402
from django.contrib.auth.admin import GroupAdmin as _DjangoGroupAdmin # noqa: E402
from django.contrib.auth.admin import UserAdmin as _DjangoUserAdmin # noqa: E402
from django.contrib.auth.models import Group # noqa: E402
class _SuperuserWritesOnly:
def has_add_permission(self, request):
return request.user.is_superuser
def has_change_permission(self, request, obj=None):
return request.user.is_superuser
def has_delete_permission(self, request, obj=None):
return request.user.is_superuser
class StaffUserAdmin(_SuperuserWritesOnly, _DjangoUserAdmin):
pass
class StaffGroupAdmin(_SuperuserWritesOnly, _DjangoGroupAdmin):
pass
admin.site.unregister(get_user_model())
admin.site.register(get_user_model(), StaffUserAdmin)
admin.site.unregister(Group)
admin.site.register(Group, StaffGroupAdmin)
@admin.register(LoginEvent)
class LoginEventAdmin(admin.ModelAdmin):
"""سابقه‌ی ورود/خروج؛ فقط‌خواندنی (سابقه نباید از پنل عوض شود)."""
list_display = ('created_at', 'event', 'user_id', 'phone', 'ip')
list_filter = ('event', 'created_at')
search_fields = ('phone', 'user_id', 'ip')
date_hierarchy = 'created_at'
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False

View File

@@ -71,6 +71,14 @@ def decode_access_token(token: str) -> int:
return int(value)
def _issued_at(token: str) -> int:
"""
ثانیه‌ی صدورِ توکن. قالبِ TimestampSigner «مقدار:زمان:امضا» است و زمان
base62؛ فقط بعد از unsignِ موفق صدا زده می‌شود، پس ساختار معتبر است.
"""
return signing.b62_decode(token.rsplit(':', 2)[-2])
class TadrisinoTokenAuthentication(BaseAuthentication):
"""
هدر مورد انتظار: Authorization: Bearer <access_token>
@@ -91,7 +99,10 @@ class TadrisinoTokenAuthentication(BaseAuthentication):
token = parts[1]
user_id = decode_access_token(token)
return (_get_request_user(user_id), token)
user = _get_request_user(user_id)
if user.tokens_valid_after is not None and _issued_at(token) < int(user.tokens_valid_after.timestamp()):
raise AuthenticationFailed('از حساب خارج شده‌اید؛ دوباره وارد شوید.')
return (user, token)
def authenticate_header(self, request):
return self.keyword

107
accounts/jalali.py Normal file
View File

@@ -0,0 +1,107 @@
"""
تبدیلِ تاریخِ شمسی ↔ میلادی برای تاریخِ تولد.
الگوریتمِ jalaali (همان jalaali-js، بورکوفسکی): کبیسه‌ها از جدولِ نقطه‌های شکستِ
چرخه حساب می‌شوند، نه از قاعده‌ی سی‌وسه‌ساله‌ی ساده، و برای سال‌های ۱۱۷۸ تا ۱۶۳۳
با تقویمِ رسمی یکی است. تست‌ها (`accounts/tests_birth_date.py`) چند تاریخِ
شناخته‌شده‌ی دو طرفِ اسفندِ کبیسه را می‌سنجند.
چرا در بک‌اند: فرانت فقط روز/ماه/سالِ شمسی می‌فرستد. اگر هر دو طرف تبدیل
می‌کردند، دو پیاده‌سازی می‌توانست یک روز با هم اختلاف داشته باشد.
"""
from datetime import date
_BREAKS = [-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210,
1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178]
def _cal(jy):
"""(leap, gy, march) — leap: سالِ مانده در چرخه؛ march: روزِ مارسِ نوروز."""
if jy < _BREAKS[0] or jy >= _BREAKS[-1]:
raise ValueError(f'سالِ شمسیِ خارج از بازه: {jy}')
gy = jy + 621
leap_j = -14
jp = _BREAKS[0]
jump = 0
for jm in _BREAKS[1:]:
jump = jm - jp
if jy < jm:
break
leap_j += (jump // 33) * 8 + (jump % 33) // 4
jp = jm
n = jy - jp
leap_j += (n // 33) * 8 + ((n % 33) + 3) // 4
if jump % 33 == 4 and jump - n == 4:
leap_j += 1
leap_g = gy // 4 - ((gy // 100 + 1) * 3) // 4 - 150
march = 20 + leap_j - leap_g
if jump - n < 6:
n = n - jump + ((jump + 4) // 33) * 33
leap = (((n + 1) % 33) - 1) % 4
if leap == -1:
leap = 4
return leap, gy, march
def is_leap(jy: int) -> bool:
return _cal(jy)[0] == 0
def month_length(jy: int, jm: int) -> int:
if jm <= 6:
return 31
if jm <= 11:
return 30
return 30 if is_leap(jy) else 29
def to_gregorian(jy: int, jm: int, jd: int) -> date:
"""ValueError برای تاریخِ ناموجود (مثلاً ۳۰ اسفندِ سالِ غیرکبیسه)."""
if not 1 <= jm <= 12 or not 1 <= jd <= month_length(jy, jm):
raise ValueError('چنین روزی در تقویمِ شمسی وجود ندارد.')
_, gy, march = _cal(jy)
nowruz = date(gy, 3, march)
day_of_year = (jm - 1) * 31 - (jm - 7) * (jm >= 7) + jd - 1
return date.fromordinal(nowruz.toordinal() + day_of_year)
def from_gregorian(d: date) -> tuple[int, int, int]:
jy = d.year - 621
_, gy, march = _cal(jy)
k = d.toordinal() - date(gy, 3, march).toordinal()
if k < 0:
jy -= 1
_, gy, march = _cal(jy)
k = d.toordinal() - date(gy, 3, march).toordinal()
if k < 186:
return jy, k // 31 + 1, k % 31 + 1
k -= 186
return jy, k // 30 + 7, k % 30 + 1
def parse(value: str) -> date:
"""«1385-04-12» (ارقامِ فارسی هم) → تاریخِ میلادی."""
from accounts.phone import ascii_digits
parts = ascii_digits(str(value)).strip().replace('/', '-').split('-')
# isascii: «²» برای isdigit رقم است ولی int() آن را نمی‌پذیرد (پیامِ خطای انگلیسیِ پایتون).
if len(parts) != 3 or not all(p.isascii() and p.isdigit() for p in parts):
raise ValueError('تاریخ باید به شکلِ سال-ماه-روز باشد، مثلاً 1385-04-12.')
return to_gregorian(int(parts[0]), int(parts[1]), int(parts[2]))
def to_string(d: date) -> str:
jy, jm, jd = from_gregorian(d)
return f'{jy:04d}-{jm:02d}-{jd:02d}'
def age_of(birth_date: date | None, birth_year: int | None, today: date | None = None) -> int | None:
"""سنِ کامل از تاریخِ تولد؛ برای کاربری که فقط سالِ شمسی داده، تقریبی (سال منهای سال)."""
from django.utils import timezone
today = today or timezone.localdate()
if birth_date:
return today.year - birth_date.year - ((today.month, today.day) < (birth_date.month, birth_date.day))
if birth_year:
return from_gregorian(today)[0] - birth_year
return None

View File

@@ -0,0 +1,139 @@
"""
پاک کردنِ کامل‌ِ چند کاربر و همه‌ی داده‌شان در همه‌ی اپ‌ها.
چرا لازم شد (۲۰۲۶-۱۰-۰۲): پیش از رسیدنِ کاربرِ واقعی، حساب‌هایی که تیم با شماره‌ی واقعیِ خودش
ساخته بود باید پاک شوند تا همان شماره‌ها دوباره مسیرِ «کاربرِ تازه» را ببینند. پاک کردنِ ردیفِ
`accounts.User` کافی نیست: اپ‌ها به کاربر فقط ارجاعِ سبک (`user_id`) دارند، نه FK، پس امتیاز،
گفتگو، سفارش و اعلانِ او یتیم می‌ماند و کاربرِ تازه‌ای که همان شناسه را بگیرد آن‌ها را می‌دید
(روی SQLite شناسه دوباره داده نمی‌شود، ولی روی Postgres پس از بازنشانیِ sequence ممکن است).
مدل‌ها خودکار پیدا می‌شوند: هر مدلی که ستونِ `user_id` یا `referred_user_id` دارد. پس اپِ تازه
بی‌ویرایشِ این فایل پوشش داده می‌شود. کدهای OTPِ همان شماره‌ها هم پاک می‌شوند تا سقفِ روزانه‌ی
پیامکِ آن شماره از صفر شروع شود.
پیش‌فرض فقط گزارش است و چیزی پاک نمی‌کند:
python manage.py delete_users --phones 09121234567 09351234567
python manage.py delete_users --ids 17 22 --yes
python manage.py delete_users --real-phones # هر کاربرِ شماره‌دار جز شماره‌های دمو
`--real-phones` یعنی هر کاربری که شماره دارد و شماره‌اش در TEST_LOGIN_PHONES (حساب‌های دمو با کدِ
ثابت) نیست. شماره‌ها از سرور بیرون نمی‌آیند؛ گزارش فقط چهار رقمِ اول و سه رقمِ آخر را نشان می‌دهد.
مهمان‌ها (بی‌شماره) با این گزینه انتخاب نمی‌شوند.
سفارشِ پرداخت‌شده سندِ مالی است؛ کاربری که سفارشِ «paid» دارد بدونِ --include-paid پاک نمی‌شود.
"""
from django.apps import apps
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from accounts.models import OTPCode, User
from accounts.phone import normalize_phone
USER_COLUMNS = ('user_id', 'referred_user_id')
def user_scoped_models():
"""
[(model, column)] برای هر مدلِ خودِ پروژه که به کاربرِ اپ ارجاع می‌دهد، جز خودِ User.
فقط اپ‌هایی که داخلِ پوشه‌ی پروژه‌اند. `admin.LogEntry` هم ستونِ user_id دارد ولی آن
کارمندِ ادمینِ جنگوست، نه کاربرِ اپ؛ بارِ اول که این دستور روی سرور گزارش گرفت، همان را
هم شمرده بود و اگر اجرا می‌شد تاریخچه‌ی ادمینِ شماره‌ی ۱ پاک می‌شد.
"""
from pathlib import Path
from django.conf import settings
base = Path(settings.BASE_DIR).resolve()
found = []
for model in apps.get_models():
if model is User:
continue
app_path = Path(model._meta.app_config.path).resolve()
if base not in app_path.parents or 'site-packages' in app_path.parts:
continue
names = {f.attname for f in model._meta.concrete_fields}
found += [(model, column) for column in USER_COLUMNS if column in names]
return found
def plan(user_ids):
"""{'app.Model.column': count} — آنچه پاک می‌شود، پیش از پاک شدن."""
counts = {}
for model, column in user_scoped_models():
n = model.objects.filter(**{f'{column}__in': user_ids}).count()
if n:
counts[f'{model._meta.label}.{column}'] = n
return counts
def delete_users(user_ids, keep=(), delete_otp_codes=True):
"""
keep: برچسبِ مدل‌هایی که نباید پاک شوند (مثلاً 'payment.Order' برای حذفِ حسابِ خودِ کاربر).
delete_otp_codes: این دستور (کارِ ادمین) کدهای شماره را پاک می‌کند تا آن شماره از صفر شروع کند؛
حذفِ حساب به دستِ خودِ کاربر این کار را نمی‌کند تا سقفِ پیامکِ شماره دور زده نشود.
"""
phones = list(User.objects.filter(pk__in=user_ids).exclude(phone=None).values_list('phone', flat=True))
with transaction.atomic():
for model, column in user_scoped_models():
if model._meta.label in keep:
continue
model.objects.filter(**{f'{column}__in': user_ids}).delete()
if delete_otp_codes:
OTPCode.objects.filter(phone__in=phones).delete()
User.objects.filter(pk__in=user_ids).delete()
class Command(BaseCommand):
help = 'کاربر و همه‌ی داده‌اش را در همه‌ی اپ‌ها پاک می‌کند (پیش‌فرض: فقط گزارش).'
def add_arguments(self, parser):
parser.add_argument('--phones', nargs='*', default=[])
parser.add_argument('--ids', nargs='*', type=int, default=[])
parser.add_argument('--real-phones', action='store_true',
help='همه‌ی کاربرهای شماره‌دار جز شماره‌های TEST_LOGIN_PHONES.')
parser.add_argument('--yes', action='store_true', help='واقعاً پاک کن.')
parser.add_argument('--include-paid', action='store_true',
help='کاربرِ دارای سفارشِ پرداخت‌شده را هم پاک کن.')
parser.add_argument('--skip-paid', action='store_true',
help='کاربرِ دارای سفارشِ پرداخت‌شده را کنار بگذار و بقیه را پاک کن.')
def handle(self, *args, **options):
phones = [normalize_phone(p) or p for p in options['phones']]
users = list(User.objects.filter(phone__in=phones)) + list(User.objects.filter(pk__in=options['ids']))
if options['real_phones']:
from django.conf import settings
users += list(User.objects.exclude(phone=None).exclude(phone='')
.exclude(phone__in=list(settings.TEST_LOGIN_PHONES)))
users = list({u.pk: u for u in users}.values())
missing = set(phones) - {u.phone for u in users}
if missing:
self.stdout.write(f'این شماره‌ها کاربری ندارند: {len(missing)}')
if not users:
raise CommandError('کاربری برای پاک کردن پیدا نشد.')
from payment.models import Order
paid = set(Order.objects.filter(user_id__in=[u.pk for u in users], status='paid')
.values_list('user_id', flat=True))
if paid and options['skip_paid']:
self.stdout.write(f'کنار گذاشته شدند (سفارشِ پرداخت‌شده دارند): {sorted(paid)}')
users = [u for u in users if u.pk not in paid]
if not users:
raise CommandError('بعد از کنار گذاشتنِ پرداخت‌کننده‌ها کسی نماند.')
elif paid and not options['include_paid']:
raise CommandError(f'کاربرهای {sorted(paid)} سفارشِ پرداخت‌شده دارند؛ با --include-paid پاک می‌شوند.')
ids = [u.pk for u in users]
for u in users:
phone = f'{u.phone[:4]}***{u.phone[-3:]}' if u.phone else 'مهمان'
self.stdout.write(f' کاربرِ {u.pk} {phone} ساخته‌شده {u.created_at:%Y-%m-%d}')
for label, n in sorted(plan(ids).items()):
self.stdout.write(f' {label}: {n}')
if not options['yes']:
self.stdout.write(self.style.WARNING('فقط گزارش بود؛ برای پاک کردن --yes بدهید.'))
return
delete_users(ids)
self.stdout.write(self.style.SUCCESS(f'{len(ids)} کاربر و همه‌ی داده‌شان پاک شدند.'))

View File

@@ -1,12 +1,12 @@
"""
یک دیتاست کوچک و واقعی برای تست دستی/API لوکال یا staging می‌سازه، طوری که
اجرای مکرر (repeat) هیچ رکورد تکراری‌ای نسازه — همه‌چیز با get_or_create روی
یک کلید طبیعی/یکتا (phone، device_id، code، idempotency_key و ...) انجام
یک کلید طبیعی/یکتا (phone، code، idempotency_key و ...) انجام
می‌شه، نه با create ساده.
هدف: پوشش کمینه‌ی جریان‌های اصلی API در هر ۱۱ اپ (accounts، content،
هدف: پوشش کمینه‌ی جریان‌های اصلی API در هر ۱۰ اپ (accounts، content،
progress، ai_tutor، subscription، payment، discount، notifications،
affiliate، support، family) با کمترین حجم داده‌ی لازم برای این‌که هر
affiliate، support) با کمترین حجم داده‌ی لازم برای این‌که هر
endpoint اصلی بدون نیاز به ساختن دستی پیش‌نیازهاش قابل تست باشه.
⚠️ این دستور فقط برای محیط لوکال/staging است، نه پروداکشن:
@@ -37,17 +37,14 @@ from django.db import transaction
from django.utils import timezone
from accounts.authentication import issue_access_token
from accounts.models import OTPCode, Profile, Session, User
from accounts.models import OTPCode, Profile, Session, User, hash_refresh_token
# --- شناسه‌های ثابتِ داده‌ی seed --------------------------------------------
# شماره‌های 0912000000X عمداً خارج از بازه‌ی واقعیِ اپراتورهای ایران نیستن؛
# چون این فقط دیتای لوکال/staging است نه پروداکشن، همین کافیه. با پیشوند
# یکسان و بازه‌ی اختصاصی، شناساییِ رکوردهای seed در دیتابیس هم ساده می‌مونه.
MAIN_PHONE = '09120000001'
PARENT_PHONE = '09120000002'
CHILD_PHONE = '09120000003'
REFERRED_PHONE = '09120000004'
GUEST_DEVICE_ID = 'seed-guest-device-0001'
SEED_TAG = '[SEED]'
@@ -91,7 +88,7 @@ class Command(BaseCommand):
self.stdout.write(f' - {label}: user_id={user.pk} phone={user.phone or "-"} token={token}')
self.stdout.write(
'\nنکته: این توکن‌ها با ACCESS_TOKEN_TTL_SECONDS فعلی امضا شدن و بعد از انقضا '
'باید دوباره seed یا از /api/auth/session یا /api/auth/login گرفته بشن.'
'باید دوباره seed یا از /api/auth/login گرفته بشن.'
)
# -- reset ----------------------------------------------------------
@@ -101,18 +98,14 @@ class Command(BaseCommand):
from affiliate.models import AffiliateAccount, Conversion
from content.models import Grade, NodeProgress, UserAnswer, UserLessonCompletion
from discount.models import DiscountCode, Offer, Redemption, UserOffer
from family.models import FamilyLink
from notifications.models import Notification
from payment.models import Order
from progress.models import LeaderboardEntry, StreakRecord, UserHearts, UserStats
from subscription.models import Plan, Subscription
from support.models import BugReport, Ticket
phones = [MAIN_PHONE, PARENT_PHONE, CHILD_PHONE, REFERRED_PHONE]
phones = [MAIN_PHONE, REFERRED_PHONE]
user_ids = list(User.objects.filter(phone__in=phones).values_list('pk', flat=True))
guest = User.objects.filter(device_id=GUEST_DEVICE_ID, is_guest=True).first()
if guest:
user_ids.append(guest.pk)
if user_ids:
UserAnswer.objects.filter(user_id__in=user_ids).delete()
@@ -132,7 +125,6 @@ class Command(BaseCommand):
Conversion.objects.filter(referred_user_id__in=user_ids).delete()
Ticket.objects.filter(user_id__in=user_ids).delete()
BugReport.objects.filter(user_id__in=user_ids).delete()
FamilyLink.objects.filter(parent_user_id__in=user_ids).delete()
from progress.models import Character
Character.objects.filter(slug__startswith='seed-').delete()
@@ -144,17 +136,13 @@ class Command(BaseCommand):
OTPCode.objects.filter(phone__in=phones).delete()
Session.objects.filter(user__phone__in=phones).delete()
if guest:
Session.objects.filter(user=guest).delete()
User.objects.filter(phone__in=phones).delete()
if guest:
guest.delete()
self.stdout.write('رکوردهای قبلیِ seed حذف شدن؛ در حال ساختِ دوباره...')
# -- seed -------------------------------------------------------------
def _seed(self):
main_user, guest_user, parent_user, child_user, referred_user = self._seed_accounts()
main_user, referred_user = self._seed_accounts()
self._seed_content_and_progress(main_user)
self._seed_characters()
self._seed_ai_tutor(main_user)
@@ -163,12 +151,9 @@ class Command(BaseCommand):
self._seed_notifications(main_user)
self._seed_affiliate(main_user, referred_user)
self._seed_support(main_user)
self._seed_family(parent_user, child_user)
tokens = [
('کاربر اصلی (ثبت‌نام‌شده)', main_user, issue_access_token(main_user.pk)),
('کاربر مهمان', guest_user, issue_access_token(guest_user.pk)),
('والد', parent_user, issue_access_token(parent_user.pk)),
('کاربر اصلی', main_user, issue_access_token(main_user.pk)),
]
return {'tokens': tokens}
@@ -176,48 +161,26 @@ class Command(BaseCommand):
def _seed_accounts(self):
main_user, _ = User.objects.get_or_create(
phone=MAIN_PHONE,
defaults=dict(name=f'{SEED_TAG} کاربر اصلی', is_guest=False, grade_id=None),
defaults=dict(name=f'{SEED_TAG} کاربر اصلی', grade_id=None),
)
Profile.objects.get_or_create(
user=main_user, defaults=dict(birth_year=2015, parent_phone=PARENT_PHONE, bio=f'{SEED_TAG} پروفایل تستی')
user=main_user, defaults=dict(birth_year=1394, bio=f'{SEED_TAG} پروفایل تستی')
)
Session.objects.get_or_create(
user=main_user,
refresh_token=f'seed-refresh-{MAIN_PHONE}',
refresh_token=hash_refresh_token(f'seed-refresh-{MAIN_PHONE}'),
defaults=dict(
device_id='seed-device-main',
expires_at=timezone.now() + timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
revoked=False,
),
)
guest_user, _ = User.objects.get_or_create(
device_id=GUEST_DEVICE_ID,
is_guest=True,
defaults=dict(name=f'{SEED_TAG} کاربر مهمان'),
)
Session.objects.get_or_create(
user=guest_user,
refresh_token=f'seed-refresh-{GUEST_DEVICE_ID}',
defaults=dict(
device_id=GUEST_DEVICE_ID,
expires_at=timezone.now() + timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
revoked=False,
),
)
parent_user, _ = User.objects.get_or_create(
phone=PARENT_PHONE, defaults=dict(name=f'{SEED_TAG} والد', is_guest=False)
)
child_user, _ = User.objects.get_or_create(
phone=CHILD_PHONE, defaults=dict(name=f'{SEED_TAG} فرزند', is_guest=False)
)
referred_user, _ = User.objects.get_or_create(
phone=REFERRED_PHONE, defaults=dict(name=f'{SEED_TAG} کاربر معرفی‌شده', is_guest=False)
phone=REFERRED_PHONE, defaults=dict(name=f'{SEED_TAG} کاربر معرفی‌شده')
)
self.stdout.write(self.style.SUCCESS('accounts: 5 کاربر (اصلی/مهمان/والد/فرزند/معرفی‌شده) آماده شد.'))
return main_user, guest_user, parent_user, child_user, referred_user
self.stdout.write(self.style.SUCCESS('accounts: ۲ کاربر (اصلی/معرفی‌شده) آماده شد.'))
return main_user, referred_user
# -- content + progress --------------------------------------------------
def _seed_content_and_progress(self, main_user):
@@ -469,28 +432,3 @@ class Command(BaseCommand):
)
self.stdout.write(self.style.SUCCESS('support: 1 تیکت با یک پیام، و 1 گزارش باگ آماده شد.'))
# -- family -----------------------------------------------------------------------
def _seed_family(self, parent_user, child_user):
from family.models import FamilyLink, ParentalSettings, WeeklyReportLog
link, _ = FamilyLink.objects.get_or_create(
parent_user_id=parent_user.pk,
child_phone=CHILD_PHONE,
defaults=dict(
child_user_id=child_user.pk,
invite_code='SEEDFAM01',
status='accepted',
accepted_at=timezone.now(),
),
)
ParentalSettings.objects.get_or_create(
family_link=link, defaults=dict(daily_limit_minutes=60)
)
WeeklyReportLog.objects.get_or_create(
family_link=link,
week_start=(timezone.now() - timedelta(days=timezone.now().weekday())).date(),
defaults=dict(sent_at=timezone.now()),
)
self.stdout.write(self.style.SUCCESS('family: 1 اتصالِ خانواده (پذیرفته‌شده) با تنظیمات و گزارشِ هفتگی آماده شد.'))

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.17 on 2026-09-25 02:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0003_user_last_activity_at'),
]
operations = [
migrations.AddField(
model_name='otpcode',
name='failed_attempts',
field=models.PositiveSmallIntegerField(default=0),
),
migrations.AddField(
model_name='user',
name='tokens_valid_after',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@@ -0,0 +1,40 @@
"""
شماره‌های موجود را به همان شکلِ 09XXXXXXXXX می‌برد که serializerها حالا می‌سازند
(accounts/phone.py)؛ وگرنه کسی که با ‎+98…‎ ثبت‌نام کرده بود دیگر به حسابِ خودش
نمی‌رسید و حسابِ تازه می‌گرفت.
ردیفی که شکلِ نرمالش از قبل مالِ کاربرِ دیگری است دست نمی‌خورد (unique است و
ادغامِ دو حساب تصمیمِ انسانی است)؛ ردیفی هم که موبایلِ ایران نیست همان‌طور می‌ماند.
هر دو فقط شمرده و چاپ می‌شوند. برگشت‌ناپذیر است چون شکلِ قبلی ذخیره نمی‌شود.
"""
from django.db import migrations
def normalize(apps, schema_editor):
from accounts.phone import normalize_phone
User = apps.get_model('accounts', 'User')
changed = skipped_taken = skipped_invalid = 0
for user in User.objects.exclude(phone__isnull=True).exclude(phone=''):
target = normalize_phone(user.phone)
if target is None:
skipped_invalid += 1
elif target != user.phone:
if User.objects.filter(phone=target).exists():
skipped_taken += 1
else:
User.objects.filter(pk=user.pk).update(phone=target)
changed += 1
if changed or skipped_taken or skipped_invalid:
print(f'\n normalize phones: changed={changed} taken={skipped_taken} invalid={skipped_invalid}')
class Migration(migrations.Migration):
dependencies = [
('accounts', '0004_otpcode_failed_attempts_user_tokens_valid_after'),
]
operations = [
migrations.RunPython(normalize, migrations.RunPython.noop),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.2.17 on 2026-10-01 21:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0005_normalize_user_phones'),
]
operations = [
migrations.RemoveField(
model_name='profile',
name='parent_phone',
),
migrations.AddField(
model_name='profile',
name='birth_date',
field=models.DateField(blank=True, null=True),
),
]

View File

@@ -0,0 +1,28 @@
"""
Session.refresh_token از این به بعد هشِ SHA-256 است (accounts/models.py، hash_refresh_token).
ردیف‌های موجود هش می‌شوند تا توکن‌هایی که کلاینت‌ها در دست دارند همچنان کار کنند. از هش نمی‌شود
خودِ توکن را ساخت؛ برگرداندنِ این migration هش‌ها را سرِ جایشان می‌گذارد و کدِ قبلی آن‌ها را
نمی‌شناسد، یعنی هر کاربر یک بار دوباره وارد می‌شود.
"""
import hashlib
from django.db import migrations
def hash_existing(apps, schema_editor):
Session = apps.get_model('accounts', 'Session')
# اول مادی کردن، بعد نوشتن: کرسرِ بازِ SQLite وسطِ حلقه‌ی نوشتن قفل می‌سازد (CLAUDE.md).
rows = list(Session.objects.values_list('id', 'refresh_token'))
for pk, raw in rows:
Session.objects.filter(pk=pk).update(refresh_token=hashlib.sha256(raw.encode()).hexdigest())
class Migration(migrations.Migration):
dependencies = [
('accounts', '0006_profile_birth_date_drop_parent_phone'),
]
operations = [
migrations.RunPython(hash_existing, migrations.RunPython.noop),
]

View File

@@ -0,0 +1,45 @@
# Generated by Django 5.2.17 on 2026-10-05 14:25
from django.db import migrations
def refuse_if_guests_remain(apps, schema_editor):
"""
ورودِ مهمان حذف شده؛ ستونِ is_guest که برود، حسابِ مهمان بی‌هیچ نشانه‌ای یک کاربرِ بی‌شماره
می‌شود. پس تا مهمانی مانده، migration عمداً می‌شکند: اول مهمان‌ها را پاک کنید
(manage.py delete_users)، بعد migrate.
"""
User = apps.get_model('accounts', 'User')
left = User.objects.filter(is_guest=True).count()
if left:
raise RuntimeError(
f'{left} حسابِ مهمان (is_guest=True) هنوز در دیتابیس هست؛ پیش از این migration با '
f'delete_users پاکشان کنید، وگرنه به کاربرِ بی‌شماره تبدیل می‌شوند.'
)
class Migration(migrations.Migration):
dependencies = [
('accounts', '0007_hash_refresh_tokens'),
]
operations = [
migrations.RunPython(refuse_if_guests_remain, migrations.RunPython.noop),
migrations.RemoveConstraint(
model_name='user',
name='uniq_guest_device_id',
),
migrations.RemoveField(
model_name='session',
name='device_id',
),
migrations.RemoveField(
model_name='user',
name='device_id',
),
migrations.RemoveField(
model_name='user',
name='is_guest',
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.2.17 on 2026-10-08 07:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0008_remove_guest_login'),
]
operations = [
migrations.CreateModel(
name='LoginEvent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('event', models.CharField(choices=[('login_ok', 'ورود موفق'), ('login_failed', 'ورود ناموفق'), ('login_blocked', 'ورود مسدود'), ('logout', 'خروج'), ('account_deleted', 'حذف حساب')], db_index=True, max_length=20)),
('user_id', models.IntegerField(blank=True, db_index=True, null=True)),
('phone', models.CharField(blank=True, db_index=True, max_length=15)),
('ip', models.GenericIPAddressField(blank=True, null=True)),
('user_agent', models.CharField(blank=True, max_length=300)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
],
options={
'ordering': ['-created_at'],
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.17 on 2026-10-11 16:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0009_loginevent'),
]
operations = [
migrations.AlterField(
model_name='otpcode',
name='expires_at',
field=models.DateTimeField(db_index=True),
),
]

View File

@@ -1,9 +1,11 @@
import hashlib
from django.db import models
class User(models.Model):
"""
کاربر اصلی سیستم (مهمان یا ثبت‌نام‌شده با شماره).
کاربر اصلی سیستم: ثبت‌نام‌شده با شماره‌ی موبایل (تنها راه ورود).
توجه: این مدل، AUTH_USER_MODEL جنگو نیست؛ یک مدل بیزنسی مستقله که
با TadrisinoTokenAuthentication سفارشی احراز هویت می‌شه.
@@ -11,30 +13,23 @@ class User(models.Model):
این اپ عمداً هیچ‌وقت FK واقعی به مدل اپ دیگه نمی‌زنه.
"""
phone = models.CharField(max_length=15, unique=True, null=True, blank=True, db_index=True)
device_id = models.CharField(max_length=64, null=True, blank=True, db_index=True)
name = models.CharField(max_length=100, blank=True)
avatar = models.CharField(max_length=100, blank=True)
is_guest = models.BooleanField(default=True)
grade_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Grade
created_at = models.DateTimeField(auto_now_add=True)
# آخرین زمانِ فعالیتِ کاربر (لاگین موفق، رفرش توکن موفق، یا سشنِ
# مهمانِ واقعی). فقط برای هدف‌گیریِ آینده‌ی کمپین‌های پیامی نگه‌داری
# آخرین زمانِ فعالیتِ کاربر (لاگین موفق یا رفرش توکن موفق). فقط برای هدف‌گیریِ آینده‌ی کمپین‌های پیامی نگه‌داری
# می‌شه؛ در حال حاضر هیچ منطقِ دیگه‌ای به این فیلد وابسته نیست.
last_activity_at = models.DateTimeField(null=True, blank=True)
# access tokenهای صادرشده پیش از این لحظه رد می‌شوند (خروج از حساب). توکنِ
# access فقط user_id امضاشده است و به Session وصل نیست؛ بدونِ این، خروج
# فقط refresh را باطل می‌کرد و توکنِ فعلی تا یک ساعت کار می‌کرد.
tokens_valid_after = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-created_at']
constraints = [
# جلوگیری از ساخت دو کاربر مهمان هم‌زمان با یک device_id یکسان.
models.UniqueConstraint(
fields=['device_id'],
condition=models.Q(is_guest=True) & ~models.Q(device_id=None) & ~models.Q(device_id=''),
name='uniq_guest_device_id',
),
]
def __str__(self):
return self.name or self.phone or f"guest-{self.pk}"
return self.name or self.phone or f"user-{self.pk}"
@property
def is_authenticated(self):
@@ -45,23 +40,32 @@ class User(models.Model):
class Profile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
# سالِ تولدِ شمسی. از ۲۰۲۶-۱۰-۰۱ از birth_date پر می‌شود؛ برای کاربرهای
# قدیمی که فقط سال داده‌اند تنها منبع است.
birth_year = models.PositiveSmallIntegerField(null=True, blank=True)
parent_phone = models.CharField(max_length=15, blank=True)
# تاریخِ تولدِ کامل (میلادی ذخیره می‌شود، در API شمسی است؛ accounts/jalali.py).
birth_date = models.DateField(null=True, blank=True)
bio = models.CharField(max_length=200, blank=True)
def __str__(self):
return f"profile-{self.user_id}"
def hash_refresh_token(raw: str) -> str:
"""آنچه در Session.refresh_token ذخیره و با آن جست‌وجو می‌شود؛ خودِ توکن فقط دستِ کلاینت است."""
return hashlib.sha256(raw.encode()).hexdigest()
class Session(models.Model):
"""
نگهداری refresh_token هر ورود. توکن دسترسی (access token) در دیتابیس
ذخیره نمی‌شه؛ به‌صورت stateless امضا (signed) می‌شه — دقیقاً طبق فیلدهای
همین مدل که فقط refresh_token داره، نه access token.
یک ورود. توکنِ دسترسی ذخیره نمی‌شود (امضاشده و stateless است، accounts/authentication.py).
`refresh_token` هشِ SHA-256ِ توکنِ تمدید است، نه خودش (hash_refresh_token): هر کس دیتابیس،
یک بکاپ یا پنلِ ادمین را ببیند با آن نمی‌تواند به‌جای کاربر وارد شود. توکن ۶۴ نویسه‌ی تصادفی
است، پس هشِ سریعِ بی‌نمک کافی است.
"""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sessions')
refresh_token = models.CharField(max_length=200, unique=True)
device_id = models.CharField(max_length=64, blank=True)
expires_at = models.DateTimeField()
revoked = models.BooleanField(default=False)
@@ -72,8 +76,46 @@ class Session(models.Model):
class OTPCode(models.Model):
phone = models.CharField(max_length=15, db_index=True)
code = models.CharField(max_length=6)
expires_at = models.DateTimeField()
# ایندکس: سقفِ سراسریِ پیامکِ روزانه در هر درخواستِ کد کدهای ۲۴ ساعتِ اخیر را می‌شمارد
# (accounts/views.py)؛ ردیف‌ها پاک نمی‌شوند، پس بی‌ایندکس این شمارش با هر ورود کندتر می‌شد.
expires_at = models.DateTimeField(db_index=True)
is_used = models.BooleanField(default=False)
# تلاش‌های غلط روی همین کد. در دیتابیس شمرده می‌شود، نه در کشِ LocMem که
# برای هر worker جداست؛ بعد از OTP_MAX_FAILED_ATTEMPTS کد باطل می‌شود.
failed_attempts = models.PositiveSmallIntegerField(default=0)
def __str__(self):
return f"otp-{self.phone}"
class LoginEvent(models.Model):
"""
سابقه‌ی ماندگارِ ورود و خروج، برای پرسش‌هایی مثلِ «این کاربر کی آمد، کی رفت».
چرا جدول و نه فقط لاگ: لاگِ stdout با ری‌استارت یا چرخشِ journald از بین می‌رود؛ این ردیف‌ها
در دیتابیس و بکاپ می‌مانند. `user_id` ارجاعِ سبک است (نه FK، مثلِ بقیه‌ی پروژه)، و ردیف با حذفِ
حساب نمی‌ماند مگر در KEPT_ON_ACCOUNT_DELETE. شماره‌ی کامل برای ادمین‌ها ذخیره می‌شود
(تصمیمِ مالک، ۲۰۲۶-۱۰-۰۸)؛ در سیاستِ حریمِ خصوصی باید بیاید.
"""
LOGIN_OK = 'login_ok'
LOGIN_FAILED = 'login_failed' # کدِ غلط یا منقضی
LOGIN_BLOCKED = 'login_blocked' # سقفِ تلاشِ شماره (429)
LOGOUT = 'logout'
ACCOUNT_DELETED = 'account_deleted'
EVENT_CHOICES = [
(LOGIN_OK, 'ورود موفق'), (LOGIN_FAILED, 'ورود ناموفق'), (LOGIN_BLOCKED, 'ورود مسدود'),
(LOGOUT, 'خروج'), (ACCOUNT_DELETED, 'حذف حساب'),
]
event = models.CharField(max_length=20, choices=EVENT_CHOICES, db_index=True)
user_id = models.IntegerField(null=True, blank=True, db_index=True)
phone = models.CharField(max_length=15, blank=True, db_index=True)
ip = models.GenericIPAddressField(null=True, blank=True)
user_agent = models.CharField(max_length=300, blank=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f"{self.event} user={self.user_id} {self.created_at:%Y-%m-%d %H:%M}"

43
accounts/phone.py Normal file
View File

@@ -0,0 +1,43 @@
"""
نرمال‌سازیِ شماره‌ی موبایلِ ایران به شکلِ یکتای ‎09XXXXXXXXX‎.
چرا: پیش از این `phone` رشته‌ی خام بود و ‎0912…‎، ‎+98912…‎، ‎98912…‎ و همان
شماره با ارقامِ فارسی چهار «شماره»ی جدا بودند — هر کدام cooldown و سقفِ پیامکِ
خودش را داشت و حسابِ جدا می‌ساخت (security-before-production.md، بندِ ۱۷).
هرچه شکلِ ‎09\\d{9}‎ نگیرد رد می‌شود، پس شماره‌ی خارجی یا خطِ ثابت هم به
کاوه‌نگار نمی‌رسد (پیامکِ بین‌المللی گران است و هدفِ SMS pumping).
عمداً بی‌وابستگی به DRF است تا config/settings.py هم بتواند واردش کند
(کلیدهای TEST_LOGIN_PHONES)؛ فیلدِ serializer در accounts/serializers.py است.
"""
import re
import unicodedata
_SEPARATORS = re.compile(r'[\s\-().\u200c\u200e\u200f]')
# [0-9] نه \d: در پایتون \d هر رقمِ یونیکد (تمام‌عرض، ریاضی، دوناگری…) را هم می‌گیرد.
_MOBILE = re.compile(r'09[0-9]{9}')
def ascii_digits(value: str) -> str:
"""
هر رقمِ دهدهیِ یونیکد (فارسی، عربی، تمام‌عرض «0»، ریاضی «𝟎»، …) → رقمِ لاتین.
بدونِ این «09123456789» شماره‌ی جدایی می‌شد با سقفِ پیامک و حسابِ جدا. «²» و «①» رقمِ
دهدهی نیستند و دست نمی‌خورند، پس شماره‌ای که آن‌ها را دارد رد می‌شود.
"""
return ''.join(str(unicodedata.decimal(ch)) if not ch.isascii() and ch.isdecimal() else ch
for ch in value)
def normalize_phone(value) -> str | None:
"""شکلِ یکتای ‎09XXXXXXXXX‎، یا None اگر موبایلِ ایران نباشد."""
s = _SEPARATORS.sub('', ascii_digits(str(value or '')))
if s.startswith('+98'):
s = '0' + s[3:]
elif s.startswith('0098'):
s = '0' + s[4:]
elif s.startswith('98') and len(s) == 12:
s = '0' + s[2:]
elif s.startswith('9') and len(s) == 10:
s = '0' + s
return s if _MOBILE.fullmatch(s) else None

32
accounts/public_id.py Normal file
View File

@@ -0,0 +1,32 @@
"""
شناسه‌ی نمایشیِ کاربر («User482917») به‌جای شماره‌ی ردیفِ دیتابیس.
شناسه‌ی خام («۱۰») هم کوچک و بی‌اعتبار دیده می‌شد، هم می‌گفت اپ چند کاربر دارد. این شکل
شش‌رقمی و ثابت است و از خودِ id ساخته می‌شود، پس ستونی در دیتابیس لازم ندارد:
code = 100000 + (id × 7919) mod 900000
۷۹۱۹ با ۹۰۰۰۰۰ هیچ مقسومِ علیهِ مشترکی ندارد، پس تا ۹۰۰ هزار کاربر هیچ دو کاربری یک کد
نمی‌گیرند و کد به id برمی‌گردد (`decode`)؛ پنلِ ادمین با همین کاربر را پیدا می‌کند. این رمز
نیست: هر کس فرمول را بداند id را درمی‌آورد. فقط برای نمایش و پشتیبانی است، نه دسترسی.
"""
import re
PREFIX = 'User'
_MOD = 900_000
_MUL = 7919
_INV = pow(_MUL, -1, _MOD)
_PATTERN = re.compile(rf'^\s*(?:{PREFIX})?\s*(\d{{6}})\s*$', re.IGNORECASE)
def encode(user_id: int) -> str:
return f'{PREFIX}{100_000 + (user_id * _MUL) % _MOD}'
def decode(code: str) -> int | None:
"""«User482917» یا «482917» → id؛ None اگر شکلش این نیست."""
match = _PATTERN.match(code or '')
if not match:
return None
user_id = ((int(match.group(1)) - 100_000) * _INV) % _MOD
return user_id or None

View File

@@ -15,7 +15,7 @@ class TadrisinoTokenAuthenticationScheme(OpenApiAuthenticationExtension):
'scheme': 'bearer',
'bearerFormat': 'access_token',
'description': (
'توکن دسترسی که از /api/auth/session یا /api/auth/login گرفته می‌شه. '
'توکن دسترسی که از /api/auth/login گرفته می‌شه. '
'در فیلد Value فقط خودِ توکن رو بذارید (بدون کلمه‌ی Bearer)؛ Swagger '
'خودش اضافه می‌کنه.'
),

View File

@@ -1,16 +1,71 @@
import re
import unicodedata
from rest_framework import serializers
from .phone import ascii_digits, normalize_phone
# لینک، آیدیِ شبکه‌ی اجتماعی، و هر رشته‌ی ۵ رقمی یا بیشتر با هر جداکننده‌ی غیرِ حرفی (فاصله،
# خط‌تیره، ستاره، ایموجی…). \d در پایتون هر رقمِ یونیکد است (فارسی، عربی، تمام‌عرض، ریاضی).
# این نام در رتبه‌بندی به بچه‌های دیگر نشان داده می‌شود. حرف بینِ رقم‌ها («0x9x1…») و عدد با
# حروف («صفر نه یک…») گرفته نمی‌شود.
_CONTACT = re.compile(
r'(https?://\S+|www\.\S+|\S+\.(?:com|ir|org|net|me)\S*|@\w+'
r'|(?:\d(?:[^\w@]|_)*){5,})',
re.IGNORECASE,
)
# «ig: name»، «insta name»، «آیدی name» و مانندش: کلیدواژه و هر چه بعدش آمده حذف می‌شود.
_HANDLE = re.compile(
r'(?:\b(?:ig|insta(?:gram)?|tele(?:gram)?|tg|id|whats ?app|rubika|eitaa|bale)\b'
r'|اینستا\S*|(?:تلگرام|تل|آیدی|ایدی|واتساپ|روبیکا|ایتا)(?!\w)|بله\s*:)\s*[::]?.*',
re.IGNORECASE,
)
def clean_display_name(value: str) -> str:
# NFKC: «①»، «²»، «𝟬» و «@» به رقم و @ِ معمولی برمی‌گردند تا الگوهای پایین آن‌ها را هم بگیرند؛
# نامِ ذخیره‌شده همین شکلِ نرمال است.
value = _HANDLE.sub(' ', unicodedata.normalize('NFKC', value or ''))
return ' '.join(_CONTACT.sub(' ', value).split())[:30]
class IranMobileField(serializers.CharField):
"""CharField که خروجی‌اش همیشه ‎09XXXXXXXXX‎ است."""
def __init__(self, **kwargs):
kwargs.setdefault('max_length', 20)
super().__init__(**kwargs)
def to_internal_value(self, data):
phone = normalize_phone(super().to_internal_value(data))
if phone is None:
raise serializers.ValidationError('شماره‌ی موبایل معتبر نیست؛ مثلاً 09121234567.')
return phone
# ---- Request serializers ----
class GuestSessionRequestSerializer(serializers.Serializer):
type = serializers.ChoiceField(choices=['guest'])
deviceId = serializers.CharField(max_length=64)
class OTPCodeField(serializers.CharField):
"""
کدِ ورود: ۴ تا ۶ رقم (کدِ پیامکی ۶ رقمی است، کدهای TEST_LOGIN_PHONES ۴ تا ۶). رقمِ فارسی و
دیگر رقم‌های یونیکد به لاتین برمی‌گردند؛ هر چیزِ دیگر ۴۰۰ است. بدونِ این، کدِ غیرِ ASCII برای
شماره‌ی تست به secrets.compare_digest می‌رسید و TypeError (۵۰۰) می‌داد.
"""
def __init__(self, **kwargs):
kwargs.setdefault('max_length', 6)
super().__init__(**kwargs)
def to_internal_value(self, data):
code = ascii_digits(super().to_internal_value(data))
if not re.fullmatch(r'[0-9]{4,6}', code):
raise serializers.ValidationError('کد باید ۴ تا ۶ رقم باشد.')
return code
class LoginRequestSerializer(serializers.Serializer):
phone = serializers.CharField(max_length=15)
otp = serializers.CharField(max_length=6)
phone = IranMobileField()
otp = OTPCodeField()
class RefreshRequestSerializer(serializers.Serializer):
@@ -20,17 +75,20 @@ class RefreshRequestSerializer(serializers.Serializer):
class SetGradeRequestSerializer(serializers.Serializer):
gradeId = serializers.IntegerField()
def validate_gradeId(self, value):
# پایه‌ی ناموجود ۴۰۰ است، نه ذخیره‌ی بی‌صدا (نقشه‌ی خالی). عددِ بزرگ‌تر از ستونِ SQLite هم
# پیش‌تر هنگامِ ذخیره OverflowError (۵۰۰) می‌داد؛ exists() آن را بی‌خطا «نیست» می‌بیند.
from content.models import Grade
def _jalali_year_now() -> int:
"""
سالِ جاریِ شمسی. تبدیلِ سن به سالِ تولد بدونِ این، سالِ میلادی می‌داد و
اعتبارسنجیِ birthYear (بازه‌ی ۱۳۰۰ تا ۱۵۰۰) ردش می‌کرد.
"""
from django.utils import timezone
if not Grade.objects.filter(pk=value).exists():
raise serializers.ValidationError('این پایه وجود ندارد.')
return value
today = timezone.localdate()
# فروردین ۱ برابرِ ۲۱ مارس است؛ پیش از آن هنوز سالِ شمسیِ قبلی است.
return today.year - 622 if (today.month, today.day) >= (3, 21) else today.year - 623
# همان بازه‌ای که پیش‌تر برای «سن» پذیرفته می‌شد. بزرگسال هم می‌آید (سینیور،
# ۲۰۲۶-۱۰-۰۱): کسی که فقط یک مبحثِ ریاضی را می‌خواهد یاد بگیرد.
MIN_AGE = 5
MAX_AGE = 99
class UpdateProfileRequestSerializer(serializers.Serializer):
@@ -38,49 +96,59 @@ class UpdateProfileRequestSerializer(serializers.Serializer):
ورودی PATCH /api/user/profile — همه‌ی فیلدها اختیاری‌اند و فقط همان‌هایی
که فرستاده شوند تغییر می‌کنند (partial update واقعی).
`phone` و `isGuest` عمداً اینجا نیستند: شماره فقط از مسیرِ OTP عوض
`phone` عمداً اینجا نیست: شماره فقط از مسیرِ OTP عوض
می‌شود و مهمان‌بودن را کاربر نباید بتواند خودش تغییر دهد.
"""
name = serializers.CharField(max_length=100, required=False, allow_blank=True)
avatar = serializers.CharField(max_length=100, required=False, allow_blank=True)
def validate_name(self, value):
"""
نام در جدولِ رتبه‌بندی به بچه‌های دیگر نشان داده می‌شود؛ شماره، لینک و
آیدی (راهِ تماس گرفتن با یک کودکِ غریبه) از آن حذف می‌شود. عمداً خطا
نمی‌دهد تا صفحه‌ی پروفایلِ اپ گیر نکند؛ فقط بقیه‌ی نام ذخیره می‌شود.
"""
return clean_display_name(value)
# فقط سال (روشِ قدیمِ اپ). birthDate دقیق‌تر است و ترجیح دارد.
birthYear = serializers.IntegerField(required=False, allow_null=True,
min_value=1300, max_value=1500)
parentPhone = serializers.CharField(max_length=15, required=False, allow_blank=True)
# تاریخِ تولدِ شمسی «1385-04-12». سرور میلادی ذخیره می‌کند و birthYear را هم
# از آن پر می‌کند؛ تبدیل فقط در accounts/jalali.py است.
birthDate = serializers.CharField(required=False, allow_null=True, max_length=10)
bio = serializers.CharField(max_length=200, required=False, allow_blank=True)
# صفحه‌ی ثبت‌نامِ فرانت سن می‌پرسد، نه سالِ تولد. ذخیره‌سازی همچنان
# birthYear است چون کهنه نمی‌شود؛ این فقط ورودی را ترجمه می‌کند تا فرانت
# مجبور نباشد خودش تبدیل کند و دو طرف حسابِ متفاوت درنیاورند.
age = serializers.IntegerField(required=False, allow_null=True,
min_value=5, max_value=99, write_only=True)
def validate_birthDate(self, value):
if value in (None, ''):
return None
from django.utils import timezone
from . import jalali
try:
born = jalali.parse(value)
except ValueError as exc:
raise serializers.ValidationError(str(exc))
today = timezone.localdate()
age = today.year - born.year - ((today.month, today.day) < (born.month, born.day))
if not MIN_AGE <= age <= MAX_AGE:
raise serializers.ValidationError(f'سن باید بینِ {MIN_AGE} و {MAX_AGE} سال باشد.')
return born
def validate(self, attrs):
if not attrs:
raise serializers.ValidationError('حداقل یکی از فیلدها باید ارسال شود.')
age = attrs.pop('age', None)
if age is not None:
if attrs.get('birthYear') is not None:
raise serializers.ValidationError(
'یکی از age یا birthYear را بفرستید، نه هر دو را.',
)
attrs['birthYear'] = _jalali_year_now() - age
if 'birthDate' in attrs and 'birthYear' in attrs:
raise serializers.ValidationError('یکی از birthDate یا birthYear را بفرستید، نه هر دو را.')
return attrs
class RequestOTPRequestSerializer(serializers.Serializer):
"""اپ کمکی برای گرفتن OTP (در سند اصلی نبود، برای تست‌پذیری Swagger اضافه شد)."""
phone = serializers.CharField(max_length=15)
phone = IranMobileField()
# ---- Response serializers ----
class GuestSessionResponseSerializer(serializers.Serializer):
userId = serializers.CharField()
token = serializers.CharField()
isNewUser = serializers.BooleanField()
isGuest = serializers.BooleanField()
class LoginResponseSerializer(serializers.Serializer):
userId = serializers.CharField()
token = serializers.CharField()
@@ -118,11 +186,30 @@ class RequestOTPResponseSerializer(serializers.Serializer):
class ProfileResponseSerializer(serializers.Serializer):
"""پاسخ GET /api/user/profile — فقط داده‌ی لازم برای تب Profile اپ موبایل."""
userId = serializers.CharField()
# شناسه‌ی نمایشی («User482917»، accounts/public_id.py)؛ اپ آن را زیرِ نام نشان می‌دهد
# و پشتیبانی با همین در پنلِ ادمین حساب را پیدا می‌کند.
publicId = serializers.CharField()
phone = serializers.CharField(allow_null=True)
name = serializers.CharField()
avatar = serializers.CharField()
isGuest = serializers.BooleanField()
gradeId = serializers.IntegerField(allow_null=True)
birthYear = serializers.IntegerField(allow_null=True)
parentPhone = serializers.CharField()
birthDate = serializers.CharField(allow_null=True, help_text='شمسی، «1385-04-12»؛ null اگر فقط سال داده شده.')
bio = serializers.CharField()
class DeleteAccountRequestSerializer(serializers.Serializer):
# کدِ پیامکیِ تازه (از POST /api/auth/otp/request با همان شماره).
otp = OTPCodeField()
class DeleteAccountResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()
class LogoutRequestSerializer(serializers.Serializer):
refreshToken = serializers.CharField(required=False, allow_blank=True)
class LogoutResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()

View File

@@ -42,6 +42,14 @@ class SMSDeliveryError(APIException):
default_code = 'sms_delivery_failed'
class SMSDeliveryUncertain(SMSDeliveryError):
"""
درخواست به کاوه‌نگار رسید ولی جوابش در مهلت نیامد؛ پیامک شاید رفته باشد.
۲۰۲۶-۰۹-۲۷ دیده شد: پاسخ ۵٫۴ ثانیه طول کشید، ما با مهلتِ ۵ ثانیه ۵۰۳ دادیم و
کد را پاک کردیم، ولی پیامک رسید — کاربر کدی در دست داشت که دیگر کار نمی‌کرد.
"""
class OTPDeliveryError(SMSDeliveryError):
"""
⚠️ اصلاح: قبلاً send_otp_sms یک استاب بود که فقط لاگ می‌کرد و همیشه
@@ -76,6 +84,36 @@ def send_sms(*, phone: str, message: str) -> None:
می‌زنه، و پاسخ رو اعتبارسنجی می‌کنه. کلیدِ API هیچ‌وقت داخل کد نیست؛
همیشه از env خونده می‌شه (settings.KAVENEGAR_API_KEY).
"""
payload = {'receptor': phone, 'message': message}
if settings.KAVENEGAR_SENDER:
payload['sender'] = settings.KAVENEGAR_SENDER
_kavenegar_post(phone=phone, method='sms/send.json', payload=payload)
def send_verify_lookup(*, phone: str, template: str, token: str) -> None:
"""
ارسال با الگوی تأییدشده‌ی کاوه‌نگار (verify/lookup) — متن را الگوی پنل می‌سازد
و ما فقط token را می‌فرستیم. کاوه‌نگارِ این حساب پیامکِ متنِ آزاد را از خطِ
پیش‌فرض لغو می‌کند (وضعیتِ ۱۳، ۲۰۲۶-۰۹-۲۵) و کدِ ورود را فقط از این راه می‌پذیرد.
token نباید فاصله داشته باشد (قاعده‌ی کاوه‌نگار)؛ کدِ OTP شش رقمِ پیوسته است.
"""
_kavenegar_post(
phone=phone, method='verify/lookup.json',
payload={'receptor': phone, 'template': template, 'token': token},
)
# کاوه‌نگار گاهی بیش از ۵ ثانیه طول می‌کشد (۵٫۴ ثانیه در ۲۰۲۶-۰۹-۲۷) و شبکه‌ی ایران
# کند می‌شود؛ صاحبِ پروژه ۳۰ ثانیه خواست. جمعِ دو مهلت (۴۰ ثانیه) باید زیرِ مهلتِ
# فرانت برای همین درخواست بماند (`requestOtp` در MathApp_frontend، ۴۵ ثانیه)، وگرنه
# کاربر «ارتباط برقرار نشد» می‌بیند در حالی که پیامک رسیده. gunicorn ۱۲۰ ثانیه و
# gthread است؛ مهلتِ nginx و ArvanCloud برای این مسیر سنجیده نشده.
SMS_CONNECT_TIMEOUT_SECONDS = 10
SMS_READ_TIMEOUT_SECONDS = 30
def _kavenegar_post(*, phone: str, method: str, payload: dict) -> None:
"""فراخوانیِ مشترکِ کاوه‌نگار و سنجشِ پاسخ؛ هر شکستی SMSDeliveryError است."""
if not settings.KAVENEGAR_API_KEY:
logger.error(
'SMS misconfigured: KAVENEGAR_API_KEY is not set (phone_suffix=%s)',
@@ -83,18 +121,23 @@ def send_sms(*, phone: str, message: str) -> None:
)
raise SMSDeliveryError('پیکربندیِ سرویسِ پیامک ناقص است.')
payload = {'receptor': phone, 'message': message}
if settings.KAVENEGAR_SENDER:
payload['sender'] = settings.KAVENEGAR_SENDER
try:
response = requests.post(
f'https://api.kavenegar.com/v1/{settings.KAVENEGAR_API_KEY}/sms/send.json',
f'https://api.kavenegar.com/v1/{settings.KAVENEGAR_API_KEY}/{method}',
data=payload,
timeout=5,
# (اتصال، خواندن). وصل‌نشدن یعنی قطعاً نرفت؛ دیر رسیدنِ جواب یعنی نامعلوم.
timeout=(SMS_CONNECT_TIMEOUT_SECONDS, SMS_READ_TIMEOUT_SECONDS),
)
except requests.RequestException:
logger.exception('SMS request to provider failed: phone_suffix=%s', _phone_suffix(phone))
except requests.ReadTimeout:
logger.warning(
'SMS provider reply timed out after the request was sent; delivery unknown: '
'phone_suffix=%s method=%s', _phone_suffix(phone), method,
)
raise SMSDeliveryUncertain('پاسخِ سرویسِ پیامک دیر رسید.')
except requests.RequestException as exc:
# فقط نوعِ خطا: متنِ خطای requests آدرسِ کامل را دارد و کلیدِ API بخشی از همان آدرس است.
logger.error('SMS request to provider failed: phone_suffix=%s method=%s error=%s',
_phone_suffix(phone), method, type(exc).__name__)
raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
if response.status_code != 200:
@@ -125,6 +168,16 @@ def send_sms(*, phone: str, message: str) -> None:
)
raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
# پذیرفته‌شدن ≠ رسیدن. شناسه و وضعیتِ پیام لاگ می‌شود تا اگر کسی گفت «کد
# نیامد»، همین شناسه در پنلِ کاوه‌نگار (گزارشِ ارسال) دنبال شود — مثلاً
# شماره‌ای که پیامکِ تبلیغاتی را مسدود کرده. متنِ پیام لاگ نمی‌شود.
entries = body.get('entries') if isinstance(body, dict) else None
first = entries[0] if isinstance(entries, list) and entries and isinstance(entries[0], dict) else {}
logger.info(
'SMS accepted by provider: phone_suffix=%s messageid=%s message_status=%s',
_phone_suffix(phone), first.get('messageid'), first.get('status'),
)
def send_otp_sms(*, phone: str, code: str) -> None:
"""
@@ -136,7 +189,8 @@ def send_otp_sms(*, phone: str, code: str) -> None:
واقعی‌ای نمی‌فرسته — چون در این محیط، RequestOTPView خودِ کد رو در
فیلدِ devOtp پاسخ برمی‌گردونه و SMS واقعی لازم نیست (رفتار قبلی dev
حفظ شده).
- DEBUG=False (production): send_sms صدا زده می‌شه؛ هر SMSDeliveryError
- DEBUG=False (production): اگر KAVENEGAR_OTP_TEMPLATE تنظیم باشد با الگو
(send_verify_lookup)، وگرنه متنِ آزادِ OTP_SMS_TEMPLATE با send_sms؛ هر SMSDeliveryError
به OTPDeliveryError نگاشت می‌شه تا رفتار/نوعِ استثنایی که
RequestOTPView انتظارش رو داره حفظ بمونه (کاربر پاسخ ۵۰۳ صریح
می‌گیره، نه یک success دروغین).
@@ -148,9 +202,16 @@ def send_otp_sms(*, phone: str, code: str) -> None:
)
return
message = settings.OTP_SMS_TEMPLATE.format(code=code)
try:
send_sms(phone=phone, message=message)
if settings.KAVENEGAR_OTP_TEMPLATE:
send_verify_lookup(phone=phone, template=settings.KAVENEGAR_OTP_TEMPLATE, token=code)
else:
send_sms(phone=phone, message=settings.OTP_SMS_TEMPLATE.format(code=code))
except SMSDeliveryUncertain:
# کد نگه داشته می‌شود و پاسخ موفق است: اگر پیامک رسیده، همان کد کار می‌کند؛
# اگر نرسیده، کاربر بعد از cooldown کدِ تازه می‌خواهد. برعکسش (۵۰۳ و پاک کردنِ
# کد) کاربری را که پیامک گرفته قفل می‌کرد. cooldown جلوی دو پیامک را می‌گیرد.
return
except SMSDeliveryError as exc:
raise OTPDeliveryError(str(exc)) from exc

View File

@@ -1,5 +1,5 @@
"""
سیگنال ثبت‌نامِ واقعی کاربر (نه guest session). سایر اپ‌ها (مثلاً discount
سیگنال ثبت‌نامِ واقعی کاربر. سایر اپ‌ها (مثلاً discount
برای اختصاص خودکار آفر) به‌جای import کردن مستقیم از accounts، فقط این
سیگنال رو در apps.py خودشون connect می‌کنن؛ این یعنی accounts هیچ‌وقت لازم
نیست بدونه discount/offer اصلاً وجود داره — دقیقاً همون درخواست سینیور برای

View File

@@ -13,7 +13,7 @@ from rest_framework.test import APIClient
from rest_framework.throttling import ScopedRateThrottle
from .authentication import issue_access_token
from .models import OTPCode, Profile, Session, User
from .models import OTPCode, Profile, Session, User, hash_refresh_token
from .services import OTPDeliveryError, SMSDeliveryError, send_otp_sms
@@ -27,14 +27,14 @@ class SendOtpSmsTests(TestCase):
@override_settings(DEBUG=True)
def test_dev_mode_does_not_call_provider(self):
with patch('accounts.services.requests.post') as mock_post:
send_otp_sms(phone='+989120000000', code='1234')
send_otp_sms(phone='09120000000', code='1234')
mock_post.assert_not_called()
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='')
def test_production_without_api_key_raises_instead_of_silent_success(self):
with patch('accounts.services.requests.post') as mock_post:
with self.assertRaises(OTPDeliveryError):
send_otp_sms(phone='+989120000000', code='1234')
send_otp_sms(phone='09120000000', code='1234')
mock_post.assert_not_called()
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key', KAVENEGAR_SENDER='')
@@ -49,11 +49,11 @@ class SendOtpSmsTests(TestCase):
'entries': [{'messageid': 1, 'status': 1}],
}
with patch('accounts.services.requests.post', return_value=mock_response) as mock_post:
send_otp_sms(phone='+989120000000', code='1234')
send_otp_sms(phone='09120000000', code='1234')
mock_post.assert_called_once()
args, kwargs = mock_post.call_args
self.assertIn('test-key', args[0])
self.assertEqual(kwargs['data']['receptor'], '+989120000000')
self.assertEqual(kwargs['data']['receptor'], '09120000000')
self.assertIn('1234', kwargs['data']['message'])
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key')
@@ -61,7 +61,7 @@ class SendOtpSmsTests(TestCase):
mock_response = Mock(status_code=400, text='bad request')
with patch('accounts.services.requests.post', return_value=mock_response):
with self.assertRaises(OTPDeliveryError):
send_otp_sms(phone='+989120000000', code='1234')
send_otp_sms(phone='09120000000', code='1234')
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key')
def test_production_http_200_with_application_level_rejection_raises(self):
@@ -77,7 +77,7 @@ class SendOtpSmsTests(TestCase):
}
with patch('accounts.services.requests.post', return_value=mock_response):
with self.assertRaises(OTPDeliveryError):
send_otp_sms(phone='+989120000000', code='1234')
send_otp_sms(phone='09120000000', code='1234')
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key')
def test_production_malformed_provider_body_raises(self):
@@ -87,20 +87,20 @@ class SendOtpSmsTests(TestCase):
mock_response.json.side_effect = ValueError('No JSON object could be decoded')
with patch('accounts.services.requests.post', return_value=mock_response):
with self.assertRaises(OTPDeliveryError):
send_otp_sms(phone='+989120000000', code='1234')
send_otp_sms(phone='09120000000', code='1234')
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key')
def test_production_network_failure_raises(self):
import requests as requests_module
with patch('accounts.services.requests.post', side_effect=requests_module.ConnectionError('boom')):
with self.assertRaises(OTPDeliveryError):
send_otp_sms(phone='+989120000000', code='1234')
send_otp_sms(phone='09120000000', code='1234')
@override_settings(DEBUG=True)
def test_dev_mode_log_never_contains_full_phone_only_safe_suffix(self):
# 🔒 اثبات فیکسِ این دور: لاگِ OTP نباید هیچ‌وقت شماره‌تلفنِ کامل رو
# نشون بده؛ فقط ۴ رقمِ آخر (شناسه‌ی امن) باید در پیامِ لاگ باشه.
phone = '+989120000000'
phone = '09120000000'
with self.assertLogs('accounts.services', level='INFO') as captured:
send_otp_sms(phone=phone, code='1234')
@@ -124,7 +124,7 @@ class SendOtpSmsTests(TestCase):
with patch('accounts.services.requests.post', return_value=mock_response):
with self.assertLogs('accounts.services', level='ERROR') as captured:
with self.assertRaises(OTPDeliveryError):
send_otp_sms(phone='+989120000000', code='1234')
send_otp_sms(phone='09120000000', code='1234')
message = captured.records[0].getMessage()
self.assertNotIn('bad request body secret-ish', message)
self.assertIn('phone_suffix=0000', message)
@@ -136,7 +136,7 @@ class SendOtpSmsTests(TestCase):
with patch('accounts.services.requests.post', return_value=mock_response):
with self.assertLogs('accounts.services', level='ERROR') as captured:
with self.assertRaises(OTPDeliveryError):
send_otp_sms(phone='+989120000000', code='1234')
send_otp_sms(phone='09120000000', code='1234')
message = captured.records[0].getMessage()
self.assertNotIn('not json body secret-ish', message)
self.assertIn('phone_suffix=0000', message)
@@ -154,14 +154,13 @@ class SendOtpSmsTests(TestCase):
with patch('accounts.services.requests.post', return_value=mock_response):
with self.assertLogs('accounts.services', level='ERROR') as captured:
with self.assertRaises(OTPDeliveryError):
send_otp_sms(phone='+989120000000', code='1234')
send_otp_sms(phone='09120000000', code='1234')
message = captured.records[0].getMessage()
self.assertNotIn('secret-ish detail', message)
self.assertIn('phone_suffix=0000', message)
self.assertIn('provider_status=401', message)
class RequestOTPViewTests(TestCase):
"""
تست‌های سطح API برای RequestOTPView: مطمئن می‌شیم رفتارِ dev قبلی حفظ
@@ -176,38 +175,37 @@ class RequestOTPViewTests(TestCase):
def test_dev_mode_returns_otp_in_response_without_provider_call(self):
with patch('accounts.views.send_otp_sms') as mock_send:
response = self.client.post(
'/api/auth/otp/request', {'phone': '+989120000001'}, format='json',
'/api/auth/otp/request', {'phone': '09120000001'}, format='json',
)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.data['success'])
self.assertTrue(response.data['devOtp'])
mock_send.assert_called_once()
self.assertTrue(OTPCode.objects.filter(phone='+989120000001').exists())
self.assertTrue(OTPCode.objects.filter(phone='09120000001').exists())
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='')
def test_production_delivery_failure_does_not_report_success(self):
response = self.client.post(
'/api/auth/otp/request', {'phone': '+989120000002'}, format='json',
'/api/auth/otp/request', {'phone': '09120000002'}, format='json',
)
self.assertEqual(response.status_code, 503)
# OTP row is created before delivery is attempted; only the SMS leg fails.
self.assertTrue(OTPCode.objects.filter(phone='+989120000002').exists())
# ردیفِ کدی که هرگز نرسید حذف می‌شود تا در cooldown و سقفِ روزانه‌ی
# دیتابیسی شمرده نشود (۲۰۲۶-۰۹-۲۵).
self.assertFalse(OTPCode.objects.filter(phone='09120000002').exists())
@override_settings(DEBUG=True)
def test_generated_otp_is_always_a_4_digit_string_with_leading_zeros(self):
# ⚠️ فیکسِ این تست: تولیدِ OTP از random.randint به secrets.randbelow
# تغییر کرده؛ این تست مطمئن می‌شه فرمتِ ۴ رقمی (با صفرهای ابتدایی
# حفظ‌شده، نه یک عددِ کوتاه‌تر) دست‌نخورده مونده.
def test_generated_otp_is_always_a_6_digit_string_with_leading_zeros(self):
# ۶ رقم از ۲۰۲۶-۰۹-۲۵ (با ۴ رقم حدس زدن عملی بود)؛ صفرهای ابتدایی باید بمانند.
with patch('accounts.views.secrets.randbelow', return_value=7):
with patch('accounts.views.send_otp_sms'):
response = self.client.post(
'/api/auth/otp/request', {'phone': '+989120000003'}, format='json',
'/api/auth/otp/request', {'phone': '09120000003'}, format='json',
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['devOtp'], '0007')
otp = OTPCode.objects.get(phone='+989120000003')
self.assertEqual(otp.code, '0007')
self.assertEqual(len(otp.code), 4)
self.assertEqual(response.data['devOtp'], '000007')
otp = OTPCode.objects.get(phone='09120000003')
self.assertEqual(otp.code, '000007')
self.assertEqual(len(otp.code), 6)
self.assertTrue(otp.code.isdigit())
@@ -244,21 +242,21 @@ class RequestOTPStaleInvalidationTests(TestCase):
# (RequestOTPPhoneCooldownTests).
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
def test_second_request_invalidates_first_otp(self):
phone = '+989121110001'
first_code = self._request_otp(phone, '1111')
phone = '09121110001'
first_code = self._request_otp(phone, '111111')
first_otp = OTPCode.objects.get(phone=phone, code=first_code)
self.assertFalse(first_otp.is_used)
self._request_otp(phone, '2222')
self._request_otp(phone, '222222')
first_otp.refresh_from_db()
self.assertTrue(first_otp.is_used)
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
def test_newest_otp_still_works_for_login(self):
phone = '+989121110002'
self._request_otp(phone, '1111')
second_code = self._request_otp(phone, '2222')
phone = '09121110002'
self._request_otp(phone, '111111')
second_code = self._request_otp(phone, '222222')
response = self.client.post(
'/api/auth/login', {'phone': phone, 'otp': second_code}, format='json',
@@ -267,9 +265,9 @@ class RequestOTPStaleInvalidationTests(TestCase):
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
def test_older_otp_is_rejected_after_new_request(self):
phone = '+989121110003'
first_code = self._request_otp(phone, '1111')
self._request_otp(phone, '2222')
phone = '09121110003'
first_code = self._request_otp(phone, '111111')
self._request_otp(phone, '222222')
response = self.client.post(
'/api/auth/login', {'phone': phone, 'otp': first_code}, format='json',
@@ -286,30 +284,30 @@ class RefreshTokenReplayTests(TestCase):
def setUp(self):
self.client = APIClient()
self.user = User.objects.create(is_guest=True, device_id='device-refresh-test')
self.user = User.objects.create(phone='09121230201')
self.raw = 'test-refresh-token-abc123'
self.session = Session.objects.create(
user=self.user,
refresh_token='test-refresh-token-abc123',
device_id='device-refresh-test',
refresh_token=hash_refresh_token(self.raw),
expires_at=timezone.now() + timezone.timedelta(days=30),
revoked=False,
)
def test_valid_refresh_token_succeeds(self):
response = self.client.post(
'/api/auth/refresh', {'refreshToken': self.session.refresh_token}, format='json',
'/api/auth/refresh', {'refreshToken': self.raw}, format='json',
)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.data['token'])
def test_same_refresh_token_rejected_on_second_use(self):
first_response = self.client.post(
'/api/auth/refresh', {'refreshToken': self.session.refresh_token}, format='json',
'/api/auth/refresh', {'refreshToken': self.raw}, format='json',
)
self.assertEqual(first_response.status_code, 200)
second_response = self.client.post(
'/api/auth/refresh', {'refreshToken': self.session.refresh_token}, format='json',
'/api/auth/refresh', {'refreshToken': self.raw}, format='json',
)
self.assertEqual(second_response.status_code, 401)
@@ -318,15 +316,15 @@ class RefreshTokenReplayTests(TestCase):
def test_successful_refresh_returns_new_refresh_token(self):
response = self.client.post(
'/api/auth/refresh', {'refreshToken': self.session.refresh_token}, format='json',
'/api/auth/refresh', {'refreshToken': self.raw}, format='json',
)
self.assertEqual(response.status_code, 200)
new_refresh_token = response.data['refreshToken']
self.assertTrue(new_refresh_token)
self.assertNotEqual(new_refresh_token, self.session.refresh_token)
self.assertNotEqual(new_refresh_token, self.raw)
self.assertTrue(
Session.objects.filter(
refresh_token=new_refresh_token, user=self.user, revoked=False,
refresh_token=hash_refresh_token(new_refresh_token), user=self.user, revoked=False,
).exists()
)
@@ -344,16 +342,16 @@ class LoginPhoneRateLimitTests(TestCase):
@override_settings(DEBUG=True, OTP_LOGIN_PHONE_MAX_ATTEMPTS=3)
def test_repeated_attempts_for_same_phone_are_blocked(self):
phone = '+989123334444'
phone = '09123334444'
for _ in range(3):
response = self.client.post(
'/api/auth/login', {'phone': phone, 'otp': 'wrong'}, format='json',
'/api/auth/login', {'phone': phone, 'otp': '000000'}, format='json',
)
self.assertEqual(response.status_code, 400)
blocked_response = self.client.post(
'/api/auth/login', {'phone': phone, 'otp': 'wrong'}, format='json',
'/api/auth/login', {'phone': phone, 'otp': '000000'}, format='json',
)
self.assertEqual(blocked_response.status_code, 429)
@@ -376,7 +374,7 @@ class RequestOTPPhoneRateLimitTests(TestCase):
# (RequestOTPPhoneCooldownTests).
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_MAX_ATTEMPTS=3, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
def test_repeated_requests_for_same_phone_are_eventually_blocked(self):
phone = '+989127778888'
phone = '09127778888'
# otp_request (IP-محور) پیش‌فرض 5/hour است؛ برای اینکه فقط
# محدودیتِ شماره‌تلفن رو بسنجیم، throttle IP-محور رو موقتاً باز
@@ -396,7 +394,7 @@ class RequestOTPPhoneRateLimitTests(TestCase):
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_MAX_ATTEMPTS=3, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
def test_changing_client_ip_does_not_bypass_the_phone_limit(self):
phone = '+989127778889'
phone = '09127778889'
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'otp_request': '1000/hour'}):
with patch('accounts.views.send_otp_sms'):
@@ -418,7 +416,7 @@ class RequestOTPPhoneRateLimitTests(TestCase):
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_MAX_ATTEMPTS=1, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
def test_throttled_request_creates_no_new_otp_and_sends_no_sms(self):
phone = '+989127778890'
phone = '09127778890'
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'otp_request': '1000/hour'}):
with patch('accounts.views.send_otp_sms') as mock_send:
@@ -460,7 +458,7 @@ class RequestOTPPhoneRateLimitConcurrencyTests(TransactionTestCase):
def test_two_simultaneous_requests_do_not_both_pass_the_phone_limit(self):
import threading
phone = '+989127778891'
phone = '09127778891'
barrier = threading.Barrier(2)
results = []
results_lock = threading.Lock()
@@ -504,7 +502,7 @@ class RequestOTPPhoneCooldownTests(TestCase):
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=60)
def test_immediate_resend_for_same_phone_is_blocked_by_cooldown(self):
phone = '+989127779001'
phone = '09127779001'
with patch('accounts.views.send_otp_sms'):
first_response = self.client.post(
@@ -523,7 +521,7 @@ class RequestOTPPhoneCooldownTests(TestCase):
# موفق بشه، بقیه باید با cooldown (429) بلاک بشن — و چون
# درخواست‌هایِ بلاک‌شده توسطِ cooldown هیچ‌وقت به شمارنده‌ی ساعتی
# نمی‌رسن، سهمیه‌ی ساعتی مصرف نمی‌شه.
phone = '+989127779002'
phone = '09127779002'
# throttle IP-محورِ otp_request (پیش‌فرض 5/hour) رو موقتاً باز
# می‌کنیم تا اینجا فقط cooldown/سهمیه‌ی ساعتیِ per-phone سنجیده بشه،
@@ -543,7 +541,11 @@ class RequestOTPPhoneCooldownTests(TestCase):
# سهمیه‌ی ساعتی هنوز دست‌نخورده است: بعد از پایانِ cooldown،
# همچنان می‌شه تا سقفِ مجاز درخواست داد.
# گذشتِ زمان: هم کلیدِ کش و هم cooldownِ دیتابیسی (از expires_at).
cache.delete(f'otp_request_phone_cooldown:{phone}')
OTPCode.objects.filter(phone=phone).update(
expires_at=timezone.now() + timezone.timedelta(seconds=settings.OTP_TTL_SECONDS - 61),
)
with patch('accounts.views.send_otp_sms') as mock_send_again:
allowed_again = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
@@ -553,7 +555,7 @@ class RequestOTPPhoneCooldownTests(TestCase):
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=60)
def test_cooldown_blocked_request_creates_no_otp_and_sends_no_sms(self):
phone = '+989127779003'
phone = '09127779003'
with patch('accounts.views.send_otp_sms') as mock_send:
self.client.post('/api/auth/otp/request', {'phone': phone}, format='json')
@@ -571,7 +573,7 @@ class RequestOTPPhoneCooldownTests(TestCase):
def test_cooldown_expires_after_configured_window(self):
import time
phone = '+989127779004'
phone = '09127779004'
with patch('accounts.views.send_otp_sms'):
first_response = self.client.post(
@@ -590,10 +592,10 @@ class RequestOTPPhoneCooldownTests(TestCase):
def test_cooldown_is_per_phone_not_global(self):
with patch('accounts.views.send_otp_sms'):
first_response = self.client.post(
'/api/auth/otp/request', {'phone': '+989127779005'}, format='json',
'/api/auth/otp/request', {'phone': '09127779005'}, format='json',
)
second_response = self.client.post(
'/api/auth/otp/request', {'phone': '+989127779006'}, format='json',
'/api/auth/otp/request', {'phone': '09127779006'}, format='json',
)
self.assertEqual(first_response.status_code, 200)
self.assertEqual(second_response.status_code, 200)
@@ -606,7 +608,7 @@ class RequestOTPPhoneCooldownTests(TestCase):
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=120)
def test_cooldown_response_reports_retry_after_header_and_body(self):
phone = '+989127779008'
phone = '09127779008'
with patch('accounts.views.send_otp_sms'):
first_response = self.client.post(
@@ -635,7 +637,7 @@ class RequestOTPPhoneCooldownTests(TestCase):
def test_retry_after_counts_down_as_time_passes(self):
import time
phone = '+989127779009'
phone = '09127779009'
with patch('accounts.views.send_otp_sms'):
self.client.post('/api/auth/otp/request', {'phone': phone}, format='json')
@@ -675,7 +677,7 @@ class RequestOTPPhoneCooldownConcurrencyTests(TransactionTestCase):
def test_two_simultaneous_requests_for_same_phone_only_one_passes_cooldown(self):
import threading
phone = '+989127779007'
phone = '09127779007'
barrier = threading.Barrier(2)
results = []
results_lock = threading.Lock()
@@ -708,7 +710,7 @@ class RequestOTPPhoneCooldownConcurrencyTests(TransactionTestCase):
# هیچ کدی دریافت نکرده بود ولی همچنان تا ۱۲۰ ثانیه بلاک می‌موند.
# این تست تضمین می‌کنه که با شکستِ ارسال، cooldown پاک می‌شه و
# کاربر می‌تونه فوراً دوباره تلاش کنه.
phone = '+989127779010'
phone = '09127779010'
with patch('accounts.views.send_otp_sms', side_effect=OTPDeliveryError('boom')):
first_response = self.client.post(
@@ -729,7 +731,7 @@ class RequestOTPPhoneCooldownConcurrencyTests(TransactionTestCase):
def test_cooldown_still_active_after_successful_delivery(self):
# اثباتِ عدمِ رگرسیون: وقتی ارسال موفق باشه، cooldown همچنان طبقِ
# رفتارِ قبلی فعال می‌مونه (فقط شکستِ تحویل باید cooldown رو پاک کنه).
phone = '+989127779011'
phone = '09127779011'
with patch('accounts.views.send_otp_sms'):
first_response = self.client.post(
@@ -755,23 +757,25 @@ class RefreshTokenThrottleTests(TestCase):
def setUp(self):
cache.clear()
self.client = APIClient()
self.user = User.objects.create(is_guest=True, device_id='device-refresh-throttle-test')
self.user = User.objects.create(phone='09121230202')
def _create_session(self, token_suffix):
return Session.objects.create(
raw = f'test-refresh-token-{token_suffix}'
session = Session.objects.create(
user=self.user,
refresh_token=f'test-refresh-token-{token_suffix}',
device_id='device-refresh-throttle-test',
refresh_token=hash_refresh_token(raw),
expires_at=timezone.now() + timezone.timedelta(days=30),
revoked=False,
)
session.raw = raw
return session
def test_requests_within_the_limit_still_succeed(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'token_refresh': '3/hour'}):
for i in range(3):
session = self._create_session(i)
response = self.client.post(
'/api/auth/refresh', {'refreshToken': session.refresh_token}, format='json',
'/api/auth/refresh', {'refreshToken': session.raw}, format='json',
)
self.assertEqual(response.status_code, 200)
@@ -780,13 +784,13 @@ class RefreshTokenThrottleTests(TestCase):
for i in range(3):
session = self._create_session(i)
response = self.client.post(
'/api/auth/refresh', {'refreshToken': session.refresh_token}, format='json',
'/api/auth/refresh', {'refreshToken': session.raw}, format='json',
)
self.assertEqual(response.status_code, 200)
extra_session = self._create_session('extra')
blocked_response = self.client.post(
'/api/auth/refresh', {'refreshToken': extra_session.refresh_token}, format='json',
'/api/auth/refresh', {'refreshToken': extra_session.raw}, format='json',
)
self.assertEqual(blocked_response.status_code, 429)
@@ -794,7 +798,7 @@ class RefreshTokenThrottleTests(TestCase):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'token_refresh': '1/hour'}):
first_session = self._create_session('first')
first_response = self.client.post(
'/api/auth/refresh', {'refreshToken': first_session.refresh_token}, format='json',
'/api/auth/refresh', {'refreshToken': first_session.raw}, format='json',
)
self.assertEqual(first_response.status_code, 200)
@@ -803,7 +807,7 @@ class RefreshTokenThrottleTests(TestCase):
with patch('accounts.views.issue_access_token') as mock_issue_token:
blocked_response = self.client.post(
'/api/auth/refresh', {'refreshToken': blocked_session.refresh_token}, format='json',
'/api/auth/refresh', {'refreshToken': blocked_session.raw}, format='json',
)
self.assertEqual(blocked_response.status_code, 429)
mock_issue_token.assert_not_called()
@@ -816,51 +820,6 @@ class RefreshTokenThrottleTests(TestCase):
self.assertFalse(blocked_session.revoked)
class GuestSessionThrottleTests(TestCase):
"""
🔒 اثبات محدودیتِ جدیدِ ScopedRateThrottle (scope='guest_session') روی
GuestSessionView: درخواست‌های تکراری از همون کلاینت (IP) باید بعد از
حدِ مجاز با ۴۲۹ رد بشن.
"""
def setUp(self):
self.client = APIClient()
cache.clear()
@override_settings(
REST_FRAMEWORK={
**settings.REST_FRAMEWORK,
'DEFAULT_THROTTLE_RATES': {
**settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'],
'guest_session': '3/hour',
},
}
)
def test_repeated_guest_session_requests_are_eventually_throttled(self):
# override_settings(REST_FRAMEWORK=...) به‌تنهایی رفتارِ
# ScopedRateThrottle رو تغییر نمی‌ده: DRF مقدارِ THROTTLE_RATES رو
# یک‌بار موقعِ import، به‌عنوانِ class attribute، از دیکشنریِ اصلیِ
# settings.py می‌گیره؛ override_settings دیکشنریِ settings رو عوض
# می‌کنه ولی این class attribute هنوز به نسخه‌ی قدیمی اشاره می‌کنه.
# برای سنجیدنِ نرخِ واقعی، مستقیماً patch.dict روی همون دیکشنریِ
# زنده‌ی ScopedRateThrottle.THROTTLE_RATES لازمه.
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'guest_session': '3/hour'}):
for i in range(3):
response = self.client.post(
'/api/auth/session',
{'type': 'guest', 'deviceId': f'device-{i}'},
format='json',
)
self.assertEqual(response.status_code, 200)
blocked_response = self.client.post(
'/api/auth/session',
{'type': 'guest', 'deviceId': 'device-blocked'},
format='json',
)
self.assertEqual(blocked_response.status_code, 429)
class UserProfileViewTests(TestCase):
"""تست‌های GET /api/user/profile — پروفایل کاربر جاری."""
@@ -877,8 +836,8 @@ class UserProfileViewTests(TestCase):
response = self._get_profile()
self.assertEqual(response.status_code, 401)
def test_returns_profile_for_guest_without_existing_profile_row(self):
user = User.objects.create(device_id='device-profile-1', is_guest=True, name='')
def test_returns_profile_for_user_without_existing_profile_row(self):
user = User.objects.create(phone='09000000055', name='')
token = issue_access_token(user.id)
response = self._get_profile(token)
@@ -886,39 +845,36 @@ class UserProfileViewTests(TestCase):
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data['userId'], str(user.id))
self.assertEqual(data['isGuest'], True)
self.assertIsNone(data['phone'])
self.assertEqual(data['phone'], '09000000055')
self.assertIsNone(data['gradeId'])
self.assertIsNone(data['birthYear'])
self.assertEqual(data['parentPhone'], '')
self.assertIsNone(data['birthDate'])
self.assertEqual(data['bio'], '')
# get_or_create باید یک رکورد Profile واقعی ساخته باشه.
self.assertTrue(Profile.objects.filter(user_id=user.id).exists())
def test_returns_existing_profile_fields_for_registered_user(self):
user = User.objects.create(
phone='+989120000001', is_guest=False, name='علی', avatar='avatar.png', grade_id=2,
phone='09120000001', name='علی', avatar='avatar.png', grade_id=2,
)
Profile.objects.create(user=user, birth_year=2005, parent_phone='+989120000002', bio='سلام')
Profile.objects.create(user=user, birth_year=1384, bio='سلام')
token = issue_access_token(user.id)
response = self._get_profile(token)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data['phone'], '+989120000001')
self.assertEqual(data['phone'], '09120000001')
self.assertEqual(data['name'], 'علی')
self.assertEqual(data['avatar'], 'avatar.png')
self.assertEqual(data['isGuest'], False)
self.assertEqual(data['gradeId'], 2)
self.assertEqual(data['birthYear'], 2005)
self.assertEqual(data['parentPhone'], '+989120000002')
self.assertEqual(data['birthYear'], 1384)
self.assertEqual(data['bio'], 'سلام')
def test_does_not_leak_other_users_profile(self):
other = User.objects.create(device_id='device-other', is_guest=True, name='')
other = User.objects.create(phone='09000000056', name='')
Profile.objects.create(user=other, bio='محرمانه')
user = User.objects.create(device_id='device-profile-2', is_guest=True, name='')
user = User.objects.create(phone='09000000057', name='')
token = issue_access_token(user.id)
response = self._get_profile(token)
@@ -948,13 +904,13 @@ class SendSmsAdminActionTests(TestCase):
# inactive/never-active موردنظرِ خودشون رو صریحاً می‌سازن.
now = timezone.now()
self.user1 = User.objects.create(
name='u1', is_guest=False, phone='0910000001', last_activity_at=now,
name='u1', phone='0910000001', last_activity_at=now,
)
self.user2 = User.objects.create(
name='u2', is_guest=False, phone='0910000002', last_activity_at=now,
name='u2', phone='0910000002', last_activity_at=now,
)
self.guest_no_phone = User.objects.create(
name='guest', is_guest=True, phone=None, last_activity_at=now,
self.no_phone_user = User.objects.create(
name='no-phone', phone=None, last_activity_at=now,
)
self.changelist_url = reverse('admin:accounts_user_changelist')
@@ -979,10 +935,10 @@ class SendSmsAdminActionTests(TestCase):
mock_send_sms.assert_not_called()
@patch('accounts.admin.send_sms')
def test_apply_sends_sms_to_users_with_phone_and_skips_guest(self, mock_send_sms):
def test_apply_sends_sms_to_users_with_phone_and_skips_users_without(self, mock_send_sms):
response = self.client.post(self.changelist_url, {
'action': 'send_sms_to_selected',
'_selected_action': [self.user1.pk, self.user2.pk, self.guest_no_phone.pk],
'_selected_action': [self.user1.pk, self.user2.pk, self.no_phone_user.pk],
'apply': 'ارسال پیامک',
'message': 'سلام از طرف ادمین',
})
@@ -1046,14 +1002,14 @@ class SendSmsAdminActionTests(TestCase):
"""
now = timezone.now()
active_user = User.objects.create(
name='active', is_guest=False, phone='0910000009', last_activity_at=now,
name='active', phone='0910000009', last_activity_at=now,
)
inactive_user = User.objects.create(
name='inactive', is_guest=False, phone='0910000010',
name='inactive', phone='0910000010',
last_activity_at=now - timezone.timedelta(days=90),
)
never_active_user = User.objects.create(
name='never-active', is_guest=False, phone='0910000011', last_activity_at=None,
name='never-active', phone='0910000011', last_activity_at=None,
)
cutoff_date = (now - timezone.timedelta(days=30)).date()
@@ -1101,7 +1057,7 @@ class SendSmsAdminActionTests(TestCase):
'inactive' هم مثلِ 'selected' حفظ بشه.
"""
User.objects.create(
name='inactive-no-phone', is_guest=True, phone=None,
name='inactive-no-phone', phone=None,
last_activity_at=timezone.now() - timezone.timedelta(days=90),
)
cutoff_date = (timezone.now() - timezone.timedelta(days=30)).date()
@@ -1131,7 +1087,7 @@ class SendSmsAdminActionTests(TestCase):
"""
response = self.client.post(self.changelist_url, {
'action': 'send_sms_to_selected',
'_selected_action': [self.user1.pk, self.user2.pk, self.guest_no_phone.pk],
'_selected_action': [self.user1.pk, self.user2.pk, self.no_phone_user.pk],
'apply': 'ارسال پیامک',
'message': 'متن',
'target': 'selected',
@@ -1153,11 +1109,11 @@ class SendSmsAdminActionTests(TestCase):
"""
now = timezone.now()
User.objects.create(
name='inactive-1', is_guest=False, phone='0910000021',
name='inactive-1', phone='0910000021',
last_activity_at=now - timezone.timedelta(days=90),
)
User.objects.create(
name='inactive-2', is_guest=False, phone='0910000022',
name='inactive-2', phone='0910000022',
last_activity_at=now - timezone.timedelta(days=90),
)
cutoff_date = (now - timezone.timedelta(days=30)).date()
@@ -1201,7 +1157,7 @@ class SendSmsAdminActionTests(TestCase):
"""
response = self.client.post(self.changelist_url, {
'action': 'send_sms_to_selected',
'_selected_action': [self.user1.pk, self.guest_no_phone.pk],
'_selected_action': [self.user1.pk, self.no_phone_user.pk],
'apply': 'ارسال پیامک',
'message': 'متن',
'target': 'selected',
@@ -1242,7 +1198,7 @@ class SupervisorGroupAccessTests(TestCase):
is_staff=True,
)
self.target_user = User.objects.create(name='target', is_guest=False, phone='0910000099')
self.target_user = User.objects.create(name='target', phone='0910000099')
self.plan = Plan.objects.create(title='یک‌ماهه', price=10000)
self.order = Order.objects.create(
user_id=self.target_user.id, amount=10000, idempotency_key='sup-order-1',
@@ -1349,7 +1305,7 @@ class SupervisorGroupAccessTests(TestCase):
original_name = self.target_user.name
response = self.client.post(
reverse('admin:accounts_user_change', args=[self.target_user.pk]),
{'name': 'دستکاری‌شده', 'is_guest': 'on'},
{'name': 'دستکاری‌شده'},
)
self.assertEqual(response.status_code, 403)
self.target_user.refresh_from_db()
@@ -1438,7 +1394,7 @@ class AuthUserRowCacheTests(TestCase):
def setUp(self):
cache.clear()
self.client = APIClient()
self.user = User.objects.create(device_id='dev-authcache', is_guest=True, name='n')
self.user = User.objects.create(phone='09000000058', name='n')
self.token = issue_access_token(self.user.id)
# هر endpointِ authenticated؛ /api/user/hearts سبک است.
self.url = '/api/user/hearts'
@@ -1470,6 +1426,8 @@ class AuthUserRowCacheTests(TestCase):
@override_settings(AUTH_USER_CACHE_TTL=30)
def test_set_grade_still_works_with_cache_on(self):
from content.models import Grade
Grade.objects.create(pk=3, title='g3', icon='', color='')
r = self.client.post(
'/api/user/grade', {'gradeId': 3}, format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}')
@@ -1506,7 +1464,7 @@ class UpdateUserProfileTests(TestCase):
def setUp(self):
self.client = APIClient()
cache.clear()
self.user = User.objects.create(device_id='device-patch-1', is_guest=True, name='')
self.user = User.objects.create(phone='09000000059', name='')
self.token = issue_access_token(self.user.id)
def tearDown(self):
@@ -1550,13 +1508,150 @@ class UpdateUserProfileTests(TestCase):
def test_empty_body_is_rejected(self):
self.assertEqual(self._patch({}).status_code, 400)
def test_phone_and_guest_flag_cannot_be_changed(self):
response = self._patch({'name': 'رضا', 'phone': '09120000000', 'isGuest': False})
def test_phone_cannot_be_changed(self):
response = self._patch({'name': 'رضا', 'phone': '09120000000'})
self.assertEqual(response.status_code, 200)
self.user.refresh_from_db()
self.assertIsNone(self.user.phone)
self.assertTrue(self.user.is_guest)
self.assertNotEqual(self.user.phone, '09120000000')
def test_out_of_range_birth_year_is_rejected(self):
self.assertEqual(self._patch({'birthYear': 2026}).status_code, 400)
class TestLoginPhoneTests(TestCase):
"""
🔒 شماره‌های تستِ تیم (TEST_LOGIN_PHONES): باید ورودِ بدونِ پیامک را برای
همان چند شماره ممکن کنند و برای هیچ شماره‌ی دیگری نه. این تست‌ها با
DEBUG=False اجرا می‌شوند چون دقیقاً همان حالتی است که روی سرور هست و
DEV_MASTER_OTP در آن خاموش است.
"""
PHONE = '09120000001'
CODE = '834512'
def setUp(self):
self.client = APIClient()
cache.clear()
@override_settings(DEBUG=False, DEV_MASTER_OTP=None,
TEST_LOGIN_PHONES={PHONE: CODE})
def test_configured_phone_logs_in_without_sms(self):
with patch('accounts.views.send_otp_sms') as mock_send:
request = self.client.post(
'/api/auth/otp/request', {'phone': self.PHONE}, format='json',
)
self.assertEqual(request.status_code, 200)
# مهم‌ترین نکته‌ی این مسیر: هیچ پیامکی نباید فرستاده شود، وگرنه بدونِ
# کاوه‌نگار همین‌جا ۵۰۳ می‌گرفتیم و کل هدف از بین می‌رفت.
mock_send.assert_not_called()
self.assertEqual(request.data['devOtp'], '')
login = self.client.post(
'/api/auth/login', {'phone': self.PHONE, 'otp': self.CODE}, format='json',
)
self.assertEqual(login.status_code, 200)
self.assertTrue(login.data['token'])
@override_settings(DEBUG=False, DEV_MASTER_OTP=None,
TEST_LOGIN_PHONES={PHONE: CODE})
def test_code_is_never_returned_in_the_response(self):
response = self.client.post(
'/api/auth/otp/request', {'phone': self.PHONE}, format='json',
)
self.assertNotIn(self.CODE, str(response.data))
@override_settings(DEBUG=False, DEV_MASTER_OTP=None,
TEST_LOGIN_PHONES={PHONE: CODE})
def test_wrong_code_for_a_configured_phone_is_rejected(self):
response = self.client.post(
'/api/auth/login', {'phone': self.PHONE, 'otp': '000000'}, format='json',
)
self.assertEqual(response.status_code, 400)
@override_settings(DEBUG=False, DEV_MASTER_OTP=None,
TEST_LOGIN_PHONES={PHONE: CODE})
def test_the_code_does_not_work_for_any_other_phone(self):
"""تفاوتِ اصلی با DEV_MASTER_OTP: کد فقط مالِ همان یک شماره است."""
response = self.client.post(
'/api/auth/login', {'phone': '09129999999', 'otp': self.CODE}, format='json',
)
self.assertEqual(response.status_code, 400)
@override_settings(DEBUG=False, DEV_MASTER_OTP=None, TEST_LOGIN_PHONES={})
def test_empty_configuration_changes_nothing(self):
"""بدونِ تنظیم، مسیرِ OTP دقیقاً همان رفتارِ قبلی را دارد."""
with patch('accounts.views.send_otp_sms') as mock_send:
response = self.client.post(
'/api/auth/otp/request', {'phone': '09121112233'}, format='json',
)
self.assertEqual(response.status_code, 200)
mock_send.assert_called_once()
@override_settings(DEBUG=False, DEV_MASTER_OTP=None,
TEST_LOGIN_PHONES={PHONE: CODE},
OTP_LOGIN_PHONE_MAX_ATTEMPTS=3)
def test_brute_force_on_a_configured_phone_is_still_rate_limited(self):
for _ in range(3):
self.client.post(
'/api/auth/login', {'phone': self.PHONE, 'otp': '111111'}, format='json',
)
blocked = self.client.post(
'/api/auth/login', {'phone': self.PHONE, 'otp': '111111'}, format='json',
)
self.assertEqual(blocked.status_code, 429)
class TestLoginPhoneParsingTests(TestCase):
"""ورودیِ خرابِ TEST_LOGIN_PHONES باید سرویس را بالا نیاورد، نه اینکه بی‌صدا رد شود."""
def _parse(self, raw):
from config.settings import _parse_test_login_phones
return _parse_test_login_phones(raw)
def test_valid_pairs_are_parsed(self):
self.assertEqual(
self._parse(' 09120000001:834512 , +989120000002:110947 '),
{'09120000001': '834512', '09120000002': '110947'},
)
def test_empty_value_yields_no_test_phones(self):
self.assertEqual(self._parse(''), {})
def test_malformed_entry_raises(self):
from django.core.exceptions import ImproperlyConfigured
for raw in ('09120000001', '09120000001:12', 'abc:123456',
'09120000001:abcdef', '09120000001:1234567'):
with self.subTest(raw=raw), self.assertRaises(ImproperlyConfigured):
self._parse(raw)
class RefreshTokenStoredHashedTests(TestCase):
"""دیتابیس، بکاپ یا پنلِ ادمین نباید خودِ توکنِ تمدید را داشته باشد؛ فقط هشش."""
def test_issued_tokens_are_stored_only_as_hash(self):
client = APIClient()
user = User.objects.create(phone='09121230203')
raw = 'raw-token-for-hash-test'
Session.objects.create(user=user, refresh_token=hash_refresh_token(raw),
expires_at=timezone.now() + timezone.timedelta(days=1))
self.assertFalse(Session.objects.filter(refresh_token=raw).exists())
self.assertFalse(Session.objects.filter(refresh_token=raw).exists())
self.assertTrue(Session.objects.filter(refresh_token=hash_refresh_token(raw)).exists())
renewed = client.post('/api/auth/refresh', {'refreshToken': raw}, format='json').json()['refreshToken']
self.assertFalse(Session.objects.filter(refresh_token=renewed).exists())
self.assertTrue(Session.objects.filter(refresh_token=hash_refresh_token(renewed), revoked=False).exists())
def test_migration_hashes_existing_rows(self):
from importlib import import_module
from django.apps import apps as global_apps
user = User.objects.create(phone='09121230204')
Session.objects.create(user=user, refresh_token='old-raw-token',
expires_at=timezone.now() + timezone.timedelta(days=1))
import_module('accounts.migrations.0007_hash_refresh_tokens').hash_existing(global_apps, None)
self.assertEqual(Session.objects.get(user=user).refresh_token, hash_refresh_token('old-raw-token'))
response = APIClient().post('/api/auth/refresh', {'refreshToken': 'old-raw-token'}, format='json')
self.assertEqual(response.status_code, 200)

View File

@@ -0,0 +1,121 @@
"""
حساب، ورود و چند موردِ دیگر از بازبینیِ ۲۰۲۶-۰۹-۲۵.
"""
import time
from unittest.mock import patch
from django.core.cache import cache
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import OTPCode, User
from content.models import Choice, Grade, Lesson, Node, Question, Unit
from progress.models import UserHearts, UserStats
def _client(user):
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
return client
def _login(phone):
"""ورودِ واقعی با پیامکِ (ماک‌شده) و کد؛ پاسخِ login را برمی‌گرداند."""
with patch('accounts.views.secrets.randbelow', return_value=123456), \
patch('accounts.views.send_otp_sms'):
APIClient().post('/api/auth/otp/request', {'phone': phone}, format='json')
return APIClient().post('/api/auth/login', {'phone': phone, 'otp': '123456'}, format='json').json()
@override_settings(OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
class LogoutTests(TestCase):
def setUp(self):
cache.clear()
def test_logout_kills_the_current_token_and_its_refresh(self):
session = _login('09121234901')
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f'Bearer {session["token"]}')
self.assertEqual(client.get('/api/user/profile').status_code, 200)
time.sleep(1.1) # زمانِ توکن ثانیه‌ای است؛ توکن باید پیش از خروج صادر شده باشد
response = client.post('/api/auth/logout', {'refreshToken': session['refreshToken']}, format='json')
self.assertEqual(response.status_code, 200)
self.assertEqual(client.get('/api/user/profile').status_code, 401)
refreshed = APIClient().post('/api/auth/refresh', {'refreshToken': session['refreshToken']}, format='json')
self.assertEqual(refreshed.status_code, 401)
# ورودِ دوباره کار می‌کند
again = _login('09121234901')
fresh = APIClient()
fresh.credentials(HTTP_AUTHORIZATION=f'Bearer {again["token"]}')
self.assertEqual(fresh.get('/api/user/profile').status_code, 200)
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
class OtpTests(TestCase):
def setUp(self):
cache.clear()
def _request(self, phone, code='123456'):
with patch('accounts.views.secrets.randbelow', return_value=int(code)), \
patch('accounts.views.send_otp_sms'):
APIClient().post('/api/auth/otp/request', {'phone': phone}, format='json')
def test_a_code_dies_after_five_wrong_guesses(self):
phone = '09121234001'
self._request(phone)
for guess in ('000001', '000002', '000003', '000004', '000005'):
APIClient().post('/api/auth/login', {'phone': phone, 'otp': guess}, format='json')
self.assertTrue(OTPCode.objects.get(phone=phone).is_used)
right = APIClient().post('/api/auth/login', {'phone': phone, 'otp': '123456'}, format='json')
self.assertEqual(right.status_code, 400)
class DisplayNameTests(TestCase):
def test_contact_details_are_stripped_from_the_name(self):
user = User.objects.create(phone='09121234004')
body = _client(user).patch('/api/user/profile', {'name': 'سارا 0912 345 6789 @sara'},
format='json').json()
self.assertEqual(body['name'], 'سارا')
class HeartsTests(TestCase):
def setUp(self):
cache.clear()
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
node = Node.objects.create(unit=Unit.objects.create(grade=grade, title='ف', order=1), title='ن', order=1)
self.question = Question.objects.create(lesson=Lesson.objects.create(node=node, title='د'),
type='multiple_choice', prompt='؟')
self.right = Choice.objects.create(question=self.question, value='۱', is_correct=True)
self.user = User.objects.create(phone='09121234005')
UserHearts.objects.create(user_id=self.user.id, current_hearts=0)
def _answer(self):
return _client(self.user).post(f'/api/questions/{self.question.id}/answer',
{'selectedOptionId': self.right.id}, format='json')
# از ۲۰۲۶-۱۰-۰۲ پیش‌فرض روشن است (قلبِ هدیه‌ی بسته‌ها بدونِ آن اثری نداشت)؛ با .env خاموش می‌شود.
@override_settings(HEARTS_ENFORCED=False)
def test_can_be_switched_off(self):
self.assertEqual(self._answer().status_code, 200)
@override_settings(HEARTS_ENFORCED=True)
def test_when_enforced_no_hearts_means_no_answer(self):
response = self._answer()
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()['code'], 'no_hearts')
class AdminLoginThrottleTests(TestCase):
def setUp(self):
cache.clear()
@override_settings(ADMIN_LOGIN_MAX_ATTEMPTS=3)
def test_password_guessing_is_capped(self):
codes = [self.client.post('/admin/login/', {'username': 'a', 'password': 'b'}).status_code
for _ in range(4)]
self.assertEqual(codes[-1], 429)
self.assertNotEqual(codes[0], 429)

View File

@@ -0,0 +1,104 @@
"""
تاریخِ تولدِ شمسی در پروفایل (۲۰۲۶-۱۰-۰۱). اپ روز/ماه/سالِ شمسی می‌فرستد؛ سرور
میلادی ذخیره می‌کند و birthYear را از آن می‌سازد. راه‌های شکستن: تاریخِ ناموجود
(۳۰ اسفندِ غیرکبیسه)، ارقامِ فارسی، سنِ خارج از بازه، و جدا افتادنِ سال از تاریخ.
"""
from datetime import date
from django.core.cache import cache
from django.test import SimpleTestCase, TestCase
from rest_framework.test import APIClient
from accounts import jalali
from accounts.authentication import issue_access_token
from accounts.models import Profile, User
class JalaliConversionTests(SimpleTestCase):
KNOWN = [
((1405, 1, 1), date(2026, 3, 21)),
((1403, 12, 30), date(2025, 3, 20)), # اسفندِ کبیسه
((1404, 12, 29), date(2026, 3, 20)), # آخرین روزِ سالِ غیرکبیسه
((1385, 4, 12), date(2006, 7, 3)),
((1367, 10, 11), date(1989, 1, 1)),
]
def test_known_dates_both_ways(self):
for jalali_date, gregorian in self.KNOWN:
self.assertEqual(jalali.to_gregorian(*jalali_date), gregorian)
self.assertEqual(jalali.from_gregorian(gregorian), jalali_date)
def test_thirtieth_of_esfand_exists_only_in_leap_years(self):
self.assertTrue(jalali.is_leap(1403))
self.assertFalse(jalali.is_leap(1404))
with self.assertRaises(ValueError):
jalali.to_gregorian(1404, 12, 30)
def test_every_day_of_a_century_round_trips(self):
start = date(1940, 1, 1).toordinal()
for offset in range(0, 36600, 7):
day = date.fromordinal(start + offset)
self.assertEqual(jalali.to_gregorian(*jalali.from_gregorian(day)), day)
def test_persian_digits_and_slashes_are_accepted(self):
self.assertEqual(jalali.parse('۱۳۸۵/۰۴/۱۲'), date(2006, 7, 3))
class ProfileBirthDateApiTests(TestCase):
def setUp(self):
cache.clear()
self.user = User.objects.create(phone='09000000053')
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
def patch(self, body):
return self.client.patch('/api/user/profile', body, format='json')
def test_date_is_stored_and_returned_in_jalali_with_its_year(self):
response = self.patch({'birthDate': '1385-04-12'})
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response.json()['birthDate'], '1385-04-12')
self.assertEqual(response.json()['birthYear'], 1385)
self.assertEqual(Profile.objects.get(user=self.user).birth_date, date(2006, 7, 3))
self.assertEqual(self.client.get('/api/user/profile').json()['birthDate'], '1385-04-12')
def test_adults_are_accepted(self):
self.assertEqual(self.patch({'birthDate': '1360-01-15'}).status_code, 200)
def test_impossible_dates_are_rejected(self):
for bad in ('1404-12-30', '1385-13-01', '1385-07-31', '1385-4', 'دیروز'):
self.assertEqual(self.patch({'birthDate': bad}).status_code, 400, bad)
def test_future_and_too_young_or_old_are_rejected(self):
for bad in ('1410-01-01', '1403-01-01', '1300-01-01'):
self.assertEqual(self.patch({'birthDate': bad}).status_code, 400, bad)
def test_date_and_year_together_are_ambiguous(self):
self.assertEqual(self.patch({'birthDate': '1385-04-12', 'birthYear': 1390}).status_code, 400)
def test_a_different_year_alone_clears_the_old_date(self):
self.patch({'birthDate': '1385-04-12'})
data = self.patch({'birthYear': 1388}).json()
self.assertEqual((data['birthYear'], data['birthDate']), (1388, None))
def test_null_clears_the_date(self):
self.patch({'birthDate': '1385-04-12'})
data = self.patch({'birthDate': None}).json()
self.assertEqual((data['birthYear'], data['birthDate']), (None, None))
def test_parent_phone_is_gone(self):
self.assertNotIn('parentPhone', self.client.get('/api/user/profile').json())
class PublicIdTests(TestCase):
def test_profile_shows_a_six_digit_code_that_maps_back_to_the_user(self):
from accounts.public_id import decode
user = User.objects.create(phone='09000000054')
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
code = client.get('/api/user/profile').json()['publicId']
self.assertRegex(code, r'^User\d{6}$')
self.assertEqual(decode(code), user.id)
self.assertEqual(decode(code[4:]), user.id)
self.assertIsNone(decode('User12'))

View File

@@ -0,0 +1,60 @@
"""
POST /api/user/delete — حذفِ حسابِ خود (۲۰۲۶-۱۰-۰۲). راه‌های شکستن: حذف با توکنِ تنها
(بی‌کد)، حدسِ کد، حذفِ حسابِ دمو، و پاک شدنِ سندِ مالی.
"""
from django.core.cache import cache
from django.test import TestCase, override_settings
from django.utils import timezone
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import OTPCode, User
from payment.models import Order
from progress.models import UserStats
def _client(user):
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
return client
class DeleteAccountTests(TestCase):
def setUp(self):
cache.clear()
self.user = User.objects.create(phone='09121115001')
UserStats.objects.create(user_id=self.user.id, xp=50)
self.client = _client(self.user)
def code(self, value='482913'):
OTPCode.objects.create(phone=self.user.phone, code=value,
expires_at=timezone.now() + timezone.timedelta(minutes=5))
return value
def delete(self, **body):
return self.client.post('/api/user/delete', body, format='json')
def test_a_token_alone_is_not_enough(self):
self.assertEqual(self.delete().status_code, 400)
self.assertTrue(User.objects.filter(pk=self.user.pk).exists())
def test_a_wrong_code_deletes_nothing(self):
self.code()
self.assertEqual(self.delete(otp='111111').status_code, 400)
self.assertTrue(User.objects.filter(pk=self.user.pk).exists())
def test_the_right_code_deletes_everything_but_payment_records(self):
Order.objects.create(user_id=self.user.id, amount=99000, status='paid', idempotency_key='k')
response = self.delete(otp=self.code())
self.assertEqual(response.status_code, 200, response.content)
self.assertFalse(User.objects.filter(pk=self.user.pk).exists())
self.assertFalse(UserStats.objects.filter(user_id=self.user.pk).exists())
self.assertTrue(Order.objects.filter(user_id=self.user.pk).exists())
# توکنِ قبلی دیگر به هیچ کاربری نمی‌رسد.
self.assertIn(self.client.get('/api/user/profile').status_code, (401, 403))
@override_settings(TEST_LOGIN_PHONES={'09121115001': '4321'})
def test_demo_accounts_cannot_be_deleted(self):
self.assertEqual(self.delete(otp='4321').status_code, 403)
self.assertTrue(User.objects.filter(pk=self.user.pk).exists())

View File

@@ -0,0 +1,75 @@
"""delete_users: کاربر و همه‌ی داده‌اش در همه‌ی اپ‌ها، و هیچ چیزِ کاربرِ دیگری."""
from io import StringIO
from django.core.management import CommandError, call_command
from django.test import TestCase, override_settings
from accounts.models import OTPCode, Profile, User
from ai_tutor.models import Conversation, Message
from notifications.models import Notification
from payment.models import Order
from progress.models import UserStats
class DeleteUsersTests(TestCase):
def setUp(self):
self.doomed = User.objects.create(phone='09121110001')
self.kept = User.objects.create(phone='09121110002')
for user in (self.doomed, self.kept):
Profile.objects.create(user=user)
UserStats.objects.create(user_id=user.id, xp=10)
Message.objects.create(conversation=Conversation.objects.create(user_id=user.id), role='user', text='؟')
Notification.objects.create(user_id=user.id, type='xp_earned', title='ت')
OTPCode.objects.create(phone=user.phone, code='123456', expires_at='2030-01-01T00:00Z')
def run_command(self, *args):
out = StringIO()
call_command('delete_users', *args, stdout=out)
return out.getvalue()
def test_dry_run_changes_nothing(self):
out = self.run_command('--phones', self.doomed.phone)
self.assertIn('progress.UserStats.user_id: 1', out)
self.assertTrue(User.objects.filter(pk=self.doomed.pk).exists())
def test_everything_of_that_user_goes_and_nothing_else(self):
self.run_command('--phones', self.doomed.phone, '--yes')
self.assertFalse(User.objects.filter(pk=self.doomed.pk).exists())
for model in (UserStats, Conversation, Notification):
self.assertFalse(model.objects.filter(user_id=self.doomed.pk).exists(), model)
self.assertTrue(model.objects.filter(user_id=self.kept.pk).exists(), model)
self.assertFalse(OTPCode.objects.filter(phone=self.doomed.phone).exists())
self.assertTrue(OTPCode.objects.filter(phone=self.kept.phone).exists())
self.assertEqual(Message.objects.count(), 1)
def test_paid_orders_need_explicit_permission(self):
Order.objects.create(user_id=self.doomed.id, amount=1000, status='paid', idempotency_key='k1')
with self.assertRaises(CommandError):
self.run_command('--ids', str(self.doomed.pk), '--yes')
self.assertTrue(User.objects.filter(pk=self.doomed.pk).exists())
self.run_command('--ids', str(self.doomed.pk), '--yes', '--include-paid')
self.assertFalse(Order.objects.filter(user_id=self.doomed.pk).exists())
@override_settings(TEST_LOGIN_PHONES={'09121110002': '1234'})
def test_real_phones_keeps_demo_numbers(self):
self.run_command('--real-phones', '--yes')
self.assertFalse(User.objects.filter(pk=self.doomed.pk).exists())
self.assertTrue(User.objects.filter(pk=self.kept.pk).exists())
def test_skip_paid_keeps_the_payer_and_deletes_the_rest(self):
Order.objects.create(user_id=self.doomed.id, amount=1000, status='paid', idempotency_key='k2')
self.run_command('--ids', str(self.doomed.pk), str(self.kept.pk), '--yes', '--skip-paid')
self.assertTrue(User.objects.filter(pk=self.doomed.pk).exists())
self.assertFalse(User.objects.filter(pk=self.kept.pk).exists())
def test_django_admin_history_is_never_touched(self):
from django.contrib.admin.models import LogEntry
from django.contrib.auth.models import User as StaffUser
from accounts.management.commands.delete_users import user_scoped_models
self.assertNotIn(LogEntry, [model for model, _ in user_scoped_models()])
staff = StaffUser.objects.create(id=self.doomed.pk, username='staff')
LogEntry.objects.create(user=staff, action_flag=1, object_repr='x')
self.run_command('--ids', str(self.doomed.pk), '--yes')
self.assertEqual(LogEntry.objects.count(), 1)

View File

@@ -0,0 +1,55 @@
"""
LoginEvent: سابقه‌ی ماندگارِ ورود/خروج. آزموده: ورودِ موفق، کدِ غلط، مسدودشدن، خروج، ماندنِ
ردیف بعد از حذفِ حساب. آزموده نشده: حجمِ بالا و نوشتن هم‌زمان.
"""
from django.core.cache import cache
from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import LoginEvent, OTPCode, User
class LoginEventTests(TestCase):
def setUp(self):
cache.clear()
self.phone = '09121116001'
self.api = APIClient()
def otp(self, code='482913'):
OTPCode.objects.create(phone=self.phone, code=code,
expires_at=timezone.now() + timezone.timedelta(minutes=5))
def login(self, code):
return self.api.post('/api/auth/login', {'phone': self.phone, 'otp': code}, format='json',
HTTP_USER_AGENT='test-agent', REMOTE_ADDR='203.0.113.7')
def test_success_and_failure_are_recorded(self):
self.assertEqual(self.login('000000').status_code, 400)
self.otp()
self.assertEqual(self.login('482913').status_code, 200)
events = list(LoginEvent.objects.order_by('id').values_list('event', 'phone', 'ip', 'user_agent'))
self.assertEqual(events, [
('login_failed', self.phone, '203.0.113.7', 'test-agent'),
('login_ok', self.phone, '203.0.113.7', 'test-agent'),
])
self.assertEqual(LoginEvent.objects.get(event='login_ok').user_id,
User.objects.get(phone=self.phone).id)
def test_logout_is_recorded(self):
user = User.objects.create(phone=self.phone)
self.api.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
self.assertEqual(self.api.post('/api/auth/logout', {}, format='json').status_code, 200)
self.assertTrue(LoginEvent.objects.filter(event='logout', user_id=user.id).exists())
def test_events_survive_account_deletion(self):
user = User.objects.create(phone=self.phone)
LoginEvent.objects.create(event='login_ok', user_id=user.id, phone=self.phone)
self.api.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
self.otp()
r = self.api.post('/api/user/delete', {'otp': '482913'}, format='json')
self.assertEqual(r.status_code, 200, r.content)
self.assertFalse(User.objects.filter(pk=user.pk).exists())
self.assertEqual(set(LoginEvent.objects.values_list('event', flat=True)),
{'login_ok', 'account_deleted'})

View File

@@ -0,0 +1,196 @@
"""
نرمال‌سازیِ شماره و سقف‌های دیتابیسیِ پیامکِ OTP (۲۰۲۶-۰۹-۲۵، همان روزی که
کلیدِ کاوه‌نگار وصل شد). security-before-production.md بندِ ۱۷.
کش در هر تست پاک می‌شود تا رفتارِ «workerِ دیگر» (کشِ خالی، دیتابیسِ مشترک)
شبیه‌سازی شود. هم‌زمانیِ واقعیِ دو پروسه‌ی gunicorn تست نشده است.
"""
from unittest.mock import patch
import requests
from django.core.cache import cache
from django.test import TestCase, override_settings
from django.utils import timezone
from rest_framework.test import APIClient
from accounts.models import OTPCode, User
from accounts.services import send_sms
def _request(phone):
return APIClient().post('/api/auth/otp/request', {'phone': phone}, format='json')
@override_settings(DEBUG=True, TEST_LOGIN_PHONES={})
class PhoneNormalizationTests(TestCase):
def setUp(self):
cache.clear()
def test_other_spellings_of_the_same_number_share_one_cooldown(self):
# همان سنجه‌ای که security-before-production.md برای بستنِ بندِ ۱۷ نوشته.
with patch('accounts.views.send_otp_sms') as send:
self.assertEqual(_request('09121112233').status_code, 200)
for spelling in ('+989121112233', '989121112233', '۰۹۱۲۱۱۱۲۲۳۳', '0912 111 2233'):
self.assertEqual(_request(spelling).status_code, 429, spelling)
send.assert_called_once()
self.assertEqual(set(OTPCode.objects.values_list('phone', flat=True)), {'09121112233'})
def test_login_with_another_spelling_reaches_the_same_account(self):
User.objects.create(phone='09121112244')
with patch('accounts.views.secrets.randbelow', return_value=246810), \
patch('accounts.views.send_otp_sms'):
_request('+989121112244')
body = APIClient().post('/api/auth/login', {'phone': '۰۹۱۲۱۱۱۲۲۴۴', 'otp': '246810'},
format='json').json()
self.assertEqual(User.objects.filter(phone__endswith='9121112244').count(), 1)
self.assertEqual(body['userId'], str(User.objects.get(phone='09121112244').id))
def test_non_mobile_numbers_are_rejected_before_any_sms(self):
with patch('accounts.views.send_otp_sms') as send:
for bad in ('+14155552671', '02188888888', '0912123', 'abc'):
self.assertEqual(_request(bad).status_code, 400, bad)
send.assert_not_called()
self.assertFalse(OTPCode.objects.exists())
@override_settings(TEST_LOGIN_PHONES={'09120000101': '4321'})
def test_demo_phone_matches_in_any_spelling(self):
response = APIClient().post('/api/auth/login', {'phone': '+989120000101', 'otp': '4321'},
format='json')
self.assertEqual(response.status_code, 200)
@override_settings(DEBUG=True, TEST_LOGIN_PHONES={}, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=120)
class DatabaseBackedLimitTests(TestCase):
"""کشِ LocMem مالِ هر worker است؛ این سقف‌ها باید با کشِ خالی هم بمانند."""
def setUp(self):
cache.clear()
def test_cooldown_holds_on_a_worker_with_an_empty_cache(self):
with patch('accounts.views.send_otp_sms') as send:
self.assertEqual(_request('09121112255').status_code, 200)
cache.clear() # درخواستِ بعدی به workerِ دیگری می‌رسد
response = _request('09121112255')
self.assertEqual(response.status_code, 429)
self.assertTrue(1 <= response.json()['retryAfter'] <= 120)
send.assert_called_once()
@override_settings(OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0, OTP_REQUEST_PHONE_DAILY_MAX=3)
def test_daily_cap_per_phone(self):
with patch('accounts.views.send_otp_sms') as send:
statuses = []
for _ in range(5):
cache.clear() # سقفِ ساعتیِ کش را کنار می‌گذارد؛ فقط سقفِ روزانه بماند
statuses.append(_request('09121112266').status_code)
self.assertEqual(statuses, [200, 200, 200, 429, 429])
self.assertEqual(send.call_count, 3)
@override_settings(OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
def test_codes_older_than_a_day_do_not_count(self):
OTPCode.objects.bulk_create([
OTPCode(phone='09121112277', code='000000',
expires_at=timezone.now() - timezone.timedelta(days=2))
for _ in range(20)
])
with patch('accounts.views.send_otp_sms'):
self.assertEqual(_request('09121112277').status_code, 200)
@override_settings(OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0, OTP_SMS_GLOBAL_DAILY_MAX=2)
def test_global_daily_cap_stops_sms_pumping(self):
with patch('accounts.views.send_otp_sms') as send:
self.assertEqual(_request('09121110001').status_code, 200)
self.assertEqual(_request('09121110002').status_code, 200)
with self.assertLogs('accounts.views', level='ERROR'):
response = _request('09121110003')
self.assertEqual(response.status_code, 503)
self.assertEqual(send.call_count, 2)
self.assertFalse(OTPCode.objects.filter(phone='09121110003').exists())
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='k')
def test_failed_delivery_frees_the_cooldown_and_quota(self):
with patch('accounts.services.requests.post', side_effect=requests.ConnectionError('down')):
self.assertEqual(_request('09121112288').status_code, 503)
cache.clear()
ok = type('R', (), {'status_code': 200, 'json': lambda self: {
'return': {'status': 200}, 'entries': [{'messageid': 1, 'status': 1}]}})()
with patch('accounts.services.requests.post', return_value=ok):
self.assertEqual(_request('09121112288').status_code, 200)
class ProviderLoggingTests(TestCase):
@override_settings(KAVENEGAR_API_KEY='k')
def test_accepted_message_logs_its_id_not_its_text(self):
ok = type('R', (), {'status_code': 200, 'json': lambda self: {
'return': {'status': 200}, 'entries': [{'messageid': 8792343, 'status': 1}]}})()
with patch('accounts.services.requests.post', return_value=ok), \
self.assertLogs('accounts.services', level='INFO') as logs:
send_sms(phone='09121112299', message='کد ورود شما: 123456')
joined = '\n'.join(logs.output)
self.assertIn('messageid=8792343', joined)
self.assertNotIn('123456', joined)
def _kavenegar_reply(http=200, status=200):
return type('R', (), {'status_code': http, 'json': lambda self: {
'return': {'status': status}, 'entries': [{'messageid': 1, 'status': 1}] if status == 200 else None}})()
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='k', TEST_LOGIN_PHONES={})
class VerifyLookupTests(TestCase):
"""خطِ حسابِ فعلی متنِ آزاد را لغو می‌کند (وضعیتِ ۱۳)؛ کدِ ورود باید با الگو برود."""
def setUp(self):
cache.clear()
@override_settings(KAVENEGAR_OTP_TEMPLATE='tadrisino-otp')
def test_otp_goes_through_the_template_with_only_the_code(self):
with patch('accounts.views.secrets.randbelow', return_value=4321), \
patch('accounts.services.requests.post', return_value=_kavenegar_reply()) as post:
self.assertEqual(_request('09121113301').status_code, 200)
url, data = post.call_args.args[0], post.call_args.kwargs['data']
self.assertTrue(url.endswith('/verify/lookup.json'))
self.assertEqual(data, {'receptor': '09121113301', 'template': 'tadrisino-otp', 'token': '004321'})
@override_settings(KAVENEGAR_OTP_TEMPLATE='')
def test_without_a_template_the_plain_send_is_kept(self):
with patch('accounts.services.requests.post', return_value=_kavenegar_reply()) as post:
self.assertEqual(_request('09121113302').status_code, 200)
self.assertTrue(post.call_args.args[0].endswith('/sms/send.json'))
self.assertIn('message', post.call_args.kwargs['data'])
@override_settings(KAVENEGAR_OTP_TEMPLATE='misspelled')
def test_unknown_template_is_an_explicit_503_and_frees_the_cooldown(self):
# کاوه‌نگار برای الگوی ناموجود/تأییدنشده HTTP ۴۲۴ می‌دهد (۲۰۲۶-۰۹-۲۵ زنده دیده شد).
with patch('accounts.services.requests.post', return_value=_kavenegar_reply(424, 424)):
self.assertEqual(_request('09121113303').status_code, 503)
self.assertFalse(OTPCode.objects.filter(phone='09121113303').exists())
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='k', KAVENEGAR_OTP_TEMPLATE='SMSVerify', TEST_LOGIN_PHONES={})
class SlowProviderTests(TestCase):
"""۲۰۲۶-۰۹-۲۷: کاوه‌نگار ۵٫۴ ثانیه طول کشید، پیامک رسید، ولی ما ۵۰۳ دادیم و کد را پاک کردیم."""
def setUp(self):
cache.clear()
def test_late_reply_keeps_the_code_and_moves_the_user_on(self):
with patch('accounts.views.secrets.randbelow', return_value=135790), \
patch('accounts.services.requests.post', side_effect=requests.ReadTimeout('slow')):
self.assertEqual(_request('09121114401').status_code, 200)
self.assertTrue(OTPCode.objects.filter(phone='09121114401', code='135790', is_used=False).exists())
login = APIClient().post('/api/auth/login', {'phone': '09121114401', 'otp': '135790'}, format='json')
self.assertEqual(login.status_code, 200)
# cooldown سرِ جایش است، پس دکمه‌ی دوباره پیامکِ دوم نمی‌فرستد
with patch('accounts.services.requests.post') as post:
self.assertEqual(_request('09121114401').status_code, 429)
post.assert_not_called()
def test_no_connection_is_still_an_explicit_503(self):
with patch('accounts.services.requests.post', side_effect=requests.ConnectTimeout('down')):
self.assertEqual(_request('09121114402').status_code, 503)
self.assertFalse(OTPCode.objects.filter(phone='09121114402').exists())
def test_timeouts_leave_room_for_a_slow_iranian_network(self):
with patch('accounts.services.requests.post', return_value=_kavenegar_reply()) as post:
_request('09121114403')
self.assertEqual(post.call_args.kwargs['timeout'], (10, 30))

View File

@@ -0,0 +1,241 @@
"""
حفاظ‌های بازبینیِ امنیتیِ پیش از تحویل (هویت و حساب). هر تست رفتاری را نگه می‌دارد که پیش‌تر غلط بود.
"""
import logging
import socket
from unittest import mock
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.core.cache import cache
from django.core.management import call_command
from django.test import TestCase, override_settings
from django.utils import timezone
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import OTPCode, User
from config.log_formatters import JSONLogFormatter
def _bearer(token):
c = APIClient()
c.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
return c
def _otp_row(phone, code='482913'):
OTPCode.objects.create(phone=phone, code=code, expires_at=timezone.now() + timezone.timedelta(minutes=5))
return code
class AccountDeletionKeepsLimitsTests(TestCase):
def setUp(self):
cache.clear()
from discount.models import DiscountCode
from subscription.models import Plan
self.plan = Plan.objects.create(title='ماهانه‌ی آزمایشی', price=45000, duration_days=30)
DiscountCode.objects.create(code='FREE1', type='percent', value=100, max_uses=1)
def _user_redeems(self, phone):
user = User.objects.create(phone=phone)
c = _bearer(issue_access_token(user.id))
p = c.post('/api/subscription/purchase', {'planId': str(self.plan.id)}, format='json')
return c, c.post('/api/discount/redeem', {'code': 'FREE1', 'orderId': p.json()['orderId']}, format='json')
def test_single_use_code_stays_used_after_delete(self):
c1, first = self._user_redeems('09121230101')
self.assertEqual(first.status_code, 200)
OTPCode.objects.create(phone='09121230101', code='777777',
expires_at=timezone.now() + timezone.timedelta(minutes=5))
self.assertEqual(c1.post('/api/user/delete', {'otp': '777777'}, format='json').status_code, 200)
_, again = self._user_redeems('09121230102')
self.assertEqual(again.status_code, 400)
@override_settings(DEBUG=False, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
def test_per_phone_sms_cap_survives_delete(self):
phone = '09121230009'
user = User.objects.create(phone=phone)
for i in range(10):
OTPCode.objects.create(phone=phone, code=f'{i:06d}', is_used=True,
expires_at=timezone.now() - timezone.timedelta(minutes=i + 1))
with mock.patch('accounts.views.send_otp_sms'):
OTPCode.objects.create(phone=phone, code='777777',
expires_at=timezone.now() + timezone.timedelta(minutes=5))
d = _bearer(issue_access_token(user.id)).post('/api/user/delete', {'otp': '777777'}, format='json')
self.assertEqual(d.status_code, 200)
cache.clear()
r = APIClient().post('/api/auth/otp/request', {'phone': phone}, format='json')
self.assertEqual(r.status_code, 429)
class DisplayNameContactFilterTests(TestCase):
def test_contacts_are_removed_and_names_kept(self):
from accounts.serializers import clean_display_name as clean
for sample in ('0912/123/4567', '۰۹۱۲/۱۲۳/۴۵۶۷', '0912 _123_ 4567', 'ig: ali_kid_2014', 'آیدی: kid2014'):
self.assertEqual(clean(sample), '', sample)
for name in ('علی رضایی', 'آیدین', 'Sara 2014', 'ایتالیا', 'تلما'):
self.assertEqual(clean(name), name)
class _Capture(logging.Handler):
def __init__(self):
super().__init__()
self.lines = []
self.setFormatter(JSONLogFormatter())
def emit(self, record):
self.lines.append(self.format(record))
FAKE_KEY = '4E6F74415265616C4B6176656E656761724B6579414243444546'
@override_settings(KAVENEGAR_API_KEY=FAKE_KEY, KAVENEGAR_OTP_TEMPLATE='SMSVerify')
class KavenegarKeyNotLoggedTests(TestCase):
"""کلیدِ کاوه‌نگار بخشی از آدرس است و متنِ خطای requests آدرس را دارد."""
def test_connection_error_log_has_no_key(self):
from accounts import services
cap = _Capture()
log = logging.getLogger('accounts.services')
log.addHandler(cap)
def no_dns(*args, **kwargs):
raise socket.gaierror(socket.EAI_NONAME, 'network disabled in tests')
try:
with mock.patch('socket.getaddrinfo', no_dns), self.assertRaises(services.SMSDeliveryError):
services.send_verify_lookup(phone='09120000000', template='SMSVerify', token='123456')
finally:
log.removeHandler(cap)
out = '\n'.join(cap.lines)
self.assertIn('SMS request to provider failed', out)
self.assertNotIn(FAKE_KEY, out)
def _staff_with(*perms, username='staff1'):
from django.contrib.auth.models import Permission
staff = get_user_model().objects.create_user(username, f'{username}@example.com', 'pw-for-tests-only', is_staff=True)
for app_label, codename in perms:
staff.user_permissions.add(Permission.objects.get(content_type__app_label=app_label, codename=codename))
return staff
class StaffCannotSwapLoginPhoneTests(TestCase):
"""کارمندی با change_user شماره‌ی کاربر را عوض نمی‌کند؛ وگرنه با شماره‌ی خودش کد می‌گیرد و واردِ آن حساب می‌شود."""
def setUp(self):
cache.clear()
self.victim = User.objects.create(phone='09120000077', name='victim')
self.client.force_login(_staff_with(('accounts', 'change_user'), ('accounts', 'view_user')))
def tearDown(self):
cache.clear()
def test_phone_is_read_only(self):
r = self.client.post(f'/admin/accounts/user/{self.victim.pk}/change/', {
'phone': '09129999999', 'name': 'renamed', 'avatar': '', 'grade_id': ''})
self.assertEqual(r.status_code, 302)
self.victim.refresh_from_db()
self.assertEqual(self.victim.name, 'renamed')
self.assertEqual(self.victim.phone, '09120000077')
login = APIClient().post('/api/auth/login', {'phone': '09129999999', 'otp': _otp_row('09129999999')},
format='json')
self.assertNotEqual(login.json().get('userId'), str(self.victim.pk))
class StaffAccountsAdminSuperuserOnlyTests(TestCase):
"""auth.User/Group پنل: با change_group یا change_user (auth) کارمند خودش را ابرکاربر یا همه‌کاره نمی‌کند."""
def test_change_group_cannot_grant_all_permissions(self):
from django.contrib.auth.models import Permission
call_command('setup_supervisor_group', verbosity=0)
staff = _staff_with(('auth', 'change_group'), ('auth', 'view_group'))
group = Group.objects.get(name='Supervisor')
staff.groups.add(group)
self.client.force_login(staff)
r = self.client.post(f'/admin/auth/group/{group.pk}/change/',
{'name': 'Supervisor', 'permissions': list(Permission.objects.values_list('pk', flat=True))})
self.assertEqual(r.status_code, 403)
staff = get_user_model().objects.get(pk=staff.pk)
self.assertFalse(staff.has_perm('accounts.add_otpcode'))
def test_change_user_cannot_make_self_superuser(self):
staff = _staff_with(('auth', 'change_user'), ('auth', 'view_user'))
self.client.force_login(staff)
form = self.client.get(f'/admin/auth/user/{staff.pk}/change/')
self.assertEqual(form.status_code, 200)
self.assertNotContains(form, 'name="is_superuser"')
r = self.client.post(f'/admin/auth/user/{staff.pk}/change/', {
'username': staff.username, 'is_active': 'on', 'is_staff': 'on', 'is_superuser': 'on',
'last_login_0': '', 'last_login_1': '', 'date_joined_0': '2026-01-01', 'date_joined_1': '00:00:00'})
self.assertEqual(r.status_code, 403)
self.assertFalse(get_user_model().objects.get(pk=staff.pk).is_superuser)
class UnicodeInputsAreNormalisedOrRejectedTests(TestCase):
"""رقم‌های تمام‌عرض/ریاضی، کدِ غیرِ ASCIIو پایه‌ی ناموجود: ۴۰۰ یا نرمال‌شده، نه ۵۰۰ و نه حسابِ جدا."""
def setUp(self):
cache.clear()
self.addCleanup(cache.clear)
def test_fullwidth_and_math_digits_normalise_to_same_phone(self):
from accounts.phone import normalize_phone
for variant in ('09123456789', '𝟎𝟗𝟏𝟐𝟑𝟒𝟓𝟔𝟕𝟖𝟗', '۰۹۱۲۳۴۵۶۷۸۹', '+98 912 345 6789'):
self.assertEqual(normalize_phone(variant), '09123456789', variant)
self.assertIsNone(normalize_phone('0912345678²'))
def test_non_ascii_otp_is_400_not_500(self):
phone = '09120000099'
with override_settings(TEST_LOGIN_PHONES={phone: '123456'}):
for code in ('²²²²', '١٢٣٤٥٦٧'[:6] + 'x', 'abcd'):
r = APIClient().post('/api/auth/login', {'phone': phone, 'otp': code}, format='json')
self.assertEqual(r.status_code, 400, code)
def test_unknown_or_huge_grade_is_400(self):
user = User.objects.create(phone='09120000097')
app = _bearer(issue_access_token(user.id))
for gid in (99999, 10 ** 30):
self.assertEqual(app.post('/api/user/grade', {'gradeId': gid}, format='json').status_code, 400, gid)
def test_birth_date_with_superscript_digit_is_400(self):
user = User.objects.create(phone='09120000096')
r = _bearer(issue_access_token(user.id)).patch('/api/user/profile', {'birthDate': '13²5-04-12'}, format='json')
self.assertEqual(r.status_code, 400)
def test_display_name_nfkc_contacts_removed(self):
from accounts.serializers import clean_display_name as clean
for sample in ('09123456789', '𝟎𝟗𝟏𝟐𝟑𝟒𝟓𝟔𝟕𝟖𝟗', '0912*345*6789', '@kid2014'):
self.assertEqual(clean(sample), '', sample)
self.assertEqual(clean('علی رضایی'), 'علی رضایی')
class StaffCannotMintLoginsTests(TestCase):
"""کارمندِ غیرابرکاربر با اجازه‌ی add نمی‌تواند OTPCode یا Session دستی بسازد (= ورود به هر شماره)."""
def setUp(self):
from django.contrib.auth.models import Permission, User as AuthUser
self.staff = AuthUser.objects.create_user('staff1', 's@x.c', 'pw12345!', is_staff=True)
self.staff.user_permissions.set(Permission.objects.filter(
codename__in=['add_otpcode', 'change_otpcode', 'view_otpcode',
'add_session', 'change_session', 'view_session']))
self.root = AuthUser.objects.create_superuser('root1', 'r@x.c', 'pw12345!')
def test_staff_gets_403_on_add_pages(self):
self.client.force_login(self.staff)
for model in ('otpcode', 'session'):
self.assertEqual(self.client.get(f'/admin/accounts/{model}/add/').status_code, 403, model)
def test_superuser_can_still_add(self):
self.client.force_login(self.root)
for model in ('otpcode', 'session'):
self.assertEqual(self.client.get(f'/admin/accounts/{model}/add/').status_code, 200, model)

View File

@@ -0,0 +1,46 @@
"""پرونده‌ی کاربر در ادمین: ترتیب، CSV، و دسترسی."""
from django.test import TestCase
from django.utils import timezone
from accounts.models import LoginEvent, User
from payment.models import Order
class UserActivityTests(TestCase):
def setUp(self):
self.user = User.objects.create(phone='09121117001', name='علی')
LoginEvent.objects.create(event='login_ok', user_id=self.user.id, phone=self.user.phone, ip='203.0.113.9')
Order.objects.create(user_id=self.user.id, amount=45000, plan_title='بسته ۲۰', idempotency_key='k1', status='paid')
from django.contrib.auth.models import User as AuthUser
self.admin = AuthUser.objects.create_superuser('boss', 'b@x.ir', 'pw')
self.client.force_login(self.admin)
def test_page_lists_login_and_order(self):
r = self.client.get(f'/admin/accounts/user/{self.user.id}/activity/')
self.assertEqual(r.status_code, 200)
body = r.content.decode()
self.assertIn('ورود موفق', body)
self.assertIn('بسته ۲۰', body)
self.assertIn('45000 IRT', body)
def test_csv_has_bom_and_rows(self):
r = self.client.get(f'/admin/accounts/user/{self.user.id}/activity/?format=csv')
text = r.content.decode('utf-8')
self.assertTrue(text.startswith(''))
self.assertIn('203.0.113.9', text)
self.assertEqual(len(text.strip().splitlines()), 4) # سرستون + ثبت‌نام + ورود + سفارش
def test_view_only_staff_without_order_permission_is_refused(self):
from django.contrib.auth.models import Permission, User as AuthUser
s = AuthUser.objects.create_user('sup', 's@x.ir', 'pw', is_staff=True)
s.user_permissions.add(Permission.objects.get(codename='view_user', content_type__app_label='accounts'))
self.client.force_login(s)
self.assertEqual(self.client.get(f'/admin/accounts/user/{self.user.id}/activity/').status_code, 403)
def test_failed_login_by_phone_is_included(self):
LoginEvent.objects.create(event='login_failed', phone=self.user.phone, ip='198.51.100.4')
r = self.client.get(f'/admin/accounts/user/{self.user.id}/activity/')
self.assertIn('ورود ناموفق', r.content.decode())
def test_unknown_user_is_404(self):
self.assertEqual(self.client.get('/admin/accounts/user/999999/activity/').status_code, 404)

View File

@@ -1,8 +1,9 @@
from django.urls import path
from .views import (
GuestSessionView,
DeleteAccountView,
LoginView,
LogoutView,
RefreshTokenView,
RequestOTPView,
SetUserGradeView,
@@ -10,10 +11,11 @@ from .views import (
)
urlpatterns = [
path('auth/session', GuestSessionView.as_view(), name='auth-session'),
path('auth/otp/request', RequestOTPView.as_view(), name='auth-otp-request'),
path('auth/login', LoginView.as_view(), name='auth-login'),
path('auth/refresh', RefreshTokenView.as_view(), name='auth-refresh'),
path('auth/logout', LogoutView.as_view(), name='auth-logout'),
path('user/grade', SetUserGradeView.as_view(), name='user-grade'),
path('user/profile', UserProfileView.as_view(), name='user-profile'),
path('user/delete', DeleteAccountView.as_view(), name='user-delete'),
]

View File

@@ -6,22 +6,27 @@ import uuid
from django.conf import settings
from django.core.cache import cache
from django.db import transaction
from django.db.models import F
from django.utils import timezone
from drf_spectacular.utils import extend_schema
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.throttling import BaseThrottle, ScopedRateThrottle
from rest_framework.views import APIView
from .authentication import issue_access_token
from .models import OTPCode, Profile, Session, User
from config.db import release_db_connection
from .authentication import invalidate_user_cache, issue_access_token
from .models import LoginEvent, OTPCode, Profile, Session, User, hash_refresh_token
from .services import OTPDeliveryError, send_otp_sms
from .serializers import (
GuestSessionRequestSerializer,
GuestSessionResponseSerializer,
DeleteAccountRequestSerializer,
DeleteAccountResponseSerializer,
LoginRequestSerializer,
LoginResponseSerializer,
LogoutRequestSerializer,
LogoutResponseSerializer,
ProfileResponseSerializer,
RefreshRequestSerializer,
RefreshResponseSerializer,
@@ -35,73 +40,28 @@ from .serializers import (
logger = logging.getLogger(__name__)
class GuestAwareAccountThrottle(ScopedRateThrottle):
def record_login_event(request, event: str, *, user_id=None, phone: str = '') -> None:
"""
⚠️ اضافه شده: همون الگویِ GuestAwareContentThrottle (content/views.py)،
GuestAwareAIThrottle (ai_tutor/views.py) و بقیه؛ چون GuestSessionView
با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) می‌سازه، یک
ScopedRateThrottleِ سادهٔ per-user توسطِ چرخشِ حساب مهمان دورزدنی
بود. اینجا برای SetUserGradeView (که مهمان‌ها هم می‌تونن صداش بزنن)
از همین کلید بر اساسِ IP برای مهمان‌ها استفاده می‌شه؛ رفتارِ
throttle برای کاربرهای غیرمهمان (per-user، مثلِ قبل) دست‌نخورده
می‌مونه.
ردیفِ LoginEvent می‌نویسد. شکستش ورود/خروج را خراب نمی‌کند (ثبتِ سابقه نباید کاربر را بیرون
بیندازد) ولی با logger.exception دیده می‌شود. زیرِ بارِ هم‌زمان و روی Postgres سنجیده نشده.
"""
def get_cache_key(self, request, view):
user = request.user
if user and user.is_authenticated and getattr(user, 'is_guest', False):
ident = f'guest-ip:{self.get_ident(request)}'
elif user and user.is_authenticated:
ident = user.pk
else:
ident = self.get_ident(request)
return self.cache_format % {'scope': self.scope, 'ident': ident}
class GuestSessionView(APIView):
"""POST /api/auth/session — ایجاد سشن مهمان"""
throttle_scope = 'guest_session'
throttle_classes = [ScopedRateThrottle]
@extend_schema(
request=GuestSessionRequestSerializer,
responses=GuestSessionResponseSerializer,
tags=['Identity & Profile (accounts)'],
)
def post(self, request):
serializer = GuestSessionRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
device_id = serializer.validated_data['deviceId']
user, is_new_user = User.objects.get_or_create(
device_id=device_id,
is_guest=True,
defaults={'name': ''},
try:
LoginEvent.objects.create(
event=event, user_id=user_id, phone=phone,
ip=BaseThrottle().get_ident(request) or None,
user_agent=request.META.get('HTTP_USER_AGENT', '')[:300],
)
# ثبتِ فعالیتِ کاربر: ایجاد/استفاده‌ی سشنِ مهمان یک سشنِ واقعیِ
# کاربره، پس last_activity_at به‌روزرسانی می‌شه (بدونِ تأثیر روی
# سایرِ رفتارِ endpoint).
user.last_activity_at = timezone.now()
user.save(update_fields=['last_activity_at'])
token = issue_access_token(user.id)
data = {
'userId': str(user.id),
'token': token,
'isNewUser': is_new_user,
'isGuest': True,
}
return Response(GuestSessionResponseSerializer(data).data)
except Exception:
logger.exception('LoginEvent نوشته نشد: event=%s user_id=%s', event, user_id)
class RequestOTPView(APIView):
"""
POST /api/auth/otp/request — درخواست کد OTP (کمکی، برای تست از طریق Swagger)
در محیط توسعه، کد ساخته‌شده در پاسخ برگردونده می‌شه تا نیازی به SMS واقعی نباشه.
POST /api/auth/otp/request — درخواستِ کدِ ورود.
کد فقط با پیامک تحویل داده می‌شود. دو استثنا: در حالتِ DEBUG کد در فیلدِ
devOtp برمی‌گردد تا تستِ محلی به پیامکِ واقعی نیاز نداشته باشد، و برای
شماره‌های TEST_LOGIN_PHONES کدِ ثابتِ از پیش تعریف‌شده معتبر است.
"""
throttle_scope = 'otp_request'
@@ -117,6 +77,22 @@ class RequestOTPView(APIView):
serializer.is_valid(raise_exception=True)
phone = serializer.validated_data['phone']
# شماره‌ی تستِ تیم: کد ثابت و از قبل معلوم است، پس نه ردیفِ OTP تازه‌ای
# لازم است نه پیامکی. cooldown و سقفِ ساعتی هم رد می‌شوند چون هر دو از
# گیرنده‌ی واقعیِ پیامک محافظت می‌کنند و اینجا پیامکی در کار نیست.
#
# ⚠️ کد عمداً در پاسخ برنمی‌گردد، برخلافِ devOtp در حالتِ DEBUG: این
# مسیر روی سرورِ عمومی هم فعال است، پس برگرداندنش یعنی هر کسی که شماره
# را بداند می‌تواند وارد شود.
if phone in settings.TEST_LOGIN_PHONES:
logger.info('OTP request for configured test phone: phone_suffix=%s', phone[-4:])
data = {
'success': True,
'devOtp': '',
'note': 'این شماره‌ی تستِ تیم است؛ کدِ ثابتش در تنظیماتِ سرور تعریف شده و پیامکی ارسال نمی‌شود.',
}
return Response(RequestOTPResponseSerializer(data).data)
# حداقل فاصله‌ی زمانی بین دو درخواستِ همون شماره (burst protection)؛
# قبل از شمارنده‌ی ساعتی چک می‌شه تا یک درخواستِ بلاک‌شده سهمیه‌ی
# ساعتی رو مصرف نکنه. کلید مقدارِ epoch انقضا رو ذخیره می‌کنه تا
@@ -151,6 +127,8 @@ class RequestOTPView(APIView):
# محدودیتِ صدورِ OTP بر اساسِ شماره، مستقل از throttleِ IP-محورِ بالا
# (وگرنه یه مهاجم می‌تونه بینِ چند IP پخش کنه). cache.add فقط وقتی
# کلید نیست مقدار می‌ذاره، پس اتمیکه.
# شماره پیش از اینجا به 09XXXXXXXXX نرمال شده (accounts/phone.py)، پس
# املای دیگرِ همان شماره سهمِ جدا نمی‌گیرد.
phone_requests_key = f'otp_request_phone_attempts:{phone}'
if cache.add(phone_requests_key, 1, timeout=settings.OTP_REQUEST_PHONE_WINDOW_SECONDS):
attempt_count = 1
@@ -172,23 +150,65 @@ class RequestOTPView(APIView):
status=status.HTTP_429_TOO_MANY_REQUESTS,
)
# فقط آخرین کد صادرشده معتبر است.
OTPCode.objects.filter(phone=phone, is_used=False).update(is_used=True)
code = f'{secrets.randbelow(10000):04d}'
OTPCode.objects.create(
phone=phone,
code=code,
expires_at=timezone.now() + timezone.timedelta(minutes=5),
)
# سقف‌های دیتابیسی: cooldown و سقفِ ساعتیِ بالا در کشِ LocMem و مالِ هر
# worker‌اند (روی سرور ×۳). این‌ها از ردیف‌های OTPCode شمرده می‌شوند و بینِ
# workerها مشترک‌اند. زمانِ صدور = expires_at − OTP_TTL_SECONDS (مدل
# created_at ندارد). بررسی و ساختِ ردیف در یک تراکنش‌اند؛ با
# transaction_mode=IMMEDIATE دو درخواستِ هم‌زمان پشتِ هم اجرا می‌شوند
# (هم‌زمانیِ بینِ دو workerِ واقعی با تست پوشش داده نشده).
# فراخوانیِ کاوه‌نگار عمداً بیرونِ تراکنش است تا قفلِ نوشتن حینِ شبکه نماند.
now = timezone.now()
ttl = timezone.timedelta(seconds=settings.OTP_TTL_SECONDS)
day_window_expiry = now - timezone.timedelta(days=1) + ttl
with transaction.atomic():
phone_codes = OTPCode.objects.filter(phone=phone)
last_expiry = phone_codes.order_by('-expires_at').values_list('expires_at', flat=True).first()
if cooldown_seconds and last_expiry is not None:
elapsed = (now - (last_expiry - ttl)).total_seconds()
if elapsed < cooldown_seconds:
retry_after = max(1, math.ceil(cooldown_seconds - elapsed))
return Response(
{
'detail': f'لطفاً {retry_after} ثانیه صبر کنید و دوباره درخواست کد بدهید.',
'retryAfter': retry_after,
},
status=status.HTTP_429_TOO_MANY_REQUESTS,
headers={'Retry-After': str(retry_after)},
)
if phone_codes.filter(expires_at__gt=day_window_expiry).count() >= settings.OTP_REQUEST_PHONE_DAILY_MAX:
logger.warning('OTP request phone daily limit exceeded: phone_suffix=%s', phone[-4:])
return Response(
{'detail': 'سقفِ روزانه‌ی درخواستِ کد برای این شماره پر شده است؛ فردا دوباره تلاش کنید.'},
status=status.HTTP_429_TOO_MANY_REQUESTS,
)
if OTPCode.objects.filter(expires_at__gt=day_window_expiry).count() >= settings.OTP_SMS_GLOBAL_DAILY_MAX:
# یا حمله‌ی SMS pumping است یا رشدِ واقعی؛ هر دو یعنی کسی باید نگاه کند.
logger.error(
'OTP global daily SMS cap reached: cap=%s phone_suffix=%s',
settings.OTP_SMS_GLOBAL_DAILY_MAX, phone[-4:],
)
return Response(
{'detail': 'ارسالِ پیامک موقتاً ممکن نیست؛ لطفاً کمی بعد دوباره تلاش کنید.'},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
# فقط آخرین کد صادرشده معتبر است.
OTPCode.objects.filter(phone=phone, is_used=False).update(is_used=True)
# ۶ رقم: با ۴ رقم و محدودیتِ تلاشِ per-worker، گرفتنِ حسابِ یک نفرِ مشخص
# روزی چند درصد شانس داشت. فرانت تا ۶ رقم را می‌پذیرد.
code = f'{secrets.randbelow(1_000_000):06d}'
otp_row = OTPCode.objects.create(phone=phone, code=code, expires_at=now + ttl)
# کاوه‌نگار تا چند ثانیه طول می‌کشد؛ روزِ لانچ این پرترافیک‌ترین مسیر است و اتصالِ دیتابیس
# در این مدت به pool برمی‌گردد (config/db.py).
release_db_connection()
try:
send_otp_sms(phone=phone, code=code)
except OTPDeliveryError:
# ارسال شکست خورده یعنی کاربر کدی نگرفته؛ cooldown رو پاک می‌کنیم
# تا مجبور نشه ۲ دقیقه بدونِ داشتنِ کد منتظر بمونه. سقفِ ساعتی/
# throttleِ IP دست‌نخورده می‌مونن (محدودیتِ حجم، نه این cooldown).
# تا مجبور نشه ۲ دقیقه بدونِ داشتنِ کد منتظر بمونه، و ردیفِ کد رو هم
# حذف می‌کنیم تا در cooldown و سقفِ روزانه‌ی دیتابیسی شمرده نشه.
# سقفِ ساعتیِ کش و throttleِ IP دست‌نخورده می‌مونن (محدودیتِ حجم).
cache.delete(phone_cooldown_key)
otp_row.delete()
raise
# devOtp فقط در محیط dev برمی‌گردد؛ در پروداکشن کد فقط از طریق پیامک تحویل داده می‌شود.
@@ -203,6 +223,57 @@ class RequestOTPView(APIView):
return Response(RequestOTPResponseSerializer(data).data)
def verify_phone_otp(phone: str, otp: str) -> Response | None:
"""
کدِ پیامکیِ این شماره را می‌سنجد و مصرف می‌کند؛ None یعنی درست بود، وگرنه پاسخِ خطا.
مشترکِ ورود (LoginView) و حذفِ حساب (DeleteAccountView): هر دو باید همان سقفِ تلاشِ
شماره، همان قفلِ ردیف و همان باطل شدنِ کد پس از چند حدسِ غلط را داشته باشند. پیش از
۲۰۲۶-۱۰-۰۲ این منطق فقط داخلِ LoginView بود.
"""
# محدودیت تلاش بر اساس شماره تلفن (مستقل از throttle IP-محورِ هر view).
phone_attempts_key = f'otp_login_phone_attempts:{phone}'
if cache.add(phone_attempts_key, 1, timeout=settings.OTP_LOGIN_PHONE_WINDOW_SECONDS):
attempt_count = 1
else:
try:
attempt_count = cache.incr(phone_attempts_key)
except ValueError:
# بینِ add و incr دقیقاً منقضی شده؛ پنجره رو از نو شروع کن.
cache.set(phone_attempts_key, 1, timeout=settings.OTP_LOGIN_PHONE_WINDOW_SECONDS)
attempt_count = 1
if attempt_count > settings.OTP_LOGIN_PHONE_MAX_ATTEMPTS:
# هیچ سکرتی (OTP، token) لاگ نمی‌شه؛ از شماره فقط ۴ رقمِ آخر.
logger.warning(
'OTP login phone attempt limit exceeded: phone_suffix=%s attempt_count=%s max_attempts=%s',
phone[-4:], attempt_count, settings.OTP_LOGIN_PHONE_MAX_ATTEMPTS,
)
return Response(
{'detail': 'تعداد تلاش‌های ورود برای این شماره بیش از حد مجاز است.'},
status=status.HTTP_429_TOO_MANY_REQUESTS,
)
# قفل ردیف OTP جلوی مصرف دوباره‌ی یک کد یک‌بارمصرف را در درخواست‌های هم‌زمان می‌گیرد.
with transaction.atomic():
test_code = settings.TEST_LOGIN_PHONES.get(phone, '')
if settings.DEV_MASTER_OTP is not None and otp == settings.DEV_MASTER_OTP:
return None
if test_code and secrets.compare_digest(test_code, otp):
# شماره‌ی تستِ تیم. compare_digest تا نتیجه از روی زمانِ پاسخ قابلِ استنتاج نباشه.
logger.info('Login via configured test phone: phone_suffix=%s', phone[-4:])
return None
otp_obj = OTPCode.objects.select_for_update().filter(
phone=phone, code=otp, is_used=False, expires_at__gte=timezone.now()
).order_by('-id').first()
if otp_obj:
otp_obj.is_used = True
otp_obj.save(update_fields=['is_used'])
return None
_count_failed_attempt(phone)
return Response({'detail': 'کد OTP نامعتبر یا منقضی‌شده است.'}, status=status.HTTP_400_BAD_REQUEST)
class LoginView(APIView):
"""POST /api/auth/login — ورود با OTP"""
@@ -220,56 +291,18 @@ class LoginView(APIView):
phone = serializer.validated_data['phone']
otp = serializer.validated_data['otp']
# محدودیت تلاش ورود بر اساس شماره تلفن (مستقل از throttle IP-محور بالا).
phone_attempts_key = f'otp_login_phone_attempts:{phone}'
if cache.add(phone_attempts_key, 1, timeout=settings.OTP_LOGIN_PHONE_WINDOW_SECONDS):
attempt_count = 1
else:
try:
attempt_count = cache.incr(phone_attempts_key)
except ValueError:
# بینِ add و incr دقیقاً منقضی شده؛ پنجره رو از نو شروع کن.
cache.set(phone_attempts_key, 1, timeout=settings.OTP_LOGIN_PHONE_WINDOW_SECONDS)
attempt_count = 1
if attempt_count > settings.OTP_LOGIN_PHONE_MAX_ATTEMPTS:
# ⚠️ افزوده شده: لاگ هشدارِ امنیتی برای رصد/آلارمِ brute-force روی
# این endpoint. هیچ سکرتی (OTP، access/refresh token) لاگ نمی‌شه؛
# شماره‌ی تلفن هم کامل نوشته نمی‌شه، فقط ۴ رقم آخرش (برای
# correlation در تحلیل/پشتیبانی، بدون افشای کامل PII).
logger.warning(
'OTP login phone attempt limit exceeded: phone_suffix=%s attempt_count=%s max_attempts=%s',
phone[-4:], attempt_count, settings.OTP_LOGIN_PHONE_MAX_ATTEMPTS,
)
return Response(
{'detail': 'تعداد تلاش‌های ورود برای این شماره بیش از حد مجاز است.'},
status=status.HTTP_429_TOO_MANY_REQUESTS,
)
# قفل ردیف OTP جلوی مصرف دوباره‌ی یک کد یک‌بارمصرف را در درخواست‌های هم‌زمان می‌گیرد.
with transaction.atomic():
valid = False
if settings.DEV_MASTER_OTP is not None and otp == settings.DEV_MASTER_OTP:
valid = True
else:
otp_obj = OTPCode.objects.select_for_update().filter(
phone=phone, code=otp, is_used=False, expires_at__gte=timezone.now()
).order_by('-id').first()
if otp_obj:
otp_obj.is_used = True
otp_obj.save(update_fields=['is_used'])
valid = True
if not valid:
return Response({'detail': 'کد OTP نامعتبر یا منقضی‌شده است.'}, status=status.HTTP_400_BAD_REQUEST)
user, created = User.objects.get_or_create(
error = verify_phone_otp(phone, otp)
if error is not None:
record_login_event(
request,
LoginEvent.LOGIN_BLOCKED if error.status_code == status.HTTP_429_TOO_MANY_REQUESTS
else LoginEvent.LOGIN_FAILED,
phone=phone,
defaults={'is_guest': False, 'name': ''},
)
if user.is_guest:
user.is_guest = False
user.save(update_fields=['is_guest'])
return error
with transaction.atomic():
user, created = User.objects.get_or_create(phone=phone, defaults={'name': ''})
# ثبتِ فعالیتِ کاربر برای ورودِ موفق.
user.last_activity_at = timezone.now()
@@ -283,10 +316,10 @@ class LoginView(APIView):
refresh_token = uuid.uuid4().hex
Session.objects.create(
user=user,
refresh_token=refresh_token,
device_id=request.data.get('deviceId', ''),
refresh_token=hash_refresh_token(refresh_token),
expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
)
record_login_event(request, LoginEvent.LOGIN_OK, user_id=user.id, phone=phone)
access_token = issue_access_token(user.id)
data = {
@@ -297,6 +330,103 @@ class LoginView(APIView):
return Response(LoginResponseSerializer(data).data)
def _count_failed_attempt(phone: str) -> None:
"""حدسِ غلط روی کدهای فعالِ این شماره؛ کدی که به سقف برسد باطل می‌شود."""
active = OTPCode.objects.filter(phone=phone, is_used=False, expires_at__gte=timezone.now())
active.update(failed_attempts=F('failed_attempts') + 1)
active.filter(failed_attempts__gte=settings.OTP_MAX_FAILED_ATTEMPTS).update(is_used=True)
# سوابقِ پرداخت سندِ مالی‌اند و با حذفِ حساب نمی‌روند (تراکنش‌ها با سفارش می‌مانند).
# Conversion هم می‌ماند: ردیفِ «کاربرِ معرفی‌شده» سابقه‌ی پورسانتِ *معرف* است، نه داده‌ی این
# کاربر؛ تا ۲۰۲۶-۱۰-۰۳ با حذفِ حسابِ معرفی‌شده پورسانتِ معرف هم پاک می‌شد.
# Redemption و UserOffer هم می‌مانند: سقفِ مصرفِ کدِ تخفیف ردیف‌های Redemption را می‌شمارد، پس پاک
# شدنشان یک کدِ یک‌بارمصرف را با «بساز، بخر، حذف کن» دوباره آزاد می‌کرد.
KEPT_ON_ACCOUNT_DELETE = ('payment.Order', 'statuslog.StatusChangeLog', 'affiliate.Conversion',
'discount.Redemption', 'discount.UserOffer', 'accounts.LoginEvent')
class DeleteAccountView(APIView):
"""
POST /api/user/delete — حذفِ همیشگیِ حسابِ خودِ کاربر و همه‌ی داده‌اش.
چرا این‌طور (۲۰۲۶-۱۰-۰۲):
- کاربرِ شماره‌دار باید کدِ پیامکیِ تازه بدهد (همان verify_phone_otpِ ورود، با همان سقفِ حدس).
توکنِ access به‌تنهایی کافی نیست: گوشیِ بازمانده دستِ یک بچه‌ی دیگر یا توکنِ دزدیده‌شده نباید
بتواند حساب را برای همیشه پاک کند.
- داده در همه‌ی اپ‌ها پاک می‌شود (delete_users)، جز سفارش‌ها و تراکنش‌هایشان که سندِ مالی‌اند.
- حساب‌های دمو (TEST_LOGIN_PHONES) حذف نمی‌شوند؛ مشترک‌اند و برای ارائه پُرشده‌اند.
- بعد از حذف، توکن‌های همین کاربر کار نمی‌کنند چون کاربری با آن شناسه نمانده.
"""
permission_classes = [IsAuthenticated]
throttle_scope = 'account_delete'
throttle_classes = [ScopedRateThrottle]
@extend_schema(
request=DeleteAccountRequestSerializer,
responses=DeleteAccountResponseSerializer,
tags=['Identity & Profile (accounts)'],
)
def post(self, request):
from .management.commands.delete_users import delete_users
serializer = DeleteAccountRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
user = request.user
if user.phone in settings.TEST_LOGIN_PHONES:
return Response({'detail': 'حسابِ نمایشی را نمی‌شود حذف کرد.'}, status=status.HTTP_403_FORBIDDEN)
error = verify_phone_otp(user.phone, v['otp'])
if error is not None:
return error
logger.info('Account deleted by its owner: user_id=%s', user.id)
record_login_event(request, LoginEvent.ACCOUNT_DELETED, user_id=user.id, phone=user.phone or '')
# کدهای پیامکیِ شماره می‌مانند: سقفِ روزانه‌ی پیامکِ هر شماره از همین ردیف‌ها شمرده می‌شود.
delete_users([user.id], keep=KEPT_ON_ACCOUNT_DELETE, delete_otp_codes=False)
return Response(DeleteAccountResponseSerializer({'success': True}).data)
class LogoutView(APIView):
"""
POST /api/auth/logout — خروج از حساب.
refreshTokenِ این دستگاه (اگر فرستاده شود) باطل می‌شود و access tokenهای
صادرشده *پیش از ثانیه‌ی خروج* رد می‌شوند. دستگاه‌های دیگر با refreshِ
خودشان بی‌صدا توکنِ تازه می‌گیرند؛ فقط همین دستگاه بیرون می‌ماند.
⚠️ مقایسه ثانیه‌ای است: توکنی که در همان ثانیه‌ی خروج صادر شده معتبر
می‌ماند (بازتولید شد). با AUTH_USER_CACHE_TTL>0 و کشِ per-worker، کشِ
کاربر فقط در همین worker پاک می‌شود؛ پیش‌فرض ۰ است و آن حالت تست نشده.
"""
# سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `user_profile_write` همان نرخِ مسیرهای هم‌خانواده است.
throttle_scope = 'user_profile_write'
throttle_classes = [ScopedRateThrottle]
permission_classes = [IsAuthenticated]
@extend_schema(
request=LogoutRequestSerializer, responses=LogoutResponseSerializer,
tags=['Identity & Profile (accounts)'],
)
def post(self, request):
serializer = LogoutRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
refresh_token = serializer.validated_data.get('refreshToken') or ''
with transaction.atomic():
if refresh_token:
Session.objects.filter(
user_id=request.user.id, refresh_token=hash_refresh_token(refresh_token),
).update(revoked=True)
User.objects.filter(pk=request.user.id).update(tokens_valid_after=timezone.now())
invalidate_user_cache(request.user.id)
record_login_event(request, LoginEvent.LOGOUT, user_id=request.user.id, phone=request.user.phone or '')
return Response(LogoutResponseSerializer({'success': True}).data)
class RefreshTokenView(APIView):
"""POST /api/auth/refresh — تمدید توکن"""
@@ -322,13 +452,13 @@ class RefreshTokenView(APIView):
# UPDATE اتمیک جلوی استفاده‌ی هم‌زمان از یک refresh token را می‌گیرد (replay protection).
with transaction.atomic():
updated_count = Session.objects.filter(
refresh_token=refresh_token, revoked=False, expires_at__gte=timezone.now()
refresh_token=hash_refresh_token(refresh_token), revoked=False, expires_at__gte=timezone.now()
).update(revoked=True)
if not updated_count:
return Response({'detail': 'رفرش‌توکن نامعتبر یا منقضی‌شده است.'}, status=status.HTTP_401_UNAUTHORIZED)
old_session = Session.objects.get(refresh_token=refresh_token)
old_session = Session.objects.get(refresh_token=hash_refresh_token(refresh_token))
# ثبتِ فعالیتِ کاربر برای رفرشِ موفقِ توکن.
User.objects.filter(pk=old_session.user_id).update(last_activity_at=timezone.now())
@@ -336,8 +466,7 @@ class RefreshTokenView(APIView):
new_refresh_token = secrets.token_hex(32)
new_session = Session.objects.create(
user_id=old_session.user_id,
refresh_token=new_refresh_token,
device_id=old_session.device_id,
refresh_token=hash_refresh_token(new_refresh_token),
expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
)
@@ -354,12 +483,10 @@ class SetUserGradeView(APIView):
"""POST /api/user/grade — ثبت پایه‌ی کاربر (نیاز به احراز هویت)"""
permission_classes = [IsAuthenticated]
# ⚠️ اضافه شده: این endpoint قبلاً هیچ throttleای نداشت؛ یک کاربرِ
# (از جمله مهمان) احرازهویت‌شده می‌تونست تعدادِ نامحدودی درخواستِ
# نوشتنِ دیتابیس بفرسته. طبقِ همون الگویِ سایرِ اپ‌ها، یک
# throttle_scope مجزا با GuestAwareAccountThrottle اضافه شد.
# بدونِ throttle، یک کاربرِ احرازهویت‌شده می‌تواند
# نامحدود درخواستِ نوشتن روی دیتابیس بفرستد.
throttle_scope = 'user_profile_write'
throttle_classes = [GuestAwareAccountThrottle]
throttle_classes = [ScopedRateThrottle]
@extend_schema(
request=SetGradeRequestSerializer,
@@ -379,6 +506,22 @@ class SetUserGradeView(APIView):
return Response(SetGradeResponseSerializer(data).data)
def _profile_payload(user, profile) -> dict:
from . import jalali, public_id
return {
'userId': str(user.id),
'publicId': public_id.encode(user.id),
'phone': user.phone,
'name': user.name,
'avatar': user.avatar,
'gradeId': user.grade_id,
'birthYear': profile.birth_year,
'birthDate': jalali.to_string(profile.birth_date) if profile.birth_date else None,
'bio': profile.bio,
}
class UserProfileView(APIView):
"""
GET /api/user/profile — اطلاعات پروفایل کاربر جاری
@@ -392,7 +535,7 @@ class UserProfileView(APIView):
permission_classes = [IsAuthenticated]
# نوشتن است، پس همان throttleِ نوشتنِ پروفایل که SetUserGradeView دارد.
throttle_scope = 'user_profile_write'
throttle_classes = [GuestAwareAccountThrottle]
throttle_classes = [ScopedRateThrottle]
def get_throttles(self):
# GET خواندنی است و نباید سهمیه‌ی نوشتن را مصرف کند.
@@ -407,23 +550,12 @@ class UserProfileView(APIView):
def get(self, request):
user = request.user
# مدل Profile به‌صورت lazy ساخته می‌شه چون هیچ‌جای دیگه‌ای (نه
# GuestSessionView، نه LoginView) رکورد Profile رو از قبل نمی‌سازه؛
# LoginView) رکورد Profile رو از قبل نمی‌سازه؛
# get_or_create این‌جا امن است چون فقط توسطِ کاربرِ همون رکورد صدا
# زده می‌شه (OneToOne روی user_id جاری، نه ورودیِ خارجی).
profile, _ = Profile.objects.get_or_create(user_id=user.id)
data = {
'userId': str(user.id),
'phone': user.phone,
'name': user.name,
'avatar': user.avatar,
'isGuest': user.is_guest,
'gradeId': user.grade_id,
'birthYear': profile.birth_year,
'parentPhone': profile.parent_phone,
'bio': profile.bio,
}
return Response(ProfileResponseSerializer(data).data)
return Response(ProfileResponseSerializer(_profile_payload(user, profile)).data)
@extend_schema(
request=UpdateProfileRequestSerializer,
@@ -439,7 +571,7 @@ class UserProfileView(APIView):
profile, _ = Profile.objects.get_or_create(user_id=user.id)
user_fields = {'name': 'name', 'avatar': 'avatar'}
profile_fields = {'birthYear': 'birth_year', 'parentPhone': 'parent_phone', 'bio': 'bio'}
profile_fields = {'birthYear': 'birth_year', 'birthDate': 'birth_date', 'bio': 'bio'}
changed_user = [attr for key, attr in user_fields.items() if key in v]
changed_profile = [attr for key, attr in profile_fields.items() if key in v]
@@ -450,6 +582,17 @@ class UserProfileView(APIView):
for key, attr in profile_fields.items():
if key in v:
setattr(profile, attr, v[key])
# سال و تاریخ نباید از هم جدا بیفتند: تاریخ سال را می‌سازد، و سالِ تنها
# (اپِ قدیمی) تاریخی را که دیگر با آن نمی‌خواند پاک می‌کند.
if 'birthDate' in v:
from . import jalali
profile.birth_year = jalali.from_gregorian(v['birthDate'])[0] if v['birthDate'] else None
changed_profile.append('birth_year')
elif 'birthYear' in v and profile.birth_date is not None:
from . import jalali
if jalali.from_gregorian(profile.birth_date)[0] != v['birthYear']:
profile.birth_date = None
changed_profile.append('birth_date')
# update_fields: فقط همان ستون‌هایی که واقعاً عوض شده‌اند نوشته شوند تا
# PATCH همزمان از یک دستگاهِ دیگر، فیلدهای دست‌نخورده را بازنویسی نکند.
@@ -458,15 +601,4 @@ class UserProfileView(APIView):
if changed_profile:
profile.save(update_fields=changed_profile)
data = {
'userId': str(user.id),
'phone': user.phone,
'name': user.name,
'avatar': user.avatar,
'isGuest': user.is_guest,
'gradeId': user.grade_id,
'birthYear': profile.birth_year,
'parentPhone': profile.parent_phone,
'bio': profile.bio,
}
return Response(ProfileResponseSerializer(data).data)
return Response(ProfileResponseSerializer(_profile_payload(user, profile)).data)

View File

@@ -4,6 +4,9 @@ from rest_framework import serializers
class AffiliateMeResponseSerializer(serializers.Serializer):
referralCode = serializers.CharField()
commissionRate = serializers.FloatField()
referredCount = serializers.IntegerField(help_text='کاربرهایی که کدِ شما را ثبت کرده‌اند.')
paidCount = serializers.IntegerField(help_text='از میانِ آن‌ها، کسانی که واقعاً پرداخت کرده‌اند.')
totalCommission = serializers.IntegerField(help_text='جمعِ پورسانتِ پرداخت‌های واقعی (تومان).')
class TrackRequestSerializer(serializers.Serializer):

47
affiliate/services.py Normal file
View File

@@ -0,0 +1,47 @@
"""
پورسانتِ معرف: فقط روی پرداختِ واقعی، فقط یک بار برای هر کاربرِ معرفی‌شده.
تا ۲۰۲۶-۰۹-۲۵ هیچ کدی Conversion را از «pending» جلو نمی‌برد و پورسانتی
حساب نمی‌شد؛ برنامه‌ی معرفی فقط یک جدولِ ثبت بود. حالا اولین سفارشِ
پرداخت‌شده‌ی کاربرِ معرفی‌شده *بعد از ثبتِ کد* Conversion را `paid` می‌کند و
`commission_amount = مبلغِ پرداختی × commission_rate` را ثبت می‌کند.
⚠️ «کاربرِ تازه» بررسی نمی‌شود: مشتریِ قدیمی هم هر وقت بخواهد کد ثبت می‌کند
و تمدیدِ بعدی‌اش پورسانت می‌سازد (بازبینیِ ۲۰۲۶-۰۹-۲۵، بازتولید شد). جلوی
معرفیِ خود با شماره‌ی دوم هم چیزی نیست.
پرداختِ خودِ پورسانت به معرف (تسویه) هنوز دستی و از پنلِ ادمین است.
ثبت‌نام یا ورودِ مهمان هیچ پورسانتی نمی‌سازد؛ فقط پول جابه‌جا شدنِ واقعی.
"""
import logging
from django.db import transaction
from .models import Conversion
logger = logging.getLogger(__name__)
def record_paid_order(order) -> Conversion | None:
"""از apply_order_payment_result صدا زده می‌شود، داخلِ همان تراکنش."""
if not order.amount:
return None
try:
with transaction.atomic():
conversion = (
Conversion.objects.select_for_update()
.select_related('affiliate_account')
.filter(referred_user_id=order.user_id, status='pending')
.first()
)
if conversion is None:
return None
rate = conversion.affiliate_account.commission_rate or 0
conversion.status = 'paid'
conversion.order_id = order.id
conversion.commission_amount = int(order.amount * rate)
conversion.save(update_fields=['status', 'order_id', 'commission_amount'])
return conversion
except Exception: # noqa: BLE001 — پورسانت نباید تاییدِ پرداختِ خودِ کاربر را خراب کند
logger.exception('affiliate: ثبتِ پورسانتِ سفارش %s شکست خورد', order.id)
return None

View File

@@ -1,6 +1,6 @@
from django.shortcuts import get_object_or_404
from drf_spectacular.utils import extend_schema
from rest_framework.exceptions import ValidationError
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
@@ -15,42 +15,26 @@ from .serializers import (
)
class GuestAwareAffiliateThrottle(ScopedRateThrottle):
"""
⚠️ اضافه شده: همون الگویِ GuestAwareDiscountThrottle/GuestAwareFamilyThrottle
و بقیه؛ چرخشِ حسابِ مهمان (deviceId جدید → pk جدید) یک ScopedRateThrottleِ
سادهٔ per-user رو دور می‌زد. اینجا حساس‌تره چون referralCode یک
شناسه‌ی حدس‌زدنی‌ست (404 برای کدِ نامعتبر = oracle) و یک حدسِ درست
یک Conversion واقعی می‌سازه که به کمیسیونِ مالیِ affiliate صاحبِ کد
منجر می‌شه؛ یعنی بدونِ این throttle، چرخشِ حساب مهمان به مهاجم
guessهای عملاً نامحدودی برای enumerate/fraud کردنِ کدهای رفرال
می‌داد. برای کاربرهای مهمان کلیدِ cache از pk به IP کلاینت تغییر
می‌کنه؛ رفتارِ throttle برای کاربرهای غیرمهمان (per-user، مثلِ
قبل) دست‌نخورده می‌مونه.
"""
def get_cache_key(self, request, view):
user = request.user
if user and user.is_authenticated and getattr(user, 'is_guest', False):
ident = f'guest-ip:{self.get_ident(request)}'
elif user and user.is_authenticated:
ident = user.pk
else:
ident = self.get_ident(request)
return self.cache_format % {'scope': self.scope, 'ident': ident}
class AffiliateMeView(APIView):
"""GET /api/affiliate/me — حساب و کد ارجاع کاربر"""
# سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `read` همان نرخِ مسیرهای هم‌خانواده است.
throttle_scope = 'read'
throttle_classes = [ScopedRateThrottle]
permission_classes = [IsAuthenticated]
@extend_schema(responses=AffiliateMeResponseSerializer, tags=['Affiliate (affiliate)'])
def get(self, request):
account, _ = AffiliateAccount.objects.get_or_create(user_id=request.user.id)
data = {'referralCode': account.referral_code, 'commissionRate': account.commission_rate}
paid = account.conversions.filter(status='paid')
data = {
'referralCode': account.referral_code,
'commissionRate': account.commission_rate,
'referredCount': account.conversions.count(),
'paidCount': paid.count(),
'totalCommission': sum(paid.values_list('commission_amount', flat=True)),
}
return Response(AffiliateMeResponseSerializer(data).data)
@@ -71,10 +55,9 @@ class TrackConversionView(APIView):
permission_classes = [IsAuthenticated]
# referralCode نامعتبر ۴۰۴ می‌گیره (یک oracle قابل‌probe)، پس throttle
# مجزا هم probe رو کند می‌کنه هم (با GuestAwareAffiliateThrottle) جلویِ
# bucket تازه از چرخشِ حساب مهمان رو می‌گیره.
# مجزا probe رو کند می‌کنه.
throttle_scope = 'affiliate_track'
throttle_classes = [GuestAwareAffiliateThrottle]
throttle_classes = [ScopedRateThrottle]
@extend_schema(
request=TrackRequestSerializer, responses=TrackResponseSerializer, tags=['Affiliate (affiliate)']
@@ -83,7 +66,6 @@ class TrackConversionView(APIView):
serializer = TrackRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
account = get_object_or_404(AffiliateAccount, referral_code=v['referralCode'])
referred_user_id = request.user.id
@@ -102,6 +84,10 @@ class TrackConversionView(APIView):
class ConversionsListView(APIView):
"""GET /api/affiliate/conversions — لیست تبدیل‌ها و کمیسیون"""
# سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `read` همان نرخِ مسیرهای هم‌خانواده است.
throttle_scope = 'read'
throttle_classes = [ScopedRateThrottle]
permission_classes = [IsAuthenticated]
@extend_schema(responses=ConversionListResponseSerializer, tags=['Affiliate (affiliate)'])

76
ai_tutor/concurrency.py Normal file
View File

@@ -0,0 +1,76 @@
"""
سقفِ جواب‌های هم‌زمانِ دستیار در هر worker (AI_MAX_CONCURRENT_PER_WORKER).
جوابِ دستیار یک نخِ gunicorn را تا پایانِ کار (۳ تا ۴۰ ثانیه، منتظرِ Qdrant و مدل) نگه می‌دارد. بدونِ
سقف، اگر چند ده دانش‌آموز با هم سوال بپرسند همه‌ی نخ‌های worker گرفته می‌شوند و نقشه، آزمون و ثبتِ
جواب (که هرکدام چند میلی‌ثانیه‌اند) پشتِ آن‌ها صف می‌کشند: یک بخشِ کند کلِ اپ را می‌خواباند. این همان
الگوی bulkhead است: هر بخش سهمِ جدای خودش را دارد. درخواستی که جا نگیرد فوراً ۵۰۳ می‌گیرد.
سمافور مالِ همین پروسه است (هر worker جدا). هم‌زمانیِ واقعی زیرِ بارِ gunicorn با مدلِ واقعی آزموده نشده؛
تست‌ها رفتارِ گرفتن/پس‌دادن را در مسیرِ JSON و استریم نگه می‌دارند (ai_tutor/tests_concurrency.py).
"""
import threading
from django.conf import settings
_lock = threading.Lock()
_in_flight = 0
def limit() -> int:
return settings.AI_MAX_CONCURRENT_PER_WORKER
def try_acquire() -> bool:
"""بی‌انتظار: یا جا هست و گرفته می‌شود، یا False. انتظار نخ را همان‌طور نگه می‌داشت که می‌خواهیم نشود."""
global _in_flight
with _lock:
if _in_flight >= limit():
return False
_in_flight += 1
return True
def release() -> None:
global _in_flight
with _lock:
if _in_flight <= 0:
# پس‌دادنِ بی‌گرفتن یعنی باگ در جفت‌شدنِ گرفتن/پس‌دادن؛ شمارنده منفی نمی‌شود تا سقف بی‌اثر نشود.
raise RuntimeError('ai concurrency slot released more times than acquired')
_in_flight -= 1
def in_flight() -> int:
return _in_flight
class ReleaseWhenDone:
"""
محتوای استریم را می‌پیچد تا جا وقتی پس داده شود که استریم واقعاً تمام شد: یا با پایانِ خواندن،
یا با close() که جنگو هنگامِ بستنِ پاسخ صدا می‌زند (کاربر وسطِ جواب صفحه را بست، یا پاسخ اصلاً
خوانده نشد). generatorی که شروع نشده با close() بلوکِ finallyاش را اجرا نمی‌کند؛ برای همین
پس‌دادن این‌جاست، نه داخلِ generator.
"""
def __init__(self, iterable):
self._iterable = iterable
self._released = False
def _release_once(self):
if not self._released:
self._released = True
release()
def __iter__(self):
try:
yield from self._iterable
finally:
self._release_once()
def close(self):
try:
close = getattr(self._iterable, 'close', None)
if close is not None:
close()
finally:
self._release_once()

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.17 on 2026-09-28 12:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai_tutor', '0005_message_chunk_count_message_completion_tokens_and_more'),
]
operations = [
migrations.AddField(
model_name='message',
name='diagram',
field=models.JSONField(blank=True, null=True),
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 5.2.17 on 2026-10-02 07:34
from django.db import migrations, models
def mark_existing_as_free(apps, schema_editor):
# پیام‌های پیش از بسته‌ها همه از سهمیه‌ی روزانه بودند؛ بدونِ این، سوال‌های امروزِ پیش از
# دیپلو در شمارشِ سهمیه نمی‌آمدند.
apps.get_model('ai_tutor', 'Message').objects.filter(role='user').update(charge='free')
class Migration(migrations.Migration):
dependencies = [
('ai_tutor', '0006_message_diagram'),
]
operations = [
migrations.AddField(
model_name='message',
name='charge',
field=models.CharField(blank=True, choices=[('free', 'رایگانِ روزانه'), ('credit', 'اعتبارِ بسته'), ('none', 'بی\u200cهزینه')], default='', max_length=6),
),
migrations.RunPython(mark_existing_as_free, migrations.RunPython.noop),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.17 on 2026-10-11 16:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai_tutor', '0007_message_charge'),
]
operations = [
migrations.AlterField(
model_name='message',
name='created_at',
field=models.DateTimeField(auto_now_add=True, db_index=True),
),
]

View File

@@ -25,7 +25,14 @@ class Message(models.Model):
role = models.CharField(max_length=10, choices=ROLE_CHOICES)
text = models.TextField()
question_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Question
created_at = models.DateTimeField(auto_now_add=True)
# فقط برای پیامِ کاربر: این سوال از کجا پرداخت شد. «free» از سهمیه‌ی روزانه، «none» هیچ (رد در صافیِ
# سرور یا جوابِ کش‌شده که به API نرسید). سهمیه‌ی امروز = تعدادِ «free»های امروز. «credit» (اعتبارِ بسته‌ی
# سوال) فقط روی پیام‌های پیش از subscription.0017 است؛ دیگر نوشته نمی‌شود ولی ردِ تاریخیِ آن پیام‌هاست.
CHARGE_CHOICES = [('free', 'رایگانِ روزانه'), ('credit', 'اعتبارِ بسته'), ('none', 'بی‌هزینه')]
charge = models.CharField(max_length=6, choices=CHARGE_CHOICES, blank=True, default='')
# ایندکس: سقفِ روزانه‌ی کلِ سامانه (_check_global_daily_cap) در هر سوال پیام‌های امروزِ همه را
# می‌شمارد؛ این سریع‌ترین جدولِ در حالِ رشد است.
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
# --- حاشیه‌نویسیِ تولید (فقط برای پیام‌های role='assistant') --------------
# سینیور خواست لایک/دیسلایک بعداً قابلِ تحلیل باشد: «کدام جواب بهتر بوده».
@@ -43,6 +50,8 @@ class Message(models.Model):
completion_tokens = models.PositiveIntegerField(default=0)
latency_ms = models.PositiveIntegerField(default=0)
from_cache = models.BooleanField(default=False)
# شکلِ پاسخ (rag.services.diagram)؛ فقط توصیفِ JSONِ اعتبارسنجی‌شده، نه SVG.
diagram = models.JSONField(null=True, blank=True)
class Meta:
ordering = ['created_at']

View File

@@ -0,0 +1,65 @@
"""
وضعیتِ provider هوش مصنوعی (GapGPT): «کار می‌کند» یا «اعتبار تمام شده».
چرا لازم است: ۲۰۲۶-۰۹-۲۵ اعتبارِ GapGPT تمام شد و دستیار برای همه‌ی کاربرها
۵۰۳ داد («دستیار الان در دسترس نیست»)، در حالی که /api/health همچنان
`openai: true` نشان می‌داد — چون فقط «کلید تنظیم شده» را می‌دید. تنها راهِ
فهمیدنش خواندنِ لاگِ سرور بود.
حالا هر گذار (ok → quota_exhausted و برعکس) یک ردیف در statuslog می‌شود:
همه‌ی workerها آن را می‌بینند (کشِ LocMem این‌طور نیست) و /api/health
گزارشش می‌کند. فقط گذار ثبت می‌شود، نه هر درخواست.
"""
import logging
from django.core.cache import cache
logger = logging.getLogger(__name__)
LABEL = 'ai_tutor.Provider'
OK = 'ok'
EXHAUSTED = 'quota_exhausted'
_CACHE_KEY = 'ai_tutor:provider_state'
def current_state() -> str:
state = cache.get(_CACHE_KEY)
if state is None:
from statuslog.models import StatusChangeLog
state = (
StatusChangeLog.objects.filter(label=LABEL)
.values_list('new_status', flat=True).first()
) or OK
cache.set(_CACHE_KEY, state, 60)
return state
def _transition(new_state: str, event: str) -> None:
from statuslog.signals import status_changed
old_state = current_state()
if old_state == new_state:
return
status_changed.send(sender=LABEL, object_id=0, old_status=old_state,
new_status=new_state, event=event)
cache.set(_CACHE_KEY, new_state, 60)
def record_failure(exc) -> None:
"""خطای provider؛ فقط تمام‌شدنِ اعتبار/سقفِ حساب وضعیت را عوض می‌کند."""
from rag.services.clients import is_account_limit
try:
if is_account_limit(exc):
_transition(EXHAUSTED, 'provider_account_limit')
except Exception: # noqa: BLE001 — گزارش‌دهی نباید پاسخِ ۵۰۳ را خراب کند
logger.exception('provider_status: ثبتِ خطا شکست خورد')
def record_success() -> None:
try:
if current_state() != OK:
_transition(OK, 'provider_recovered')
except Exception: # noqa: BLE001
logger.exception('provider_status: ثبتِ بازگشت شکست خورد')

View File

@@ -18,7 +18,9 @@ class AskRequestSerializer(serializers.Serializer):
# RAG_GRADE_TITLE_OVERRIDES ساخته می‌شه.
grade = serializers.CharField(required=False, allow_null=True, allow_blank=True)
lessonNumber = serializers.IntegerField(required=False, allow_null=True)
userQuestion = serializers.CharField()
# سقفِ طول: کلِ متن به مدلِ پولی می‌رود؛ بی‌سقف، یک درخواستِ صدهزار نویسه‌ای
# به‌تنهایی هزینه‌ی صدها سوالِ عادی را داشت.
userQuestion = serializers.CharField(max_length=2000)
stream = serializers.BooleanField(required=False, default=False)
@@ -32,7 +34,8 @@ class AskResponseSerializer(serializers.Serializer):
messageId = serializers.CharField()
answer = serializers.CharField()
suggestedFollowUps = serializers.ListField(child=serializers.CharField())
remainingQuota = serializers.IntegerField()
remainingQuota = serializers.IntegerField(help_text='سوال‌های رایگانِ باقیِ امروز.')
diagram = serializers.DictField(required=False, allow_null=True)
# تکه‌های درسیِ بازیابی‌شده از Qdrant که پاسخ بر پایه‌شون ساخته شده؛
# خالی یعنی retrieval انجام نشده (نبودِ subject/lessonNumber یا خاموش‌بودنِ RAG).
usedChunks = UsedChunkSerializer(many=True, required=False, default=list)
@@ -44,6 +47,8 @@ class MessageSerializer(serializers.Serializer):
messageId = serializers.CharField()
role = serializers.CharField()
text = serializers.CharField()
# شکلِ پاسخ؛ شِمایش در rag/services/diagram.py (plane / numberline / fraction).
diagram = serializers.DictField(required=False, allow_null=True)
class ConversationMessagesResponseSerializer(serializers.Serializer):
@@ -61,3 +66,8 @@ class FeedbackRequestSerializer(serializers.Serializer):
class FeedbackResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()
class QuotaResponseSerializer(serializers.Serializer):
freeDaily = serializers.IntegerField(help_text='سوال‌های رایگانِ هر روز برای این کاربر.')
remainingQuota = serializers.IntegerField(help_text='سوال‌های رایگانِ باقیِ امروز.')

View File

@@ -1,7 +1,7 @@
from unittest.mock import patch
from django.core.cache import cache
from django.test import TestCase
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from rest_framework.throttling import ScopedRateThrottle
@@ -29,7 +29,7 @@ class AskIdempotencyFollowUpsTests(TestCase):
# بینِ تست‌ها پاک نمی‌شه) می‌تونه از یک تستِ قبلی باقی بمونه.
cache.clear()
self.client = APIClient()
self.user = User.objects.create(device_id='device-ask-1', is_guest=True, name='')
self.user = User.objects.create(phone='09000000021', name='')
self.token = issue_access_token(self.user.id)
def _ask(self, idempotency_key):
@@ -65,7 +65,7 @@ class AskIdempotencyFollowUpsTests(TestCase):
self.assertEqual(retry_response.status_code, 200)
self.assertEqual(retry_response.data['suggestedFollowUps'], original_follow_ups)
self.assertEqual(retry_response.data['answer'], first_response.data['answer'])
self.assertEqual(retry_response.json()['answer'], first_response.json()['answer'])
self.assertEqual(retry_response.data['messageId'], first_response.data['messageId'])
@@ -83,7 +83,7 @@ class AskAIGenerationFailureTests(TestCase):
# ⚠️ افزوده شده: نگاه کنید به توضیحِ مشابه در AskIdempotencyFollowUpsTests.setUp
cache.clear()
self.client = APIClient()
self.user = User.objects.create(device_id='device-ask-fail-1', is_guest=True, name='')
self.user = User.objects.create(phone='09000000022', name='')
self.token = issue_access_token(self.user.id)
def _ask(self, idempotency_key=None):
@@ -111,9 +111,11 @@ class AskAIGenerationFailureTests(TestCase):
def test_generation_failure_does_not_consume_quota_slot(self):
# یک سوالِ ناموفق می‌فرستیم (که نباید کوتا مصرف کنه)، بعد دقیقاً
# DAILY_AI_QUOTA سوالِ موفق پشتِ سرِ هم می‌فرستیم؛ همه باید ۲۰۰
# به اندازه‌ی سقفِ روزانه سوالِ موفق پشتِ سرِ هم می‌فرستیم؛ همه باید ۲۰۰
# بگیرن، یعنی سوالِ ناموفقِ اول اسلاتی از کوتا مصرف نکرده.
from .views import DAILY_AI_QUOTA
from .views import daily_quota
DAILY_AI_QUOTA = daily_quota(self.user.id)
with patch(
'ai_tutor.views.generate_tutor_answer',
@@ -126,7 +128,7 @@ class AskAIGenerationFailureTests(TestCase):
ok_response = self._ask()
self.assertEqual(ok_response.status_code, 200)
# این سوالِ اضافه باید رد بشه چون سقفِ ۱۵ (نه ۱۴) واقعاً پر شده.
# این سوالِ اضافه باید رد بشه چون سقف (نه یکی کمتر) واقعاً پر شده.
throttled_response = self._ask()
self.assertEqual(throttled_response.status_code, 429)
@@ -175,7 +177,7 @@ class AskAIConcurrentIdempotencyKeyTests(TestCase):
# ⚠️ افزوده شده: نگاه کنید به توضیحِ مشابه در AskIdempotencyFollowUpsTests.setUp
cache.clear()
self.client = APIClient()
self.user = User.objects.create(device_id='device-ask-race-1', is_guest=True, name='')
self.user = User.objects.create(phone='09000000023', name='')
self.token = issue_access_token(self.user.id)
def test_losing_request_cleans_up_and_returns_winners_result(self):
@@ -223,7 +225,7 @@ class AskAIConcurrentIdempotencyKeyTests(TestCase):
# هر دو پاسخ باید دقیقاً به همون AskIdempotency (نتیجه‌ی برنده)
# اشاره کنن.
self.assertEqual(outer_response.data['messageId'], inner_response.data['messageId'])
self.assertEqual(outer_response.data['answer'], inner_response.data['answer'])
self.assertEqual(outer_response.json()['answer'], inner_response.json()['answer'])
# فقط یک رکوردِ AskIdempotency برای این کلید باید وجود داشته باشه.
self.assertEqual(
@@ -252,9 +254,9 @@ class AskAIConversationApiContractTests(TestCase):
# ⚠️ افزوده شده: نگاه کنید به توضیحِ مشابه در AskIdempotencyFollowUpsTests.setUp
cache.clear()
self.client = APIClient()
self.user = User.objects.create(device_id='device-contract-1', is_guest=True, name='')
self.user = User.objects.create(phone='09000000024', name='')
self.token = issue_access_token(self.user.id)
self.other_user = User.objects.create(device_id='device-contract-2', is_guest=True, name='')
self.other_user = User.objects.create(phone='09000000025', name='')
self.other_token = issue_access_token(self.other_user.id)
def test_no_conversation_id_creates_new_conversation(self):
@@ -346,7 +348,7 @@ class GenerationBoundaryContextTests(TestCase):
# فرستاده شدن به مرزِ تولید برسن — این metadataی لازم برای
# RAG/Qdrantِ آینده‌ست، نه تاریخچه.
client = APIClient()
user = User.objects.create(device_id='device-boundary-grade-1', is_guest=True, name='')
user = User.objects.create(phone='09000000026', name='')
token = issue_access_token(user.id)
with patch(
@@ -374,7 +376,7 @@ class GenerationBoundaryContextTests(TestCase):
# پیاده نشده، context باید None بمونه (فقط gradeId/lessonId/سوال
# مجازن).
client = APIClient()
user = User.objects.create(device_id='device-boundary-1', is_guest=True, name='')
user = User.objects.create(phone='09000000027', name='')
token = issue_access_token(user.id)
conversation = Conversation.objects.create(user_id=user.id)
@@ -430,7 +432,7 @@ class AskAIViewThrottleTests(TestCase):
def setUp(self):
self.client = APIClient()
cache.clear()
self.user = User.objects.create(device_id='device-ai-throttle-1', is_guest=True, name='')
self.user = User.objects.create(phone='09000000028', name='')
self.token = issue_access_token(self.user.id)
def _ask(self, idempotency_key):
@@ -442,6 +444,8 @@ class AskAIViewThrottleTests(TestCase):
HTTP_IDEMPOTENCY_KEY=idempotency_key,
)
# سقفِ روزانه (پیش‌فرض ۲) نباید پیش از throttle برسد؛ این تست فقط throttle را می‌سنجد.
@override_settings(AI_DAILY_QUOTA_FREE=10)
def test_requests_within_rate_succeed_and_excess_is_throttled(self):
# هر درخواست idempotency-key متفاوت دارد تا هر بار واقعاً از کوتای
# روزانه مصرف کند (نه یک replay ساده)، و صرفاً throttle سنجیده شود.
@@ -454,18 +458,8 @@ class AskAIViewThrottleTests(TestCase):
self.assertEqual(throttled_response.status_code, 429)
class AskAIViewGuestThrottleBypassTests(TestCase):
"""
🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='ai_ask' برای هر
کاربرِ احرازهویت‌شده (مهمان یا نه) user.pk بود. چون GuestSessionView
با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) می‌سازه، یک
مهاجم می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان، هر بار یک bucket
throttle تازه برای AskAIView بگیره. حالا GuestAwareAIThrottle برای
کاربرهای مهمان کلید رو بر اساسِ IP (همون get_ident که guest_session
هم استفاده می‌کنه) می‌سازه، پس چرخشِ حساب مهمان دیگه bucket تازه
نمی‌ده. رفتارِ throttle برای کاربرِ غیرمهمان (per-user، مثلِ قبل)
دست‌نخورده مونده.
"""
class AskAIViewPerUserThrottleTests(TestCase):
"""هر کاربرِ ثبت‌نام‌شده سطلِ throttleِ جدای خودش را دارد؛ مصرفِ حسابِ دیگر به او نمی‌رسد."""
def setUp(self):
cache.clear()
@@ -480,35 +474,49 @@ class AskAIViewGuestThrottleBypassTests(TestCase):
HTTP_IDEMPOTENCY_KEY=idempotency_key,
)
def test_rotating_guest_accounts_share_the_same_throttle_bucket(self):
# سه حساب مهمانِ متفاوت (شبیه‌سازیِ چرخشِ حساب برای دورزدنِ throttle)،
# همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند.
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'ai_ask': '2/hour'}):
guest1 = User.objects.create(device_id='device-guest-rot-1', is_guest=True, name='')
response1 = self._ask(issue_access_token(guest1.id), 'guest-rot-key-1')
self.assertEqual(response1.status_code, 200)
guest2 = User.objects.create(device_id='device-guest-rot-2', is_guest=True, name='')
response2 = self._ask(issue_access_token(guest2.id), 'guest-rot-key-2')
self.assertEqual(response2.status_code, 200)
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
# bucket بر اساسِ IP به‌اشتراک گذاشته شده، نه user.pk.
guest3 = User.objects.create(device_id='device-guest-rot-3', is_guest=True, name='')
response3 = self._ask(issue_access_token(guest3.id), 'guest-rot-key-3')
self.assertEqual(response3.status_code, 429)
def test_registered_users_keep_independent_per_user_buckets(self):
# عدمِ رگرسیون: کاربرهای غیرمهمان (ثبت‌نام‌شده با شماره) باید مثلِ
# قبل هرکدوم bucket جداگانه‌ی خودشون رو داشته باشن — throttle
# برای اونا هنوز بر اساسِ user.pk است، نه IP.
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'ai_ask': '1/hour'}):
user_a = User.objects.create(is_guest=False, name='', phone='+989120000101')
user_a = User.objects.create(name='', phone='+989120000101')
response_a = self._ask(issue_access_token(user_a.id), 'user-a-key-1')
self.assertEqual(response_a.status_code, 200)
user_b = User.objects.create(is_guest=False, name='', phone='+989120000102')
user_b = User.objects.create(name='', phone='+989120000102')
response_b = self._ask(issue_access_token(user_b.id), 'user-b-key-1')
self.assertEqual(response_b.status_code, 200)
class StubNeverReachesProductionTests(TestCase):
"""بی‌کلیدِ RAG و بیرون از DEBUG، دانش‌آموز ۵۰۳ می‌گیرد، نه متنِ «پاسخ نمونه (stub)»."""
@override_settings(DEBUG=False, OPENAI_API_KEY='', QDRANT_API_KEY='', RAG_FORCE_MOCK=False)
def test_missing_keys_are_a_503_and_cost_nothing(self):
cache.clear()
user = User.objects.create(phone='09000000029', name='')
response = APIClient().post(
'/api/ai/ask', {'userQuestion': '۲+۲ چند است؟'}, format='json',
HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}',
)
self.assertEqual(response.status_code, 503)
self.assertFalse(Message.objects.filter(conversation__user_id=user.id).exists())
class GlobalDailyCapTests(TestCase):
"""سقفِ روزانه‌ی کلِ سامانه برای سوال‌هایی که به مدل می‌رسند (AI_GLOBAL_DAILY_MAX)."""
@override_settings(AI_GLOBAL_DAILY_MAX=2)
def test_the_whole_system_stops_at_the_daily_cap(self):
cache.clear()
codes = []
for i in range(3):
user = User.objects.create(phone=f'0900{i:07d}', name='')
codes.append(APIClient().post(
'/api/ai/ask', {'userQuestion': f'۲+{i} چند است؟'}, format='json',
HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}',
REMOTE_ADDR=f'10.0.0.{i}',
).status_code)
self.assertEqual(codes, [200, 200, 503])

View File

@@ -0,0 +1,92 @@
"""
سقفِ جواب‌های هم‌زمانِ دستیار (ai_tutor/concurrency.py): رگبارِ سوال نباید همه‌ی نخ‌های worker را بگیرد.
هم‌زمانیِ واقعیِ چند نخ با مدلِ واقعی آزموده نشده؛ این‌جا جا را با گرفتنِ دستی پر می‌کنیم.
"""
from unittest import mock
from django.core.cache import cache
from django.core.signals import request_finished
from django.db import close_old_connections
from django.test import TestCase, override_settings
from accounts.authentication import issue_access_token
from accounts.models import User
from ai_tutor import concurrency
from ai_tutor.models import Message
from content.models import Grade
@override_settings(AI_MAX_CONCURRENT_PER_WORKER=1)
class AskConcurrencyCapTests(TestCase):
def setUp(self):
cache.clear()
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
self.user = User.objects.create(phone='09120007801', grade_id=grade.id)
self.assertEqual(concurrency.in_flight(), 0)
def tearDown(self):
self.assertEqual(concurrency.in_flight(), 0, 'هر تست باید جا را پس بدهد')
def _ask(self, **extra):
return self.client.post(
'/api/ai/ask', {'userQuestion': 'توان چیست؟', **extra}, content_type='application/json',
HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}',
)
def _rag(self):
return (
mock.patch('rag.services.config.rag_is_configured', return_value=True),
mock.patch('rag.services.retrieval.retrieve_context',
return_value=([{'title': 'فصل', 'summary': 'متن'}], {'gradeTitle': 'نهم'})),
mock.patch('rag.services.generation.generate_answer', return_value=('جواب', [], {})),
mock.patch('rag.services.generation.stream_answer', side_effect=lambda *a, **k: iter(['جو', 'اب'])),
)
def test_full_means_503_with_retry_after_and_no_quota_used(self):
self.assertTrue(concurrency.try_acquire()) # یک سوالِ دیگر در حالِ جواب است
try:
with self.assertLogs('ai_tutor.views', level='WARNING'):
response = self._ask()
finally:
concurrency.release()
self.assertEqual(response.status_code, 503)
self.assertEqual(response.json()['detail'], 'دستیار الان خیلی شلوغه؛ چند ثانیه دیگه دوباره بپرس.')
self.assertEqual(response['Retry-After'], '10')
self.assertFalse(Message.objects.exists()) # نه پیامی ساخته شد نه سهمیه‌ای رزرو شد
def test_json_answer_gives_the_slot_back(self):
a, b, c, d = self._rag()
with a, b, c, d:
self.assertEqual(self._ask().status_code, 200)
self.assertEqual(concurrency.in_flight(), 0)
self.assertEqual(self._ask().status_code, 200) # سقفِ ۱: دومی هم جا گرفت
def test_a_failed_answer_gives_the_slot_back(self):
a, b, _, d = self._rag()
failing = mock.patch('rag.services.generation.generate_answer', side_effect=RuntimeError('provider'))
with a, b, failing, d, self.assertLogs('ai_tutor.views', level='ERROR'):
self.assertEqual(self._ask().status_code, 503)
def test_stream_holds_the_slot_until_it_is_read(self):
a, b, c, d = self._rag()
with a, b, c, d:
response = self._ask(stream=True)
self.assertEqual(concurrency.in_flight(), 1) # هنوز خوانده نشده
body = b''.join(response.streaming_content).decode() # کلاینتِ تست پایانش close() می‌زند
self.assertIn('event: done', body)
def test_a_stream_that_is_never_read_still_gives_the_slot_back(self):
a, b, c, d = self._rag()
with a, b, c, d:
response = self._ask(stream=True)
# کاربر پیش از اولین تکه رفت. سیگنالِ request_finished در TestCase اتصالِ تستی را می‌بندد؛ کلاینتِ
# تست هم برای پاسخِ استریم همین‌طور جدایش می‌کند.
request_finished.disconnect(close_old_connections)
try:
response.close()
finally:
request_finished.connect(close_old_connections)
def test_release_without_acquire_is_a_loud_bug(self):
with self.assertRaises(RuntimeError):
concurrency.release()

View File

@@ -0,0 +1,131 @@
"""
«از پی بپرس» بدونِ lessonId هم به کتاب مستند می‌شود.
آن صفحه از نوارِ پایینِ اپ باز می‌شود و درسی در کار نیست؛ قبلاً در این حالت
retrieval کلاً رد می‌شد و همه‌ی جواب‌ها عمومی بودند. حالا پایه‌ی خودِ کاربر
کتاب را معلوم می‌کند.
"""
from unittest import mock
from django.core.cache import cache
from django.test import TestCase
from accounts.authentication import issue_access_token
from accounts.models import User
from ai_tutor.models import Conversation
from content.models import Grade
class AskWithoutLessonUsesTheStudentsBookTests(TestCase):
def setUp(self):
cache.clear()
def _ask(self, user, **extra):
return self.client.post(
'/api/ai/ask', {'userQuestion': 'توان چیست؟', **extra},
content_type='application/json',
HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}',
)
def _patched(self):
retrieve = mock.patch(
'rag.services.retrieval.retrieve_context',
return_value=([{'title': 'فصل', 'summary': 'متن'}], {'gradeTitle': 'نهم'}),
)
generate = mock.patch(
'rag.services.generation.generate_answer', return_value=('جواب', [], {}),
)
configured = mock.patch('rag.services.config.rag_is_configured', return_value=True)
return retrieve, generate, configured
def test_math_grade_picks_that_grades_book(self):
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
user = User.objects.create(phone='09120000011', grade_id=grade.id)
retrieve, generate, configured = self._patched()
with retrieve as retrieve_mock, generate, configured:
response = self._ask(user)
self.assertEqual(response.status_code, 200)
kwargs = retrieve_mock.call_args.kwargs
self.assertEqual(kwargs['subject'], 'ریاضی')
self.assertEqual(kwargs['grade_title'], 'نهم')
self.assertEqual(response.json()['usedChunks'], [{'title': 'فصل', 'summary': 'متن'}])
def test_calculus_grades_use_the_calculus_book(self):
grade = Grade.objects.create(title='پایه دوازدهم', icon='', color='')
user = User.objects.create(phone='09120000012', grade_id=grade.id)
retrieve, generate, configured = self._patched()
with retrieve as retrieve_mock, generate, configured:
self._ask(user)
self.assertEqual(retrieve_mock.call_args.kwargs['subject'], 'حسابان')
def test_the_follow_up_message_keeps_the_book(self):
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
user = User.objects.create(phone='09120000013', grade_id=grade.id)
retrieve, generate, configured = self._patched()
with retrieve as retrieve_mock, generate, configured:
first = self._ask(user).json()
self._ask(user, conversationId=first['conversationId'])
self.assertEqual(retrieve_mock.call_args.kwargs['subject'], 'ریاضی')
conversation = Conversation.objects.get(pk=first['conversationId'])
self.assertEqual(conversation.subject, 'ریاضی')
def test_a_user_without_a_grade_still_gets_an_answer(self):
user = User.objects.create(phone='09120000014')
retrieve, generate, configured = self._patched()
with retrieve as retrieve_mock, generate, configured:
response = self._ask(user)
self.assertEqual(response.status_code, 200)
self.assertEqual(retrieve_mock.call_args.kwargs['subject'], '')
class AskFollowsTheBookTheStudentPickedTests(TestCase):
"""۲۰۲۶-۱۰-۱۱: یک پایه چند کتاب دارد؛ دستیار از کتابی جواب می‌دهد که دانش‌آموز انتخاب کرده یا درس به آن تعلق دارد."""
def setUp(self):
cache.clear()
self.grade = Grade.objects.create(title='پایه دوازدهم', icon='', color='')
self.user = User.objects.create(phone='09120000021', grade_id=self.grade.id)
def _ask(self, **extra):
retrieve = mock.patch('rag.services.retrieval.retrieve_context',
return_value=([], {'gradeTitle': 'دوازدهم'}))
generate = mock.patch('rag.services.generation.generate_answer', return_value=('جواب', [], {}))
configured = mock.patch('rag.services.config.rag_is_configured', return_value=True)
with retrieve as retrieve_mock, generate, configured:
response = self.client.post(
'/api/ai/ask', {'userQuestion': 'ماتریس چیست؟', **extra}, content_type='application/json',
HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}',
)
self.assertEqual(response.status_code, 200)
return retrieve_mock.call_args.kwargs
def test_explicit_subject_without_lesson_gets_the_grade_title(self):
kwargs = self._ask(subject='هندسه')
self.assertEqual((kwargs['subject'], kwargs['grade_title']), ('هندسه', 'دوازدهم'))
def test_lesson_beats_the_subject_stored_on_the_conversation(self):
from content.models import Lesson, Node, Subject, Unit
geo = Unit.objects.create(grade=self.grade, subject=Subject.objects.get(key='geometry'),
title='فصل ۱: ماتریس و کاربردها', order=1)
calc = Unit.objects.create(grade=self.grade, subject=Subject.objects.get(key='calculus'),
title='فصل ۴: مشتق', order=4)
geo_lesson = Lesson.objects.create(node=Node.objects.create(unit=geo, title='n'), title='l')
calc_lesson = Lesson.objects.create(node=Node.objects.create(unit=calc, title='n'), title='l')
first = self.client.post(
'/api/ai/ask', {'userQuestion': 'سلام', 'lessonId': str(geo_lesson.id)},
content_type='application/json', HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}',
)
self.assertEqual(first.status_code, 200)
conversation_id = first.json()['conversationId']
kwargs = self._ask(conversationId=conversation_id, lessonId=str(calc_lesson.id))
self.assertEqual((kwargs['subject'], kwargs['lesson_number']), ('حسابان', 4))

View File

@@ -0,0 +1,33 @@
from django.core.cache import cache
from django.test import TestCase
from accounts.authentication import issue_access_token
from accounts.models import User
from ai_tutor.models import Conversation, Message
class HistoryNeverShowsLatexTests(TestCase):
def test_old_latex_message_is_cleaned_on_read(self):
cache.clear()
user = User.objects.create(phone='09120000042')
conv = Conversation.objects.create(user_id=user.id)
Message.objects.create(conversation=conv, role='user', text='حل کن $x^{2}=4$')
Message.objects.create(conversation=conv, role='assistant', text=r'جواب: \( \frac{1}{2} \vec{a} \) و **مهم** x^2')
auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(user.id)}'}
r = self.client.get(f'/api/ai/conversations/{conv.id}/messages', **auth)
self.assertEqual(r.status_code, 200, r.content)
texts = [m['text'] for m in r.json()['messages']]
for t in texts:
self.assertNotIn('\\', t)
self.assertNotIn('**', t)
self.assertNotIn('^', t)
self.assertNotIn('$', t)
self.assertIn('x²', texts[0])
class ToPlainSubscriptTests(TestCase):
def test_bare_subscripts_are_unicode(self):
from rag.services.mathtext import to_plain
out = to_plain('S_n = n/2 × (a_1 + a_n)')
self.assertNotIn('_', out)
self.assertIn('Sₙ', out)

53
ai_tutor/tests_quota.py Normal file
View File

@@ -0,0 +1,53 @@
"""
سقفِ روزانه‌ی دستیار: رایگان در برابرِ اشتراکی (۲۰۲۶-۱۰-۰۱). تا آن روز همه ۱۵ سوال
داشتند، یعنی گران‌ترین بخشِ اپ برای کاربرِ بی‌اشتراک هم کامل باز بود.
"""
from datetime import timedelta
from django.core.cache import cache
from django.test import TestCase, override_settings
from django.utils import timezone
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from subscription.models import Plan, Subscription
from subscription.services import ai_daily_quota
@override_settings(AI_DAILY_QUOTA_FREE=2)
class DailyQuotaTests(TestCase):
def setUp(self):
cache.clear()
self.user = User.objects.create(phone='09120006001')
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
def ask(self):
return self.client.post('/api/ai/ask', {'userQuestion': '۲ + ۲ چند میشه؟'}, format='json')
def test_free_user_gets_the_free_quota_and_a_hint_to_subscribe(self):
self.assertEqual([self.ask().status_code for _ in range(2)], [200, 200])
blocked = self.ask()
self.assertEqual(blocked.status_code, 429)
self.assertIn('اشتراک', blocked.json()['detail']) # بسته از subscription.0016 فروخته نمی‌شود
def test_remaining_quota_follows_the_users_own_limit(self):
self.assertEqual(self.ask().json()['remainingQuota'], 1)
def test_subscriber_gets_the_plans_quota(self):
plan = Plan.objects.create(title='ماهانه', price=99000, duration_days=30, ai_daily_quota=4)
Subscription.objects.create(user_id=self.user.id, plan=plan, status='active',
expires_at=timezone.now() + timedelta(days=30))
self.assertEqual(ai_daily_quota(self.user.id), 4)
self.assertEqual([self.ask().status_code for _ in range(5)], [200, 200, 200, 200, 429])
def test_expired_subscription_falls_back_to_free(self):
plan = Plan.objects.create(title='ماهانه', price=99000, duration_days=30, ai_daily_quota=40)
Subscription.objects.create(user_id=self.user.id, plan=plan, status='active',
expires_at=timezone.now() - timedelta(days=1))
self.assertEqual(ai_daily_quota(self.user.id), 2)
def test_gifted_subscription_without_plan_gets_the_default(self):
Subscription.objects.create(user_id=self.user.id, plan=None, status='active', expires_at=None)
self.assertEqual(ai_daily_quota(self.user.id), 4) # DEFAULT_SUBSCRIBER_AI_QUOTA، مثلِ پلن‌های فروشی

View File

@@ -0,0 +1,138 @@
"""
حفاظ‌های بازبینیِ امنیتیِ پیش از تحویل (دستیار). مدل و Qdrant همه ساختگی‌اند.
"""
import json
from types import SimpleNamespace
from unittest.mock import patch
from django.core.cache import cache
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from ai_tutor.models import Message
from rag.services.clients import reset_clients_cache
RAG_ON = dict(OPENAI_API_KEY='t', QDRANT_API_KEY='t', RAG_FORCE_MOCK=False, AI_DAILY_QUOTA_FREE=3)
ANSWER = '۱) دو به‌علاوه‌ی دو می‌شود چهار.\n✅ جواب: ۴\n'
OVERFLOW_DIAGRAM = '<diagram>{"kind":"numberline","min":1' + '0' * 400 + '*2,"max":5}</diagram>'
DEEP_DIAGRAM = '<diagram>' + '[' * 3000 + ']' * 3000 + '</diagram>'
class FakeOpenAI:
def __init__(self, reply):
self.reply = reply
self.calls = 0
self.chat = SimpleNamespace(completions=SimpleNamespace(create=self._create))
self.embeddings = SimpleNamespace(
create=lambda **k: SimpleNamespace(data=[SimpleNamespace(embedding=[0.01] * 1536)]))
def _create(self, **kw):
self.calls += 1
if kw.get('stream'):
parts = [self.reply[i:i + 25] for i in range(0, len(self.reply), 25)]
events = [SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content=p))], usage=None)
for p in parts]
events.append(SimpleNamespace(choices=[], usage=SimpleNamespace(prompt_tokens=900, completion_tokens=300)))
return iter(events)
return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content=self.reply))],
usage=SimpleNamespace(prompt_tokens=900, completion_tokens=300))
def sse(resp):
out = []
for block in b''.join(resp.streaming_content).decode().split('\n\n'):
ev = data = None
for line in block.strip().splitlines():
if line.startswith('event: '):
ev = line[7:]
elif line.startswith('data: '):
data = json.loads(line[6:])
if ev:
out.append((ev, data))
return out
@override_settings(**RAG_ON)
class _Base(TestCase):
def setUp(self):
cache.clear()
reset_clients_cache()
self.user = User.objects.create(phone='09120009901')
self.c = APIClient()
self.c.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
def ask(self, q, **extra):
return self.c.post('/api/ai/ask', {'userQuestion': q, **extra}, format='json')
def remaining(self):
return self.c.get('/api/ai/quota').data['remainingQuota']
class BrokenDiagramDoesNotMakeAnswersFreeTests(_Base):
"""پارسرِ شکل OverflowError/RecursionError را بالا می‌داد و جوابِ استریم‌شده رایگان و بی‌ثبت می‌ماند."""
def test_stream_and_json_answers_are_charged(self):
for diagram in (OVERFLOW_DIAGRAM, DEEP_DIAGRAM):
with patch('rag.services.clients.get_openai_client', return_value=FakeOpenAI(ANSWER + diagram)):
events = sse(self.ask(f'دو به‌علاوه‌ی دو؟ {len(diagram)}', stream=True))
self.assertEqual(events[-1][0], 'done')
self.assertIsNone(events[-1][1]['diagram'])
with patch('rag.services.clients.get_openai_client', return_value=FakeOpenAI(ANSWER + OVERFLOW_DIAGRAM)):
self.assertEqual(self.ask('دو به‌علاوه‌ی دو؟').status_code, 200)
self.assertEqual(self.remaining(), 0)
self.assertEqual(Message.objects.filter(role='assistant').count(), 3)
@override_settings(RAG_ANSWER_CACHE_TTL=300)
class LateOffTopicMarkerTests(_Base):
def test_rest_is_not_streamed_stored_or_cached(self):
from rag.services.guardrails import OFF_TOPIC_MARKER, REFUSAL
reply = ('۱) این یک جمله‌ی ریاضیِ بلند برای عبور از چهل نویسه است.\n'
f'{OFF_TOPIC_MARKER} متنِ غیرِ ریاضی: یک انشای کامل درباره‌ی فوتبال.\n')
fake = FakeOpenAI(reply)
q = 'سوال درباره‌ی چیزی'
with patch('rag.services.clients.get_openai_client', return_value=fake):
streamed = ''.join(d['delta'] for e, d in sse(self.ask(q, stream=True)) if e == 'chunk')
other = User.objects.create(phone='09120009902')
c2 = APIClient()
c2.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(other.id)}')
second = c2.post('/api/ai/ask', {'userQuestion': q}, format='json')
self.assertNotIn('فوتبال', streamed)
self.assertIn(REFUSAL, streamed)
self.assertEqual(Message.objects.filter(role='assistant').first().text, REFUSAL)
self.assertNotIn('فوتبال', second.data['answer'])
self.assertEqual(fake.calls, 2) # جوابِ ردشده در کش نرفت
class DisconnectMidStreamTests(_Base):
def test_partial_answer_is_saved(self):
with patch('rag.services.clients.get_openai_client', return_value=FakeOpenAI(ANSWER * 10)):
resp = self.ask('سوال', stream=True)
it = iter(resp.streaming_content)
next(it)
next(it)
resp._iterator.close() # همان GeneratorExit که بستنِ صفحه می‌سازد
saved = Message.objects.filter(role='assistant')
self.assertEqual(saved.count(), 1)
self.assertTrue(saved.first().text.endswith('…'))
self.assertEqual(self.remaining(), 2)
class UnknownCollectionIsNotSearchedTests(_Base):
def test_client_chosen_collection_is_ignored(self):
seen = []
def fake_search(collection, vec, **kw):
seen.append(collection)
return [{'score': 0.9, 'payload': {'title': 'doc', 'text': 'PAYLOAD-OF-UNRELATED-COLLECTION'}}]
with patch('rag.services.clients.get_openai_client', return_value=FakeOpenAI('پاسخ')), \
patch('rag.services.retrieval.search', side_effect=fake_search):
r = self.ask('هر چیزی', subject='any', grade='other collection')
self.assertEqual(r.status_code, 200)
self.assertEqual(seen, [])
self.assertEqual(r.data['usedChunks'], [])

View File

@@ -1,9 +1,10 @@
from django.urls import path
from .views import AskAIView, ConversationMessagesView, MessageFeedbackView
from .views import AskAIView, ConversationMessagesView, MessageFeedbackView, QuotaView
urlpatterns = [
path('ai/ask', AskAIView.as_view(), name='ai-ask'),
path('ai/quota', QuotaView.as_view(), name='ai-quota'),
path('ai/conversations/<int:conversation_id>/messages', ConversationMessagesView.as_view(), name='ai-conversation-messages'),
path('ai/messages/<int:message_id>/feedback', MessageFeedbackView.as_view(), name='ai-message-feedback'),
]

View File

@@ -16,8 +16,13 @@ from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from accounts.models import User as AccountUser
from config.db import release_db_connection
from content.plain_math import convert as plain_convert
from rag.services.mathtext import to_plain
from . import concurrency, provider_status
from .models import AskIdempotency, Conversation, Message, MessageFeedback
from .serializers import (
QuotaResponseSerializer,
AskRequestSerializer,
AskResponseSerializer,
ConversationMessagesResponseSerializer,
@@ -27,7 +32,45 @@ from .serializers import (
logger = logging.getLogger(__name__)
DAILY_AI_QUOTA = 15
def _learner_age(user) -> int | None:
"""سنِ کاربر از پروفایلش (accounts)؛ لحنِ دستیار برای ۱۸+ بزرگسالانه است."""
from accounts.jalali import age_of
from accounts.models import Profile
profile = Profile.objects.filter(user_id=user.id).only('birth_date', 'birth_year').first()
return age_of(profile.birth_date, profile.birth_year) if profile else None
def _cache_grade(grade_title: str, age: int | None) -> str:
"""پایه در کلیدِ کشِ جواب، با لحن: جوابِ بزرگسالانه به کودک سرو نشود و برعکس."""
from rag.services.generation import tone_for
return f'{grade_title}|adult' if tone_for(grade_title, age) == 'adult' else grade_title
def quota_state(user_id: int) -> dict:
"""
{'remainingQuota'}: سوال‌های باقیِ امروز، هر بار تازه از دیتابیس. همه‌ی پاسخ‌ها از همین می‌خوانند تا
شمارنده‌ی اپ هیچ‌وقت از موجودیِ واقعی جدا نیفتد.
"""
used_free = Message.objects.filter(
conversation__user_id=user_id, role='user', created_at__date=timezone.localdate(), charge='free',
).count()
return {'remainingQuota': max(daily_quota(user_id) - used_free, 0)}
def _uncharge(user_message) -> None:
"""سوالی که به API نرسید (جوابِ کش‌شده): از سهمیه کم نشود."""
if user_message.charge == 'free':
user_message.charge = 'none'
user_message.save(update_fields=['charge'])
def daily_quota(user_id: int) -> int:
"""سقفِ سوالِ روزانه‌ی این کاربر؛ رایگان یا از پلنِ اشتراکش (subscription.services)."""
from subscription.services import ai_daily_quota
return ai_daily_quota(user_id)
def _annotation(retrieval_meta: dict, generation_meta: dict, *, from_cache: bool) -> dict:
@@ -53,42 +96,11 @@ def _annotation(retrieval_meta: dict, generation_meta: dict, *, from_cache: bool
'completion_tokens': int(generation_meta.get('completionTokens') or 0),
'latency_ms': int(generation_meta.get('latencyMs') or 0),
'from_cache': bool(from_cache),
# شکلِ اعتبارسنجی‌شده در rag.services.diagram؛ کشِ قدیمی آن را ندارد.
'diagram': generation_meta.get('diagram') if isinstance(generation_meta.get('diagram'), dict) else None,
}
class GuestAwareAIThrottle(ScopedRateThrottle):
"""
⚠️ اضافه شده: ScopedRateThrottle استاندارد برای کاربرِ احرازهویت‌شده
(مهمان یا نه) از request.user.pk به‌عنوانِ کلیدِ throttle استفاده
می‌کنه. چون GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ
تازه (pk جدید) می‌سازه، یک مهاجم می‌تونست با ساختنِ پی‌درپیِ حساب‌های
مهمان، برای هر حساب یک bucket مستقلِ throttle (و همین‌طور کوتای
روزانه‌ی AskAIView که هم بر اساسِ user_id شمارش می‌شه) بگیره — یعنی
نرخِ واقعیِ استفاده از این endpoint گرون‌قیمت به‌جایِ محدود به نرخِ
ai_ask در ساعت، عملاً فقط به نرخِ ساختِ حساب مهمان (guest_session)
محدود می‌شد.
این کلاس فقط برای کاربرهای مهمان (is_guest=True) کلیدِ cache رو از
pk کاربر به IP کلاینت (همون get_ident که guest_session هم استفاده
می‌کنه) تغییر می‌ده؛ یعنی همه‌ی حساب‌های مهمانِ ساخته‌شده از یک IP
مشترکِ throttle bucket ai_ask رو دارن و دیگه با عوض‌کردنِ حساب
نمی‌شه bucket تازه گرفت. رفتارِ throttle برای کاربرهای غیرمهمان
(ثبت‌نام‌شده با شماره) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی
می‌مونه؛ منطقِ DAILY_AI_QUOTA و بقیه‌ی view دست‌نخورده‌ست.
"""
def get_cache_key(self, request, view):
user = request.user
if user and user.is_authenticated and getattr(user, 'is_guest', False):
ident = f'guest-ip:{self.get_ident(request)}'
elif user and user.is_authenticated:
ident = user.pk
else:
ident = self.get_ident(request)
return self.cache_format % {'scope': self.scope, 'ident': ident}
def _is_db_ref_id(value) -> bool:
"""
⚠️ افزوده شده: lessonId/chapterId/questionId در قراردادِ API به‌صورتِ
@@ -97,10 +109,80 @@ def _is_db_ref_id(value) -> bool:
این تابع فقط تشخیص می‌ده که آیا مقدار برای ذخیره‌سازی در همچین
فیلدی امن/عددی هست یا نه؛ روی مرزِ generate_tutor_answer اثری نداره
(اونجا همیشه مقدارِ خامِ اصلی پاس داده می‌شه).
پیامِ دستیار هم از همین مسیر (`_db_ref_or_none`) رد می‌شود؛ پیش از
۲۰۲۶-۰۹-۲۵ نمی‌شد و questionIdِ غیرعددی *بعد از* تولیدِ جواب ۵۰۰ می‌داد و
سهمِ روزانه مصرف‌شده می‌ماند.
"""
if value in (None, ''):
return False
return str(value).strip().lstrip('-').isdigit()
# isascii: '²'.isdigit() هم True است ولی int('²') خطا می‌دهد.
text = str(value).strip().lstrip('-')
return text.isascii() and text.isdigit()
def _db_ref_or_none(value):
"""مقدارِ عددی برای ستونِ IntegerField، وگرنه None (نه خطای ORM)."""
return value if _is_db_ref_id(value) else None
def _conversation_history(conversation, before_message) -> list[dict]:
"""
چند پیامِ آخرِ همین گفتگو، پیش از سوالِ تازه، به ترتیبِ زمان.
بدونِ این، هر سوال جدا از قبلی‌ها جواب داده می‌شد: «نفهمیدم، ساده‌تر بگو»
برای مدل هیچ مرجعی نداشت.
"""
from rag.services.generation import HISTORY_TURNS
rows = list(
Message.objects.filter(conversation=conversation, id__lt=before_message.id)
.order_by('-id').values('role', 'text')[:HISTORY_TURNS]
)
return list(reversed(rows))
def _retrieval_query(question: str, history: list[dict], focus: str = '') -> str:
"""
متنی که embedding می‌شود. «یه مثال دیگه بزن» به‌تنهایی به هیچ تکه‌ای از
کتاب نزدیک نیست؛ سوالِ قبلیِ کاربر (یا سوالِ تمرینیِ اپ) موضوع را معلوم می‌کند.
"""
if focus:
return f'{focus}\n{question}'
previous = next((t['text'] for t in reversed(history) if t['role'] == 'user'), '')
return f'{previous}\n{question}' if previous else question
def _question_focus(v: dict, user) -> str:
"""اگر اپ questionId فرستاده، متنِ همان سوال و جوابِ دانش‌آموز (rag.generation)."""
question_id = v.get('questionId')
if not _is_db_ref_id(question_id):
return ''
from rag.services.generation import question_focus
return question_focus(question_id, user.id)
class AIDailyCapReached(APIException):
status_code = 503
default_detail = 'دستیار امروز به سقفِ استفاده رسیده؛ لطفاً فردا دوباره بپرس.'
default_code = 'ai_daily_cap'
def _check_global_daily_cap() -> None:
"""
سقفِ روزانه‌ی کلِ سامانه (AI_GLOBAL_DAILY_MAX): سوال‌های امروز که به مدل رفته‌اند یا با کش
برگشته‌اند ولی هنوز «free» مانده‌اند. در دیتابیس شمرده می‌شود، پس بینِ workerها
مشترک است. داخلِ همان تراکنشِ IMMEDIATEِ فازِ ۱ صدا زده می‌شود؛ روی Postgres دو درخواستِ
هم‌زمان ممکن است هر دو از لبه رد شوند (چند سوال بیشتر از سقف، نه بیشتر).
"""
from django.conf import settings
start = timezone.localtime().replace(hour=0, minute=0, second=0, microsecond=0)
used = Message.objects.filter(role='user', created_at__gte=start, charge='free').count()
if used >= settings.AI_GLOBAL_DAILY_MAX:
logger.error('AI global daily cap reached: cap=%s used=%s', settings.AI_GLOBAL_DAILY_MAX, used)
raise AIDailyCapReached()
class AIProviderUnavailable(APIException):
@@ -136,10 +218,14 @@ def generate_tutor_answer(
تولید می‌کنه. مالکیتِ تاریخچه‌ی مکالمه همیشه دستِ دیتابیسِ خودمونه، نه
provider.
اگه RAG پیکربندی نشده باشه (نبودِ OPENAI_API_KEY/QDRANT_API_KEY یا
RAG_FORCE_MOCK)، همون پاسخِ stubِ قطعیِ قبلی برمی‌گرده تا اجرای بدونِ
کلید و تست‌ها دست‌نخورده کار کنن.
اگه RAG پیکربندی نشده باشه، پاسخِ stub فقط در DEBUG یا با RAG_FORCE_MOCK
(تست‌ها) برمی‌گرده. بیرون از آن خطا می‌دهد و AskAIView آن را ۵۰۳ می‌کند.
تا ۲۰۲۶-۱۰-۰۳ stub در پروداکشن هم برمی‌گشت: اگر کلیدی از .env سرور می‌افتاد،
دانش‌آموز با ۲۰۰ متنِ «این یک پاسخ نمونه (stub)… OPENAI_API_KEY را تنظیم
کنید» می‌گرفت و سهمیه‌ی روزانه‌اش هم کم می‌شد (با تستِ دورریختنی بازتولید شد).
"""
from django.conf import settings
from rag.services.config import rag_is_configured
if rag_is_configured():
@@ -153,6 +239,9 @@ def generate_tutor_answer(
answer, follow_ups, _meta = generate_answer(user_question, context_chunks)
return answer, follow_ups
if not (settings.DEBUG or settings.RAG_FORCE_MOCK):
raise RuntimeError('RAG پیکربندی نشده (OPENAI_API_KEY/QDRANT_API_KEY)؛ stub فقط در DEBUG.')
context_note = f' (با context: {context})' if context else ''
answer = (
f'این یک پاسخ نمونه (stub) برای سوال «{user_question}»{context_note} است. '
@@ -163,14 +252,23 @@ def generate_tutor_answer(
return answer, follow_ups
class AIBusy(APIException):
"""همه‌ی جاهای هم‌زمانِ دستیار در این worker پرند (ai_tutor/concurrency.py). سهمیه دست نخورده."""
status_code = 503
default_detail = 'دستیار الان خیلی شلوغه؛ چند ثانیه‌ی دیگه دوباره بپرس.'
default_code = 'assistant_busy'
# هندلرِ DRF از `wait` هدرِ Retry-After می‌سازد.
wait = 10
class AskAIView(APIView):
"""POST /api/ai/ask — سوال از AI (استریم اختیاری؛ در این فاز استریم پیاده نشده)"""
permission_classes = [IsAuthenticated]
# DAILY_AI_QUOTA فقط سقفِ روزانه‌ست و جلوی burstِ کوتاه‌مدت رو نمی‌گیره؛
# daily_quota فقط سقفِ روزانه‌ست و جلوی burstِ کوتاه‌مدت رو نمی‌گیره؛
# این throttle_scope مجزا اون burst رو محدود می‌کنه.
throttle_scope = 'ai_ask'
throttle_classes = [GuestAwareAIThrottle]
throttle_classes = [ScopedRateThrottle]
@extend_schema(
request=AskRequestSerializer,
@@ -189,6 +287,24 @@ class AskAIView(APIView):
tags=['AI Tutor (ai_tutor)'],
)
def post(self, request):
# پیش از هر کارِ دیگری، حتی رزروِ سهمیه: درخواستِ ردشده نباید از سهمیه‌ی روزانه کم کند.
if not concurrency.try_acquire():
logger.warning('ai_busy user_id=%s in_flight=%s limit=%s',
request.user.id, concurrency.in_flight(), concurrency.limit())
raise AIBusy()
try:
response = self._ask(request)
except BaseException:
concurrency.release()
raise
if isinstance(response, StreamingHttpResponse):
# جا تا پایانِ استریم دستِ همین درخواست می‌ماند (ReleaseWhenDone).
response.streaming_content = concurrency.ReleaseWhenDone(response.streaming_content)
else:
concurrency.release()
return response
def _ask(self, request):
serializer = AskRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
@@ -207,15 +323,39 @@ class AskAIView(APIView):
# فاز ۲ (فراخوانی AI) کاملاً بیرون از هر قفل/تراکنشی اجرا می‌شود تا
# تأخیر شبکه قفل کاربر را نگه ندارد، فاز ۳ پیام دستیار و idempotency
# را ثبت می‌کند. اگر فاز ۲/۳ شکست بخورد، رزرو فاز ۱ آزاد می‌شود.
# محافظِ دستیار، لایه‌ی ۱ (rag/services/guardrails.py): ترفندِ آشکار برای عوض کردنِ
# قواعد، یا جمله‌ای درباره‌ی آسیب به خود، اصلاً به مدل نمی‌رسد؛ پس هزینه‌ای ندارد و از
# سهمیه هم کم نمی‌شود. سقفِ burst (throttle_scope=ai_ask) جلوی تکرارش را می‌گیرد.
from rag.services import guardrails
screened = guardrails.screen(v['userQuestion'])
with transaction.atomic():
AccountUser.objects.select_for_update().get(pk=user.id)
# هر سوالی که به API می‌رسد پول است و از سقفِ روزانه کم می‌شود (رایگان: AI_DAILY_QUOTA_FREE،
# مشترک: Plan.ai_daily_quota). قفلِ ردیفِ کاربر جلوی دو سوالِ هم‌زمان روی آخرین سهم را می‌گیرد.
today = timezone.localdate()
used_today = Message.objects.filter(
conversation__user_id=user.id, role='user', created_at__date=today
conversation__user_id=user.id, role='user', created_at__date=today, charge='free',
).count()
if used_today >= DAILY_AI_QUOTA:
raise Throttled(detail='سقف روزانه‌ی سوال از هوش مصنوعی به پایان رسیده است.')
quota = daily_quota(user.id)
if not screened:
_check_global_daily_cap()
if screened:
charge = 'none'
elif used_today < quota:
charge = 'free'
else:
from subscription.services import has_active_subscription
n = str(quota).translate(str.maketrans('0123456789', '۰۱۲۳۴۵۶۷۸۹'))
raise Throttled(detail=(
# مشترک تا فردا صبر می‌کند، بی‌اشتراک اشتراک می‌خرد (سوالِ جدا فروخته نمی‌شود).
f'سقفِ امروزِ سوال از دستیار ({n} سوال) پر شد. فردا دوباره می‌تونی بپرسی.'
if has_active_subscription(user.id) else
f'سوال‌های رایگانِ امروز ({n} سوال) تمام شد. با خریدِ اشتراک هر روز سوالِ بیشتری بپرس.'
))
conversation_id = v.get('conversationId')
conversation_is_new = not conversation_id
@@ -246,6 +386,7 @@ class AskAIView(APIView):
role='user',
text=v['userQuestion'],
question_id=raw_question_id if _is_db_ref_id(raw_question_id) else None,
charge=charge,
)
# قفل ردیف کاربر اینجا آزاد شده؛ فراخوانی AI بیرون از هر تراکنشی اجرا می‌شود.
# لایه‌ی RAG (اپ rag) اینجا context را می‌سازد: پرسش → embedding →
@@ -257,10 +398,14 @@ class AskAIView(APIView):
# فقط conversationId می‌فرستد و دیگر lessonId/gradeId نمی‌آید.
grade_id = v.get('gradeId') or conversation.grade_id
lesson_id = v.get('lessonId') or conversation.lesson_id
subject = (v.get('subject') or conversation.subject or '').strip()
# ⚠️ درسِ تازه‌ی همین درخواست از کتابِ ذخیره‌شده‌ی مکالمه جلو می‌افتد: از وقتی یک پایه چند
# کتاب دارد، مکالمه‌ای که با درسِ هندسه شروع شده می‌تواند با درسِ حسابان ادامه پیدا کند و
# `conversation.subject`/`lesson_number`ِ قدیمی جواب را از کتابِ غلط می‌گرفت.
explicit_lesson = bool(v.get('lessonId'))
subject = (v.get('subject') or ('' if explicit_lesson else conversation.subject) or '').strip()
grade_title = (v.get('grade') or '').strip()
lesson_number = v.get('lessonNumber')
if lesson_number is None:
if lesson_number is None and not explicit_lesson:
lesson_number = conversation.lesson_number
# اگر اپ فقط lessonId (یا فقط conversationId) داده — حالتِ طبیعی، چون
@@ -287,6 +432,31 @@ class AskAIView(APIView):
conversation.lesson_number = lesson_number
conversation.save(update_fields=['subject', 'lesson_number'])
# کتاب صریح آمده (فرانت کتابِ انتخابیِ دانش‌آموز را می‌فرستد) ولی درس و عنوانِ پایه نه:
# عنوانِ پایه از پایه‌ی خودِ کاربر درمی‌آید، وگرنه resolve_target هیچ collectionی نمی‌سازد
# و جواب بی‌صدا عمومی می‌شد.
if subject and not grade_title:
from rag.services.config import resolve_grade_title
grade_title = resolve_grade_title(grade_id or getattr(user, 'grade_id', None)) or ''
# نه درسی آمده نه subject — حالتِ عادیِ صفحه‌ی «از پی بپرس» که از نوارِ
# پایین باز می‌شود. پایه‌ی ذخیره‌شده‌ی خودِ کاربر کتاب را معلوم می‌کند؛
# بدونِ این، جواب‌های آن صفحه هیچ‌وقت به کتاب مستند نمی‌شدند.
if not subject:
from rag.services.lesson_context import resolve_from_grade
grade_id = grade_id or getattr(user, 'grade_id', None)
derived = resolve_from_grade(grade_id)
subject = derived.get('subject', '')
grade_title = grade_title or derived.get('gradeTitle', '')
if subject and not conversation.subject:
conversation.subject = subject
conversation.grade_id = conversation.grade_id or grade_id
conversation.save(update_fields=['subject', 'grade_id'])
age = _learner_age(user)
# --- مسیرِ استریم (SSE) --- فقط وقتی stream=true و RAG پیکربندی‌شده.
# قراردادِ JSONِ غیراستریم دست‌نخورده می‌ماند؛ Swagger که stream
# نمی‌فرسته همون مسیرِ قبلی رو می‌گیره.
@@ -299,7 +469,7 @@ class AskAIView(APIView):
user_message=user_message, conversation_is_new=conversation_is_new,
idempotency_key=idempotency_key, used_today=used_today,
subject=subject, grade_id=grade_id, grade_title=grade_title,
lesson_number=lesson_number,
lesson_number=lesson_number, screened=screened, quota=quota, age=age,
)
used_chunks: list[dict] = []
@@ -310,14 +480,20 @@ class AskAIView(APIView):
try:
from rag.services.config import rag_is_configured
if rag_is_configured():
if screened:
answer_text, follow_ups = screened[1], []
generation_meta = {'promptVersion': f'guardrail-{screened[0]}'}
elif rag_is_configured():
from rag.services import answer_cache
from rag.services.generation import generate_answer
from rag.services.retrieval import resolve_target, retrieve_context
history = _conversation_history(conversation, user_message)
focus = _question_focus(v, user)
cacheable = answer_cache.cacheable(history) and not focus
_col, _fk, _fv = resolve_target(subject, grade_title, grade_id, lesson_number)
cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'], grade_title)
cached = answer_cache.get(cache_key)
cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'], _cache_grade(grade_title, age))
cached = answer_cache.get(cache_key) if cacheable else None
if cached is not None:
answer_text = cached['answer']
follow_ups = list(cached['followUps'])
@@ -325,9 +501,13 @@ class AskAIView(APIView):
retrieval_meta = cached.get('retrievalMeta') or {}
generation_meta = cached.get('generationMeta') or {}
answer_from_cache = True
_uncharge(user_message)
else:
# Qdrant و مدل چند ثانیه طول می‌کشند؛ اتصالِ دیتابیس در این مدت به pool برمی‌گردد.
release_db_connection()
used_chunks, retrieval_meta = retrieve_context(
v['userQuestion'], subject=subject, grade_id=grade_id,
_retrieval_query(v['userQuestion'], history, focus),
subject=subject, grade_id=grade_id,
grade_title=grade_title, lesson_number=lesson_number,
)
used_chunks = [
@@ -337,18 +517,25 @@ class AskAIView(APIView):
answer_text, follow_ups, generation_meta = generate_answer(
v['userQuestion'], used_chunks or None,
retrieval_meta.get('gradeTitle') or grade_title or '',
history=history, focus=focus, age=age,
)
answer_cache.set(cache_key, {
'answer': answer_text, 'followUps': follow_ups,
'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta,
'generationMeta': generation_meta,
})
provider_status.record_success()
# ردِ «غیرِ ریاضی» نه منبعِ کتاب نشان می‌دهد نه کش می‌شود.
if generation_meta.get('refused'):
used_chunks = []
elif cacheable:
answer_cache.set(cache_key, {
'answer': answer_text, 'followUps': follow_ups,
'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta,
'generationMeta': generation_meta,
})
else:
answer_text, follow_ups = generate_tutor_answer(
v['userQuestion'], grade_id, lesson_id, context
)
except Exception:
except Exception as exc:
logger.exception('generate_tutor_answer failed for user_id=%s', user.id)
provider_status.record_failure(exc)
self._release_reserved_slot(conversation, user_message, conversation_is_new)
raise AIProviderUnavailable()
@@ -358,7 +545,7 @@ class AskAIView(APIView):
conversation=conversation,
role='assistant',
text=answer_text,
question_id=v.get('questionId') or None,
question_id=_db_ref_or_none(v.get('questionId')),
**_annotation(retrieval_meta, generation_meta, from_cache=answer_from_cache),
)
@@ -382,15 +569,15 @@ class AskAIView(APIView):
self._release_reserved_slot(conversation, user_message, conversation_is_new)
return Response(self._build_ask_response(existing).data)
used_today += 1 # سوال همین درخواست هم جزو مصرف امروز است
remaining_quota = max(DAILY_AI_QUOTA - used_today, 0)
state = quota_state(user.id)
data = {
'conversationId': str(conversation.id),
'messageId': str(assistant_message.id),
'answer': answer_text,
'suggestedFollowUps': follow_ups,
'remainingQuota': remaining_quota,
'remainingQuota': state['remainingQuota'],
'diagram': assistant_message.diagram,
'usedChunks': [
{'title': c.get('title', ''), 'summary': c.get('summary', '')}
for c in used_chunks
@@ -411,17 +598,20 @@ class AskAIView(APIView):
def _streaming_response(self, *, request, v, user, conversation, user_message,
conversation_is_new, idempotency_key, used_today,
subject, grade_id, grade_title, lesson_number):
subject, grade_id, grade_title, lesson_number, screened=None, quota=None,
age=None):
"""
نسخه‌ی SSE از post(): همون منطقِ retrieval + کوتا + idempotency، ولی
پاسخ توکن‌به‌توکن استریم می‌شه. رویدادها:
event: chunk data: {"delta": "..."}
event: done data: {conversationId, messageId, remainingQuota, usedChunks[, retrievalDebug]}
event: done data: {conversationId, messageId, remainingQuota, diagram, usedChunks[, retrievalDebug]}
event: error data: {"detail": "..."}
فاز ۱ (رزرو کوتا + conversation + user_message) قبلاً در post() انجام شده.
"""
from rag.services import answer_cache
from rag.services.generation import PROMPT_VERSION, default_follow_ups, stream_answer
from rag.services import answer_cache, guardrails
from rag.services.generation import (
PROMPT_VERSION, chat_model_for, default_follow_ups, stream_answer,
)
from rag.services.retrieval import resolve_target, retrieve_context
def _sse(event: str, payload: dict) -> str:
@@ -432,20 +622,32 @@ class AskAIView(APIView):
generation_meta: dict = {}
answer_from_cache = False
_col, _fk, _fv = resolve_target(subject, grade_title, grade_id, lesson_number)
cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'], grade_title)
cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'], _cache_grade(grade_title, age))
try:
cached = answer_cache.get(cache_key)
if cached is not None:
history = _conversation_history(conversation, user_message)
focus = _question_focus(v, user)
cacheable = answer_cache.cacheable(history) and not focus
cached = answer_cache.get(cache_key) if cacheable and not screened else None
if screened:
answer_text, follow_ups = screened[1], []
used_chunks, retrieval_meta = [], {}
generation_meta = {'promptVersion': f'guardrail-{screened[0]}'}
yield _sse('chunk', {'delta': answer_text})
elif cached is not None:
answer_text = cached['answer']
follow_ups = list(cached['followUps'])
used_chunks = cached['usedChunks']
retrieval_meta = cached.get('retrievalMeta') or {}
generation_meta = cached.get('generationMeta') or {}
answer_from_cache = True
_uncharge(user_message)
yield _sse('chunk', {'delta': answer_text})
else:
# کلِ استریم (تا ۴۰ ثانیه) بی‌اتصالِ دیتابیس؛ ثبتِ پیامِ آخر یکی تازه قرض می‌گیرد.
release_db_connection()
used_chunks, retrieval_meta = retrieve_context(
v['userQuestion'], subject=subject, grade_id=grade_id,
_retrieval_query(v['userQuestion'], history, focus),
subject=subject, grade_id=grade_id,
grade_title=grade_title, lesson_number=lesson_number,
)
used_chunks = [
@@ -453,31 +655,49 @@ class AskAIView(APIView):
for c in used_chunks
]
parts: list[str] = []
stream_result: dict = {}
started = time.monotonic()
for delta in stream_answer(
v['userQuestion'], used_chunks or None,
retrieval_meta.get('gradeTitle') or grade_title or '',
):
parts.append(delta)
yield _sse('chunk', {'delta': delta})
effective_grade = retrieval_meta.get('gradeTitle') or grade_title or ''
try:
for delta in stream_answer(
v['userQuestion'], used_chunks or None, effective_grade,
history=history, focus=focus, result=stream_result, age=age,
):
parts.append(delta)
yield _sse('chunk', {'delta': delta})
except GeneratorExit:
# کاربر صفحه را وسطِ جواب بست. سوال خرج می‌ماند (مدل هزینه‌اش را گرفته)، ولی
# همان بخشی که ساخته شد ثبت می‌شود تا گفتگو سوالِ بی‌جواب نداشته باشد.
self._save_partial_answer(conversation, user, v, ''.join(parts))
raise
provider_status.record_success()
answer_text = ''.join(parts).strip() or 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم.'
follow_ups = default_follow_ups()
# مسیرِ استریم usage برنمی‌گرداند (پاسخ توکن‌به‌توکن است)،
# پس فقط چیزی را ثبت می‌کنیم که واقعاً می‌دانیم؛ توکن‌ها صفر
# می‌مانند و گزارشِ هزینه هم همین را نشان می‌دهد.
# توکن‌ها از آخرین تکه‌ی استریم می‌آیند (stream_options.include_usage).
# تا ۲۰۲۶-۱۰-۰۱ این‌جا صفر ثبت می‌شد، یعنی هزینه‌ی واقعیِ اپ هیچ‌جا
# اندازه‌گیری نمی‌شد؛ providerی که usage نفرستد هنوز صفر می‌دهد.
usage = stream_result.get('usage') or {}
generation_meta = {
'model': settings.OPENAI_CHAT_MODEL,
'model': chat_model_for(effective_grade),
'promptVersion': PROMPT_VERSION,
'latencyMs': int((time.monotonic() - started) * 1000),
'hadContext': bool(used_chunks),
'diagram': stream_result.get('diagram'),
'promptTokens': usage.get('promptTokens', 0),
'completionTokens': usage.get('completionTokens', 0),
}
answer_cache.set(cache_key, {
'answer': answer_text, 'followUps': follow_ups,
'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta,
'generationMeta': generation_meta,
})
except Exception:
if stream_result.get('refused'):
used_chunks = []
answer_text = guardrails.REFUSAL
elif cacheable:
answer_cache.set(cache_key, {
'answer': answer_text, 'followUps': follow_ups,
'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta,
'generationMeta': generation_meta,
})
except Exception as exc:
logger.exception('stream generate failed for user_id=%s', user.id)
provider_status.record_failure(exc)
self._release_reserved_slot(conversation, user_message, conversation_is_new)
yield _sse('error', {'detail': AIProviderUnavailable.default_detail})
return
@@ -487,7 +707,7 @@ class AskAIView(APIView):
with transaction.atomic():
assistant_message = Message.objects.create(
conversation=conversation, role='assistant', text=answer_text,
question_id=v.get('questionId') or None,
question_id=_db_ref_or_none(v.get('questionId')),
**_annotation(retrieval_meta, generation_meta, from_cache=answer_from_cache),
)
if idempotency_key:
@@ -510,17 +730,18 @@ class AskAIView(APIView):
'conversationId': str(existing.conversation_id),
'messageId': str(existing.assistant_message_id),
'suggestedFollowUps': existing.suggested_follow_ups,
'remainingQuota': max(DAILY_AI_QUOTA - used_today, 0),
**quota_state(user.id),
'diagram': existing.assistant_message.diagram,
'usedChunks': [],
})
return
used_today += 1
done = {
'conversationId': str(conversation.id),
'messageId': str(assistant_message.id),
'suggestedFollowUps': follow_ups,
'remainingQuota': max(DAILY_AI_QUOTA - used_today, 0),
**quota_state(user.id),
'diagram': assistant_message.diagram,
'usedChunks': [
{'title': c.get('title', ''), 'summary': c.get('summary', '')}
for c in used_chunks
@@ -542,6 +763,20 @@ class AskAIView(APIView):
response['X-Accel-Buffering'] = 'no'
return response
@staticmethod
def _save_partial_answer(conversation, user, v, text):
logger.info('stream closed by client mid-answer: user_id=%s conversation_id=%s chars=%s',
user.id, conversation.id, len(text))
if not text.strip():
return
try:
Message.objects.create(
conversation=conversation, role='assistant', text=text.strip() + ' …',
question_id=_db_ref_or_none(v.get('questionId')),
)
except Exception:
logger.exception('saving partial answer failed: conversation_id=%s', conversation.id)
def _release_reserved_slot(self, conversation, user_message, conversation_is_new):
"""پاک‌کردن رزرو فاز ۱ وقتی فاز بعدی شکست می‌خورد، تا نه پیام یتیم بماند و نه کوتا مصرف‌شده حساب شود."""
with transaction.atomic():
@@ -571,18 +806,15 @@ class AskAIView(APIView):
idempotent به‌خودی‌خود چیزی مصرف نکرده.
"""
conversation = existing.conversation
today = timezone.localdate()
used_today = Message.objects.filter(
conversation__user_id=existing.user_id, role='user', created_at__date=today
).count()
remaining_quota = max(DAILY_AI_QUOTA - used_today, 0)
state = quota_state(existing.user_id)
data = {
'conversationId': str(conversation.id),
'messageId': str(existing.assistant_message.id),
'answer': existing.assistant_message.text,
'answer': to_plain(existing.assistant_message.text),
'suggestedFollowUps': existing.suggested_follow_ups,
'remainingQuota': remaining_quota,
'remainingQuota': state['remainingQuota'],
'diagram': existing.assistant_message.diagram,
}
return AskResponseSerializer(data)
@@ -590,6 +822,10 @@ class AskAIView(APIView):
class ConversationMessagesView(APIView):
"""GET /api/ai/conversations/{id}/messages — تاریخچه‌ی مکالمه"""
# سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `read` همان نرخِ مسیرهای هم‌خانواده است.
throttle_scope = 'read'
throttle_classes = [ScopedRateThrottle]
permission_classes = [IsAuthenticated]
@extend_schema(responses=ConversationMessagesResponseSerializer, tags=['AI Tutor (ai_tutor)'])
@@ -603,7 +839,10 @@ class ConversationMessagesView(APIView):
'conversationId': str(conversation.id),
'lessonId': str(conversation.lesson_id) if conversation.lesson_id else None,
'messages': [
{'messageId': str(m.id), 'role': m.role, 'text': m.text} for m in messages
{'messageId': str(m.id), 'role': m.role,
# تورِ ایمنی: پیامِ قدیمی با LaTeX/markdown هم هرگز خام نمایش داده نمی‌شود.
'text': to_plain(m.text) if m.role == 'assistant' else plain_convert(m.text), 'diagram': m.diagram}
for m in messages
],
}
return Response(ConversationMessagesResponseSerializer(data).data)
@@ -612,6 +851,10 @@ class ConversationMessagesView(APIView):
class MessageFeedbackView(APIView):
"""POST /api/ai/messages/{id}/feedback — فیدبک 👍/👎"""
# سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `user_profile_write` همان نرخِ مسیرهای هم‌خانواده است.
throttle_scope = 'user_profile_write'
throttle_classes = [ScopedRateThrottle]
permission_classes = [IsAuthenticated]
@extend_schema(
@@ -632,3 +875,19 @@ class MessageFeedbackView(APIView):
defaults['reason'] = serializer.validated_data['reason']
MessageFeedback.objects.update_or_create(message=message, defaults=defaults)
return Response(FeedbackResponseSerializer({'success': True}).data)
class QuotaView(APIView):
"""
GET /api/ai/quota — «امروز X سوال». اپ پیش از اولین سوال این را نشان می‌دهد؛ بعد از هر جواب همین عدد
در خودِ پاسخ هم می‌آید (remainingQuota).
"""
permission_classes = [IsAuthenticated]
throttle_scope = 'read'
throttle_classes = [ScopedRateThrottle]
@extend_schema(responses=QuotaResponseSerializer, tags=['AI Tutor (ai_tutor)'])
def get(self, request):
data = {'freeDaily': daily_quota(request.user.id), **quota_state(request.user.id)}
return Response(QuotaResponseSerializer(data).data)

View File

@@ -33,8 +33,8 @@ class MessageCampaignAdmin(admin.ModelAdmin):
@admin.register(CampaignDelivery)
class CampaignDeliveryAdmin(admin.ModelAdmin):
"""
نمایشِ read-only: لیست و صفحه‌ی جزئیاتِ هر رکورد همیشه قابلِ
مشاهده‌ست (has_view_permission صریحاً True)، ولی هیچ راهی برای
نمایشِ read-only: لیست و صفحه‌ی جزئیات فقط با اجازه‌ی view_campaigndelivery
(رفتارِ پیش‌فرضِ جنگو)، و هیچ راهی برای
افزودن، ویرایش یا حذفِ رکورد از ادمین وجود نداره تا وضعیتِ تحویل
(که فقط باید توسطِ campaigns.services.execute_campaign آپدیت بشه)
از ادمین دست‌کاری نشه.
@@ -46,13 +46,6 @@ class CampaignDeliveryAdmin(admin.ModelAdmin):
fields = ('campaign', 'user_id', 'channel', 'status', 'error_message', 'created_at')
readonly_fields = fields
def has_view_permission(self, request, obj=None):
# صریحاً True: بدونِ override، fallbackِ پیش‌فرضِ Django به کاربرِ
# staffِ بدونِ پرمیشنِ view/change حتی اجازه‌ی دیدنِ این صفحه‌ی
# read-only رو هم نمی‌ده — یعنی مدل عملاً غیرقابلِ‌دسترس می‌شه، نه
# فقط view-only.
return True
def has_add_permission(self, request):
return False

View File

@@ -20,16 +20,20 @@ from notifications.services import notify
from .models import CampaignDelivery, MessageCampaign
def _retry_on_sqlite_lock(fn, *, retries=5, base_delay=0.05):
def _retry_on_sqlite_lock(fn, *, retries=8, base_delay=0.05, max_delay=0.4):
"""
روی SQLite (مخصوصاً دیتابیسِ حافظه‌ایِ shared-cache که تست‌ها ازش
استفاده می‌کنن)، وقتی دو اتصالِ هم‌زمان می‌خوان همون جدول رو بنویسن،
ممکنه OperationalError('database table is locked') بگیریم. این
SQLITE_LOCKED‌ه نه SQLITE_BUSY، پس busy_timeoutِ معمولیِ اتصال کمکی
نمی‌کنه و بلافاصله raise می‌شه؛ یک خطای واقعیِ داده نیست، فقط
تناقضِ لحظه‌ایِ لاکه. این تابع فقط همین خطای گذرا رو با چند بار
تلاشِ سریع (backoffِ کوچیکِ فزاینده) دوباره امتحان می‌کنه؛ هر
روی SQLite وقتی دو اتصالِ هم‌زمان می‌خوان همون جدول رو بنویسن، ممکنه
OperationalError('database table is locked') بگیریم. این SQLITE_LOCKED‌ه
نه SQLITE_BUSY، پس busy_timeoutِ اتصال کمکی نمی‌کنه و بلافاصله raise
می‌شه؛ خطای واقعیِ داده نیست، فقط تناقضِ لحظه‌ایِ لاک. هر
OperationalError دیگه (یا شکستِ تلاشِ آخر) دست‌نخورده بالا می‌ره.
⚠️ پنجره‌ی انتظار باید از خودِ قفل درازتر باشد، وگرنه محافظ فقط *گاهی*
کار می‌کند. نسخه‌ی اول ۵ تلاش با backoffِ خطی داشت — رویِ‌هم ۰٫۵ ثانیه —
و تستِ هم‌زمانیِ campaigns را در حدودِ نیمی از اجراهای کاملِ سوئیت قرمز
می‌کرد، چون زیرِ بارِ کلِ تست‌ها قفل بیشتر از آن طول می‌کشد. حالا
backoff نمایی است و تا ~۲ ثانیه صبر می‌کند؛ سقفِ max_delay هم هست تا
یک قفلِ طولانی، درخواستِ کاربر را بی‌نهایت معلق نگه ندارد.
"""
for attempt in range(retries):
try:
@@ -37,7 +41,7 @@ def _retry_on_sqlite_lock(fn, *, retries=5, base_delay=0.05):
except OperationalError as exc:
if 'locked' not in str(exc).lower() or attempt == retries - 1:
raise
time.sleep(base_delay * (attempt + 1))
time.sleep(min(base_delay * (2 ** attempt), max_delay))
# عمداً به notifications.models.Notification.TYPE_CHOICES اضافه نشده: آن
# choices فقط رویدادهاییه که notifications به‌عنوانِ listener گوش می‌ده،
@@ -220,16 +224,26 @@ def execute_campaign(campaign: MessageCampaign) -> None:
already_sent_user_ids = _already_sent_user_ids(campaign)
if campaign.channel == 'in_app':
for user in users.only('id').iterator():
if user.id in already_sent_user_ids:
# ⚠️ عمداً list() و نه .iterator(): کرسرِ باز باید *پیش از* اولین
# نوشتن بسته شود. با .iterator() خواندنِ کاربرها در تمامِ طولِ حلقه
# باز می‌ماند و روی SQLite همان، جدول را برای هر نویسنده‌ی هم‌زمان
# قفل می‌کند — قفلی که تا پایانِ حلقه رها نمی‌شود، پس هیچ مقدار
# retry هم نجاتش نمی‌دهد. این دقیقاً همان چیزی بود که تستِ
# هم‌زمانیِ campaigns را در حدودِ نیمی از اجراها قرمز می‌کرد.
#
# حافظه: اینجا فقط idها نگه داشته می‌شوند و این ماژول طبقِ
# docstringِ بالا برای لیست‌های کوچک است، نه کمپینِ انبوه.
for user_id in list(users.values_list('id', flat=True)):
if user_id in already_sent_user_ids:
continue
delivery = _claim_delivery(campaign, user.id)
delivery = _claim_delivery(campaign, user_id)
if delivery is None:
continue
def _notify_and_mark_sent():
def _notify_and_mark_sent(user_id=user_id, delivery=delivery):
with transaction.atomic():
notify(
user_id=user.id,
user_id=user_id,
type=CAMPAIGN_NOTIFICATION_TYPE,
title=campaign.title,
body=campaign.body,
@@ -251,16 +265,19 @@ def execute_campaign(campaign: MessageCampaign) -> None:
return
if campaign.channel == 'sms':
for user in users.only('id', 'phone').iterator():
if user.id in already_sent_user_ids:
# همان دلیلِ شاخه‌ی in_app بالا، و اینجا حتی مهم‌تر: وسطِ این حلقه
# یک فراخوانیِ شبکه‌ای به کاوه‌نگار انجام می‌شود، پس کرسرِ باز
# می‌توانست به‌اندازه‌ی کلِ ارسالِ پیامک‌ها جدول را قفل نگه دارد.
for user_id, phone in list(users.values_list('id', 'phone')):
if user_id in already_sent_user_ids:
continue
if not user.phone:
if not phone:
continue
delivery = _claim_delivery(campaign, user.id)
delivery = _claim_delivery(campaign, user_id)
if delivery is None:
continue
try:
account_services.send_sms(phone=user.phone, message=campaign.body)
account_services.send_sms(phone=phone, message=campaign.body)
except SMSDeliveryError as exc:
_mark_delivery_result(delivery, error=exc)
else:

View File

@@ -52,8 +52,8 @@ class ExecuteCampaignIdempotencyTests(TestCase):
"""
def setUp(self):
self.user1 = User.objects.create(name='u1', is_guest=False, phone='0910000001')
self.user2 = User.objects.create(name='u2', is_guest=False, phone='0910000002')
self.user1 = User.objects.create(name='u1', phone='0910000001')
self.user2 = User.objects.create(name='u2', phone='0910000002')
def test_in_app_second_run_skips_already_sent_users(self):
campaign = MessageCampaign.objects.create(
@@ -187,7 +187,7 @@ class CampaignDeliveryConcurrencyTests(TestCase):
"""
def setUp(self):
self.user = User.objects.create(name='u', is_guest=False, phone='0910000003')
self.user = User.objects.create(name='u', phone='0910000003')
self.campaign = MessageCampaign.objects.create(
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
)
@@ -261,7 +261,7 @@ class ExecuteCampaignRealConcurrencyTests(TransactionTestCase):
"""
def setUp(self):
self.user = User.objects.create(name='u', is_guest=False, phone='0910000004')
self.user = User.objects.create(name='u', phone='0910000004')
self.campaign = MessageCampaign.objects.create(
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
)
@@ -306,7 +306,7 @@ class InAppDeliveryConsistencyTests(TestCase):
"""
def setUp(self):
self.user = User.objects.create(name='u', is_guest=False, phone='0910000005')
self.user = User.objects.create(name='u', phone='0910000005')
self.campaign = MessageCampaign.objects.create(
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
)
@@ -437,7 +437,7 @@ class CampaignDeliveryAdminReadOnlyTests(TestCase):
username='admin3', email='admin3@example.com', password='pass12345',
)
self.client.force_login(self.staff_user)
self.user = User.objects.create(name='u', is_guest=False, phone='0910000009')
self.user = User.objects.create(name='u', phone='0910000009')
self.campaign = MessageCampaign.objects.create(title='t', body='b', channel='sms')
self.delivery = CampaignDelivery.objects.create(
campaign=self.campaign, user_id=self.user.id, channel='sms',

View File

@@ -0,0 +1,30 @@
"""
حفاظ‌های بازبینیِ امنیتی (کمپین‌ها در پنل).
"""
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.test import TestCase
from .models import CampaignDelivery, MessageCampaign
class CampaignDeliveryNeedsViewPermissionTests(TestCase):
"""لاگِ تحویلِ کمپین (user_id، متنِ خطای ارسال) فقط با view_campaigndelivery دیده می‌شود، نه برای هر کارمند."""
def setUp(self):
campaign = MessageCampaign.objects.create(title='t', body='b', channel='sms')
self.delivery = CampaignDelivery.objects.create(campaign=campaign, user_id=5, channel='sms',
status='failed', error_message='e')
self.staff = get_user_model().objects.create_user('st', 'st@example.com', 'pw-for-tests-only', is_staff=True)
self.client.force_login(self.staff)
def test_staff_without_permission_gets_403(self):
self.assertEqual(self.client.get('/admin/campaigns/campaigndelivery/').status_code, 403)
self.assertEqual(
self.client.get(f'/admin/campaigns/campaigndelivery/{self.delivery.pk}/change/').status_code, 403)
def test_view_permission_still_read_only(self):
self.staff.user_permissions.add(
Permission.objects.get(content_type__app_label='campaigns', codename='view_campaigndelivery'))
self.assertEqual(self.client.get('/admin/campaigns/campaigndelivery/').status_code, 200)
self.assertEqual(self.client.get('/admin/campaigns/campaigndelivery/add/').status_code, 403)

129
config/cache.py Normal file
View File

@@ -0,0 +1,129 @@
"""
RedisCache که با قطعی‌ی Redis درخواست‌ها را خراب نمی‌کند، ولی بی‌صدا هم نیست.
وضعیت: فقط وقتی فعال است که `CACHE_URL` ست باشد. روی سرور (۲۰۲۶-۱۰-۰۷) ست نیست و کش `LocMemCache`
است: Redisِ آن ماشین مالِ تیم‌های دیگر است و عمداً به آن وصل نمی‌شویم. این کلاس برای Redisِ اختصاصیِ
خودمان نوشته و آزموده شده است.
چرا: سقفِ نرخ، کشِ برنامه‌ی درسی، کشِ جوابِ دستیار و شمارنده‌های ورود از کش می‌خوانند.
`django.core.cache.backends.redis.RedisCache` با هر خطای شبکه استثنا می‌دهد و چون DRF برای هر
درخواستِ throttleدار کش را می‌خواند، قطعیِ Redis یعنی ۵۰۰ برای تقریباً همه‌ی API (هر هفت تستِ
`config/tests_cache_resilience.py` روی کشِ معمولی می‌شکند).
رفتار هنگامِ خطا (همه با `logger.error`، حداکثر هر ۳۰ ثانیه یک بار برای هر پروسه تا لاگ پر نشود؛
و `/api/health` در همان حال ۵۰۳ می‌دهد چون پروبِ کش شکست می‌خورد):
get → default (miss) set/delete → نادیده add → True («کلید نبود، ادامه بده»)
incr → ValueError («کلید نیست»؛ همان‌چیزی که کدِ فراخواننده از قبل می‌شناسد)
نتیجه‌ی عملی: در طولِ قطعی سقف‌های مبتنی بر کش (throttleهای DRF، پنجره‌ی ساعتیِ OTP، سقفِ ورودِ ادمین،
قفلِ یک‌دقیقه‌ایِ بازسنجیِ پرداخت) **اعمال نمی‌شوند** (fail-open)؛ کشِ درس‌ها هر بار از دیتابیس ساخته می‌شود.
آن‌چه در دیتابیس شمرده می‌شود (cooldown و سقفِ روزانه‌ی هر شماره، سقفِ کلِ پیامک، حدسِ هر کدِ OTP) سر جایش
می‌ماند. انتخابِ fail-open تصمیمِ ما بود: بسته‌شدنِ کلِ API به‌خاطر خرابیِ یک سرویسِ مشترک بدتر از چند
دقیقه بی‌سقف است. نیاز به بازنگری دارد اگر ترافیکِ مخرب دیده شود.
آزموده نشده: خطاهایی که از `redis.exceptions.RedisError` و `OSError` نیستند (مثلاً خطای serializer) عمداً
گرفته نمی‌شوند.
"""
import logging
import threading
import time
import redis
from django.core.cache.backends.base import DEFAULT_TIMEOUT
from django.core.cache.backends.redis import RedisCache
logger = logging.getLogger(__name__)
_ERRORS = (redis.exceptions.RedisError, OSError)
_LOG_EVERY_SECONDS = 30
_last_logged = 0.0
_lock = threading.Lock()
def _report(operation: str, exc: Exception) -> None:
global _last_logged
now = time.monotonic()
with _lock:
due = (now - _last_logged) >= _LOG_EVERY_SECONDS or _last_logged == 0.0
if due:
_last_logged = now
if due:
logger.error(
'cache: %s روی Redis شکست خورد (%s: %s)؛ سقف‌های مبتنی بر کش تا برگشتنِ Redis اعمال نمی‌شوند '
'و /api/health ۵۰۳ می‌دهد.', operation, type(exc).__name__, exc,
)
class ResilientRedisCache(RedisCache):
def get(self, key, default=None, version=None):
try:
return super().get(key, default, version)
except _ERRORS as exc:
_report('get', exc)
return default
def set(self, key, value, timeout=DEFAULT_TIMEOUT, version=None):
try:
return super().set(key, value, timeout, version)
except _ERRORS as exc:
_report('set', exc)
def add(self, key, value, timeout=DEFAULT_TIMEOUT, version=None):
try:
return super().add(key, value, timeout, version)
except _ERRORS as exc:
_report('add', exc)
return True
def delete(self, key, version=None):
try:
return super().delete(key, version)
except _ERRORS as exc:
_report('delete', exc)
return False
def get_many(self, keys, version=None):
try:
return super().get_many(keys, version)
except _ERRORS as exc:
_report('get_many', exc)
return {}
def set_many(self, data, timeout=DEFAULT_TIMEOUT, version=None):
try:
return super().set_many(data, timeout, version)
except _ERRORS as exc:
_report('set_many', exc)
return list(data)
def delete_many(self, keys, version=None):
try:
return super().delete_many(keys, version)
except _ERRORS as exc:
_report('delete_many', exc)
def has_key(self, key, version=None):
try:
return super().has_key(key, version)
except _ERRORS as exc:
_report('has_key', exc)
return False
def incr(self, key, delta=1, version=None):
try:
return super().incr(key, delta, version)
except _ERRORS as exc:
_report('incr', exc)
raise ValueError(f"Key '{key}' not found") from exc
def touch(self, key, timeout=DEFAULT_TIMEOUT, version=None):
try:
return super().touch(key, timeout, version)
except _ERRORS as exc:
_report('touch', exc)
return False
def clear(self):
try:
return super().clear()
except _ERRORS as exc:
_report('clear', exc)

53
config/db.py Normal file
View File

@@ -0,0 +1,53 @@
"""
آزاد کردنِ اتصالِ دیتابیس پیش از یک انتظارِ طولانیِ شبکه.
با pool (config/settings.py) هر نخ اتصال را از اولین کوئری تا پایانِ درخواست نگه می‌دارد. جوابِ
دستیار ۳ تا ۴۰ ثانیه منتظرِ Qdrant و مدل می‌ماند؛ اگر اتصال در این مدت دستِ همان نخ بماند، چند
سوالِ هم‌زمان از دستیار کلِ pool را می‌گیرد و بقیه‌ی اپ (نقشه، آزمون) پشتشان صف می‌کشد. این تابع
اتصال را به pool پس می‌دهد؛ کوئریِ بعدی همان نخ خودکار یکی تازه قرض می‌گیرد.
"""
import logging
from django.db import connection
logger = logging.getLogger(__name__)
def release_db_connection() -> None:
"""
اتصالِ این نخ را پس می‌دهد، مگر وسطِ یک تراکنش باشیم: بستنِ اتصال آن‌جا تراکنش را می‌کشد.
در درخواستِ وب هیچ تراکنشی دورِ view نیست (ATOMIC_REQUESTS خاموش است)؛ تراکنش‌هایی که
TestCase دورِ هر تست می‌پیچد بی‌صدا رد می‌شوند، و هر atomic()ِ دیگری یعنی کسی این را داخلِ
تراکنشِ خودش صدا زده (باگ؛ اتصال نگه داشته و WARNING لاگ می‌شود).
"""
if connection.in_atomic_block:
if not all(getattr(a, '_from_testcase', False) for a in connection.atomic_blocks):
logger.warning('release_db_connection called inside atomic(); connection kept')
return
connection.close()
# ── pool پر: ۵۰۳ به‌جای ۵۰۰ ──
# وقتی همه‌ی اتصال‌های pool تا DB_POOL_TIMEOUT ثانیه مشغول ماندند، درخواست ۵۰۳ با Retry-After می‌گیرد
# (کاهشِ بار، مثلِ سرویس‌های بزرگ زیرِ ازدحام): کلاینت می‌فهمد خرابی نیست و چند ثانیه بعد دوباره
# می‌فرستد. هر بار ERROR لاگ می‌شود، چون یعنی بار از ظرفیتِ این سرور گذشته. دو جا صدا زده می‌شود:
# هندلرِ خطای DRF (config/exceptions.py؛ هر خطای ناشناخته را خودش ۵۰۰ می‌کند و به میان‌افزار نمی‌رسد)
# و DatabaseBusyMiddleware برای viewهای غیرِ DRF مثلِ /admin/.
SERVER_BUSY_RETRY_AFTER = 5
SERVER_BUSY_BODY = {'detail': 'سرور الان خیلی شلوغ است؛ چند ثانیه دیگر دوباره امتحان کنید.', 'code': 'server_busy'}
def is_pool_timeout(exc) -> bool:
"""خطای «همه‌ی اتصال‌های pool مشغول‌اند». جنگو PoolTimeout را در OperationalError می‌پیچد."""
from django.db import OperationalError
if not isinstance(exc, OperationalError):
return False
from psycopg_pool import PoolTimeout
return isinstance(exc.__cause__, PoolTimeout)
def log_server_busy(request) -> None:
logger.error('db_pool_exhausted method=%s path=%s',
getattr(request, 'method', ''), getattr(request, 'path', ''))

View File

@@ -7,6 +7,9 @@ JSON. این هندلر اون حالت رو با traceback کامل لاگ می
"""
import logging
from django.core.exceptions import RequestDataTooBig, SuspiciousOperation
from config.db import SERVER_BUSY_BODY, SERVER_BUSY_RETRY_AFTER, is_pool_timeout, log_server_busy
from rest_framework.response import Response
from rest_framework.views import exception_handler as drf_exception_handler
@@ -19,11 +22,28 @@ def custom_exception_handler(exc, context):
return response
request = context.get('request')
path = getattr(request, 'path', '')
# بدنه‌ی بزرگ‌تر از DATA_UPLOAD_MAX_MEMORY_SIZE (≈ ۲٫۵ مگابایت): Django خودش این را ۴۰۰ می‌کند، ولی
# چون DRF آن را نمی‌شناسد پیش‌تر اینجا «۵۰۰ + ERRORِ لاگ با traceback» می‌شد و هر درخواستِ بزرگ لاگ را پر
# می‌کرد (۲۰۲۶-۱۰-۰۷ روی سرورِ زنده دیده شد). باقیِ SuspiciousOperationها هم همان ۴۰۰ِ Django را می‌گیرند.
# poolِ دیتابیس پر (config/db.py): ۵۰۳ با Retry-After. پیش از خواندنِ request.user پایین‌تر، که اگر
# احرازِ هویت خودش همین خطا را داده بود دوباره منتظرِ pool می‌ماند.
if is_pool_timeout(exc):
log_server_busy(request)
return Response(SERVER_BUSY_BODY, status=503, headers={'Retry-After': str(SERVER_BUSY_RETRY_AFTER)})
if isinstance(exc, RequestDataTooBig):
logger.warning('request_too_large path=%s', path)
return Response({'detail': 'حجمِ درخواست بیش از حدِ مجاز است.'}, status=413)
if isinstance(exc, SuspiciousOperation):
logger.warning('suspicious_operation path=%s type=%s', path, type(exc).__name__)
return Response({'detail': 'درخواستِ نامعتبر.'}, status=400)
user = getattr(request, 'user', None)
user_id = user.id if getattr(user, 'is_authenticated', False) else None
logger.exception(
'unhandled_exception path=%s user_id=%s',
getattr(request, 'path', ''), user_id,
path, user_id,
)
return Response(
{'detail': 'خطای غیرمنتظره‌ای رخ داد؛ لطفاً بعداً دوباره تلاش کنید.'},

View File

@@ -12,6 +12,9 @@ GET /api/health — «آیا این سرور واقعاً سالم است؟»
عمداً هیچ سکرتی برنمی‌گرداند: فقط بله/خیرِ «تنظیم شده» و شمارشِ محتوا.
"""
import logging
import os
import time
from datetime import timedelta
from django.conf import settings
@@ -24,6 +27,8 @@ from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
logger = logging.getLogger(__name__)
def _check_database() -> dict:
try:
@@ -108,14 +113,231 @@ def _traffic() -> dict:
def _check_integrations() -> dict:
"""فقط «تنظیم شده یا نه» — هیچ کلیدی برنمی‌گردد."""
"""
فقط «تنظیم شده یا نه» — هیچ کلیدی برنمی‌گردد. `aiProvider` وضعیتِ واقعیِ
آخرین تماس است: کلیدِ تنظیم‌شده با اعتبارِ تمام‌شده هم `openai: true` بود.
"""
return {
'qdrant': bool(getattr(settings, 'QDRANT_API_KEY', '')),
'openai': bool(getattr(settings, 'OPENAI_API_KEY', '')),
'sms': bool(getattr(settings, 'KAVENEGAR_API_KEY', '')),
'bazaar': _bazaar_configured(),
'bazaarDynamicPrice': bool(getattr(settings, 'BAZAAR_DYNAMIC_PRICE_KEY', '')),
'aiProvider': _ai_provider_state(),
'chatModel': getattr(settings, 'OPENAI_CHAT_MODEL', ''),
'chatModelUpper': getattr(settings, 'OPENAI_CHAT_MODEL_UPPER', '') or None,
}
def _bazaar_configured() -> bool:
try:
from payment.bazaar import is_configured
return is_configured()
except Exception: # noqa: BLE001 — سلامت نباید با خطای خواندنِ اعتبارِ بازار ۵۰۰ بدهد؛ ولی دیده شود
logger.exception('health: بررسیِ تنظیمِ بازار شکست خورد')
return False
def _ai_provider_state() -> dict:
try:
from statuslog.models import StatusChangeLog
row = StatusChangeLog.objects.filter(label='ai_tutor.Provider').values(
'new_status', 'created_at').first()
except Exception as exc: # noqa: BLE001
return {'error': type(exc).__name__}
if row is None:
return {'state': 'ok', 'since': None}
return {'state': row['new_status'], 'since': row['created_at'].isoformat()}
def _offsite_backup() -> dict:
"""
کپیِ زنده‌ی دیتابیس روی سرورِ دیگر (`ops/pgbackup/`، شرح در docs/operations.md «بکاپ»).
آن سرور با `pg_receivewal` از اسلاتِ تکثیرِ `OFFSITE_WAL_SLOT` هر تغییر را لحظه‌ای می‌گیرد؛ این‌جا از
سمتِ همین دیتابیس دیده می‌شود که وصل است و چقدر عقب است. ست‌نبودنِ اسلات روی Postgres یعنی کپیِ
بیرونی نداریم و هشدار می‌شود. base backupِ روزانه‌ی آن سرور از این‌جا دیده نمی‌شود (journalِ همان‌جا).
"""
if connection.vendor != 'postgresql':
return {'state': 'not_postgres'}
if settings.OFFSITE_SYNC_STATUS_FILE:
return _pushed_backup()
slot = settings.OFFSITE_WAL_SLOT
if not slot:
return {'state': 'not_configured'}
try:
with connection.cursor() as cursor:
cursor.execute(
'SELECT active, wal_status, pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) '
'FROM pg_replication_slots WHERE slot_name = %s',
[slot],
)
row = cursor.fetchone()
except Exception as exc: # noqa: BLE001
return {'state': 'error', 'error': type(exc).__name__}
return _slot_state(row)
def _slot_state(row) -> dict:
if row is None:
return {'state': 'slot_missing'}
active, wal_status, lag = row
lag = int(lag or 0)
if wal_status == 'lost':
# سقفِ max_slot_wal_keep_size خورده: دیسکِ این سرور امن ماند ولی زنجیره‌ی WAL آن‌جا بریده است.
state = 'lost'
elif not active:
state = 'disconnected'
elif lag > settings.OFFSITE_WAL_MAX_LAG_BYTES:
state = 'lagging'
else:
state = 'ok'
return {'state': state, 'lagBytes': lag}
def _pushed_backup() -> dict:
"""
بکاپِ رمزشده‌ی ۲۱۶: pgBackRest هر WAL را با archive_command آرشیو می‌کند (pg_stat_archiver) و
`tadrisino-offsite-sync` مخزن را هر دقیقه به ۲۴۷ می‌فرستد و زمانش را در OFFSITE_SYNC_STATUS_FILE می‌نویسد.
کهنگیِ آخرین آرشیو هشدار نیست: دیتابیسِ بی‌تغییر WALِ تازه‌ای ندارد که آرشیو شود.
"""
try:
with connection.cursor() as cursor:
cursor.execute('SELECT last_archived_time, last_failed_time FROM pg_stat_archiver')
last_archived, last_failed = cursor.fetchone()
except Exception as exc: # noqa: BLE001
return {'state': 'error', 'error': type(exc).__name__}
try:
with open(settings.OFFSITE_SYNC_STATUS_FILE) as f:
synced_at = int(f.read().strip())
except FileNotFoundError:
synced_at = None
except (OSError, ValueError) as exc:
return {'state': 'error', 'error': type(exc).__name__}
return _pushed_state(last_archived, last_failed, synced_at, time.time())
def _pushed_state(last_archived, last_failed, synced_at, now) -> dict:
age = None if synced_at is None else int(now - synced_at)
if last_failed is not None and (last_archived is None or last_failed > last_archived):
state = 'archive_failing'
elif age is None:
state = 'never_synced'
elif age > settings.OFFSITE_SYNC_MAX_AGE_SECONDS:
state = 'sync_stale'
else:
state = 'ok'
return {
'state': state,
'syncAgeSeconds': age,
'lastArchivedAt': last_archived.isoformat() if last_archived else None,
}
# اتصال‌هایی بیرون از poolِ workerها: pg_receivewal و pg_basebackupِ سرورِ بکاپ (۲)، pg_dump و
# migrateِ deploy.sh (۲)، فرمانِ مدیریتی یا psqlِ دستی (۲)، و workerی که gunicorn با max_requests
# عوض می‌کند و تا بسته شدنِ قبلی هم‌زمان بالاست (یک pool).
DB_CONNECTION_RESERVE = 6
def _db_pool() -> dict:
"""
poolِ اتصالِ همین worker (config/settings.py) و بودجه‌ی کلِ اتصال‌ها در برابرِ max_connections.
آمارِ pool مالِ workerی است که این درخواست را جواب داد، نه همه؛ `requestsErrors` یعنی چند بار
درخواستی تا DB_POOL_TIMEOUT اتصال نگرفت و ۵۰۳ شد (از بالا آمدنِ این worker). بودجه از
GUNICORN_WORKERS (gunicorn.conf.py، post_fork) حساب می‌شود؛ بیرون از gunicorn (runserver) ۱ است.
"""
if connection.vendor != 'postgresql':
return {'state': 'not_postgres'}
pool = connection.pool
if pool is None:
return {'state': 'off'}
try:
with connection.cursor() as cursor:
cursor.execute(
"SELECT current_setting('max_connections')::int - current_setting('superuser_reserved_connections')::int, "
'(SELECT count(*) FROM pg_stat_activity WHERE datname = current_database())'
)
allowed, in_use = cursor.fetchone()
except Exception as exc: # noqa: BLE001
return {'state': 'error', 'error': type(exc).__name__}
stats = pool.get_stats()
workers = int(os.environ.get('GUNICORN_WORKERS', '1'))
budget = workers * pool.max_size + DB_CONNECTION_RESERVE
return {
'state': 'ok' if budget <= allowed else 'over_budget',
'workers': workers,
'poolMaxSize': pool.max_size,
'budget': budget,
'maxConnections': allowed,
'connectionsInUse': in_use,
'thisWorker': {
'size': stats.get('pool_size', 0),
'available': stats.get('pool_available', 0),
'waiting': stats.get('requests_waiting', 0),
'requests': stats.get('requests_num', 0),
'requestsQueued': stats.get('requests_queued', 0),
'requestsErrors': stats.get('requests_errors', 0),
},
}
def _ai_concurrency() -> dict:
from ai_tutor import concurrency
return {'maxPerWorker': concurrency.limit(), 'inFlightThisWorker': concurrency.in_flight()}
def _db_pool_warnings(pool: dict) -> list[str]:
if pool.get('state') == 'over_budget':
return [
f"بودجه‌ی اتصالِ دیتابیس ({pool['workers']} worker × {pool['poolMaxSize']} + {DB_CONNECTION_RESERVE} رزرو "
f"= {pool['budget']}) از سقفِ Postgres ({pool['maxConnections']}) بیشتر است: زیرِ بار درخواست‌ها ۵۰۰ می‌گیرند. "
'DB_POOL_MAX_SIZE یا تعدادِ workerها را کم کنید.'
]
if pool.get('state') == 'error':
return ['وضعیتِ poolِ دیتابیس خوانده نشد.']
return []
_OFFSITE_MESSAGES = {
'not_configured': 'کپیِ زنده‌ی دیتابیس بیرون از این سرور تنظیم نشده (OFFSITE_WAL_SLOT).',
'slot_missing': 'اسلاتِ تکثیرِ OFFSITE_WAL_SLOT در Postgres نیست؛ سرورِ بکاپ هیچ‌وقت وصل نشده یا اسلات حذف شده.',
'disconnected': 'سرورِ بکاپ الان وصل نیست؛ تغییرات روی همین سرور جمع می‌شوند تا سقفِ max_slot_wal_keep_size.',
'lagging': 'سرورِ بکاپ از دیتابیس عقب است.',
'lost': 'سرورِ بکاپ آن‌قدر قطع بود که زنجیره‌ی WAL برید؛ روی آن سرور اسلات و base backupِ تازه لازم است.',
'error': 'وضعیتِ کپیِ زنده‌ی دیتابیس خوانده نشد.',
'archive_failing': 'آرشیوِ WAL (pgBackRest) شکست می‌خورد و تغییراتِ تازه بکاپ نمی‌شوند: sudo -u postgres pgbackrest --stanza=zareian check',
'never_synced': 'بکاپِ رمزشده هنوز هیچ‌وقت به سرورِ بیرونی (۲۴۷) نرسیده (فایلِ OFFSITE_SYNC_STATUS_FILE نیست).',
'sync_stale': 'ارسالِ بکاپِ رمزشده به ۲۴۷ عقب افتاده: journalctl -u tadrisino-offsite-sync',
}
def _offsite_warnings(offsite: dict) -> list[str]:
msg = _OFFSITE_MESSAGES.get(offsite['state'])
return [msg] if msg else []
# بیش از این یعنی آرشیوِ شبانه (`ops/applogs/archive-app-log.sh`) چند روز است اجرا نشده؛
# با ترافیکِ ۶۰۰ کاربرِ فعال یک روز حدودِ ۳۰ مگ است (برآورد، نه سنجش).
APP_LOG_MAX_BYTES = 256 * 2**20
def _app_log_warnings() -> list[str]:
"""با چرخشِ بیرونی، برنامه app.log را پاک نمی‌کند؛ اگر آرشیوِ شبانه بخوابد فایل بی‌انتها بزرگ می‌شود."""
if not settings.LOG_DIR or settings.LOG_FILE_ROTATION != 'external':
return []
try:
size = os.path.getsize(os.path.join(settings.LOG_DIR, 'app.log'))
except FileNotFoundError:
return []
if size > APP_LOG_MAX_BYTES:
return [f'app.log {size // 2**20} مگ است: آرشیوِ شبانه‌ی لاگ (zareian-applog-archive.timer) اجرا نشده.']
return []
def _warnings() -> list[str]:
"""
چیزهایی که خرابی نیستند ولی روی یک سرورِ عمومی خطرناک‌اند.
@@ -132,6 +354,29 @@ def _warnings() -> list[str]:
)
if getattr(settings, 'DEV_MASTER_OTP', None) is not None:
out.append('کدِ یک‌سانِ ورود (DEV_MASTER_OTP) فعال است — هر کسی می‌تواند به‌جای هر کاربری وارد شود.')
test_phones = getattr(settings, 'TEST_LOGIN_PHONES', {})
if test_phones:
# نه خودِ شماره و نه کد برنمی‌گردد؛ فقط تعداد، تا کسی که تنظیماتِ سرور
# را ندیده هم بداند چند حسابِ تست با کدِ ثابت باز است و بتواند ببنددشان.
# تعداد عمداً گفته نمی‌شود: این صفحه عمومی است و «۷ حسابِ کدِ ثابت» برای
# مهاجم یعنی هدفِ مشخص برای حدس زدن.
out.append(
'شماره‌های تستِ ورود با کدِ ثابت فعال‌اند (TEST_LOGIN_PHONES). روی سرور باید خالی باشد: '
'هر کس کد را دیده باشد وارد می‌شود. برای آزمون یک OTPCode در پنلِ ادمین بسازید.'
)
if not settings.DEBUG and settings.SECRET_KEY.startswith('django-insecure'):
# بزرگ‌ترین ریسکِ باز (docs/security-before-production.md): این کلید در گیت است و هر
# کس ریپو را ببیند برای هر کاربری توکن می‌سازد. فقط بله/خیر، نه خودِ کلید.
out.append(
'کلیدِ امضای توکن همان کلیدِ کامیت‌شده در ریپو است (django-insecure): هر کس ریپو را '
'ببیند می‌تواند به‌جای هر کاربری وارد شود. راهِ رفع در docs/security-before-production.md.'
)
if _ai_provider_state().get('state') == 'quota_exhausted':
out.append(
'اعتبارِ سرویسِ هوش مصنوعی (GapGPT) تمام شده: دستیار برای همه‌ی کاربرها '
'«در دسترس نیست» می‌دهد تا حساب شارژ شود.'
)
out.extend(_app_log_warnings())
backend = settings.CACHES['default']['BACKEND']
if not settings.DEBUG and 'LocMem' in backend:
out.append('کش محلیِ هر پروسه است؛ محدودیتِ نرخ بینِ workerها مشترک نیست.')
@@ -140,6 +385,9 @@ def _warnings() -> list[str]:
return out
from payment.permissions import HasInternalServiceKey # noqa: E402
class HealthView(APIView):
"""
GET /api/health — وضعیتِ این نسخه‌ی مستقرشده.
@@ -163,7 +411,16 @@ class HealthView(APIView):
}
critical_ok = all(checks[k]['ok'] for k in ('database', 'migrations', 'cache'))
warnings = _warnings()
status = 200 if critical_ok else 503
# عموم فقط ok/error می‌بینند؛ جزئیات (هشدارها، ترافیک، سرویس‌های وصل‌شده، IP) پشتِ هدرِ
# X-Internal-Key. کد ۲۰۰/۵۰۳ عمومی می‌ماند تا پایشِ بیرونی کار کند. اگر INTERNAL_SERVICE_KEY
# در .env نباشد، هیچ‌کس جزئیات را نمی‌بیند (نه اینکه همه ببینند).
if not HasInternalServiceKey().has_permission(request, self):
return Response({'status': 'ok' if critical_ok else 'error'}, status=status)
offsite = _offsite_backup()
db_pool = _db_pool()
warnings = _warnings() + _offsite_warnings(offsite) + _db_pool_warnings(db_pool)
payload = {
'status': 'ok' if critical_ok else 'error',
'debug': bool(settings.DEBUG),
@@ -171,6 +428,15 @@ class HealthView(APIView):
'checks': checks,
'integrations': _check_integrations(),
'traffic': _traffic(),
'offsiteBackup': offsite,
'dbPool': db_pool,
# جواب‌های هم‌زمانِ دستیار در workerی که این را جواب داد (ai_tutor/concurrency.py).
'aiConcurrency': _ai_concurrency(),
'allowedOrigins': list(getattr(settings, 'CORS_ALLOWED_ORIGINS', [])),
# IPای که throttle برای همین درخواست می‌بیند. باید IPِ عمومیِ خودِ
# صدازننده باشد؛ اگر IPِ ArvanCloud یا nginx بود، NUM_PROXIES غلط
# است و همه‌ی مهمان‌ها یک سهمِ مشترک دارند. فقط IPِ خودِ صدازننده
# را به خودش برمی‌گرداند، پس چیزی لو نمی‌دهد.
'clientIp': ScopedRateThrottle().get_ident(request),
}
return Response(payload, status=200 if critical_ok else 503)
return Response(payload, status=status)

View File

@@ -89,3 +89,136 @@ class APIRequestLoggingMiddleware:
log_func(message, request.method, request.path, status, duration_ms, _user_id_for_log(request))
except Exception:
logger.exception('api_request logging failed')
class TrustedProxyMiddleware:
"""
X-Forwarded-For فقط وقتی پذیرفته می‌شود که درخواست واقعاً از ArvanCloud آمده باشد.
NUM_PROXIES=2 فرض می‌کند همیشه آروان و nginx جلوی ما هستند؛ ولی nginxِ این ماشین روی IPِ خودش
(پورت ۸۰ و ۴۴۳) هم جواب می‌دهد. کسی که مستقیم به IP بزند و در هر درخواست یک XFFِ تازه بفرستد،
برای DRF هر بار «کاربرِ تازه» بود و از همه‌ی سقف‌های IP-محور (درخواستِ کد، حدسِ کد، ورودِ ادمین)
رد می‌شد (۲۰۲۶-۱۰-۱۳ روی سرور بازتولید شد: ۶۵ از ۶۵ درخواستِ health با XFFِ متفاوت ۲۰۰).
X-Real-IP را nginx خودش از آدرسِ TCP می‌گذارد (`proxy_params`ِ سایتِ `mathapp`؛ مقدارِ کلاینت را
بازنویسی می‌کند)، پس جعل‌پذیر نیست. اگر آن آدرس در TRUSTED_PROXY_NETWORKS نیست، XFF دور ریخته و
خودِ آن آدرس IPِ کاربر می‌شود. بی X-Real-IP (runserverِ محلی، تست‌ها) دست نمی‌زند.
سنجیده نشد: رنج‌های IPv6ِ آروان (فهرستِ آروان در این تاریخ فقط IPv4 داشت).
"""
WARN_EVERY_SECONDS = 60 * 60
def __init__(self, get_response):
from django.conf import settings
self.get_response = get_response
self.networks = settings.TRUSTED_PROXY_NETWORKS
def _trusted(self, peer: str) -> bool:
import ipaddress
try:
address = ipaddress.ip_address(peer)
except ValueError:
return False
return any(address in network for network in self.networks)
def __call__(self, request):
peer = request.META.get('HTTP_X_REAL_IP', '').strip()
if peer and not self._trusted(peer):
forwarded = request.META.get('HTTP_X_FORWARDED_FOR', '')
# XFF با دو مقدار یعنی پراکسیِ دیگری جلوی nginx بوده: یا کسی XFF جعل کرده، یا آروان رنجِ
# تازه‌ای گرفته که در فهرست نیست. دومی همه‌ی کاربرانِ آن edge را یک IP می‌کند و سقف‌ها برایشان
# زود پر می‌شود؛ پس هر peer ساعتی یک بار WARNING می‌گیرد تا در لاگ دیده شود.
if forwarded.count(',') >= 1:
from django.core.cache import cache
if cache.add(f'untrusted_proxy_warned:{peer}', 1, self.WARN_EVERY_SECONDS):
logger.warning('X-Forwarded-For from a peer outside TRUSTED_PROXY_NETWORKS ignored: '
'peer=%s (a new ArvanCloud range would also look like this)', peer)
request.META['HTTP_X_FORWARDED_FOR'] = peer
return self.get_response(request)
class APINoStoreMiddleware:
"""
هر پاسخِ `/api/` هدرِ `Cache-Control: no-store` می‌گیرد، مگر خودِ view
چیزِ دیگری گذاشته باشد (مثلاً استریمِ دستیار که `no-cache` دارد).
ArvanCloud جلوی سرور است و الان برای API کش نمی‌کند (`x-cache: BYPASS`)،
ولی این فقط به تنظیمِ پنلِ آروان بند است. پاسخ‌های API به کاربر وابسته‌اند
(توکنِ Bearer)؛ اگر روزی کسی کش را در پنل روشن کند، بدونِ این هدر یک
دانش‌آموز می‌توانست `/api/home` یا پروفایلِ دیگری را ببیند.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
if request.path.startswith(_LOGGED_PATH_PREFIX) and not response.has_header('Cache-Control'):
response['Cache-Control'] = 'private, no-store'
return response
class DatabaseBusyMiddleware:
"""
poolِ دیتابیسِ پر → ۵۰۳ با Retry-After به‌جای ۵۰۰ (config/db.py) برای viewهای غیرِ DRF مثلِ /admin/؛
viewهای DRF را هندلرِ خطای DRF (config/exceptions.py) جواب می‌دهد، چون هر خطا را خودش ۵۰۰ می‌کند
و استثنا هیچ‌وقت به این‌جا نمی‌رسد. فقط خطای pool را می‌گیرد؛ هر OperationalErrorِ دیگری (Postgres
پایین، کوئریِ timeout‌خورده) همان ۵۰۰ِ قبلی می‌ماند.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
return self.get_response(request)
def process_exception(self, request, exception):
from django.http import JsonResponse
from config.db import SERVER_BUSY_BODY, SERVER_BUSY_RETRY_AFTER, is_pool_timeout, log_server_busy
if not is_pool_timeout(exception):
return None
log_server_busy(request)
response = JsonResponse(SERVER_BUSY_BODY, status=503)
response['Retry-After'] = str(SERVER_BUSY_RETRY_AFTER)
return response
class AdminLoginThrottleMiddleware:
"""
سقفِ تلاشِ ورود به /admin/ برای هر IP (ADMIN_LOGIN_MAX_ATTEMPTS در ۱۵ دقیقه).
پنلِ ادمین روی دامنه‌ی عمومی است و فرمِ ورودِ جنگو هیچ محدودیتی ندارد؛
یعنی رمزِ ادمین (که همه‌ی محتوا و پرداخت‌ها را کنترل می‌کند) بی‌سقف قابلِ
حدس زدن بود. شمارش در کشِ پیش‌فرض است: تا CACHE_URL (Redis) تنظیم نشده،
برای هر worker جدا — یعنی سقفِ واقعی تا سه برابر. IP با همان منطقِ
NUM_PROXIESِ throttleهای DRF خوانده می‌شود.
"""
WINDOW_SECONDS = 15 * 60
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.method == 'POST' and request.path.startswith('/admin/login'):
from django.conf import settings
from django.core.cache import cache
from django.http import HttpResponse
from rest_framework.throttling import BaseThrottle
key = f'admin_login:{BaseThrottle().get_ident(request)}'
cache.add(key, 0, self.WINDOW_SECONDS)
try:
attempts = cache.incr(key)
except ValueError:
cache.set(key, 1, self.WINDOW_SECONDS)
attempts = 1
if attempts > getattr(settings, 'ADMIN_LOGIN_MAX_ATTEMPTS', 10):
logger.warning('admin login throttled: ident=%s attempts=%s', key, attempts)
return HttpResponse(
'تلاشِ ورود بیش از حد مجاز است؛ ۱۵ دقیقه‌ی دیگر دوباره امتحان کنید.',
status=429, content_type='text/plain; charset=utf-8',
)
return self.get_response(request)

24
config/renderers.py Normal file
View File

@@ -0,0 +1,24 @@
"""
JSONِ همه‌ی پاسخ‌های API، با متنِ فارسیِ بی‌اعراب و بی «ی»ِ اضافه (`content.plain_math.plain_persian`).
چرا این‌جا و نه در داده: محتوا (۱۸ هزار سوال، عنوانِ فصل/جزیره/درس) و پیام‌های کد اعراب دارند. بازنویسیِ عنوان‌ها
در دیتابیس و seed اتصالِ سوال به درس را می‌شکند (سوال‌ها با مسیرِ عنوان بسته می‌شوند و دفترِ SeedRecord با هشِ
عنوان کلید می‌خورد). یک لایه در خروجی هر پیام و محتوایی را، تازه یا قدیمی، یک‌جا می‌گیرد؛ ادمین و دیتابیس همان
متنِ اصلی را نگه می‌دارند. پاسخِ زنده‌ی دستیار (SSE) از این renderer رد نمی‌شود و همین کار را در
`rag.services.mathtext.to_plain` می‌کند.
هزینه: `plain_persian_utf8` را ببین؛ زیرِ بارِ سرور اندازه نشده.
"""
from rest_framework.renderers import JSONRenderer
from content.plain_math import plain_persian_utf8
class PlainPersianJSONRenderer(JSONRenderer):
"""یک گذر روی بایت‌های JSONِ نهایی، نه روی تک‌تکِ رشته‌های داده (که روی کلِ curriculum ۴ برابرِ کندتر بود).
ensure_ascii در DRF خاموش است (UNICODE_JSON پیش‌فرض)، پس حروفِ فارسی خودشان در خروجی‌اند، نه \\uXXXX؛
`ApiOutputTests` همین را می‌سنجد."""
def render(self, data, accepted_media_type=None, renderer_context=None):
body = super().render(data, accepted_media_type, renderer_context)
return plain_persian_utf8(body) if body else body

View File

@@ -10,8 +10,11 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.2/ref/settings/
"""
import ipaddress
import os
import re
from pathlib import Path
from urllib.parse import urlsplit
from django.core.exceptions import ImproperlyConfigured
@@ -51,21 +54,49 @@ if not SECRET_KEY:
else:
import secrets
import time
# سه workerِ gunicorn هم‌زمان به این‌جا می‌رسند. فایل فقط یک بار و اتمی ساخته می‌شود
# (O_EXCL، و نوشتن در فایلِ موقت + rename)، و بقیه همان را می‌خوانند؛ در غیرِ این صورت هر
# worker کلیدِ خودش را می‌ساخت و توکن فقط روی یکی از آن‌ها معتبر بود. سنجیده با سه
# پروسه‌ی هم‌زمان، نه با gunicornِ واقعی.
_key_file = BASE_DIR / '.django_secret_key'
try:
SECRET_KEY = _key_file.read_text().strip()
except OSError:
SECRET_KEY = ''
if not SECRET_KEY:
SECRET_KEY = secrets.token_urlsafe(64)
_lock_file = BASE_DIR / '.django_secret_key.lock'
SECRET_KEY = ''
for _attempt in range(50):
try:
_key_file.write_text(SECRET_KEY)
_key_file.chmod(0o600)
SECRET_KEY = _key_file.read_text().strip()
except FileNotFoundError:
SECRET_KEY = ''
except OSError as exc:
raise ImproperlyConfigured(f'خواندنِ {_key_file} ممکن نشد ({exc}).') from exc
if SECRET_KEY:
break
try:
_fd = os.open(_lock_file, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
except FileExistsError:
time.sleep(0.1) # پروسه‌ی دیگری در حالِ ساختنِ کلید است
continue
except OSError as exc:
raise ImproperlyConfigured(
f'DJANGO_SECRET_KEY ست نیست و نوشتنِ {_key_file} هم ممکن نشد ({exc}). '
f'DJANGO_SECRET_KEY ست نیست و ساختنِ {_key_file} هم ممکن نشد ({exc}). '
'یا متغیر را ست کنید یا اجازه‌ی نوشتن در پوشه‌ی پروژه بدهید.'
) from exc
try:
os.close(_fd)
if not _key_file.exists():
_tmp = BASE_DIR / '.django_secret_key.tmp'
_tmp_fd = os.open(_tmp, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
with os.fdopen(_tmp_fd, 'w') as fh:
fh.write(secrets.token_urlsafe(64))
os.replace(_tmp, _key_file)
finally:
_lock_file.unlink(missing_ok=True)
if not SECRET_KEY:
raise ImproperlyConfigured(
f'کلیدِ امضا ساخته نشد: {_lock_file} مانده است. اگر هیچ پروسه‌ای در حالِ اجرا نیست، '
'آن را پاک کنید و دوباره راه‌اندازی کنید.'
)
def _env_list(name: str) -> list[str]:
return [v.strip() for v in os.environ.get(name, '').split(',') if v.strip()]
@@ -88,20 +119,47 @@ def _with_defaults(name: str, defaults: list[str]) -> list[str]:
return out
# دامنه‌های شناخته‌شده‌ی خودمان همیشه مجازند تا یک .env قدیمی یا ناقص باعثِ
# DisallowedHost نشود؛ هر چه در محیط باشد به این‌ها اضافه می‌شود.
# --- دامنه‌ها ----------------------------------------------------------------
# دو آدرسِ عمومی؛ تنها جای کد که دامنه در آن نوشته شده. ALLOWED_HOSTS، CSRF،
# CORS، callbackِ زرین‌پال، برگشت از پرداخت و redirectِ بازار همه از همین دو
# ساخته می‌شوند. برای جابه‌جایی دامنه فقط این دو را در .env عوض کنید؛ جزئیات
# و ترتیبِ کار در docs/operations.md («عوض کردنِ دامنه»).
#
# پیش‌فرض دارند چون خالی بودنشان سرویس را نمی‌خواباند، فقط مسیرِ پرداخت و
# فرانت را بی‌صدا می‌شکند. `.strip() or` لازم است: کلید ممکن است در .env
# باشد ولی خالی، و os.environ.get فقط وقتی کلید *نباشد* پیش‌فرض می‌دهد.
PUBLIC_BASE_URL = (
os.environ.get('PUBLIC_BASE_URL', '').strip() or 'https://mathapi.tadrisino2.ir'
).rstrip('/')
# آدرسِ خودِ اپ (فرانت)؛ برگشت از درگاه و CORS از آن ساخته می‌شوند.
FRONTEND_URL = (
os.environ.get('FRONTEND_URL', '').strip() or 'https://math.tadrisino2.ir'
).rstrip('/')
def _origin(url):
parts = urlsplit(url)
return f'{parts.scheme}://{parts.netloc}' if parts.scheme and parts.netloc else ''
def _hostname(url):
return urlsplit(url).hostname or ''
# دامنه‌های خودمان (از دو آدرسِ بالا) همیشه مجازند تا یک .env قدیمی یا ناقص
# باعثِ DisallowedHost نشود؛ هر چه در محیط باشد به این‌ها اضافه می‌شود. در دورانِ
# جابه‌جاییِ دامنه، دامنه‌ی قبلی را در DJANGO_ALLOWED_HOSTS نگه دارید تا تا
# عوض شدنِ DNS هر دو جواب بدهند.
DEFAULT_ALLOWED_HOSTS = [
'api.math.tadrisino2.ir',
'math.tadrisino2.ir',
'localhost', '127.0.0.1',
]
h for h in (_hostname(PUBLIC_BASE_URL), _hostname(FRONTEND_URL)) if h
] + ['localhost', '127.0.0.1']
ALLOWED_HOSTS = _with_defaults('DJANGO_ALLOWED_HOSTS', DEFAULT_ALLOWED_HOSTS)
# لازم برای ورود به پنلِ ادمین از پشتِ پراکسیِ HTTPS؛ بدونش فرمِ ورود
# «CSRF verification failed» می‌دهد و کسی نمی‌فهمد چرا.
CSRF_TRUSTED_ORIGINS = _with_defaults(
'CSRF_TRUSTED_ORIGINS',
['https://api.math.tadrisino2.ir', 'https://math.tadrisino2.ir'],
[o for o in (_origin(PUBLIC_BASE_URL), _origin(FRONTEND_URL)) if o],
)
# --- امنیتِ پشتِ پراکسی ---------------------------------------------------
@@ -132,9 +190,7 @@ SESSION_COOKIE_HTTPONLY = True
# تنها راهش env بود، یک دیپلویِ فراموش‌شده یعنی بک‌اندِ سالم که مرورگر همه‌ی
# درخواست‌هایش را بلاک می‌کند — خطایی که در سمتِ سرور هیچ ردی نمی‌گذارد و فقط
# در کنسولِ مرورگر دیده می‌شود.
CORS_DEFAULT_ORIGINS = [
'https://math.tadrisino2.ir', # فرانتِ مستقر
]
CORS_DEFAULT_ORIGINS = [o for o in (_origin(FRONTEND_URL),) if o] # فرانتِ مستقر
CORS_ALLOWED_ORIGINS = _with_defaults('CORS_ALLOWED_ORIGINS', CORS_DEFAULT_ORIGINS)
# ⚠️ پورتِ سرورِ توسعه را نباید فهرست کرد. فهرستِ ثابتِ قبلی (۳۰۰۰ و ۵۱۷۳) روزی
@@ -179,30 +235,27 @@ INSTALLED_APPS = [
'drf_spectacular',
'corsheaders',
# Tadrisino apps (فاز اول - ۴ از ۱۱ اپ)
# اپ‌های خودمان. هر کدام یک مسئولیت و مدل‌های خودش را دارد؛ ارجاعِ بینِ
# اپ‌ها با user_id/object_id انجام می‌شود نه ForeignKey، تا هر کدام بعداً
# قابلِ جدا شدن بماند (docs/architecture.md).
# مسیرِ اصلیِ کاربر: ورود، محتوا، پیشرفت، دستیار
'accounts',
'content',
'progress',
'ai_tutor',
# Tadrisino apps (فاز دوم - بخش ۱ از ۳: subscription, payment, discount)
# کسبِ درآمد
'subscription',
'payment',
'discount',
# Tadrisino apps (فاز دوم - بخش ۲: notifications)
# ارتباط با کاربر و پشتیبانی
'notifications',
# Tadrisino apps (فاز دوم - بخش ۳: affiliate)
'affiliate',
# Tadrisino apps (فاز دوم - بخش ۴: support)
'support',
# Tadrisino apps (فاز دوم - بخش ۵: family)
'family',
# Tadrisino apps (سیستم پرداخت بازار + آفر/تخفیف + لاگ عمومی تغییر Status)
# لاگِ عمومیِ تغییرِ وضعیت، مستقل از بقیه‌ی اپ‌ها
'statuslog',
# Tadrisino apps (مدلِ مینیمالِ ذخیره‌سازیِ کمپین‌های پیامی، بدونِ منطقِ ارسال)
@@ -213,19 +266,43 @@ INSTALLED_APPS = [
'rag',
]
# شبکه‌هایی که X-Forwarded-Forشان پذیرفته می‌شود (config.middleware.TrustedProxyMiddleware).
# پیش‌فرض: فهرستِ edgeهای ArvanCloud از https://www.arvancloud.ir/fa/ips.txt (۲۰۲۶-۱۰-۱۳). اگر آروان رنجِ
# تازه بگیرد و این‌جا نباشد، کاربرانِ پشتِ آن edge یک IP دیده می‌شوند و لاگ WARNINGِ
# «outside TRUSTED_PROXY_NETWORKS» می‌دهد؛ آن وقت فهرست را از همان آدرس تازه کنید (یا در .env بگذارید).
# مقدارِ نامعتبر بالا آمدن را می‌شکند، نه این‌که بی‌صدا نادیده گرفته شود.
_ARVAN_EDGE_NETWORKS = (
'185.143.232.0/22,188.229.116.16/30,94.101.182.0/27,2.144.3.128/28,37.32.16.0/27,37.32.17.0/27,'
'37.32.18.0/27,37.32.19.0/27,185.215.232.0/22,178.131.120.48/28,94.101.183.0/28,78.157.36.112/28,'
'95.38.61.80/28,193.24.119.0/29'
)
TRUSTED_PROXY_NETWORKS = [
ipaddress.ip_network(cidr.strip())
for cidr in os.environ.get('TRUSTED_PROXY_NETWORKS', _ARVAN_EDGE_NETWORKS).split(',')
if cidr.strip()
]
MIDDLEWARE = [
# پیش از هر چیزی که IPِ کاربر را می‌خواند (سقفِ ورودِ ادمین، throttleهای DRF).
'config.middleware.TrustedProxyMiddleware',
'django.middleware.security.SecurityMiddleware',
# ⚠️ CorsMiddleware باید تا حدِ ممکن بالا و حتماً قبل از CommonMiddleware
# باشد، وگرنه پاسخ‌هایی که زودتر برمی‌گردند (ریدایرکتِ APPEND_SLASH، خطای
# ۴۰۰) هدرِ CORS نمی‌گیرند و در مرورگر به‌شکلِ خطای مبهمِ شبکه دیده می‌شوند.
'corsheaders.middleware.CorsMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
# سقفِ حدسِ رمزِ /admin/ برای هر IP (config/middleware.py).
'config.middleware.AdminLoginThrottleMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
# پاسخِ API هرگز در کشِ ArvanCloud/مرورگر نماند (config/middleware.py).
'config.middleware.APINoStoreMiddleware',
# poolِ دیتابیسِ پر → ۵۰۳ با Retry-After به‌جای ۵۰۰ برای viewهای غیرِ DRF (config/db.py).
'config.middleware.DatabaseBusyMiddleware',
# ⚠️ افزوده شده: لاگِ سبکِ هر درخواستِ API (متد/مسیر/وضعیت/مدت/کاربر)؛
# عمداً آخرِ لیست قرار گرفته تا نزدیک‌ترین میان‌افزار به خودِ view باشه
# (مدت‌زمانِ دقیق‌تر، و request.user که توسطِ DRF authentication در
@@ -238,7 +315,9 @@ ROOT_URLCONF = 'config.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
# قالب‌های سطحِ پروژه (که به هیچ اپی تعلق ندارند) — فعلاً فقط
# پوسته‌ی راست‌به‌چپِ صفحه‌های مستنداتِ API.
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
@@ -256,23 +335,102 @@ WSGI_APPLICATION = 'config.wsgi.application'
# Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
# timeout بالاتر از پیش‌فرض: وقتی دو کانکشنِ هم‌زمان (مثلِ تستِ
# concurrency واقعیِ campaigns) به قفلِ نوشتنِ SQLite برخورد
# می‌کنن، این باعث می‌شه کانکشنِ منتظر تا ۲۰ ثانیه صبر کنه به‌جای
# اینکه فوراً OperationalError('database table is locked') بده.
'OPTIONS': {
'timeout': 20,
},
# کانکشنِ DB به‌ازای هر request باز/بسته نشه؛ تا این تعداد ثانیه
# persistent بمونه. با SQLite بی‌اثرِ عملی ولی بی‌ضرره؛ بعد از
# مهاجرت به Postgres جلوی هزینه‌ی TCP+auth در هر request رو می‌گیره.
'CONN_MAX_AGE': int(os.environ.get('DB_CONN_MAX_AGE', '60')),
# Postgres وقتی DB_NAME ست باشد؛ وگرنه SQLite (وضعِ فعلیِ سرور تا روزِ انتقال).
# ست‌بودنِ DB_NAME با ناقص‌بودنِ بقیه (کاربر/رمز) عمداً خطا می‌دهد، نه اتصالِ بی‌رمز.
DB_NAME = os.environ.get('DB_NAME', '')
if DB_NAME:
_missing = [k for k in ('DB_USER', 'DB_PASSWORD') if not os.environ.get(k)]
if _missing:
raise RuntimeError('DB_NAME ست شده ولی %s خالی است.' % ', '.join(_missing))
if 'DB_CONN_MAX_AGE' in os.environ:
# pool جای اتصالِ دائمی را گرفته و جنگو این دو را با هم نمی‌پذیرد؛ مقدارِ قدیمی بی‌صدا
# نادیده گرفته نمی‌شود تا کسی خیال نکند هنوز اثر دارد.
raise RuntimeError('DB_CONN_MAX_AGE دیگر معنی ندارد (اتصال‌ها از pool می‌آیند)؛ از .env برش دارید.')
# ── pool اتصال‌ها ──
# تا ۲۰۲۶-۱۰-۱۱ هر نخِ gunicorn (۳ worker × ۳۲ نخ = ۹۶) با CONN_MAX_AGE یک اتصالِ دائمیِ جدا
# نگه می‌داشت، ولی Postgresِ ما max_connections=60 دارد (۵۷ تا برای غیرِ ابرکاربر). روی
# Postgresِ محلی با همان سقف، ۱۵۰ درخواستِ هم‌زمان روی /api/home یک‌سومِ درخواست‌ها را ۵۰۰
# کرد («remaining connection slots are reserved»). حالا هر worker یک pool دارد و نخ‌ها اتصال
# را فقط تا پایانِ درخواست قرض می‌گیرند؛ زیرِ ازدحام نخ‌ها در صفِ pool منتظر می‌مانند و پس از
# DB_POOL_TIMEOUT ثانیه ۵۰۳ با Retry-After می‌گیرند (config/db.py)،
# نه ۵۰۰. بودجه: workers × DB_POOL_MAX_SIZE باید زیرِ max_connections بماند و جا برای
# pg_receivewal، pg_dump و migrateِ دیپلو، و psqlِ دستی بگذارد؛ /api/health آن را می‌سنجد.
# ۸ عدد: هر کوئری این‌جا چند میلی‌ثانیه است، پس ۸ اتصال صدها درخواست در ثانیه را برای یک
# worker (که با GIL حداکثر یک هسته است) می‌کشد؛ در لودتستِ محلی از ۸ به ۱۶ گذرشی بیشتر نشد.
DB_POOL_MIN_SIZE = int(os.environ.get('DB_POOL_MIN_SIZE', '2'))
DB_POOL_MAX_SIZE = int(os.environ.get('DB_POOL_MAX_SIZE', '8'))
DB_POOL_TIMEOUT = float(os.environ.get('DB_POOL_TIMEOUT', '10'))
if not 1 <= DB_POOL_MIN_SIZE <= DB_POOL_MAX_SIZE:
# psycopg_pool این را تازه سرِ اولین کوئری می‌فهمد و آن‌وقت همه‌ی درخواست‌ها ۵۰۰ می‌شوند.
raise RuntimeError(f'DB_POOL_MIN_SIZE ({DB_POOL_MIN_SIZE}) باید بینِ ۱ و DB_POOL_MAX_SIZE ({DB_POOL_MAX_SIZE}) باشد.')
# کوئریِ بیش از ۳۰ ثانیه در درخواستِ وب یعنی باگ یا قفلِ گیرکرده؛ بدونِ سقف اتصالِ pool و نخ را
# تا timeoutِ gunicorn (۱۲۰ ثانیه) گرو می‌گرفت. تراکنشِ بازِ بیکار (کدی که atomic را رها نکرده)
# بعد از ۶۰ ثانیه بسته می‌شود تا قفل‌هایش بقیه را نخواباند. برای یک فرمانِ مدیریتیِ طولانیِ دستی:
# DB_STATEMENT_TIMEOUT_MS=0 python manage.py ...
DB_STATEMENT_TIMEOUT_MS = int(os.environ.get('DB_STATEMENT_TIMEOUT_MS', '30000'))
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': DB_NAME,
'USER': os.environ['DB_USER'],
'PASSWORD': os.environ['DB_PASSWORD'],
'HOST': os.environ.get('DB_HOST', '127.0.0.1'),
'PORT': os.environ.get('DB_PORT', '5432'),
'CONN_MAX_AGE': 0,
# اتصالِ مرده (مثلاً بعد از ری‌استارتِ Postgres) پیش از تحویل از pool کنار گذاشته می‌شود،
# نه این‌که اولین درخواستِ بعدی رویش ۵۰۰ بگیرد. هزینه: یک رفت‌وبرگشتِ محلی برای هر قرض.
'CONN_HEALTH_CHECKS': True,
'OPTIONS': {
'pool': {
'min_size': DB_POOL_MIN_SIZE,
'max_size': DB_POOL_MAX_SIZE,
'timeout': DB_POOL_TIMEOUT,
'max_idle': 300,
'max_lifetime': 1800,
},
'connect_timeout': 5,
'options': (f'-c statement_timeout={DB_STATEMENT_TIMEOUT_MS} '
'-c idle_in_transaction_session_timeout=60000'),
},
# قفلِ ردیف روی Postgres واقعی است (select_for_update)؛ transaction_mode
# فقط برای SQLite بود. با تست‌های هم‌زمانی روی Postgres سنجیده می‌شود.
}
}
}
else:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
# timeout بالاتر از پیش‌فرض: وقتی دو کانکشنِ هم‌زمان (مثلِ تستِ
# concurrency واقعیِ campaigns) به قفلِ نوشتنِ SQLite برخورد
# می‌کنن، این باعث می‌شه کانکشنِ منتظر تا ۲۰ ثانیه صبر کنه به‌جای
# اینکه فوراً OperationalError('database table is locked') بده.
'OPTIONS': {
'timeout': 20,
# هر atomic() قفلِ نوشتن را از اول می‌گیرد (BEGIN IMMEDIATE).
# SQLite از SELECT ... FOR UPDATE پشتیبانی نمی‌کند و جنگو
# select_for_update را رویش بی‌صدا نادیده می‌گیرد؛ پس محافظ‌های
# «قفلِ ردیف» در پرداخت، تخفیف، قلب و سهمیه‌ی دستیار بدونِ این
# عملاً قفلی نداشتند. با IMMEDIATE کلِ تراکنش سریالی می‌شود (قفلِ
# کلِ دیتابیس، نه ردیف) — روی SQLite درشت‌تر ولی درست. هزینه:
# تراکنش‌ها پشتِ هم صف می‌کشند؛ فراخوانی‌های شبکه‌ای (AI، زرین‌پال)
# عمداً بیرون از atomic() هستند. بعد از Postgres این را بردارید.
'transaction_mode': 'IMMEDIATE',
},
# کانکشنِ DB به‌ازای هر request باز/بسته نشه؛ تا این تعداد ثانیه
# persistent بمونه. با SQLite بی‌اثرِ عملی ولی بی‌ضرره؛ بعد از
# مهاجرت به Postgres جلوی هزینه‌ی TCP+auth در هر request رو می‌گیره.
'CONN_MAX_AGE': int(os.environ.get('DB_CONN_MAX_AGE', '60')),
# تست‌ها روی فایل، مثلِ پروداکشن. دیتابیسِ پیش‌فرضِ تست در حافظه با
# shared-cache است که قفلش جدول‌به‌جدول و بی‌انتظار است («table is
# locked») — رفتاری که روی فایلِ واقعی پیش نمی‌آید؛ تستِ هم‌زمانیِ
# campaigns با BEGIN IMMEDIATE فقط همین‌جا می‌افتاد.
'TEST': {'NAME': str(BASE_DIR / '.test-db.sqlite3')},
}
}
# In production a shared cache is required for rate limiting to work across
# workers/instances; falls back to LocMemCache only when DEBUG=True.
@@ -281,8 +439,13 @@ CACHE_URL = os.environ.get('CACHE_URL', '')
if CACHE_URL:
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
# ResilientRedisCache: قطعیِ Redis API را ۵۰۰ نمی‌کند (config/cache.py شرح می‌دهد چه چیزی در
# عوضش اعمال نمی‌شود). روی سرور فعلاً CACHE_URL خالی است: Redisِ روی آن ماشین مالِ تیم‌های
# دیگر است و عمداً به آن وصل نمی‌شویم؛ این مسیر برای Redisِ اختصاصیِ خودمان آماده است.
'BACKEND': 'config.cache.ResilientRedisCache',
'LOCATION': CACHE_URL,
# بدونِ timeout، Redisِ گیرکرده هر درخواست را تا پایانِ timeoutِ gunicorn (۱۲۰ ثانیه) نگه می‌دارد.
'OPTIONS': {'socket_connect_timeout': 1, 'socket_timeout': 1},
}
}
elif DEBUG:
@@ -382,7 +545,7 @@ REST_FRAMEWORK = {
# BrowsableAPIRenderer فقط در dev؛ در پروداکشن هم overhead داره هم یک
# مرورگرِ HTMLِ API رو در معرض دید می‌ذاره.
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
'config.renderers.PlainPersianJSONRenderer',
*(['rest_framework.renderers.BrowsableAPIRenderer'] if DEBUG else []),
],
'DEFAULT_THROTTLE_CLASSES': [
@@ -390,15 +553,34 @@ REST_FRAMEWORK = {
],
# بدونِ NUM_PROXIES، DRF هدرِ X-Forwarded-For رو بدون‌چک به‌عنوانِ IP
# کلاینت قبول می‌کنه؛ یعنی هر کلاینت می‌تونست با XFF دلخواه، bucket
# تازه از GuestAware*Throttleها بگیره (bypassِ چرخشِ حساب مهمان). این
# تازه از throttleهای IP-محور بگیره. این
# مقدار باید دقیقاً با تعدادِ لایه‌ی پراکسی/LB جلوی اپ یکی باشه (مثلاً
# Cloudflare+Nginx=۲)، وگرنه IP واقعی اشتباه خونده می‌شه.
'NUM_PROXIES': int(os.environ.get('NUM_PROXIES', '1')),
#
# پروداکشن دو لایه دارد: ArvanCloud (TLS، `server: ArvanCloud`) → nginx روی
# همین ماشین → gunicorn. XFF در جنگو «IPِ کاربر, IPِ آروان» است؛ با ۱
# آخرین مقدار، یعنی IPِ آروان، IPِ کاربر حساب می‌شد و همه‌ی مهمان‌ها و همه‌ی
# درخواست‌های OTP پشتِ یک edgeِ آروان یک سهمِ مشترک داشتند (ورودِ مهمان
# ۲۰ در ساعت برای *همه*). برای اجرای محلی بی‌پراکسی هم بی‌خطر است: وقتی
# XFF نیست یا کوتاه‌تر است، DRF همان REMOTE_ADDR/اولین مقدار را می‌گیرد.
# `/api/health` در `clientIp` نشان می‌دهد سرور چه IPای می‌بیند.
'NUM_PROXIES': int(os.environ.get('NUM_PROXIES', '2')),
'DEFAULT_THROTTLE_RATES': {
'otp_request': '5/hour',
'otp_login': '10/hour',
'guest_session': '20/hour',
# سقف‌های per-IP عمداً گشاده‌اند: پشتِ CGNATِ اپراتورهای موبایل، وای‌فایِ
# مدرسه یا شبکه‌ی یک دفتر، ده‌ها نفر یک IP دارند و با ۵ در ساعت فقط ۵ نفر
# می‌توانستند وارد شوند. حفاظِ اصلی per-phone و per-code است (پایین‌تر و
# در دیتابیس)؛ این‌ها فقط جلوی یک ماشینِ تنها را می‌گیرند.
# ۲۰ تا ۲۰۲۶-۱۰-۱۱ (تصمیمِ مالک): کلاسی که روی یک وای‌فای با هم وارد می‌شود سقف را پر می‌کرد. ۱۰۰ در
# ساعت از یک IP هنوز جلوی یک ماشینِ تنها را می‌گیرد؛ هزینه‌ی پیامک را سقفِ هر شماره (۱۰ در روز) و سقفِ
# روزانه‌ی کلِ سامانه (OTP_SMS_GLOBAL_DAILY_MAX) می‌بندند.
'otp_request': os.environ.get('OTP_REQUEST_IP_RATE', '100/hour'),
# ۳۰ تا ۲۰۲۶-۱۰-۱۱: یک کلاسِ سی‌نفره روی وای‌فایِ مدرسه با چند غلطِ تایپی سقف را پر می‌کرد. حدسِ کد
# را سقفِ هر کد (OTP_MAX_FAILED_ATTEMPTS=۵، در دیتابیس) و هر شماره می‌بندد، نه این.
'otp_login': os.environ.get('OTP_LOGIN_IP_RATE', '300/hour'),
'bazaar_verify': '10/hour',
# شروعِ خریدِ بازار (امضای قیمت)؛ بی‌فراخوانیِ بیرونی، ولی هر بار یک توکنِ تازه.
'bazaar_start': '30/hour',
'bazaar_oauth': '20/hour',
'ai_ask': '20/hour',
'rag_ingest': '30/hour',
# سقفِ ارزانِ خواندن روی endpointهای پرترافیکِ GET (home/leaderboard/
@@ -406,17 +588,26 @@ REST_FRAMEWORK = {
# به UX عادی دست نمی‌زنه.
'read': os.environ.get('READ_THROTTLE_RATE', '120/min'),
'purchase': '10/hour',
'token_refresh': '30/hour',
# بی‌توکن صدا زده می‌شود، پس سهمِ همه‌ی کاربرانِ یک IP مشترک است و هر کاربرِ فعال
# ساعتی یک بار تمدید می‌کند (توکنِ دسترسی یک‌ساعته). تا ۲۰۲۶-۱۰-۰۳ سی بود: سی‌ویکمین
# دانش‌آموزِ یک وای‌فایِ مدرسه یا یک CGNATِ موبایل ۴۲۹ می‌گرفت (بازتولید شد) و فرانت
# بیرونش می‌انداخت. refresh token یک‌بارمصرف و ۶۴ نویسه‌ی تصادفی است، پس حدس زدنش با
# سقفِ بالاتر هم ممکن نیست؛ این سقف فقط جلوی یک ماشینِ تنها را می‌گیرد.
'token_refresh': os.environ.get('TOKEN_REFRESH_IP_RATE', '600/hour'),
'question_answer': '60/min',
'question_report': '20/hour',
'lesson_complete': '10/hour',
'content_vote': '300/hour',
# هر درس اینجا یک نوع مسئله است با یکی-دو سوال، نه یک جلسه‌ی درسی؛
# ۱۰ در ساعت را دانش‌آموزِ عادی می‌زد و پیشرفتش ۴۲۹ می‌گرفت.
'lesson_complete': '120/hour',
'node_complete': '60/hour',
'support_create': '30/hour',
'offer_redeem': '10/hour',
'discount_validate': '20/hour',
'discount_redeem': '10/hour',
'family_invite_accept': '10/hour',
'family_invite_create': '5/hour',
'user_profile_write': '60/hour',
# حذفِ حساب با کدِ پیامکی؛ سقفِ حدسِ کد جدا در verify_phone_otp است.
'account_delete': '5/hour',
'affiliate_track': '10/hour',
'payment_webhook': '60/hour',
'payment_pay': '30/hour',
@@ -428,14 +619,104 @@ REST_FRAMEWORK = {
SPECTACULAR_SETTINGS = {
'TITLE': 'Tadrisino API',
# این متن قراردادِ بینِ ما و تیمِ فرانت است و تنها جایی‌ست که هر دو طرف
# می‌بینند. پس به‌جای فهرستِ امکانات، به سه سوالِ اولِ هر مصرف‌کننده جواب
# می‌دهد: چطور توکن بگیرم، خطا چه شکلی برمی‌گردد، و کِی محدود می‌شوم.
# Swagger UI و Redoc هر دو Markdown را رندر می‌کنند.
'DESCRIPTION': (
'بک‌اند اپلیکیشن آموزشی Tadrisino — احراز هویت با OTP، محتوای درسی '
'و پیشرفت کاربر، دستیار هوش مصنوعی، اشتراک و پرداخت، تخفیف، '
'اعلان‌ها، همکاری در فروش، پشتیبانی و حساب خانواده.'
'بک‌اندِ اپلیکیشنِ آموزشیِ تدریسینو: برنامه‌ی درسی، آزمون، پیشرفتِ '
'کاربر، دستیارِ هوشِ مصنوعی، اشتراک و پرداخت.\n'
'\n'
'## احرازِ هویت\n'
'\n'
'همه‌ی درخواست‌های احرازشده با یک توکنِ Bearer انجام می‌شوند:\n'
'\n'
'```\n'
'Authorization: Bearer <token>\n'
'```\n'
'\n'
'توکن را فقط با شماره‌ی موبایل می‌گیرید: `POST /api/auth/otp/request` '
'و بعد `POST /api/auth/login`. ورودِ مهمان نداریم؛ هر کاربر حداقل ثبت‌نامِ '
'اولیه با شماره دارد. توکنِ دسترسی یک ساعت اعتبار دارد؛ با '
'`POST /api/auth/refresh` تازه‌اش کنید.\n'
'\n'
'**در همین صفحه:** روی **Authorize** بزنید و *فقط خودِ توکن* را وارد '
'کنید — کلمه‌ی `Bearer` را Swagger خودش اضافه می‌کند.\n'
'\n'
'اندپوینتی که توکن نگیرد، درخواست را ناشناس می‌بیند؛ اینکه ورود لازم '
'باشد یا نه، به خودِ آن اندپوینت بستگی دارد.\n'
'\n'
'## خطاها\n'
'\n'
'هر خطا — حتی خطای پیش‌بینی‌نشده — یک پاسخِ JSON است، نه صفحه‌ی HTML. '
'پیامِ `detail` فارسی و قابلِ نمایش به کاربرِ نهایی است.\n'
'\n'
'```json\n'
'{"detail": "کد OTP نامعتبر یا منقضی‌شده است."}\n'
'```\n'
'\n'
'| کد | معنی |\n'
'|---|---|\n'
'| `400` | ورودی نامعتبر است |\n'
'| `401` | توکن نیست، نامعتبر است یا منقضی شده |\n'
'| `403` | توکن معتبر است ولی اجازه‌ی این کار را ندارد |\n'
'| `404` | چیزی با این شناسه وجود ندارد |\n'
'| `429` | از محدودیتِ نرخ گذشتید — بخشِ بعد |\n'
'| `503` | سرویسِ بیرونی (پیامک، پرداخت) در دسترس نیست |\n'
'\n'
'`503` عمدی‌ست: وقتی سرویسِ بیرونی تنظیم یا در دسترس نباشد، پاسخِ '
'صریح می‌گیرید، نه موفقیتِ ساختگی.\n'
'\n'
'## محدودیتِ نرخ\n'
'\n'
'اندپوینت‌های حساس (ورود، درخواستِ کد، پرسش از دستیار) سقفِ مستقل '
'دارند. عبور از سقف یعنی `429`، و هر جا که معنی داشته باشد هدرِ '
'`Retry-After` هم می‌آید. کاربرِ مهمان بر اساسِ IP شمرده می‌شود.\n'
'\n'
'مسیرِ ورود سقفِ جداگانه‌ای بر اساسِ *شماره* هم دارد، مستقل از IP، تا '
'پخش کردنِ تلاش بینِ چند IP فایده‌ای نداشته باشد.\n'
'\n'
'## قراردادها\n'
'\n'
'- نامِ فیلدها `camelCase` است (`refreshToken`, `nodeId`).\n'
'- زمان‌ها ISO 8601 با منطقه‌ی زمانی‌اند.\n'
'- متنِ رو به کاربر فارسی است؛ شناسه‌ها و نامِ فیلدها انگلیسی.\n'
'- این اسکیما **مرجعِ قرارداد** است. کلاینت را از روی حافظه ننویسید — '
'از `/api/schema/` تولیدش کنید.\n'
),
'VERSION': '1.0.0',
'CONTACT': {'name': 'تیمِ بک‌اندِ تدریسینو'},
'EXTERNAL_DOCS': {
'description': 'فرانت‌اند: تنها کلاینتِ این API (src/api/)',
'url': 'https://gitea.tadrisino2.ir/Tadrisino/MathApp_frontend',
},
# ترتیبِ تگ‌ها در Swagger به همین ترتیب است، نه الفبایی — یعنی مسیرِ
# واقعیِ یک کاربر: اول وارد شو، بعد محتوا بگیر، بعد پیشرفت و بقیه.
'TAGS': [
{'name': 'Identity & Profile (accounts)', 'description': 'ورود، session، پروفایل'},
{'name': 'Curriculum (content)', 'description': 'پایه، فصل، نود، درس، سوال، آزمون'},
{'name': 'Progress & Gamification (progress)', 'description': 'آمار، streak، لیدربرد، شخصیت‌ها'},
{'name': 'AI Tutor (ai_tutor)', 'description': 'پرسش از دستیار و بازخورد روی پاسخ'},
{'name': 'RAG (rag)', 'description': 'بازیابی از محتوای کتاب — داخلی'},
{'name': 'Subscription (subscription)', 'description': 'پلن‌ها و اشتراکِ کاربر'},
{'name': 'Payment (payment)', 'description': 'سفارش و تراکنش (زرین‌پال و کافه‌بازار)'},
{'name': 'Discount (discount)', 'description': 'کدِ تخفیف و آفرها'},
{'name': 'Notifications (notifications)', 'description': 'اعلان‌های کاربر'},
{'name': 'Support (support)', 'description': 'تیکت و گزارشِ اشکال'},
{'name': 'Affiliate (affiliate)', 'description': 'برنامه‌ی معرفی و پورسانت'},
{'name': 'System', 'description': 'سلامتِ سرویس'},
],
'SERVE_INCLUDE_SCHEMA': False,
# مستندات فقط برای ادمینِ واردشده به /admin/. IsAdminUser به‌تنهایی کافی نیست: احرازِ پیش‌فرضِ DRF
# فقط Bearer است و ادمینِ پنل ۴۰۱ می‌گرفت؛ SessionAuthentication کوکیِ پنل را می‌شناسد.
# فایلِ schema برای فرانت: `python manage.py spectacular --file schema.yaml` (بی‌نیاز از HTTP).
'SERVE_PERMISSIONS': ['rest_framework.permissions.IsAdminUser'],
'SERVE_AUTHENTICATION': ['rest_framework.authentication.SessionAuthentication'],
'COMPONENT_SPLIT_REQUEST': True,
# نامِ صریحِ enumها؛ بدونِ آن drf-spectacular نامی مثلِ «KindEd8Enum» می‌سازد و هشدار می‌دهد.
'ENUM_NAME_OVERRIDES': {
'LessonKindEnum': [('quiz', 'quiz'), ('video', 'video')],
},
'SWAGGER_UI_SETTINGS': {
'deepLinking': True,
'displayRequestDuration': True,
@@ -444,6 +725,12 @@ SPECTACULAR_SETTINGS = {
'filter': True,
'persistAuthorization': True,
},
# نسخه‌ی ثابت، نه @latest: این صفحه‌ها روی همان دامنه‌ی /admin/ اجرا می‌شوند، و نسخه‌ی منتشرشده
# در npm عوض‌شدنی نیست؛ با @latest هر نسخه‌ی آلوده‌ی تازه‌ی این بسته‌ها در مرورگرِ کارمند اجرا می‌شد.
# SRI ندارد. ارتقا یعنی عوض کردنِ همین دو عدد.
'SWAGGER_UI_DIST': 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1',
'SWAGGER_UI_FAVICON_HREF': 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/favicon-32x32.png',
'REDOC_DIST': 'https://cdn.jsdelivr.net/npm/redoc@2.5.4',
}
# اعتبار توکن دسترسی (ثانیه) و رفرش‌توکن (روز)
@@ -478,10 +765,89 @@ OTP_REQUEST_PHONE_WINDOW_SECONDS = 3600
# باید با countdownِ فرانت یکی باشه — countdown صرفاً UX است، نه مرجعِ امنیتی.
OTP_REQUEST_PHONE_COOLDOWN_SECONDS = 120
# عمرِ هر کدِ ورود. سقف‌های دیتابیسیِ پایین زمانِ صدور را از expires_at منهای
# همین مقدار می‌خوانند، پس عوض کردنش آن سقف‌ها را هم (برای کدهای قدیمی) جابه‌جا می‌کند.
OTP_TTL_SECONDS = 300
# سقف‌هایی که در دیتابیس شمرده می‌شوند، نه در کش. کشِ پیش‌فرض LocMem و مالِ
# هر worker است، پس cooldown و سقفِ ساعتیِ بالا روی سرور (۳ worker) عملاً سه
# برابرند؛ این دو بینِ همه‌ی workerها مشترک‌اند و بدونِ Redis هم درست کار می‌کنند.
# - روزانه‌ی هر شماره: جلوی بمبارانِ پیامکیِ یک گوشی در طولِ روز.
# - روزانه‌ی کلِ سامانه: سقفِ خرجِ اعتبارِ کاوه‌نگار اگر کسی با شماره‌های تصادفی
# و IPهای زیاد پیامک بکشد (SMS pumping). رسیدن به آن ۵۰۳ و لاگِ ERROR می‌دهد؛
# با رشدِ کاربرِ واقعی از .env بالا ببرید.
OTP_REQUEST_PHONE_DAILY_MAX = int(os.environ.get('OTP_REQUEST_PHONE_DAILY_MAX', '10'))
OTP_SMS_GLOBAL_DAILY_MAX = int(os.environ.get('OTP_SMS_GLOBAL_DAILY_MAX', '300'))
# هر کدِ ورود بعد از این تعداد حدسِ غلط باطل می‌شود (شمارش در دیتابیس).
# با کدِ ۶رقمی یعنی شانسِ حدس زدنِ هر کد ۵ در یک میلیون.
OTP_MAX_FAILED_ATTEMPTS = 5
# در محیط dev، این کد OTP همیشه معتبر است تا بدون SMS واقعی تست شود.
# فقط وقتی DEBUG=True مقدار دارد؛ در پروداکشن None است.
DEV_MASTER_OTP = '0000' if DEBUG else None
# شماره‌های تستِ ورود: «شماره:کد» با کامای جداکننده. برای این شماره‌ها پیامک
# ارسال نمی‌شود و همان کدِ ثابت معتبر است — تا ورودِ تیمِ توسعه به تنظیم بودنِ
# کاوه‌نگار یا به یک آدمِ خاص وابسته نباشد. برخلافِ DEV_MASTER_OTP «هر
# شماره‌ای» نیست و به DEBUG گره نخورده. روالِ استفاده در docs/operations.md.
#
# ⚠️ شماره باید *دقیقاً* همان رشته‌ای باشد که کلاینت می‌فرستد: این کدبیس شماره
# را normalize نمی‌کند (accounts/serializers.py فقط CharField است)، پس
# «09121111111» و «+989121111111» دو کاربرِ جدا هستند.
def _parse_test_login_phones(raw: str) -> dict[str, str]:
from accounts.phone import normalize_phone
pairs = {}
for item in raw.split(','):
item = item.strip()
if not item:
continue
phone, sep, code = item.partition(':')
phone, code = phone.strip(), code.strip()
# سقفِ ۶ رقمِ کد دلبخواه نیست: LoginRequestSerializer فیلدِ otp را
# max_length=6 گرفته، پس کدِ بلندتر پیش از رسیدن به اینجا ۴۰۰ می‌شد و
# به‌نظر می‌آمد کدِ تست کار نمی‌کند.
if not sep or not re.fullmatch(r'\+?\d{10,15}', phone) or not re.fullmatch(r'\d{4,6}', code):
raise ImproperlyConfigured(
f'TEST_LOGIN_PHONES مقدارِ نامعتبر دارد: «{item}». '
'قالبِ درست «شماره:کد» است؛ شماره ۱۰ تا ۱۵ رقم (با + اختیاری) '
'و کد ۴ تا ۶ رقم. مثال: TEST_LOGIN_PHONES=09120000001:834512'
)
# همان نرمال‌سازیِ ورودیِ API (accounts/phone.py)، وگرنه شماره‌ای که
# اینجا ‎+98…‎ نوشته شده با ورودیِ نرمال‌شده‌ی ‎09…‎ هرگز جور نمی‌شد.
normalized = normalize_phone(phone)
if normalized is None:
raise ImproperlyConfigured(
f'TEST_LOGIN_PHONES شماره‌ی موبایلِ ایران نیست: «{item}». مثال: 09120000001:834512'
)
pairs[normalized] = code
return pairs
TEST_LOGIN_PHONES = _parse_test_login_phones(os.environ.get('TEST_LOGIN_PHONES', ''))
# شماره‌های آزمونگر: برای این کاربرها هیچ جزیره‌ای روی نقشه قفل نیست (قفلِ «جزیره‌ی قبلی را تمام کن»).
# فقط قفلِ ترتیب را برمی‌دارد؛ اشتراکِ فصل‌ها، قلب و سقفِ دستیار همان قواعدِ عادی‌اند و برای آزمونگر
# با یک ردیفِ Subscription در ادمین داده می‌شود. چرا env و نه فیلدِ کاربر: تیمِ تست عوض می‌شود و
# نباید برای هر نفر migration یا دسترسیِ ادمین لازم باشد؛ چرا نه DEBUG: روی سرورِ واقعی لازم است.
# شماره‌ی نامعتبر راهاندازی را می‌شکند (بی‌صدا نادیده گرفته نمی‌شود).
def _parse_unlock_all_phones(raw: str) -> frozenset:
from accounts.phone import normalize_phone
phones = set()
for item in raw.split(','):
item = item.strip()
if not item:
continue
normalized = normalize_phone(item)
if normalized is None:
raise ImproperlyConfigured(f'UNLOCK_ALL_PHONES شماره‌ی موبایلِ ایران نیست: «{item}».')
phones.add(normalized)
return frozenset(phones)
UNLOCK_ALL_PHONES = _parse_unlock_all_phones(os.environ.get('UNLOCK_ALL_PHONES', ''))
# -----------------------------------------------------------------------
# OTP SMS delivery (accounts/services.py: send_otp_sms)
# -----------------------------------------------------------------------
@@ -489,7 +855,15 @@ DEV_MASTER_OTP = '0000' if DEBUG else None
# بدون تنظیم این env، ارسال باید صراحتاً خطا بدهد، نه silently موفق باشد.
KAVENEGAR_API_KEY = os.environ.get('KAVENEGAR_API_KEY', '')
KAVENEGAR_SENDER = os.environ.get('KAVENEGAR_SENDER', '')
OTP_SMS_TEMPLATE = os.environ.get('OTP_SMS_TEMPLATE', 'کد ورود شما به تدریسینو: {code}')
# نامِ الگوی تأییدشده در پنلِ کاوه‌نگار برای کدِ ورود (verify/lookup، با %token%).
# وقتی تنظیم باشد OTP_SMS_TEMPLATE پایین استفاده نمی‌شود و متن را خودِ الگو می‌سازد.
# خطِ پیش‌فرضِ حسابِ فعلی پیامکِ متنِ آزاد را لغو می‌کند، پس روی سرور لازم است.
KAVENEGAR_OTP_TEMPLATE = os.environ.get('KAVENEGAR_OTP_TEMPLATE', '').strip()
# جمله‌ی دوم هشدارِ مهندسیِ اجتماعی است (رایج در پیامک‌های بانکی)؛ کلِ متن زیرِ
# ۷۰ نویسه می‌ماند تا یک صفحه پیامکِ فارسی حساب شود، نه دو صفحه.
OTP_SMS_TEMPLATE = os.environ.get(
'OTP_SMS_TEMPLATE', 'کد ورود شما به تدریسینو: {code}\nاین کد را به هیچ‌کس ندهید.',
)
# -----------------------------------------------------------------------
# Content progress (NodeProgress status)
@@ -502,24 +876,25 @@ OTP_SMS_TEMPLATE = os.environ.get('OTP_SMS_TEMPLATE', 'کد ورود شما به
# می‌شه که نباید فوراً به‌عنوان «رهاکردن درس» حساب بشه.
NODE_PROGRESS_ABANDON_DAYS = 14
# قلبِ صفر جلوی جواب دادن را در سرور بگیرد؟ (content.views.QuestionAnswerView)
# از ۲۰۲۶-۱۰-۰۲ روشن است (خواستِ صاحبِ پروژه): فرانت ۴۰۳ِ `no_hearts` را با صفحه‌ی «قلب‌هایت
# تمام شد» و زمانِ پر شدن نشان می‌دهد؛ قلبِ نامحدود فقط با اشتراک است.
# HEARTS_ENFORCED=0 در .env آن را خاموش می‌کند.
HEARTS_ENFORCED = os.environ.get('HEARTS_ENFORCED', '1') == '1'
# تلاشِ ورودِ ادمین در ۱۵ دقیقه برای هر IP.
ADMIN_LOGIN_MAX_ATTEMPTS = 10
# -----------------------------------------------------------------------
# Payment security
# -----------------------------------------------------------------------
# POST /api/payment/orders باید فقط سرور-به-سرور (از subscription) صدا زده شود؛
# کلاینت باید هدر X-Internal-Key برابر همین مقدار بفرستد. بدون env در
# پروداکشن مقدار خالی می‌ماند و HasInternalServiceKey صراحتاً رد می‌کند.
INTERNAL_SERVICE_KEY = os.environ.get(
'INTERNAL_SERVICE_KEY',
'dev-internal-service-key-change-me' if DEBUG else '',
)
# هیچ پیش‌فرضی، حتی در DEBUG: رشته‌ی پیش‌فرضِ داخلِ ریپو هر لپ‌تاپِ توسعه‌ای را که روی شبکه
# باز است به ingest در Qdrant راه می‌داد.
INTERNAL_SERVICE_KEY = os.environ.get('INTERNAL_SERVICE_KEY', '')
# POST /api/payment/webhook باید از درگاه پرداخت بیاید، نه یک کلاینت دلخواه.
# فعلاً یک shared-secret ساده (X-Webhook-Secret) استفاده می‌شود؛ در اتصال
# واقعی باید با بررسی امضای HMAC درگاه جایگزین شود.
PAYMENT_WEBHOOK_SECRET = os.environ.get(
'PAYMENT_WEBHOOK_SECRET',
'dev-webhook-secret-change-me' if DEBUG else '',
)
# -----------------------------------------------------------------------
# Bazaar In-App Billing (پرداخت درون‌برنامه‌ای کافه‌بازار — REST API v2)
@@ -528,29 +903,37 @@ PAYMENT_WEBHOOK_SECRET = os.environ.get(
# گرفته می‌شه، بعد بک‌اند با همون REFRESH_TOKEN، ACCESS_TOKEN تازه می‌گیره.
# قبل از اتصال واقعی، مسیرِ endpointها رو از پنل devapi تایید کنید — بازار
# این روش رو قبلاً تغییر داده.
BAZAAR_PACKAGE_NAME = os.environ.get('BAZAAR_PACKAGE_NAME', 'ir.tadrisino.app')
# applicationIdِ APK و پکیجِ پیشخان (com.tadrisino.math.app روی سرور؛ ۲۰۲۶-۱۰-۱۲ بازار محصول‌های ۱۰۳۴۲–۱۰۳۴۴ را
# زیرِ همین نام شناخت). پیش‌فرض همان است تا لپ‌تاپ و سرور یکی باشند.
BAZAAR_PACKAGE_NAME = os.environ.get('BAZAAR_PACKAGE_NAME', 'com.tadrisino.math.app')
BAZAAR_CLIENT_ID = os.environ.get('BAZAAR_CLIENT_ID', '')
BAZAAR_CLIENT_SECRET = os.environ.get('BAZAAR_CLIENT_SECRET', '')
BAZAAR_REFRESH_TOKEN = os.environ.get('BAZAAR_REFRESH_TOKEN', '')
# روشِ جدیدِ بازار: توکنِ ثابتِ پیشخان (API پیشخان بازار ← دریافت توکن جدید).
# اگر ست باشد بر OAuthِ بالا (CLIENT_ID/SECRET/REFRESH_TOKEN) مقدم است.
BAZAAR_PISHKHAN_API_SECRET = os.environ.get('BAZAAR_PISHKHAN_API_SECRET', '').strip()
BAZAAR_API_BASE = os.environ.get('BAZAAR_API_BASE', 'https://pardakht.cafebazaar.ir/devapi/v2')
# Redirect URI که در پنل بازار ثبت شده؛ باید با آن *عیناً* یکی باشد. به
# خواستِ مدیرِ حسابِ بازار (۲۰۲۶-۰۹-۲۶) همان آدرسِ verify است: GETِ آن codeِ
# بازار را می‌گیرد و POSTِ آن خریدِ اپ را. با عوض شدنِ PUBLIC_BASE_URL این هم
# عوض می‌شود، و آن‌وقت در پنلِ بازار هم باید عوض شود.
BAZAAR_REDIRECT_URI = (
os.environ.get('BAZAAR_REDIRECT_URI', '').strip()
or f'{PUBLIC_BASE_URL}/api/payment/bazaar/verify'
)
# کلیدِ «تخفیف پویا» از پیشخانِ بازار (پرداخت درون‌برنامه‌ای ← تخفیف پویا). با آن سرور برای هر خرید
# یک JWTِ یک‌بارمصرف امضا می‌کند که قیمتِ همان سفارش (پس از کدِ تخفیف) را به بازار می‌گوید؛ پس قیمتِ
# ادمین ملاک است، نه قیمتِ پیشخان (که باید از آن کمتر نباشد). بی‌آن بازار قیمتِ پیشخان را می‌گیرد و
# کدِ تخفیف روی سفارشِ بازار ۴۰۰ است. فقط سمتِ سرور؛ هرگز در اپ.
BAZAAR_DYNAMIC_PRICE_KEY = os.environ.get('BAZAAR_DYNAMIC_PRICE_KEY', '').strip()
# چند ساعت لینکِ «اجازه»ی بازار باز می‌ماند؛ بعد از اتصالِ موفق خودش بسته می‌شود.
BAZAAR_AUTHORIZE_WINDOW_HOURS = int(os.environ.get('BAZAAR_AUTHORIZE_WINDOW_HOURS', '72'))
# -----------------------------------------------------------------------
# Zarinpal (درگاهِ اصلیِ پرداختِ ما — Payment API v4)
# -----------------------------------------------------------------------
# آدرسِ عمومیِ خودِ بک‌اند؛ هم برای ساختنِ paymentUrlی که به کلاینت برمی‌گردد
# (PaymentRedirectView) و هم برای callback_url که به زرین‌پال می‌فرستیم لازم
# است.
#
# چرا پیش‌فرض دارد: خالی بودنش سرویس را نمی‌خواباند، فقط مسیرِ پرداخت را
# بی‌سروصدا می‌شکند — paymentUrl نسبی می‌شود و زرین‌پال callback خالی
# می‌گیرد. این دقیقاً همان دسته خطاست که فقط موقعِ اولین پرداختِ واقعی معلوم
# می‌شود. دامنه‌ی خودمان از قبل معلوم است، پس حدس زدنی نیست.
# .strip() اول لازم است: کلید در .env سرور وجود دارد ولی خالی است، و
# os.environ.get فقط وقتی کلید *نباشد* پیش‌فرض می‌دهد — مقدارِ خالی آن را
# خنثی می‌کرد و مسیرِ پرداخت باز هم بی‌صدا می‌شکست.
PUBLIC_BASE_URL = (
os.environ.get('PUBLIC_BASE_URL', '').strip() or 'https://api.math.tadrisino2.ir'
).rstrip('/')
# paymentUrlی که به کلاینت برمی‌گردد و callback_urlی که به زرین‌پال می‌رود
# هر دو از PUBLIC_BASE_URL (بخشِ «دامنه‌ها» در بالای همین فایل) ساخته می‌شوند.
ZARINPAL_MERCHANT_ID = os.environ.get('ZARINPAL_MERCHANT_ID', '')
ZARINPAL_SANDBOX = os.environ.get('ZARINPAL_SANDBOX') == '1'
@@ -564,14 +947,19 @@ ZARINPAL_STARTPAY_BASE = os.environ.get(
'https://sandbox.zarinpal.com/pg/StartPay' if ZARINPAL_SANDBOX
else 'https://www.zarinpal.com/pg/StartPay',
)
ZARINPAL_CALLBACK_URL = os.environ.get(
'ZARINPAL_CALLBACK_URL',
f'{PUBLIC_BASE_URL}/api/payment/zarinpal/callback' if PUBLIC_BASE_URL else '',
# `.strip() or` مثلِ بقیه: کلیدِ خالی در .env نباید callback را خالی کند (آن‌وقت
# هر پرداختی بی‌صدا ۵۰۳ می‌گرفت).
ZARINPAL_CALLBACK_URL = (
os.environ.get('ZARINPAL_CALLBACK_URL', '').strip()
or f'{PUBLIC_BASE_URL}/api/payment/zarinpal/callback'
)
# اختیاری: بعدِ verify، مرورگر/کلاینت را به این URL (مثلاً یک deep link اپ)
# با ?orderId=...&status=... ریدایرکت می‌کنیم. اگه خالی باشه، همون‌جا یک
# پاسخِ JSON ساده برمی‌گرده.
ZARINPAL_REDIRECT_URL = os.environ.get('ZARINPAL_REDIRECT_URL', '')
# بعدِ verify، مرورگر را به این URL با ?orderId=...&status=... برمی‌گردانیم
# (برای اپِ موبایل می‌تواند یک deep link باشد). پیش‌فرض خودِ اپِ وب است؛ قبلاً
# خالی بود و دانش‌آموز بعد از پرداختِ واقعی روی یک صفحه‌ی JSONِ خام روی دامنه‌ی
# API می‌ماند. مقدارِ صریحِ `-` یعنی همان پاسخِ JSON (برای تست/ابزار).
ZARINPAL_REDIRECT_URL = os.environ.get('ZARINPAL_REDIRECT_URL', '').strip() or f'{FRONTEND_URL}/'
if ZARINPAL_REDIRECT_URL == '-':
ZARINPAL_REDIRECT_URL = ''
# -----------------------------------------------------------------------
# RAG / AI provider (اپ rag + ai_tutor.generate_tutor_answer)
@@ -582,8 +970,32 @@ ZARINPAL_REDIRECT_URL = os.environ.get('ZARINPAL_REDIRECT_URL', '')
# تست‌های موجود و اجرای بدونِ کلید دست‌نخورده کار می‌کنن).
OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY', '')
OPENAI_BASE_URL = os.environ.get('OPENAI_BASE_URL', 'https://api.gapgpt.app/v1')
OPENAI_CHAT_MODEL = os.environ.get('OPENAI_CHAT_MODEL', 'gpt-4o-mini')
OPENAI_VISION_MODEL = os.environ.get('OPENAI_VISION_MODEL', 'gpt-4o')
# gpt-4.1-mini از ۲۰۲۶-۰۹-۲۶ (تصمیمِ صاحبِ پروژه): در سنجشِ ۱۰۲ مسئله ۹۶ درست در برابرِ
# ۸۵–۹۰ِ gpt-4o-mini، با ۲٫۹ برابر هزینه (۰٫۱۲ سنت برای هر پاسخ). شرح: گزارش ۲۳ و
# docs/architecture.md. پیش‌فرض همین‌جاست تا دیپلوی به ویرایشِ دستیِ .env نیاز نداشته باشد.
OPENAI_CHAT_MODEL = os.environ.get('OPENAI_CHAT_MODEL', 'gpt-4.1-mini')
# سقفِ سوالِ روزانه از دستیار برای کاربرِ بدونِ اشتراک (مشترکان: Plan.ai_daily_quota).
# ۲ (تصمیمِ مالک): سهمِ رایگان فقط برای آشنایی است و هزینه‌ی خالص دارد (۲ × ~۷۰۰ تومان برای هر کاربرِ فعال در
# روز؛ docs/ai-cost-and-guardrails.md «اشتراک‌ها»). سوالِ بیشتر فقط با اشتراک.
AI_DAILY_QUOTA_FREE = int(os.environ.get('AI_DAILY_QUOTA_FREE', '2'))
# سقفِ روزانه‌ی *کلِ سامانه* برای سوال‌هایی که به مدل می‌رسند، همان الگوی
# OTP_SMS_GLOBAL_DAILY_MAX. سقف‌های بالا برای هر کاربر و هر IP‌اند؛ کسی با چند IP و مهمان‌های
# تازه می‌توانست خرجِ GapGPT را بی‌سقف بالا ببرد. ۳۰۰۰ با ~۰٫۲ سنت برای هر سوال یعنی حدودِ ۶
# دلار در روز؛ رسیدن به آن ۵۰۳ و لاگِ ERROR می‌دهد. با رشدِ کاربرِ واقعی از .env بالا ببرید.
AI_GLOBAL_DAILY_MAX = int(os.environ.get('AI_GLOBAL_DAILY_MAX', '3000'))
# حداکثر جوابِ هم‌زمانِ دستیار در هر worker (ai_tutor/concurrency.py). هر جواب یک نخ را ۳ تا ۴۰ ثانیه
# نگه می‌دارد؛ بی‌سقف، رگبارِ سوال هر ۳۲ نخِ worker را می‌گرفت و نقشه و آزمون پشتش صف می‌کشیدند. با ۱۲،
# دست‌کم ۲۰ نخ برای بقیه‌ی اپ می‌ماند؛ ۴ worker × ۱۲ = ۴۸ جوابِ هم‌زمان. بیشتر از آن ۵۰۳ «شلوغ است»
# می‌گیرد، پیش از رزروِ سهمیه. سقفِ هم‌زمانیِ خودِ GapGPT را نمی‌دانیم و نسنجیده‌ایم.
AI_MAX_CONCURRENT_PER_WORKER = int(os.environ.get('AI_MAX_CONCURRENT_PER_WORKER', '12'))
# اختیاری: مدلِ جدا فقط برای پایه‌های دهم تا دوازدهم (حسابان/مثلثات). خالی یعنی
# همه‌ی پایه‌ها OPENAI_CHAT_MODEL. انتخابش با `rag_model_benchmark` سنجیده می‌شود.
OPENAI_CHAT_MODEL_UPPER = os.environ.get('OPENAI_CHAT_MODEL_UPPER', '')
# فقط برای مدل‌های reasoning (o-series/gpt-5): low | medium | high؛ خالی = پیش‌فرضِ مدل.
OPENAI_CHAT_REASONING_EFFORT = os.environ.get('OPENAI_CHAT_REASONING_EFFORT', 'low')
# gpt-4.1-mini در سنجشِ ۲۰۲۶-۰۹-۲۰ هم ارزان‌تر و هم دقیق‌تر از gpt-4o بود
# (docs/extraction-playbook.md)؛ پیش‌فرض همان است تا به .env وابسته نباشد.
OPENAI_VISION_MODEL = os.environ.get('OPENAI_VISION_MODEL', 'gpt-4.1-mini')
# نباید تغییر کنه مگر اینکه collectionهای Qdrant هم با همون مدلِ جدید بازساخته بشن.
OPENAI_EMBEDDING_MODEL = os.environ.get('OPENAI_EMBEDDING_MODEL', 'text-embedding-3-small')
OPENAI_EMBEDDING_DIM = int(os.environ.get('OPENAI_EMBEDDING_DIM', '1536'))
@@ -617,9 +1029,10 @@ RAG_ANSWER_CACHE_TTL = int(os.environ.get('RAG_ANSWER_CACHE_TTL', '1800'))
# در اجرای تست‌ها همیشه RAG خاموش است تا هیچ تستی به OpenAI/Qdrant واقعی
# نزند (سرعت + پایداری). تست‌هایی که مسیرِ RAG را می‌آزمایند خودشان با
# override_settings + mockِ کلاینت‌ها این را برمی‌گردانند (rag/tests.py).
# فقط خودِ دستورِ `manage.py test`؛ هر آرگومانی به اسمِ «test» (مثلاً نامِ یک collection) نه.
import sys as _sys
if 'test' in _sys.argv:
if _sys.argv[1:2] == ['test']:
RAG_FORCE_MOCK = True
RAG_ANSWER_CACHE_TTL = 0
@@ -660,13 +1073,40 @@ LOGGING = {
# Promtail بتونه tail کنه. بدونش (اجرای عادی خارج از Docker)، فقط stdout.
LOG_DIR = os.environ.get('LOG_DIR', '')
# کپیِ زنده‌ی دیتابیس روی سرورِ دیگر (ops/pgbackup/): نامِ اسلاتِ تکثیری که آن سرور از آن می‌خواند.
# خالی روی Postgres یعنی کپیِ بیرونی نداریم و /api/health هشدار می‌دهد.
OFFSITE_WAL_SLOT = os.environ.get('OFFSITE_WAL_SLOT', '')
# بیش از این عقب‌ماندن هشدار است. ۶۴ مگ ≈ چهار فایلِ WAL؛ در ترافیکِ عادی عقب‌ماندگی نزدیکِ صفر است.
OFFSITE_WAL_MAX_LAG_BYTES = 64 * 2**20
# سرورِ اختصاصی (۲۱۶): بکاپِ رمزشده‌ی pgBackRest؛ `tadrisino-offsite-sync` هر دقیقه آن را به ۲۴۷ می‌فرستد و زمانِ
# آخرین ارسالِ موفق (epoch) را در این فایل می‌نویسد (docs/operations.md «سرورِ اختصاصیِ جدید»). ست باشد، بر
# OFFSITE_WAL_SLOT مقدم است. OFFSITE_WAL_SLOT را فقط ۲۴۹ دارد و با سوئیچ به ۲۱۶ همراهِ _slot_state حذف می‌شود.
OFFSITE_SYNC_STATUS_FILE = os.environ.get('OFFSITE_SYNC_STATUS_FILE', '')
# ارسال هر دقیقه است؛ ۱۰ دقیقه بی‌ارسال یعنی چند اجرای پشتِ سرِ هم شکست خورده.
OFFSITE_SYNC_MAX_AGE_SECONDS = 600
# چرخشِ فایلِ لاگ: 'size' (پیش‌فرض، Docker و محلی) = ۶ فایلِ ۱۰ مگی و قدیمی‌ها پاک می‌شوند؛
# 'external' (سرور) = برنامه هرگز پاک نمی‌کند، `ops/applogs/archive-app-log.sh` هر شب app.log را جابه‌جا و
# فشرده می‌کند و ۲۴۷ آن را برای همیشه نگه می‌دارد. WatchedFileHandler پس از جابه‌جایی فایلِ تازه باز می‌کند،
# که با ۴ پروسه‌ی gunicorn امن‌تر از چرخشِ داخلیِ RotatingFileHandler است (آن هر پروسه جدا می‌چرخاند).
LOG_FILE_ROTATION = os.environ.get('LOG_FILE_ROTATION', 'size')
if LOG_FILE_ROTATION not in ('size', 'external'):
raise ImproperlyConfigured(f"LOG_FILE_ROTATION باید 'size' یا 'external' باشد، نه {LOG_FILE_ROTATION!r}")
if LOG_DIR:
os.makedirs(LOG_DIR, exist_ok=True)
LOGGING['handlers']['file'] = {
'class': 'logging.handlers.RotatingFileHandler',
'filename': os.path.join(LOG_DIR, 'app.log'),
'maxBytes': 10 * 1024 * 1024, # 10MB در هر فایل، قبل از rotate
'backupCount': 5,
'formatter': 'json',
}
if LOG_FILE_ROTATION == 'external':
LOGGING['handlers']['file'] = {
'class': 'logging.handlers.WatchedFileHandler',
'filename': os.path.join(LOG_DIR, 'app.log'),
'formatter': 'json',
}
else:
LOGGING['handlers']['file'] = {
'class': 'logging.handlers.RotatingFileHandler',
'filename': os.path.join(LOG_DIR, 'app.log'),
'maxBytes': 10 * 1024 * 1024, # 10MB در هر فایل، قبل از rotate
'backupCount': 5,
'formatter': 'json',
}
LOGGING['root']['handlers'].append('file')

View File

@@ -2,11 +2,11 @@ import importlib
import json
import logging
import os
from urllib.parse import urlsplit
from unittest.mock import patch
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.test import RequestFactory, SimpleTestCase, TestCase
from django.test import RequestFactory, SimpleTestCase, TestCase, override_settings
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
@@ -171,9 +171,12 @@ class APIRequestLoggingMiddlewareTests(TestCase):
self.client = APIClient()
def test_authenticated_api_request_is_logged_with_user_id_and_no_secrets(self):
user = User.objects.create(device_id='device-mw-log-1', is_guest=True, name='')
user = User.objects.create(phone='09000000005', name='')
token = issue_access_token(user.id)
from content.models import Grade
Grade.objects.create(pk=10, title='g10', icon='', color='')
with self.assertLogs('config.middleware', level='INFO') as captured:
response = self.client.post(
'/api/user/grade',
@@ -199,18 +202,18 @@ class APIRequestLoggingMiddlewareTests(TestCase):
def test_anonymous_api_request_is_logged_with_null_user_id(self):
with self.assertLogs('config.middleware', level='INFO') as captured:
response = self.client.post(
'/api/auth/session',
{'type': 'guest', 'deviceId': 'device-mw-log-anon-1'},
'/api/auth/refresh',
{'refreshToken': 'not-a-real-token'},
format='json',
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.status_code, 401)
self.assertEqual(len(captured.records), 1)
message = captured.records[0].getMessage()
self.assertIn('method=POST', message)
self.assertIn('path=/api/auth/session', message)
self.assertIn('status=200', message)
self.assertIn('path=/api/auth/refresh', message)
self.assertIn('status=401', message)
self.assertIn('user_id=None', message)
def test_non_api_request_is_not_logged(self):
@@ -319,7 +322,8 @@ class ServerEnvDefaultsTests(SimpleTestCase):
اینکه بدونِ هیچ متغیرِ محیطی‌ای مقدارِ کارآمد دارند.
"""
HOST = 'api.math.tadrisino2.ir'
# دامنه‌ی API از PUBLIC_BASE_URL می‌آید، نه از یک رشته‌ی ثابت در تست.
HOST = urlsplit(settings.PUBLIC_BASE_URL).hostname
def test_the_api_domain_is_allowed_without_any_env_var(self):
self.assertIn(self.HOST, settings.DEFAULT_ALLOWED_HOSTS)
@@ -342,7 +346,7 @@ class ServerEnvDefaultsTests(SimpleTestCase):
"""کلید در .env هست ولی خالی — os.environ.get پیش‌فرض نمی‌دهد."""
with patch.dict(os.environ, {'PUBLIC_BASE_URL': ''}):
value = (os.environ.get('PUBLIC_BASE_URL', '').strip()
or 'https://api.math.tadrisino2.ir')
or 'https://mathapi.tadrisino2.ir')
self.assertTrue(value.startswith('https://'))
def test_admin_login_origin_is_trusted_without_any_env_var(self):
@@ -451,3 +455,64 @@ class CorsDevPortTests(TestCase):
# و پذیرشِ هر پورتِ localhost را واقعاً خطرناک می‌کرد.
from django.conf import settings
self.assertFalse(settings.CORS_ALLOW_CREDENTIALS)
class ApiDocsRtlShellTests(TestCase):
"""
صفحه‌های مستنداتِ API باید پوسته‌ی راست‌به‌چپِ خودمان را داشته باشند.
بدونِ آن، Swagger متنِ فارسی را در یک بلوکِ چپ‌به‌راست می‌چیند و نتیجه‌اش
پریدنِ نقطه‌ی جمله به ابتدای خط و برعکس شدنِ ستون‌های جدول است. این تست
هست چون خرابی‌اش فقط با چشم دیده می‌شود: هیچ خطایی نمی‌دهد و هیچ تستِ
دیگری نمی‌گیردش.
"""
def setUp(self):
from django.contrib.auth.models import User as AuthUser
self.client.force_login(AuthUser.objects.create_superuser('docs-admin', 'd@x.ir', 'pw'))
def test_docs_are_closed_to_anonymous_visitors(self):
from rest_framework.test import APIClient
for path in ('/api/docs/', '/api/redoc/', '/api/schema/'):
self.assertIn(APIClient().get(path).status_code, (401, 403), path)
def test_swagger_ui_carries_the_rtl_shell(self):
html = self.client.get('/api/docs/').content.decode()
self.assertIn('direction: rtl', html)
# کد باید صریحاً چپ‌به‌راست بماند، وگرنه «Authorization: Bearer ...»
# در بلوکِ RTL جابه‌جا می‌شود.
self.assertIn('direction: ltr', html)
# قالبِ اصلیِ drf-spectacular باید دست‌نخورده زیرش باشد.
self.assertIn('swagger-ui-bundle', html)
def test_redoc_carries_the_rtl_shell(self):
html = self.client.get('/api/redoc/').content.decode()
self.assertIn('direction: rtl', html)
class InsecureSecretKeyWarningTests(TestCase):
"""کلیدِ کامیت‌شده‌ی django-insecure در پروداکشن باید در /api/health دیده شود (۲۰۲۶-۱۰-۰۳)."""
def test_committed_key_is_reported(self):
from config import health
with override_settings(DEBUG=False, SECRET_KEY='django-insecure-xyz'):
self.assertIn('django-insecure', ' '.join(health._warnings()))
with override_settings(DEBUG=False, SECRET_KEY='a-real-random-key'):
self.assertNotIn('django-insecure', ' '.join(health._warnings()))
class HealthIsMinimalForThePublicTests(TestCase):
"""/api/health بی‌کلید فقط ok/error می‌دهد؛ با کلیدِ درست جزئیات."""
@override_settings(INTERNAL_SERVICE_KEY='k')
def test_public_sees_only_status(self):
body = self.client.get('/api/health').json()
self.assertEqual(set(body), {'status'})
wrong = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='nope').json()
self.assertEqual(set(wrong), {'status'})
full = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').json()
self.assertIn('traffic', full)
@override_settings(INTERNAL_SERVICE_KEY='')
def test_without_a_configured_key_nobody_gets_details(self):
self.assertEqual(set(self.client.get('/api/health', HTTP_X_INTERNAL_KEY='').json()), {'status'})

View File

@@ -0,0 +1,26 @@
import os
import tempfile
from django.test import SimpleTestCase, override_settings
from config import health
class AppLogSizeWarningTests(SimpleTestCase):
"""با چرخشِ بیرونی برنامه app.log را پاک نمی‌کند؛ اگر آرشیوِ شبانه بخوابد /api/health باید بگوید."""
def _with_log(self, size, rotation):
d = tempfile.mkdtemp()
with open(os.path.join(d, 'app.log'), 'wb') as f:
f.truncate(size)
with override_settings(LOG_DIR=d, LOG_FILE_ROTATION=rotation):
return health._app_log_warnings()
def test_big_log_with_external_rotation_warns(self):
self.assertEqual(len(self._with_log(health.APP_LOG_MAX_BYTES + 1, 'external')), 1)
def test_small_log_is_quiet(self):
self.assertEqual(self._with_log(1024, 'external'), [])
def test_size_rotation_never_warns(self):
self.assertEqual(self._with_log(health.APP_LOG_MAX_BYTES + 1, 'size'), [])

View File

@@ -0,0 +1,126 @@
"""
بازبینیِ دومِ ۲۰۲۶-۰۹-۲۵: ایرادهایی که بازتولید و بسته شدند.
هر تست همان سناریویی را اجرا می‌کند که پیش‌تر خراب بود. ایرادهایی که عمداً
باز ماندند (تصمیمِ محصولی یا خطرِ شکستنِ فرانت) در docs/security-before-production.md
و کامنتِ همان کدها ثبت‌اند.
"""
from unittest.mock import MagicMock, patch
from django.core.cache import cache
from django.test import TestCase, override_settings
from django.utils import timezone
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from content.models import Choice, Grade, Lesson, Node, Question, Unit
from discount.models import Offer
from payment.models import Order
from payment.services import apply_order_payment_result
from subscription.models import Plan, Subscription
def _client(user):
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
return client
class _Base(TestCase):
def setUp(self):
cache.clear()
self.user = User.objects.create(phone='09120008001')
self.client = _client(self.user)
self.year = Plan.objects.create(title='سالانه', price=299000, duration_days=365)
def tearDown(self):
cache.clear()
def buy(self, client=None):
response = (client or self.client).post(
'/api/subscription/purchase', {'planId': str(self.year.id)}, format='json')
self.assertEqual(response.status_code, 200, response.content)
return Order.objects.get(pk=response.json()['orderId'])
def pay(self, order, ref):
apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id=ref,
new_status='paid', event='test')
class OfferNeverShortensSubscription(_Base):
def test_trial_code_after_a_paid_year_keeps_the_year(self):
self.pay(self.buy(), ref='R1')
before = Subscription.objects.get(user_id=self.user.id).expires_at
Offer.objects.create(name='هفته رایگان', kind='trial', duration_days=7, code='WEEK')
response = self.client.post('/api/discount/offers/redeem', {'code': 'WEEK'}, format='json')
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(Subscription.objects.get(user_id=self.user.id).expires_at, before)
def test_trial_still_works_for_a_user_without_subscription(self):
Offer.objects.create(name='هفته رایگان', kind='trial', duration_days=7, code='WEEK')
self.client.post('/api/discount/offers/redeem', {'code': 'WEEK'}, format='json')
sub = Subscription.objects.get(user_id=self.user.id)
self.assertEqual(sub.status, 'active')
self.assertIn((sub.expires_at - timezone.now()).days, (6, 7))
def test_free_full_still_upgrades_a_paid_year_to_unlimited(self):
self.pay(self.buy(), ref='R3')
Offer.objects.create(name='رایگانِ کامل', kind='free_full', code='FULL')
self.client.post('/api/discount/offers/redeem', {'code': 'FULL'}, format='json')
self.assertIsNone(Subscription.objects.get(user_id=self.user.id).expires_at)
@override_settings(ZARINPAL_MERCHANT_ID='x' * 36, ZARINPAL_REDIRECT_URL='https://app.example/')
class ZarinpalErrorBodyReturnsToApp(_Base):
def test_documented_error_body_redirects_instead_of_500(self):
order = self.buy()
Order.objects.filter(pk=order.pk).update(gateway_authority='A' * 36)
fake = MagicMock(status_code=200)
fake.json.return_value = {'data': [], 'errors': {'code': -51, 'message': 'failed'}}
with patch('payment.zarinpal.requests.post', return_value=fake):
response = APIClient().get('/api/payment/zarinpal/callback',
{'Authority': 'A' * 36, 'Status': 'OK'})
self.assertEqual(response.status_code, 302)
self.assertIn('status=failed', response['Location'])
class WeeklyReportCountsIslands(TestCase):
# تا ۲۰۲۶-۱۰-۰۱ همین سناریو گزارشِ والد را از /api/family/weekly-report می‌خواند؛
# آن اپ حذف شد و گزارش حالا در حسابِ خودِ دانش‌آموز است.
def test_island_quiz_xp_reaches_the_weekly_report(self):
cache.clear()
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
node = Node.objects.create(unit=Unit.objects.create(grade=grade, title='فصل', order=1),
title='جزیره', order=1)
child = User.objects.create(phone='09120008002', grade_id=grade.id)
child_client = _client(child)
for i in range(4):
question = Question.objects.create(
lesson=Lesson.objects.create(node=node, title=f'درس {i}'),
type='multiple_choice', prompt=f'سوال {i}')
right = Choice.objects.create(question=question, value='الف', is_correct=True)
Choice.objects.create(question=question, value='ب', is_correct=False)
child_client.post(f'/api/questions/{question.id}/answer', {'selectedOptionId': right.id},
format='json')
child_client.post(f'/api/nodes/{node.id}/quiz/complete',
{'correctCount': 4, 'totalQuestions': 4}, format='json')
week = child_client.get('/api/user/weekly-report').json()['thisWeek']
self.assertEqual(week['xp'], 40)
self.assertEqual(week['islands'], 1)
self.assertEqual(week['lessons'], 0)
self.assertEqual((week['questions'], week['correct']), (4, 4))
class AskWithNonNumericQuestionId(TestCase):
def test_answer_is_saved_not_500(self):
cache.clear()
user = User.objects.create(phone='09120008004')
response = _client(user).post('/api/ai/ask',
{'userQuestion': 'کسر چیست؟', 'questionId': 'q-12'},
format='json')
self.assertEqual(response.status_code, 200, response.content)
from ai_tutor.models import Message
self.assertIsNone(Message.objects.get(pk=response.json()['messageId']).question_id)

View File

@@ -0,0 +1,178 @@
"""
قطعیِ Redisِ مشترک نباید API را ۵۰۰ کند (config/cache.py). دو دسته:
۱. Redis در دسترس نیست (پورتِ بسته، و آدرسِ بی‌جواب برای timeout): هر نوع کارکرد را می‌سنجد.
۲. یک redis-serverِ واقعیِ محلی (اگر نصب باشد): کارکردِ عادی، پیشوندِ کلید، و «وسطِ کار خاموش و دوباره روشن».
"""
import shutil
import socket
import subprocess
import tempfile
import time
import unittest
from unittest import mock
from django.core.cache import caches
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from content.models import Grade, Lesson, Node, Unit
from . import cache as cache_module
BACKEND = 'config.cache.ResilientRedisCache'
def _caches(location):
return {'default': {'BACKEND': BACKEND, 'LOCATION': location,
'OPTIONS': {'socket_connect_timeout': 1, 'socket_timeout': 1}}}
class _Quiet:
def setUp(self):
super().setUp()
cache_module._last_logged = 0.0
@override_settings(CACHES=_caches('redis://127.0.0.1:1/0'))
@override_settings(INTERNAL_SERVICE_KEY='k')
class RedisDownTests(_Quiet, TestCase):
"""پورتِ ۱ بسته است: هر اتصال فوراً رد می‌شود."""
def test_every_operation_degrades_the_documented_way(self):
c = caches['default']
with self.assertLogs('config.cache', 'ERROR'):
self.assertEqual(c.get('k', 'dflt'), 'dflt')
self.assertIsNone(c.set('k', 1, 10))
self.assertIs(c.add('k', 1, 10), True) # «کلید نبود» → فراخواننده ادامه می‌دهد
self.assertIs(c.delete('k'), False)
self.assertEqual(c.get_many(['a', 'b']), {})
self.assertEqual(c.set_many({'a': 1}), ['a'])
self.assertIs(c.has_key('k'), False)
with self.assertRaises(ValueError): # همان استثنایی که فراخواننده‌ها می‌شناسند
c.incr('k')
self.assertEqual(c.get_or_set('k', lambda: 'built', 10), 'built')
def test_errors_are_logged_but_not_flooded(self):
c = caches['default']
with self.assertLogs('config.cache', 'ERROR') as logs:
c.get('a'); c.get('b'); c.set('c', 1)
self.assertEqual(len(logs.output), 1)
def test_throttled_public_endpoint_still_answers(self):
grade = Grade.objects.create(title='G', icon='', color='', number=88)
lesson = Lesson.objects.create(node=Node.objects.create(
unit=Unit.objects.create(grade=grade, title='U', order=1), title='N', order=1), title='L')
client = APIClient()
for _ in range(5):
self.assertEqual(client.get(f'/api/lessons/{lesson.id}/questions').status_code, 200)
self.assertEqual(client.get('/api/grades').status_code, 200)
def test_authenticated_endpoint_still_answers(self):
user = User.objects.create(phone='09120000888')
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
self.assertEqual(client.get('/api/user/hearts').status_code, 200)
self.assertEqual(client.get('/api/home').status_code, 200)
def test_otp_request_still_works_because_its_caps_live_in_the_database(self):
with mock.patch('accounts.views.send_otp_sms'):
response = APIClient().post('/api/auth/otp/request', {'phone': '09120000889'}, format='json')
self.assertEqual(response.status_code, 200, response.content)
def test_admin_login_page_is_not_blocked(self):
response = self.client.post('/admin/login/', {'username': 'x', 'password': 'y'})
self.assertNotEqual(response.status_code, 429)
self.assertNotEqual(response.status_code, 500)
def test_health_says_so(self):
response = APIClient().get('/api/health', HTTP_X_INTERNAL_KEY='k')
self.assertEqual(response.status_code, 503)
self.assertIs(response.json()['checks']['cache']['ok'], False)
class RedisHangsTests(_Quiet, TestCase):
def test_an_unanswering_redis_costs_about_a_second_not_a_hang(self):
# آدرسی که جواب نمی‌دهد (بلاک‌هول)؛ اگر شبکه فوراً خطا بدهد هم تست درست است.
with override_settings(CACHES=_caches('redis://10.255.255.1:6379/0')):
started = time.monotonic()
self.assertEqual(caches['default'].get('k', 'dflt'), 'dflt')
self.assertLess(time.monotonic() - started, 4)
def _free_port():
with socket.socket() as s:
s.bind(('127.0.0.1', 0))
return s.getsockname()[1]
@unittest.skipUnless(shutil.which('redis-server'), 'redis-server نصب نیست')
@override_settings(INTERNAL_SERVICE_KEY='k')
class RealRedisTests(_Quiet, TestCase):
def setUp(self):
super().setUp()
self.port = _free_port()
self.dir = tempfile.mkdtemp()
self.proc = None
self.start()
def tearDown(self):
self.stop()
shutil.rmtree(self.dir, ignore_errors=True)
super().tearDown()
def start(self):
self.proc = subprocess.Popen(
['redis-server', '--port', str(self.port), '--bind', '127.0.0.1', '--save', '',
'--appendonly', 'no', '--dir', self.dir],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
with socket.socket() as s:
if s.connect_ex(('127.0.0.1', self.port)) == 0:
return
time.sleep(0.05)
self.fail('redis-server بالا نیامد')
def stop(self):
if self.proc and self.proc.poll() is None:
self.proc.terminate()
self.proc.wait(timeout=5)
def test_normal_behaviour_and_keys_have_a_ttl(self):
import redis
with override_settings(CACHES=_caches(f'redis://127.0.0.1:{self.port}/1')):
c = caches['default']
c.set('k', {'a': 1}, 30)
self.assertEqual(c.get('k'), {'a': 1})
self.assertTrue(c.add('n', 0, 30))
self.assertFalse(c.add('n', 0, 30))
self.assertEqual(c.incr('n'), 1)
with self.assertRaises(ValueError):
c.incr('missing')
raw = redis.Redis(host='127.0.0.1', port=self.port, db=1)
self.assertEqual(redis.Redis(host='127.0.0.1', port=self.port, db=0).dbsize(), 0) # فقط دیتابیسِ خودمان
self.assertGreater(raw.ttl(raw.keys('*k')[0]), 0) # TTL گذاشته شده؛ کلیدِ بی‌انقضا نمی‌ماند
def test_throttle_is_shared_and_health_is_ok(self):
with override_settings(CACHES=_caches(f'redis://127.0.0.1:{self.port}/1')):
response = APIClient().get('/api/health', HTTP_X_INTERNAL_KEY='k')
self.assertIs(response.json()['checks']['cache']['ok'], True)
def test_redis_dying_mid_flight_and_coming_back_without_restarting_the_app(self):
with override_settings(CACHES=_caches(f'redis://127.0.0.1:{self.port}/1')):
c = caches['default']
c.set('k', 'before', 60)
self.assertEqual(c.get('k'), 'before')
self.stop() # Redis می‌میرد
with self.assertLogs('config.cache', 'ERROR'):
self.assertEqual(c.get('k', 'miss'), 'miss')
self.assertEqual(APIClient().get('/api/grades').status_code, 200)
self.assertEqual(APIClient().get('/api/health', HTTP_X_INTERNAL_KEY='k').status_code, 503)
self.start() # برمی‌گردد، بدونِ ری‌استارتِ برنامه
c.set('k2', 'after', 60)
self.assertEqual(c.get('k2'), 'after')
self.assertEqual(APIClient().get('/api/health', HTTP_X_INTERNAL_KEY='k').status_code, 200)

192
config/tests_db_pool.py Normal file
View File

@@ -0,0 +1,192 @@
"""
poolِ اتصالِ Postgres و رفتارِ زیرِ ازدحام (config/settings.py، config/db.py).
بارِ واقعی با لودتستِ محلی سنجیده شد (Postgresِ ۱۴ با max_connections=60 مثلِ سرور، ۳ worker × ۳۲ نخ،
۱۰۰ کاربرِ هم‌زمان): کدِ قبلی ۲۱٪ِ درخواست‌ها را ۵۰۰ کرد و هر ۵۷ اتصال را گرفت؛ با pool صفر خطا و ۲۴ اتصال.
این تست‌ها تکه‌هایی را نگه می‌دارند که بی‌لودتست هم می‌شود سنجید.
"""
import logging
import os
import subprocess
import sys
from unittest import mock
from django.core.cache import cache
from django.db import OperationalError, connection, transaction
from django.test import RequestFactory, SimpleTestCase, TestCase, TransactionTestCase
from psycopg_pool import PoolTimeout
from accounts.authentication import issue_access_token
from accounts.models import User
from config import health
from config.db import is_pool_timeout, release_db_connection
from config.middleware import DatabaseBusyMiddleware
from content.models import Grade
def _pool_timeout_error():
"""همان شکلی که جنگو PoolTimeout را بالا می‌دهد (wrap_database_errors)."""
err = OperationalError("couldn't get a connection after 10.00 sec")
err.__cause__ = PoolTimeout("couldn't get a connection after 10.00 sec")
return err
class PoolTimeoutDetectionTests(SimpleTestCase):
def test_only_a_wrapped_pool_timeout_counts(self):
self.assertTrue(is_pool_timeout(_pool_timeout_error()))
self.assertFalse(is_pool_timeout(OperationalError('server closed the connection')))
self.assertFalse(is_pool_timeout(PoolTimeout('raw, not wrapped by django')))
self.assertFalse(is_pool_timeout(ValueError()))
class ServerBusyResponseTests(TestCase):
def setUp(self):
cache.clear()
self.user = User.objects.create(phone='09120007711')
self.auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(self.user.id)}'}
def test_drf_view_answers_503_with_retry_after_not_500(self):
# هندلرِ خطای DRF (config/exceptions.py) هر خطای ناشناخته را خودش ۵۰۰ می‌کند و به میان‌افزار
# نمی‌رسد؛ اولین نسخه‌ی این تغییر فقط میان‌افزار را داشت و در لودتست همه ۵۰۰ ماند.
with mock.patch('content.views.HomeView.get', side_effect=_pool_timeout_error()), \
self.assertLogs('config.db', level='ERROR') as logs:
response = self.client.get('/api/home', **self.auth)
self.assertEqual(response.status_code, 503)
self.assertEqual(response['Retry-After'], '5')
self.assertEqual(response.json()['code'], 'server_busy')
self.assertIn('db_pool_exhausted', logs.output[0])
def test_other_database_errors_stay_500(self):
with mock.patch('content.views.HomeView.get', side_effect=OperationalError('db down')), \
self.assertLogs('config.exceptions', level='ERROR'):
response = self.client.get('/api/home', **self.auth)
self.assertEqual(response.status_code, 500)
def test_middleware_covers_non_drf_views(self):
request = RequestFactory().get('/admin/')
middleware = DatabaseBusyMiddleware(lambda r: None)
with self.assertLogs('config.db', level='ERROR'):
response = middleware.process_exception(request, _pool_timeout_error())
self.assertEqual(response.status_code, 503)
self.assertEqual(response['Retry-After'], '5')
self.assertIsNone(middleware.process_exception(request, OperationalError('db down')))
class ReleaseConnectionTests(TransactionTestCase):
def test_outside_a_transaction_the_connection_goes_back(self):
User.objects.count()
self.assertIsNotNone(connection.connection)
release_db_connection()
self.assertIsNone(connection.connection)
self.assertEqual(User.objects.count(), 0) # کوئریِ بعدی خودش یکی تازه می‌گیرد
def test_inside_atomic_it_keeps_the_connection_and_warns(self):
with transaction.atomic():
User.objects.count()
with self.assertLogs('config.db', level='WARNING'):
release_db_connection()
self.assertIsNotNone(connection.connection)
User.objects.create(phone='09120007712') # تراکنش سالم ماند
self.assertEqual(User.objects.count(), 1)
class ReleaseUnderTestCaseIsSilentTests(TestCase):
def test_testcase_transaction_is_not_reported_as_a_bug(self):
logger = logging.getLogger('config.db')
with mock.patch.object(logger, 'warning') as warning:
release_db_connection()
warning.assert_not_called()
class AskReleasesTheConnectionBeforeTheModelTests(TransactionTestCase):
"""دستیار تا ۴۰ ثانیه منتظرِ Qdrant و مدل است؛ در این مدت نباید اتصالی از pool دستش باشد."""
def setUp(self):
cache.clear()
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
self.user = User.objects.create(phone='09120007713', grade_id=grade.id)
self.seen = {}
def _ask(self, **extra):
return self.client.post(
'/api/ai/ask', {'userQuestion': 'توان چیست؟', **extra}, content_type='application/json',
HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}',
)
def _retrieve(self, *args, **kwargs):
self.seen['retrieve'] = connection.connection
return [{'title': 'فصل', 'summary': 'متن'}], {'gradeTitle': 'نهم'}
def test_json_path(self):
def generate(*args, **kwargs):
self.seen['generate'] = connection.connection
return 'جواب', [], {}
with mock.patch('rag.services.config.rag_is_configured', return_value=True), \
mock.patch('rag.services.retrieval.retrieve_context', side_effect=self._retrieve), \
mock.patch('rag.services.generation.generate_answer', side_effect=generate):
response = self._ask()
self.assertEqual(response.status_code, 200)
self.assertIsNone(self.seen['retrieve'])
self.assertIsNone(self.seen['generate'])
def test_stream_path(self):
def stream(*args, **kwargs):
self.seen['stream'] = connection.connection
yield 'جواب'
with mock.patch('rag.services.config.rag_is_configured', return_value=True), \
mock.patch('rag.services.retrieval.retrieve_context', side_effect=self._retrieve), \
mock.patch('rag.services.generation.stream_answer', side_effect=stream):
response = self._ask(stream=True)
body = b''.join(response.streaming_content).decode()
self.assertIn('event: done', body)
self.assertIsNone(self.seen['retrieve'])
self.assertIsNone(self.seen['stream'])
class PoolBudgetHealthTests(SimpleTestCase):
def test_over_budget_is_a_warning(self):
warnings = health._db_pool_warnings(
{'state': 'over_budget', 'workers': 6, 'poolMaxSize': 10, 'budget': 66, 'maxConnections': 57})
self.assertEqual(len(warnings), 1)
self.assertIn('66', warnings[0])
self.assertEqual(health._db_pool_warnings({'state': 'ok'}), [])
self.assertEqual(health._db_pool_warnings({'state': 'not_postgres'}), [])
class PoolBudgetOnPostgresTests(TestCase):
def test_reports_the_real_limits(self):
if connection.vendor != 'postgresql':
self.skipTest('pool فقط روی Postgres است')
with mock.patch.dict(os.environ, {'GUNICORN_WORKERS': '4'}):
state = health._db_pool()
self.assertEqual(state['workers'], 4)
self.assertEqual(state['budget'], 4 * state['poolMaxSize'] + health.DB_CONNECTION_RESERVE)
self.assertGreater(state['maxConnections'], 0)
self.assertIn(state['state'], ('ok', 'over_budget'))
class PoolSettingsFailLoudlyTests(SimpleTestCase):
"""تنظیمِ غلط باید سرویس را بالا نیاورد، نه این‌که سرِ اولین کوئری همه‌ی درخواست‌ها ۵۰۰ شوند."""
def _load_settings(self, **env):
base = {k: v for k, v in os.environ.items() if not k.startswith('DB_')}
base.update({'DB_NAME': 'x', 'DB_USER': 'x', 'DB_PASSWORD': 'x', **env})
return subprocess.run(
[sys.executable, '-c', 'import config.settings'],
env=base, capture_output=True, text=True, cwd=os.path.dirname(os.path.dirname(__file__)),
)
def test_good_settings_load(self):
self.assertEqual(self._load_settings().returncode, 0)
def test_old_conn_max_age_is_refused(self):
result = self._load_settings(DB_CONN_MAX_AGE='60')
self.assertNotEqual(result.returncode, 0)
self.assertIn('DB_CONN_MAX_AGE', result.stderr)
def test_min_above_max_is_refused(self):
result = self._load_settings(DB_POOL_MIN_SIZE='3', DB_POOL_MAX_SIZE='2')
self.assertNotEqual(result.returncode, 0)
self.assertIn('DB_POOL_MIN_SIZE', result.stderr)

62
config/tests_domains.py Normal file
View File

@@ -0,0 +1,62 @@
"""
دامنه فقط از PUBLIC_BASE_URL و FRONTEND_URL می‌آید.
تنظیمات در زمانِ import ساخته می‌شوند، پس هر حالت در یک پروسه‌ی جدا با env
خودش بار می‌شود. اگر کسی دوباره دامنه‌ای را مستقیم در settings بنویسد، حالتِ
«دامنه‌ی تازه» اینجا قرمز می‌شود.
"""
import json
import os
import subprocess
import sys
from pathlib import Path
from django.test import SimpleTestCase
BASE_DIR = Path(__file__).resolve().parent.parent
PROBE = """
import json, os
os.environ['DJANGO_SETTINGS_MODULE'] = 'config.settings'
import django; django.setup()
from django.conf import settings as s
print(json.dumps({
'hosts': s.ALLOWED_HOSTS, 'csrf': s.CSRF_TRUSTED_ORIGINS, 'cors': s.CORS_ALLOWED_ORIGINS,
'callback': s.ZARINPAL_CALLBACK_URL, 'redirect': s.ZARINPAL_REDIRECT_URL, 'bazaar': s.BAZAAR_REDIRECT_URI,
}))
"""
def load_settings(**env):
clean = {k: v for k, v in os.environ.items() if not k.startswith(('DJANGO_ALLOWED', 'CSRF_', 'CORS_', 'ZARINPAL_', 'BAZAAR_REDIRECT', 'PUBLIC_BASE', 'FRONTEND_URL'))}
clean.update(env)
# .env محلی نباید مقدارهای آزمون را بپوشاند؛ python-dotenv متغیرِ موجود را عوض نمی‌کند،
# پس کلیدهای خالی را هم صریح می‌گذاریم.
for key in ('DJANGO_ALLOWED_HOSTS', 'CSRF_TRUSTED_ORIGINS', 'CORS_ALLOWED_ORIGINS', 'ZARINPAL_CALLBACK_URL',
'ZARINPAL_REDIRECT_URL', 'BAZAAR_REDIRECT_URI'):
clean.setdefault(key, '')
out = subprocess.run([sys.executable, '-c', PROBE], cwd=BASE_DIR, env=clean, capture_output=True, text=True, check=True)
return json.loads(out.stdout.strip().splitlines()[-1])
class DomainSettingsTests(SimpleTestCase):
def test_new_domain_moves_everything(self):
s = load_settings(PUBLIC_BASE_URL='https://api.new-domain.ir', FRONTEND_URL='https://app.new-domain.ir/')
self.assertIn('api.new-domain.ir', s['hosts'])
self.assertIn('app.new-domain.ir', s['hosts'])
self.assertIn('https://app.new-domain.ir', s['cors'])
self.assertIn('https://api.new-domain.ir', s['csrf'])
self.assertEqual(s['callback'], 'https://api.new-domain.ir/api/payment/zarinpal/callback')
self.assertEqual(s['redirect'], 'https://app.new-domain.ir/')
self.assertEqual(s['bazaar'], 'https://api.new-domain.ir/api/payment/bazaar/verify')
self.assertNotIn('tadrisino2', json.dumps(s))
def test_extra_domain_is_added_not_replacing(self):
s = load_settings(
PUBLIC_BASE_URL='https://api.new-domain.ir', FRONTEND_URL='https://app.new-domain.ir',
DJANGO_ALLOWED_HOSTS='old.example.ir', CORS_ALLOWED_ORIGINS='https://old.example.ir',
)
self.assertIn('old.example.ir', s['hosts'])
self.assertIn('api.new-domain.ir', s['hosts'])
self.assertIn('https://old.example.ir', s['cors'])
self.assertIn('https://app.new-domain.ir', s['cors'])

View File

@@ -0,0 +1,20 @@
"""
روی سرور هیچ شماره‌ای با کدِ ورودِ ثابت نباید باشد (حساب‌های دمو ۲۰۲۶-۱۰-۱۳ پاک شدند). اگر کسی
TEST_LOGIN_PHONES را دوباره پر کند، /api/health (با کلید) باید بگوید، نه بی‌صدا بپذیرد.
"""
from django.test import TestCase, override_settings
@override_settings(INTERNAL_SERVICE_KEY='k')
class FixedCodeLoginsAreFlaggedTests(TestCase):
def _warnings(self):
return self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').json()['warnings']
@override_settings(TEST_LOGIN_PHONES={'09120000001': '834512'})
def test_a_fixed_code_phone_raises_a_warning(self):
self.assertTrue(any('TEST_LOGIN_PHONES' in w for w in self._warnings()))
@override_settings(TEST_LOGIN_PHONES={})
def test_empty_list_is_quiet(self):
self.assertFalse(any('TEST_LOGIN_PHONES' in w for w in self._warnings()))

View File

@@ -0,0 +1,77 @@
"""
وضعیتِ کپیِ زنده‌ی دیتابیس در /api/health (config/health.py: _slot_state، _offsite_warnings).
پرسشِ واقعی از pg_replication_slots این‌جا اجرا نمی‌شود (تست‌ها روی SQLite‌اند)؛ روی سرور با اسلاتِ
واقعی دیده شد (docs/operations.md «بکاپ»).
"""
from datetime import datetime, timedelta, timezone
from django.test import SimpleTestCase, override_settings
from config import health
class SlotStateTests(SimpleTestCase):
def test_missing_slot(self):
self.assertEqual(health._slot_state(None)['state'], 'slot_missing')
def test_streaming_and_caught_up_is_ok(self):
self.assertEqual(health._slot_state((True, 'reserved', 0)), {'state': 'ok', 'lagBytes': 0})
def test_inactive_slot_is_disconnected(self):
self.assertEqual(health._slot_state((False, 'reserved', 0))['state'], 'disconnected')
def test_lost_slot_wins_over_everything(self):
self.assertEqual(health._slot_state((False, 'lost', None))['state'], 'lost')
@override_settings(OFFSITE_WAL_MAX_LAG_BYTES=100)
def test_lag_above_limit(self):
self.assertEqual(health._slot_state((True, 'reserved', 101))['state'], 'lagging')
self.assertEqual(health._slot_state((True, 'reserved', 100))['state'], 'ok')
@override_settings(OFFSITE_SYNC_MAX_AGE_SECONDS=600)
class PushedStateTests(SimpleTestCase):
"""بکاپِ رمزشده‌ی ۲۱۶ (_pushed_state). پرسشِ pg_stat_archiver و خواندنِ فایل روی سرور دیده شد، نه این‌جا."""
T0 = datetime(2026, 10, 17, 16, 0, tzinfo=timezone.utc)
def test_recent_sync_is_ok(self):
r = health._pushed_state(self.T0, None, 1000, now=1060)
self.assertEqual((r['state'], r['syncAgeSeconds']), ('ok', 60))
self.assertEqual(r['lastArchivedAt'], self.T0.isoformat())
def test_old_sync_is_stale(self):
self.assertEqual(health._pushed_state(self.T0, None, 1000, now=1601)['state'], 'sync_stale')
self.assertEqual(health._pushed_state(self.T0, None, 1000, now=1600)['state'], 'ok')
def test_no_status_file_is_never_synced(self):
self.assertEqual(health._pushed_state(self.T0, None, None, now=1000)['state'], 'never_synced')
def test_failure_after_last_success_wins(self):
later = self.T0 + timedelta(minutes=1)
self.assertEqual(health._pushed_state(self.T0, later, 1000, now=1001)['state'], 'archive_failing')
self.assertEqual(health._pushed_state(None, later, 1000, now=1001)['state'], 'archive_failing')
def test_old_failure_then_success_is_ok(self):
earlier = self.T0 - timedelta(minutes=1)
self.assertEqual(health._pushed_state(self.T0, earlier, 1000, now=1001)['state'], 'ok')
def test_idle_database_with_no_archive_yet_is_ok(self):
self.assertEqual(health._pushed_state(None, None, 1000, now=1001)['state'], 'ok')
class OffsiteWarningTests(SimpleTestCase):
def test_ok_and_dev_are_silent(self):
self.assertEqual(health._offsite_warnings({'state': 'ok', 'lagBytes': 0}), [])
self.assertEqual(health._offsite_warnings({'state': 'not_postgres'}), [])
def test_every_bad_state_warns(self):
for state in ('not_configured', 'slot_missing', 'disconnected', 'lagging', 'lost', 'error',
'archive_failing', 'never_synced', 'sync_stale'):
self.assertEqual(len(health._offsite_warnings({'state': state})), 1, state)
def test_sqlite_reports_not_postgres(self):
# سوئیت روی Postgres هم اجرا می‌شود (پیش از هر تغییرِ دیتابیسی)؛ آن‌جا این حالت معنی ندارد.
if health.connection.vendor != 'sqlite':
self.skipTest('فقط روی SQLite')
self.assertEqual(health._offsite_backup(), {'state': 'not_postgres'})

View File

@@ -0,0 +1,49 @@
"""
اعراب و «ی»ِ اضافه به کاربر نمی‌رسد (تصمیمِ شرکت، ۲۰۲۶-۱۰-۱۴): قاعده (`content.plain_math.plain_persian`) و لایه‌ی
خروجیِ API (`config.renderers.PlainPersianJSONRenderer`). دیتابیس متنِ اصلی را نگه می‌دارد.
"""
from django.core.cache import cache
from django.test import SimpleTestCase, TestCase
from rest_framework.test import APIClient
from content.models import Grade
from content.plain_math import plain_persian, plain_persian_utf8
class PlainPersianRuleTests(SimpleTestCase):
def test_kasra_and_written_ezafe_ye_go(self):
self.assertEqual(plain_persian('از پایه‌ی سوم، ریاضیِ کتابِ خودت'), 'از پایه سوم، ریاضی کتاب خودت')
def test_fatha_damma_shadda_sukun_and_hamza_above_go(self):
self.assertEqual(plain_persian('اَوّلُ خانهٔ مْ'), 'اول خانه م')
def test_tanwin_stays_because_it_is_spelling(self):
self.assertEqual(plain_persian('قبلاً، موقتاً'), 'قبلاً، موقتاً')
def test_ye_that_is_part_of_a_word_stays(self):
# «ه‌ای» (نکره) و «ه‌یی…» که حرفِ دیگری به «ی» چسبیده، اضافه نیستند.
self.assertEqual(plain_persian('جزیره‌ای تازه'), 'جزیره‌ای تازه')
self.assertEqual(plain_persian('خانه‌یمان'), 'خانه‌یمان')
def test_idempotent_and_non_strings_untouched(self):
once = plain_persian('همه‌ی جزیره‌های پایه‌ی هشتمِ')
self.assertEqual(plain_persian(once), once)
self.assertIsNone(plain_persian(None))
self.assertEqual(plain_persian(5), 5)
def test_utf8_version_matches_the_text_version(self):
sample = '{"t": "همه‌ی جزیره‌ی", "u": "خانه‌یمان، قبلاً، اَوّلُ، پایه‌ی ۸", "id": 12}'
self.assertEqual(plain_persian_utf8(sample.encode()).decode(), plain_persian(sample))
class ApiOutputTests(TestCase):
def setUp(self):
cache.clear()
def test_api_json_is_clean_while_the_database_keeps_the_original(self):
Grade.objects.create(id=8, number=8, title='پایه‌ی هشتمِ', icon='x', color='#000')
body = APIClient().get('/api/grades').content.decode('utf-8')
self.assertIn('پایه هشتم', body)
self.assertNotIn('ِ', body)
self.assertNotIn('ه‌ی', body)
self.assertEqual(Grade.objects.get(id=8).title, 'پایه‌ی هشتمِ')

View File

@@ -0,0 +1,31 @@
"""
بدنه‌ی درخواستِ بزرگ ۵۰۰ و ERRORِ لاگ نمی‌دهد (config/exceptions.py). روی سرورِ زنده، JSONِ ۳ مگابایتی به
/api/auth/login ۵۰۰ داده بود؛ ۲۰۰ کیلوبایت درست ۴۰۰ می‌داد.
"""
import json
from django.core.cache import cache
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
@override_settings(DATA_UPLOAD_MAX_MEMORY_SIZE=2_621_440)
class OversizedBodyTests(TestCase):
def setUp(self):
cache.clear()
def _post(self, path, size):
body = json.dumps({'phone': '09120000000', 'otp': '1' * size})
return APIClient().post(path, data=body, content_type='application/json')
def test_a_body_over_the_limit_is_413_with_a_persian_message(self):
for path in ('/api/auth/login', '/api/auth/otp/request'):
with self.assertNoLogs('config.exceptions', 'ERROR'):
response = self._post(path, 3_000_000)
self.assertEqual(response.status_code, 413, path)
self.assertIn('حجم', response.json()['detail'])
def test_a_large_but_allowed_body_still_gets_the_normal_validation_error(self):
response = self._post('/api/auth/login', 200_000)
self.assertEqual(response.status_code, 400)
self.assertIn('otp', response.json())

View File

@@ -0,0 +1,73 @@
"""
X-Forwarded-For فقط از ArvanCloud پذیرفته می‌شود (config.middleware.TrustedProxyMiddleware).
بازتولیدِ ۲۰۲۶-۱۰-۱۳ روی سرور: درخواستِ مستقیم به IPِ سرور با XFFِ تازه در هر بار، همه‌ی سقف‌های IP-محور را
دور می‌زد. X-Real-IP همان چیزی است که nginx از آدرسِ TCP می‌گذارد.
"""
from django.core.cache import cache
from django.test import RequestFactory, TestCase, override_settings
from rest_framework.throttling import BaseThrottle
from config.middleware import TrustedProxyMiddleware
ARVAN_EDGE = '37.32.19.8'
def _ident_after_middleware(**meta):
request = RequestFactory().get('/api/health', **meta)
seen = {}
def view(req):
seen['ident'] = BaseThrottle().get_ident(req)
return None
TrustedProxyMiddleware(view)(request)
return seen['ident']
class TrustedProxyIdentTests(TestCase):
def setUp(self):
cache.clear()
def test_request_through_arvan_uses_the_client_address(self):
ident = _ident_after_middleware(
HTTP_X_REAL_IP=ARVAN_EDGE, HTTP_X_FORWARDED_FOR=f'5.5.5.5, {ARVAN_EDGE}',
)
self.assertEqual(ident, '5.5.5.5')
def test_direct_hit_ignores_a_forged_forwarded_for(self):
for forged in ('1.2.3.4', '9.9.9.9, 1.2.3.4'):
ident = _ident_after_middleware(
HTTP_X_REAL_IP='203.0.113.7', HTTP_X_FORWARDED_FOR=f'{forged}, 203.0.113.7',
)
self.assertEqual(ident, '203.0.113.7')
def test_direct_hit_is_logged_once_per_peer(self):
with self.assertLogs('config.middleware', level='WARNING') as logs:
for _ in range(3):
_ident_after_middleware(
HTTP_X_REAL_IP='203.0.113.9', HTTP_X_FORWARDED_FOR='1.1.1.1, 203.0.113.9',
)
self.assertEqual(len(logs.records), 1)
def test_without_nginx_header_nothing_changes(self):
# runserverِ محلی: nginx نیست، پس X-Real-IP هم نیست.
ident = _ident_after_middleware(REMOTE_ADDR='127.0.0.1')
self.assertEqual(ident, '127.0.0.1')
@override_settings(ADMIN_LOGIN_MAX_ATTEMPTS=2)
class AdminLoginThrottleCannotBeDodgedTests(TestCase):
def setUp(self):
cache.clear()
def test_rotating_forwarded_for_on_a_direct_hit_still_hits_the_limit(self):
codes = [
self.client.post(
'/admin/login/', {'username': 'x', 'password': 'y'},
HTTP_X_REAL_IP='203.0.113.7', HTTP_X_FORWARDED_FOR=f'10.0.{i}.1, 203.0.113.7',
).status_code
for i in range(3)
]
self.assertEqual(codes[-1], 429)

View File

@@ -10,13 +10,21 @@ from django.contrib import admin
from django.http import JsonResponse
from django.urls import include, path
from django.views import defaults as django_default_views
from drf_spectacular.utils import extend_schema
from config.health import HealthView
from rest_framework.throttling import ScopedRateThrottle
from drf_spectacular.views import (
SpectacularAPIView,
SpectacularRedocView,
SpectacularSwaggerView,
)
@extend_schema(exclude=True)
class HealthViewSlashAlias(HealthView):
"""نسخه‌ی اسلش‌دارِ `/api/health`، فقط برای پایشِ بیرونی — بیرون از اسکیما."""
logger = logging.getLogger(__name__)
@@ -39,6 +47,13 @@ def handler500(request):
return django_default_views.server_error(request)
class ThrottledSchemaView(SpectacularAPIView):
"""بی‌ورود و بی‌کش: هر بار ~۵۶ms CPU و ۱۱۰KB؛ بی‌throttle چند ده درخواست در ثانیه workerها را پر می‌کرد."""
throttle_classes = [ScopedRateThrottle]
throttle_scope = 'read'
urlpatterns = [
path('admin/', admin.site.urls),
@@ -50,34 +65,36 @@ urlpatterns = [
# هر `git pull` بعدی روی همین فایل گیر می‌کرد. یک مسیرِ اضافه ارزانش
# است در برابرِ یک فایلِ دست‌کاری‌شده روی پروداکشن.
path('api/health', HealthView.as_view(), name='health'),
path('api/health/', HealthView.as_view(), name='health-slash'),
# همان ویو، فقط برای پایشی که با اسلش صدا می‌زند. از اسکیما بیرون است تا
# دو مسیرِ یکسان operationIdهای health_retrieve و health_retrieve_2 نسازند.
#
# ⚠️ زیرکلاس لازم است، نه exclude روی خودِ HealthView. extend_schema روی
# یک کلاس همان کلاس را تغییر می‌دهد، و چون هر دو مسیر به یک کلاس اشاره
# می‌کردند، `/api/health` هم با خودش از اسکیما بیرون می‌افتاد — یعنی
# اندپوینتِ سلامت اصلاً در Swagger دیده نمی‌شد.
path('api/health/', HealthViewSlashAlias.as_view(), name='health-slash'),
# OpenAPI schema + Swagger UI + Redoc
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
path('api/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
path('api/schema/', ThrottledSchemaView.as_view(), name='schema'),
# قالبِ خودمان فقط یک پوسته‌ی راست‌به‌چپ روی قالبِ drf-spectacular است؛
# بدونش متنِ فارسی در یک بلوکِ LTR رندر می‌شود و نقطه‌ی جمله و ترتیبِ
# ستون‌های جدول جابه‌جا می‌شوند. توضیح در خودِ قالب.
path('api/docs/', SpectacularSwaggerView.as_view(
url_name='schema', template_name='tadrisino/swagger_ui.html'), name='swagger-ui'),
path('api/redoc/', SpectacularRedocView.as_view(
url_name='schema', template_name='tadrisino/redoc.html'), name='redoc'),
# اپ‌های فاز ۱
# همه زیرِ /api/ جمع می‌شوند؛ هر اپ مسیرهای خودش را نگه می‌دارد، پس
# ترتیبِ این فهرست اهمیتی ندارد و تداخلی بینشان نیست.
path('api/', include('accounts.urls')),
path('api/', include('content.urls')),
path('api/', include('progress.urls')),
path('api/', include('ai_tutor.urls')),
path('api/', include('rag.urls')),
# اپ‌های فاز ۲ / بخش ۱ (subscription, payment, discount)
path('api/', include('subscription.urls')),
path('api/', include('payment.urls')),
path('api/', include('discount.urls')),
# اپ‌های فاز ۲ / بخش ۲ (notifications)
path('api/', include('notifications.urls')),
# اپ‌های فاز ۲ / بخش ۳ (affiliate)
path('api/', include('affiliate.urls')),
# اپ‌های فاز ۲ / بخش ۴ (support)
path('api/', include('support.urls')),
# اپ‌های فاز ۲ / بخش ۵ (family)
path('api/', include('family.urls')),
]

View File

@@ -1,6 +1,7 @@
import json
from django.contrib import admin, messages
from django.core.exceptions import PermissionDenied
from django.db.models import Count, Q
from django.http import HttpResponse
from django.shortcuts import redirect, render
@@ -14,8 +15,8 @@ from .curriculum_io import parse as parse_curriculum
from .question_io import ImportError_, export_questions, import_questions, parse
from .models import (
Choice, Grade, Lesson, Node, NodeProgress, Question, QuestionReport, Unit, UserAnswer,
UserLessonCompletion,
Choice, ContentVote, Grade, Lesson, LessonReport, Node, NodeProgress, Question, QuestionReport, Subject, Unit,
UserAnswer, UserLessonCompletion,
)
@@ -32,7 +33,7 @@ class GradeAdmin(admin.ModelAdmin):
سرور بود که به Qdrant و به دسترسیِ SSH وابسته بودند.
"""
list_display = ('id', 'title', 'icon', 'color')
list_display = ('number', 'title', 'id', 'icon', 'color')
search_fields = ('title',)
actions = ['export_curriculum_action']
change_list_template = 'admin/content/grade/change_list.html'
@@ -54,6 +55,9 @@ class GradeAdmin(admin.ModelAdmin):
return response
def import_curriculum_view(self, request):
# admin_view فقط is_staff را می‌سنجد؛ این صفحه پایه/فصل/درس می‌سازد و متنِ درس را عوض می‌کند.
if not (self.has_add_permission(request) and self.has_change_permission(request)):
raise PermissionDenied
if request.method == 'POST':
uploaded = request.FILES.get('file')
if uploaded is None:
@@ -80,10 +84,21 @@ class GradeAdmin(admin.ModelAdmin):
})
@admin.register(Subject)
class SubjectAdmin(admin.ModelAdmin):
"""کتاب‌های ریاضی (ریاضی، حسابان، هندسه، …) که نوارِ جابه‌جاییِ اپ از آن‌ها ساخته می‌شود."""
list_display = ('order', 'title', 'key', 'icon', 'color')
ordering = ('order',)
@admin.register(Unit)
class UnitAdmin(admin.ModelAdmin):
list_display = ('id', 'title', 'grade', 'order')
list_filter = ('grade',)
list_display = ('id', 'title', 'grade', 'subject', 'order')
list_filter = ('grade', 'subject')
# فیلترِ «فصل» در صفحه‌ی سوال‌ها هم از همین ترتیب می‌آید: پایه‌به‌پایه، نه
# همه‌ی «فصل ۱»ها پشتِ هم.
ordering = ('grade__number', 'grade_id', 'order')
@admin.register(Node)
@@ -93,9 +108,57 @@ class NodeAdmin(admin.ModelAdmin):
search_fields = ('title', 'unit__title')
def _vote_counts(kind):
"""Subquery: (likes, dislikes) برای هر هدف؛ ContentVote ارجاعِ سبک است، پس annotate مستقیم ممکن نیست."""
from django.db.models import IntegerField, OuterRef, Subquery
from django.db.models.functions import Coalesce
def sub(value):
return Coalesce(Subquery(
ContentVote.objects.filter(kind=kind, target_id=OuterRef('pk'), value=value)
.order_by().values('target_id').annotate(n=Count('id')).values('n'),
output_field=IntegerField()), 0)
return sub(1), sub(-1)
class VoteColumnsMixin:
vote_kind = ''
def get_queryset(self, request):
likes, dislikes = _vote_counts(self.vote_kind)
return super().get_queryset(request).annotate(_likes=likes, _dislikes=dislikes)
@admin.display(description='👍', ordering='_likes')
def likes(self, obj):
return obj._likes or '—'
@admin.display(description='👎', ordering='_dislikes')
def dislikes(self, obj):
if not obj._dislikes:
return '—'
return format_html('<span style="color:#b91c1c;font-weight:600">{}</span>', obj._dislikes)
@admin.register(LessonReport)
class LessonReportAdmin(admin.ModelAdmin):
"""گزارشِ دانش‌آموزان روی متنِ درس‌ها."""
list_display = ('id', 'lesson', 'reason', 'status', 'note', 'created_at')
list_filter = ('status', 'reason', 'lesson__node__unit__grade')
search_fields = ('lesson__title', 'note')
autocomplete_fields = ['lesson']
actions = ['mark_resolved']
@admin.action(description='علامت زدن به‌عنوانِ بررسی‌شده', permissions=['change'])
def mark_resolved(self, request, queryset):
updated = queryset.update(status='resolved', resolved_at=timezone.now())
self.message_user(request, f'{updated} گزارش بررسی‌شده علامت خورد.', messages.SUCCESS)
@admin.register(Lesson)
class LessonAdmin(admin.ModelAdmin):
list_display = ('id', 'title', 'node')
class LessonAdmin(VoteColumnsMixin, admin.ModelAdmin):
vote_kind = 'lesson'
list_display = ('id', 'title', 'node', 'likes', 'dislikes')
list_filter = ('node',)
# بدونِ این، انتخابِ درس در فرمِ سوال یک دراپ‌داونِ بیش از هزارتایی است
# که عملاً غیرقابلِ استفاده است.
@@ -103,7 +166,8 @@ class LessonAdmin(admin.ModelAdmin):
@admin.register(Question)
class QuestionAdmin(admin.ModelAdmin):
class QuestionAdmin(VoteColumnsMixin, admin.ModelAdmin):
vote_kind = 'question'
"""
سوال‌ها علاوه بر ویرایشِ تکی، از همین‌جا فله‌ای وارد و خارج می‌شوند.
@@ -112,7 +176,7 @@ class QuestionAdmin(admin.ModelAdmin):
می‌پذیرد، پس چرخه‌ی ویرایش بسته است.
"""
list_display = ('id', 'prompt', 'lesson', 'type', 'open_reports', 'wrong_percent')
list_display = ('id', 'prompt', 'lesson', 'type', 'open_reports', 'likes', 'dislikes', 'wrong_percent')
list_filter = ('type', 'lesson__node__unit__grade', 'lesson__node__unit')
inlines = [ChoiceInline]
actions = ['export_selected']
@@ -175,6 +239,9 @@ class QuestionAdmin(admin.ModelAdmin):
return response
def import_view(self, request):
# admin_view فقط is_staff را می‌سنجد؛ «جایگزینی» سوال‌ها و جواب‌های دانش‌آموزان به آن‌ها را پاک می‌کند.
if not self.has_add_permission(request):
raise PermissionDenied
if request.method == 'POST':
uploaded = request.FILES.get('file')
if uploaded is None:
@@ -182,6 +249,8 @@ class QuestionAdmin(admin.ModelAdmin):
return redirect(request.path)
replace = request.POST.get('replace') == 'on'
if replace and not self.has_delete_permission(request):
raise PermissionDenied
try:
rows = parse(uploaded.read())
result = import_questions(rows, replace=replace)
@@ -235,7 +304,7 @@ class QuestionReportAdmin(admin.ModelAdmin):
autocomplete_fields = ['question']
actions = ['mark_resolved']
@admin.action(description='علامت زدن به‌عنوانِ بررسی‌شده')
@admin.action(description='علامت زدن به‌عنوانِ بررسی‌شده', permissions=['change'])
def mark_resolved(self, request, queryset):
updated = queryset.update(status='resolved', resolved_at=timezone.now())
self.message_user(request, f'{updated} گزارش بررسی‌شده علامت خورد.', messages.SUCCESS)

View File

@@ -10,9 +10,12 @@
گزارش‌های بازِ کاربران، و درصدِ پاسخِ غلط — یعنی همان سیگنالی که می‌گوید کدام
سوال ایراد دارد یا بیش از حد سخت است.
فقط برای کارمندی که اجازه‌ی دیدنِ سوال دارد، چون کلیدِ جواب‌ها را نشان می‌دهد.
وابستگیِ تازه‌ای اضافه نمی‌کند: چند اندپوینتِ کوچکِ JSON و JavaScript ساده.
"""
from django.contrib.admin.views.decorators import staff_member_required
from django.contrib.auth.decorators import permission_required
from django.db.models import Count, Q
from django.http import JsonResponse
from django.shortcuts import render
@@ -22,6 +25,7 @@ from .models import Grade, Lesson, Node, Question, Unit
@staff_member_required
@permission_required('content.view_question', raise_exception=True)
def browser_view(request):
return render(request, 'admin/content/browser.html', {
'title': 'مرورِ محتوا',
@@ -30,6 +34,7 @@ def browser_view(request):
@staff_member_required
@permission_required('content.view_question', raise_exception=True)
def units_json(request, grade_id: int):
rows = (
Unit.objects.filter(grade_id=grade_id)
@@ -41,6 +46,7 @@ def units_json(request, grade_id: int):
@staff_member_required
@permission_required('content.view_question', raise_exception=True)
def nodes_json(request, unit_id: int):
rows = (
Node.objects.filter(unit_id=unit_id)
@@ -52,6 +58,7 @@ def nodes_json(request, unit_id: int):
@staff_member_required
@permission_required('content.view_question', raise_exception=True)
def lessons_json(request, node_id: int):
rows = (
Lesson.objects.filter(node_id=node_id)
@@ -63,6 +70,7 @@ def lessons_json(request, node_id: int):
@staff_member_required
@permission_required('content.view_question', raise_exception=True)
def questions_json(request, lesson_id: int):
"""
هر سوال با دو سیگنال: گزارشِ بازِ کاربران، و درصدِ پاسخِ غلط.

View File

@@ -92,14 +92,20 @@ def parse(raw: bytes) -> list[dict]:
'lessons': [
{
'title': _text(lesson.get('title'), 'title', f'{nwhere} › نوع {li + 1}'),
'intro_text': (lesson.get('introText') or lesson.get('intro_text') or ''),
'intro_text': (lesson.get('introText') or ''),
'example': str(lesson.get('example') or ''),
'steps': [str(x) for x in (lesson.get('steps') or []) if str(x).strip()][:12],
'figures': lesson.get('figures') or [],
}
for li, lesson in enumerate(lessons)
],
})
clean_units.append({
# کتاب صریح است: فایلِ بی‌کتاب رد می‌شود تا فصلِ حسابان بی‌صدا «ریاضی» نشود.
'subject': _text(unit.get('subject'), 'subject', uwhere),
'title': _text(unit.get('title'), 'title', uwhere),
'blurb': str(unit.get('blurb') or '')[:120],
'order': int(unit.get('order') or ui + 1),
'nodes': clean_nodes,
})
@@ -113,6 +119,16 @@ def parse(raw: bytes) -> list[dict]:
return cleaned
def _subject_or_error(title: str):
"""کتابِ فایل باید از قبل در جدولِ Subject باشد (ادمین ← کتاب‌ها)؛ نامِ ناشناخته خطا است، نه ساختنِ بی‌صدا."""
from .models import Subject
subject = Subject.objects.filter(title=title).first()
if subject is None:
known = '، '.join(Subject.objects.values_list('title', flat=True))
raise CurriculumError(f'کتابِ «{title}» در جدولِ کتاب‌ها نیست. موجود: {known}')
return subject
@transaction.atomic
def import_curriculum(grades: list[dict]) -> dict:
"""
@@ -131,11 +147,16 @@ def import_curriculum(grades: list[dict]) -> dict:
counts['grades'] += 1 if created else 0
for unit_row in grade_row['units']:
subject = _subject_or_error(unit_row['subject'])
unit, created = Unit.objects.get_or_create(
grade=grade, title=unit_row['title'],
defaults={'order': unit_row['order']},
defaults={'order': unit_row['order'], 'subject': subject},
)
counts['units'] += 1 if created else 0
# متنِ ادمین برنده است: فقط خانه‌ی خالی از فایل پر می‌شود
if unit_row.get('blurb') and not unit.blurb:
unit.blurb = unit_row['blurb']
unit.save(update_fields=['blurb'])
for node_row in unit_row['nodes']:
node, created = Node.objects.get_or_create(
@@ -149,12 +170,27 @@ def import_curriculum(grades: list[dict]) -> dict:
node=node, title=lesson_row['title'],
defaults={'intro_text': lesson_row['intro_text']},
)
changed = []
if created:
counts['lessons'] += 1
elif lesson_row['intro_text'] and lesson.intro_text != lesson_row['intro_text']:
lesson.intro_text = lesson_row['intro_text']
lesson.save(update_fields=['intro_text'])
counts['updated'] += 1
changed.append('intro_text')
# مثال و قدم‌ها فقط وقتی از فایل می‌آیند که در دیتابیس خالی‌اند؛ آنچه
# کسی از پنلِ ادمین نوشته با دیپلوی بازنویسی نمی‌شود.
if lesson_row.get('example') and not lesson.example:
lesson.example = lesson_row['example']
changed.append('example')
if lesson_row.get('steps') and not lesson.steps:
lesson.steps = lesson_row['steps']
changed.append('steps')
if lesson_row.get('figures') and not lesson.figures:
lesson.figures = lesson_row['figures']
changed.append('figures')
if changed:
lesson.save(update_fields=changed)
if not created:
counts['updated'] += 1
return counts
@@ -162,21 +198,28 @@ def import_curriculum(grades: list[dict]) -> dict:
def export_curriculum(grades_queryset) -> dict:
"""خروجی دقیقاً همان قالبی‌ست که import می‌خواند."""
out = []
for grade in grades_queryset.prefetch_related('units__nodes__lessons'):
for grade in grades_queryset.prefetch_related('units__subject', 'units__nodes__lessons'):
out.append({
'title': grade.title,
'icon': grade.icon,
'color': grade.color,
'units': [
{
'subject': unit.subject.title,
'title': unit.title,
'order': unit.order,
**({'blurb': unit.blurb} if unit.blurb else {}),
'nodes': [
{
'title': node.title,
'order': node.order,
'lessons': [
{'title': lesson.title, 'introText': lesson.intro_text}
{
'title': lesson.title, 'introText': lesson.intro_text,
**({'example': lesson.example} if lesson.example else {}),
**({'steps': lesson.steps} if lesson.steps else {}),
**({'figures': lesson.figures} if lesson.figures else {}),
}
for lesson in node.lessons.all()
],
}

View File

@@ -0,0 +1,175 @@
"""
پر کردنِ متن‌های خالیِ درس و فصل از خودِ کتاب — بدونِ هیچ فراخوانیِ پولی.
python manage.py fill_lesson_texts # همه‌ی پایه‌ها
python manage.py fill_lesson_texts --dry-run # فقط گزارش
- `Unit.blurb`: یک جمله از عنوانِ جزیره‌های همان فصل («در این فصل: …»).
- `Lesson.example` و `Lesson.steps`: یک مسئله‌ی حل‌شده‌ی خودِ کتاب برای همان درس،
فقط از تکه‌هایی که راه‌حلشان بازبینی و تأیید شده (`solution_verdict='agrees'`)،
با جوابِ بازسنجی‌شده (`checked_answer`) در قدمِ آخر.
دو قاعده که عمداً اینجاست:
1. **فقط خانه‌ی خالی پر می‌شود.** متنی که کسی از پنلِ ادمین نوشته دست نمی‌خورد، و
اجرای دوباره چیزی را عوض نمی‌کند.
2. **مثال همان مسئله‌ی آزمونِ درس نیست.** سوالِ آزمون از اولین تکه‌ی تأییدشده‌ی درس
ساخته شده (generate_questions_from_chunks)؛ اینجا از دومی به بعد برداشته می‌شود و
تکه‌ای که جوابش با جوابِ درستِ سوالِ همان درس یکی است کنار می‌رود. بدونِ این،
صفحه‌ی درس جوابِ آزمون را پیشاپیش لو می‌داد. آزموده نشده: اینکه بازنویسیِ مدل در
ساختِ سوال جواب را عوض کرده باشد و این مقایسه آن را نبیند.
"""
import re
from django.core.management.base import BaseCommand
from django.db import transaction
from content.management.commands.generate_questions_from_chunks import Command as QuestionCommand
from content.models import Choice, Grade, Lesson, Unit
from rag.models import LessonChunk
from rag.services.config import build_collection_name
from rag.services.mathtext import to_plain
from rag.services.textbooks import TEXTBOOKS
BLURB_MAX = 120
STEP_MAX = 6
STEP_CHARS = 320
EXAMPLE_CHARS = 700
_DIGITS = str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789')
def _norm(text: str) -> str:
"""برای مقایسه‌ی جواب‌ها: رقمِ لاتین، بی‌فاصله، بی‌علامتِ اضافه."""
return re.sub(r'[\s٫.,،:]+', '', (text or '').translate(_DIGITS)).lower()
# کلمه‌های عنوانِ درس که چیزی درباره‌ی موضوع نمی‌گویند
_STOP = {'و', 'از', 'با', 'در', 'به', 'برای', 'یک', 'دو', 'های', 'ها', 'آن', 'این', 'حل', 'محاسبه', 'تعیین',
'پیدا', 'کردن', 'نوشتن', 'رسم', 'مسئله', 'مسائل', 'تمرین', 'مقدار', 'عدد', 'اعداد', 'شکل', 'کمک',
'داده', 'شده', 'تعداد', 'ممکن', 'شرایط', 'درست', 'نادرست', 'عبارت', 'عبارت‌های', 'استفاده', 'رابطه',
'بررسی', 'تشخیص', 'یافتن', 'نمایش', 'مقایسه', 'کاربرد', 'مفهوم', 'روش', 'انواع', 'ساده', 'طول'}
# مسئله‌ای که به شکل یا جدولی اشاره می‌کند که در متن نیست، به‌عنوانِ مثال به کار نمی‌آید
_FIGURE_REF = re.compile(r'شکل|روبه‌رو|روبرو|مقابل|صفحه(?:ٔ|ی)?\s*قبل|فعالیت|داده شده را|جدول زیر|نمودار(?: زیر|\s*ون|\s*توابع)|زیر را|توابع f و g|با توجه به نمودار')
def related(lesson_title: str, chunk) -> bool:
"""
آیا مسئله به همین درس مربوط است؟ جفت‌شدن از روی بازه‌ی صفحه گاهی مسئله‌ی نامربوطِ همان
صفحه‌ها را می‌داد (برای «نمودارِ قدرمطلق» مسئله‌ای درباره‌ی نمایشِ چندجمله‌ای). دستِ‌کم
یک کلمه‌ی معنادارِ عنوان باید در نوع یا متنِ مسئله باشد.
"""
words = [w for w in re.split(r'[\s،,()«»\-/]+', lesson_title) if len(w) >= 3 and w not in _STOP]
if not words:
return False
haystack = f'{chunk.problem_type} {chunk.problem}'
return sum(w in haystack for w in words) >= min(2, len(words))
def unit_blurb(unit: Unit) -> str:
titles = [n.title.strip() for n in unit.nodes.order_by('order') if n.title.strip()]
if not titles:
return ''
head = 'در این فصل: '
parts: list[str] = []
for t in titles:
candidate = '، '.join(parts + [t])
if len(head) + len(candidate) + 1 > BLURB_MAX:
break
parts.append(t)
if not parts:
return (head + titles[0])[:BLURB_MAX - 1] + '…'
text = '، '.join(parts[:-1]) + (f' و {parts[-1]}' if len(parts) > 1 else parts[0])
if len(parts) < len(titles):
text += ' و …'
return f'{head}{text}.'[:BLURB_MAX]
def split_steps(solution: str) -> list[str]:
"""راه‌حلِ کتاب → چند قدمِ کوتاه. اول با خط‌های جدا، بعد با جمله‌ها."""
plain = to_plain(solution or '').strip()
lines = [ln.strip(' -•\t') for ln in plain.splitlines() if ln.strip(' -•\t')]
if len(lines) <= 1:
lines = [s.strip() for s in re.split(r'(?<=[.؛!؟])\s+', plain) if s.strip()]
# شماره‌ی قدم را فرانت خودش می‌گذارد
lines = [re.sub(r'^(?:(?:گام|مرحله|قدم)\s*[0-9۰-۹]{1,2}\s*[::.\-)]?|[0-9۰-۹]{1,2}[).\-])\s*', '', ln) for ln in lines]
if len(lines) > STEP_MAX:
# قدم‌های اضافه در قدمِ آخر ادغام می‌شوند تا چیزی گم نشود
lines = lines[:STEP_MAX - 1] + [' '.join(lines[STEP_MAX - 1:])]
return [ln[:STEP_CHARS] for ln in lines if ln]
class Command(BaseCommand):
help = 'پر کردنِ Unit.blurb و Lesson.example/steps از کتاب (فقط خانه‌های خالی؛ بدونِ هزینه).'
def add_arguments(self, parser):
parser.add_argument('--dry-run', action='store_true', help='فقط گزارش بده؛ چیزی ننویس.')
def handle(self, *args, **opts):
dry = opts['dry_run']
counts = {'blurbs': 0, 'examples': 0, 'no_chunk': 0, 'kept': 0}
with transaction.atomic():
for unit in Unit.objects.prefetch_related('nodes'):
if unit.blurb.strip():
counts['kept'] += 1
continue
blurb = unit_blurb(unit)
if blurb:
unit.blurb = blurb
unit.save(update_fields=['blurb'])
counts['blurbs'] += 1
finder = QuestionCommand()
for book in TEXTBOOKS.values():
grade = Grade.objects.filter(title=f'پایه {book.grade}').first()
if grade is None:
continue
finder._book = book
collection = build_collection_name(book.subject, book.grade)
lessons = (Lesson.objects.filter(node__unit__grade=grade, node__unit__subject__title=book.subject)
.select_related('node__unit').order_by('node__unit__order', 'node__order', 'id'))
for lesson in lessons:
if lesson.example.strip() or lesson.steps:
continue
chunk = self._example_chunk(finder, lesson, collection)
if chunk is None:
counts['no_chunk'] += 1
continue
steps = split_steps(chunk.solution)
answer = (chunk.checked_answer or '').strip()
if answer and not any(_norm(answer) in _norm(s) for s in steps[-2:]):
steps = (steps[:STEP_MAX - 1] if len(steps) >= STEP_MAX else steps) + [f'✅ جواب: {to_plain(answer)}']
lesson.example = to_plain(chunk.problem)[:EXAMPLE_CHARS]
lesson.steps = steps
lesson.save(update_fields=['example', 'steps'])
counts['examples'] += 1
if dry:
transaction.set_rollback(True)
prefix = 'اگر اجرا شود: ' if dry else ''
self.stdout.write(self.style.SUCCESS(
f'{prefix}{counts["blurbs"]} توضیحِ فصل و {counts["examples"]} مثالِ درس پر شد؛ '
f'{counts["no_chunk"]} درس مسئله‌ی تأییدشده‌ی دوم نداشت؛ '
f'{counts["kept"]} فصل از قبل متن داشت و دست نخورد.'
))
def _example_chunk(self, finder, lesson, collection):
"""دومین مسئله‌ی تأییدشده‌ی درس، که جوابش جوابِ سوالِ آزمونِ همین درس نباشد."""
by_title = list(
LessonChunk.objects.filter(collection=collection, problem_type=lesson.title,
solution_verdict=LessonChunk.VERDICT_AGREES)
.exclude(problem='').exclude(solution='').order_by('source_page', 'order')[:6]
)
candidates = by_title or finder._chunks_by_page(
lesson, collection, need=6, opts={'verified_only': True}, used=set(), skip=0)
quiz_answers = {
_norm(v) for v in Choice.objects.filter(question__lesson=lesson, is_correct=True).values_list('value', flat=True)
}
# اولی مالِ سوالِ آزمون است (همان ترتیبِ قطعیِ ساختِ سوال)
for chunk in candidates[1:]:
if not (chunk.solution or '').strip() or _FIGURE_REF.search(chunk.problem):
continue
if not by_title and not related(lesson.title, chunk):
continue
if _norm(chunk.checked_answer) and _norm(chunk.checked_answer) in quiz_answers:
continue
return chunk
return None

View File

@@ -0,0 +1,293 @@
"""
شکلِ ثابتِ درس‌ها را یک بار می‌سازد و در `Lesson.figures` می‌نویسد.
همان مسیرِ ارزانِ دستیار: همان مدلِ چت، همان کاتالوگِ <diagram>، همان اعتبارسنجِ
`rag.services.diagram.validate`. فرقش این است که نتیجه ذخیره می‌شود و بعد از تأییدِ چشمی با
`seed_content --export` به curriculum.json می‌رود.
سه چیز که هزینه و خطا را پایین می‌آورد:
1. **پیش‌صافیِ رایگان:** اگر متنِ درس به هیچ بخشِ کاتالوگ نمی‌خورد (حسابِ عددیِ ساده)، مدل صدا
زده نمی‌شود و درس «بی‌نیازِ شکل» ثبت می‌شود.
2. **فقط بخش‌های مربوطِ کاتالوگ** (نه کلِ آن، ~۳٬۵۰۰ توکن) کنارِ درس می‌رود.
3. **گامِ وارسیِ مستقل:** هر شکلی که مدل ساخت، مدلِ دوم با متنِ درس مقایسه می‌کند؛ شکلی که
همان چیزِ متن را نشان نمی‌دهد (عدد/برچسبِ ناهمخوان، قدمِ اشتباه) ذخیره نمی‌شود.
سقفِ هزینه با فرمولِ رسمیِ gpt-4.1-mini محاسبه می‌شود (۰٫۴۰ و ۱٫۶۰ دلار در هر میلیون توکن). صورتحسابِ
واقعیِ GapGPT در سنجش‌های قبلی چند برابر کمتر بود، پس سقف محافظه‌کارانه است. آزموده نشده: درستیِ
هندسی/ریاضیِ شکل؛ وارسیِ مدلِ دوم جایگزینِ دیدنِ چشمی نیست.
python manage.py gen_lesson_figures --grade 12 --limit 20 --max-usd 2 --ledger fig_ledger.json
"""
import json
import re
import threading
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import openai
from django.core.management.base import BaseCommand, CommandError
from content.models import Lesson
from rag.services import clients, diagram as diagram_mod
from rag.services.generation import chat_model_for, completion_kwargs
USD_PER_M_IN = 0.40
USD_PER_M_OUT = 1.60
MAX_FIGURES = 3
PROMPT = (
'تو برای یک درسِ ریاضی (اپِ آموزشی) شکل‌های آموزشی می‌سازی. متنِ درس: «توضیح»، «مثال» و «قدم‌ها» '
'(شماره‌ی قدم‌ها از ۱، همان شماره‌ای که کاربر می‌بیند).\n'
'قاعده‌ها:\n'
f'- فقط جایی شکل بگذار که دیدنش مفهوم یا مسئله را روشن می‌کند؛ حداکثر {MAX_FIGURES} شکل. '
'شکلِ تزیینی یا تکراری نساز. اگر هیچ‌جا لازم نبود []. ترجیح با «example» و «step» است؛ '
'«intro» فقط وقتی متنِ توضیح خودش عدد یا نمادِ مشخص می‌دهد.\n'
'- هر شکل باید دقیقاً همان را نشان دهد که آن بخش می‌گوید، با همان اعداد و نام‌ها؛ چیزی که در '
'متن نیست اختراع نکن. برای بخشی که در متن نیست (مثال/قدم) شکل نساز.\n'
'- نمودارِ خط‌شکسته یا تکه‌ای را با type=segment بین نقطه‌های متوالی بکش، نه type=line. '
'برای نمودارِ تابع بازه‌ی x را کوچک بگیر (مقدارِ y از حدود ۹ بیشتر نشود) و فقط items لازم را بگذار؛ '
'marks فقط برای یک منحنی. نقطه‌هایی که در items نمی‌آیند تعریف نکن.\n'
'- شکلِ فقط‌نقطه (چند نقطه‌ی بی‌ارتباط) نساز؛ نقطه را روی نمودار یا شکلِ مربوطش بگذار. ضابطه‌ی تابع را '
'عیناً از متن بردار (ضریب و انتقال را حذف نکن). برای مثلثِ متساوی‌الاضلاع/متساوی‌الساقین و مانندش '
'طول‌ها را با نوعش سازگار بده. نوعِ pattern/groups را فقط وقتی بگذار که خودِ متن شکل یا شمارشِ مشخصی می‌دهد.\n'
'- شکلِ هر قدم همان را نشان دهد که متنِ همان قدم می‌گوید (نه قدمِ قبل یا بعد).\n'
'- مثال‌های کاتالوگ فقط قالب‌اند؛ نقطه، تابع یا آیتمی را که این درس لازم ندارد کپی نکن.\n'
'خروجی فقط یک آرایه‌ی JSON: [{"where":"intro"|"example"|"step","index":1,"diagram":{...}}] '
'(index فقط برای step و همان شماره‌ی ۱‌به‌بعدِ قدم). بدونِ توضیح و بدونِ ```.\n\n'
)
CHECK = (
'بازبینِ سخت‌گیرِ ریاضی هستی. یک درس و شکل‌هایی که برایش ساخته‌اند (هر شکل یک JSON) داده می‌شود. '
'برای هر شکل ابتدا در یک جمله بنویس «shows»: دقیقاً چه چیزی می‌کشد (ضابطه‌ی هر تابع، مختصات نقطه‌ها، '
'طول ضلع‌ها و زاویه‌ها، بازه‌ها، عددهای جدول، موقعیت و برچسب‌ها). بعد با متنِ همان بخشِ درس مقایسه کن. '
'ok=false بده اگر: ضابطه یا عددی با متن نمی‌خواند (مثلاً ضریب یا انتقال گم شده)؛ شکل از نظر ریاضی/هندسی '
'ناسازگار است (مثلاً مثلثِ متساوی‌الاضلاع با ضلع‌های نابرابر)؛ موقعیتِ نقطه‌ها با برچسبشان نمی‌خواند؛ '
'شکل مفهومِ اصلیِ آن بخش را نشان نمی‌دهد یا فقط نقطه‌های پراکنده/برچسبِ بی‌معنی است؛ چیزی اختراع شده؛ '
'شکلِ قدم به همان قدم مربوط نیست. هر شکِ جدی → ok=false. سلیقه‌ای ایراد نگیر. '
'خروجی فقط JSON: {"verdicts":[{"i":0,"shows":"...","ok":true|false,"why":"یک جمله"}]} به ترتیبِ شکل‌ها.'
)
def _lesson_text(lesson: Lesson) -> str:
steps = '\n'.join(f'قدم {i}: {s}' for i, s in enumerate(lesson.steps or [], 1))
return f'عنوان: {lesson.title}\nتوضیح: {lesson.intro_text}\nمثال: {lesson.example}\n{steps}'
def _spec_for(text: str) -> str:
"""فقط بخش‌هایی از کاتالوگ که کلمه‌هایشان در متن هست؛ بی‌بخش → '' (مدل صدا زده نمی‌شود)."""
parts = [section for pattern, section in diagram_mod.SECTIONS if pattern.search(text)]
return '\n'.join([diagram_mod._HEAD, *parts]) if parts else ''
_SUBSTANTIVE = {'function', 'segment', 'line', 'ray', 'polygon', 'triangle', 'quad', 'regularPolygon', 'circle',
'angle', 'exteriorAngle', 'arc', 'vector'}
def _is_thin(diagram: dict) -> bool:
"""
شکلِ صفحه‌ای که هیچ منحنی، پاره‌خط یا شکلی ندارد و فقط نقطه‌ی پراکنده است چیزی نشان نمی‌دهد
(مثلاً چند نقطه‌ی A و B و C بی‌ارتباط). نوعِ تازه‌ای که این فهرست نمی‌شناسد «محتوادار» حساب می‌شود.
"""
if diagram.get('kind') != 'plane':
return False
items = diagram.get('items') or []
if any(i.get('type') in _SUBSTANTIVE or i.get('type') not in ('point', 'label', None) for i in items):
return False
return True
def _json_list(raw: str):
raw = re.sub(r'^```(?:json)?|```$', '', (raw or '').strip()).strip()
start = raw.index('[')
items, _ = json.JSONDecoder().raw_decode(raw[start:])
if not isinstance(items, list):
raise ValueError('not a list')
return items
class Command(BaseCommand):
help = 'ساختِ شکلِ ثابتِ درس‌ها (یک بار، ذخیره در Lesson.figures).'
def add_arguments(self, p):
p.add_argument('--grade', type=int, help='بدونِ آن همه‌ی پایه‌ها')
p.add_argument('--subject', help='عنوانِ کتاب (مثلاً هندسه)؛ بدونِ آن همه‌ی کتاب‌ها')
p.add_argument('--limit', type=int, default=4)
p.add_argument('--lesson', type=int, help='فقط همین شناسه')
p.add_argument('--redo', action='store_true', help='درس‌هایی که شکل دارند هم')
p.add_argument('--dry-run', action='store_true')
p.add_argument('--workers', type=int, default=4)
p.add_argument('--max-usd', type=float, default=2.0, help='سقفِ هزینه (فرمولِ رسمی)؛ رسید، می‌ایستد')
p.add_argument('--ledger', help='JSON از درس‌های بررسی‌شده ({id: نتیجه}) تا دوباره پول نگیرند')
p.add_argument('--quiet', action='store_true')
p.add_argument('--recheck', action='store_true',
help='شکل‌های ذخیره‌شده را دوباره با وارسیِ سخت‌گیر بسنج و ناموفق‌ها را پاک کن (بی‌ساخت)')
p.add_argument('--no-check', action='store_true', help='گامِ وارسیِ مستقل را رد کن (فقط برای آزمایش)')
def handle(self, *a, **o):
if o['recheck']:
return self._recheck(o)
qs = Lesson.objects.exclude(example='').order_by(
'node__unit__grade__number', 'node__unit__order', 'node__order', 'id')
if o['grade']:
qs = qs.filter(node__unit__grade__number=o['grade'])
if o['subject']:
qs = qs.filter(node__unit__subject__title=o['subject'])
if o['lesson']:
qs = qs.filter(pk=o['lesson'])
if not o['redo']:
qs = qs.filter(figures=[])
ledger_path = Path(o['ledger']) if o['ledger'] else None
ledger = json.loads(ledger_path.read_text()) if ledger_path and ledger_path.exists() else {}
if not o['redo']:
qs = qs.exclude(pk__in=[int(k) for k in ledger])
lessons = list(qs[:o['limit']])
if not lessons:
raise CommandError('درسی پیدا نشد.')
model = chat_model_for('')
client = clients.get_openai_client()
lock = threading.Lock()
usage = {'in': 0, 'out': 0}
stats = {'no_trigger': 0, 'none': 0, 'saved': 0, 'figures': 0, 'rejected': 0, 'invalid': 0, 'stopped': 0}
def spent():
return usage['in'] / 1e6 * USD_PER_M_IN + usage['out'] / 1e6 * USD_PER_M_OUT
def ask(system, user, **extra):
r = client.chat.completions.create(
model=model, **completion_kwargs(model), **extra,
messages=[{'role': 'system', 'content': system}, {'role': 'user', 'content': user}])
u = getattr(r, 'usage', None)
with lock:
usage['in'] += getattr(u, 'prompt_tokens', 0) or 0
usage['out'] += getattr(u, 'completion_tokens', 0) or 0
return r.choices[0].message.content or ''
def record(lesson, outcome):
with lock:
if ledger_path and not o['dry_run']:
ledger[str(lesson.id)] = outcome
ledger_path.write_text(json.dumps(ledger, ensure_ascii=False))
def work(lesson):
text = _lesson_text(lesson)
spec = _spec_for(text)
if not spec:
with lock:
stats['no_trigger'] += 1
record(lesson, 'no_trigger')
return
if spent() >= o['max_usd']:
with lock:
stats['stopped'] += 1
return
try:
items = _json_list(ask(PROMPT + spec, text))
# خطای شبکه/درگاه یک درس را رد می‌کند، نه کلِ اجرا را؛ در ledger ثبت نمی‌شود تا دفعه‌ی بعد دوباره برود.
except (ValueError, KeyError, openai.OpenAIError) as e:
with lock:
stats['invalid'] += 1
self.stderr.write(f'درس {lesson.id}: خروجیِ نامعتبر ({e}); ذخیره نشد.')
return
figures, dropped = [], 0
for it in items[:MAX_FIGURES + 2]:
where = it.get('where') if isinstance(it, dict) else None
s = diagram_mod.validate(it.get('diagram')) if where else None
idx = it.get('index')
if where == 'step' and isinstance(idx, int):
idx -= 1 # مدل ۱‌به‌بعد می‌نویسد؛ ذخیره از صفر
if (s is None or where not in ('intro', 'example', 'step')
or (where == 'step' and not (isinstance(idx, int) and 0 <= idx < len(lesson.steps or [])))
or (where == 'example' and not lesson.example)):
dropped += 1
continue
if _is_thin(s):
dropped += 1
continue
for item in s.get('items', []):
if item.get('type') == 'segment':
item.pop('ticks', None) # علامتِ «ضلع مساوی» روی نمودارِ تابع فقط شلوغی است
figures.append({'where': where, **({'index': idx} if where == 'step' else {}), 'diagram': s})
figures = figures[:MAX_FIGURES]
if not figures:
with lock:
stats['none'] += 1
record(lesson, 'none' if not items else 'invalid_all')
return
kept, notes = figures, []
if not o['no_check']:
try:
verdicts = json.loads(ask(CHECK, text + '\n\nشکل‌ها:\n' + '\n'.join(
f'{i}: ' + json.dumps(f, ensure_ascii=False) for i, f in enumerate(figures)),
response_format={'type': 'json_object'})).get('verdicts', [])
ok = {v.get('i'): v for v in verdicts if isinstance(v, dict)}
kept = [f for i, f in enumerate(figures) if ok.get(i, {}).get('ok') is True]
notes = [f"{i}: {v.get('why', '')}" for i, v in ok.items() if v.get('ok') is not True]
except (ValueError, KeyError, AttributeError, openai.OpenAIError) as e:
kept, notes = [], [f'وارسی ناموفق: {e}']
with lock:
stats['rejected'] += len(figures) - len(kept)
stats['figures'] += len(kept)
if kept:
stats['saved'] += 1
self.stdout.write(f'درس {lesson.id} «{lesson.title}»: {len(kept)} شکل از {len(figures)} '
f'({dropped} ردشده در اعتبارسنج)' + (f' | رد: {"; ".join(notes)}' if notes else ''))
if not o['quiet']:
for f in kept:
self.stdout.write(' ' + json.dumps(f, ensure_ascii=False))
if kept and not o['dry_run']:
lesson.figures = kept
lesson.save(update_fields=['figures'])
record(lesson, f'figures:{len(kept)}' if kept else 'rejected')
with ThreadPoolExecutor(max_workers=max(1, o['workers'])) as ex:
list(ex.map(work, lessons))
self.stdout.write(f'{stats}\nتوکن: ورودی {usage["in"]}، خروجی {usage["out"]}؛ هزینه‌ی فرمولی ≈ ${spent():.3f}')
def _recheck(self, o):
qs = Lesson.objects.exclude(figures=[]).order_by('node__unit__grade__number', 'id')
if o['grade']:
qs = qs.filter(node__unit__grade__number=o['grade'])
lessons = list(qs[:o['limit']])
model = chat_model_for('')
client = clients.get_openai_client()
lock = threading.Lock()
usage = {'in': 0, 'out': 0}
stats = {'lessons': 0, 'kept': 0, 'dropped': 0, 'errors': 0}
def work(lesson):
figures = [f for f in lesson.figures if not _is_thin(f['diagram'])]
thin = len(lesson.figures) - len(figures)
text = _lesson_text(lesson)
try:
r = client.chat.completions.create(
model=model, **completion_kwargs(model), response_format={'type': 'json_object'},
messages=[{'role': 'system', 'content': CHECK}, {'role': 'user', 'content': text + '\n\nشکل‌ها:\n' + '\n'.join(
f'{i}: ' + json.dumps(f, ensure_ascii=False) for i, f in enumerate(figures))}])
u = getattr(r, 'usage', None)
ok = {v.get('i'): v for v in json.loads(r.choices[0].message.content or '{}').get('verdicts', [])
if isinstance(v, dict)} if figures else {}
except (ValueError, KeyError, AttributeError) as e:
with lock:
stats['errors'] += 1
self.stderr.write(f'درس {lesson.id}: وارسی ناموفق ({e}); دست نخورد.')
return
kept = [f for i, f in enumerate(figures) if ok.get(i, {}).get('ok') is True]
with lock:
usage['in'] += getattr(u, 'prompt_tokens', 0) or 0
usage['out'] += getattr(u, 'completion_tokens', 0) or 0
stats['lessons'] += 1
stats['kept'] += len(kept)
stats['dropped'] += len(lesson.figures) - len(kept)
why = '; '.join(f"{i}: {v.get('why', '')}" for i, v in ok.items() if v.get('ok') is not True)
if len(kept) != len(lesson.figures) and not o['quiet']:
self.stdout.write(f'درس {lesson.id} «{lesson.title}»: {len(kept)}/{len(lesson.figures)}'
f' (بی‌محتوا {thin}) | {why}')
if len(kept) != len(lesson.figures) and not o['dry_run']:
lesson.figures = kept
lesson.save(update_fields=['figures'])
with ThreadPoolExecutor(max_workers=max(1, o['workers'])) as ex:
list(ex.map(work, lessons))
cost = usage['in'] / 1e6 * USD_PER_M_IN + usage['out'] / 1e6 * USD_PER_M_OUT
self.stdout.write(f'{stats}\nتوکن: ورودی {usage["in"]}، خروجی {usage["out"]}؛ هزینه‌ی فرمولی ≈ ${cost:.3f}')

View File

@@ -13,13 +13,20 @@
python manage.py generate_questions_from_chunks --grade نهم --limit 5 --dry-run
python manage.py generate_questions_from_chunks --grade نهم --limit 20
python manage.py generate_questions_from_chunks --grade نهم
مسیرِ رایگان (و پیش‌فرضِ عملی): `--export DIR` هیچ فراخوانیِ پولی نمی‌کند و
فقط جفت‌های (درس، مسئله‌ی تأییدشده) را در چند فایلِ JSON می‌ریزد تا subagentها
سوال‌ها را بنویسند و خروجی با `content.question_io` وارد شود. اندازه‌گیریِ
۱۴۰۵/۰۶/۲۹: o4-mini روی این پروکسی ۰٫۷۷ سنت برای هر سوال، subagentِ Sonnet
صفر — با بازده‌ی ۹۲٪. متنِ پرامپت برای هر دو مسیر یکی است (همین PROMPT پایین)،
پس کیفیت دو شاخه از هم جدا نمی‌افتد.
"""
import json
import os
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
@@ -92,6 +99,11 @@ class Command(BaseCommand):
help='چند سوال برای هر درس.')
parser.add_argument('--dry-run', action='store_true',
help='فقط نشان بده چند سوال ساخته می‌شود؛ هیچ فراخوانیِ پولی نکن.')
parser.add_argument('--export', metavar='DIR',
help='به‌جای فراخوانیِ مدل، جفت‌های (درس، مسئله) را در این '
'پوشه به JSON بنویس تا subagent سوال‌ها را بسازد. رایگان.')
parser.add_argument('--batch-size', type=int, default=30,
help='چند جفت در هر فایلِ --export. پیش‌فرض ۳۰.')
parser.add_argument('--model', default='o4-mini',
help='مدلِ سازنده. پیش‌فرض o4-mini؛ gpt-4o-mini کیفیتِ '
'قابلِ قبولی نداد (۴ سوال از ۶ خراب).')
@@ -135,6 +147,10 @@ class Command(BaseCommand):
if len(targets) > 10:
self.stdout.write(f' … و {len(targets) - 10} مورد دیگر')
if opts['export']:
self._export(targets, opts)
return
# ~۹۰۰ توکن ورودی و ~۱۵۰ خروجی برای هر سوال با gpt-4o-mini.
self.model = opts['model']
cost = len(targets) * 0.0018 # اندازه‌گیریِ واقعیِ o4-mini
@@ -196,6 +212,58 @@ class Command(BaseCommand):
f'\n{created} سوال ساخته شد، {skipped} رد شد، {failed} خطا.'
))
# ------------------------------------------------------------------
def _export(self, targets, opts):
"""
جفت‌ها را در فایل‌های کوچکِ JSON بنویس؛ هیچ مدلی صدا زده نمی‌شود.
چرا تکه‌تکه و نه یک فایلِ بزرگ: هر فایل سهمِ یک subagent است و باید در
یک نشست جا شود. و چرا `prompt` را هم کنارش می‌نویسیم: تا کسی که
سوال‌ها را می‌سازد قواعد را از حافظه بازنویسی نکند — همان متنی که به
مدلِ پولی می‌رفت عیناً در دسترسِ subagent است.
"""
directory = opts['export']
os.makedirs(directory, exist_ok=True)
book = self._book
size = max(1, opts['batch_size'])
slug = f'{book.subject}-{book.grade}'
written = []
for index in range(0, len(targets), size):
rows = [
{
'lessonId': lesson.pk,
'unit': lesson.node.unit.title,
'node': lesson.node.title,
'lessonTitle': lesson.title,
'problemType': chunk.problem_type,
'problem': chunk.problem,
'solution': chunk.solution,
}
for lesson, chunk in targets[index:index + size]
]
path = os.path.join(directory, f'{slug}-{index // size + 1:02d}.json')
with open(path, 'w', encoding='utf-8') as handle:
json.dump(rows, handle, ensure_ascii=False, indent=1)
written.append((path, len(rows)))
rules = os.path.join(directory, f'{slug}-RULES.txt')
with open(rules, 'w', encoding='utf-8') as handle:
handle.write(PROMPT.format(
grade=book.grade, problem_type='<problemType>',
problem='<problem>', solution='<solution>',
))
self.stdout.write(self.style.SUCCESS(
f'\n{len(targets)} جفت در {len(written)} فایل نوشته شد: {directory}'
))
self.stdout.write(f' قواعدِ ساختِ سوال: {rules}')
self.stdout.write(
' خروجیِ هر subagent باید قالبِ content.question_io باشد '
'({"lessonId", "prompt", "choices", "correct"}) و با '
'«ورودِ سوال» در پنلِ ادمین یا import_questions وارد شود.'
)
# ------------------------------------------------------------------
def _page_span(self, lesson):
"""بازه‌ی صفحه‌ی PDF برای نودِ این درس، از فهرستِ خودِ کتاب."""
@@ -214,7 +282,7 @@ class Command(BaseCommand):
return book.pdf_pages(chapter)
return None
def _chunks_by_page(self, lesson, collection, need, opts, used):
def _chunks_by_page(self, lesson, collection, need, opts, used, skip=0):
span = self._page_span(lesson)
if span is None:
return []
@@ -224,13 +292,13 @@ class Command(BaseCommand):
.exclude(problem='').exclude(pk__in=used))
if opts['verified_only']:
qs = qs.filter(solution_verdict=LessonChunk.VERDICT_AGREES)
return list(qs.order_by('source_page', 'order')[:need])
return list(qs.order_by('source_page', 'order')[skip:skip + need])
def _targets(self, grade, collection, opts):
"""[(lesson, chunk), ...] — فقط درس‌هایی که هنوز سوال کم دارند."""
lessons = (
Lesson.objects
.filter(node__unit__grade=grade)
.filter(node__unit__grade=grade, node__unit__subject__title=self._book.subject)
.select_related('node__unit')
.order_by('node__unit__order', 'node__order', 'id')
)
@@ -252,7 +320,12 @@ class Command(BaseCommand):
if opts['verified_only']:
candidates = candidates.filter(
solution_verdict=LessonChunk.VERDICT_AGREES)
chunks = list(candidates.order_by('source_page', 'order')[:need])
# از `have` تا به بعد، نه از اول: ترتیب قطعی است، پس اجرای قبلی
# دقیقاً `have` تکه‌ی نخست را مصرف کرده. بدونِ این پرش، هر اجرای
# دوم با --per-lesson بالاتر همان مسئله را دوباره تحویل می‌داد و
# سوالِ تقریباً تکراری می‌ساخت — که نه خطا می‌دهد و نه در import
# گیر می‌افتد، چون صورتِ سوال بازنویسی شده و رشته‌اش یکی نیست.
chunks = list(candidates.order_by('source_page', 'order')[have:have + need])
# تطبیقِ عنوان شکننده است: عنوانِ درس از یک استخراجِ قدیمی آمده و
# هر بار که کتاب دوباره خوانده شود مدل عنوانِ تازه می‌سازد. بعد از
@@ -264,7 +337,8 @@ class Command(BaseCommand):
# chunkهای همان بازه به درس‌های همان نود می‌خورند — بدونِ هیچ
# فراخوانیِ مدلی.
if not chunks and opts['by_page']:
chunks = self._chunks_by_page(lesson, collection, need, opts, used)
chunks = self._chunks_by_page(lesson, collection, need, opts, used,
skip=have)
for chunk in chunks:
used.add(chunk.pk)

View File

@@ -24,7 +24,7 @@ from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from content import curriculum_io, question_io
from content import curriculum_io, question_io, seed_sync
from content.models import Grade, Lesson, Question
# ردیف‌های نمونه هرگز نباید در فایلِ ریپو بنشینند: این فایل مستقیم روی
@@ -119,10 +119,10 @@ class Command(BaseCommand):
# dry-run با rollback انجام می‌شود، نه با شمارشِ تقریبی: تنها چیزی که
# واقعاً می‌گوید چه اتفاقی می‌افتد، خودِ اتفاق است.
# seed_sync کارِ ادمین (حذف، تغییرِ نام، اصلاحِ متن) را برنمی‌گرداند؛ شرح در خودِ ماژول.
try:
with transaction.atomic():
counts = curriculum_io.import_curriculum(grades)
question_counts = self._load_questions()
counts = seed_sync.sync(grades, self._question_rows())
if dry_run:
transaction.set_rollback(True)
except (curriculum_io.CurriculumError, question_io.ImportError_) as exc:
@@ -134,23 +134,20 @@ class Command(BaseCommand):
f'{counts["grades"]} پایه، {counts["units"]} فصل، '
f'{counts["nodes"]} مبحث، {counts["lessons"]} درسِ تازه '
f'({counts["updated"]} درس به‌روز شد)؛ '
f'{question_counts["created"]} سوالِ تازه '
f'({question_counts["skipped"]} سوال از قبل بود)'
f'{counts["created"]} سوالِ تازه '
f'({counts["skipped"]} سوال از قبل بود)'
))
if counts['retired'] or counts['kept_admin_text']:
self.stdout.write(
f' {counts["retired"]} موردِ فایل ساخته نشد چون ادمین پاکش کرده یا نامش را عوض کرده؛ '
f'{counts["kept_admin_text"]} متنِ درس که ادمین ویرایش کرده دست نخورد.'
)
def _load_questions(self) -> dict:
def _question_rows(self) -> list:
if not QUESTIONS_FILE.exists():
return {'created': 0, 'skipped': 0}
return []
raw = json.loads(QUESTIONS_FILE.read_text(encoding='utf-8'))
rows = raw.get('questions', raw) if isinstance(raw, dict) else raw
if not rows:
return {'created': 0, 'skipped': 0}
# مسیرِ عنوان → lessonId، سپس همان اعتبارسنجیِ همیشگیِ question_io.
resolved = question_io.resolve_paths(rows)
parsed = question_io.parse(json.dumps(resolved).encode('utf-8'))
return question_io.import_questions(parsed, skip_existing=True)
return (raw.get('questions', raw) if isinstance(raw, dict) else raw) or []
def _has_questions(path: Path) -> bool:

View File

@@ -21,17 +21,24 @@
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from content.models import Grade, Lesson, Node, Unit
from content.models import Grade, Lesson, Node, Subject, Unit
from rag.models import LessonChunk
from rag.services.config import build_collection_name
from rag.services.textbooks import TEXTBOOKS, get_textbook
GRADE_STYLE = {
# چهارم/پنجم (۲۰۲۶-۱۰-۰۳): رنگ‌های گرمِ روشن، کنارِ سبزِ ششم، تا سه پایه‌ی دبستان یک خانواده باشند.
'چهارم': ('grade-4', '#FFB300'),
'پنجم': ('grade-5', '#FF7043'),
'ششم': ('grade-6', '#4CAF50'),
'هفتم': ('grade-7', '#2196F3'),
'هشتم': ('grade-8', '#9C27B0'),
'نهم': ('grade-9', '#FF9800'),
'دهم': ('grade-10', '#E91E63'),
# حسابان: رشته‌ی ریاضی‌فیزیک، پس عمداً رنگِ سرد و جدا از شش پایه‌ی عمومی
# تا در نقشه‌ی بازی به‌چشم یک مسیرِ دیگر بیاید، نه ادامه‌ی همان‌ها.
'یازدهم': ('grade-11', '#00BCD4'),
'دوازدهم': ('grade-12', '#3F51B5'),
}
@@ -172,11 +179,17 @@ class Command(BaseCommand):
title=grade_title, defaults={'icon': icon, 'color': color},
)
# کتاب باید در جدولِ Subject باشد (migrationِ 0015 پنج‌تایی را می‌سازد؛ کتابِ تازه از ادمین).
subject = Subject.objects.filter(title=book.subject).first()
if subject is None:
raise CommandError(
f'کتابِ «{book.subject}» در جدولِ Subject نیست؛ از ادمین (محتوا ← کتاب‌ها) بسازید.'
)
counts = {'units': 0, 'nodes': 0, 'lessons': 0}
for chapter, groups in plan:
unit, _ = Unit.objects.update_or_create(
grade=grade, title=chapter.title,
defaults={'order': chapter.number},
defaults={'order': chapter.number, 'subject': subject},
)
counts['units'] += 1

View File

@@ -0,0 +1,36 @@
"""
جزیره‌های بلند را به جزیره‌های حداکثر ۶ درسی می‌شکند (منطق در `content/node_split.py`).
python manage.py split_long_nodes --grade 8 --dry-run
python manage.py split_long_nodes --grade 4 --grade 5
پایه‌های ۳ تا ۱۲ را migrationِ 0018 یک بار شکسته است؛ این دستور برای پایه‌ی تازه یا آستانه‌ی دیگر است و اجرای
دوباره‌اش بی‌اثر. بعد از اجرا فایل‌های seed را دوباره بسازید (`seed_content --export`)، وگرنه
`seed_content` جزیره‌های بلندِ قدیمی را کنارِ بخش‌های تازه دوباره می‌سازد.
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from content.models import Lesson, Node, NodeProgress, Unit, UserNodeCompletion
from content.node_split import split_long_nodes
class Command(BaseCommand):
help = 'شکستنِ جزیره‌های بلند به جزیره‌های کوتاه‌تر، با حفظِ پیشرفتِ کاربران.'
def add_arguments(self, parser):
parser.add_argument('--grade', type=int, action='append', required=True,
help='شماره‌ی پایه؛ چند بار قابلِ تکرار.')
parser.add_argument('--max-lessons', type=int, default=7)
parser.add_argument('--part-cap', type=int, default=6)
parser.add_argument('--dry-run', action='store_true')
def handle(self, *args, **opts):
units = Unit.objects.filter(grade__number__in=opts['grade']).order_by('id')
with transaction.atomic():
counts = split_long_nodes(
Node=Node, Lesson=Lesson, UserNodeCompletion=UserNodeCompletion,
NodeProgress=NodeProgress, units=units,
max_lessons=opts['max_lessons'], part_cap=opts['part_cap'], dry_run=opts['dry_run'],
)
self.stdout.write(f"{'(dry-run) ' if opts['dry_run'] else ''}{counts}")

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.2.17 on 2026-09-23 19:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('content', '0006_lesson_duration_minutes_lesson_example_lesson_kind_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='grade',
options={'ordering': [models.OrderBy(models.F('number'), nulls_last=True), 'id']},
),
migrations.AddField(
model_name='grade',
name='number',
field=models.PositiveSmallIntegerField(blank=True, help_text='شماره\u200cی پایه، ۱ تا ۱۲. خالی بماند از عنوان حدس زده می\u200cشود.', null=True, unique=True),
),
]

View File

@@ -0,0 +1,105 @@
"""
id هر پایه را برابرِ شماره‌اش می‌کند (پایه هشتم → 8) و همه‌ی ارجاع‌ها را همراهش.
تا پیش از این id ترتیبِ واردشدنِ کتاب‌ها بود (نهم=۱، ششم=۲، …، هشتم=۵)، پس
`gradeId: 5` یعنی هشتم و همه‌جا — ادمین، فیلترِ سوال‌ها، API — پایه‌ها نامرتب
بودند. شماره‌ی پایه از عنوان خوانده می‌شود، نه از id، پس روی هر دیتابیسی (با
هر ترتیبِ idِ قبلی) درست کار می‌کند.
ارجاع‌ها: FKِ `Unit.grade`، سه ارجاعِ سبکِ `grade_id` در accounts، ai_tutor و
rag، و `grade_id` داخلِ JSONِ `target_filter` کمپین‌های پیامکی. پایه‌ای که عنوانش شماره ندارد ([DEMO]/[SEED]) اگر idش در بازه‌ی ۱ تا ۱۲
باشد به بالای ۱۰۰ می‌رود تا جا برای پایه‌های واقعی باز شود.
برگشت‌ناپذیر به‌معنای «idهای قدیمی برنمی‌گردند»؛ برگشتِ migration فقط فیلد را
برمی‌دارد و idهای تازه می‌مانند، که برای همه‌ی کدها بی‌تفاوت است.
"""
from django.db import migrations
from content.models import grade_number_from_title
LOOSE_REFS = [('accounts', 'User'), ('ai_tutor', 'Conversation'), ('rag', 'LessonChunk')]
TEMP_OFFSET = 1_000_000
FIRST_UNNUMBERED_ID = 101
def forwards(apps, schema_editor):
Grade = apps.get_model('content', 'Grade')
Unit = apps.get_model('content', 'Unit')
refs = [apps.get_model(app, model) for app, model in LOOSE_REFS]
grades = list(Grade.objects.order_by('id'))
taken = set()
for grade in grades:
number = grade_number_from_title(grade.title)
if number and number not in taken:
taken.add(number)
Grade.objects.filter(pk=grade.pk).update(number=number)
grade.number = number
next_free = max([g.id for g in grades] + [FIRST_UNNUMBERED_ID - 1]) + 1
moves = {}
for grade in grades:
if grade.number:
target = grade.number
elif grade.id <= 12:
target, next_free = next_free, next_free + 1
else:
continue
if target != grade.id:
moves[grade.id] = target
def move(old, new):
Grade.objects.filter(id=old).update(id=new)
Unit.objects.filter(grade_id=old).update(grade_id=new)
for model in refs:
model.objects.filter(grade_id=old).update(grade_id=new)
# دو مرحله، چون مقصدِ یکی ممکن است idِ فعلیِ دیگری باشد (هشتم ۵→۸ درحالی‌که
# دوازدهم هنوز ۸ است).
for old in moves:
move(old, old + TEMP_OFFSET)
for old, new in moves.items():
move(old + TEMP_OFFSET, new)
# کمپینِ «همه‌ی کاربرانِ پایه‌ی X» idِ پایه را در JSON نگه می‌دارد
Campaign = apps.get_model('campaigns', 'MessageCampaign')
for campaign in Campaign.objects.all():
target = campaign.target_filter or {}
if target.get('type') == 'grade' and target.get('grade_id') in moves:
target['grade_id'] = moves[target['grade_id']]
Campaign.objects.filter(pk=campaign.pk).update(target_filter=target)
# پایه‌ی بی‌شماره‌ای که بعداً ساخته شود idِ خودکار می‌گیرد؛ شمارنده باید از
# بازه‌ی ۱ تا ۱۲ بالاتر باشد وگرنه با «پایه نهم» (id=9) برخورد می‌کند.
table = Grade._meta.db_table
floor = max([next_free - 1] + list(moves.values()) + [FIRST_UNNUMBERED_ID - 1])
with schema_editor.connection.cursor() as cursor:
vendor = schema_editor.connection.vendor
if vendor == 'sqlite':
cursor.execute('UPDATE sqlite_sequence SET seq = MAX(seq, %s) WHERE name = %s', [floor, table])
cursor.execute(
'INSERT INTO sqlite_sequence (name, seq) SELECT %s, %s '
'WHERE NOT EXISTS (SELECT 1 FROM sqlite_sequence WHERE name = %s)',
[table, floor, table],
)
elif vendor == 'postgresql':
cursor.execute(
f"SELECT setval(pg_get_serial_sequence('{table}', 'id'), "
f"GREATEST(%s, (SELECT COALESCE(MAX(id), 1) FROM {table})))",
[floor],
)
class Migration(migrations.Migration):
dependencies = [
('content', '0007_grade_number'),
('accounts', '0003_user_last_activity_at'),
('ai_tutor', '0005_message_chunk_count_message_completion_tokens_and_more'),
('rag', '0006_remove_lessonchunk_solution_agrees_and_more'),
('campaigns', '0003_campaigndelivery_one_active_per_user_channel'),
]
operations = [
migrations.RunPython(forwards, migrations.RunPython.noop),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.2.17 on 2026-09-23 23:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('content', '0008_grade_id_equals_number'),
]
operations = [
migrations.CreateModel(
name='UserNodeCompletion',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.IntegerField(db_index=True)),
('correct_count', models.PositiveIntegerField()),
('total_questions', models.PositiveIntegerField()),
('stars', models.PositiveSmallIntegerField()),
('xp_earned', models.PositiveIntegerField(default=0)),
('coins_earned', models.PositiveIntegerField(default=0)),
('idempotency_key', models.CharField(blank=True, max_length=100, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('node', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='completions', to='content.node')),
],
options={
'indexes': [models.Index(fields=['user_id', 'node'], name='content_use_user_id_f372a4_idx')],
'constraints': [models.UniqueConstraint(condition=models.Q(('idempotency_key__isnull', False), models.Q(('idempotency_key', ''), _negated=True)), fields=('user_id', 'node', 'idempotency_key'), name='uniq_node_completion_idempotency_key')],
},
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.17 on 2026-10-03 08:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('content', '0009_usernodecompletion'),
]
operations = [
migrations.CreateModel(
name='SeedRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(max_length=40, unique=True)),
('kind', models.CharField(max_length=10)),
('value', models.CharField(blank=True, max_length=40)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.17 on 2026-10-07 12:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('content', '0010_seedrecord'),
]
operations = [
migrations.AddField(
model_name='lesson',
name='figures',
field=models.JSONField(blank=True, default=list),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.17 on 2026-10-08 08:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('content', '0011_lesson_figures'),
]
operations = [
migrations.AlterField(
model_name='choice',
name='value',
field=models.CharField(max_length=300),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.17 on 2026-10-11 16:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('content', '0012_choice_value_300'),
]
operations = [
migrations.AlterField(
model_name='userlessoncompletion',
name='created_at',
field=models.DateTimeField(auto_now_add=True, db_index=True),
),
migrations.AlterField(
model_name='usernodecompletion',
name='created_at',
field=models.DateTimeField(auto_now_add=True, db_index=True),
),
]

View File

@@ -0,0 +1,23 @@
from django.db import migrations, models
class Migration(migrations.Migration):
"""مرحله‌ی ۱ از ۴ (جدولِ کتاب‌ها). چهار مرحله‌ی جدا چون PostgreSQL اجازه نمی‌دهد در یک تراکنش
هم داده نوشت و هم همان جدول را ALTER کرد (pending trigger events)."""
dependencies = [('content', '0013_alter_userlessoncompletion_created_at_and_more')]
operations = [
migrations.CreateModel(
name='Subject',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.SlugField(max_length=30, unique=True)),
('title', models.CharField(max_length=50, unique=True)),
('icon', models.CharField(blank=True, max_length=100)),
('color', models.CharField(blank=True, max_length=10)),
('order', models.PositiveSmallIntegerField(default=0)),
],
options={'ordering': ['order', 'id']},
),
]

View File

@@ -0,0 +1,24 @@
from django.db import migrations
# رنگ‌ها جدا از رنگِ پایه‌ها (Grade.color) و از هم‌اند تا دکمه‌های نوارِ کتاب‌ها در نقشه
# به‌چشم یک مسیرِ جدا بیایند. آیکون‌ها نامِ lucide‌اند (همان فهرستی که نقشه می‌شناسد).
SUBJECTS = [
('math', 'ریاضی', 'Sigma', '#ED6956', 1),
('calculus', 'حسابان', 'LineChart', '#00BCD4', 2),
('geometry', 'هندسه', 'Compass', '#9B7BFF', 3),
('stats', 'آمار و احتمال', 'BarChart3', '#FF7A3D', 4),
('discrete', 'گسسته', 'Network', '#5FBF8F', 5),
]
def create_subjects(apps, schema_editor):
Subject = apps.get_model('content', 'Subject')
for key, title, icon, color, order in SUBJECTS:
Subject.objects.get_or_create(
key=key, defaults={'title': title, 'icon': icon, 'color': color, 'order': order},
)
class Migration(migrations.Migration):
dependencies = [('content', '0014_subject')]
operations = [migrations.RunPython(create_subjects, migrations.RunPython.noop)]

View File

@@ -0,0 +1,20 @@
import django.db.models.deletion
from django.db import migrations, models
import content.models
class Migration(migrations.Migration):
dependencies = [('content', '0015_seed_subjects')]
operations = [
migrations.AddField(
model_name='unit',
name='subject',
field=models.ForeignKey(
default=content.models.default_subject_pk,
on_delete=django.db.models.deletion.PROTECT,
related_name='units', to='content.subject',
),
),
]

View File

@@ -0,0 +1,19 @@
from django.db import migrations
def mark_calculus(apps, schema_editor):
"""
فصل‌های پایه‌ی یازدهم و دوازدهمِ موجود از کتابِ «حسابان»اند (`CALCULUS_11/12`)، نه ریاضیِ عمومی.
۰۰۱۶ همه را «ریاضی» گذاشت؛ اینجا درست می‌شود. پایه از `number` شناخته می‌شود، نه از id.
بررسی‌نشده: دیتابیسی که در آن فصلِ یازدهم/دوازدهم از کتابی جز حسابان آمده باشد (روی سرور و
لپ‌تاپ نیست؛ هیچ کتابِ دیگری برای آن پایه‌ها پیش از این migration ingest نشده بود).
"""
Subject = apps.get_model('content', 'Subject')
Unit = apps.get_model('content', 'Unit')
calculus = Subject.objects.get(key='calculus')
Unit.objects.filter(grade__number__in=(11, 12)).update(subject=calculus)
class Migration(migrations.Migration):
dependencies = [('content', '0016_unit_subject')]
operations = [migrations.RunPython(mark_calculus, migrations.RunPython.noop)]

View File

@@ -0,0 +1,25 @@
from django.db import migrations
from content.node_split import split_long_nodes
def split(apps, schema_editor):
"""
جزیره‌های بلندِ (بیش از ۷ درس) پایه‌های سوم تا دوازدهم را به جزیره‌های حداکثر ۶ درسی می‌شکند (`content/node_split.py`).
روی دیتابیسِ خالی (تست، سرورِ تازه) هیچ کاری نمی‌کند؛ آنجا `seed_content` از فایلِ بازساخته‌شده می‌سازد.
"""
Unit = apps.get_model('content', 'Unit')
stats = split_long_nodes(
Node=apps.get_model('content', 'Node'),
Lesson=apps.get_model('content', 'Lesson'),
UserNodeCompletion=apps.get_model('content', 'UserNodeCompletion'),
NodeProgress=apps.get_model('content', 'NodeProgress'),
units=Unit.objects.filter(grade__number__in=range(3, 13)).order_by('id'),
)
if stats['nodes_split']:
print(f'\n split_long_nodes: {stats}')
class Migration(migrations.Migration):
dependencies = [('content', '0017_calculus_units')]
operations = [migrations.RunPython(split, migrations.RunPython.noop)]

View File

@@ -0,0 +1,12 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [('content', '0018_split_long_calculus_nodes')]
operations = [
migrations.AddField(
model_name='question',
name='figure',
field=models.JSONField(blank=True, null=True),
),
]

View File

@@ -0,0 +1,54 @@
"""
متنِ درس‌هایی که LaTeX خام داشتند (`\( \vec{a} \)`، `^{n}`) به متنِ ساده‌ی یونیکد برمی‌گردد.
فرانت LaTeX رندر نمی‌کند، پس دانش‌آموز بک‌اسلش و آکولاد می‌دید (درس‌های بردارِ دوازدهم).
فایل‌های seed همین تبدیل را گرفته‌اند، ولی `seed_content` متنِ مثال/قدم‌های درسِ موجود را
عوض نمی‌کند (فقط خانه‌ی خالی را پر می‌کند)، پس سرور بی‌این migration همان متنِ خراب را نگه می‌داشت.
تبدیل در `content/plain_math.py` است و idempotent: بار دوم هیچ‌چیز عوض نمی‌شود. معکوس ندارد
(متنِ قدیم بی‌فایده بود). نمودارها: فقط `FIGURE_TEXT_KEYS`، نه `expr`.
"""
import hashlib
from django.db import migrations
from content.plain_math import convert, convert_figures
def _h(text):
return hashlib.sha1((text or '').encode('utf-8')).hexdigest()
def _key(*parts):
return hashlib.sha1('\x1f'.join(('intro', *map(str, parts))).encode('utf-8')).hexdigest()
def forwards(apps, schema_editor):
Lesson = apps.get_model('content', 'Lesson')
SeedRecord = apps.get_model('content', 'SeedRecord')
qs = Lesson.objects.select_related('node__unit__grade')
for lesson in qs.iterator(chunk_size=500):
new = {
'intro_text': convert(lesson.intro_text),
'example': convert(lesson.example),
'steps': [convert(s) for s in (lesson.steps or [])],
'figures': convert_figures(lesson.figures or []),
}
changed = [f for f, v in new.items() if v != getattr(lesson, f)]
if not changed:
continue
old_intro = lesson.intro_text
for f in changed:
setattr(lesson, f, new[f])
lesson.save(update_fields=changed)
if 'intro_text' in changed:
# دفترِ seed «آخرین متنی که خودمان نوشتیم» را با هش نگه می‌دارد؛ هشِ قدیم بماند
# متنِ تازه «کارِ ادمین» حساب می‌شود و دیگر از فایل تازه نمی‌شود.
n = lesson.node
key = _key(n.unit.grade.title, n.unit.title, n.title, lesson.title)
SeedRecord.objects.filter(key=key, value=_h(old_intro)).update(value=_h(new['intro_text']))
class Migration(migrations.Migration):
dependencies = [('content', '0019_question_figure')]
operations = [migrations.RunPython(forwards, migrations.RunPython.noop)]

View File

@@ -0,0 +1,107 @@
"""
LaTeX و نشانه‌گذاریِ خام (`^(−۴)`، `**…**`، `\\vec`) را از همه‌ی متن‌هایی که دانش‌آموز می‌بیند پاک می‌کند:
سوال‌ها و گزینه‌ها، متنِ درس‌ها و گفتگوهای دستیار. فایل‌های seed همین تبدیل را دارند؛ بدونِ این migration
`seed_content` سوالِ تمیز را «تازه» می‌دید (صورتش با دیتابیس فرق دارد) و کنارِ نسخه‌ی قدیمی دوباره می‌ساخت.
تبدیل `content/plain_math.py::convert` است (idempotent). معکوس ندارد. پاسخِ دستیار با
`rag.services.mathtext.to_plain` (همان مسیرِ زنده‌ی پاسخ‌ها).
"""
import hashlib
from django.db import migrations
from content.plain_math import convert, convert_figures
def _h(text):
return hashlib.sha1((text or '').encode('utf-8')).hexdigest()
def _key(*parts):
return hashlib.sha1('\x1f'.join(('intro', *map(str, parts))).encode('utf-8')).hexdigest()
def forwards(apps, schema_editor):
Question = apps.get_model('content', 'Question')
Choice = apps.get_model('content', 'Choice')
Lesson = apps.get_model('content', 'Lesson')
SeedRecord = apps.get_model('content', 'SeedRecord')
batch = []
for q in Question.objects.all().iterator(chunk_size=1000):
new = convert(q.prompt)
if new != q.prompt:
q.prompt = new
batch.append(q)
if len(batch) >= 500:
Question.objects.bulk_update(batch, ['prompt'])
batch = []
if batch:
Question.objects.bulk_update(batch, ['prompt'])
batch = []
for c in Choice.objects.all().iterator(chunk_size=2000):
new = convert(c.value)
if new != c.value:
c.value = new
batch.append(c)
if len(batch) >= 500:
Choice.objects.bulk_update(batch, ['value'])
batch = []
if batch:
Choice.objects.bulk_update(batch, ['value'])
# بعد از تبدیل ممکن است دو سوالِ هم‌صورت در یک درس بماند: نسخه‌ی قدیمیِ LaTeXدار و نسخه‌ای که seed
# پیش از این migration با متنِ تمیز ساخته بود. یکی نگه داشته می‌شود: آن‌که پاسخِ بیشتری دارد، وگرنه قدیمی‌تر.
from django.db.models import Count
UserAnswer = apps.get_model('content', 'UserAnswer')
groups = {}
for qid, lid, prompt in Question.objects.values_list('id', 'lesson_id', 'prompt').iterator(chunk_size=2000):
groups.setdefault((lid, prompt), []).append(qid)
drop = []
for ids in groups.values():
if len(ids) > 1:
answered = dict(UserAnswer.objects.filter(question_id__in=ids).values_list('question_id').annotate(n=Count('id')))
keep = max(ids, key=lambda i: (answered.get(i, 0), -i))
drop.extend(i for i in ids if i != keep)
if drop:
Question.objects.filter(id__in=drop).delete()
for lesson in Lesson.objects.select_related('node__unit__grade').iterator(chunk_size=500):
new = {
'title': convert(lesson.title),
'intro_text': convert(lesson.intro_text),
'example': convert(lesson.example),
'steps': [convert(s) for s in (lesson.steps or [])],
'figures': convert_figures(lesson.figures or []),
}
changed = [f for f, v in new.items() if v != getattr(lesson, f)]
if not changed:
continue
old_intro, old_title = lesson.intro_text, lesson.title
for f in changed:
setattr(lesson, f, new[f])
lesson.save(update_fields=changed)
if 'intro_text' in changed:
n = lesson.node
key = _key(n.unit.grade.title, n.unit.title, n.title, old_title)
SeedRecord.objects.filter(key=key, value=_h(old_intro)).update(value=_h(new['intro_text']))
try:
Message = apps.get_model('ai_tutor', 'Message')
except LookupError:
return
from rag.services.mathtext import to_plain
batch = []
for m in Message.objects.all().iterator(chunk_size=500):
new = to_plain(m.text) if m.role == 'assistant' else convert(m.text)
if new != m.text:
m.text = new
batch.append(m)
if batch:
Message.objects.bulk_update(batch, ['text'])
class Migration(migrations.Migration):
dependencies = [('content', '0020_plain_math_lesson_texts'), ('ai_tutor', '0008_alter_message_created_at')]
operations = [migrations.RunPython(forwards, migrations.RunPython.noop)]

Some files were not shown because too many files have changed in this diff Show More