Compare commits

..

276 Commits

Author SHA1 Message Date
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
3026df0397 carry today's 312 new questions to the server
کدِ امروز پوش شد ولی محتوا جا ماند: db.sqlite3 در .gitignore است و سوال‌ها فقط
از مسیرِ content/seed/questions.json به سرور می‌رسند.

۱٬۴۴۱ → ۱٬۷۸۳ سوال. تازه‌ها از مسئله‌های تأییدشده‌ی هشتم، نهم و دهم ساخته شدند
و هر کدام سه گزینه‌ی انحرافی دارند که اشتباهِ واقعیِ دانش‌آموز را بازتاب می‌دهند
(جمعِ توان‌ها به‌جای تفریق، جاافتادنِ علامتِ منفی، محیط به‌جای مساحت).

اثرش روی مسیرِ بازی: نودهای ناقص از ۵۸ به ۴۱ رسیدند و ۱۸۹ نود از ۲۳۰ حالا
کوییزِ کامل دارند. ۴۱ موردِ باقی‌مانده همگی از کتاب‌های سوم، ششم و هفتم‌اند که
امروز دست‌نخورده ماندند.

مسئله‌های Qdrant دیپلوی نمی‌خواهند — مستقیم روی همان سرور نوشته شده‌اند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 12:50:30 +03:30
acce78bb03 write down the extraction method so another team can reuse it
docs/extraction-playbook.md: چهار تصمیمی که تفاوتِ چند برابری در هزینه و کیفیت
ساختند، با عددهای اندازه‌گیری‌شده‌ی واقعی نه تخمین.

سرفصل‌ها: انتخابِ مدل از روی اندازه‌گیری (gpt-4.1-mini هم دقیق‌تر از gpt-4o بود
هم ۳٫۵ برابر ارزان‌تر، و gemini برخلافِ جدولِ قیمت سه برابر گران‌تر)، قاعده‌ی
self-contained، تعمیرِ بک‌اسلشِ LaTeX، محافظِ شکل، و ساختِ سوال با subagent
به‌جای API.

وجهِ مشترکِ هر چهار مورد: هیچ‌کدام خطا تولید نمی‌کردند — نه در لاگ، نه در تست.
فقط دادهٔ کمتر یا بدتر می‌ساختند. برای همین توصیه‌ی محوریِ فایل این است که قبل
از اعتماد به هر ران انبوه، نمونه‌ای ۴۰-۵۰تایی با چشم خوانده شود.

تست‌های rag هم اینجا می‌آیند: بازخوانی، تعمیرِ JSON، و محافظِ شکل.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 12:36:45 +03:30
3156bcbbf3 stop counting the frontend developer's port numbers
فهرستِ ثابتِ CORS (۳۰۰۰ و ۵۱۷۳) روزی شکست که Vite دید ۵۱۷۳ اشغال است و
بی‌سروصدا رفت روی ۵۱۷۴ — رفتارِ پیش‌فرضِ خودش. فرانت‌کار خطای CORS گرفت، از
سمتِ سرور هیچ ردی در هیچ لاگی نبود (خطا فقط در کنسولِ مرورگر دیده می‌شود)، و
چون هم‌زمان با تغییراتِ بک‌اند شد به اشتباه گردنِ ما افتاد. هزینه‌اش یک ویرایشِ
.env و یک ری‌استارتِ پروداکشن بود، برای چیزی که اصلاً به پروداکشن ربطی نداشت.

به‌جای شمردنِ پورت‌ها، الگوی «هر پورتی روی خودِ ماشینِ توسعه‌دهنده» پذیرفته شد.
سطحِ حمله اضافه نمی‌کند: توکنِ ما در هدر است نه کوکی و CORS_ALLOW_CREDENTIALS
خاموش، پس صفحه‌ای روی localhostِ مهاجم هیچ اعتبارنامه‌ای از مرورگرِ قربانی
نمی‌گیرد؛ CORS هم اصلاً سدِ سرور-به-سرور نیست.

تست‌ها هم پوشش می‌دهند که localhost.evil.com و math.tadrisino2.ir.evil.com
همچنان رد شوند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 12:36:45 +03:30
d15a28799a anchor a question to its lesson by page, not by a title that keeps changing
chunkها با تطبیقِ دقیقِ رشته به درس وصل می‌شدند (problem_type == lesson.title).
عنوانِ درس از یک استخراجِ قدیمی آمده بود و هر بازخوانیِ کتاب عنوانِ تازه می‌سازد،
پس بعد از بازخوانیِ امروز فقط ۵ تا ۸ درصدِ عنوان‌ها می‌خوردند — یعنی ۹۵٪ درس‌ها
بی‌هیچ خطایی هیچ نامزدی پیدا نمی‌کردند و ساختِ سوال صفر برمی‌گرداند.

لنگرِ پایدار صفحه است: هر Node دقیقاً یک بخشِ فهرستِ کتاب با بازه‌ی صفحه‌ی مشخص
است (تطبیق ۱۰۰٪: ۳۴/۳۴، ۲۸/۲۸، ۲۳/۲۳) و هر chunk هم source_page دارد. وقتی
عنوان نخورد، از روی بازه‌ی صفحه سراغ chunk می‌رویم — بدونِ هیچ فراخوانیِ مدلی.

نتیجه: هشتم ۰→۲۰۱، نهم ۰→۴۹، دهم ۰→۱۳۴.

رفتارِ قدیم با --strict-title حفظ شد، و یک محافظ گذاشته شد که یک chunk به دو
درس داده نشود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 12:36:45 +03:30
96bda905db stop the figure guard from throwing away four fifths of what it caught
محافظِ «وابسته به شکل» یک regexِ تخت بود (شکل|جدول|روبه‌رو|مقابل|زیر را) و
مسئله‌ها را بدونِ هیچ فراخوانی کنار می‌گذاشت. فاجعه‌بار گشاد بود:

    «زاویه‌های روبه‌رو در لوزی برابرند؟»   ← «روبه‌رو» یعنی مقابل، نه شکل
    «کسرهای زیر را گویا کنید: ۲/√۳»       ← داده همان‌جاست
    «ظرفی به شکل مخروطی با شعاع ۴»        ← «به شکلِ» یعنی شبیهِ

اندازه‌گیری روی نمونه‌ی ۵۰تایی از کتاب‌های بازخوانی‌شده: ۸۴٪ از رد‌شده‌ها واقعاً
قابلِ قضاوت بودند — یعنی ~۷۸۴ مسئله‌ی سالم بی‌صدا از چرخه‌ی ساختِ سوال بیرون
مانده بود، بدونِ هیچ خطایی در هیچ لاگی.

قاعده‌ی تازه دو شرط دارد و هر دو باید برقرار باشند: ارجاعِ صریح به چیزی بیرونِ
متن، و نیامدنِ هیچ توصیفی بعد از آن. شرطِ دوم همان چیزی را می‌گیرد که قاعده‌ی
self-contained تولید می‌کند: «با توجه به شکل مقابل که شامل {a,1} است».

اثرش روی دادهٔ واقعی: unjudgeable از ۱٬۴۴۱ به ۵۰۸ و agrees از ۷۹۱ به ۲٬۴۶۶.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 12:36:16 +03:30
fa347915c4 let a book be re-read without duplicating everything in it
ingestِ دوباره جایگزین نمی‌کرد: qdrant_point_id یک uuid4 تازه به ازای هر ردیف
است، پس خواندنِ دوباره‌ی یک صفحه نسخه‌ی تکراری کنارِ نسخه‌ی قدیمی می‌گذاشت و
جستجو نمی‌توانست تشخیص بدهد کدام کدام است.

--refresh {figures,unjudgeable,both,all} و --refresh-pages اضافه شدند.

سه تصمیمِ طراحی که عمدی‌اند:

۱. پاک‌سازی صفحه‌به‌صفحه است و درست پیش از خواندنِ همان صفحه، نه یک‌جا از اول.
   وسطِ تست یک crash همین را ثابت کرد: فقط یک صفحه خالی ماند (۲۷۹→۲۷۶) و بقیه‌ی
   کتاب دست‌نخورده. پاک‌سازیِ یک‌جا همان crash را به ۸۱ صفحه‌ی خالی تبدیل می‌کرد.

۲. اول Qdrant، بعد دیتابیس. شناسه‌ی نقطه فقط در ردیفِ DB است، پس ترتیبِ برعکس
   یعنی شکستِ Qdrant نقاط را برای همیشه بی‌صاحب می‌گذارد — نه قابلِ یافتن، نه
   قابلِ پاک کردن.

۳. order از Max گرفته می‌شود نه count: بازخوانی ردیف‌ها را از وسط پاک می‌کند و
   count عددی می‌داد که با orderهای موجود تصادم داشت.

تخمینِ هزینه حالا از اندازه‌گیریِ واقعیِ پنلِ صورتحساب می‌آید نه از جدولِ قیمت،
و وعده‌ی «با Batch API نصف» حذف شد چون GapGPT اصلاً /v1/batches ندارد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 12:36:16 +03:30
b115ccd37a stop losing the pages that matter most to a stray backslash
مدلِ vision در متنِ ریاضی LaTeX می‌نویسد (\frac، \sqrt، \vec) و بک‌اسلشِ تنها
در JSON نامعتبر است، پس json.loads کلِ صفحه را دور می‌ریخت — دقیقاً صفحه‌های
پرفرمول، یعنی باارزش‌ترین‌ها. روی حسابان یازدهم/دوازدهم چهار صفحه هرچه تلاش
شد «خطادار» ماندند تا همین‌جا پیدا شد.

⚠️ نسخه‌ی اولِ تعمیر خودش تله داشت: \f، \t، \b، \r و \n از نظرِ JSON معتبرند،
پس «سالم» رد می‌شدند و \frac به formfeed+«rac» و \theta به tab+«heta» تبدیل
می‌شد — خرابیِ بی‌صدا به‌جای خطای بلند. قاعده‌ی تفکیک: دستورِ LaTeX همیشه با
حرفِ ASCII ادامه می‌یابد، ولی escapeِ واقعی با رقم، فارسی یا پایانِ رشته.

کنارش: پایه‌های یازدهم و دوازدهم به persona و تشخیصِ پایه اضافه شدند («دوازدهم»
پیش از «دهم» بررسی می‌شود چون زیررشته‌اش را دارد)، سقفِ max_tokens از ۳۰۰۰ به
۶۰۰۰ رفت چون قاعده‌ی self-contained خروجی را بلندتر کرده، و حسابان یازدهم و
دوازدهم با نگاشتِ فصل‌به‌صفحه ثبت شدند.

«حسابان» به MATH_SUBJECTS اضافه شد: بدونِ آن retrieval این کتاب‌ها را «درسِ
متنی» می‌دید و به‌جای chapter روی lesson_number فیلتر می‌کرد — بی‌هیچ خطایی
همیشه صفر نتیجه.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 12:35:46 +03:30
8eedbdeac0 let a deploy repair the server instead of stopping at it
تا حالا اگر کسی روی سرور فایلی را دستی عوض کرده بود، `git pull --ff-only`
شکست می‌خورد و کلِ اسکریپت همان‌جا می‌ایستاد. چون هیچ‌کدام از مراحلِ بعدی
اجرا نمی‌شد، سرور با کدِ قدیمی می‌ماند — بی‌صدا. یک بار همین دو روز طول کشید.

حالا مرحله‌ی اول fetch می‌کند، اگر تغییرِ دستی (یا کامیتِ محلی) باشد آن را در
.deploy-backups/*.patch نگه می‌دارد، و با `git reset --hard origin/<branch>`
کنارش می‌گذارد و ادامه می‌دهد. سرور در هر وضعیتی باشد، یک اجرا دقیقاً همان
چیزی می‌شود که روی gitea است.

`git clean` عمداً زده نمی‌شود و نباید اضافه شود: db.sqlite3 و .env و
.django_secret_key ردیابی‌نشده و داخلِ همین پوشه‌اند.

با مخزنِ ساختگی هر سه حالت آزموده شد: تغییرِ دستی، درختِ تمیز، و کامیتِ محلیِ
واگرا. در هر سه، .env و db.sqlite3 دست‌نخورده ماندند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 00:54:06 +03:30
6ec82081b5 stop deploy.sh from pulling a new copy of itself out from under bash
bash اسکریپت را یک‌جا نمی‌خواند؛ حین اجرا از روی دیسک ادامه می‌دهد. پس وقتی
`git pull` همین فایل را عوض می‌کند، ادامه‌ی اجرا مخلوطی از نسخه‌ی قدیم و جدید
است. یک بار همین اتفاق افتاد و گران تمام شد: نسخه‌ی قدیمی که در حال اجرا بود
سرویسِ تیمِ دیگری را ری‌استارت کرد، در حالی که نسخه‌ی اصلاح‌شده همان لحظه روی
دیسک نشسته بود.

حالا امضای فایل قبل و بعدِ pull مقایسه می‌شود و اگر عوض شده باشد، نسخه‌ی تازه
از اول اجرا می‌شود (با SKIP_PULL=1 و یک نگهبان در برابرِ حلقه‌ی بی‌پایان).

و یک حفره‌ی کوچک‌ترِ هم‌خانواده: اگر زمانِ بالا آمدنِ سرویس اصلاً خوانده نشود،
دیگر موفقیت فرض نمی‌شود. «نمی‌دانم» همان چیزی بود که دو روز پنهانش کرد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 00:41:11 +03:30
700b2e4c7f give our service a name nobody can confuse with someone else's
روی این سرور یک `gunicorn.service` هست که مالِ تیمِ دیگری است. هر کسی که
عجله داشته باشد و `systemctl restart gunicorn` بزند، سرویسِ آن‌ها را
می‌خواباند و سرویسِ ما اصلاً ری‌استارت نمی‌شود — یعنی دیپلوی بی‌صدا اعمال
نمی‌شود. همین دیشب یک بار اتفاق افتاد.

حالا همان یونیت با نامِ `zareian-mathapp` هم صدا زده می‌شود (symlink در
/etc/systemd/system). نامِ قبلی هم کار می‌کند، پس چیزی نمی‌شکند.

خط تیره و نه نقطه: systemd آخرین نقطه را جداکننده‌ی نوعِ یونیت می‌داند، پس
`zareian.mathapp` را باید هر بار کامل با `.service` نوشت.

deploy.sh هم اول دنبالِ همین نام می‌گردد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 00:14:46 +03:30
0132235026 take out a comment that was rendering as page text
`{# ... #}` در جنگو فقط تک‌خطی است. کامنتی که دیشب اضافه کردم سه خط بود، پس
جنگو کامنت حسابش نکرد و کلِ متنش بالای صفحه‌ی مرورِ محتوا چاپ شد.

کلاً برداشته شد. برای توضیحِ چندخطی در قالب باید {% comment %} استفاده شود،
ولی اینجا اصلاً توضیحی لازم نبود.

همه‌ی قالب‌های پروژه برای همین الگو بررسی شدند؛ جای دیگری نبود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 00:05:18 +03:30
ae9c33c5de write down how to handle the server password
روشی که این جلسه استفاده شد حالا نوشته شده تا هر کسی (آدم یا ایجنت) همان را
تکرار کند: رمز در یک فایلِ chmod 600 در پوشه‌ی موقت، ورود با sshpass -f و
sudo -S از ورودی، و shred -u بلافاصله بعدِ کار.

دلیلِ هر قدم هم نوشته شد، چون بدونِ دلیل قدم‌ها تشریفات به‌نظر می‌رسند و کنار
گذاشته می‌شوند. مهم‌ترینش: رمز هیچ‌وقت در خطِ فرمان نیاید — روی این سرور چند
تیمِ دیگر حساب دارند و ps دستورِ هر پروسه را به همه نشان می‌دهد.

و صریح نوشته شد که رمز فقط از خودِ کاربر در همان جلسه می‌آید؛ جایی ذخیره
نمی‌شود و نباید بشود. اگر در گفتگو نبود، باید پرسید نه گشت.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 00:00:03 +03:30
3179ad0b3b stop printing the content browser's title twice
ویو `title` را به کانتکست می‌دهد و admin/base.html خودش آن را هم در مسیرِ
راهنما و هم به‌عنوان <h1> صفحه رندر می‌کند. قالب یک <h1> دستی هم داشت، پس
عنوان دو بار پشتِ هم چاپ می‌شد. دستی برداشته شد؛ عنوانِ تبِ مرورگر و مسیرِ
راهنما دست‌نخورده می‌مانند چون هر دو از همان `title` می‌آیند.

بخشِ وضعیت در CLAUDE.md هم به‌روز شد: می‌گفت سرور کدِ قدیمی و دیتابیسِ خالی
دارد، که دیگر درست نیست. به‌جایش چیزی نوشته شد که دفعه‌ی بعد به درد بخورد —
اینکه فایلِ روی دیسک یعنی فایلِ مستقر‌شده نیست، و چه چیزهایی عمداً باز مانده.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 23:49:05 +03:30
fecba2011b make the content browser readable in dark mode
صفحه‌ی مرورِ محتوا در پنلِ ادمین عملاً نامرئی بود: هر پنل زمینه‌ی روشنِ ثابت
داشت (#fff، #f6f6f6، #f1f5f9) ولی رنگِ متن تعیین نشده بود. مرورگری که روی
حالتِ تیره است، رنگِ متنِ ادمین را روشن می‌کند — و نتیجه متنِ سفید روی زمینه‌ی
سفید بود.

حالا پنل‌ها از متغیرهای خودِ ادمین استفاده می‌کنند تا با هر دو تم بروند، و
چیپ‌های رنگی که عمداً زمینه‌ی روشنِ ثابت دارند رنگِ متنِ تیره‌ی صریح گرفتند.
همین اشکال در صفحه‌ی واردکردنِ سوال هم بود و درست شد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 23:11:44 +03:30
de118db58f make a deploy fail loudly when the service never restarted
سرور دو روز کدِ قدیمی را سرو کرد و هیچ‌کس نفهمید. علتش این بود که
`systemctl restart` شکست خورده بود ولی پروسه‌ی قبلی هنوز بالا بود و همان
۲۰۰ را می‌داد — پس اسکریپت «✓ سرویس سالم بالا آمد» چاپ کرد و با کدِ صفر
تمام شد. محتوا و فایل‌های استاتیک هم روی دیسک بودند و سرو نمی‌شدند.

حالا زمانِ بالا آمدنِ سرویس قبل و بعد مقایسه می‌شود. اگر عوض نشده باشد،
۲۰۰ گرفتن دیگر موفقیت حساب نمی‌شود و اسکریپت با خطا تمام می‌کند.

نامِ عامِ `gunicorn` هم از فهرستِ حدسِ سرویس برداشته شد: روی سرورِ واقعی یک
`gunicorn.service` هست که مالِ تیمِ دیگری است، و حدس زدنش یعنی ری‌استارت
کردنِ سرویسِ یک نفرِ دیگر. نامِ درستِ ما `gunicorn-mathapp` است.

`api/health/` هم کنارِ `api/health` ثبت شد. پایشِ سرور شکلِ اسلش‌دار را صدا
می‌زد و چون ثبت نبود، کسی روی سرور `config/urls.py` را دستی ویرایش کرده
بود — و از آن به بعد هر `git pull` روی همان فایل گیر می‌کرد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 22:56:51 +03:30
07fab4c094 write down what the last two days changed about content and answer quality
CLAUDE.md تنها جایی است که هر جلسه‌ی تازه اول می‌خواندش، و چیزهایی که این دوره
یاد گرفتیم جایی ثبت نشده بود. چهار بخش اضافه شد و «وضعیتِ فعلی» به بالا آمد:

• محتوا حالا در content/seed/ کامیت می‌شود و deploy خودش بالا می‌آوردش — بدونِ
  Qdrant، بدونِ اعتبارِ AI، بدونِ SSH. و اینکه سوال با مسیرِ عنوان به درس وصل
  می‌شود نه با id، چون idها بینِ دو دیتابیس یکی نیستند.
• لایه‌ی راستی‌آزمایی: کتاب از روی تصویر خوانده می‌شود، پس خطا همان‌جا وارد
  می‌شود. برچسب سه‌حالته است و سوال فقط از agreesها ساخته می‌شود.
• دو درسی که گران تمام شدند: نتیجه‌ی دوحالته نرخ را سه برابر نشان می‌دهد، و
  «۳/۱۰۴» در این کتاب‌ها عددِ اعشاری است نه کسر.
• شکافِ شکل‌ها (۴۲٪ محتوا) و اینکه اصلاحش در پرامپت هست ولی اجرا نشده.
• تفاوتِ ۴۲۹ سقفِ حساب با ۴۰۳ اتمامِ اعتبار — و اینکه متنِ دومی فاصله دارد نه
  زیرخط، که یک بار ۷۷۰ فراخوانیِ بی‌فایده به بار آورد.

«وضعیت تا ۲۰۲۶-۰۹-۱۷» هم اضافه شد: بک‌اند آماده و تست‌شده است، و دو مانعِ
باقی‌مانده دستِ ما نیست — سرور روی کدِ قدیمی، و فرانتی که به هیچ APIی وصل نیست.
هر دو قبل از اینکه کسی دنبالِ باگ بگردد باید چک شوند.

هیچ فایلِ تازه‌ای اضافه نشد؛ همان فایلِ موجود به‌روز شد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 05:59:19 +03:30
16d3d9f149 add the last 81 quiz questions
دستهٔ c5. ۱٬۳۶۰ → ۱٬۴۴۱.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:36:34 +03:30
e89de084e7 add 161 more quiz questions from verified textbook solutions
دسته‌ی c2 و باقی‌ماندهٔ دیشب. ۱٬۱۹۹ → ۱٬۳۶۰.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:25:10 +03:30
b26aca0f63 label every textbook solution, and build quizzes at the level the app plays
سه کارِ به‌هم‌پیوسته که از یک کشف شروع شدند: راه‌حل‌هایی که vision از کتاب
درآورده گاهی غلط‌اند، و این خطا در بازیابی دیده نمی‌شود — فقط وقتی کسی به
`solution` اعتماد کند خودش را نشان می‌دهد.

۱) بازبینیِ راه‌حل‌ها (rag_verify_solutions)

هر chunk دوباره حل می‌شود و با کتاب مقایسه. نتیجه سه‌حالته است، نه بله/خیر:
اجرای اول با بولی ۱۹٪ خطا گزارش کرد؛ خواندنِ دستیِ نمونه‌ها نشان داد ۳۵٪ از
آن‌ها مسئله‌هایی بودند که به شکلِ کتاب ارجاع می‌دادند و اصلاً قابلِ قضاوت
نبودند، و ۲۴٪ موردی که «۳/۱۰۴» را کسرِ ۳÷۱۰۴ خوانده بود نه عددِ ۳٫۱۰۴. با دو
حالت، «نمی‌دانم» ناچار به «غلط است» ترجمه می‌شد.

با سه حالت و پرامپتی که این دو تله را می‌شناسد: ۲٬۱۱۹ chunk بررسی شد،
۱٬۱۱۲ درست، ۱۲۳ غلط (۵٫۸٪)، ۸۸۱ غیرقابلِ قضاوت. آن ۸۸۱ رایگان برچسب خوردند
چون پیش‌فیلتر قبل از هر فراخوانی کنارشان گذاشت.

`solution` هرگز بازنویسی نمی‌شود — کتاب باید کتاب بماند. برچسب در payloadِ
Qdrant هم می‌نشیند، ولی با نامی که `retrieval._payload_text` نمی‌خواند: یک
نویسه هم به متنی که به مدل می‌رسد اضافه نشد.

نمونه‌ی خطاهای واقعیِ کتاب که پیدا شد:
    BMI = ۱۰۰/(۲٫۷۴)²          کتاب: ۳۳٫۲      درست: ۱۳٫۳
    ۱۶ آبی + ۲۴ صورتی          کتاب: «کل ۵۰»   درست: ۴۰
    بزرگ‌ترین عددِ ۴رقمیِ بی‌تکرار کتاب: ۸۵۳۲     درست: ۹۸۷۶
    g(x) = x³ − ۲              کتاب: «نمایی»   درست: چندجمله‌ای

۲) سوال‌ها فقط از راه‌حل‌های تأییدشده

۱٬۱۹۹ سوال در content/seed/questions.json، همه از chunkهایی که برچسبِ
«درست» گرفته‌اند. هر شش پایه برنامه‌ی درسی دارند (۱٬۹۲۸ درس) — هشتم و دهم
تازه ساخته شدند.

۳) GET /api/nodes/{id}/quiz

واحدِ بازی در اپ «مبحث» است ولی سوال‌ها به «درس» بسته‌اند، و درس اینجا
ریزترین دانه است: یک نوعِ مسئله، نه یک جلسه. نتیجه‌اش این بود که اپ برای یک
آزمونِ چهارسوالی باید ده‌ها درخواست می‌زد. این اندپوینت از همه‌ی درس‌های مبحث
نمونه برمی‌دارد، حداکثر یکی از هر درس تا آزمون روی یک مهارت گیر نکند.

درهم‌ریزی با بذرِ nodeId:userId پایدار است: رفرشِ صفحه همان آزمون را می‌دهد،
نه مجموعه‌ی تازه‌ای که پیشرفت را بی‌معنا کند. `questionCount` جدا از
`requested` می‌آید تا مبحثِ کم‌سوال وانمود نکند کامل است.

──────────
اصلاحاتِ جانبی که سرِ راه لازم شدند:

• QDRANT_TIMEOUT_SECONDS: کلاینت بدونِ timeout ساخته می‌شد و پیش‌فرضِ ۵
  ثانیه‌ایِ کتابخانه ingestِ کتابِ هشتم را روی اولین فراخوانی کشت.
• پاک‌سازیِ نقاطِ نیمه‌نوشته: upsert با wait=True ممکن است نوشته باشد و فقط
  جوابش تایم‌اوت شده باشد؛ مسیرِ جبران فقط ردیف‌های DB را پس می‌گرفت و هر
  retry یک نسخه‌ی تکراری در Qdrant جا می‌گذاشت (۸ مورد در پایه نهم، پاک شد).
• تشخیصِ سقفِ حساب: «insufficient user quota» با فاصله می‌آید نه زیرخط، و
  محافظِ اول نمی‌گرفتش — یک اجرا ۷۷۰ فراخوانیِ محکوم‌به‌شکست زد.
• پرامپتِ vision حالا می‌خواهد محتوای شکل داخلِ متنِ مسئله نوشته شود. ۴۲٪ از
  محتوای موجود به شکلی ارجاع می‌دهد که در متن نیست؛ مدل آن را دیده بود ولی
  از او خواسته نشده بود بنویسدش. کتاب‌های موجود قبل از این قاعده وارد شده‌اند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:11:15 +03:30
92dc2242f8 take the points back too when an upsert fails
بررسیِ collectionها بعد از ingestِ پایه نهم نشان داد Qdrant ۲۸۷ نقطه دارد ولی
دیتابیس ۲۷۹ ردیف. هشت نقطه‌ی اضافه، همه از صفحه‌ی ۱۲۵ — همان صفحه‌ای که دو بار
تایم‌اوت داد و بار سوم گرفت. سه مسئله سه نسخه داشتند و دو نقطه هم بی‌صاحب بود.

علت: `upsert` با `wait=True` ممکن است نوشته باشد و فقط جوابش تایم‌اوت شده
باشد. مسیرِ جبران فقط ردیف‌های DB را پس می‌گرفت، نه نقطه‌ها؛ و چون هر اجرای
دوباره `qdrant_point_id` تازه می‌سازد، هر retry یک نسخه‌ی تکراری اضافه می‌کرد.

اثرش در بازیابی دیده می‌شود نه در لاگ: top-k با سه نسخه از یک مسئله پر می‌شود
و بقیه‌ی متنِ مرتبط بیرون می‌ماند — یعنی دستیار بافتِ کمتری می‌بیند و هیچ‌کس
هم متوجه نمی‌شود.

حالا در صورتِ شکست، نقطه‌ها هم پاک می‌شوند. اگر خودِ پاک‌سازی هم شکست بخورد
لاگ می‌شود ولی خطای اصلی پوشانده نمی‌شود، چون صفحه باید ناتمام بماند تا اجرای
بعدی دوباره سراغش برود.

داده‌ی موجود هم تمیز شد: ۸ نقطه‌ی بی‌صاحب پاک شدند و حالا ۲۷۹ = ۲۷۹.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 23:00:09 +03:30
1131569b76 ground grade 9's questions in the textbook, and catch the book's own errors
پایه نهم تا دیروز ناقص بود: ۲۸ صفحه از کتاب ingest نشده بود و فصل ۸ (حجم و
مساحت) اصلاً وجود نداشت. حالا ۱۴۳ از ۱۴۳ صفحه رفته و برنامه‌ی درسی ۸ فصل،
۲۸ مبحث و ۲۵۸ درس دارد.

۱۷۱ سوالِ تازه از روی مسئله‌های واقعیِ همان کتاب ساخته شد (جمعِ فایل: ۳۳۹).
برخلافِ سوال‌های نمونه‌ی قبلی که از عنوانِ مبحث ساخته شده بودند، این‌ها به
مسئله‌ی مشخصی در کتاب وصل‌اند.

مهم‌تر از خودِ سوال‌ها، چیزی که وسطِ کار معلوم شد: **بخشی از راه‌حل‌هایی که
vision از PDF استخراج کرده غلط است.** ۱۷ مورد پیدا شد، از جمله:

    شمارنده‌های ۶۰ که نه اول‌اند و نه مرکب → کتاب: {۱, ۶۰}   (۶۰ مرکب است)
    x−y=۵ و y=x−۳ → کتاب: (۸,۵)            (خطوط موازی‌اند، بی‌جواب)
    نور در ۱۰۰ ساعت → کتاب: ۱۰۸×۱۰¹³        (ده برابر؛ درست ۱٫۰۸×۱۰¹⁴)
    |۱−√۳| → کتاب: ۱−√۳                     (منفی است؛ درست √۳−۱)
    استوانه ۶۰cm×۱m → کتاب: ۲۸۲٫۷ m³        (خطای واحد؛ درست ۰٫۲۸۳)

اجرای قبلی با gpt-4o-mini همین جواب‌های غلط را بی‌صدا به‌عنوانِ گزینه‌ی درست
علامت می‌زد، چون فقط بازنویسی می‌کرد. مسیرِ تازه اول مسئله را مستقل حل می‌کند
و بعد با راه‌حلِ کتاب مقایسه می‌کند؛ هر اختلاف علامت می‌خورد و دستی بررسی شد.

۷۵ مسئله عمداً سوال نشدند — به شکل یا جدولی در کتاب ارجاع می‌دادند که در متن
نیست. حدس زدن در این موارد یعنی سوالِ بی‌جواب.

`seed_content --export` هم دیگر نمی‌تواند سوال‌ها را بی‌صدا پاک کند: فایل
دست‌نویس است و در دیتابیسِ هر ماشینی نیست، و یک export ساده آن را با آرایه‌ی
خالی بازنویسی می‌کرد. حالا خطا می‌دهد و `--only curriculum` را پیشنهاد می‌کند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 20:40:09 +03:30
0c374fed99 default the env template to DEBUG off
`.env.example` روی هر دو ماشین کپی می‌شود — لپ‌تاپِ توسعه‌دهنده و سرور — و
مقدارش 1 بود. همان فایل روی سرورِ عمومی نشست و ماه‌ها آنجا ماند، با کدِ
ورودِ 0000 باز برای هر شماره‌ای.

پیش‌فرض باید حالتی باشد که فراموش کردنش بی‌خطر بماند: 0 روی لپ‌تاپ فقط یعنی
خطاها خلاصه‌ترند، ولی 1 روی سرور یعنی هر کسی می‌تواند به‌جای هر کاربری وارد
شود. هزینهٔ این دو اشتباه قابلِ مقایسه نیست.

DEBUG جای دیگری هم تعریف نشده: `DJANGO_DEBUG` فقط از محیط خوانده می‌شود و
`.env` در gitignore است، پس هیچ کامیتی حالتِ هیچ ماشینی را عوض نمی‌کند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:58:33 +03:30
4857f49f92 collect static files even when DEBUG is on
پنلِ ادمین روی سرور بدونِ CSS بالا آمد و هیچ خطایی هم در لاگ نبود.

علت ترتیبِ کارها بود: این مرحله وقتی DEBUG روشن بود رد می‌شد، چون در آن حالت
whitenoise از finders می‌خواند و جمع‌آوری لازم نیست. کسی با DEBUG روشن دیپلوی
کرد و بعد DEBUG را خاموش کرد — کارِ درستی هم بود — و از آن لحظه جنگو دنبالِ
فایل‌هایی می‌گشت که هیچ‌وقت جمع نشده بودند.

یعنی درستیِ دیپلوی به ترتیبِ کارِ یک آدم گره خورده بود. چند ثانیه صرفه‌جویی،
به قیمتِ شکستی که فقط در مرورگر دیده می‌شود. حالا بی‌قید و شرط اجرا می‌شود؛
idempotent است و در هر دو حالت درست کار می‌کند.

staticfiles/ هم به gitignore اضافه شد: حالا که این مرحله همیشه اجرا می‌شود،
روی ماشینِ هر توسعه‌دهنده‌ای ساخته می‌شود و ۴٫۴ مگابایت فایلِ تولیدی نباید
با یک `git add .` واردِ مخزن شود.

بررسی شد که با DEBUG=False کلِ مسیر کار می‌کند: استاتیک ۲۰۰، صفحه‌ی ادمین
۲۰۰، API ۲۰۰، و ورود با OTP از پنل. کدِ 0000 همان‌طور که باید، رد می‌شود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:53:55 +03:30
ca983d237f give every topic a starter question so the app can be clicked through
تا امروز کوریکولوم بود و سوال نبود: هر ۱۴ سوالِ دیتابیسِ لوکال روی ردیف‌های
[DEMO] نشسته بودند و در export کنار گذاشته می‌شدند. یعنی سینیور می‌توانست در
مرورگرِ محتوا پایین برود ولی هیچ درسی آزمونی نداشت — که از «محتوا نداریم»
بدتر است، چون شبیهِ خرابی به‌نظر می‌رسد.

حالا هر ۱۶۸ مبحث دستِ‌کم یک سوالِ چهارگزینه‌ای دارد: ۲۳ برای پایه نهم، ۴۶ ششم،
۴۶ هفتم، ۵۳ سوم. هر سوال به درسی وصل است که عنوانش همان موضوع را می‌گوید، نه
به یک درسِ دلخواه.

جای گزینهٔ درست با seed ثابت جابه‌جا شده (۴۴/۴۹/۳۵/۴۰ در چهار موقعیت). اگر
جوابِ درست همیشه گزینهٔ اول باشد، آزمون دیگر چیزی را نمی‌سنجد و آمارِ
«درصدِ غلط» هم بی‌معنا می‌شود. یک تست همین را نگه می‌دارد.

این‌ها نمونه‌اند نه محتوای نهایی: یک سوال برای هر مبحث فقط نشان می‌دهد مسیرِ
درس → آزمون → XP کار می‌کند. سوال‌های واقعیِ هر درس کارِ
`generate_questions_from_chunks` است که به اعتبارِ AI نیاز دارد، و از پنلِ
ادمین هم می‌شود دستی اضافه کرد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:42:14 +03:30
9c3db68cbb carry the curriculum in the repo so a fresh server is not empty
سرورِ production از روزِ اول `{"grades": []}` برگردانده: دیتابیس gitignore است
(درست هم هست) و تنها راهِ پر کردنش اجرای `rag_import_collection` و بعد
`seed_curriculum_from_textbook` روی همان سرور بود — یعنی وابستگی به Qdrant، به
اعتبارِ AI که تمام شده، و به کسی که SSH دارد. نتیجه این شد که هیچ‌کس نتوانست
چیزی را تست کند.

حالا برنامه‌ی درسی در `content/seed/curriculum.json` کامیت می‌شود (۴ پایه، ۳۱
فصل، ۱۶۸ مبحث، ۱۳۰۰ درس، ۵۲۷ کیلوبایت متن) و `deploy.sh` خودش بالا می‌آوردش.
سرورِ تازه نه Qdrant می‌خواهد نه ترمینال.

`db.sqlite3` عمداً کامیت نمی‌شود: باینری است، دیف نمی‌شود، و هر دیپلوی محتوای
سرور — و فردا پاسخ‌های کاربران — را با نسخه‌ی لوکالِ یک نفر جایگزین می‌کند.

سوال‌ها با مسیرِ عنوان ذخیره می‌شوند نه با lessonId. این تصمیمِ اصلیِ کامیت
است: لوکالِ ما ۱۳۰۷ درس دارد با idهای ۳ تا ۲۰۰۵ (جای خالی، از پاک شدنِ دیتای
دمو)، و همان کوریکولوم روی دیتابیسِ خالی idهای ۱ تا ۱۳۰۷ می‌گیرد. با id،
سوالِ درسِ ۳ روی سرور به درسِ بی‌ربطی می‌چسبید — بدونِ هیچ خطایی. تست
`test_question_follows_the_title_path_not_the_id` همین را نگه می‌دارد.

`seed_content` ایدمپوتنت است و هیچ‌چیز پاک نمی‌کند: کوریکولوم با عنوان
هم‌سان‌سازی می‌شود و سوالِ تکراری رد می‌شود (`skip_existing`). روی سروری که
کاربرِ واقعی دارد، دیپلوی نباید بتواند محتوا یا پاسخی را از بین ببرد — و
ادمین باید بتواند سوالِ سیدشده را اصلاح کند بی‌آنکه دیپلویِ بعدی برش گرداند.

ردیف‌های `[DEMO]`/`[SEED]` موقعِ export کنار گذاشته می‌شوند؛ آن‌ها همان‌هایی‌اند
که `purge_demo_data` برای پاک کردنشان نوشته شد و جایشان در فایلی که مستقیم روی
production لود می‌شود نیست.

هنوز صفر سوالِ واقعی داریم: هر ۱۴ سوالِ لوکال رویِ ردیف‌های دمو بودند. مسیرِ
سوال‌ها ساخته و تست شده، ولی تا وقتی اعتبارِ AI شارژ نشود فایلش خالی می‌ماند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:20:26 +03:30
f3be99e4e2 record where this project is heading, not just how it works
هر ایجنتِ تازه ساختارِ کد را می‌بیند ولی نمی‌داند صاحبِ پروژه دنبالِ چیست:
دو ریپو و یک قراردادِ API (نه ادغامِ ریپوها)، حذفِ هر قدمِ دستی از انتشار،
محتوای قابلِ ویرایش بدونِ برنامه‌نویس، و سنجش‌پذیر بودنِ کیفیتِ پاسخِ AI.

بدونِ این، پیشنهادها به سمتی می‌روند که با مسیرِ پروژه نمی‌خواند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 02:44:03 +03:30
8b2a92dd3c write down how the two halves connect
هر بار کسی تازه به این پروژه می‌رسد، همان سه چیز را از نو کشف می‌کند: اینکه
فرانت مسیرهای نسبی صدا می‌زند و nginx به‌جای ۴۰۴ صفحه‌ی اپ را برمی‌گرداند،
اینکه ترتیبِ بالا آوردن مهم است، و اینکه کلاینت را نباید از روی حافظه نوشت.

همه در CLAUDE.md نوشته شد، نه در فایلِ تازه — همان‌جایی که خوانده می‌شود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 02:39:40 +03:30
2218f82938 give topic cards a colour before anyone picks one
رنگ و آیکونِ مبحث تازه اضافه شده بودند و هنوز هیچ‌کس پرشان نکرده، یعنی
کارت‌ها تا وقتی کسی از پنلِ ادمین وقت بگذارد بی‌رنگ می‌ماندند.

حالا وقتی فیلد خالی است، مقدارِ پیش‌فرض از پالت و آیکون‌های خودِ اپ می‌آید
(همان رنگ‌های طرح و همان آیکون‌هایی که از lucide وارد می‌شوند)، تا بومی به
نظر برسد نه وصله.

چرخشی بر اساسِ جایگاهِ مبحث در پایه است، نه تصادفی: رنگی که بینِ دو بارگذاری
عوض شود بدتر از بی‌رنگ بودن است، و دو مبحثِ کنارِ هم نباید یک‌رنگ دربیایند.
مقدارِ صریحِ ادمین همیشه برنده است.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 02:10:50 +03:30
10ad1e232c let the subscription card come from the server
کارتِ اشتراک زیرِ هر پلن فهرستِ امکانات و یک برچسب («محبوب‌ترین»، «بهترین
ارزش») نشان می‌دهد، و هر دو در کدِ فرانت هاردکد بودند — یعنی عوض کردنِ یک
بندِ متنی به دیپلویِ تازه‌ی اپ نیاز داشت.

Plan حالا features و badge دارد. `popular` سرِ جایش ماند: بولین است و متنِ
برچسب را نمی‌دهد، پس این جایگزینش نیست، کنارش است. هیچ‌کدام اجباری نیستند و
پلن‌های موجود بدونشان همچنان سرو می‌شوند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 02:03:15 +03:30
99a0cafe24 serve what the frontend screens actually need
صفحه‌های فرانت خوانده شدند و با API مقایسه شدند. ساختارِ ما از کتابِ واقعی
درآمده و درست‌تر است، پس مدل عوض نشد — ولی چند چیز کم داشت.

مهم‌ترینش یک باگ بود: /api/home پارامتر را `grade` می‌خواند و کلاینتی که به
فرانت فرستادیم `gradeId` می‌فرستد. پارامترِ ناشناخته بی‌صدا نادیده گرفته
می‌شد، یعنی کاربر پایه‌ای را انتخاب می‌کرد و نقشه‌ی پایه‌ی دیگری می‌دید
بدونِ هیچ خطایی. حالا هر دو نام پذیرفته می‌شوند (`gradeId` نامِ درست است،
با بقیه‌ی API یکی). تستِ سرتاسریِ قبلی این را نگرفته بود چون home را بدونِ
آرگومان صدا زده بود.

/api/grades حالا lessonCount می‌دهد. فهرستِ پایه‌های فرانت تا دوازدهم است
ولی محتوای ما تا نهم؛ بدونِ این عدد، کاربر پایه‌ی خالی را انتخاب می‌کند و
صفحه‌ی خالی می‌بیند بی‌آنکه بفهمد چرا.

GET /api/nodes/{id}/lessons اضافه شد. /api/home فقط شناسه‌ی درس‌ها را
می‌دهد، ولی نقشه‌ی مهارت برای هر قدم عنوان، نوع، زیرنویس و «انجام شد» لازم
دارد — بدونش فرانت باید برای هر درس یک intro جدا می‌گرفت.

Lesson حالا kind (آزمون/ویدیو) و متنِ سه‌تکه دارد: توضیح، مثال، حلِ
گام‌به‌گام. ویدیو از قبل سوالِ بازِ محصول بود و فرانت آیتمش را دارد؛ حالا
API می‌تواند بیانش کند، حتی اگر محتوایش بعداً بیاید. Unit یک blurb گرفت و
Node آیکون و رنگ، تا فرانت رنگ‌ها را هاردکد نکند. هیچ‌کدام اجباری نیست، پس
محتوای موجود دست‌نخورده کار می‌کند.

PATCH /api/user/profile حالا age را هم می‌پذیرد (صفحه‌ی ثبت‌نامِ فرانت سن
می‌پرسد). ذخیره‌سازی همچنان birthYear است چون کهنه نمی‌شود؛ این فقط ورودی
را ترجمه می‌کند تا دو طرف حسابِ متفاوت درنیاورند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 01:42:24 +03:30
8effe5b60b secure the admin session and make traffic visible from outside
دو شکاف که وقتی آدم‌های دیگر به پروژه وصل می‌شوند مهم‌تر می‌شوند.

کوکیِ نشستِ پنلِ ادمین و توکنِ CSRF بدونِ پرچمِ secure رد و بدل می‌شدند.
احرازِ هویتِ خودِ API کوکی نیست و این‌ها فقط برای ادمین‌اند — یعنی دقیقاً
همان‌جایی که اعتبارنامه‌ی واقعی وارد می‌شود. حالا خارج از DEBUG امن‌اند، و
SECURE_PROXY_SSL_HEADER هم اضافه شد چون TLS روی nginx تمام می‌شود و بدونش
جنگو درخواست را HTTP می‌بیند و کوکیِ امن اصلاً ست نمی‌شود.

SECURE_SSL_REDIRECT و HSTS عمداً روشن نشدند: اولی پشتِ پراکسیِ بدتنظیم
حلقه‌ی ریدایرکت می‌سازد و کلِ سایت را می‌خواباند، دومی در مرورگرِ کاربر کش
می‌شود و عملاً برگشت‌ناپذیر است. هر دو تصمیمِ زیرساختی‌اند نه کد.

/api/health حالا ترافیک را هم گزارش می‌دهد: تعدادِ کاربر، فعال‌های ۲۴ ساعتِ
اخیر، و زمانِ آخرین فعالیت. تا امروز هیچ راهی نبود از بیرون بفهمیم فرانت
واقعاً به سرور رسیده یا نه — باید منتظر می‌ماندیم کسی بگوید. فقط شمارش و
زمان؛ تست جداگانه بررسی می‌کند هیچ شماره یا نامی بیرون نرود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 01:20:02 +03:30
39a9f435ac make the deploy script survive an unfamiliar server
دو حالتی که فقط روی سرورِ واقعی پیش می‌آیند، نه اینجا.

اسکریپت‌های قدیمیِ activate به PS1 بدونِ محافظ دست می‌زنند و زیرِ set -u با
«unbound variable» کلِ دیپلوی را می‌خوابانند — venv اینجا سالم است ولی venv
سرور لزوماً نه، پس set -u دورِ همان یک خط موقتاً خاموش می‌شود.

بررسیِ سلامت فقط 127.0.0.1:8000 را می‌زد. اگر gunicorn روی سوکت یا پورتِ
دیگری باشد، دیپلویِ کاملاً موفق هم با پیامِ خطا تمام می‌شد. حالا آدرسِ
عمومی هم امتحان می‌شود و در پایان می‌گوید کدام آدرس‌ها را زده.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:53:43 +03:30
a3a47b459d stop an existing .env from cancelling the defaults meant to replace it
پیش‌فرض‌های دیروز روی یک سرورِ تازه کار می‌کردند ولی روی سرورِ واقعی نه، و
تستِ با .env واقعی نشانش داد: هر دو حالتِ شایع خنثی‌شان می‌کرد.

مقدارِ موجود ولی ناقص: .env سرور از قبل
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 دارد. چون مقدار خالی نبود، کاملاً
جایگزینِ پیش‌فرض می‌شد و دامنه‌ی api.math.tadrisino2.ir باز هم غایب می‌ماند —
یعنی همان «Disallowed Host» قبلی، و باز هم ویرایشِ دستیِ فایل. حالا
دامنه‌های خودمان به مقدارِ محیطی اضافه می‌شوند نه جایگزینش؛ همین برای
CSRF_TRUSTED_ORIGINS و CORS_ALLOWED_ORIGINS هم اعمال شد.

مقدارِ موجود ولی خالی: PUBLIC_BASE_URL در .env هست و خالی است، و
os.environ.get فقط وقتی کلید *نباشد* پیش‌فرض می‌دهد. نتیجه‌اش paymentUrl
نسبی و callbackِ خالیِ زرین‌پال بود — خرابی‌ای که فقط موقعِ اولین پرداختِ
واقعی معلوم می‌شود. حالا مقدارِ خالی هم به پیش‌فرض می‌افتد.

این سه‌تا همان‌هایی‌اند که تا امروز مسئولِ سرور باید دستی به .env اضافه
می‌کرد و چون در .env.example هم نبودند، هر بار از نو کشف می‌شدند. حالا در
همان فایل مستند شده‌اند و تست نگهبانشان است.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:00:35 +03:30
3dacf3a50f fix two checks that only broke in the case they exist for
بازبینیِ کارِ امروز، سه مورد پیدا کرد.

/api/health وقتی مهاجرتِ اعمال‌نشده وجود داشت می‌شکست: کدِ فهرست‌کردنِ
مهاجرت‌ها به migration.key تکیه می‌کرد که اصلاً وجود ندارد (فیلدها app_label
و name هستند). یعنی دقیقاً در همان حالتی که این بررسی برایش ساخته شده،
به‌جای نامِ مهاجرت‌ها فقط AttributeError برمی‌گرداند. حالا تستی دارد که
مهاجرتِ معلق را شبیه‌سازی می‌کند — چیزی که نبودش باعث شد اولین بار غلط بماند.

وارد کردنِ سوال با replace عددِ غلط گزارش می‌کرد: delete() در جنگو مجموعِ
همه‌ی ردیف‌های آبشاری را می‌دهد، پس «۵ سوال پاک شد» در واقع گزینه‌ها و
پاسخ‌ها را هم می‌شمرد.

deploy.sh بررسیِ متغیرهای محیطی‌اش با تنظیماتِ جدید ناسازگار شده بود:
متغیرهایی را اجباری می‌دانست که حالا پیش‌فرض دارند، و فقط شل را می‌خواند نه
.env — یعنی روی سرورِ درست هم دیپلوی را رد می‌کرد. حالا به‌جای تکرارِ منطقِ
تنظیمات، از خودِ جنگو می‌پرسد چه چیزی مؤثر است و گزارش می‌دهد.

کوچک‌تر: بررسیِ سلامت یک درخواست می‌زند نه دو تا، و شکستِ ری‌استارت دیگر
اسکریپت را بی‌صدا تمام نمی‌کند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:42:36 +03:30
e5d2427560 keep one env example instead of two
دو فایلِ نمونه یعنی دو جا که باید هم‌زمان به‌روز بمانند، و همان چیزی بود که
قبلاً باعث شد نمونه عقب بماند و کسی که سرور را بالا می‌آورد نداند چه بنویسد.
حالا یک فایل، هم برای ماشینِ محلی هم برای سرور.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:15:42 +03:30
3e31fea42e decide in the repo what the server should not have to guess
فایلِ نمونه‌ی قبلی هنوز چند جای خالی داشت که کسی باید پرشان می‌کرد، و دو بار
همین باعثِ رفت‌وبرگشت شد. حالا فقط چیزهایی جای خالی‌اند که واقعاً فقط آن طرف
می‌داند: کلیدهای سرویس‌های بیرونی و نامِ سرویسِ systemd.

بقیه از قبل تصمیم گرفته شده‌اند و اگر فایل خالی هم باشد سرویس بالا می‌آید.
حالتِ پیش‌فرض عمداً مرحله‌ی توسعه است (DEBUG=1) با ریسک‌هایش نوشته، نه
پروداکشن — چون وضعیتِ امروزِ پروژه همین است و فایلی که وضعیتِ واقعی را
توصیف نکند دوباره حدس‌زدنی می‌شود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:09:12 +03:30
37d66a4a4c give content editors a drill-down browser and per-question signals
پیدا کردنِ «سوال‌های فلان مبحث» در فهرستِ پیش‌فرضِ جنگو عملاً ممکن نبود: باید
بینِ چند صفحه‌ی جدا با دراپ‌داون‌های هزارتایی بالا و پایین می‌رفتی.

/admin/content/question/browse/ چهار ستونِ آبشاری دارد — پایه → فصل → درس →
مبحث — و هر ستون فقط وقتی پر می‌شود که قبلی انتخاب شده باشد، پس هیچ‌وقت
فهرستِ هزارتایی جلوی چشم نیست. ته‌اش سوال‌های همان مبحث با گزینه‌هایشان
می‌آیند و از همان‌جا ویرایش یا اضافه می‌شوند.

کنارِ هر سوال دو عددِ تصمیم‌ساز: گزارش‌های بازِ کاربران، و درصدِ پاسخِ غلط.
درصد زیرِ ۵ پاسخ نشان داده نمی‌شود — یک نمونه‌ی کوچک سوالِ سالم را
«مشکل‌دار» جلوه می‌دهد. همین دو ستون به فهرستِ سوال‌ها هم اضافه شدند.

content.QuestionReport و POST /api/questions/{id}/report تازه‌اند: تنها
بازخوردی که تا امروز جمع می‌شد مالِ چتِ دستیار بود، و روی سوال‌های مسیرِ
آموزشی هیچ راهی نبود که کاربر بگوید گزینه‌ی درست غلط است. گزارشِ بازِ تکراریِ
همان کاربر ردیفِ تازه نمی‌سازد تا دابل‌کلیک آمار را به‌هم نزند.

وابستگیِ تازه‌ای اضافه نشد: چند اندپوینتِ کوچکِ JSON و JavaScript ساده.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:08:00 +03:30
67361f6711 boot with sane defaults instead of refusing to start
سه بار پشتِ سر هم همین الگو تکرار شد: یک متغیرِ محیطی ست نبود، جنگو موقعِ
بالا آمدن مرد، و از بیرون فقط «502 Bad Gateway» دیده شد. کسی که سرور را بالا
می‌آورد هیچ سرنخی نداشت، و تشخیص هر بار یک رفت‌وبرگشتِ یک‌روزه بود.

ALLOWED_HOSTS و CSRF_TRUSTED_ORIGINS حالا دامنه‌های شناخته‌شده‌ی خودمان را
پیش‌فرض دارند. CSRF مخصوصاً مهم است: بدونش ورود به پنلِ ادمین از پشتِ پراکسیِ
HTTPS «CSRF verification failed» می‌دهد و علتش از پیام معلوم نیست.

SECRET_KEY اگر ست نباشد، در پروداکشن یک کلیدِ تصادفیِ واقعی ساخته و در
.django_secret_key نگه داشته می‌شود (۰۶۰۰، خارج از گیت). همان‌قدر
غیرقابلِ حدس، بدونِ یک قدمِ دستیِ دیگر؛ و چون روی دیسک می‌ماند، ری‌استارت
نشست‌های کاربران را باطل نمی‌کند.

CACHE_URL نبودن دیگر مانعِ بالا آمدن نیست. مشترک نبودنِ کش بینِ workerها یک
ضعفِ واقعی‌ست ولی دلیلِ خوبی برای خوابیدنِ کلِ سرویس نیست؛ حالا بالا می‌آید و
/api/health صریحاً هشدار می‌دهد. تستِ مربوطه هم از «باید بالا نیاید» به
«باید بالا بیاید ولی بی‌صدا نماند» تغییر کرد.

متغیرِ محیطی همچنان بر همه‌ی این پیش‌فرض‌ها اولویت دارد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:08:00 +03:30
9cee574fa9 put the whole content pipeline behind the admin panel
سوال‌ها از قبل فله‌ای وارد می‌شدند ولی دو شکاف مانده بود: افزودنِ تکی عملاً
غیرقابلِ استفاده بود، و خودِ برنامه‌ی درسی هیچ راهی جز ترمینالِ سرور نداشت.

افزودنِ تکیِ سوال: انتخابِ درس یک دراپ‌داونِ ۱۳۰۷تایی بود. حالا autocomplete
است و بر اساسِ عنوانِ درس، فصل و پایه جستجو می‌شود.

برنامه‌ی درسی (Grade → Unit → Node → Lesson) حالا از /admin/content/grade/
وارد و خارج می‌شود. تا امروز رساندنش به سرور یعنی اجرای rag_import_collection
و بعد seed_curriculum_from_textbook روی همان سرور — یعنی وابستگی به Qdrant و
به آدمی با دسترسیِ SSH. حالا یک‌بار لوکال ساخته می‌شود و فایلش بالا می‌رود.

هم‌سان‌سازی با عنوان است نه با id: idها بینِ دو دیتابیس یکی نیستند و تکیه بر
آن‌ها یعنی وارد کردن روی سرور محتوای بی‌ربط را بازنویسی می‌کند.

وارد کردنِ برنامه‌ی درسی عمداً چیزی پاک نمی‌کند و idempotent است — یک فایلِ
ناقص نباید محتوای سرور را از بین ببرد، و اجرای دوباره نباید چیزی را دو بار
بسازد. (وارد کردنِ سوال‌ها اما replace دارد، چون جایگزینیِ سوال‌های یک درس
کارِ رایجی است.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:46:50 +03:30
eaa4b761dd stop a deploy from silently blocking the frontend or leaving 0000 open
سه حالتِ خراب که هیچ‌کدام در لاگِ سرور ردی نمی‌گذارند و فقط وقتی کشف می‌شدند
که یک آدم از بیرون اپ را دستی امتحان کند.

CORS_ALLOWED_ORIGINS فقط از env خوانده می‌شد و با DEBUG روشن به localhost
محدود می‌ماند. یعنی بک‌اندِ کاملاً سالم، ولی مرورگر همه‌ی درخواست‌های
math.tadrisino2.ir را بلاک می‌کند — خطایی که سمتِ سرور دیده نمی‌شود و فقط در
کنسولِ مرورگر پیداست. origin های شناخته‌شده حالا پیش‌فرضِ داخلِ کدند تا یک
متغیرِ فراموش‌شده اتصال را نشکند؛ env همچنان بازنویسی‌شان می‌کند.

/api/health حالا warnings هم می‌دهد. مهم‌ترینش: با DEBUG روشن روی سرورِ عمومی،
DEV_MASTER_OTP یعنی هر کسی با کدِ 0000 می‌تواند به‌جای هر شماره‌ای وارد شود.
این تا امروز فقط با خواندنِ تنظیماتِ سرور معلوم می‌شد، و ما به سرور دسترسی
نداریم. allowedOrigins هم برگردانده می‌شود تا مشکلِ CORS از بیرون قابلِ
تشخیص باشد.

deploy.sh قبل از دست زدن به دیتابیس متغیرهای لازم را بررسی می‌کند و با پیامِ
روشن می‌ایستد، به‌جای اینکه وسطِ کار بشکند و 502 بدهد.
.env.production.example همان‌ها را یک‌جا مستند می‌کند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:46:23 +03:30
190bff3d0f manage questions from the admin panel, not the server terminal
تا الان تنها راهِ گذاشتنِ سوال روی یک سرور، زدنِ دستور در ترمینالِ همان سرور
بود. یعنی هر تغییرِ محتوایی به کسی که SSH دارد وابسته می‌شد، تاریخچه نداشت و
برگشت‌پذیر نبود. محتوا داده است نه کد؛ باید با رابطِ کاربری ویرایش شود.

در /admin/content/question/ دکمه‌ی «وارد کردنِ سوال‌ها از فایل» اضافه شد، و
اکشنِ «دریافتِ فایلِ سوال‌های انتخاب‌شده» خروجی می‌دهد. خروجی دقیقاً همان قالبی
است که ورودی می‌پذیرد، پس چرخه‌ی «بگیر → ویرایش کن → برگردان» بسته است و برای
گرفتنِ نمونه هم لازم نیست کسی مستندات بخواند.

قالب عمداً ساده است (lessonId، prompt، choices، correct) تا کسی که برنامه‌نویس
نیست هم بتواند در ویرایشگرِ متن درستش کند.

اعتبارسنجی قبل از هر نوشتنی کامل انجام می‌شود و کلِ فایل در یک تراکنش است:
فایلِ نامعتبر هیچ چیزی نمی‌نویسد. وارد کردنِ نیمه‌کاره بدترین حالت بود، چون
کسی که فایل را داده فکر می‌کند کار شده ولی درس نصفه پر است.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:29:24 +03:30
cbe9b63dc4 make a deploy observable and one command instead of a checklist
بالا آوردنِ نسخه‌ی جدید تا الان چند کارِ دستی به ترتیبِ درست بود (pull، نصبِ
وابستگی‌ها، مهاجرت، استاتیک، ری‌استارت) و فراموش شدنِ هر کدام اپ را می‌خواباند
با خطایی که از بیرون فقط «502 Bad Gateway» دیده می‌شد. همین الان هم سرور
دقیقاً به همین دلیل خوابیده: کامیتِ ac1b1af پکیجِ django-cors-headers را اضافه
کرد، و بدونِ pip install جنگو با ModuleNotFoundError بالا نمی‌آید.

deploy.sh همه را به ترتیب و idempotent انجام می‌دهد. pip install همیشه اجرا
می‌شود، نه فقط وقتی requirements عوض شده — تشخیصِ «عوض شده یا نه» خودش یک جای
فراموش‌شدنیِ دیگر است. آخرِ کار خودش سلامت را بررسی می‌کند و اگر بالا نیامده
باشد علت‌های رایج را چاپ می‌کند، به‌جای اینکه کسی بعداً از بیرون کشفش کند.

GET /api/health وضعیت را بدونِ احراز و بدونِ SSH بیرون می‌دهد: دیتابیس،
مهاجرت‌های اعمال‌نشده، کش، و شمارشِ پایه/درس/سوال روی همان سرور. مهم‌ترینش
مهاجرتِ اعمال‌نشده است — حالتی که اپ ظاهراً بالا می‌آید و بعد زیرِ بارِ واقعی
با خطای ستونِ ناموجود می‌شکند. عمداً هیچ سکرتی برنمی‌گرداند، فقط بله/خیرِ
«تنظیم شده».

۵۰۳ برمی‌گرداند وقتی دیتابیس/کش/مهاجرت خراب است تا پایشِ خودکار بتواند رویش
حساب کند؛ نبودِ محتوا خرابی حساب نمی‌شود ولی گزارش می‌شود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:28:59 +03:30
5f3aae0756 cover the new paths, especially the ones that fail silently
خطاهای این پروژه خاموش‌اند: نامِ اشتباهِ collection یا فیلترِ نخورده نتیجه‌ی
خالی می‌دهد نه استثنا، و دستیار فقط بدتر جواب می‌دهد. این تست‌ها نگهبانِ
همان‌جاهایند.

TextbookLayoutTests بررسی می‌کند رشته‌ی هر فصل بینِ ingest و retrieval یکی
باشد و بازه‌ی صفحه‌ها پیوسته بماند. IngestBookResumeTests مطمئن می‌شود
صفحه‌ی بی‌مسئله دوبار پول نمی‌گیرد، صفحه‌ی خطادار در اجرای بعد دوباره
سراغش می‌روند، و upsertِ شکست‌خورده rollback می‌شود.
LessonContextResolutionTests نگهبانِ نرمال‌سازیِ «پایه نهم» → «ریاضی نهم»
است و اینکه مسیرِ gradeId و مسیرِ lessonId به یک collection برسند.
GradeAwarePromptTests جلوی برگشتنِ لحنِ دبستانی برای پایه‌های بالا را
می‌گیرد. FollowUpStaysGroundedTests پیامِ دومِ یک گفتگو را هم مستند نگه
می‌دارد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:01:24 +03:30
ac1b1af398 open the API up to the frontend
سینیور خواست زودتر به فرانت‌اند وصل شویم تا از آنجا هم تست شود. این‌ها
سمتِ ما لازم بودند:

CORS با allowlist صریح (نه ALLOW_ALL): احرازِ هویتِ ما توکنِ Bearer در هدر
است، پس origin ناشناس نباید از مرورگرِ کاربر به API ما بزند.
CORS_ALLOW_CREDENTIALS عمداً خاموش می‌ماند — کوکی‌ای در کار نیست و روشن
کردنش فقط سطحِ حمله اضافه می‌کند. CorsMiddleware بالاتر از CommonMiddleware
گذاشته شد وگرنه ریدایرکتِ APPEND_SLASH و خطای ۴۰۰ هدرِ CORS نمی‌گیرند و در
مرورگر به‌شکلِ خطای مبهمِ شبکه دیده می‌شوند.

PATCH /api/user/profile اضافه شد؛ تا پیش از این هیچ راهی برای ثبتِ نامِ
کاربر نبود. GET /api/home بدونِ پارامتر حالا پایه‌ی ذخیره‌شده‌ی کاربر را
برمی‌گرداند و POST /api/ai/ask فقط با lessonId کار می‌کند — هر دو برای
اینکه فرانت لازم نباشد پایه را خودش نگه دارد. questionCount به
lessons/{id}/intro اضافه شد.

docs/frontend/api.ts و README.md کلاینتِ تایپ‌دار و راهنمای اتصال‌اند، از
روی OpenAPI واقعیِ همین سرویس تولید شده‌اند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:01:06 +03:30
fa56308a35 make the tutor grade-aware and its answers measurable
مهم‌ترین خواسته‌ی سینیور این بود که بسنجیم پاسخِ AI واقعاً یاد می‌دهد یا نه.
برای سنجیدن، اول باید پاسخ‌ها قابلِ ردیابی می‌شدند.

پرامپت‌ها پایه‌آگاه شدند: تا پیش از این «معلمِ دبستانی» برای پایه نهم هاردکد
بود، هم در تولیدِ پاسخ و هم در vision — یعنی دانش‌آموزِ نهم لحنِ ابتدایی
می‌گرفت. PROMPT_VERSION حالا روی هر پیام ذخیره می‌شود.

هر پاسخ حاشیه‌نویسی می‌شود: مدل، نسخه‌ی پرامپت، پایه، collection، مقدارِ
فیلتر، تعداد chunk، توکن، تأخیر و from_cache. فیدبک reason اختیاری گرفت تا
لایک/دیسلایک بعداً قابلِ تحلیل باشد («بشه چک کرد کدوم بهتر بوده»).

ai_feedback_report --by prompt_version نسبتِ رضایت را کنارِ مصرف/هزینه
می‌گذارد. rag_eval سوال‌های واقعی را از مسیرِ واقعی می‌پرسد، با
--retrieval-only برای اجرای ارزان.

rag/services/lesson_context.py از lessonId تنها پایه و فصل را درمی‌آورد؛
normalize_grade_title هم اختلافِ «پایه نهم» در دیتابیس با «ریاضی نهم» در
نامِ collection را می‌بندد — بدونِ آن مسیرِ gradeId بی‌سروصدا خالی برمی‌گشت.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:00:40 +03:30
78130e7194 build the real curriculum from the textbook structure
تا پیش از این کلِ محتوای دیتابیس ردیف‌های [DEMO] و [SEED] بود — دانش‌آموزِ
پایه نهم در دمو «جمع تا ۱۰» می‌دید. سینیور «یک بیسیکِ کامل با دیتای واقعی»
خواسته بود.

seed_curriculum_from_textbook (رایگان، هیچ فراخوانیِ پولی ندارد):
    Grade  ← پایه‌ی کتاب
    Unit   ← فصلِ کتاب
    Node   ← درسِ داخلِ فصل، از فهرستِ خودِ کتاب
    Lesson ← نوعِ مسئله‌ی استخراج‌شده در بازه‌ی صفحه‌ی همان درس
intro_text مستقیم از generalSolution کتاب می‌آید.

generate_questions_from_chunks سوالِ چهارگزینه‌ای با گزینه‌های انحرافیِ
باورپذیر می‌سازد (هزینه‌دار، اعتبارسنجیِ سخت‌گیرانه). سوال اینجا جدا شد
چون ساختنِ گزینه‌ی انحرافیِ درست به مدل نیاز دارد.

purge_demo_data ردیف‌های [SEED]/[DEMO] را گزارش و — فقط با --apply — پاک
می‌کند؛ بدونِ آن این ردیف‌ها در دمو داخلِ API دیده می‌شوند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:00:10 +03:30
c79be0a336 ingest whole textbooks chapter by chapter, resumably
نقشه‌ی فیزیکیِ کتاب‌ها (افستِ صفحه، بازه‌ی هر فصل، درس‌های داخلِ فصل از
فهرستِ خودِ کتاب) در rag/services/textbooks.py ثبت شد تا عنوانِ فصل از
کتاب بیاید نه از حدسِ مدل — رشته‌ی فصل باید دقیقاً بینِ ingest و retrieval
یکی باشد وگرنه نتیجه بی‌سروصدا خالی برمی‌گردد.

rag_ingest_book کتاب را فصل‌به‌فصل ingest می‌کند و قابلِ ازسرگیری است:
rag.IngestedPage نشانه‌ی «این صفحه پردازش شد» را نگه می‌دارد حتی وقتی
صفحه chunkی نساخته، وگرنه هر بار اجرای دوباره بابتِ صفحه‌های بی‌مسئله
دوباره پول می‌گرفت. --plan هزینه را رایگان برآورد می‌کند.

rag_import_collection نقاطِ موجودِ Qdrant را در LessonChunk می‌ریزد —
فقط می‌خواند، رایگان و idempotent، برای پایه‌هایی که تیمِ دیگر ingest
کرده بود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 18:59:03 +03:30
52d9694583 keep ingested chunks findable and structured
Two bugs made self-ingested content useless, both silently:

- _payload wrote `chapter_number` (int) while math retrieval filters on
  `chapter` (the full "فصل ۲: کسر" string), so anything we ingest could
  never match a search — no error, just permanently invisible content.
- ingest_pdf flattened the vision extraction into one text blob and threw
  away type/keywords/generalSolution/problem/solution. LessonChunk is the
  source of truth, so rebuilding meant paying for vision again — and the
  problem/solution pairs are the input for building lesson questions.

LessonChunk now stores those fields and _payload emits them under the same
keys the other team's collections use, so ours and theirs stay one shape.

Also fixes a regression from the whitenoise commit: the Manifest storage
variant raises when staticfiles.json is absent, which broke all 23 admin
rendering tests (Django forces DEBUG=False in tests, so USE_FINDERS is off)
and would have taken the admin panel down entirely the day DEBUG=0 ships
without a collectstatic run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:24:18 +03:30
f0e3e3c96c serve static files via whitenoise under gunicorn
Admin/DRF/Swagger CSS and JS never load under gunicorn since Django's
staticfiles app only auto-serves through `runserver`. WHITENOISE_USE_FINDERS
is tied to DEBUG so no collectstatic step is needed while DEBUG=1.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 22:41:04 +03:30
092fcf13f2 add CSRF_TRUSTED_ORIGINS setting for reverse-proxied domains
Admin login (and any POST form) fails with "Origin checking failed"
behind a proxy that terminates TLS, since Django had no trusted-origins
list configured at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 22:26:46 +03:30
37a1741e0e add gamification characters (Duolingo-style mascot catalog)
New progress.Character model: an admin-managed catalog (same pattern as
subscription.Plan) of selectable mascots, each unlocked once the user's
XP crosses unlock_xp. No separate ownership table yet — unlocking is a
pure function of UserStats.xp, kept intentionally minimal since this is
the first of an incremental rollout the senior asked for.

- GET /api/characters — catalog with per-user unlocked/selected flags
- POST /api/characters/select — picks an active character, rejecting
  ones the user hasn't unlocked yet
- UserStats.selected_character_id tracks the pick
- seed_test_data now seeds 4 sample characters (owl/fox/turtle/lion)
  for local/staging testing

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 13:53:17 +03:30
b5476e1c5d connect Zarinpal as the real payment gateway
payment.services.call_payment_gateway was a stub returning a fake
payment.gateway/pay/{id} URL. It's replaced with a real Zarinpal
integration (payment/zarinpal.py, Payment API v4):

- create_order/CreateOrderView still never call Zarinpal directly —
  paymentUrl points at our own GET /api/payment/orders/{id}/pay
  (PaymentRedirectView), which requests the real Zarinpal authority on
  first visit and redirects to StartPay. This keeps order creation from
  depending on Zarinpal's uptime/latency, and means none of the existing
  purchase/idempotency tests needed to change.
- GET /api/payment/zarinpal/callback (ZarinpalCallbackView) is where
  Zarinpal redirects the user back to; it always re-verifies
  server-to-server via zarinpal.verify_payment before marking an order
  paid, so a forged Status=OK in the query string alone does nothing.
- zarinpal.py fails closed (503 / invalid) when ZARINPAL_MERCHANT_ID
  isn't configured, matching payment/bazaar.py's existing convention —
  never a fake successful payment.

Adds Order.gateway_authority, PUBLIC_BASE_URL/ZARINPAL_* settings, and
a payment_pay throttle scope.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 13:27:13 +03:30
a275eac7fd fix heart refill and grant unlimited hearts to active subscribers
next_refill_at was set on UserHearts but never read or recalculated,
so a heart lost to zero never came back. UserHearts.apply_regen() now
catches up missed 24h intervals on read/deduct, and lose_heart() starts
the timer only when it isn't already running.

Any user with an active (non-expired) subscription now skips heart
deduction entirely, regardless of which plan they're on — good enough
for now until per-plan perks are needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 13:26:49 +03:30
a367685b2e expose per-node lessonIds in GET /api/home
The skill-map response gave units -> nodes with id/title/status but no way
to map a node to its lessons, and no other endpoint fills that gap
(/api/progress/nodes doesn't either). Any client rendering the map needs
this to know which lesson to open when a node is tapped.

The ids are already computed in the cached _grade_tree; this just surfaces
them. Additive and backward-compatible; content tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 11:04:47 +03:30
4839123698 remove dead RAG tutor module and stale config notes
rag/services/tutor.py
  - deleted: answer_question() had no callers (AskAIView calls
    retrieve_context + generate_answer directly) and carried an unused
    import.
rag/services/config.py
  - docstring: drop the claim that the lesson-filter key is settings-driven
    (it is auto-selected per subject in known_collections.py).
.env.example
  - drop the leftover note about the removed RAG_QDRANT_LESSON_FILTER_KEY.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 13:02:43 +03:30
40b4dd53a9 cache the subscription plans list
subscription/views.py
  - PlansListView: served from cache (subscription:plans:v1, 1h TTL);
    plans change ~never and the list is read on every subscription screen.
subscription/receivers.py (new)
  - post_save/post_delete on Plan -> cache.delete(PLANS_CACHE_KEY).
subscription/apps.py
  - SubscriptionConfig.ready() imports receivers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 08:46:30 +03:30
d25ecab0b3 optional short-TTL cache for the auth user row
TadrisinoTokenAuthentication ran one SELECT on accounts_user for every
authenticated request. Off by default (AUTH_USER_CACHE_TTL=0) so behaviour
is unchanged until a deployment opts in.

accounts/authentication.py
  - _get_request_user(): when AUTH_USER_CACHE_TTL>0, cache all concrete
    field values under auth:user:<id> and rebuild a full User instance
    (so save(update_fields=[...]) stays safe); otherwise load from DB as
    before. invalidate_user_cache() / _load_user_or_fail() helpers.
accounts/receivers.py (new)
  - post_save/post_delete on User -> invalidate_user_cache(pk); covers
    every in-app write path (SetUserGrade, login, refresh, last_activity).
accounts/apps.py
  - AccountsConfig.ready() imports receivers.
accounts/tests.py
  - AuthUserRowCacheTests: disabled -> loads every request; enabled ->
    loads once then cache; User.save() invalidates; SetUserGradeView still
    persists and the next request sees the new grade.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 08:46:11 +03:30
ff57adbc5f throttle hot read endpoints and cap unbounded list responses
progress/views.py
  - GuestAwareReadThrottle (same guest-by-IP pattern as the other apps);
    throttle_scope='read' on UserSummaryView, StreakCalendarView,
    LeaderboardView.
notifications/views.py
  - GuestAwareReadThrottle + throttle_scope='read' on NotificationListView.
  - list capped at NOTIFICATIONS_MAX=100 newest (Meta.ordering already
    newest-first); unreadCount still counted over the full set.
notifications/tests.py
  - 150 rows -> 100 returned newest-first, unreadCount = 75.
rag/views.py
  - ChunkListView: explicit order_by('-id') before the existing [:200] cap.
ai_tutor/views.py
  - ConversationMessagesView: return the newest 200 messages in
    chronological order instead of the whole conversation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 08:45:43 +03:30
35218bf6a1 fix HomeView N+1 and cache the curriculum tree
GET /api/home is the most-hit authenticated endpoint (every app open) and
was running one COUNT per node. Now it is a flat number of queries
regardless of grade size, and the shared curriculum skeleton is served
from cache; per-user progress/stats stay live.

content/views.py
  - _node_status(): takes a precomputed set of completed lesson_ids and a
    node's lesson_ids instead of firing its own COUNT query.
  - HomeView.get(): one query for all completed lesson_ids of the user in
    the grade, status computed in Python; grade tree (units -> nodes ->
    lesson_ids, no per-user data) read from cache
    (content:tree:grade:{id}:v{N}); missing grade -> 404 (was an
    unhandled 500); grade with no units -> empty units, not 404.
  - GradesListView: served from cache (content:grades:v{N}).
  - curriculum_version() / bump_curriculum_version(): one shared version
    key; bumping it invalidates every content cache key at once.
  - both GET views get throttle_scope='read' + GuestAwareContentThrottle.
content/receivers.py
  - post_save/post_delete on Grade/Unit/Node/Lesson -> bump_curriculum_version().
content/tests.py
  - HomeViewScalabilityTests: query count flat for 2 vs 20 nodes; 2nd call
    hits cache (no content_unit/node SQL); a new Node invalidates the tree;
    missing grade 404; empty grade 200.
  - GradesListCacheTests: list cached, invalidated on new Grade.
  - ReadThrottleTests: /api/home returns 429 past the limit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 08:45:18 +03:30
eeb6bd6138 scale prep — persistent DB connections, read throttle, prod renderer, auth-cache knob
settings.py
  - DATABASES.default.CONN_MAX_AGE from DB_CONN_MAX_AGE env (default 60);
    harmless on SQLite, saves a TCP+auth handshake per request on Postgres.
  - DEFAULT_RENDERER_CLASSES: BrowsableAPIRenderer only when DEBUG — no
    HTML API browser or its overhead in production.
  - DEFAULT_THROTTLE_RATES['read'] = READ_THROTTLE_RATE env (default
    120/min): a cheap ceiling for the hot GET endpoints.
  - AUTH_USER_CACHE_TTL env (default 0 = off): TTL for the per-request
    user-row cache added in a later commit; 0 keeps current behaviour.
.env.example
  - document DB_CONN_MAX_AGE / AUTH_USER_CACHE_TTL / READ_THROTTLE_RATE.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 08:44:50 +03:30
40d358ecb5 readme: add RAG / AI-assistant section
README.md
  - `rag` app row in the app table.
  - new "دستیار هوش مصنوعی (RAG)" subsection: what /api/ai/ask does now,
    the OPENAI_/QDRANT_ keys it needs, how to test it in Swagger, the
    stream flag, rag_catalog, and the internal ingest endpoints.
  - production-notes bullet updated: /api/ai/ask is real RAG when keys are
    set, no longer "stub only".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 02:39:48 +03:30
149f1ad9ae return mascotMood on answer submission
The math-app frontend prototype drives a RobotMascot with a fixed set of
moods; this gives that animation an explicit backend signal. Complements
the existing Lesson.mascot_animation / LessonIntro.mascotAnimation, which
are unchanged.

serializers.py
  - AnswerResponseSerializer: new mascotMood field.
views.py
  - QuestionAnswerView: mascotMood = 'happy' for a correct answer else
    'encourage', on both the fresh response and the idempotent-replay
    response.
tests.py
  - QuestionAnswerMascotMoodTests: correct -> happy, wrong -> encourage,
    and the mood is preserved across an idempotent replay.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 02:30:50 +03:30
5416fbb2ba real RAG answers on POST /api/ai/ask, plus SSE streaming and answer cache
When RAG is configured the endpoint retrieves lesson-scoped chunks and
generates a grounded answer; otherwise the previous deterministic stub
runs unchanged, so keyless runs and existing tests are unaffected.
Quota / idempotency / user-row lock / clean-503 behaviour is preserved on
every path.

models.py
  - Conversation.subject (CharField) and Conversation.lesson_number
    (IntegerField): retrieval metadata sent on the first ask and reused
    for the rest of the conversation.
migrations/0004_conversation_lesson_number_conversation_subject.py
  - migration for the two new fields.
serializers.py
  - AskRequestSerializer: optional subject / grade / lessonNumber to pin
    the Qdrant collection and lesson/chapter.
  - AskResponseSerializer: usedChunks (retrieved [{title, summary}]) and
    retrievalDebug (collection / filterKey / chapter-or-lesson / counts,
    emitted only under DEBUG).
views.py
  - generate_tutor_answer(): unchanged signature; RAG-configured branch
    calls rag.services generation, stub branch otherwise.
  - AskAIView.post(): resolve subject/grade/lessonNumber (falling back to
    the Conversation), check the answer cache via resolve_target(), run
    retrieve_context + generate_answer on a miss and populate the cache,
    return usedChunks (+ retrievalDebug under DEBUG). Failures still
    release the reserved quota slot and surface as 503.
  - AskAIView._streaming_response(): opt-in "stream": true returns a
    text/event-stream of chunk / done / error events; same retrieval,
    phase-3 persist, idempotency and quota accounting as the JSON path,
    and it consults the answer cache first. Non-stream JSON contract is
    untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 02:30:33 +03:30
ff47c8d4ba register rag app, RAG provider settings, autoload .env
settings.py
  - load .env via python-dotenv (override=False) so local runs and the RAG
    keys work without `export`; production/docker env still wins.
  - add 'rag' to INSTALLED_APPS.
  - new RAG / AI provider block: OPENAI_API_KEY / OPENAI_BASE_URL (GapGPT)
    / OPENAI_CHAT_MODEL / OPENAI_VISION_MODEL / OPENAI_EMBEDDING_MODEL /
    OPENAI_TIMEOUT_SECONDS, QDRANT_URL / QDRANT_API_KEY,
    RAG_COLLECTION_NAME_TEMPLATE / RAG_GRADE_TITLE_OVERRIDES /
    RAG_RETRIEVAL_TOP_K / RAG_FORCE_MOCK / RAG_ANSWER_CACHE_TTL.
  - add 'rag_ingest' to DEFAULT_THROTTLE_RATES.
  - under `manage.py test`: force RAG_FORCE_MOCK on and RAG_ANSWER_CACHE_TTL
    to 0 so no test hits OpenAI/Qdrant or a warm cache.
urls.py
  - mount rag.urls under /api/.
requirements.txt
  - python-dotenv; openai, qdrant-client; pymupdf / pdf2image / pillow
    (optional, only for PDF ingestion).
.env.example
  - document the OPENAI_* / QDRANT_* / RAG_* keys, the discovered Qdrant
    collection names, and the parked OPENAI_API_KEY_OPENAI_DIRECT slot
    (no provider-switch logic by design).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 02:30:12 +03:30
ac93e17305 new app — cleaned-up RAG pipeline (Qdrant retrieval + GPT), ported from Rag_backend
Self-contained app; wired into settings/urls in the next commit. Data
stays in the parallel team's live Qdrant — nothing is copied into our DB.

apps.py
  - RagConfig.
models.py
  - LessonChunk: DB-of-record for one summarized lesson chunk
    (original_text + summary + embedding_vector) mirrored as a single
    Qdrant point; loose lesson_id/grade_id ints, no cross-app FK.
    LessonChunkImage for future multimodal lessons.
migrations/0001_initial.py
  - initial tables for the two models.
services/clients.py
  - lazy, lru_cache'd OpenAI + Qdrant clients (Rag_backend builds them at
    import time and crashes without keys). RagNotConfigured raised when a
    key/lib is missing; Qdrant client uses check_compatibility=False for
    the 1.19-client / 1.17-server minor version gap.
services/config.py
  - rag_is_configured(), collection-name template, grade-title resolution
    (RAG_GRADE_TITLE_OVERRIDES -> content.Grade.title), retrieval top-k.
services/known_collections.py
  - exact live Qdrant catalog: 6 collection names, math chapter-number ->
    exact "فصل N: ..." string tables per (subject, grade), and the
    علوم/اجتماعی lesson-number -> title maps. filter_key_for_subject()
    picks `chapter` for math, `lesson_number` otherwise;
    resolve_math_chapter() maps a plain chapter number (Persian or Latin
    digits) to the stored string.
services/embedding.py
  - embed_text() pinned to OPENAI_EMBEDDING_MODEL (text-embedding-3-small,
    1536d) so vectors match the other team's existing collections.
services/chunking.py
  - split_text_into_chunks(), summarize_chunk() (gpt-4o-mini, JSON out);
    plain exceptions instead of ninja HttpError.
services/qdrant_store.py
  - ensure_collection(), upsert_points(), search(): the lesson filter
    matches the value both as str and native, since Qdrant stores
    lesson_number as int and chapter as string.
services/retrieval.py
  - resolve_target() -> (collection, filter_key, filter_value), reused by
    the cache key and debug block.
  - retrieve_context() -> (chunks, meta): embed -> Qdrant search scoped to
    one lesson/chapter -> top-k. Math payloads (no `summary`) fall back to
    generalSolution/problem/solution; a 404 collection returns empty.
services/generation.py
  - generate_answer() and stream_answer() (token iterator for SSE);
    context-grounded Persian prompt that explains from retrieved text
    rather than refusing.
services/pdf.py
  - textbook PDF -> page images -> gpt-4o vision problem-type extraction
    (adapted from Rag_backend mathapp); returns dicts, no on-disk JSON.
    pymupdf/pdf2image optional — missing libs raise RagNotConfigured.
services/ingest.py
  - ingest_text() / ingest_pdf(): chunk -> summarize -> embed -> persist
    LessonChunk + upsert Qdrant, in one transaction.
services/answer_cache.py
  - short-TTL cache of the final answer keyed by
    sha1(collection | filter_value | normalized-question).
services/tutor.py
  - thin answer_question() (retrieve -> generate) for direct callers.
serializers.py, urls.py, views.py
  - POST /api/rag/ingest/text, POST /api/rag/ingest/pdf (HasInternalServiceKey,
    throttle scope rag_ingest), GET /api/rag/chunks review list.
    RagProviderUnavailable -> clean 503.
admin.py
  - LessonChunk admin with an inline for its images.
management/commands/rag_catalog.py
  - read-only live Qdrant listing (--values, --json); never writes.
tests.py
  - mocked-client tests: ask-with-retrieval, ingest, SSE stream, answer
    cache, and rag_is_configured() gating.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 02:29:54 +03:30
38999f3541 editor: add django-stubs/DRF-stubs for accurate type checking in VS Code 2026-08-26 04:04:36 +03:30
a33e575cfb readme: document docker exec usage for manage.py commands 2026-08-26 03:23:19 +03:30
683bd40169 cleanup: trim bloated multi-line comments across the project 2026-08-26 03:22:20 +03:30
83435c5a5f error-handling: add global exception handling, guard Bazaar API calls 2026-08-26 03:22:20 +03:30
49d596f077 readme: split into dev quick-start and production deploy guides 2026-08-26 03:22:20 +03:30
05ab6ffc36 admin & data: correct admin action redirect, add SQLite retry, seed_test_data command 2026-08-26 03:22:20 +03:30
f322d27e4f Merge remote-tracking branch 'origin/main' 2026-08-25 03:41:50 +03:30
a9200772a3 Add campaigns/ Add supervisor/ Add messaging features/ Separate SMS 2026-08-25 03:39:41 +03:30
7b9ab3adc3 Update README.md 2026-08-23 23:58:24 +03:30
80298b47b1 throttling, abuse-protection & logging/monitoring pass 2026-08-23 23:44:00 +03:30
cb18c93a3e Harden auth, AI tutor, payments, and subscriptions 2026-08-22 04:42:36 +03:30
f945f960c9 Update project 2026-08-19 17:15:55 +03:30
02d40e1238 Merge updated project structure with previous history 2026-08-18 16:54:53 +03:30
331148037e Initial commit - clean project structure 2026-08-18 16:48:15 +03:30
463 changed files with 975925 additions and 1245 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.git/
venv/
.venv/
__pycache__/
*.pyc
*.pyo
db.sqlite3
.env
*.log
.DS_Store

147
.env.example Normal file
View File

@@ -0,0 +1,147 @@
# نمونه‌ی تنظیماتِ بک‌اندِ تدریسینو — برای لپ‌تاپ و سرور.
#
# cp .env.example .env
#
# هیچ‌کدام برای بالا آمدنِ سرویس اجباری نیست: دامنه‌ها، CORS، کلیدِ امضا و کش در
# خودِ config/settings.py پیش‌فرضِ کارا دارند. مقدارهای این فایل با آن پیش‌فرض‌ها
# ادغام می‌شوند، جایگزینشان نمی‌شوند. شرحِ کامل: docs/operations.md.
#
# مقدارِ واقعیِ کلیدها فقط در .env (که در git نیست)، هرگز در این فایل یا در چت.
# ═══════════════════════════════════════════════════════════════════════
# ۱) پایه
# ═══════════════════════════════════════════════════════════════════════
# روی لپ‌تاپ 1، روی سرور همیشه 0.
# DEBUG=1 روی سرور یعنی کدِ ورودِ 0000 برای هر شماره‌ای و صفحه‌ی خطا با تنظیمات.
DJANGO_DEBUG=0
# خالی بماند: بارِ اول یک کلیدِ تصادفی ساخته و در .django_secret_key (0600) نگه
# داشته می‌شود. هرگز مقدارِ django-insecure-… داخلِ settings.py را این‌جا نگذارید.
DJANGO_SECRET_KEY=
# Redis برای محدودیتِ نرخِ مشترک بینِ workerها؛ خالی = کشِ حافظه‌ی هر worker
# (/api/health هشدار می‌دهد). مثال: redis://127.0.0.1:6379/1
CACHE_URL=
# 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=
# ═══════════════════════════════════════════════════════════════════════
# ۲) دامنه‌ها — دو آدرس، بقیه از آن‌ها ساخته می‌شوند
# ═══════════════════════════════════════════════════════════════════════
# 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=old.example.ir
# CSRF_TRUSTED_ORIGINS=https://old.example.ir
# CORS_ALLOWED_ORIGINS=https://old-app.example.ir
# تعدادِ پروکسی‌های جلوی اپ. سرورِ فعلی: ArvanCloud → nginx، یعنی 2.
# بیشتر از واقعیت = جعلِ IP و دور زدنِ محدودیتِ نرخ؛ کمتر = همه‌ی مهمان‌ها یک IP.
NUM_PROXIES=2
# ═══════════════════════════════════════════════════════════════════════
# ۳) پیامکِ کدِ ورود (کاوه‌نگار)
# ═══════════════════════════════════════════════════════════════════════
# بدونِ کلید، درخواستِ کد خطای صریحِ ۵۰۳ می‌دهد (نه موفقیتِ ساختگی).
KAVENEGAR_API_KEY=
# نامِ الگوی تأییدشده در پنلِ کاوه‌نگار (verify/lookup). روی سرورِ فعلی SMSVerify.
# خطِ این حساب پیامکِ متنِ آزاد را لغو می‌کند، پس بدونِ الگو کدی نمی‌رسد.
KAVENEGAR_OTP_TEMPLATE=
# خطِ اختصاصی؛ خالی = خطِ پیش‌فرضِ حساب.
KAVENEGAR_SENDER=
# شماره‌های تستیِ تیم با کدِ ثابت، بدونِ پیامک: «شماره:کد» با کاما.
# کد تصادفی باشد؛ پیش از کاربرِ واقعی خالی شود (/api/health تا آن‌وقت هشدار می‌دهد).
# TEST_LOGIN_PHONES=09120000001:834512,09120000002:110947
TEST_LOGIN_PHONES=
# ═══════════════════════════════════════════════════════════════════════
# ۴) دستیارِ هوش مصنوعی (RAG)
# ═══════════════════════════════════════════════════════════════════════
# بدونِ این دو کلید، POST /api/ai/ask پاسخِ آزمایشیِ ثابت (stub) می‌دهد.
# GapGPT پروکسیِ OpenAI است و فقط از داخلِ ایران جواب می‌دهد.
OPENAI_API_KEY=
OPENAI_BASE_URL=https://api.gapgpt.app/v1
QDRANT_URL=https://qdrant.tadrisino2.ir:443
QDRANT_API_KEY=
# مدل‌ها؛ پیش‌فرض‌ها در 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
# ═══════════════════════════════════════════════════════════════════════
# ۵) پرداخت
# ═══════════════════════════════════════════════════════════════════════
# زرین‌پال. بدونِ مرچنت، پرداخت ۵۰۳ می‌دهد. مرچنت باید برای دامنه‌ی PUBLIC_BASE_URL
# ثبت شده باشد، وگرنه زرین‌پال خطای -14 می‌دهد.
ZARINPAL_MERCHANT_ID=
# 1 = محیطِ آزمایشیِ زرین‌پال (بی‌پولِ واقعی). روی سرورِ فعلی خالی است: درگاهِ واقعی.
ZARINPAL_SANDBOX=
# کافه‌بازار (فقط خریدِ درون‌برنامه‌ایِ اپِ اندروید). روشِ ترجیحی توکنِ ثابتِ پیشخان
# است؛ سه متغیرِ 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

19
.gitignore vendored
View File

@@ -4,3 +4,22 @@ __pycache__/
db.sqlite3 db.sqlite3
.DS_Store .DS_Store
.env .env
# کتاب‌های درسی: خودِ PDFها در گیت نمی‌آیند (حجیم‌اند)
data/textbooks/*
!data/textbooks/README.md
# کلیدِ امضای تولیدشده‌ی خودکار (هرگز کامیت نشود)
.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

13
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,13 @@
{
"python.defaultInterpreterPath": "${workspaceFolder}/venv/bin/python",
"python.analysis.typeCheckingMode": "basic",
"python.analysis.diagnosticSeverityOverrides": {
// جنگو خیلی از attributeها (reverse relation ها مثل account.conversions،
// related_name، فیلدهای اضافه‌شده توسط متاکلاس) رو موقع اجرا می‌سازه،
// نه در زمان نوشتن کد؛ Pylance (برخلاف mypy) هیچ‌وقت نمی‌تونه این‌ها
// رو بشناسه، حتی با django-stubs. این محدودیت واقعی نیست، پس خاموشش
// می‌کنیم؛ بقیه‌ی هشدارهای واقعی (typo، آرگومان اشتباه، ایمپورت گمشده)
// دست‌نخورده می‌مونن.
"reportAttributeAccessIssue": "none"
}
}

444
CLAUDE.md Normal file
View File

@@ -0,0 +1,444 @@
# CLAUDE.md
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.
## The project in one paragraph
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 shared 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. You
may, but these machines are **shared company servers**, not ours. Our app is one tenant among many.
A careless command can take down the whole company's git, mail and other teams' production apps,
and there is no technical guard rail: our account has full root. **The only guard rail is you.**
### What is ours — and nothing else is
**`78.38.35.249` (`haghzad`, app server)**, account `administrator`:
| Ours | Note |
|---|---|
| `/home/administrator/mathapp-v2` | backend checkout; `.env`, `.django_secret_key`, `logs/`, `.deploy-backups/` are untracked and live here |
| `/home/administrator/mathapp_frontend_new` | frontend checkout; nginx serves its `dist/` |
| `gunicorn-mathapp.service` = alias **`zareian-mathapp`** | always type the alias |
| `zareian-redis.service` (port 6390) | our own Redis |
| Postgres cluster `14/zareian` (port 5440, DB `zareian_mathapp`) | our own cluster |
| `zareian-applog-archive.timer` | nightly log archive |
| nginx: `sites-enabled/mathapp`, `sites-available/mathapp-front`, `snippets/zareian-*.conf` | source of truth is `ops/nginx/` |
**Not ours, never touch:** `~/mathapp` (old, not git, not what runs), the generic `gunicorn.service`
(another team's app), `gitea`, `postfix`/`dovecot`, `named`, `mariadb`, `postgresql@14-main` (port 5432),
`redis-server` (6379), `rabbitmq`, `metabase`, `apirag`, `artinafront`, `metaverse`, `celerybeat`,
other people's home directories (`aria`, `artina`, `behdad`, `zahra`, `git`, `gitea`) and every
other nginx site. `metabase` and Artina's `celerybeat` crash-loop and eat ~3 of 8 cores; that is a
request to the server admin, not something to fix.
**`78.38.35.247` (backup server)**, same account and password: ours is only `~/zareian-pgbackup/` (249's
live copy), `~/zareian-pgbackrest-216/` (216's backups, encrypted on 216; 247 never holds the key), our lines in
`~/.ssh/authorized_keys` and `~/.pgpass`, `~/.ssh/zareian_*`, and the `zareian-pg-*` / `zareian-applog-pull` units. It also runs other teams' services and its
own `postgresql@14-main`, which we never use.
**`78.38.35.216` (hostname `mathapp`, new dedicated VM, not yet serving users)**, account `zareian` (sudo; the
senior and the company owner share `mathapp-admin`, also sudo; no other logins): the whole machine is ours. The app runs as the non-sudo user `mathapp` under `/home/mathapp/`; same unit names as on 249.
Both accounts use the password on the `AdminUser-Password` line of the password file (the `Password` line under
`NewServer` is older). State and the cut-over order:
`docs/operations.md` «سرورِ اختصاصیِ جدید». Until cut-over, 249 stays the live server and every rule below applies
there unchanged; on 216, rules 1, 5, 6, 8 and 10 still apply.
### 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/administrator/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
`~/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. A full disk
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
Access is a password for the shared `administrator` account (same for 249 and 247). The owner
keeps it in `~/Desktop/رمز سرور.txt` and wants agents to read it from there rather than ask in
chat; the auto-mode classifier may still block that read until the owner confirms in the
session. Take only the password line, everything after the first `: ` (stripping only the label
once sent the whole line and SSH refused it):
```bash
sed -n '/^Password/{s/^[^:]*: *//;p;q;}' ~/Desktop/'رمز سرور.txt' | tr -d '\r\n' > "$SCRATCH/.pw" && chmod 600 "$SCRATCH/.pw" # 249/247 (first Password line)
awk '/^AdminUser-Password/{sub(/^[^:]*: */,"");printf "%s",$0;exit}' ~/Desktop/'رمز سرور.txt' | tr -d '\r\n' > "$SCRATCH/.pwn" && chmod 600 "$SCRATCH/.pwn" # 216
sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 '<command>'
sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 'sudo -S -p "" <command>' < "$SCRATCH/.pw"
rm -P "$SCRATCH/.pw" # as soon as the work is done (macOS has no shred); recreate it next time
```
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/.pw" ssh administrator@78.38.35.249 'cd /home/administrator/mathapp-v2 && ./deploy.sh'
sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 'sudo -S -p "" systemctl restart zareian-mathapp' < "$SCRATCH/.pw"
```
`deploy.sh` 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. The
restart is separate because `sudo` inside the script cannot prompt. 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);
- `git -C /home/administrator/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 `cd /home/administrator/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 # needs DJANGO_DEBUG=1 locally (devOtp, DEV_MASTER_OTP)
python manage.py migrate
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
```
**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.
Docker (`docker compose up --build`) adds a local Loki + Alloy + Grafana log stack; dev only
(`docs/docker.md`).
## Architecture
| app | responsibility | key models |
|---|---|---|
| `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 249)
- Postgres cluster `14/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 (load test on the server, `tools/loadtest/play.py`): 300 players ≈ 88 req/s, median 0.1 s;
600 ≈ 137 req/s, median 1.4 s, no errors; 1,000 hits the ceiling (~135 req/s, p95 15 s). The bottleneck
is the shared CPU, not the DB pool. More capacity needs the neighbours' crash-loops fixed plus more cores
(then 8 workers with `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 (server 247)
Every Postgres change streams live to 247 (`pg_receivewal` over a restricted SSH tunnel, slot
`zareian_247`, async; `max_slot_wal_keep_size=1GB` protects 249's disk) plus a daily base backup (7 kept).
`deploy.sh` also takes a `pg_dump -Fc` before `migrate` (last 2 kept). Payment events are exported to CSV on
247 every 6 hours; the app log is archived nightly on 249 and pulled hourly to 247, where it is kept forever.
Restore procedure, monitoring and what was tested: `docs/operations.md` «بکاپ و بازیابی». Restoring onto 249
has been done before.
### 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.
- 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).

31
Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
# ---------------------------------------------------------------------
# Dockerfile برای اپلیکیشن Django (Tadrisino).
#
# ⚠️ این ایمیج فقط برای اجرای محلی/dev در کنارِ استکِ لاگِ متمرکز
# (docker-compose.yml) ساخته شده، نه یک تنظیمِ پروداکشن. عمداً همون چیزی
# که پروژه از قبل با `python manage.py runserver` اجرا می‌کرد رو نگه
# داشته (بدونِ اضافه‌کردنِ gunicorn/uWSGI یا هر dependency جدید)، چون
# هدفِ این تغییر فقط معرفیِ زیرساختِ Docker + لاگِ متمرکزه، نه بازطراحیِ
# نحوه‌ی serve شدنِ اپلیکیشن.
# ---------------------------------------------------------------------
FROM python:3.12-slim
# جلوگیری از نوشتنِ فایل‌های .pyc و بافر نشدنِ stdout/stderr — بدونِ این
# دومی، لاگ‌های JSON ممکنه با تاخیر (یا فقط موقعِ خروج) چاپ بشن.
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# اول فقط requirements.txt کپی می‌شه تا وقتی صرفاً کدِ اپلیکیشن تغییر
# می‌کنه (نه dependency ها)، لایه‌ی pip install از کشِ Docker استفاده کنه
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
# دستورِ واقعیِ اجرا (migrate + runserver) در docker-compose.yml تعریف
# شده، نه اینجا؛ این CMD فقط یک fallback برای اجرای مستقیمِ ایمیجه.
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

157
README.md
View File

@@ -1,72 +1,139 @@
Tadrisino API # Tadrisino API
این پروژه بخش API و سرویس‌های سمت سرور سامانه Tadrisino را شامل می‌شود و ساختار آن به‌صورت ماژولار طراحی شده است. بک‌اندِ **تدریسینو**، اپِ آموزشِ ریاضی به سبکِ دولینگو برای دانش‌آموزانِ پایه‌ی سوم تا دوازدهم. دانش‌آموز روی نقشه‌ای
از جزیره‌ها جلو می‌رود، هر جزیره آزمونی کوتاه از مسئله‌های خودِ کتابِ درسی است، و هر جا گیر کند از دستیارِ هوشِ
مصنوعی می‌پرسد که جوابش را از همان کتاب می‌سازد.
ساختار پروژه به گونه‌ای است که هر بخش اصلی سیستم در یک اپلیکیشن مستقل قرار گرفته و مدل‌ها، منطق مربوط به API، Serializerها، Viewها، URLها و Migrationهای موردنیاز هر بخش در همان اپ مدیریت می‌شوند. این مخزن یک 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 (جستجوی کتاب) · کاوه‌نگار (پیامک) · زرین‌پال و کافه‌بازار (پرداخت) |
ابتدا محیط مجازی را ایجاد و فعال کنید: ---
python3 -m venv venv ## امکانات
source venv/bin/activate
سپس وابستگی‌های پروژه را نصب کنید: | بخش | چه می‌کند |
|---|---|
| **ورود** | فقط با شماره‌ی موبایل و کدِ پیامکی؛ توکنِ دسترسیِ امضاشده‌ی یک‌ساعته و توکنِ تمدید |
| **برنامه‌ی درسی** | پایه ← کتاب ← فصل ← جزیره ← درس ← سوال؛ کلِ محتوا به‌صورتِ JSONِ کامیت‌شده در `content/seed/` |
| **بازی** | آزمونِ هر جزیره، قفلِ ترتیبی، قلب، ستاره و امتیاز، زنجیره‌ی روزانه، جدولِ رتبه‌بندی، همراه‌ها |
| **دستیار** | پاسخِ گام‌به‌گام از روی همان کتاب (Qdrant + GapGPT)، فقط ریاضی، با شکلِ توصیفی که اپ می‌کشد |
| **اشتراک** | فصلِ اولِ هر کتاب رایگان؛ اشتراکِ یک/سه/شش‌ماهه از زرین‌پال یا کافه‌بازار، با کدِ تخفیف و برنامه‌ی معرفی |
| **پنلِ ادمین** | ویرایشِ محتوا، ورود و خروجِ فله‌ای سوال، پلن‌ها، سفارش‌ها (لغو و بازپرداخت)، تیکت‌ها، گزارش‌های کاربران |
| **پایش** | `/api/health` (دیتابیس، مهاجرت، کش، سرویس‌های بیرونی، بکاپ)، لاگِ JSON برای هر درخواست، دفترِ رویدادِ پرداخت |
---
## شروعِ سریع
```bash
python3.11 -m venv venv && source venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
cp .env.example .env # برای توسعه‌ی محلی DJANGO_DEBUG=1 را ست کنید
اجرای Migrationها:
python manage.py migrate 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` خودکار خوانده می‌شود.
python manage.py createsuperuser | آدرس | چیست |
|---|---|
| `/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** بگذارید.
python manage.py runserver ```bash
python manage.py test --noinput # کلِ تست‌ها (سریال؛ --parallel روی macOS گیر می‌کند)
python manage.py test --noinput content # تست‌های یک اپ
python manage.py spectacular --file schema.yaml --format openapi # خروجیِ قرارداد برای فرانت
```
مستندات API برای استکِ لاگِ محلی (Loki و Grafana) [`docs/docker.md`](docs/docker.md) را ببینید.
پس از اجرای پروژه، مستندات API از طریق Swagger در دسترس است: ---
http://127.0.0.1:8000/api/docs/ ## معماری
مستندات Redoc: یک برنامه‌ی جنگو در قالبِ «مونولیتِ ماژولار»: هر اپ یک مسئولیت دارد و مدل‌های خودش را نگه می‌دارد.
http://127.0.0.1:8000/api/redoc/ | اپ | مسئولیت |
|---|---|
| `accounts` | هویت، ورود با کدِ پیامکی، نشست، پروفایل |
| `content` | کتاب‌ها، برنامه‌ی درسی، درس، سوال، آزمونِ جزیره، بازخوردِ کاربر |
| `progress` | آمار، زنجیره‌ی روزانه، قلب، رتبه‌بندی، همراه‌ها، گزارشِ هفتگی |
| `ai_tutor` · `rag` | دستیار: گفتگو، بازیابی از Qdrant، تولید با GapGPT، ورودِ کتاب |
| `subscription` · `payment` · `discount` · `affiliate` | پلن و اشتراک، سفارش و تراکنش، کدِ تخفیف، برنامه‌ی معرفی |
| `notifications` · `support` · `campaigns` · `statuslog` | اعلان، پشتیبانی، کمپین، لاگِ تغییرِ وضعیت |
| `config` | تنظیمات، مسیرها، middleware، سلامت |
پنل مدیریت: سه قاعده که از خواندنِ یک فایل پیدا نیست:
http://127.0.0.1:8000/admin/ - **احرازِ هویت سیستمِ خودِ پروژه است.** `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) آمده است.
config/ ---
accounts/
content/
progress/
ai_tutor/
subscription/
payment/
discount/
affiliate/
support/
notifications/
family/
بخش‌های مختلف پروژه شامل مدیریت حساب کاربری، محتوای آموزشی، پیشرفت و عملکرد کاربر، سیستم آموزشی، اشتراک، پرداخت، تخفیف، همکاری در فروش، پشتیبانی، اعلان‌ها و مدیریت اعضای خانواده هستند. ## استقرار
نکات فنی سرویس روی یک سرورِ مشترکِ شرکت اجرا می‌شود و مالِ این پروژه فقط پوشه‌ی `~/mathapp-v2` و سرویسِ
`zareian-mathapp` است. کلِ انتشار:
* ساختار پروژه به‌صورت ماژولار و قابل توسعه طراحی شده است. ```bash
* APIها از طریق مسیرهای مشخص و مستند قابل دسترسی هستند. ssh -t administrator@78.38.35.249 'cd ~/mathapp-v2 && ./deploy.sh; sudo systemctl restart zareian-mathapp'
* احراز هویت و مدیریت Session برای استفاده از APIها در نظر گرفته شده است. ```
* پنل مدیریت برای مدیریت داده‌های اصلی سیستم فراهم شده است.
* Migrationهای مربوط به مدل‌های پروژه در هر اپ نگهداری می‌شوند.
* ساختار فعلی پروژه امکان توسعه و اضافه شدن قابلیت‌های جدید را بدون ایجاد تغییرات گسترده در بخش‌های موجود فراهم می‌کند.
تست `deploy.sh` کد را با `origin/main` یکی می‌کند، وابستگی‌ها را نصب می‌کند، پیش از `migrate` از دیتابیس بکاپ می‌گیرد،
محتوا را seed و فایل‌های استاتیک را جمع می‌کند و سلامتِ سرویس را می‌سنجد. سپس از بیرون:
برای بررسی و تست APIها می‌توان مستقیماً از Swagger استفاده کرد. همچنین برای ایجاد و مدیریت داده‌های اولیه، پنل مدیریت Django در دسترس است. ```bash
curl https://mathapi.tadrisino2.ir/api/health # {"status": "ok"}
```
هر تغییرِ دیتابیس لحظه‌ای به سرورِ بکاپِ جداگانه‌ای می‌رود. رویه‌ی کامل (متغیرهای محیطی، بکاپ و بازیابی، nginx،
عوض کردنِ دامنه، فروش در کافه‌بازار): [`docs/operations.md`](docs/operations.md).
---
## مستندات
| سند | موضوع |
|---|---|
| [`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های کدنویس، به‌ویژه قواعدِ کار روی سرورِ مشترک |
---
## وضعیت
سرویس زنده است و با حساب‌های آزمونگر سنجیده می‌شود؛ هنوز به روی کاربرِ عمومی باز نشده. آنچه پیش از آن باید بسته
شود در [`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

@@ -1,27 +1,347 @@
from django.contrib import admin from datetime import datetime
from .models import OTPCode, Profile, Session, User from django import forms
from django.conf import settings
from django.contrib import admin, messages
from django.contrib.admin import helpers
from django.core.exceptions import PermissionDenied
from django.shortcuts import render
from django.utils import timezone
from .models import LoginEvent, OTPCode, Profile, Session, User
from .services import SMSDeliveryError, get_inactive_users, send_sms
class SendSMSForm(forms.Form):
TARGET_SELECTED = 'selected'
TARGET_INACTIVE = 'inactive'
TARGET_CHOICES = [
(TARGET_SELECTED, 'کاربرانِ انتخاب‌شده (همان‌هایی که در لیست تیک خورده‌اند)'),
(TARGET_INACTIVE, 'کاربرانِ غیرفعال (بر اساسِ تاریخِ آخرین فعالیت)'),
]
message = forms.CharField(
label='متن پیامک', widget=forms.Textarea(attrs={'rows': 4}), max_length=500,
)
target = forms.ChoiceField(
label='گیرندگان',
choices=TARGET_CHOICES,
widget=forms.RadioSelect,
initial=TARGET_SELECTED,
required=False,
)
inactive_since = forms.DateField(
label='غیرفعال از این تاریخ به بعد',
help_text='فقط وقتی گزینه‌ی «کاربرانِ غیرفعال» انتخاب شده باشد استفاده می‌شود.',
required=False,
widget=forms.DateInput(attrs={'type': 'date'}),
)
def clean(self):
cleaned_data = super().clean()
target = cleaned_data.get('target')
inactive_since = cleaned_data.get('inactive_since')
if target == self.TARGET_INACTIVE and not inactive_since:
self.add_error(
'inactive_since',
'برای گیرندگانِ «کاربرانِ غیرفعال» وارد کردنِ تاریخ الزامی است.',
)
return cleaned_data
@admin.register(User) @admin.register(User)
class UserAdmin(admin.ModelAdmin): class UserAdmin(admin.ModelAdmin):
list_display = ('id', 'name', 'phone', 'device_id', 'is_guest', 'grade_id', 'created_at') list_display = ('id', 'public_id', 'name', 'phone', 'grade_id', 'created_at', 'activity_link')
search_fields = ('name', 'phone', 'device_id') search_fields = ('name', 'phone')
list_filter = ('is_guest',)
@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 در دسترس بذارتش.
@admin.action(description='ارسال پیامک به کاربران انتخاب‌شده', permissions=['change'])
def send_sms_to_selected(self, request, queryset):
"""اکشنِ ادمین برای ارسالِ پیامک به کاربرانِ انتخاب‌شده یا غیرفعال؛
گیرنده با فیلدِ «target» انتخاب می‌شه (cutoffِ «غیرفعال» رو خودِ ادمین
در فرم وارد می‌کنه، چیزی hardcode نیست).
ارسال کاملاً synchronous و توی همون requestه (نه صفِ پس‌زمینه)، پس
اگر گیرنده‌ها از settings.ADMIN_SMS_MAX_RECIPIENTS بیشتر باشن، اکشن
هیچ پیامکی نمی‌فرسته تا request تایم‌اوت نکنه یا provider overload نشه.
شکستِ ارسال برای یک کاربر بقیه را متوقف نمی‌کنه.
"""
if 'apply' in request.POST:
form = SendSMSForm(request.POST)
if form.is_valid():
message = form.cleaned_data['message']
target = form.cleaned_data['target']
if target == SendSMSForm.TARGET_INACTIVE:
since = timezone.make_aware(
datetime.combine(form.cleaned_data['inactive_since'], datetime.min.time())
)
recipients = get_inactive_users(since)
else:
recipients = queryset
max_recipients = settings.ADMIN_SMS_MAX_RECIPIENTS
recipients_count = recipients.count()
if recipients_count > max_recipients:
self.message_user(
request,
(
f'تعدادِ گیرندگان ({recipients_count} کاربر) بیش از سقفِ مجازِ '
f'ارسالِ همزمان از ادمین ({max_recipients} کاربر) است؛ هیچ '
f'پیامکی ارسال نشد. لطفاً بازه/انتخاب را محدودتر کنید.'
),
level=messages.ERROR,
)
# سقوط به رندرِ مشترکِ پایین (نه return None) چون None از
# یک اکشنِ ادمین باعثِ ریدایرکتِ ۳۰۲ می‌شه، نه رندرِ مستقیم.
else:
sent_count = 0
failed_count = 0
skipped_no_phone = 0
for user in recipients:
if not user.phone:
skipped_no_phone += 1
continue
try:
send_sms(phone=user.phone, message=message)
except SMSDeliveryError:
failed_count += 1
else:
sent_count += 1
if sent_count:
self.message_user(
request, f'پیامک با موفقیت به {sent_count} کاربر ارسال شد.',
level=messages.SUCCESS,
)
if failed_count:
self.message_user(
request, f'ارسالِ پیامک برای {failed_count} کاربر ناموفق بود.',
level=messages.ERROR,
)
if skipped_no_phone:
self.message_user(
request,
f'{skipped_no_phone} کاربر بدونِ شماره‌تلفن رد شدن (پیامک ارسال نشد).',
level=messages.WARNING,
)
# سقوط به رندرِ مشترکِ پایین (نه return None)، هم‌الگو با
# شاخه‌ی cap-exceeded.
else:
form = SendSMSForm()
context = {
**self.admin_site.each_context(request),
'title': 'ارسال پیامک به کاربران انتخاب‌شده',
'opts': self.model._meta,
'users': queryset,
'form': form,
'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME,
}
return render(request, 'accounts/send_sms_form.html', context)
@admin.register(Profile) @admin.register(Profile)
class ProfileAdmin(admin.ModelAdmin): class ProfileAdmin(admin.ModelAdmin):
list_display = ('id', 'user', 'birth_year', 'parent_phone') list_display = ('id', 'user', 'birth_date', 'birth_year')
@admin.register(Session) @admin.register(Session)
class SessionAdmin(admin.ModelAdmin): class SessionAdmin(admin.ModelAdmin):
list_display = ('id', 'user', 'device_id', 'expires_at', 'revoked') list_display = ('id', 'user', 'expires_at', 'revoked')
list_filter = ('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) @admin.register(OTPCode)
class OTPCodeAdmin(admin.ModelAdmin): class OTPCodeAdmin(admin.ModelAdmin):
list_display = ('id', 'phone', 'code', 'expires_at', 'is_used') list_display = ('id', 'phone', 'code', 'expires_at', 'is_used')
list_filter = ('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
# پیش‌فرض — نه AdminSite جدا و بدون هیچ ModelAdmin جدید.
#
# read-only بودن با پنهون‌کردنِ لینک تضمین نمی‌شه، بلکه با پرمیشنِ استانداردِ
# جنگو: هر لینک فقط با view_<model> نشون داده می‌شه و حتی دسترسیِ مستقیم به
# URLِ change/add/delete هم توسطِ پرمیشنِ پیش‌فرضِ همون ModelAdminها رد می‌شه
# (گروهِ Supervisor فقط view_* داره). is_staff به‌تنهایی برای ورود کافی
# نیست؛ عضویت در گروهِ Supervisor هم چک می‌شه، وگرنه PermissionDenied.
SUPERVISOR_GROUP_NAME = 'Supervisor'
def supervisor_dashboard(request):
from payment.models import Order, Transaction
from subscription.models import Subscription
if not request.user.groups.filter(name=SUPERVISOR_GROUP_NAME).exists():
raise PermissionDenied
def _link(label, model):
opts = model._meta
return {
'label': label,
'url_name': f'admin:{opts.app_label}_{opts.model_name}_changelist',
'has_perm': request.user.has_perm(f'{opts.app_label}.view_{opts.model_name}'),
}
sections = [
{'title': 'کاربران', 'links': [_link('کاربران', User)]},
{'title': 'پرداخت‌ها', 'links': [_link('سفارش‌ها', Order), _link('تراکنش‌ها', Transaction)]},
{'title': 'اشتراک‌ها', 'links': [_link('اشتراک‌ها', Subscription)]},
]
context = {
**admin.site.each_context(request),
'title': 'ناظر (Supervisor)',
'sections': sections,
}
return render(request, 'admin/supervisor_dashboard.html', context)
_original_admin_get_urls = admin.site.get_urls
def _admin_get_urls_with_supervisor():
from django.urls import path
custom_urls = [
path('supervisor/', admin.site.admin_view(supervisor_dashboard), name='supervisor-dashboard'),
]
return custom_urls + _original_admin_get_urls()
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

@@ -6,5 +6,6 @@ class AccountsConfig(AppConfig):
name = 'accounts' name = 'accounts'
def ready(self): def ready(self):
# ثبت OpenApiAuthenticationExtension برای دکمه‌ی Authorize توی Swagger UI # Registers OpenApiAuthenticationExtension for the Swagger UI Authorize button.
from . import schema # noqa: F401 from . import schema # noqa: F401
from . import receivers # noqa: F401 (باطل‌کردنِ کشِ ردیفِ کاربر)

View File

@@ -1,5 +1,6 @@
from django.conf import settings from django.conf import settings
from django.core import signing from django.core import signing
from django.core.cache import cache
from rest_framework.authentication import BaseAuthentication from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed from rest_framework.exceptions import AuthenticationFailed
@@ -7,6 +8,48 @@ from .models import User
ACCESS_TOKEN_SALT = 'tadrisino-access-token' ACCESS_TOKEN_SALT = 'tadrisino-access-token'
# فیلدهای غیرِ‌رابطه‌ایِ User که کش می‌شن؛ همه‌ی concrete fieldها تا بازسازیِ
# instance برای save(update_fields=...) امن بمونه.
_USER_CACHE_FIELDS = [f.attname for f in User._meta.concrete_fields]
def _auth_user_cache_key(user_id: int) -> str:
return f'auth:user:{user_id}'
def invalidate_user_cache(user_id: int) -> None:
cache.delete(_auth_user_cache_key(user_id))
def _load_user_or_fail(user_id: int) -> User:
try:
return User.objects.get(pk=user_id)
except User.DoesNotExist:
raise AuthenticationFailed('کاربر پیدا نشد.')
def _get_request_user(user_id: int) -> User:
"""
ردیفِ کاربر برای request جاری. با AUTH_USER_CACHE_TTL>0 از کش خونده
می‌شه (یک SELECT کمتر در هر request authenticated). instanceِ بازسازی‌شده
کاملِ همه‌ی فیلدهاست، پس save(update_fields=[...]) روش امنه.
"""
ttl = getattr(settings, 'AUTH_USER_CACHE_TTL', 0)
if not ttl:
return _load_user_or_fail(user_id)
key = _auth_user_cache_key(user_id)
data = cache.get(key)
if data is None:
user = _load_user_or_fail(user_id)
cache.set(key, {f: getattr(user, f) for f in _USER_CACHE_FIELDS}, ttl)
return user
user = User(**data)
user._state.adding = False
user._state.db = 'default'
return user
def issue_access_token(user_id: int) -> str: def issue_access_token(user_id: int) -> str:
""" """
@@ -28,6 +71,14 @@ def decode_access_token(token: str) -> int:
return int(value) return int(value)
def _issued_at(token: str) -> int:
"""
ثانیه‌ی صدورِ توکن. قالبِ TimestampSigner «مقدار:زمان:امضا» است و زمان
base62؛ فقط بعد از unsignِ موفق صدا زده می‌شود، پس ساختار معتبر است.
"""
return signing.b62_decode(token.rsplit(':', 2)[-2])
class TadrisinoTokenAuthentication(BaseAuthentication): class TadrisinoTokenAuthentication(BaseAuthentication):
""" """
هدر مورد انتظار: Authorization: Bearer <access_token> هدر مورد انتظار: Authorization: Bearer <access_token>
@@ -48,12 +99,9 @@ class TadrisinoTokenAuthentication(BaseAuthentication):
token = parts[1] token = parts[1]
user_id = decode_access_token(token) user_id = decode_access_token(token)
user = _get_request_user(user_id)
try: if user.tokens_valid_after is not None and _issued_at(token) < int(user.tokens_valid_after.timestamp()):
user = User.objects.get(pk=user_id) raise AuthenticationFailed('از حساب خارج شده‌اید؛ دوباره وارد شوید.')
except User.DoesNotExist:
raise AuthenticationFailed('کاربر پیدا نشد.')
return (user, token) return (user, token)
def authenticate_header(self, request): def authenticate_header(self, request):

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

@@ -0,0 +1,434 @@
"""
یک دیتاست کوچک و واقعی برای تست دستی/API لوکال یا staging می‌سازه، طوری که
اجرای مکرر (repeat) هیچ رکورد تکراری‌ای نسازه — همه‌چیز با get_or_create روی
یک کلید طبیعی/یکتا (phone، code، idempotency_key و ...) انجام
می‌شه، نه با create ساده.
هدف: پوشش کمینه‌ی جریان‌های اصلی API در هر ۱۰ اپ (accounts، content،
progress، ai_tutor، subscription، payment، discount، notifications،
affiliate، support) با کمترین حجم داده‌ی لازم برای این‌که هر
endpoint اصلی بدون نیاز به ساختن دستی پیش‌نیازهاش قابل تست باشه.
⚠️ این دستور فقط برای محیط لوکال/staging است، نه پروداکشن:
- هیچ سکرت واقعی (کلید API واقعی کاوه‌نگار، gateway واقعی و ...) نمی‌سازه؛
Order/Transaction با provider/gateway_ref_id ساختگی و مقدارِ ثابت ساخته
می‌شن (شبیه‌سازیِ یک پرداختِ موفق، نه فراخوانیِ واقعیِ درگاه).
- برای جلوگیری از اجرای تصادفی روی یک دیتابیسِ پروداکشن، پیش‌فرض فقط وقتی
DEBUG=True اجرا می‌شه؛ برای اجرای صریح در حالت DEBUG=False (مثلاً یک
محیط staging که عمداً DEBUG خاموش نگه داشته می‌شه) باید `--force` بدید.
اجرا:
python manage.py seed_test_data
python manage.py seed_test_data --force # وقتی DEBUG=False (staging)
python manage.py seed_test_data --reset # پاک‌کردن دیتای قبلیِ همین seed و ساختِ دوباره
idempotent است: اجرای دوباره (بدون --reset) نه رکورد تکراری می‌سازه و نه
خطا می‌ده؛ فقط همون رکوردهای قبلی رو پیدا می‌کنه (get_or_create) و دوباره
گزارششون می‌ده. تمام رکوردهای ساخته‌شده با پیشوند «[SEED]» در فیلدهای
متنیِ قابل‌مشاهده (عنوان/نام) مشخص شدن تا با دیتای واقعی اشتباه گرفته
نشن و پاک‌سازیِ دستی هم راحت باشه.
"""
from datetime import timedelta
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
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, hash_refresh_token
# --- شناسه‌های ثابتِ داده‌ی seed --------------------------------------------
# شماره‌های 0912000000X عمداً خارج از بازه‌ی واقعیِ اپراتورهای ایران نیستن؛
# چون این فقط دیتای لوکال/staging است نه پروداکشن، همین کافیه. با پیشوند
# یکسان و بازه‌ی اختصاصی، شناساییِ رکوردهای seed در دیتابیس هم ساده می‌مونه.
MAIN_PHONE = '09120000001'
REFERRED_PHONE = '09120000004'
SEED_TAG = '[SEED]'
class Command(BaseCommand):
help = (
'یک دیتاست کوچک و idempotent برای تستِ دستی/API روی محیطِ لوکال یا '
'staging می‌سازه (کاربر، محتوای درسی، پیشرفت، اشتراک/پرداخت، تخفیف، '
'دستیار هوش مصنوعی، اعلان، افیلیت، پشتیبانی، خانواده). اجرای مکرر '
'رکورد تکراری نمی‌سازه.'
)
def add_arguments(self, parser):
parser.add_argument(
'--force',
action='store_true',
help='اجرای دستور حتی وقتی DEBUG=False است (مثلاً staging عمدی).',
)
parser.add_argument(
'--reset',
action='store_true',
help='قبل از ساخت، فقط همون رکوردهای seed (بر اساس شماره/شناسه‌های ثابتِ بالا) حذف می‌شه.',
)
def handle(self, *args, **options):
if not settings.DEBUG and not options['force']:
raise CommandError(
'DEBUG=False است — این دستور برای دیتای پروداکشن نیست. اگه '
'واقعاً می‌خواید روی این محیط (مثلاً staging) اجرا بشه، '
'--force بدید.'
)
with transaction.atomic():
if options['reset']:
self._reset()
summary = self._seed()
self.stdout.write(self.style.SUCCESS('\nseed_test_data با موفقیت تمام شد.'))
self.stdout.write('\nکاربرهای تستی (برای Authorize در Swagger، فقط خودِ token را بدون "Bearer" وارد کنید):')
for label, user, token in summary['tokens']:
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/login گرفته بشن.'
)
# -- reset ----------------------------------------------------------
def _reset(self):
"""فقط دقیقاً همون رکوردهایی که این دستور می‌سازه رو (بر اساس کلیدهای ثابتِ بالا) حذف می‌کنه."""
from ai_tutor.models import Conversation
from affiliate.models import AffiliateAccount, Conversion
from content.models import Grade, NodeProgress, UserAnswer, UserLessonCompletion
from discount.models import DiscountCode, Offer, Redemption, UserOffer
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, REFERRED_PHONE]
user_ids = list(User.objects.filter(phone__in=phones).values_list('pk', flat=True))
if user_ids:
UserAnswer.objects.filter(user_id__in=user_ids).delete()
UserLessonCompletion.objects.filter(user_id__in=user_ids).delete()
NodeProgress.objects.filter(user_id__in=user_ids).delete()
UserStats.objects.filter(user_id__in=user_ids).delete()
LeaderboardEntry.objects.filter(user_id__in=user_ids).delete()
StreakRecord.objects.filter(user_id__in=user_ids).delete()
UserHearts.objects.filter(user_id__in=user_ids).delete()
Conversation.objects.filter(user_id__in=user_ids).delete()
Subscription.objects.filter(user_id__in=user_ids).delete()
Order.objects.filter(user_id__in=user_ids).delete()
UserOffer.objects.filter(user_id__in=user_ids).delete()
Redemption.objects.filter(user_id__in=user_ids).delete()
Notification.objects.filter(user_id__in=user_ids).delete()
AffiliateAccount.objects.filter(user_id__in=user_ids).delete()
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()
from progress.models import Character
Character.objects.filter(slug__startswith='seed-').delete()
Plan.objects.filter(title__startswith=SEED_TAG).delete()
DiscountCode.objects.filter(code='SEEDTEST10').delete()
Offer.objects.filter(code='SEEDTRIAL7').delete()
Grade.objects.filter(title__startswith=SEED_TAG).delete() # cascades units/nodes/lessons/questions/choices
OTPCode.objects.filter(phone__in=phones).delete()
Session.objects.filter(user__phone__in=phones).delete()
User.objects.filter(phone__in=phones).delete()
self.stdout.write('رکوردهای قبلیِ seed حذف شدن؛ در حال ساختِ دوباره...')
# -- seed -------------------------------------------------------------
def _seed(self):
main_user, referred_user = self._seed_accounts()
self._seed_content_and_progress(main_user)
self._seed_characters()
self._seed_ai_tutor(main_user)
plan_monthly, plan_yearly = self._seed_subscription_and_payment(main_user)
self._seed_discount(main_user)
self._seed_notifications(main_user)
self._seed_affiliate(main_user, referred_user)
self._seed_support(main_user)
tokens = [
('کاربر اصلی', main_user, issue_access_token(main_user.pk)),
]
return {'tokens': tokens}
# -- accounts -----------------------------------------------------------
def _seed_accounts(self):
main_user, _ = User.objects.get_or_create(
phone=MAIN_PHONE,
defaults=dict(name=f'{SEED_TAG} کاربر اصلی', grade_id=None),
)
Profile.objects.get_or_create(
user=main_user, defaults=dict(birth_year=1394, bio=f'{SEED_TAG} پروفایل تستی')
)
Session.objects.get_or_create(
user=main_user,
refresh_token=hash_refresh_token(f'seed-refresh-{MAIN_PHONE}'),
defaults=dict(
expires_at=timezone.now() + timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
revoked=False,
),
)
referred_user, _ = User.objects.get_or_create(
phone=REFERRED_PHONE, defaults=dict(name=f'{SEED_TAG} کاربر معرفی‌شده')
)
self.stdout.write(self.style.SUCCESS('accounts: ۲ کاربر (اصلی/معرفی‌شده) آماده شد.'))
return main_user, referred_user
# -- content + progress --------------------------------------------------
def _seed_content_and_progress(self, main_user):
from content.models import Choice, Grade, Lesson, Node, NodeProgress, Question, Unit, UserAnswer, UserLessonCompletion
from progress.models import ActiveDay, LeaderboardEntry, StreakRecord, UserHearts, UserStats
grade, _ = Grade.objects.get_or_create(
title=f'{SEED_TAG} پایه سوم', defaults=dict(icon='grade-3', color='#4CAF50')
)
unit, _ = Unit.objects.get_or_create(grade=grade, title=f'{SEED_TAG} جمع و تفریق', defaults=dict(order=1))
node1, _ = Node.objects.get_or_create(unit=unit, title=f'{SEED_TAG} جمع اعداد یک‌رقمی', defaults=dict(order=1))
node2, _ = Node.objects.get_or_create(unit=unit, title=f'{SEED_TAG} تفریق اعداد یک‌رقمی', defaults=dict(order=2))
lesson1, _ = Lesson.objects.get_or_create(
node=node1,
title=f'{SEED_TAG} درس اول جمع',
defaults=dict(intro_text='دو عدد را با هم جمع کن.', mascot_animation='explain'),
)
lesson2, _ = Lesson.objects.get_or_create(
node=node2,
title=f'{SEED_TAG} درس اول تفریق',
defaults=dict(intro_text='یک عدد را از عدد دیگر کم کن.', mascot_animation='explain'),
)
question1, _ = Question.objects.get_or_create(
lesson=lesson1, prompt=f'{SEED_TAG} 2 + 3 = ?', defaults=dict(type='multiple_choice', asset_group='')
)
choices1 = [('4', False), ('5', True), ('6', False), ('7', False)]
correct_choice1 = None
for value, is_correct in choices1:
choice, _ = Choice.objects.get_or_create(question=question1, value=value, defaults=dict(is_correct=is_correct))
if is_correct:
correct_choice1 = choice
question2, _ = Question.objects.get_or_create(
lesson=lesson2, prompt=f'{SEED_TAG} 9 - 4 = ?', defaults=dict(type='multiple_choice', asset_group='')
)
for value, is_correct in [('4', False), ('5', True), ('6', False)]:
Choice.objects.get_or_create(question=question2, value=value, defaults=dict(is_correct=is_correct))
# پیشرفتِ کاربرِ اصلی: درسِ اول تمام‌شده، Nodeِ اول in_progress
UserAnswer.objects.get_or_create(
user_id=main_user.pk,
question=question1,
idempotency_key='seed-answer-1',
defaults=dict(choice=correct_choice1, correct=True, xp_earned=10),
)
UserLessonCompletion.objects.get_or_create(
user_id=main_user.pk,
lesson=lesson1,
idempotency_key='seed-completion-1',
defaults=dict(correct_count=1, stars=3, xp_earned=10, coins_earned=5),
)
NodeProgress.objects.get_or_create(
user_id=main_user.pk,
node=node1,
defaults=dict(
status='in_progress',
lessons_completed_count=1,
started_at=timezone.now() - timedelta(days=1),
last_activity_at=timezone.now(),
),
)
NodeProgress.objects.get_or_create(
user_id=main_user.pk,
node=node2,
defaults=dict(status='not_started', lessons_completed_count=0),
)
stats, _ = UserStats.objects.get_or_create(
user_id=main_user.pk, defaults=dict(xp=10, coins=5, progress_percent=12.5)
)
streak, _ = StreakRecord.objects.get_or_create(
user_id=main_user.pk, defaults=dict(current_streak=1, longest_streak=1)
)
ActiveDay.objects.get_or_create(streak=streak, date=timezone.now().date())
UserHearts.objects.get_or_create(user_id=main_user.pk, defaults=dict(current_hearts=5, max_hearts=5))
LeaderboardEntry.objects.get_or_create(
user_id=main_user.pk, period='weekly', defaults=dict(xp_in_period=10, rank=1)
)
LeaderboardEntry.objects.get_or_create(
user_id=main_user.pk, period='all_time', defaults=dict(xp_in_period=10, rank=1)
)
self.stdout.write(self.style.SUCCESS(
'content/progress: 1 پایه، 1 مبحث، 2 Node، 2 درس، 2 سوال و پیشرفتِ کاربرِ اصلی آماده شد.'
))
# -- گیمیفیکیشن: کاراکترها -------------------------------------------------
def _seed_characters(self):
from progress.models import Character
# unlock_xp پلکانی، هماهنگ با xp=10ی کاربرِ اصلی که بالاتر ساخته شد:
# فقط اولین کاراکتر (unlock_xp=0) براش باز است، بقیه هنوز قفلن —
# برای این‌که هم مسیرِ «باز» و هم مسیرِ «قفل» در تست دستی قابل‌دیدن باشه.
characters = [
('seed-owl', f'{SEED_TAG} جغد کنجکاو', 'owl', 0, 1),
('seed-fox', f'{SEED_TAG} روباه باهوش', 'fox', 100, 2),
('seed-turtle', f'{SEED_TAG} لاک‌پشت صبور', 'turtle', 300, 3),
('seed-lion', f'{SEED_TAG} شیر شجاع', 'lion', 600, 4),
]
for slug, name, icon, unlock_xp, order in characters:
Character.objects.get_or_create(
slug=slug, defaults=dict(name=name, icon=icon, unlock_xp=unlock_xp, order=order),
)
self.stdout.write(self.style.SUCCESS(f'progress: {len(characters)} کاراکترِ گیمیفیکیشن آماده شد.'))
# -- ai_tutor -------------------------------------------------------------
def _seed_ai_tutor(self, main_user):
from ai_tutor.models import Conversation, Message, MessageFeedback
conversation, _ = Conversation.objects.get_or_create(
user_id=main_user.pk, lesson_id=None, chapter_id=None, grade_id=None
)
user_msg, _ = Message.objects.get_or_create(
conversation=conversation,
role='user',
text=f'{SEED_TAG} چرا حاصل 2+3 برابر 5 است؟',
)
assistant_msg, created = Message.objects.get_or_create(
conversation=conversation,
role='assistant',
text=f'{SEED_TAG} چون وقتی 2 واحد و 3 واحد را کنار هم بگذاریم، 5 واحد می‌شود.',
)
MessageFeedback.objects.get_or_create(message=assistant_msg, defaults=dict(is_helpful=True))
self.stdout.write(self.style.SUCCESS('ai_tutor: 1 مکالمه با یک پیامِ کاربر و یک پاسخِ دستیار آماده شد.'))
# -- subscription + payment -----------------------------------------------
def _seed_subscription_and_payment(self, main_user):
from payment.models import Order, Transaction
from subscription.models import Plan, Subscription
plan_monthly, _ = Plan.objects.get_or_create(
title=f'{SEED_TAG} ماهانه',
defaults=dict(price=99000, currency='IRT', discount_percent=0, popular=False, duration_days=30),
)
plan_yearly, _ = Plan.objects.get_or_create(
title=f'{SEED_TAG} سالانه',
defaults=dict(price=799000, currency='IRT', discount_percent=20, popular=True, duration_days=365),
)
order, _ = Order.objects.get_or_create(
idempotency_key='seed-order-1',
defaults=dict(
user_id=main_user.pk,
amount=plan_monthly.price,
currency='IRT',
status='paid',
provider='gateway',
),
)
Transaction.objects.get_or_create(
order=order,
gateway='zarinpal',
gateway_ref_id='SEED-REF-0001',
defaults=dict(status='paid', paid_at=timezone.now()),
)
subscription, _ = Subscription.objects.get_or_create(
user_id=main_user.pk,
defaults=dict(
plan=plan_monthly,
status='active',
expires_at=timezone.now() + timedelta(days=plan_monthly.duration_days),
order_id=order.pk,
),
)
if order.subscription_id != subscription.pk:
order.subscription_id = subscription.pk
order.save(update_fields=['subscription_id'])
self.stdout.write(self.style.SUCCESS(
'subscription/payment: 2 پلن، 1 سفارشِ پرداخت‌شده و 1 اشتراکِ فعال آماده شد.'
))
return plan_monthly, plan_yearly
# -- discount ---------------------------------------------------------------
def _seed_discount(self, main_user):
from discount.models import DiscountCode, Offer
DiscountCode.objects.get_or_create(
code='SEEDTEST10',
defaults=dict(type='percent', value=10, max_uses=100, expires_at=timezone.now() + timedelta(days=90)),
)
Offer.objects.get_or_create(
code='SEEDTRIAL7',
defaults=dict(
name=f'{SEED_TAG} هفت روز رایگان',
kind='trial',
duration_days=7,
auto_assign_on_signup=False,
is_active=True,
),
)
self.stdout.write(self.style.SUCCESS('discount: 1 کدتخفیفِ فعال و 1 آفرِ trial آماده شد.'))
# -- notifications ------------------------------------------------------------
def _seed_notifications(self, main_user):
from notifications.models import Notification
Notification.objects.get_or_create(
user_id=main_user.pk,
type='xp_earned',
title=f'{SEED_TAG} امتیاز گرفتی',
defaults=dict(body='10 امتیاز از درس اول جمع گرفتی!', related_id=None, is_read=False),
)
Notification.objects.get_or_create(
user_id=main_user.pk,
type='order_paid',
title=f'{SEED_TAG} پرداخت موفق',
defaults=dict(body='اشتراک ماهانه‌ت فعال شد.', related_id=None, is_read=True),
)
self.stdout.write(self.style.SUCCESS('notifications: 2 اعلان (یکی خوانده‌نشده، یکی خوانده‌شده) آماده شد.'))
# -- affiliate ------------------------------------------------------------------
def _seed_affiliate(self, main_user, referred_user):
from affiliate.models import AffiliateAccount, Conversion
account, _ = AffiliateAccount.objects.get_or_create(
user_id=main_user.pk, defaults=dict(referral_code='SEEDREF01', commission_rate=0.1)
)
Conversion.objects.get_or_create(
referred_user_id=referred_user.pk,
defaults=dict(affiliate_account=account, status='pending', commission_amount=0),
)
self.stdout.write(self.style.SUCCESS('affiliate: 1 حساب افیلیت و 1 کانورژنِ در انتظار آماده شد.'))
# -- support ----------------------------------------------------------------------
def _seed_support(self, main_user):
from support.models import BugReport, Ticket, TicketMessage
ticket, _ = Ticket.objects.get_or_create(
user_id=main_user.pk,
subject=f'{SEED_TAG} مشکل در نمایش درس',
defaults=dict(category='content', status='open', priority='normal'),
)
TicketMessage.objects.get_or_create(
ticket=ticket, sender='user', text=f'{SEED_TAG} درسِ اول باز نمی‌شه.'
)
BugReport.objects.get_or_create(
user_id=main_user.pk,
title=f'{SEED_TAG} کرش هنگام باز کردن درس',
defaults=dict(description='بعد از زدن دکمه‌ی شروع، اپ بسته می‌شه.', status='open'),
)
self.stdout.write(self.style.SUCCESS('support: 1 تیکت با یک پیام، و 1 گزارش باگ آماده شد.'))

View File

@@ -0,0 +1,68 @@
"""
گروهِ Supervisor رو (اگه از قبل نبود) می‌سازه و *فقط* پرمیشنِ استانداردِ
view_* رو، دقیقاً روی همون چهار مدلی که صفحه‌ی /admin/supervisor/ بهشون
لینک می‌ده، بهش می‌ده: accounts.User، payment.Order، payment.Transaction،
subscription.Subscription. هیچ پرمیشنِ add/change/delete‌ای ست نمی‌شه.
اجرا (یک‌بار، بعد از migrate):
python manage.py setup_supervisor_group
idempotent است: هر بار که دوباره اجرا بشه، مجموعه‌ی پرمیشن‌های گروه رو
دقیقاً با همین لیست sync می‌کنه (group.permissions.set)، یعنی اگه کسی
دستی یک پرمیشنِ change/delete به این گروه اضافه کرده باشه، اجرای دوباره‌ی
این دستور خودش رو تصحیح می‌کنه.
پرمیشن‌های view_* توسطِ خودِ جنگو، بعد از migrate کاملِ هر اپ، اتوماتیک
ساخته می‌شن (django.contrib.auth's post_migrate signal)؛ برای همین این
منطق در یک management command جداگونه‌ست، نه یک data migration — چون در
لحظه‌ی اجرای یک data migration ممکنه اون پرمیشن‌ها هنوز ساخته نشده باشن.
"""
from django.contrib.auth.models import Group, Permission
from django.core.management.base import BaseCommand
from accounts.admin import SUPERVISOR_GROUP_NAME
SUPERVISOR_VIEW_PERMISSIONS = [
('accounts', 'user'),
('payment', 'order'),
('payment', 'transaction'),
('subscription', 'subscription'),
]
class Command(BaseCommand):
help = (
'گروهِ Supervisor رو می‌سازه/sync می‌کنه و فقط پرمیشنِ view_* رو '
'روی User/Order/Transaction/Subscription بهش می‌ده (بدونِ '
'add/change/delete). idempotent است.'
)
def handle(self, *args, **options):
group, created = Group.objects.get_or_create(name=SUPERVISOR_GROUP_NAME)
permissions = []
missing = []
for app_label, model_name in SUPERVISOR_VIEW_PERMISSIONS:
codename = f'view_{model_name}'
try:
permissions.append(
Permission.objects.get(content_type__app_label=app_label, codename=codename)
)
except Permission.DoesNotExist:
missing.append(f'{app_label}.{codename}')
group.permissions.set(permissions)
if created:
self.stdout.write(self.style.SUCCESS(f'گروهِ "{SUPERVISOR_GROUP_NAME}" ساخته شد.'))
else:
self.stdout.write(f'گروهِ "{SUPERVISOR_GROUP_NAME}" از قبل وجود داشت؛ پرمیشن‌ها sync شدن.')
self.stdout.write(
f'{len(permissions)} پرمیشنِ view-only به گروه اختصاص داده شد (بدونِ add/change/delete).'
)
if missing:
self.stderr.write(
'این پرمیشن‌ها پیدا نشدن (migrate کامل اجرا شده؟): ' + ', '.join(missing)
)

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.2.17 on 2026-08-19 10:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0001_initial'),
]
operations = [
migrations.AddConstraint(
model_name='user',
constraint=models.UniqueConstraint(condition=models.Q(('is_guest', True), models.Q(('device_id', None), _negated=True), models.Q(('device_id', ''), _negated=True)), fields=('device_id',), name='uniq_guest_device_id'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.17 on 2026-08-24 16:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0002_user_uniq_guest_device_id'),
]
operations = [
migrations.AddField(
model_name='user',
name='last_activity_at',
field=models.DateTimeField(blank=True, null=True),
),
]

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 from django.db import models
class User(models.Model): class User(models.Model):
""" """
کاربر اصلی سیستم (مهمان یا ثبت‌نام‌شده با شماره). کاربر اصلی سیستم: ثبت‌نام‌شده با شماره‌ی موبایل (تنها راه ورود).
توجه: این مدل، AUTH_USER_MODEL جنگو نیست؛ یک مدل بیزنسی مستقله که توجه: این مدل، AUTH_USER_MODEL جنگو نیست؛ یک مدل بیزنسی مستقله که
با TadrisinoTokenAuthentication سفارشی احراز هویت می‌شه. با TadrisinoTokenAuthentication سفارشی احراز هویت می‌شه.
@@ -11,18 +13,23 @@ class User(models.Model):
این اپ عمداً هیچ‌وقت FK واقعی به مدل اپ دیگه نمی‌زنه. این اپ عمداً هیچ‌وقت FK واقعی به مدل اپ دیگه نمی‌زنه.
""" """
phone = models.CharField(max_length=15, unique=True, null=True, blank=True, db_index=True) 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) name = models.CharField(max_length=100, blank=True)
avatar = 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 grade_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Grade
created_at = models.DateTimeField(auto_now_add=True) 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: class Meta:
ordering = ['-created_at'] ordering = ['-created_at']
def __str__(self): 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 @property
def is_authenticated(self): def is_authenticated(self):
@@ -33,23 +40,32 @@ class User(models.Model):
class Profile(models.Model): class Profile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile') user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
# سالِ تولدِ شمسی. از ۲۰۲۶-۱۰-۰۱ از birth_date پر می‌شود؛ برای کاربرهای
# قدیمی که فقط سال داده‌اند تنها منبع است.
birth_year = models.PositiveSmallIntegerField(null=True, blank=True) 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) bio = models.CharField(max_length=200, blank=True)
def __str__(self): def __str__(self):
return f"profile-{self.user_id}" 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): class Session(models.Model):
""" """
نگهداری refresh_token هر ورود. توکن دسترسی (access token) در دیتابیس یک ورود. توکنِ دسترسی ذخیره نمی‌شود (امضاشده و stateless است، accounts/authentication.py).
ذخیره نمی‌شه؛ به‌صورت stateless امضا (signed) می‌شه — دقیقاً طبق فیلدهای
همین مدل که فقط refresh_token داره، نه access token. `refresh_token` هشِ SHA-256ِ توکنِ تمدید است، نه خودش (hash_refresh_token): هر کس دیتابیس،
یک بکاپ یا پنلِ ادمین را ببیند با آن نمی‌تواند به‌جای کاربر وارد شود. توکن ۶۴ نویسه‌ی تصادفی
است، پس هشِ سریعِ بی‌نمک کافی است.
""" """
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sessions') user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sessions')
refresh_token = models.CharField(max_length=200, unique=True) refresh_token = models.CharField(max_length=200, unique=True)
device_id = models.CharField(max_length=64, blank=True)
expires_at = models.DateTimeField() expires_at = models.DateTimeField()
revoked = models.BooleanField(default=False) revoked = models.BooleanField(default=False)
@@ -60,8 +76,46 @@ class Session(models.Model):
class OTPCode(models.Model): class OTPCode(models.Model):
phone = models.CharField(max_length=15, db_index=True) phone = models.CharField(max_length=15, db_index=True)
code = models.CharField(max_length=6) 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) is_used = models.BooleanField(default=False)
# تلاش‌های غلط روی همین کد. در دیتابیس شمرده می‌شود، نه در کشِ LocMem که
# برای هر worker جداست؛ بعد از OTP_MAX_FAILED_ATTEMPTS کد باطل می‌شود.
failed_attempts = models.PositiveSmallIntegerField(default=0)
def __str__(self): def __str__(self):
return f"otp-{self.phone}" 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

22
accounts/receivers.py Normal file
View File

@@ -0,0 +1,22 @@
"""
باطل‌کردنِ کشِ ردیفِ کاربر (auth:user:<id>) با هر تغییرِ User.
این سیگنال‌ها همه‌ی مسیرهای نوشتنِ داخلِ اپ (SetUserGrade، لاگین، رفرش‌توکن،
last_activity_at، ...) را پوشش می‌دهند؛ فقط تغییری که کاملاً بیرون از ORM
اعمال شود (مثلاً UPDATE مستقیمِ SQL) تا سقفِ AUTH_USER_CACHE_TTL کهنه می‌ماند.
"""
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from .authentication import invalidate_user_cache
from .models import User
@receiver(post_save, sender=User, dispatch_uid='accounts_invalidate_user_cache_on_save')
def _invalidate_on_save(sender, instance, **kwargs):
invalidate_user_cache(instance.pk)
@receiver(post_delete, sender=User, dispatch_uid='accounts_invalidate_user_cache_on_delete')
def _invalidate_on_delete(sender, instance, **kwargs):
invalidate_user_cache(instance.pk)

View File

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

View File

@@ -1,16 +1,71 @@
import re
import unicodedata
from rest_framework import serializers 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 ---- # ---- Request serializers ----
class GuestSessionRequestSerializer(serializers.Serializer): class OTPCodeField(serializers.CharField):
type = serializers.ChoiceField(choices=['guest']) """
deviceId = serializers.CharField(max_length=64) کدِ ورود: ۴ تا ۶ رقم (کدِ پیامکی ۶ رقمی است، کدهای 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): class LoginRequestSerializer(serializers.Serializer):
phone = serializers.CharField(max_length=15) phone = IranMobileField()
otp = serializers.CharField(max_length=6) otp = OTPCodeField()
class RefreshRequestSerializer(serializers.Serializer): class RefreshRequestSerializer(serializers.Serializer):
@@ -20,21 +75,80 @@ class RefreshRequestSerializer(serializers.Serializer):
class SetGradeRequestSerializer(serializers.Serializer): class SetGradeRequestSerializer(serializers.Serializer):
gradeId = serializers.IntegerField() gradeId = serializers.IntegerField()
def validate_gradeId(self, value):
# پایه‌ی ناموجود ۴۰۰ است، نه ذخیره‌ی بی‌صدا (نقشه‌ی خالی). عددِ بزرگ‌تر از ستونِ SQLite هم
# پیش‌تر هنگامِ ذخیره OverflowError (۵۰۰) می‌داد؛ exists() آن را بی‌خطا «نیست» می‌بیند.
from content.models import Grade
if not Grade.objects.filter(pk=value).exists():
raise serializers.ValidationError('این پایه وجود ندارد.')
return value
# همان بازه‌ای که پیش‌تر برای «سن» پذیرفته می‌شد. بزرگسال هم می‌آید (سینیور،
# ۲۰۲۶-۱۰-۰۱): کسی که فقط یک مبحثِ ریاضی را می‌خواهد یاد بگیرد.
MIN_AGE = 5
MAX_AGE = 99
class UpdateProfileRequestSerializer(serializers.Serializer):
"""
ورودی PATCH /api/user/profile — همه‌ی فیلدها اختیاری‌اند و فقط همان‌هایی
که فرستاده شوند تغییر می‌کنند (partial update واقعی).
`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)
# تاریخِ تولدِ شمسی «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)
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('حداقل یکی از فیلدها باید ارسال شود.')
if 'birthDate' in attrs and 'birthYear' in attrs:
raise serializers.ValidationError('یکی از birthDate یا birthYear را بفرستید، نه هر دو را.')
return attrs
class RequestOTPRequestSerializer(serializers.Serializer): class RequestOTPRequestSerializer(serializers.Serializer):
"""اپ کمکی برای گرفتن OTP (در سند اصلی نبود، برای تست‌پذیری Swagger اضافه شد).""" """اپ کمکی برای گرفتن OTP (در سند اصلی نبود، برای تست‌پذیری Swagger اضافه شد)."""
phone = serializers.CharField(max_length=15) phone = IranMobileField()
# ---- Response serializers ---- # ---- Response serializers ----
class GuestSessionResponseSerializer(serializers.Serializer):
userId = serializers.CharField()
token = serializers.CharField()
isNewUser = serializers.BooleanField()
isGuest = serializers.BooleanField()
class LoginResponseSerializer(serializers.Serializer): class LoginResponseSerializer(serializers.Serializer):
userId = serializers.CharField() userId = serializers.CharField()
token = serializers.CharField() token = serializers.CharField()
@@ -43,6 +157,7 @@ class LoginResponseSerializer(serializers.Serializer):
class RefreshResponseSerializer(serializers.Serializer): class RefreshResponseSerializer(serializers.Serializer):
token = serializers.CharField() token = serializers.CharField()
refreshToken = serializers.CharField()
expiresIn = serializers.IntegerField() expiresIn = serializers.IntegerField()
@@ -66,3 +181,35 @@ class RequestOTPResponseSerializer(serializers.Serializer):
success = serializers.BooleanField() success = serializers.BooleanField()
devOtp = serializers.CharField() devOtp = serializers.CharField()
note = serializers.CharField() note = serializers.CharField()
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()
gradeId = serializers.IntegerField(allow_null=True)
birthYear = serializers.IntegerField(allow_null=True)
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()

265
accounts/services.py Normal file
View File

@@ -0,0 +1,265 @@
"""
منطق کمکیِ اپ Accounts که از views.py همین اپ صدا زده می‌شه.
"""
import logging
import requests
from django.conf import settings
from django.db import models
from django.utils import timezone
from rest_framework.exceptions import APIException
from .models import User
logger = logging.getLogger(__name__)
def _phone_suffix(phone: str) -> str:
"""
⚠️ اصلاح: قبلاً لاگ‌های مربوط به OTP، شماره‌تلفنِ کامل رو لاگ می‌کردن
(PIIِ غیرضروری در لاگ‌های اپلیکیشن). حالا فقط ۴ رقمِ آخر (برای
correlation/دیباگ، بدونِ افشای کاملِ شماره) لاگ می‌شه — همون الگویی که
قبلاً در accounts/views.py::LoginView هم برای همین منظور استفاده شده.
⚠️ اصلاحِ این دور: لاگ‌های خطای ارائه‌دهنده (provider) دیگه
response.text خام رو لاگ نمی‌کنن — چون بدنه‌ی پاسخِ کاوه‌نگار می‌تونه
داده‌ی حساس/غیرضروری (مثلاً جزئیاتِ گیرنده یا پیامِ متنی) داشته باشه.
به‌جاش فقط context مفید و بی‌خطر (phone_suffix، status، provider_status)
لاگ می‌شه.
"""
return phone[-4:] if phone else phone
class SMSDeliveryError(APIException):
"""
خطای عمومیِ ارسالِ پیامک، برای هر فراخوانی‌ای از send_sms (نه فقط OTP).
اگه پیکربندیِ سرویسِ پیامک ناقص باشه یا خودِ درخواست به ارائه‌دهنده
شکست بخوره، همین استثنا raise می‌شه (نه یک پاسخِ ساختگیِ موفق).
"""
status_code = 503
default_detail = 'در حال حاضر امکان ارسال پیامک وجود ندارد؛ لطفاً بعداً دوباره تلاش کنید.'
default_code = 'sms_delivery_failed'
class SMSDeliveryUncertain(SMSDeliveryError):
"""
درخواست به کاوه‌نگار رسید ولی جوابش در مهلت نیامد؛ پیامک شاید رفته باشد.
۲۰۲۶-۰۹-۲۷ دیده شد: پاسخ ۵٫۴ ثانیه طول کشید، ما با مهلتِ ۵ ثانیه ۵۰۳ دادیم و
کد را پاک کردیم، ولی پیامک رسید — کاربر کدی در دست داشت که دیگر کار نمی‌کرد.
"""
class OTPDeliveryError(SMSDeliveryError):
"""
⚠️ اصلاح: قبلاً send_otp_sms یک استاب بود که فقط لاگ می‌کرد و همیشه
(بی‌قیدوشرط) به RequestOTPView «موفقیت» برمی‌گردوند؛ یعنی در پروداکشن
(DEBUG=False، جایی که devOtp دیگه توی پاسخ برنمی‌گرده) هیچ پیامکِ
واقعی‌ای ارسال نمی‌شد ولی API همچنان success=True می‌داد — کاربرِ واقعی
هیچ‌وقت کدش رو نمی‌گرفت و حتی نمی‌فهمید که مشکلی پیش اومده. حالا اگه
پیکربندیِ سرویسِ پیامک ناقص باشه یا خودِ درخواست به ارائه‌دهنده شکست
بخوره، همین استثنا raise می‌شه و RequestOTPView یک خطای صریح (نه یک
پاسخِ ساختگیِ موفق) برمی‌گردونه.
این کلاس زیرکلاسِ SMSDeliveryError است تا رفتار/نوعِ استثنایی که
RequestOTPView (و تست‌ها) انتظارش رو دارن دقیقاً حفظ بشه، در حالی که
منطقِ واقعیِ ارتباط با ارائه‌دهنده در send_sms (عمومی) پیاده شده.
"""
default_detail = 'در حال حاضر امکان ارسال پیامک OTP وجود ندارد؛ لطفاً بعداً دوباره تلاش کنید.'
default_code = 'otp_sms_delivery_failed'
def send_sms(*, phone: str, message: str) -> None:
"""
⚙️ Reusable هلپر: تنها بخشِ «ارتباط با ارائه‌دهنده» (کاوه‌نگار،
kavenegar.com) که قبلاً داخلِ send_otp_sms با منطقِ OTP قاطی شده بود،
به این تابعِ عمومی منتقل شده — بدونِ هیچ تغییری در رفتار/پیام‌های
provider. هر فیچرِ آینده (مثلاً notifications) هم می‌تونه بدونِ
تکرارِ کدِ ارائه‌دهنده، مستقیماً از همین تابع برای ارسالِ پیامک استفاده
کنه.
این تابع هیچ منطقِ مخصوصِ OTP (dev-mode short-circuit، تمپلیتِ پیامک،
یا OTPDeliveryError) نداره؛ فقط payload می‌سازه، به کاوه‌نگار درخواست
می‌زنه، و پاسخ رو اعتبارسنجی می‌کنه. کلیدِ 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)',
_phone_suffix(phone),
)
raise SMSDeliveryError('پیکربندیِ سرویسِ پیامک ناقص است.')
try:
response = requests.post(
f'https://api.kavenegar.com/v1/{settings.KAVENEGAR_API_KEY}/{method}',
data=payload,
# (اتصال، خواندن). وصل‌نشدن یعنی قطعاً نرفت؛ دیر رسیدنِ جواب یعنی نامعلوم.
timeout=(SMS_CONNECT_TIMEOUT_SECONDS, SMS_READ_TIMEOUT_SECONDS),
)
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:
logger.error(
'SMS provider returned non-200: phone_suffix=%s status=%s',
_phone_suffix(phone), response.status_code,
)
raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
# کاوه‌نگار حتی برای شکست‌های سطحِ اپلیکیشن (کلیدِ نامعتبر، اعتبارِ ناکافی و…)
# HTTP 200 برمی‌گردونه؛ نتیجه‌ی واقعی توی body.return.status هست. فقط
# اون فیلد موفقیت رو تعیین می‌کنه، نه status_code.
try:
body = response.json()
provider_status = body['return']['status']
except (ValueError, KeyError, TypeError):
logger.error(
'SMS provider returned malformed response body: phone_suffix=%s status=%s',
_phone_suffix(phone), response.status_code,
)
raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
if provider_status != 200:
logger.error(
'SMS provider rejected message at application level: phone_suffix=%s '
'provider_status=%s',
_phone_suffix(phone), provider_status,
)
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:
"""
ارسالِ پیامکِ OTP. منطقِ مخصوصِ OTP (dev-mode short-circuit، تمپلیتِ
پیامک از OTP_SMS_TEMPLATE، و نگاشتِ خطا به OTPDeliveryError) اینجا
می‌مونه؛ ارتباطِ واقعی با ارائه‌دهنده به send_sms عمومی سپرده شده.
- DEBUG=True (dev/test): دقیقاً مثل قبل، فقط لاگ می‌کنه و هیچ پیامکِ
واقعی‌ای نمی‌فرسته — چون در این محیط، RequestOTPView خودِ کد رو در
فیلدِ devOtp پاسخ برمی‌گردونه و SMS واقعی لازم نیست (رفتار قبلی dev
حفظ شده).
- DEBUG=False (production): اگر KAVENEGAR_OTP_TEMPLATE تنظیم باشد با الگو
(send_verify_lookup)، وگرنه متنِ آزادِ OTP_SMS_TEMPLATE با send_sms؛ هر SMSDeliveryError
به OTPDeliveryError نگاشت می‌شه تا رفتار/نوعِ استثنایی که
RequestOTPView انتظارش رو داره حفظ بمونه (کاربر پاسخ ۵۰۳ صریح
می‌گیره، نه یک success دروغین).
"""
if settings.DEBUG:
logger.info(
'OTP sms dispatch (dev, no real SMS sent): phone_suffix=%s code_len=%d',
_phone_suffix(phone), len(code),
)
return
try:
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
# Targeting helpers (internal, read-only): کوئری‌های قابل‌استفاده‌ی مجدد برای
# انتخابِ زیرمجموعه‌ای از کاربران (مثلاً برای کمپین‌های پیامی)؛ فقط SELECT،
# بدونِ ارسالِ پیام. ارجاع به subscription.Subscription با import محلی و
# user_id انجام می‌شه چون accounts.User هیچ‌وقت FK واقعی به اپ‌های دیگه نمی‌زنه.
def get_all_users():
"""همه‌ی کاربران (مهمان و ثبت‌نام‌شده)."""
return User.objects.all()
def get_users_by_grade(grade_id: int):
"""کاربرانی که پایه‌ی (grade_id) مشخصی دارن."""
return User.objects.filter(grade_id=grade_id)
def get_inactive_users(since):
"""
کاربرانی که از زمانِ `since` به بعد فعالیتی (لاگین/رفرش‌توکن/سشنِ
مهمانِ واقعی) نداشتن — یعنی last_activity_at قبل از `since` بوده یا
اصلاً هیچ‌وقت ثبت نشده (last_activity_at is null، مثلِ کاربرهایی که
این فیلد قبل از افزوده‌شدنش ساخته شدن یا هیچ‌وقت فعالیتِ ثبت‌شده‌ای
نداشتن).
`since` باید یک datetime (تایم‌زون‌دار، هم‌راستا با USE_TZ پروژه)
باشه؛ کالر مسئولِ محاسبه‌ی cutoff (مثلاً timezone.now() - timedelta(...))
هست — این تابع خودش هیچ عددِ روزِ hardcode‌شده‌ای نداره.
"""
return User.objects.filter(models.Q(last_activity_at__lt=since) | models.Q(last_activity_at__isnull=True))
def get_users_with_active_subscription():
"""
کاربرانی که اشتراکِ فعال دارن. «فعال» یعنی هم status='active' باشه و
هم منقضی نشده باشه (expires_at خالیه یعنی نامحدود، یا هنوز نرسیده).
فقط user_id از subscription.Subscription خونده می‌شه (بدونِ FK واقعی،
طبقِ همون قراردادِ accounts.User) و برای فیلترِ accounts.User استفاده
می‌شه.
"""
from subscription.models import Subscription
active_user_ids = Subscription.objects.filter(
status='active',
).filter(
models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=timezone.now())
).values_list('user_id', flat=True)
return User.objects.filter(id__in=active_user_ids)

11
accounts/signals.py Normal file
View File

@@ -0,0 +1,11 @@
"""
سیگنال ثبت‌نامِ واقعی کاربر. سایر اپ‌ها (مثلاً discount
برای اختصاص خودکار آفر) به‌جای import کردن مستقیم از accounts، فقط این
سیگنال رو در apps.py خودشون connect می‌کنن؛ این یعنی accounts هیچ‌وقت لازم
نیست بدونه discount/offer اصلاً وجود داره — دقیقاً همون درخواست سینیور برای
signal-based بودن منطق اعمال آفر.
"""
from django.dispatch import Signal
# kwargs: user_id (int)
user_registered = Signal()

View File

@@ -0,0 +1,24 @@
{% extends "admin/base_site.html" %}
{% load i18n %}
{% block content %}
<p>{{ users|length }} کاربر انتخاب شده‌اند. متنِ پیامک را وارد کنید:</p>
<ul>
{% for user in users %}
<li>{{ user }}{% if not user.phone %} (بدونِ شماره‌تلفن — رد می‌شه){% endif %}</li>
{% endfor %}
</ul>
<form method="post">
{% csrf_token %}
{% for user in users %}
<input type="hidden" name="{{ action_checkbox_name }}" value="{{ user.pk }}">
{% endfor %}
<input type="hidden" name="action" value="send_sms_to_selected">
{{ form.as_p }}
<input type="submit" name="apply" value="ارسال پیامک">
</form>
{% endblock %}

View File

@@ -0,0 +1,28 @@
{% extends "admin/base_site.html" %}
{% load i18n %}
{% block content %}
<p>
این صفحه فقط یک ایندکسِ خواندنی (read-only) از صفحاتِ Adminِ موجود است؛
هر بخش دقیقاً همون changelist همیشگی رو باز می‌کنه، بدون هیچ صفحه‌ی
موازیِ جدید. دسترسیِ افزودن/ویرایش/حذف، مستقل از این صفحه، بر اساسِ
پرمیشن‌های شما محدود شده است.
</p>
{% for section in sections %}
<div class="module" style="margin-bottom: 20px;">
<table>
<caption>{{ section.title }}</caption>
<tbody>
{% for link in section.links %}
{% if link.has_perm %}
<tr>
<th scope="row"><a href="{% url link.url_name %}">{{ link.label }}</a></th>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{% endfor %}
{% endblock %}

File diff suppressed because it is too large Load Diff

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,17 +1,21 @@
from django.urls import path from django.urls import path
from .views import ( from .views import (
GuestSessionView, DeleteAccountView,
LoginView, LoginView,
LogoutView,
RefreshTokenView, RefreshTokenView,
RequestOTPView, RequestOTPView,
SetUserGradeView, SetUserGradeView,
UserProfileView,
) )
urlpatterns = [ urlpatterns = [
path('auth/session', GuestSessionView.as_view(), name='auth-session'),
path('auth/otp/request', RequestOTPView.as_view(), name='auth-otp-request'), path('auth/otp/request', RequestOTPView.as_view(), name='auth-otp-request'),
path('auth/login', LoginView.as_view(), name='auth-login'), path('auth/login', LoginView.as_view(), name='auth-login'),
path('auth/refresh', RefreshTokenView.as_view(), name='auth-refresh'), 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/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

@@ -1,66 +1,72 @@
import random import logging
import math
import secrets
import uuid import uuid
from django.conf import settings 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 django.utils import timezone
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
from rest_framework import status from rest_framework import status
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.throttling import BaseThrottle, ScopedRateThrottle
from rest_framework.views import APIView from rest_framework.views import APIView
from .authentication import issue_access_token from config.db import release_db_connection
from .models import OTPCode, Session, User
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 ( from .serializers import (
GuestSessionRequestSerializer, DeleteAccountRequestSerializer,
GuestSessionResponseSerializer, DeleteAccountResponseSerializer,
LoginRequestSerializer, LoginRequestSerializer,
LoginResponseSerializer, LoginResponseSerializer,
LogoutRequestSerializer,
LogoutResponseSerializer,
ProfileResponseSerializer,
RefreshRequestSerializer, RefreshRequestSerializer,
RefreshResponseSerializer, RefreshResponseSerializer,
RequestOTPRequestSerializer, RequestOTPRequestSerializer,
RequestOTPResponseSerializer, RequestOTPResponseSerializer,
SetGradeRequestSerializer, SetGradeRequestSerializer,
UpdateProfileRequestSerializer,
SetGradeResponseSerializer, SetGradeResponseSerializer,
) )
logger = logging.getLogger(__name__)
class GuestSessionView(APIView):
"""POST /api/auth/session — ایجاد سشن مهمان"""
@extend_schema( def record_login_event(request, event: str, *, user_id=None, phone: str = '') -> None:
request=GuestSessionRequestSerializer, """
responses=GuestSessionResponseSerializer, ردیفِ LoginEvent می‌نویسد. شکستش ورود/خروج را خراب نمی‌کند (ثبتِ سابقه نباید کاربر را بیرون
tags=['Identity & Profile (accounts)'], بیندازد) ولی با logger.exception دیده می‌شود. زیرِ بارِ هم‌زمان و روی Postgres سنجیده نشده.
) """
def post(self, request): try:
serializer = GuestSessionRequestSerializer(data=request.data) LoginEvent.objects.create(
serializer.is_valid(raise_exception=True) event=event, user_id=user_id, phone=phone,
device_id = serializer.validated_data['deviceId'] ip=BaseThrottle().get_ident(request) or None,
user_agent=request.META.get('HTTP_USER_AGENT', '')[:300],
user, is_new_user = User.objects.get_or_create(
device_id=device_id,
is_guest=True,
defaults={'name': ''},
) )
except Exception:
token = issue_access_token(user.id) logger.exception('LoginEvent نوشته نشد: event=%s user_id=%s', event, user_id)
data = {
'userId': str(user.id),
'token': token,
'isNewUser': is_new_user,
'isGuest': True,
}
return Response(GuestSessionResponseSerializer(data).data)
class RequestOTPView(APIView): class RequestOTPView(APIView):
""" """
POST /api/auth/otp/request — درخواست کد OTP (کمکی، برای تست از طریق Swagger) POST /api/auth/otp/request — درخواستِ کدِ ورود.
در محیط توسعه، کد ساخته‌شده در پاسخ برگردونده می‌شه تا نیازی به SMS واقعی نباشه.
کد فقط با پیامک تحویل داده می‌شود. دو استثنا: در حالتِ DEBUG کد در فیلدِ
devOtp برمی‌گردد تا تستِ محلی به پیامکِ واقعی نیاز نداشته باشد، و برای
شماره‌های TEST_LOGIN_PHONES کدِ ثابتِ از پیش تعریف‌شده معتبر است.
""" """
throttle_scope = 'otp_request'
throttle_classes = [ScopedRateThrottle]
@extend_schema( @extend_schema(
request=RequestOTPRequestSerializer, request=RequestOTPRequestSerializer,
responses=RequestOTPResponseSerializer, responses=RequestOTPResponseSerializer,
@@ -71,23 +77,209 @@ class RequestOTPView(APIView):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
phone = serializer.validated_data['phone'] phone = serializer.validated_data['phone']
code = f'{random.randint(0, 9999):04d}' # شماره‌ی تستِ تیم: کد ثابت و از قبل معلوم است، پس نه ردیفِ OTP تازه‌ای
OTPCode.objects.create( # لازم است نه پیامکی. cooldown و سقفِ ساعتی هم رد می‌شوند چون هر دو از
phone=phone, # گیرنده‌ی واقعیِ پیامک محافظت می‌کنند و اینجا پیامکی در کار نیست.
code=code, #
expires_at=timezone.now() + timezone.timedelta(minutes=5), # ⚠️ کد عمداً در پاسخ برنمی‌گردد، برخلافِ 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 انقضا رو ذخیره می‌کنه تا
# retryAfter/هدرِ Retry-After بدونِ خواندنِ اضافه محاسبه بشه.
phone_cooldown_key = f'otp_request_phone_cooldown:{phone}'
cooldown_seconds = settings.OTP_REQUEST_PHONE_COOLDOWN_SECONDS
now_epoch = timezone.now().timestamp()
cooldown_expires_at_epoch = now_epoch + cooldown_seconds
if not cache.add(phone_cooldown_key, cooldown_expires_at_epoch, timeout=cooldown_seconds):
stored_expiry_epoch = cache.get(phone_cooldown_key)
if stored_expiry_epoch is None:
# بینِ شکستِ add و این get دقیقاً منقضی شده؛ عملاً دیگه
# cooldownی در کار نیست، اما برای رفتارِ یکدست یک
# retry_after حداقلی برمی‌گردونیم (کلاینت با تلاشِ بعدی
# مسیرِ عادی رو طی می‌کنه).
retry_after = 1
else:
retry_after = max(1, math.ceil(stored_expiry_epoch - now_epoch))
logger.warning(
'OTP request phone cooldown active: phone_suffix=%s retry_after_seconds=%s',
phone[-4:], retry_after,
)
return Response(
{
'detail': f'لطفاً {retry_after} ثانیه صبر کنید و دوباره درخواست کد بدهید.',
'retryAfter': retry_after,
},
status=status.HTTP_429_TOO_MANY_REQUESTS,
headers={'Retry-After': str(retry_after)},
)
# محدودیتِ صدورِ 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
else:
try:
attempt_count = cache.incr(phone_requests_key)
except ValueError:
# بینِ add و incr دقیقاً منقضی شده؛ پنجره رو از نو شروع کن.
cache.set(phone_requests_key, 1, timeout=settings.OTP_REQUEST_PHONE_WINDOW_SECONDS)
attempt_count = 1
if attempt_count > settings.OTP_REQUEST_PHONE_MAX_ATTEMPTS:
logger.warning(
'OTP request phone limit exceeded: phone_suffix=%s attempt_count=%s max_attempts=%s',
phone[-4:], attempt_count, settings.OTP_REQUEST_PHONE_MAX_ATTEMPTS,
)
return Response(
{'detail': 'تعداد درخواست‌های کد OTP برای این شماره بیش از حد مجاز است.'},
status=status.HTTP_429_TOO_MANY_REQUESTS,
)
# سقف‌های دیتابیسی: 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 رو پاک می‌کنیم
# تا مجبور نشه ۲ دقیقه بدونِ داشتنِ کد منتظر بمونه، و ردیفِ کد رو هم
# حذف می‌کنیم تا در cooldown و سقفِ روزانه‌ی دیتابیسی شمرده نشه.
# سقفِ ساعتیِ کش و throttleِ IP دست‌نخورده می‌مونن (محدودیتِ حجم).
cache.delete(phone_cooldown_key)
otp_row.delete()
raise
# devOtp فقط در محیط dev برمی‌گردد؛ در پروداکشن کد فقط از طریق پیامک تحویل داده می‌شود.
note = 'در پروداکشن این کد پیامک می‌شه.'
if settings.DEV_MASTER_OTP is not None:
note = f'در پروداکشن این کد پیامک می‌شه؛ برای تست همیشه {settings.DEV_MASTER_OTP} هم قبول می‌شه.'
data = { data = {
'success': True, 'success': True,
'devOtp': code, 'devOtp': code if settings.DEBUG else '',
'note': f'در پروداکشن این کد پیامک می‌شه؛ برای تست همیشه {settings.DEV_MASTER_OTP} هم قبول می‌شه.', 'note': note,
} }
return Response(RequestOTPResponseSerializer(data).data) 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): class LoginView(APIView):
"""POST /api/auth/login — ورود با OTP""" """POST /api/auth/login — ورود با OTP"""
throttle_scope = 'otp_login'
throttle_classes = [ScopedRateThrottle]
@extend_schema( @extend_schema(
request=LoginRequestSerializer, request=LoginRequestSerializer,
responses=LoginResponseSerializer, responses=LoginResponseSerializer,
@@ -99,37 +291,35 @@ class LoginView(APIView):
phone = serializer.validated_data['phone'] phone = serializer.validated_data['phone']
otp = serializer.validated_data['otp'] otp = serializer.validated_data['otp']
valid = False error = verify_phone_otp(phone, otp)
if otp == settings.DEV_MASTER_OTP: if error is not None:
valid = True record_login_event(
else: request,
otp_qs = OTPCode.objects.filter( LoginEvent.LOGIN_BLOCKED if error.status_code == status.HTTP_429_TOO_MANY_REQUESTS
phone=phone, code=otp, is_used=False, expires_at__gte=timezone.now() else LoginEvent.LOGIN_FAILED,
).order_by('-id') phone=phone,
otp_obj = otp_qs.first() )
if otp_obj: return error
otp_obj.is_used = True
otp_obj.save(update_fields=['is_used'])
valid = True
if not valid: with transaction.atomic():
return Response({'detail': 'کد OTP نامعتبر یا منقضی‌شده است.'}, status=status.HTTP_400_BAD_REQUEST) user, created = User.objects.get_or_create(phone=phone, defaults={'name': ''})
user, _ = User.objects.get_or_create( # ثبتِ فعالیتِ کاربر برای ورودِ موفق.
phone=phone, user.last_activity_at = timezone.now()
defaults={'is_guest': False, 'name': ''}, user.save(update_fields=['last_activity_at'])
)
if user.is_guest:
user.is_guest = False
user.save(update_fields=['is_guest'])
refresh_token = uuid.uuid4().hex if created:
Session.objects.create( # آفرهای ثبت‌نام (مثل یک هفته رایگان) از طریق discount/receivers.py روی این سیگنال اعمال می‌شوند.
user=user, from .signals import user_registered
refresh_token=refresh_token, user_registered.send(sender=User, user_id=user.id)
device_id=request.data.get('deviceId', ''),
expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS), refresh_token = uuid.uuid4().hex
) Session.objects.create(
user=user,
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) access_token = issue_access_token(user.id)
data = { data = {
@@ -140,9 +330,115 @@ class LoginView(APIView):
return Response(LoginResponseSerializer(data).data) 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): class RefreshTokenView(APIView):
"""POST /api/auth/refresh — تمدید توکن""" """POST /api/auth/refresh — تمدید توکن"""
# این endpoint احرازهویت نمی‌خواد (خودِ refresh token شناسه است)، پس
# ScopedRateThrottle طبیعتاً روی IP کلید می‌خوره. یک توکنِ رفرش عادتاً
# فقط گاه‌به‌گاه (نه با فرکانسِ بالا) باید استفاده بشه؛ این throttle
# جلوی سوءاستفاده از این endpoint به‌عنوان یک عملیاتِ پرتکرارِ
# دیتابیس/تولیدِ توکن رو می‌گیره، بدون تغییری در rotation یا محافظتِ
# اتمیکِ replay که پایین‌تر پیاده‌سازی شده.
throttle_scope = 'token_refresh'
throttle_classes = [ScopedRateThrottle]
@extend_schema( @extend_schema(
request=RefreshRequestSerializer, request=RefreshRequestSerializer,
responses=RefreshResponseSerializer, responses=RefreshResponseSerializer,
@@ -153,14 +449,33 @@ class RefreshTokenView(APIView):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
refresh_token = serializer.validated_data['refreshToken'] refresh_token = serializer.validated_data['refreshToken']
session = Session.objects.filter( # UPDATE اتمیک جلوی استفاده‌ی هم‌زمان از یک refresh token را می‌گیرد (replay protection).
refresh_token=refresh_token, revoked=False, expires_at__gte=timezone.now() with transaction.atomic():
).first() updated_count = Session.objects.filter(
if not session: refresh_token=hash_refresh_token(refresh_token), revoked=False, expires_at__gte=timezone.now()
return Response({'detail': 'رفرش‌توکن نامعتبر یا منقضی‌شده است.'}, status=status.HTTP_401_UNAUTHORIZED) ).update(revoked=True)
access_token = issue_access_token(session.user_id) if not updated_count:
data = {'token': access_token, 'expiresIn': settings.ACCESS_TOKEN_TTL_SECONDS} return Response({'detail': 'رفرش‌توکن نامعتبر یا منقضی‌شده است.'}, status=status.HTTP_401_UNAUTHORIZED)
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())
new_refresh_token = secrets.token_hex(32)
new_session = Session.objects.create(
user_id=old_session.user_id,
refresh_token=hash_refresh_token(new_refresh_token),
expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
)
access_token = issue_access_token(new_session.user_id)
data = {
'token': access_token,
'refreshToken': new_refresh_token,
'expiresIn': settings.ACCESS_TOKEN_TTL_SECONDS,
}
return Response(RefreshResponseSerializer(data).data) return Response(RefreshResponseSerializer(data).data)
@@ -168,6 +483,10 @@ class SetUserGradeView(APIView):
"""POST /api/user/grade — ثبت پایه‌ی کاربر (نیاز به احراز هویت)""" """POST /api/user/grade — ثبت پایه‌ی کاربر (نیاز به احراز هویت)"""
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
# بدونِ throttle، یک کاربرِ احرازهویت‌شده می‌تواند
# نامحدود درخواستِ نوشتن روی دیتابیس بفرستد.
throttle_scope = 'user_profile_write'
throttle_classes = [ScopedRateThrottle]
@extend_schema( @extend_schema(
request=SetGradeRequestSerializer, request=SetGradeRequestSerializer,
@@ -185,3 +504,101 @@ class SetUserGradeView(APIView):
data = {'success': True, 'gradeId': grade_id} data = {'success': True, 'gradeId': grade_id}
return Response(SetGradeResponseSerializer(data).data) 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 — اطلاعات پروفایل کاربر جاری
PATCH /api/user/profile — ویرایشِ همان اطلاعات (نیاز به احراز هویت)
PATCH برای صفحه‌ی «بیا باهم آشنا بشیم» در اپ لازم است: آنجا اسم (و بعداً
آواتار) گرفته می‌شود و تا پیش از این هیچ مسیری برای نوشتنشان وجود نداشت —
فقط پایه‌ی تحصیلی endpoint اختصاصیِ خودش را داشت.
"""
permission_classes = [IsAuthenticated]
# نوشتن است، پس همان throttleِ نوشتنِ پروفایل که SetUserGradeView دارد.
throttle_scope = 'user_profile_write'
throttle_classes = [ScopedRateThrottle]
def get_throttles(self):
# GET خواندنی است و نباید سهمیه‌ی نوشتن را مصرف کند.
if self.request.method == 'GET':
return []
return super().get_throttles()
@extend_schema(
responses=ProfileResponseSerializer,
tags=['Identity & Profile (accounts)'],
)
def get(self, request):
user = request.user
# مدل Profile به‌صورت lazy ساخته می‌شه چون هیچ‌جای دیگه‌ای (نه
# LoginView) رکورد Profile رو از قبل نمی‌سازه؛
# get_or_create این‌جا امن است چون فقط توسطِ کاربرِ همون رکورد صدا
# زده می‌شه (OneToOne روی user_id جاری، نه ورودیِ خارجی).
profile, _ = Profile.objects.get_or_create(user_id=user.id)
return Response(ProfileResponseSerializer(_profile_payload(user, profile)).data)
@extend_schema(
request=UpdateProfileRequestSerializer,
responses=ProfileResponseSerializer,
tags=['Identity & Profile (accounts)'],
)
def patch(self, request):
serializer = UpdateProfileRequestSerializer(data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
user = request.user
profile, _ = Profile.objects.get_or_create(user_id=user.id)
user_fields = {'name': 'name', 'avatar': 'avatar'}
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]
for key, attr in user_fields.items():
if key in v:
setattr(user, attr, v[key])
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 همزمان از یک دستگاهِ دیگر، فیلدهای دست‌نخورده را بازنویسی نکند.
if changed_user:
user.save(update_fields=changed_user)
if changed_profile:
profile.save(update_fields=changed_profile)
return Response(ProfileResponseSerializer(_profile_payload(user, profile)).data)

View File

@@ -4,11 +4,13 @@ from rest_framework import serializers
class AffiliateMeResponseSerializer(serializers.Serializer): class AffiliateMeResponseSerializer(serializers.Serializer):
referralCode = serializers.CharField() referralCode = serializers.CharField()
commissionRate = serializers.FloatField() commissionRate = serializers.FloatField()
referredCount = serializers.IntegerField(help_text='کاربرهایی که کدِ شما را ثبت کرده‌اند.')
paidCount = serializers.IntegerField(help_text='از میانِ آن‌ها، کسانی که واقعاً پرداخت کرده‌اند.')
totalCommission = serializers.IntegerField(help_text='جمعِ پورسانتِ پرداخت‌های واقعی (تومان).')
class TrackRequestSerializer(serializers.Serializer): class TrackRequestSerializer(serializers.Serializer):
referralCode = serializers.CharField() referralCode = serializers.CharField()
referredUserId = serializers.CharField()
class TrackResponseSerializer(serializers.Serializer): class TrackResponseSerializer(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,3 +1 @@
from django.test import TestCase
# Create your tests here. # Create your tests here.

View File

@@ -1,8 +1,9 @@
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from drf_spectacular.utils import extend_schema 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.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView from rest_framework.views import APIView
from .models import AffiliateAccount, Conversion from .models import AffiliateAccount, Conversion
@@ -17,13 +18,23 @@ from .serializers import (
class AffiliateMeView(APIView): class AffiliateMeView(APIView):
"""GET /api/affiliate/me — حساب و کد ارجاع کاربر""" """GET /api/affiliate/me — حساب و کد ارجاع کاربر"""
# سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `read` همان نرخِ مسیرهای هم‌خانواده است.
throttle_scope = 'read'
throttle_classes = [ScopedRateThrottle]
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@extend_schema(responses=AffiliateMeResponseSerializer, tags=['Affiliate (affiliate)']) @extend_schema(responses=AffiliateMeResponseSerializer, tags=['Affiliate (affiliate)'])
def get(self, request): def get(self, request):
account, _ = AffiliateAccount.objects.get_or_create(user_id=request.user.id) account, _ = AffiliateAccount.objects.get_or_create(user_id=request.user.id)
paid = account.conversions.filter(status='paid')
data = {'referralCode': account.referral_code, 'commissionRate': account.commission_rate} 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) return Response(AffiliateMeResponseSerializer(data).data)
@@ -31,12 +42,23 @@ class TrackConversionView(APIView):
""" """
POST /api/affiliate/track — ثبت کلیک/ارجاع POST /api/affiliate/track — ثبت کلیک/ارجاع
طبق سند، این اپ فقط با referralCode به AffiliateAccount وصل می‌شه طبق سند، این اپ فقط با referralCode به AffiliateAccount وصل می‌شه.
(نه توکن کاربر)، چون referredUserId خودش کاربر تازه‌ارجاع‌شده‌ست که referredUserId دیگه ورودیِ کلاینت نیست: چون این درخواست باید توسطِ
لزوماً صاحب حساب افیلیت نیست؛ پس اینجا permission پیش‌فرض (AllowAny) خودِ کاربرِ تازه‌ارجاع‌شده (بعد از لاگین/ثبت‌نام) زده بشه، هویتش رو
نگه داشته شده، دقیقاً مثل ValidateDiscountView در اپ discount. از توکن احرازشده (request.user) می‌گیریم، نه از بدنه‌ی درخواست.
# هویت از request.user گرفته می‌شه (نه بدنه‌ی درخواست) چون یک
# referredUserIdِ کلاینت‌دار یعنی هر کلاینتِ ناشناس می‌تونست برای هر
# کاربرِ دیگه‌ای Conversion بسازه یا با اشغالِ همه‌ی userId ها (قیدِ
# unique رو دورزدن) مسیرِ ارجاعِ واقعی رو مسدود کنه.
""" """
permission_classes = [IsAuthenticated]
# referralCode نامعتبر ۴۰۴ می‌گیره (یک oracle قابل‌probe)، پس throttle
# مجزا probe رو کند می‌کنه.
throttle_scope = 'affiliate_track'
throttle_classes = [ScopedRateThrottle]
@extend_schema( @extend_schema(
request=TrackRequestSerializer, responses=TrackResponseSerializer, tags=['Affiliate (affiliate)'] request=TrackRequestSerializer, responses=TrackResponseSerializer, tags=['Affiliate (affiliate)']
) )
@@ -44,18 +66,8 @@ class TrackConversionView(APIView):
serializer = TrackRequestSerializer(data=request.data) serializer = TrackRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
v = serializer.validated_data v = serializer.validated_data
account = get_object_or_404(AffiliateAccount, referral_code=v['referralCode']) account = get_object_or_404(AffiliateAccount, referral_code=v['referralCode'])
referred_user_id = int(v['referredUserId']) referred_user_id = request.user.id
# ⚠️ اصلاح امنیتی: قبلاً referredUserId بدون هیچ اعتبارسنجی‌ای
# مستقیم مصرف می‌شد؛ یعنی می‌شد برای یک کاربر ناموجود Conversion
# ساخت یا با فرستادن userId خودِ صاحب حساب affiliate، به خودش
# ارجاع داد (self-referral). حالا هر دو حالت رد می‌شن.
from accounts.models import User as AccountUser
if not AccountUser.objects.filter(pk=referred_user_id).exists():
raise ValidationError({'referredUserId': 'چنین کاربری وجود ندارد.'})
if referred_user_id == account.user_id: if referred_user_id == account.user_id:
raise ValidationError({'referredUserId': 'امکان ثبت ارجاع برای خودِ صاحب کد وجود ندارد.'}) raise ValidationError({'referredUserId': 'امکان ثبت ارجاع برای خودِ صاحب کد وجود ندارد.'})
@@ -72,6 +84,10 @@ class TrackConversionView(APIView):
class ConversionsListView(APIView): class ConversionsListView(APIView):
"""GET /api/affiliate/conversions — لیست تبدیل‌ها و کمیسیون""" """GET /api/affiliate/conversions — لیست تبدیل‌ها و کمیسیون"""
# سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `read` همان نرخِ مسیرهای هم‌خانواده است.
throttle_scope = 'read'
throttle_classes = [ScopedRateThrottle]
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@extend_schema(responses=ConversionListResponseSerializer, tags=['Affiliate (affiliate)']) @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

View File

View File

@@ -0,0 +1,135 @@
"""
گزارشِ کیفیت و مصرفِ دستیارِ هوش مصنوعی.
سینیور دو چیز خواست و هر دو از همین یک جدول درمی‌آیند:
«یک ساینتیشن برای لایک/دیسلایک بگیریم که بعداً بشود چک کرد کدام بهتر بوده»
«اگر لیمیت خورد یا تمام شد بهم بگید که شارژش کنیم»
نمونه:
python manage.py ai_feedback_report
python manage.py ai_feedback_report --days 7 --by grade_title
"""
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.db.models import Avg, Count, Q, Sum
from django.utils import timezone
from ai_tutor.models import Message
# قیمتِ تقریبیِ هر یک میلیون توکن (دلار) برای برآوردِ هزینه. عدد دقیق نیست و
# فقط برای «چقدر مانده / کِی باید شارژ کنیم» است.
_PRICE_PER_MTOK = {
'gpt-4o-mini': (0.15, 0.60),
'gpt-4o': (2.50, 10.00),
}
_GROUPABLE = ['prompt_version', 'model_name', 'grade_title', 'retrieval_collection']
class Command(BaseCommand):
help = 'نسبتِ 👍/👎 و مصرفِ توکنِ پاسخ‌های دستیار، به تفکیکِ نسخه‌ی پرامپت/مدل/پایه.'
def add_arguments(self, parser):
parser.add_argument('--days', type=int, default=30)
parser.add_argument('--by', choices=_GROUPABLE, default='prompt_version')
def handle(self, *args, **opts):
since = timezone.now() - timedelta(days=opts['days'])
field = opts['by']
qs = Message.objects.filter(role='assistant', created_at__gte=since)
total = qs.count()
if not total:
self.stdout.write(self.style.WARNING(
f'در {opts["days"]} روزِ گذشته هیچ پاسخِ دستیاری ثبت نشده است.'
))
return
rows = (
qs.values(field)
.annotate(
answers=Count('id'),
liked=Count('id', filter=Q(feedback__is_helpful=True)),
disliked=Count('id', filter=Q(feedback__is_helpful=False)),
with_context=Count('id', filter=Q(chunk_count__gt=0)),
avg_latency=Avg('latency_ms'),
tok_in=Sum('prompt_tokens'),
tok_out=Sum('completion_tokens'),
)
.order_by('-answers')
)
self.stdout.write(self.style.MIGRATE_HEADING(
f'\n{opts["days"]} روزِ گذشته — {total} پاسخِ دستیار، گروه‌بندی بر اساسِ {field}\n'
))
header = f'{field:<28} {"پاسخ":>6} {"👍":>5} {"👎":>5} {"رضایت":>8} {"با منبع":>9} {"میانگین ms":>11}'
self.stdout.write(header)
self.stdout.write('─' * len(header))
for r in rows:
rated = r['liked'] + r['disliked']
ratio = f"{100 * r['liked'] / rated:.0f}%" if rated else '—'
ctx = f"{100 * r['with_context'] / r['answers']:.0f}%"
label = (r[field] or '(خالی)')[:27]
self.stdout.write(
f"{label:<28} {r['answers']:>6} {r['liked']:>5} {r['disliked']:>5} "
f"{ratio:>8} {ctx:>9} {(r['avg_latency'] or 0):>11.0f}"
)
rated_total = qs.filter(feedback__isnull=False).count()
self.stdout.write('')
self.stdout.write(
f'رأی داده‌شده: {rated_total} از {total} پاسخ '
f'({100 * rated_total / total:.0f}%).'
)
# --- مصرف و هزینه ---------------------------------------------------
self.stdout.write(self.style.MIGRATE_HEADING('\nمصرفِ توکن (برای پیگیریِ شارژ)\n'))
# ⚠️ پاسخِ کش‌شده توکنِ فراخوانیِ اصلی را کپی می‌کند و هیچ هزینه‌ای
# نداشته؛ اگر بشماریمش، گزارشی که کارش تصمیم‌گیری درباره‌ی شارژ است
# به اندازه‌ی نرخِ اصابتِ کش هزینه را بیشتر نشان می‌دهد.
usage = (
qs.exclude(model_name='').filter(from_cache=False)
.values('model_name')
.annotate(tok_in=Sum('prompt_tokens'), tok_out=Sum('completion_tokens'), n=Count('id'))
.order_by('-n')
)
grand = 0.0
for u in usage:
tin, tout = u['tok_in'] or 0, u['tok_out'] or 0
price = _PRICE_PER_MTOK.get(u['model_name'])
if price:
cost = tin / 1e6 * price[0] + tout / 1e6 * price[1]
grand += cost
cost_text = f'≈ ${cost:.2f}'
else:
cost_text = 'قیمتِ این مدل در جدول نیست'
self.stdout.write(
f" {u['model_name']:<20} {u['n']:>5} پاسخ ورودی {tin:>9,} خروجی {tout:>9,} {cost_text}"
)
if grand:
self.stdout.write(self.style.WARNING(f'\n جمعِ تقریبی: ${grand:.2f}'))
cached = qs.filter(from_cache=True).count()
self.stdout.write(self.style.HTTP_INFO(
' توجه: پاسخ‌های استریمی usage برنمی‌گردانند و توکنشان صفر شمرده می‌شود، '
'پس عددِ واقعی از این بیشتر است.'
))
if cached:
self.stdout.write(self.style.HTTP_INFO(
f' {cached} پاسخ از کش آمده و در هزینه شمرده نشده (فراخوانیِ تازه‌ای نداشته).'
))
# --- بدترین پاسخ‌ها ---------------------------------------------------
worst = (
qs.filter(feedback__is_helpful=False)
.select_related('feedback')
.order_by('-created_at')[:10]
)
if worst:
self.stdout.write(self.style.MIGRATE_HEADING('\nآخرین پاسخ‌های 👎 (برای بازبینیِ دستی)\n'))
for m in worst:
reason = (m.feedback.reason or '').strip()
self.stdout.write(
f" #{m.id} [{m.prompt_version or '—'} / {m.grade_title or '—'} / "
f"chunk={m.chunk_count}] {m.text[:70]}…"
+ (f'\n دلیلِ کاربر: {reason}' if reason else '')
)

View File

@@ -0,0 +1,29 @@
# Generated by Django 5.2.17 on 2026-08-19 11:36
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai_tutor', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='AskIdempotency',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.IntegerField(db_index=True)),
('idempotency_key', models.CharField(max_length=100)),
('created_at', models.DateTimeField(auto_now_add=True)),
('assistant_message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='ai_tutor.message')),
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='ai_tutor.conversation')),
('user_message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='ai_tutor.message')),
],
options={
'constraints': [models.UniqueConstraint(fields=('user_id', 'idempotency_key'), name='uniq_ask_idempotency_key')],
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai_tutor', '0002_askidempotency'),
]
operations = [
migrations.AddField(
model_name='askidempotency',
name='suggested_follow_ups',
field=models.JSONField(default=list),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.17 on 2026-08-28 18:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai_tutor', '0003_askidempotency_suggested_follow_ups'),
]
operations = [
migrations.AddField(
model_name='conversation',
name='lesson_number',
field=models.IntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='conversation',
name='subject',
field=models.CharField(blank=True, default='', max_length=100),
),
]

View File

@@ -0,0 +1,78 @@
# Generated by Django 5.2.17 on 2026-09-12 14:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai_tutor', '0004_conversation_lesson_number_conversation_subject'),
]
operations = [
migrations.AddField(
model_name='message',
name='chunk_count',
field=models.PositiveSmallIntegerField(default=0),
),
migrations.AddField(
model_name='message',
name='completion_tokens',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='message',
name='from_cache',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='message',
name='grade_title',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='message',
name='latency_ms',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='message',
name='model_name',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='message',
name='prompt_tokens',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='message',
name='prompt_version',
field=models.CharField(blank=True, db_index=True, max_length=50),
),
migrations.AddField(
model_name='message',
name='retrieval_collection',
field=models.CharField(blank=True, max_length=200),
),
migrations.AddField(
model_name='message',
name='retrieval_filter_value',
field=models.CharField(blank=True, max_length=255),
),
migrations.AddField(
model_name='messagefeedback',
name='created_at',
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='messagefeedback',
name='reason',
field=models.CharField(blank=True, max_length=300),
),
migrations.AddField(
model_name='messagefeedback',
name='updated_at',
field=models.DateTimeField(auto_now=True, null=True),
),
]

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

@@ -6,6 +6,12 @@ class Conversation(models.Model):
lesson_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Lesson lesson_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Lesson
chapter_id = models.IntegerField(null=True, blank=True) # شناسه‌ی فصل RAG (خارج از content/) chapter_id = models.IntegerField(null=True, blank=True) # شناسه‌ی فصل RAG (خارج از content/)
grade_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Grade grade_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Grade
# metadataِ retrieval برای لایه‌ی RAG (اپ rag): نامِ درس (رشته) و
# شماره‌ی درس. اولین بار از بدنه‌ی POST /api/ai/ask می‌آن و اینجا ذخیره
# می‌شن تا ادامه‌ی همون مکالمه دوباره لازم نباشه فرستاده بشن. نامِ
# collectionِ Qdrant از روی subject + عنوانِ گرید ساخته می‌شه.
subject = models.CharField(max_length=100, blank=True, default='')
lesson_number = models.IntegerField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
@@ -19,7 +25,33 @@ class Message(models.Model):
role = models.CharField(max_length=10, choices=ROLE_CHOICES) role = models.CharField(max_length=10, choices=ROLE_CHOICES)
text = models.TextField() text = models.TextField()
question_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Question 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') --------------
# سینیور خواست لایک/دیسلایک بعداً قابلِ تحلیل باشد: «کدام جواب بهتر بوده».
# فیدبکِ خالی (یک بولین) این را نمی‌گوید — باید بدانیم آن جوابِ خاص با کدام
# مدل، کدام نسخه‌ی پرامپت و با چه context ای ساخته شده بود. این فیلدها
# هنگامِ تولید پر می‌شوند، نه هنگامِ فیدبک، تا همه‌ی پاسخ‌ها (حتی آن‌هایی که
# کسی رأی نداده) قابلِ مقایسه بمانند.
model_name = models.CharField(max_length=100, blank=True)
prompt_version = models.CharField(max_length=50, blank=True, db_index=True)
grade_title = models.CharField(max_length=50, blank=True)
retrieval_collection = models.CharField(max_length=200, blank=True)
retrieval_filter_value = models.CharField(max_length=255, blank=True)
chunk_count = models.PositiveSmallIntegerField(default=0)
prompt_tokens = models.PositiveIntegerField(default=0)
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: class Meta:
ordering = ['created_at'] ordering = ['created_at']
@@ -31,6 +63,48 @@ class Message(models.Model):
class MessageFeedback(models.Model): class MessageFeedback(models.Model):
message = models.OneToOneField(Message, on_delete=models.CASCADE, related_name='feedback') message = models.OneToOneField(Message, on_delete=models.CASCADE, related_name='feedback')
is_helpful = models.BooleanField() is_helpful = models.BooleanField()
# دلیلِ اختیاریِ کاربر. «چرا بد بود» تنها چیزی‌ست که از خودِ عدد درنمی‌آید
# و برای بهترکردنِ پرامپت واقعاً به کار می‌آید.
reason = models.CharField(max_length=300, blank=True)
created_at = models.DateTimeField(auto_now_add=True, null=True)
updated_at = models.DateTimeField(auto_now=True, null=True)
def __str__(self): def __str__(self):
return f"feedback-msg{self.message_id}" return f"feedback-msg{self.message_id}"
class AskIdempotency(models.Model):
"""
⚛️🔒 اصلاح idempotency برای POST /api/ai/ask (همون الگویی که
UserAnswer.idempotency_key/UserLessonCompletion.idempotency_key در
content و Order.idempotency_key در payment دارن): چون نتیجه‌ی هر
«سوال» شاملِ دو ردیفِ Message (کاربر + دستیار) و مصرفِ یک اسلاتِ
کوتای روزانه‌ست (نه یک ردیفِ تکی مثلِ UserAnswer)، این رکورد پل بین
(user, Idempotency-Key) و نتیجه‌ی از قبل ساخته‌شده رو نگه می‌داره تا
رتری/دابل‌کلیکِ همون کلید، دوباره پیام یا کوتای تکراری نسازه و به‌جاش
نتیجه‌ی همون درخواستِ قبلی برگردونده بشه. UniqueConstraint سطح DB روی
(user_id, idempotency_key) هم دفاع در عمق در برابر دو submit هم‌زمانِ
دقیقاً با همون کلیده (مشابه uniq_user_answer_idempotency_key).
"""
user_id = models.IntegerField(db_index=True)
idempotency_key = models.CharField(max_length=100)
conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name='+')
user_message = models.ForeignKey(Message, on_delete=models.CASCADE, related_name='+')
assistant_message = models.ForeignKey(Message, on_delete=models.CASCADE, related_name='+')
# ذخیره می‌شه تا replayِ idempotent بتونه بدونِ فراخوانیِ دوباره‌ی
# generate_tutor_answer همون مقدارِ اصلی رو برگردونه — وقتی provider
# واقعی جایگزینِ stub بشه، هر replay یعنی هزینه/تأخیرِ تکراری و
# follow-upهای متفاوت در هر retry، برخلافِ هدفِ idempotency.
suggested_follow_ups = models.JSONField(default=list)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=['user_id', 'idempotency_key'],
name='uniq_ask_idempotency_key',
),
]
def __str__(self):
return f"ask-idem-user{self.user_id}-{self.idempotency_key}"

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

@@ -7,22 +7,48 @@ class AskRequestSerializer(serializers.Serializer):
chapterId = serializers.CharField(required=False, allow_null=True, allow_blank=True) chapterId = serializers.CharField(required=False, allow_null=True, allow_blank=True)
gradeId = serializers.IntegerField(required=False, allow_null=True) gradeId = serializers.IntegerField(required=False, allow_null=True)
questionId = serializers.CharField(required=False, allow_null=True, allow_blank=True) questionId = serializers.CharField(required=False, allow_null=True, allow_blank=True)
userQuestion = serializers.CharField() # metadataِ retrieval برای لایه‌ی RAG (اپ rag): برای زدن به دیتای واقعیِ
# کتاب‌ها در Qdrant باید subject (مثلاً «علوم تجربی») + gradeId + lessonNumber
# همگی مطابقِ دادهٔ موجود ارسال بشن؛ اگه نباشن، پاسخ به‌صورتِ تدریسِ عمومی
# (بدونِ استناد به کتاب) تولید می‌شه. در ادامه‌ی همون مکالمه لازم نیست
# دوباره فرستاده بشن (روی Conversation ذخیره می‌شن).
subject = serializers.CharField(required=False, allow_null=True, allow_blank=True)
# جزءِ «پایه» در نامِ collectionِ Qdrant (مثلاً «ششم»). اگه بیاد مستقیم
# استفاده می‌شه؛ اگه نه، از روی gradeId و content.Grade.title / نگاشتِ
# 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(max_length=2000)
stream = serializers.BooleanField(required=False, default=False) stream = serializers.BooleanField(required=False, default=False)
class UsedChunkSerializer(serializers.Serializer):
title = serializers.CharField(allow_blank=True)
summary = serializers.CharField()
class AskResponseSerializer(serializers.Serializer): class AskResponseSerializer(serializers.Serializer):
conversationId = serializers.CharField() conversationId = serializers.CharField()
messageId = serializers.CharField() messageId = serializers.CharField()
answer = serializers.CharField() answer = serializers.CharField()
suggestedFollowUps = serializers.ListField(child=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)
# فقط وقتی DEBUG=True: از کدام collection/فصل چانک‌ها برداشته شد (بازبینیِ داخلی).
retrievalDebug = serializers.DictField(required=False)
class MessageSerializer(serializers.Serializer): class MessageSerializer(serializers.Serializer):
messageId = serializers.CharField() messageId = serializers.CharField()
role = serializers.CharField() role = serializers.CharField()
text = serializers.CharField() text = serializers.CharField()
# شکلِ پاسخ؛ شِمایش در rag/services/diagram.py (plane / numberline / fraction).
diagram = serializers.DictField(required=False, allow_null=True)
class ConversationMessagesResponseSerializer(serializers.Serializer): class ConversationMessagesResponseSerializer(serializers.Serializer):
@@ -33,7 +59,15 @@ class ConversationMessagesResponseSerializer(serializers.Serializer):
class FeedbackRequestSerializer(serializers.Serializer): class FeedbackRequestSerializer(serializers.Serializer):
isHelpful = serializers.BooleanField() isHelpful = serializers.BooleanField()
# اختیاری: «چرا». تنها چیزی که از خودِ 👍/👎 درنمی‌آید و برای اصلاحِ
# پرامپت واقعاً به کار می‌آید.
reason = serializers.CharField(max_length=300, required=False, allow_blank=True)
class FeedbackResponseSerializer(serializers.Serializer): class FeedbackResponseSerializer(serializers.Serializer):
success = serializers.BooleanField() success = serializers.BooleanField()
class QuotaResponseSerializer(serializers.Serializer):
freeDaily = serializers.IntegerField(help_text='سوال‌های رایگانِ هر روز برای این کاربر.')
remainingQuota = serializers.IntegerField(help_text='سوال‌های رایگانِ باقیِ امروز.')

View File

@@ -1,3 +1,522 @@
from django.test import TestCase from unittest.mock import patch
# Create your tests here. from django.core.cache import cache
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from rest_framework.throttling import ScopedRateThrottle
from accounts.authentication import issue_access_token
from accounts.models import User
from .models import AskIdempotency, Conversation, Message
from .views import generate_tutor_answer
class AskIdempotencyFollowUpsTests(TestCase):
"""
🔒 تستِ رگرسیونِ فیکس: _build_ask_response قبلاً برای بازسازیِ
suggestedFollowUps دوباره generate_tutor_answer رو صدا می‌زد — که با
یک AI provider واقعی یعنی یک فراخوانیِ واقعیِ دیگه به‌ازای هر replay.
حالا suggested_follow_ups در همون درخواستِ اولِ موفق ذخیره می‌شه و
replay بدونِ هیچ فراخوانیِ دیگری به generate_tutor_answer همون مقدار
رو برمی‌گردونه.
"""
def setUp(self):
# ⚠️ افزوده شده: بعد از اضافه‌شدنِ ScopedRateThrottle (scope='ai_ask')
# به AskAIView، cache باید بینِ تست‌ها پاک بشه؛ وگرنه شمارشگرِ throttle
# (که در محیطِ تست روی LocMemCache نگه‌داری می‌شه و به‌صورتِ خودکار
# بینِ تست‌ها پاک نمی‌شه) می‌تونه از یک تستِ قبلی باقی بمونه.
cache.clear()
self.client = APIClient()
self.user = User.objects.create(phone='09000000021', name='')
self.token = issue_access_token(self.user.id)
def _ask(self, idempotency_key):
return self.client.post(
'/api/ai/ask',
{'userQuestion': 'یک به‌علاوه یک چند می‌شود؟'},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
HTTP_IDEMPOTENCY_KEY=idempotency_key,
)
def test_initial_request_stores_generated_follow_ups(self):
response = self._ask('ask-key-1')
self.assertEqual(response.status_code, 200)
record = AskIdempotency.objects.get(user_id=self.user.id, idempotency_key='ask-key-1')
self.assertEqual(record.suggested_follow_ups, response.data['suggestedFollowUps'])
self.assertTrue(record.suggested_follow_ups)
def test_idempotent_retry_returns_same_follow_ups_without_calling_generator_again(self):
first_response = self._ask('ask-key-2')
self.assertEqual(first_response.status_code, 200)
original_follow_ups = first_response.data['suggestedFollowUps']
# ⚠️ در مسیرِ replay، generate_tutor_answer نباید اصلاً صدا زده
# بشه؛ اگه صدا زده بشه، این patch باعثِ خطا می‌شه و تست fail
# می‌کنه.
with patch(
'ai_tutor.views.generate_tutor_answer',
side_effect=AssertionError('generate_tutor_answer must not be called on idempotent replay'),
):
retry_response = self._ask('ask-key-2')
self.assertEqual(retry_response.status_code, 200)
self.assertEqual(retry_response.data['suggestedFollowUps'], original_follow_ups)
self.assertEqual(retry_response.json()['answer'], first_response.json()['answer'])
self.assertEqual(retry_response.data['messageId'], first_response.data['messageId'])
class AskAIGenerationFailureTests(TestCase):
"""
🔒 تستِ رگرسیونِ فیکسِ اصلیِ این تسک: جداکردنِ فراخوانیِ
generate_tutor_answer از قفلِ ردیفِ کاربر نباید رفتارِ صحتیِ سیستم رو
خراب کنه. اگه generate_tutor_answer خطا بده (مثلاً چون یک AI provider
واقعی موقتاً در دسترس نیست)، نباید:
- هیچ Message یتیمی (پیامِ کاربر بدونِ پاسخ) در دیتابیس بمونه؛
- اسلاتِ کوتای روزانه برای همیشه مصرف‌شده حساب بشه.
"""
def setUp(self):
# ⚠️ افزوده شده: نگاه کنید به توضیحِ مشابه در AskIdempotencyFollowUpsTests.setUp
cache.clear()
self.client = APIClient()
self.user = User.objects.create(phone='09000000022', name='')
self.token = issue_access_token(self.user.id)
def _ask(self, idempotency_key=None):
headers = {}
if idempotency_key:
headers['HTTP_IDEMPOTENCY_KEY'] = idempotency_key
return self.client.post(
'/api/ai/ask',
{'userQuestion': 'دو به‌علاوه دو چند می‌شود؟'},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
**headers,
)
def test_generation_failure_leaves_no_orphan_message_and_returns_503(self):
with patch(
'ai_tutor.views.generate_tutor_answer',
side_effect=ConnectionError('provider unreachable'),
):
response = self._ask()
self.assertEqual(response.status_code, 503)
self.assertEqual(Message.objects.filter(conversation__user_id=self.user.id).count(), 0)
self.assertEqual(Conversation.objects.filter(user_id=self.user.id).count(), 0)
def test_generation_failure_does_not_consume_quota_slot(self):
# یک سوالِ ناموفق می‌فرستیم (که نباید کوتا مصرف کنه)، بعد دقیقاً
# به اندازه‌ی سقفِ روزانه سوالِ موفق پشتِ سرِ هم می‌فرستیم؛ همه باید ۲۰۰
# بگیرن، یعنی سوالِ ناموفقِ اول اسلاتی از کوتا مصرف نکرده.
from .views import daily_quota
DAILY_AI_QUOTA = daily_quota(self.user.id)
with patch(
'ai_tutor.views.generate_tutor_answer',
side_effect=ConnectionError('provider unreachable'),
):
failed_response = self._ask()
self.assertEqual(failed_response.status_code, 503)
for _ in range(DAILY_AI_QUOTA):
ok_response = self._ask()
self.assertEqual(ok_response.status_code, 200)
# این سوالِ اضافه باید رد بشه چون سقف (نه یکی کمتر) واقعاً پر شده.
throttled_response = self._ask()
self.assertEqual(throttled_response.status_code, 429)
def test_generation_failure_on_existing_conversation_keeps_conversation(self):
# وقتی conversationId از قبل وجود داره (متعلق به این کاربر)، در
# صورتِ شکستِ generate_tutor_answer فقط user_messageِ همین
# درخواست باید حذف بشه، نه خودِ conversation (که ممکنه پیام‌های
# دیگه‌ای هم داشته باشه).
conversation = Conversation.objects.create(user_id=self.user.id)
Message.objects.create(conversation=conversation, role='user', text='سوالِ قدیمی')
Message.objects.create(conversation=conversation, role='assistant', text='پاسخِ قدیمی')
with patch(
'ai_tutor.views.generate_tutor_answer',
side_effect=ConnectionError('provider unreachable'),
):
response = self.client.post(
'/api/ai/ask',
{'userQuestion': 'یک سوالِ جدید', 'conversationId': str(conversation.id)},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
self.assertEqual(response.status_code, 503)
self.assertTrue(Conversation.objects.filter(pk=conversation.id).exists())
# فقط دو پیامِ قدیمی باید باقی مونده باشن؛ پیامِ کاربرِ رزروشده‌ی
# همین درخواستِ ناموفق باید پاک شده باشه.
self.assertEqual(conversation.messages.count(), 2)
class AskAIConcurrentIdempotencyKeyTests(TestCase):
"""
🔒⚛️ تستِ رگرسیونِ فیکسِ این تسک برای حالتِ خیلی نادرِ رقابتِ هم‌زمانِ
دو درخواست با دقیقاً همون Idempotency-Key، بعد از اینکه فراخوانیِ AI
از زیرِ قفلِ ردیفِ کاربر بیرون اومده. این تست به‌جای threadهای واقعی
(که روی SQLite ناپایدارن)، این رقابت رو به‌صورتِ قطعی/تکرارپذیر شبیه‌سازی
می‌کنه: داخلِ side_effectِ generate_tutor_answerِ درخواستِ «اول»، یک
درخواستِ HTTP کاملاً جداگانه (با همون کلید) فرستاده می‌شه که کاملاً
پردازش می‌شه و AskIdempotency رو زودتر می‌سازه (برنده)؛ درخواستِ
«اول» بعد از برگشتن از generate_tutor_answer باید در فازِ نهایی به
IntegrityError بخوره، رزروِ خودش رو پاک کنه، و نتیجه‌ی همون برنده رو
برگردونه.
"""
def setUp(self):
# ⚠️ افزوده شده: نگاه کنید به توضیحِ مشابه در AskIdempotencyFollowUpsTests.setUp
cache.clear()
self.client = APIClient()
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):
key = 'race-key-1'
inner_client = APIClient()
# ⚠️ چون generate_tutor_answer برای کلِ طولِ این with-block پچ
# شده، درخواستِ داخلیِ زیر هم به همین side_effect برمی‌خوره؛ برای
# جلوگیری از بازگشتِ بی‌نهایت، فقط اولین فراخوانی (درخواستِ
# بیرونی) درخواستِ داخلی رو می‌سازه؛ خودِ فراخوانیِ generate_tutor_
# answerِ درخواستِ داخلی (فراخوانیِ دوم) صرفاً یک پاسخِ قطعی
# برمی‌گردونه.
call_count = {'n': 0}
def side_effect(user_question, grade_id, lesson_id, context=None):
call_count['n'] += 1
if call_count['n'] == 1:
# این «درخواستِ داخلی» با همون idempotency key، در طولِ
# فراخوانیِ AIِ درخواستِ بیرونی (که قفلِ ردیفِ کاربر رو
# دیگه نگه نداره) کاملاً اجرا و کامل می‌شه — یعنی برنده‌ی
# رقابت می‌شه.
resp = inner_client.post(
'/api/ai/ask',
{'userQuestion': user_question},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
HTTP_IDEMPOTENCY_KEY=key,
)
assert resp.status_code == 200, resp.content
side_effect.inner_response = resp
return 'پاسخِ واقعی برای سوال', ['ادامه بده']
return 'پاسخِ دستیار برای درخواستِ داخلی', ['ادامه بده']
with patch('ai_tutor.views.generate_tutor_answer', side_effect=side_effect):
outer_response = self.client.post(
'/api/ai/ask',
{'userQuestion': 'سوالِ رقابتی'},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
HTTP_IDEMPOTENCY_KEY=key,
)
inner_response = side_effect.inner_response
self.assertEqual(outer_response.status_code, 200)
# هر دو پاسخ باید دقیقاً به همون AskIdempotency (نتیجه‌ی برنده)
# اشاره کنن.
self.assertEqual(outer_response.data['messageId'], inner_response.data['messageId'])
self.assertEqual(outer_response.json()['answer'], inner_response.json()['answer'])
# فقط یک رکوردِ AskIdempotency برای این کلید باید وجود داشته باشه.
self.assertEqual(
AskIdempotency.objects.filter(user_id=self.user.id, idempotency_key=key).count(), 1
)
# فقط یک پیامِ کاربر و یک پیامِ دستیار باید نهایتاً باقی مونده
# باشن؛ پیامِ رزروشده‌ی درخواستِ بازنده باید پاک شده باشه (یعنی
# کوتای روزانه فقط یک بار مصرف شده، نه دوبار).
self.assertEqual(
Message.objects.filter(conversation__user_id=self.user.id, role='user').count(), 1
)
self.assertEqual(
Message.objects.filter(conversation__user_id=self.user.id, role='assistant').count(), 1
)
class AskAIConversationApiContractTests(TestCase):
"""
🔒 تستِ رگرسیونِ این تسک: اضافه‌شدنِ مرزِ context به generate_tutor_answer
نباید هیچ تغییری در قراردادِ API endpoint (POST /api/ai/ask) بده — نه
برای شروعِ مکالمه‌ی جدید (بدونِ conversationId)، نه برای ادامه‌ی
مکالمه‌ی موجود (با conversationId)، و نه در چک‌های مالکیت/احراز هویت.
"""
def setUp(self):
# ⚠️ افزوده شده: نگاه کنید به توضیحِ مشابه در AskIdempotencyFollowUpsTests.setUp
cache.clear()
self.client = APIClient()
self.user = User.objects.create(phone='09000000024', name='')
self.token = issue_access_token(self.user.id)
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):
self.assertEqual(Conversation.objects.filter(user_id=self.user.id).count(), 0)
response = self.client.post(
'/api/ai/ask',
{'userQuestion': 'سلام'},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
self.assertEqual(response.status_code, 200)
conversations = Conversation.objects.filter(user_id=self.user.id)
self.assertEqual(conversations.count(), 1)
self.assertEqual(response.data['conversationId'], str(conversations.first().id))
def test_existing_conversation_id_continues_same_conversation(self):
conversation = Conversation.objects.create(user_id=self.user.id)
Message.objects.create(conversation=conversation, role='user', text='سوالِ قبلی')
Message.objects.create(conversation=conversation, role='assistant', text='پاسخِ قبلی')
response = self.client.post(
'/api/ai/ask',
{'userQuestion': 'سوالِ دوم', 'conversationId': str(conversation.id)},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['conversationId'], str(conversation.id))
# هنوز فقط همون یک conversation باید وجود داشته باشه؛ چیزِ جدیدی
# ساخته نشده.
self.assertEqual(Conversation.objects.filter(user_id=self.user.id).count(), 1)
self.assertEqual(conversation.messages.count(), 4)
def test_conversation_id_belonging_to_another_user_is_rejected(self):
other_conversation = Conversation.objects.create(user_id=self.other_user.id)
response = self.client.post(
'/api/ai/ask',
{'userQuestion': 'سوال', 'conversationId': str(other_conversation.id)},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
self.assertEqual(response.status_code, 404)
self.assertEqual(other_conversation.messages.count(), 0)
class GenerationBoundaryContextTests(TestCase):
"""
🏗️ تستِ رگرسیونِ اصلیِ این تسک: مرزِ تولیدِ پاسخ
(generate_tutor_answer(user_question, grade_id, lesson_id, context))
باید:
- همچنان gradeId/lessonId رو بگیره (metadataِ لازم برای RAG/Qdrantِ
آینده)، دقیقاً همون‌هایی که در بدنه‌ی API درخواست اومدن؛
- بتونه یک context اپلیکیشن‌-ساخته (رشته/شیء/لیستِ پیام) هم کنارِ اون‌ها
بگیره، بدونِ اینکه بدونه اون context چطور retrieve شده؛
و AskAIView (که هنوز لایه‌ی RAG/Qdrant رو پیاده نمی‌کنه) نباید خودش
تاریخچه‌ی خامِ مکالمه رو بسازه و به این مرز بفرسته.
"""
def setUp(self):
# ⚠️ افزوده شده: نگاه کنید به توضیحِ مشابه در AskIdempotencyFollowUpsTests.setUp
cache.clear()
def test_generate_tutor_answer_accepts_grade_lesson_and_application_provided_context(self):
# مرز باید grade_id/lesson_id رو (به‌عنوانِ metadataِ RAGِ آینده) و
# context رو (چه رشته باشه، چه شیء/دیکشنری، چه لیستِ پیام‌های
# retrieve-شده) به‌عنوانِ آرگومان‌هایی مستقل کنارِ سوالِ کاربر قبول
# کنه.
answer_text, follow_ups = generate_tutor_answer(
'مشتق تابع sin(x) چیست؟',
7,
'42',
context={'retrieved_snippets': ['قاعده‌ی مشتق‌گیریِ توابعِ مثلثاتی']},
)
self.assertIn('مشتق تابع sin(x) چیست؟', answer_text)
self.assertTrue(follow_ups)
# بدونِ context هم (رفتارِ فعلی/پیش‌فرض) باید عادی کار کنه؛ context
# باید کاملاً اختیاری بمونه.
answer_text_no_context, _ = generate_tutor_answer('یک سوال بدون context', None, None)
self.assertIn('یک سوال بدون context', answer_text_no_context)
def test_ask_ai_view_passes_grade_and_lesson_id_from_request_to_generation_boundary(self):
# gradeId/lessonIdِ همون درخواستِ API باید دقیقاً همون‌طور که
# فرستاده شدن به مرزِ تولید برسن — این metadataی لازم برای
# RAG/Qdrantِ آینده‌ست، نه تاریخچه.
client = APIClient()
user = User.objects.create(phone='09000000026', name='')
token = issue_access_token(user.id)
with patch(
'ai_tutor.views.generate_tutor_answer',
wraps=generate_tutor_answer,
) as mocked_generate:
response = client.post(
'/api/ai/ask',
{
'userQuestion': 'مشتق چیست؟',
'gradeId': 10,
'lessonId': 'lesson-55',
},
format='json',
HTTP_AUTHORIZATION=f'Bearer {token}',
)
self.assertEqual(response.status_code, 200)
mocked_generate.assert_called_once_with('مشتق چیست؟', 10, 'lesson-55', None)
def test_ask_ai_view_does_not_send_conversation_history_to_generation_boundary(self):
# این تستِ کلیدیِ این تسکه: وقتی AskAIView مرزِ تولید رو صدا می‌زنه،
# نباید هیچ تاریخچه‌ی خامِ Message/Conversation (لیستی از پیام‌های
# قبلی، یا خودِ QuerySet پیام‌ها) بهش پاس بده — چون RAG هنوز اینجا
# پیاده نشده، context باید None بمونه (فقط gradeId/lessonId/سوال
# مجازن).
client = APIClient()
user = User.objects.create(phone='09000000027', name='')
token = issue_access_token(user.id)
conversation = Conversation.objects.create(user_id=user.id)
Message.objects.create(conversation=conversation, role='user', text='سوالِ قدیمیِ اول')
Message.objects.create(conversation=conversation, role='assistant', text='پاسخِ قدیمیِ اول')
Message.objects.create(conversation=conversation, role='user', text='سوالِ قدیمیِ دوم')
Message.objects.create(conversation=conversation, role='assistant', text='پاسخِ قدیمیِ دوم')
with patch(
'ai_tutor.views.generate_tutor_answer',
wraps=generate_tutor_answer,
) as mocked_generate:
response = client.post(
'/api/ai/ask',
{'userQuestion': 'سوالِ جدید', 'conversationId': str(conversation.id)},
format='json',
HTTP_AUTHORIZATION=f'Bearer {token}',
)
self.assertEqual(response.status_code, 200)
mocked_generate.assert_called_once()
call_args, call_kwargs = mocked_generate.call_args
all_args = list(call_args) + list(call_kwargs.values())
# آرگومانِ اول باید فقط همین سوالِ تازه باشه، نه چیزِ دیگه‌ای.
self.assertEqual(call_args[0], 'سوالِ جدید')
# هیچ آرگومانی نباید حاویِ متنِ پیام‌های قدیمی یا خودِ آبجکت‌های
# Message/Conversation باشه — یعنی هیچ تاریخچه‌ی خامی به مرزِ تولید
# پاس داده نشده.
for arg in all_args:
self.assertNotIsInstance(arg, (Message, Conversation))
if isinstance(arg, str):
self.assertNotIn('قدیمی', arg)
# context (چهارمین آرگومان) در این تسک باید None باشه (RAG/Qdrant
# هنوز پیاده نشده)؛ gradeId/lessonId هم چون در این درخواست فرستاده
# نشدن، None هستن.
context_arg = call_args[3] if len(call_args) > 3 else call_kwargs.get('context')
self.assertIsNone(context_arg)
class AskAIViewThrottleTests(TestCase):
"""
🔒 اثبات محدودیتِ جدیدِ ScopedRateThrottle (scope='ai_ask') روی
AskAIView: قبلاً این endpoint هیچ throttle مخصوصی نداشت (فقط
DAILY_AI_QUOTA که سقفِ روزانه است، نه محدودیتِ burst کوتاه‌مدت).
این تست ثابت می‌کند که درخواست‌های مجاز داخلِ نرخ همچنان کار می‌کنند
و درخواستِ بیش از حد با 429 رد می‌شود — بدون اینکه به کوتای روزانه یا
idempotency دست بزند.
"""
def setUp(self):
self.client = APIClient()
cache.clear()
self.user = User.objects.create(phone='09000000028', name='')
self.token = issue_access_token(self.user.id)
def _ask(self, idempotency_key):
return self.client.post(
'/api/ai/ask',
{'userQuestion': 'یک به‌علاوه یک چند می‌شود؟'},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
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 سنجیده شود.
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'ai_ask': '3/hour'}):
for i in range(3):
response = self._ask(f'ai-ask-throttle-key-{i}')
self.assertEqual(response.status_code, 200)
throttled_response = self._ask('ai-ask-throttle-key-3')
self.assertEqual(throttled_response.status_code, 429)
class AskAIViewPerUserThrottleTests(TestCase):
"""هر کاربرِ ثبت‌نام‌شده سطلِ throttleِ جدای خودش را دارد؛ مصرفِ حسابِ دیگر به او نمی‌رسد."""
def setUp(self):
cache.clear()
self.client = APIClient()
def _ask(self, token, idempotency_key):
return self.client.post(
'/api/ai/ask',
{'userQuestion': 'یک به‌علاوه یک چند می‌شود؟'},
format='json',
HTTP_AUTHORIZATION=f'Bearer {token}',
HTTP_IDEMPOTENCY_KEY=idempotency_key,
)
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(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(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 django.urls import path
from .views import AskAIView, ConversationMessagesView, MessageFeedbackView from .views import AskAIView, ConversationMessagesView, MessageFeedbackView, QuotaView
urlpatterns = [ urlpatterns = [
path('ai/ask', AskAIView.as_view(), name='ai-ask'), 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/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'), path('ai/messages/<int:message_id>/feedback', MessageFeedbackView.as_view(), name='ai-message-feedback'),
] ]

View File

@@ -1,15 +1,28 @@
from django.db import transaction import json
import logging
import time
from django.conf import settings
from django.db import IntegrityError, transaction
from django.http import Http404, StreamingHttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils import timezone from django.utils import timezone
from drf_spectacular.utils import extend_schema from drf_spectacular.types import OpenApiTypes
from rest_framework.exceptions import Throttled from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework.exceptions import APIException, Throttled
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView from rest_framework.views import APIView
from accounts.models import User as AccountUser from accounts.models import User as AccountUser
from .models import Conversation, Message, MessageFeedback 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 ( from .serializers import (
QuotaResponseSerializer,
AskRequestSerializer, AskRequestSerializer,
AskResponseSerializer, AskResponseSerializer,
ConversationMessagesResponseSerializer, ConversationMessagesResponseSerializer,
@@ -17,109 +30,819 @@ from .serializers import (
FeedbackResponseSerializer, FeedbackResponseSerializer,
) )
DAILY_AI_QUOTA = 15 logger = logging.getLogger(__name__)
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 generate_tutor_answer(user_question: str, grade_id, lesson_id) -> tuple[str, list[str]]: 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:
""" """
استاب پاسخ AI Tutor. اینجا محل اتصال به مدل واقعی (مثلاً Claude API از طریق {'remainingQuota'}: سوال‌های باقیِ امروز، هر بار تازه از دیتابیس. همه‌ی پاسخ‌ها از همین می‌خوانند تا
Anthropic SDK) هست؛ فعلاً برای اینکه بدون نیاز به کلید API هم بشه کل جریان شمارنده‌ی اپ هیچ‌وقت از موجودیِ واقعی جدا نیفتد.
رو با Swagger تست کرد، یک پاسخ ساده و قطعی برمی‌گردونه.
""" """
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:
"""
متادیتای بازیابی/تولید → فیلدهای Message.
همه‌ی مقادیر به str/int امن تبدیل می‌شوند و هر کلیدِ غایب مقدارِ پیش‌فرض
می‌گیرد: این متادیتا از cache هم می‌آید و cacheهای قدیمی (که پیش از افزودنِ
این فیلدها ست شده‌اند) کلیدها را ندارند — نبودشان نباید ثبتِ پیام را
بشکند، چون آن‌وقت خودِ پاسخِ کاربر هم از بین می‌رود.
"""
retrieval_meta = retrieval_meta or {}
generation_meta = generation_meta or {}
filter_value = retrieval_meta.get('filterValue')
return {
'model_name': str(generation_meta.get('model') or '')[:100],
'prompt_version': str(generation_meta.get('promptVersion') or '')[:50],
'grade_title': str(retrieval_meta.get('gradeTitle') or '')[:50],
'retrieval_collection': str(retrieval_meta.get('collection') or '')[:200],
'retrieval_filter_value': ('' if filter_value is None else str(filter_value))[:255],
'chunk_count': int(retrieval_meta.get('chunkCount') or 0),
'prompt_tokens': int(generation_meta.get('promptTokens') or 0),
'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,
}
def _is_db_ref_id(value) -> bool:
"""
⚠️ افزوده شده: lessonId/chapterId/questionId در قراردادِ API به‌صورتِ
رشته (CharField) پذیرفته می‌شن، اما وقتی به‌عنوانِ ارجاعِ سبکِ (لایه‌ی
DB) روی Conversation/Message ذخیره می‌شن، فیلدِ مقصد IntegerField‌ه.
این تابع فقط تشخیص می‌ده که آیا مقدار برای ذخیره‌سازی در همچین
فیلدی امن/عددی هست یا نه؛ روی مرزِ generate_tutor_answer اثری نداره
(اونجا همیشه مقدارِ خامِ اصلی پاس داده می‌شه).
پیامِ دستیار هم از همین مسیر (`_db_ref_or_none`) رد می‌شود؛ پیش از
۲۰۲۶-۰۹-۲۵ نمی‌شد و questionIdِ غیرعددی *بعد از* تولیدِ جواب ۵۰۰ می‌داد و
سهمِ روزانه مصرف‌شده می‌ماند.
"""
if value in (None, ''):
return False
# 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):
"""
⚠️ افزوده شده: وقتی generate_tutor_answer به یک AI provider واقعی
(OpenAI/GapGPT/...) وصل بشه، شکستِ فراخوانیِ شبکه‌ای (timeout، خطای
provider و غیره) باید یک خطای واضح و قابلِ retry برای کلاینت باشه، نه
یک 500 خام. جزئیاتِ فنیِ خطای اصلی در لاگ سرور ثبت می‌شه (نگاه کنید به
AskAIView.post)، نه در پاسخِ کلاینت.
"""
status_code = 503
default_detail = 'سرویس هوش مصنوعی موقتاً در دسترس نیست. لطفاً دوباره تلاش کنید.'
default_code = 'ai_provider_unavailable'
def generate_tutor_answer(
user_question: str, grade_id, lesson_id, context=None
) -> tuple[str, list[str]]:
"""
مرزِ (boundary) تولیدِ پاسخِ AI Tutor.
امضا: (user_question, grade_id, lesson_id, context). context یک پارامترِ
عمومیِ اختیاریِ application-generated است که این تابع صرفاً مصرف می‌کنه،
بدونِ اینکه بدونه از کجا retrieve شده. جریان:
gradeId + subject + lessonNumber + userQuestion → لایه‌ی RAG (اپ rag،
Qdrant) → ~۴ chunkِ انتخاب‌شده به‌عنوانِ context → این تابع (لایه‌ی
تولید) → provider (OpenAI از طریقِ GapGPT)
AskAIView خودش retrieval را انجام می‌ده و نتیجه را به‌عنوانِ context
(لیستی از dictهای {title, summary, score}) پاس می‌ده؛ این تابع فقط
تولید می‌کنه. مالکیتِ تاریخچه‌ی مکالمه همیشه دستِ دیتابیسِ خودمونه، نه
provider.
اگه 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():
from rag.services.generation import generate_answer
context_chunks = context if isinstance(context, list) else None
# استثناهای شبکه/provider اینجا خفه نمی‌شن؛ AskAIView اون‌ها رو به
# 503 AIProviderUnavailable تبدیل می‌کنه (و اسلاتِ رزروشده رو آزاد).
# این تابع قراردادِ دوتایی‌اش را نگه می‌دارد؛ متادیتای تولید فقط در
# مسیرِ اصلیِ AskAIView (که آن را ذخیره می‌کند) لازم است.
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 = ( answer = (
f'این یک پاسخ نمونه (stub) برای سوال «{user_question}» است. ' f'این یک پاسخ نمونه (stub) برای سوال «{user_question}»{context_note} است. '
'برای اتصال به مدل واقعی هوش مصنوعی، این تابع را در ai_tutor/views.py ' 'برای اتصال به مدل واقعی هوش مصنوعی، OPENAI_API_KEY و QDRANT_API_KEY '
'جایگزین کنید (مثلاً با فراخوانی Anthropic API).' 'را در .env تنظیم کنید (لایه‌ی RAG در اپ rag).'
) )
follow_ups = ['یک مثال دیگر بزن', 'بیشتر توضیح بده'] follow_ups = ['یک مثال دیگر بزن', 'بیشتر توضیح بده']
return answer, follow_ups 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): class AskAIView(APIView):
"""POST /api/ai/ask — سوال از AI (استریم اختیاری؛ در این فاز استریم پیاده نشده)""" """POST /api/ai/ask — سوال از AI (استریم اختیاری؛ در این فاز استریم پیاده نشده)"""
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
# daily_quota فقط سقفِ روزانه‌ست و جلوی burstِ کوتاه‌مدت رو نمی‌گیره؛
# این throttle_scope مجزا اون burst رو محدود می‌کنه.
throttle_scope = 'ai_ask'
throttle_classes = [ScopedRateThrottle]
@extend_schema(request=AskRequestSerializer, responses=AskResponseSerializer, tags=['AI Tutor (ai_tutor)']) @extend_schema(
request=AskRequestSerializer,
responses=AskResponseSerializer,
parameters=[
OpenApiParameter(
'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False,
description=(
'کلید اختیاری idempotency (مثل payment و content). اگه ست بشه، '
'رتری/دابل‌کلیکِ همون سوال با همون کلید، دوباره پیامِ کاربر/دستیارِ '
'تکراری نمی‌سازه و اسلاتِ کوتای روزانه‌ی دیگری مصرف نمی‌کنه؛ به‌جاش '
'نتیجه‌ی همون درخواستِ قبلاً پردازش‌شده برگردونده می‌شه.'
),
),
],
tags=['AI Tutor (ai_tutor)'],
)
def post(self, request): 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 = AskRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
v = serializer.validated_data v = serializer.validated_data
user = request.user user = request.user
idempotency_key = request.headers.get('Idempotency-Key') or None
# درخواست تکراری با همین کلید: نتیجه‌ی قبلی برگردانده می‌شود، بدون مصرف دوباره‌ی کوتا.
if idempotency_key:
existing = AskIdempotency.objects.filter(
user_id=user.id, idempotency_key=idempotency_key,
).select_related('conversation', 'user_message', 'assistant_message').first()
if existing is not None:
return Response(self._build_ask_response(existing).data)
# سه فاز جدا: فاز ۱ کوتای روزانه را زیر قفل ردیف کاربر چک/رزرو می‌کند،
# فاز ۲ (فراخوانی AI) کاملاً بیرون از هر قفل/تراکنشی اجرا می‌شود تا
# تأخیر شبکه قفل کاربر را نگه ندارد، فاز ۳ پیام دستیار و idempotency
# را ثبت می‌کند. اگر فاز ۲/۳ شکست بخورد، رزرو فاز ۱ آزاد می‌شود.
# محافظِ دستیار، لایه‌ی ۱ (rag/services/guardrails.py): ترفندِ آشکار برای عوض کردنِ
# قواعد، یا جمله‌ای درباره‌ی آسیب به خود، اصلاً به مدل نمی‌رسد؛ پس هزینه‌ای ندارد و از
# سهمیه هم کم نمی‌شود. سقفِ burst (throttle_scope=ai_ask) جلوی تکرارش را می‌گیرد.
from rag.services import guardrails
screened = guardrails.screen(v['userQuestion'])
# ⚠️ اصلاح: چک و مصرفِ کوتای روزانه قبلاً race condition داشت —
# دو درخواست هم‌زمان از همون کاربر می‌تونستن هر دو شمارشِ «امروز»
# رو قبل از ساختن پیامِ خودشون بخونن و هر دو رد بشن، در نتیجه از
# سقف ۱۵ سوال روزانه عبور کنن. حالا با قفل‌کردن ردیف کاربر
# (select_for_update) در یک تراکنش اتمیک، چک و مصرفِ کوتا برای هر
# کاربر serialize می‌شه؛ درخواست‌های هم‌زمانِ همون کاربر منتظر آزاد
# شدن قفل می‌مونن و دیگه نمی‌تونن هم‌زمان از کنار چک رد بشن.
with transaction.atomic(): with transaction.atomic():
AccountUser.objects.select_for_update().get(pk=user.id) AccountUser.objects.select_for_update().get(pk=user.id)
# هر سوالی که به API می‌رسد پول است و از سقفِ روزانه کم می‌شود (رایگان: AI_DAILY_QUOTA_FREE،
# مشترک: Plan.ai_daily_quota). قفلِ ردیفِ کاربر جلوی دو سوالِ هم‌زمان روی آخرین سهم را می‌گیرد.
today = timezone.localdate() today = timezone.localdate()
used_today = Message.objects.filter( 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() ).count()
if used_today >= DAILY_AI_QUOTA: quota = daily_quota(user.id)
raise Throttled(detail='سقف روزانه‌ی سوال از هوش مصنوعی به پایان رسیده است.') 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_id = v.get('conversationId')
conversation_is_new = not conversation_id
if conversation_id: if conversation_id:
conversation = get_object_or_404(Conversation, pk=conversation_id, user_id=user.id) # ورودی غیرعددی هم باید 404 بدهد، نه خطای خام ORM.
try:
conversation = get_object_or_404(Conversation, pk=conversation_id, user_id=user.id)
except (ValueError, TypeError):
raise Http404
else: else:
# lessonId/chapterId روی Conversation فیلدهای IntegerField هستند؛
# مقدار غیرعددی نادیده گرفته می‌شود (None ذخیره می‌شود) اما مقدار
# خام همچنان بدون تغییر به generate_tutor_answer می‌رسد.
raw_lesson_id = v.get('lessonId')
raw_chapter_id = v.get('chapterId')
conversation = Conversation.objects.create( conversation = Conversation.objects.create(
user_id=user.id, user_id=user.id,
lesson_id=v.get('lessonId') or None, lesson_id=raw_lesson_id if _is_db_ref_id(raw_lesson_id) else None,
chapter_id=v.get('chapterId') or None, chapter_id=raw_chapter_id if _is_db_ref_id(raw_chapter_id) else None,
grade_id=v.get('gradeId'), grade_id=v.get('gradeId'),
subject=(v.get('subject') or '').strip(),
lesson_number=v.get('lessonNumber'),
) )
Message.objects.create( raw_question_id = v.get('questionId')
user_message = Message.objects.create(
conversation=conversation, conversation=conversation,
role='user', role='user',
text=v['userQuestion'], text=v['userQuestion'],
question_id=v.get('questionId') or None, question_id=raw_question_id if _is_db_ref_id(raw_question_id) else None,
charge=charge,
) )
# قفل ردیف کاربر اینجا آزاد شده؛ فراخوانی AI بیرون از هر تراکنشی اجرا می‌شود.
# لایه‌ی RAG (اپ rag) اینجا context را می‌سازد: پرسش → embedding →
# جستجوی Qdrant (محدود به subject+grade+lessonNumber) → چند خلاصه.
# اگر RAG پیکربندی نشده باشد context=None می‌ماند و generate_tutor_answer
# همان پاسخِ stub را می‌دهد. خطای شبکه/provider در همین try به 503
# تبدیل و اسلاتِ رزروشده آزاد می‌شود.
# مقادیرِ ذخیره‌شده روی مکالمه هم fallback‌اند: در پیامِ دوم به بعد، اپ
# فقط conversationId می‌فرستد و دیگر lessonId/gradeId نمی‌آید.
grade_id = v.get('gradeId') or conversation.grade_id
lesson_id = v.get('lessonId') or conversation.lesson_id
# ⚠️ درسِ تازه‌ی همین درخواست از کتابِ ذخیره‌شده‌ی مکالمه جلو می‌افتد: از وقتی یک پایه چند
# کتاب دارد، مکالمه‌ای که با درسِ هندسه شروع شده می‌تواند با درسِ حسابان ادامه پیدا کند و
# `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 and not explicit_lesson:
lesson_number = conversation.lesson_number
answer_text, follow_ups = generate_tutor_answer( # اگر اپ فقط lessonId (یا فقط conversationId) داده — حالتِ طبیعی، چون
v['userQuestion'], v.get('gradeId'), v.get('lessonId') # می‌داند دانش‌آموز روی کدام درس است نه اینکه نامِ collectionِ Qdrant
) # چیست — خودمان subject/پایه/فصل را از زنجیره‌ی
assistant_message = Message.objects.create( # Lesson→Node→Unit→Grade درمی‌آوریم.
conversation=conversation, #
role='assistant', # ⚠️ شرط عمداً `grade_title` را هم می‌بیند: پیش‌تر فقط subject و
text=answer_text, # lesson_number را چک می‌کرد، و چون Conversation فیلدی برای عنوانِ پایه
question_id=v.get('questionId') or None, # ندارد، از پیامِ دومِ هر مکالمه grade_title خالی می‌ماند و
) # resolve_target هیچ collectionی نمی‌ساخت — یعنی فقط جوابِ اول به کتاب
# مستند بود و بقیه بی‌سروصدا عمومی می‌شدند.
if lesson_id and not (subject and grade_title and lesson_number is not None):
from rag.services.lesson_context import resolve_from_lesson
used_today += 1 # همین سوالی که الان ثبت شد هم جزو مصرف امروزه derived = resolve_from_lesson(lesson_id)
remaining_quota = max(DAILY_AI_QUOTA - used_today, 0) subject = subject or derived.get('subject', '')
grade_title = grade_title or derived.get('gradeTitle', '')
if lesson_number is None:
lesson_number = derived.get('chapter')
# تا آخرِ همین مکالمه دوباره حساب نشود.
if subject and not conversation.subject:
conversation.subject = subject
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
# نمی‌فرسته همون مسیرِ قبلی رو می‌گیره.
if v.get('stream'):
from rag.services.config import rag_is_configured
if rag_is_configured():
return self._streaming_response(
request=request, v=v, user=user, conversation=conversation,
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, screened=screened, quota=quota, age=age,
)
used_chunks: list[dict] = []
retrieval_meta: dict = {}
generation_meta: dict = {}
answer_from_cache = False
context = None
try:
from rag.services.config import 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'], _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'])
used_chunks = cached['usedChunks']
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(
_retrieval_query(v['userQuestion'], history, focus),
subject=subject, grade_id=grade_id,
grade_title=grade_title, lesson_number=lesson_number,
)
used_chunks = [
{'title': c.get('title', ''), 'summary': c.get('summary', '')}
for c in used_chunks
]
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,
)
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 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()
try:
with transaction.atomic():
assistant_message = Message.objects.create(
conversation=conversation,
role='assistant',
text=answer_text,
question_id=_db_ref_or_none(v.get('questionId')),
**_annotation(retrieval_meta, generation_meta, from_cache=answer_from_cache),
)
if idempotency_key:
AskIdempotency.objects.create(
user_id=user.id,
idempotency_key=idempotency_key,
conversation=conversation,
user_message=user_message,
assistant_message=assistant_message,
suggested_follow_ups=follow_ups,
)
except IntegrityError:
if not idempotency_key:
raise
existing = AskIdempotency.objects.filter(
user_id=user.id, idempotency_key=idempotency_key,
).select_related('conversation', 'user_message', 'assistant_message').first()
if existing is None:
raise
self._release_reserved_slot(conversation, user_message, conversation_is_new)
return Response(self._build_ask_response(existing).data)
state = quota_state(user.id)
data = { data = {
'conversationId': str(conversation.id), 'conversationId': str(conversation.id),
'messageId': str(assistant_message.id), 'messageId': str(assistant_message.id),
'answer': answer_text, 'answer': answer_text,
'suggestedFollowUps': follow_ups, '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
],
} }
# فقط برای بازبینیِ داخلیِ خودمان (نه کاربر): از کدام collection/فصل
# چانک‌ها برداشته شد. فقط وقتی DEBUG روشن است در پاسخ می‌آید.
if settings.DEBUG and retrieval_meta:
data['retrievalDebug'] = {
'collection': retrieval_meta.get('collection'),
'filterKey': retrieval_meta.get('filterKey'),
'chapterOrLesson': retrieval_meta.get('filterValue'),
'gradeTitle': retrieval_meta.get('gradeTitle'),
'chunkCount': retrieval_meta.get('chunkCount'),
'chunkTitles': [c.get('title', '') for c in used_chunks],
}
return Response(AskResponseSerializer(data).data) return Response(AskResponseSerializer(data).data)
def _streaming_response(self, *, request, v, user, conversation, user_message,
conversation_is_new, idempotency_key, used_today,
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, diagram, usedChunks[, retrievalDebug]}
event: error data: {"detail": "..."}
فاز ۱ (رزرو کوتا + conversation + user_message) قبلاً در post() انجام شده.
"""
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:
return f'event: {event}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n'
def _gen():
nonlocal used_today
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'], _cache_grade(grade_title, age))
try:
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(
_retrieval_query(v['userQuestion'], history, focus),
subject=subject, grade_id=grade_id,
grade_title=grade_title, lesson_number=lesson_number,
)
used_chunks = [
{'title': c.get('title', ''), 'summary': c.get('summary', '')}
for c in used_chunks
]
parts: list[str] = []
stream_result: dict = {}
started = time.monotonic()
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()
# توکن‌ها از آخرین تکه‌ی استریم می‌آیند (stream_options.include_usage).
# تا ۲۰۲۶-۱۰-۰۱ این‌جا صفر ثبت می‌شد، یعنی هزینه‌ی واقعیِ اپ هیچ‌جا
# اندازه‌گیری نمی‌شد؛ providerی که usage نفرستد هنوز صفر می‌دهد.
usage = stream_result.get('usage') or {}
generation_meta = {
'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),
}
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
# فاز ۳: ثبتِ پیامِ دستیار + idempotency (همون منطقِ post()).
try:
with transaction.atomic():
assistant_message = Message.objects.create(
conversation=conversation, role='assistant', text=answer_text,
question_id=_db_ref_or_none(v.get('questionId')),
**_annotation(retrieval_meta, generation_meta, from_cache=answer_from_cache),
)
if idempotency_key:
AskIdempotency.objects.create(
user_id=user.id, idempotency_key=idempotency_key,
conversation=conversation, user_message=user_message,
assistant_message=assistant_message,
suggested_follow_ups=follow_ups,
)
except IntegrityError:
existing = AskIdempotency.objects.filter(
user_id=user.id, idempotency_key=idempotency_key,
).select_related('assistant_message').first() if idempotency_key else None
if existing is None:
self._release_reserved_slot(conversation, user_message, conversation_is_new)
yield _sse('error', {'detail': AIProviderUnavailable.default_detail})
return
self._release_reserved_slot(conversation, user_message, conversation_is_new)
yield _sse('done', {
'conversationId': str(existing.conversation_id),
'messageId': str(existing.assistant_message_id),
'suggestedFollowUps': existing.suggested_follow_ups,
**quota_state(user.id),
'diagram': existing.assistant_message.diagram,
'usedChunks': [],
})
return
done = {
'conversationId': str(conversation.id),
'messageId': str(assistant_message.id),
'suggestedFollowUps': follow_ups,
**quota_state(user.id),
'diagram': assistant_message.diagram,
'usedChunks': [
{'title': c.get('title', ''), 'summary': c.get('summary', '')}
for c in used_chunks
],
}
if settings.DEBUG and retrieval_meta:
done['retrievalDebug'] = {
'collection': retrieval_meta.get('collection'),
'filterKey': retrieval_meta.get('filterKey'),
'chapterOrLesson': retrieval_meta.get('filterValue'),
'gradeTitle': retrieval_meta.get('gradeTitle'),
'chunkCount': retrieval_meta.get('chunkCount'),
'chunkTitles': [c.get('title', '') for c in used_chunks],
}
yield _sse('done', done)
response = StreamingHttpResponse(_gen(), content_type='text/event-stream')
response['Cache-Control'] = 'no-cache'
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():
if conversation_is_new:
conversation.delete()
else:
user_message.delete()
def _build_ask_response(self, existing: AskIdempotency) -> AskResponseSerializer:
"""
ساختِ همون پاسخِ AskResponseSerializer برای یک AskIdempotency
از قبل ثبت‌شده (مسیرِ idempotent replay)، بدونِ ساختِ پیامِ جدید یا
مصرفِ کوتا.
⚠️ اصلاح: قبلاً suggestedFollowUps ذخیره نمی‌شد و اینجا با
فراخوانیِ دوباره‌ی generate_tutor_answer بازسازی می‌شد. تا وقتی
generate_tutor_answer یک stub خالی از اثر جانبیه بی‌ضرر بود، اما
وقتی به یک AI provider واقعی وصل بشه، هر replay یک فراخوانیِ
واقعیِ دیگه به provider می‌زد (هزینه/تأخیرِ تکراری، و
suggestedFollowUps متفاوت در هر retry — دقیقاً برخلافِ هدفِ
idempotency). حالا suggested_follow_ups مستقیماً از خودِ رکوردِ
AskIdempotency (ذخیره‌شده در همون درخواستِ اولِ موفق) خونده می‌شه؛
هیچ فراخوانیِ دیگری به generate_tutor_answer در مسیرِ replay انجام
نمی‌شه. answer هم مثلِ قبل از Message.text (بدونِ تغییر) خونده
می‌شه؛ remainingQuota هم مثلِ قبل از روی مصرفِ *فعلیِ* امروز
محاسبه می‌شه، نه لحظه‌ی درخواستِ اولیه، چون این فراخوانیِ
idempotent به‌خودی‌خود چیزی مصرف نکرده.
"""
conversation = existing.conversation
state = quota_state(existing.user_id)
data = {
'conversationId': str(conversation.id),
'messageId': str(existing.assistant_message.id),
'answer': to_plain(existing.assistant_message.text),
'suggestedFollowUps': existing.suggested_follow_ups,
'remainingQuota': state['remainingQuota'],
'diagram': existing.assistant_message.diagram,
}
return AskResponseSerializer(data)
class ConversationMessagesView(APIView): class ConversationMessagesView(APIView):
"""GET /api/ai/conversations/{id}/messages — تاریخچه‌ی مکالمه""" """GET /api/ai/conversations/{id}/messages — تاریخچه‌ی مکالمه"""
# سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `read` همان نرخِ مسیرهای هم‌خانواده است.
throttle_scope = 'read'
throttle_classes = [ScopedRateThrottle]
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@extend_schema(responses=ConversationMessagesResponseSerializer, tags=['AI Tutor (ai_tutor)']) @extend_schema(responses=ConversationMessagesResponseSerializer, tags=['AI Tutor (ai_tutor)'])
def get(self, request, conversation_id): def get(self, request, conversation_id):
conversation = get_object_or_404(Conversation, pk=conversation_id, user_id=request.user.id) conversation = get_object_or_404(Conversation, pk=conversation_id, user_id=request.user.id)
messages = conversation.messages.all() # آخرین ۲۰۰ پیام، به ترتیبِ زمانی (قدیمی→جدید). جلوی رشدِ نامحدودِ پاسخ
# برای یک مکالمه‌ی خیلی طولانی رو می‌گیره.
messages = reversed(list(conversation.messages.order_by('-id')[:200]))
data = { data = {
'conversationId': str(conversation.id), 'conversationId': str(conversation.id),
'lessonId': str(conversation.lesson_id) if conversation.lesson_id else None, 'lessonId': str(conversation.lesson_id) if conversation.lesson_id else None,
'messages': [ '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) return Response(ConversationMessagesResponseSerializer(data).data)
@@ -128,6 +851,10 @@ class ConversationMessagesView(APIView):
class MessageFeedbackView(APIView): class MessageFeedbackView(APIView):
"""POST /api/ai/messages/{id}/feedback — فیدبک 👍/👎""" """POST /api/ai/messages/{id}/feedback — فیدبک 👍/👎"""
# سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `user_profile_write` همان نرخِ مسیرهای هم‌خانواده است.
throttle_scope = 'user_profile_write'
throttle_classes = [ScopedRateThrottle]
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@extend_schema( @extend_schema(
@@ -140,8 +867,27 @@ class MessageFeedbackView(APIView):
serializer = FeedbackRequestSerializer(data=request.data) serializer = FeedbackRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
MessageFeedback.objects.update_or_create( # `reason` فقط وقتی نوشته می‌شود که در همین درخواست آمده باشد: کاربری
message=message, # که رأیش را عوض می‌کند معمولاً دلیل نمی‌نویسد، و پیش‌فرضِ '' متنِ
defaults={'is_helpful': serializer.validated_data['isHelpful']}, # قبلی‌اش را پاک می‌کرد — یعنی مفیدترین بخشِ فیدبک از دست می‌رفت.
) defaults = {'is_helpful': serializer.validated_data['isHelpful']}
if 'reason' in serializer.validated_data:
defaults['reason'] = serializer.validated_data['reason']
MessageFeedback.objects.update_or_create(message=message, defaults=defaults)
return Response(FeedbackResponseSerializer({'success': True}).data) 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)

0
campaigns/__init__.py Normal file
View File

56
campaigns/admin.py Normal file
View File

@@ -0,0 +1,56 @@
"""
ثبتِ MessageCampaign و CampaignDelivery در Django Admin.
این ماژول صرفاً برای مشاهده/مدیریتِ دستیِ کمپین‌ها و بررسیِ وضعیتِ
تحویل‌هاست؛ هیچ منطقِ اجرای کمپین (execute_campaign) اینجا صدا زده
نمی‌شه — طبق MessageCampaign.__doc__ و CampaignDelivery.__doc__، اجرا
و پنلِ ادمینِ مربوط به execution فیچرِ بعدیه.
- MessageCampaignAdmin: امکانِ ساخت/ویرایش/حذفِ کمپین (فیلدهای معمولی،
بدون منطقِ اجرا) رو با ModelAdmin پیش‌فرضِ Django فراهم می‌کنه.
- CampaignDeliveryAdmin: صرفاً read-only. CampaignDelivery یک لاگِ
وضعیتِ تولیدشده توسطِ execute_campaign()‌ه (طبق docstring خودش)؛
دستکاریِ دستیِ status/error_message از ادمین می‌تونه این لاگ رو با
چیزی که واقعاً برای کاربر اتفاق افتاده (یا نیفتاده) ناسازگار کنه و
رفتارِ idempotency/unique-constraint که در campaigns.services روی
همین رکوردها تکیه می‌کنه رو دور بزنه. به همین دلیل add/change/delete
همه غیرفعالن؛ تنها راهِ دیدنِ رکوردها لیست/دیتیلِ read-only‌ه.
"""
from django.contrib import admin
from .models import CampaignDelivery, MessageCampaign
@admin.register(MessageCampaign)
class MessageCampaignAdmin(admin.ModelAdmin):
list_display = ('id', 'title', 'channel', 'created_at')
list_filter = ('channel',)
search_fields = ('title', 'body')
readonly_fields = ('created_at',)
fields = ('title', 'body', 'channel', 'target_filter', 'created_at')
@admin.register(CampaignDelivery)
class CampaignDeliveryAdmin(admin.ModelAdmin):
"""
نمایشِ read-only: لیست و صفحه‌ی جزئیات فقط با اجازه‌ی view_campaigndelivery
(رفتارِ پیش‌فرضِ جنگو)، و هیچ راهی برای
افزودن، ویرایش یا حذفِ رکورد از ادمین وجود نداره تا وضعیتِ تحویل
(که فقط باید توسطِ campaigns.services.execute_campaign آپدیت بشه)
از ادمین دست‌کاری نشه.
"""
list_display = ('id', 'campaign', 'user_id', 'channel', 'status', 'created_at')
list_filter = ('channel', 'status')
search_fields = ('user_id', 'error_message')
fields = ('campaign', 'user_id', 'channel', 'status', 'error_message', 'created_at')
readonly_fields = fields
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

6
campaigns/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class CampaignsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'campaigns'

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.2.17 on 2026-08-24 16:52
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='MessageCampaign',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=150)),
('body', models.TextField()),
('channel', models.CharField(choices=[('sms', 'پیامک'), ('in_app', 'اعلان داخل\u200cاپ')], max_length=10)),
('target_filter', models.JSONField(blank=True, default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['-created_at'],
},
),
]

View File

@@ -0,0 +1,29 @@
# Generated by Django 5.2.17 on 2026-08-24 00:00
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('campaigns', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='CampaignDelivery',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.IntegerField(db_index=True)),
('channel', models.CharField(choices=[('sms', 'پیامک'), ('in_app', 'اعلان داخل\u200cاپ')], max_length=10)),
('status', models.CharField(choices=[('pending', 'در انتظار'), ('sent', 'ارسال شد'), ('failed', 'ناموفق')], default='pending', max_length=10)),
('error_message', models.CharField(blank=True, max_length=300)),
('created_at', models.DateTimeField(auto_now_add=True)),
('campaign', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deliveries', to='campaigns.messagecampaign')),
],
options={
'ordering': ['-created_at'],
},
),
]

View File

@@ -0,0 +1,30 @@
# Generated manually for the campaign-delivery concurrency fix.
#
# اضافه‌کردنِ یک partial/conditional unique index روی
# CampaignDelivery(campaign, user_id, channel) که فقط رکوردهایی با
# status در ('pending', 'sent') رو محدود می‌کنه. رکوردهای failed از
# این constraint مستثنا هستن تا رفتارِ retryِ فعلی (ساختنِ رکوردِ
# pendingِ جدید بعد از یک تلاشِ ناموفق) بدونِ تغییر بمونه.
#
# این migration فقط یک constraint اضافه می‌کنه؛ هیچ فیلد/مدلِ جدیدی
# نمی‌سازه و به دیتای موجود دست نمی‌زنه.
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('campaigns', '0002_campaigndelivery'),
]
operations = [
migrations.AddConstraint(
model_name='campaigndelivery',
constraint=models.UniqueConstraint(
condition=models.Q(status__in=['pending', 'sent']),
fields=('campaign', 'user_id', 'channel'),
name='campaigns_one_active_delivery_per_user_channel',
),
),
]

View File

113
campaigns/models.py Normal file
View File

@@ -0,0 +1,113 @@
from django.db import models
from django.db.models import Q, UniqueConstraint
class MessageCampaign(models.Model):
"""
ذخیره‌سازیِ مینیمالِ یک «کمپینِ پیامی» برای فیچرهای ارتباطی آینده.
این مدل صرفاً یک محلِ ذخیره‌ست؛ هیچ منطقِ ارسال/زمان‌بندی/صف
پس‌زمینه/retry/پیگیریِ تحویل اینجا نیست. ارسالِ واقعی (که از
accounts.services.send_sms یا notifications.services.notify استفاده
خواهد کرد) و انتخابِ کاربرانِ هدف (که از هلپرهای accounts.services،
مثلِ get_users_by_grade/get_inactive_users/..., استفاده خواهد کرد) در
فیچرهای بعدی روی همین رکورد پیاده می‌شن.
- `channel`: کانالِ ارسال (فعلاً پیامک یا اعلانِ داخل‌اپ).
- `target_filter`: تعریفِ هدف/فیلترِ انتخاب‌شده، به همون شکلی که در UI
ساخته می‌شه (مثلاً {"type": "grade", "grade_id": 3} یا
{"type": "inactive_since_days", "days": 14} یا {"type": "all"}).
عمداً JSON آزاد نگه داشته شده تا با هلپرهای targeting در
accounts.services (که هیچ‌کدوم جدولِ جدیدی ندارن) هماهنگ بمونه، بدونِ
اینکه این مدل به یک ساختارِ فیلترِ خاص قفل بشه.
"""
CHANNEL_CHOICES = [
('sms', 'پیامک'),
('in_app', 'اعلان داخل‌اپ'),
]
title = models.CharField(max_length=150)
body = models.TextField()
channel = models.CharField(max_length=10, choices=CHANNEL_CHOICES)
target_filter = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f"campaign-{self.pk}-{self.channel}-{self.title}"
def save(self, *args, **kwargs):
"""
Django `choices=` روی CharField صرفاً برای فرم/ادمینه و به‌خودی‌خود
در save() اجرا نمی‌شه؛ بدونِ این چک، هر مقدارِ دلخواهی برای channel
(مثلاً یک typo) می‌تونه ذخیره بشه و فقط بعداً، وقتِ اجرای
execute_campaign()، شکست بخوره. این کمترین اعتبارسنجیِ لازمه تا
مقدارِ نامعتبر اصلاً ذخیره نشه — بدونِ full_clean() روی کلِ مدل
(که فیلدهای دیگه‌ای مثل title/target_filter رو هم درگیر می‌کنه و
در این پروژه جایی برای مدل‌ها استفاده نشده) و با همون الگوی
ValueError که در campaigns.services برای خطاهای مشابه به‌کار
رفته (مثلِ UnsupportedTargetFilter).
"""
valid_channels = {choice[0] for choice in self.CHANNEL_CHOICES}
if self.channel not in valid_channels:
raise ValueError(f"Invalid MessageCampaign channel: {self.channel!r}")
super().save(*args, **kwargs)
class CampaignDelivery(models.Model):
"""
رکوردِ مینیمالِ نتیجه‌ی تحویلِ یک MessageCampaign به یک کاربرِ هدف؛
همون «پیگیریِ تحویل» که در MessageCampaign.__doc__ به‌عنوانِ فیچرِ
بعدی اشاره شده بود. این مدل صرفاً یک لاگِ وضعیت‌ه — بدونِ retry، صفِ
پس‌زمینه، زمان‌بندی، API یا پنلِ ادمین. execute_campaign() به ازای هر
تلاشِ واقعیِ ارسال (نه هر کاربرِ resolve‌شده — کاربرانِ skip‌شده مثلِ
مهمانِ بدونِ شماره رکورد نمی‌گیرن) یک رکورد می‌سازه و بعد از تلاش،
وضعیتش رو به‌روزرسانی می‌کنه.
- `user_id`: ارجاعِ سبک به accounts.User، هم‌الگو با
notifications.models.Notification.user_id (بدونِ FK واقعی).
- `status`: هنگامِ ساختِ رکورد pending‌ه (قبل از تلاش)، بعد از تلاش
به sent یا failed آپدیت می‌شه.
- `error_message`: فقط در status=failed پر می‌شه.
"""
STATUS_PENDING = 'pending'
STATUS_SENT = 'sent'
STATUS_FAILED = 'failed'
STATUS_CHOICES = [
(STATUS_PENDING, 'در انتظار'),
(STATUS_SENT, 'ارسال شد'),
(STATUS_FAILED, 'ناموفق'),
]
campaign = models.ForeignKey(
MessageCampaign, on_delete=models.CASCADE, related_name='deliveries',
)
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
channel = models.CharField(max_length=10, choices=MessageCampaign.CHANNEL_CHOICES)
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=STATUS_PENDING)
error_message = models.CharField(max_length=300, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
constraints = [
# جلوگیریِ race condition: اگه execute_campaign() برای همون
# campaign هم‌زمان اجرا بشه، این constraint تضمین می‌کنه حداکثر
# یک رکوردِ «فعال یا موفق» (pending/sent) به‌ازای هر
# (campaign, user_id, channel) باشه؛ تلاشِ دومِ هم‌زمان با
# IntegrityError شکست می‌خوره و به‌عنوانِ claim‌شده skip می‌شه.
# status=failed عمداً مستثناست تا retry (ساختِ pendingِ جدید
# بعد از شکست) دست‌نخورده بمونه.
UniqueConstraint(
fields=['campaign', 'user_id', 'channel'],
condition=Q(status__in=['pending', 'sent']),
name='campaigns_one_active_delivery_per_user_channel',
),
]
def __str__(self):
return f"delivery-{self.pk}-campaign{self.campaign_id}-user{self.user_id}-{self.status}"

287
campaigns/services.py Normal file
View File

@@ -0,0 +1,287 @@
"""
اجرای یک MessageCampaign: صرفاً «چسباندنِ» سه چیزِ از قبل موجود به هم —
هدف‌گیریِ کاربر (accounts.services)، ارسالِ پیامک (accounts.services.send_sms)
و ثبتِ اعلانِ داخل‌اپ (notifications.services.notify). این ماژول هیچ منطقِ
جدیدِ ارسال/اعلان نمی‌سازه، هیچ صف/جاب پس‌زمینه‌ای نداره، و هیچ زمان‌بندی،
retry یا پیگیریِ تحویلی انجام نمی‌ده — طبق MessageCampaign.__doc__، اونا
مسئولیتِ فیچرهای بعدی هستن.
فراخوانی همیشه sync و بلاکینگ‌ه (برای هر کاربر یک send_sms/notify)؛
مناسبِ لیست‌های کوچیک یا اجرای دستی/تست، نه کمپین‌های بزرگ در پروداکشن.
"""
import time
from django.db import IntegrityError, OperationalError, transaction
from accounts import services as account_services
from accounts.services import SMSDeliveryError
from notifications.services import notify
from .models import CampaignDelivery, MessageCampaign
def _retry_on_sqlite_lock(fn, *, retries=8, base_delay=0.05, max_delay=0.4):
"""
روی 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:
return fn()
except OperationalError as exc:
if 'locked' not in str(exc).lower() or attempt == retries - 1:
raise
time.sleep(min(base_delay * (2 ** attempt), max_delay))
# عمداً به notifications.models.Notification.TYPE_CHOICES اضافه نشده: آن
# choices فقط رویدادهاییه که notifications به‌عنوانِ listener گوش می‌ده،
# اینجا notify() مستقیم صدا زده می‌شه؛ Notification.type یک CharField
# معمولیه، مقداردهیِ خارج از choices مجازه.
CAMPAIGN_NOTIFICATION_TYPE = 'message_campaign'
class UnsupportedTargetFilter(ValueError):
"""target_filter کمپین با هیچ‌کدوم از حالت‌های فعلاً پشتیبانی‌شده مطابقت نداره."""
def _resolve_target_users(target_filter: dict):
"""
target_filter رو به یک QuerySet از accounts.User تبدیل می‌کنه، فقط با
استفاده از هلپرهای موجودِ accounts.services (بدونِ کوئریِ جدید).
حالت‌های فعلاً پشتیبانی‌شده:
{"type": "all"}
{"type": "grade", "grade_id": <int>}
{"type": "inactive_since_days", "days": <int>}
{"type": "active_subscription"}
"""
target_filter = target_filter or {}
filter_type = target_filter.get('type')
if filter_type == 'all':
return account_services.get_all_users()
if filter_type == 'grade':
return account_services.get_users_by_grade(target_filter['grade_id'])
if filter_type == 'inactive_since_days':
from datetime import timedelta
from django.utils import timezone
since = timezone.now() - timedelta(days=target_filter['days'])
return account_services.get_inactive_users(since)
if filter_type == 'active_subscription':
return account_services.get_users_with_active_subscription()
raise UnsupportedTargetFilter(
f"Unsupported target_filter type: {filter_type!r}"
)
def _mark_delivery_result(delivery: CampaignDelivery, *, error: Exception | None) -> None:
"""رکوردِ pending‌ای که قبل از تلاشِ ارسال ساخته شده رو به sent/failed آپدیت می‌کنه."""
if error is None:
delivery.status = CampaignDelivery.STATUS_SENT
_retry_on_sqlite_lock(lambda: delivery.save(update_fields=['status']))
return
delivery.status = CampaignDelivery.STATUS_FAILED
delivery.error_message = str(error)[:300]
_retry_on_sqlite_lock(lambda: delivery.save(update_fields=['status', 'error_message']))
def _claim_delivery(campaign: MessageCampaign, user_id: int) -> CampaignDelivery | None:
"""
رکوردِ pendingِ یک CampaignDelivery رو برای این کاربر «claim» می‌کنه،
یا اگه یک اجرای هم‌زمانِ دیگرِ همین campaign همین کاربر رو زودتر
claim کرده باشه، None برمی‌گردونه (بدونِ ساختنِ رکورد و بدونِ
فراخوانیِ send_sms/notify).
چکِ already_sent_user_ids قبل از شروعِ حلقه یک‌بار انجام می‌شه، پس
بینِ اون چک و رسیدنِ نوبتِ این کاربر در حلقه، ممکنه یک اجرای هم‌زمانِ
دیگر (پراسسِ دیگر/دابل‌کلیکِ اجرا) دقیقاً همین کاربر رو claim کرده و
حتی به sent رسونده باشه (TOCTOU). این تابع همون کاری‌ه که چکِ
اپلیکیشن‌لایه فرض می‌کرد ولی گارانتی نمی‌داد: با تکیه به
CampaignDelivery.Meta.constraints (یک رکوردِ فعال/موفق به ازای هر
campaign+user_id+channel)، اگه یک رکوردِ pending یا sent از قبل
برای همین کاربر وجود داشته باشه (چه توسطِ این اجرا چه توسطِ اجرای
هم‌زمانِ دیگر)، create() با IntegrityError رد می‌شه و این تابع
None برمی‌گردونه — یعنی کاربر به‌جای duplicate delivery، skip می‌شه.
"""
def _create():
with transaction.atomic():
return CampaignDelivery.objects.create(
campaign=campaign, user_id=user_id, channel=campaign.channel,
)
try:
return _retry_on_sqlite_lock(_create)
except IntegrityError:
return None
def _already_sent_user_ids(campaign: MessageCampaign) -> set[int]:
"""
user_id‌هایی که برای همین campaign+channel از قبل یک CampaignDelivery
با status=sent دارن — یعنی تحویلِ واقعی قبلاً با موفقیت انجام شده.
این استعلام دقیقاً همون کاری‌ه که پرامپت به‌عنوانِ جهتِ حل پیشنهاد
داده: «استفاده از رکوردهای CampaignDelivery موجود برای جلوگیری از
ارسالِ دوباره‌ی تحویل‌هایی که قبلاً کامل شدن». عمداً روی pending/failed
فیلتر نمی‌کنه: pending یعنی تلاشِ قبلی هیچ‌وقت به نتیجه نرسیده (مثلاً
اجرا وسط کار قطع شده) و failed یعنی واقعاً تحویل داده نشده — در هر دو
حالت، تلاشِ دوباره «duplicate delivery» نیست، بلکه اولین تحویلِ واقعیِ
موفقه؛ پس این کاربرها باید دوباره امتحان بشن.
"""
return set(
CampaignDelivery.objects.filter(
campaign=campaign,
channel=campaign.channel,
status=CampaignDelivery.STATUS_SENT,
).values_list('user_id', flat=True)
)
def execute_campaign(campaign: MessageCampaign) -> None:
"""
یک MessageCampaign رو اجرا می‌کنه: کاربرانِ هدف رو resolve می‌کنه و به
ازای هر کدوم، بسته به campaign.channel، یا اعلانِ داخل‌اپ ثبت می‌کنه
(notify) یا پیامک می‌فرسته (send_sms). به ازای هر تلاشِ واقعیِ ارسال،
یک CampaignDelivery ساخته می‌شه (ابتدا pending) و بعد از تلاش به
sent/failed آپدیت می‌شه — بدونِ retry، صفِ پس‌زمینه یا زمان‌بندی.
Idempotency: اگه execute_campaign() دوباره برای همون campaign صدا زده
بشه (مثلاً به‌خاطرِ اجرای دستیِ تکراری یا ری‌استارتِ فرآیند وسطِ کار)،
کاربرهایی که از قبل یک CampaignDelivery با status=sent برای همین
campaign+channel دارن، دوباره skip می‌شن — نه CampaignDelivery‌ی
جدیدی براشون ساخته می‌شه و نه send_sms/notify دوباره صداشون می‌زنه.
این چک قبل از شروعِ حلقه، یک‌بار برای کلِ کمپین انجام می‌شه (نه به
ازای هر کاربر) تا کوئریِ اضافه نداشته باشیم. کاربرهایی که رکوردشون
pending یا failed مونده، عمداً دوباره امتحان می‌شن؛ چون براشون هنوز
هیچ تحویلِ موفقی ثبت نشده.
Concurrency: چکِ بالا خودش TOCTOU‌ه (یک‌بار قبل از حلقه محاسبه
می‌شه)، پس اگه دو اجرای execute_campaign() برای همون campaign
هم‌زمان راه بیفتن، ممکنه هر دو یک کاربر رو «هنوز sent نیست» ببینن.
محافظتِ واقعی در _claim_delivery() و در سطحِ دیتابیسه: هر کاربر قبل
از notify/send_sms باید یک رکوردِ CampaignDelivery رو claim کنه، و
یک unique constraint روی مدل (به ازای هر campaign+user_id+channel
حداکثر یک رکوردِ pending/sent) تضمین می‌کنه که فقط یکی از اون دو
اجرای هم‌زمان می‌تونه claim موفق داشته باشه؛ اون یکی دیگه بدونِ
ساختنِ رکورد و بدونِ صدازدنِ notify/send_sms، آن کاربر رو skip
می‌کنه.
کاربرانِ مهمان بدونِ شماره‌تلفن، برای کانالِ sms به‌سادگی رد (skip)
می‌شن و هیچ CampaignDelivery‌ای براشون ساخته نمی‌شه — چون این‌جا
اصلاً تلاشی برای ارسال انجام نشده؛ accounts.services.send_sms برای
شماره‌ی خالی معنی نداره و هیچ صف/ارسالِ جایگزینی هم براشون نیست.
شکستِ تحویلِ sms (SMSDeliveryError) دیگه کلِ اجرای کمپین رو متوقف
نمی‌کنه؛ فقط همون رکورد failed می‌شه و اجرا برای بقیه‌ی کاربران ادامه
پیدا می‌کنه.
Consistency (مارک‌کردنِ sent بعد از تلاشِ موفق): برای in_app، هم
notify() (یک Notification.objects.create ساده) و هم آپدیتِ نهاییِ
CampaignDelivery به sent، هر دو نوشتنِ دیتابیسیِ ساده روی همین
دیتابیسن — پس هر دو تویِ یک `transaction.atomic()` انجام می‌شن. اگه
notify() خودش شکست بخوره یا اگه بعد از notify() موفق، خودِ نوشتنِ
«sent» به هر دلیلی (مثلاً یک خطای دیتابیسِ گذرا) شکست بخوره، کل
تراکنش rollback می‌شه — یعنی Notification‌ای هم که notify() نوشته
بود هیچ‌وقت persist نمی‌شه، و بعدش رکورد به failed آپدیت می‌شه (بیرونِ
همون تراکنشِ rollback‌شده، با یک نوشتنِ مستقلِ جدید) و exception
دوباره بالا می‌ره. نتیجه: هیچ‌وقت به وضعیتِ ناسازگارِ «Notification
واقعاً وجود داره ولی CampaignDelivery رو pendingِ گیرکرده مونده»
نمی‌رسیم؛ رکورد یا واقعاً sent می‌شه (با Notificationِ واقعی) یا
failed می‌شه (بدونِ هیچ Notificationِ persist‌شده‌ای) — و چون failed
از unique constraint مستثناست، برای اجرای بعدی قابلِ retry می‌مونه.
برای sms این کار عمداً انجام نشده و نمی‌تونه انجام بشه: send_sms()
یک تماسِ خارجی با provider‌ه، نه یک نوشتنِ دیتابیسی — یک
transaction.atomic() فقط نوشتن‌های همین دیتابیس رو rollback می‌کنه؛
پیامکی که provider واقعاً فرستاده رو نمی‌شه با rollbackِ دیتابیس
«پس گرفت». اگه بعد از یک send_sms() موفق، خودِ نوشتنِ «sent» شکست
بخوره، exception (بدونِ catch/hide) بالا می‌ره و رکورد pending
می‌مونه — دقیقاً همون رفتارِ قبلی. این pendingِ گیرکرده باعثِ
duplicate SMS در اجرای بعدی نمی‌شه (چون unique constraint یک
claimِ دوباره برای همون کاربر رو رد می‌کنه)، ولی اون یک رکوردِ
خاص هم دیگه هیچ‌وقت خودکار به sent/failed نمی‌رسه — نیازمندِ
بررسیِ دستیه، که پیامدِ ذاتیِ غیرقابل‌اجتنابِ غیرترانزکشنال‌بودنِ
provider خارجیه، نه یک باگِ قابل‌رفعِ کوچیک.
"""
users = _resolve_target_users(campaign.target_filter)
already_sent_user_ids = _already_sent_user_ids(campaign)
if campaign.channel == 'in_app':
# ⚠️ عمداً 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)
if delivery is None:
continue
def _notify_and_mark_sent(user_id=user_id, delivery=delivery):
with transaction.atomic():
notify(
user_id=user_id,
type=CAMPAIGN_NOTIFICATION_TYPE,
title=campaign.title,
body=campaign.body,
related_id=campaign.id,
)
_mark_delivery_result(delivery, error=None)
try:
_retry_on_sqlite_lock(_notify_and_mark_sent)
except Exception as exc:
# چه notify() خودش شکست بخوره چه فقط نوشتنِ نهاییِ sent
# (که تویِ همون atomic block بالاست)، هر دو حالت اینجا
# می‌رسه: تراکنش rollback شده (پس هیچ Notificationِ
# persist‌شده‌ای نمونده)، و این save یک نوشتنِ جدید و
# مستقله که رکورد رو به failed می‌بره تا برای اجرای بعدی
# گیر نکنه. exception عمداً catch/hide نمی‌شه و بالا می‌ره.
_mark_delivery_result(delivery, error=exc)
raise
return
if campaign.channel == 'sms':
# همان دلیلِ شاخه‌ی in_app بالا، و اینجا حتی مهم‌تر: وسطِ این حلقه
# یک فراخوانیِ شبکه‌ای به کاوه‌نگار انجام می‌شود، پس کرسرِ باز
# می‌توانست به‌اندازه‌ی کلِ ارسالِ پیامک‌ها جدول را قفل نگه دارد.
for user_id, phone in list(users.values_list('id', 'phone')):
if user_id in already_sent_user_ids:
continue
if not phone:
continue
delivery = _claim_delivery(campaign, user_id)
if delivery is None:
continue
try:
account_services.send_sms(phone=phone, message=campaign.body)
except SMSDeliveryError as exc:
_mark_delivery_result(delivery, error=exc)
else:
_mark_delivery_result(delivery, error=None)
return
raise ValueError(f"Unsupported campaign channel: {campaign.channel!r}")

495
campaigns/tests.py Normal file
View File

@@ -0,0 +1,495 @@
import threading
from unittest.mock import patch
from django.contrib import admin
from django.contrib.auth import get_user_model
from django.db import IntegrityError, OperationalError, connection, transaction
from django.test import TestCase, TransactionTestCase
from django.urls import reverse
from accounts.models import User
from accounts.services import SMSDeliveryError
from notifications.models import Notification
from .admin import CampaignDeliveryAdmin, MessageCampaignAdmin
from .models import CampaignDelivery, MessageCampaign
from .services import execute_campaign
class MessageCampaignChannelValidationTests(TestCase):
"""
اطمینان از این‌که مقدارِ نامعتبر برای channel اصلاً ذخیره نمی‌شه (نه
این‌که فقط بعداً موقعِ execute_campaign() شکست بخوره)، و مقادیرِ
معتبرِ فعلی (sms, in_app) بدون تغییر همچنان کار می‌کنن.
"""
def test_valid_channels_still_save(self):
for channel in ('sms', 'in_app'):
campaign = MessageCampaign.objects.create(
title=f'title-{channel}', body='body', channel=channel,
)
self.assertEqual(campaign.channel, channel)
def test_invalid_channel_rejected_on_create(self):
with self.assertRaises(ValueError):
MessageCampaign.objects.create(
title='bad', body='body', channel='email',
)
self.assertEqual(MessageCampaign.objects.count(), 0)
def test_invalid_channel_rejected_on_save(self):
campaign = MessageCampaign(title='bad', body='body', channel='whatsapp')
with self.assertRaises(ValueError):
campaign.save()
self.assertEqual(MessageCampaign.objects.count(), 0)
class ExecuteCampaignIdempotencyTests(TestCase):
"""
اجرای دوباره‌ی execute_campaign() برای همون campaign نباید کاربرهایی
که قبلاً یک CampaignDelivery با status=sent دارن رو دوباره تحویل
بده (نه CampaignDelivery تکراری، نه notify/send_sms تکراری).
"""
def setUp(self):
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(
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
)
execute_campaign(campaign)
self.assertEqual(CampaignDelivery.objects.filter(campaign=campaign).count(), 2)
self.assertEqual(Notification.objects.count(), 2)
self.assertEqual(
set(CampaignDelivery.objects.filter(campaign=campaign).values_list('status', flat=True)),
{CampaignDelivery.STATUS_SENT},
)
# اجرای دوباره‌ی همون کمپین: نباید هیچ CampaignDelivery یا
# Notification جدیدی ساخته بشه، چون هر دو کاربر از قبل sent هستن.
execute_campaign(campaign)
self.assertEqual(CampaignDelivery.objects.filter(campaign=campaign).count(), 2)
self.assertEqual(Notification.objects.count(), 2)
@patch('campaigns.services.account_services.send_sms')
def test_sms_second_run_skips_already_sent_users(self, mock_send_sms):
campaign = MessageCampaign.objects.create(
title='t', body='b', channel='sms', target_filter={'type': 'all'},
)
execute_campaign(campaign)
self.assertEqual(mock_send_sms.call_count, 2)
self.assertEqual(CampaignDelivery.objects.filter(campaign=campaign).count(), 2)
execute_campaign(campaign)
# هیچ فراخوانیِ اضافه‌ای برای send_sms نباید انجام بشه.
self.assertEqual(mock_send_sms.call_count, 2)
self.assertEqual(CampaignDelivery.objects.filter(campaign=campaign).count(), 2)
@patch('campaigns.services.account_services.send_sms')
def test_sms_failed_delivery_is_retried_not_treated_as_duplicate(self, mock_send_sms):
"""
رکوردهای failed نشونه‌ی «تحویلِ قبلاً کامل‌شده» نیستن؛ پس اجرای
دوباره باید دوباره امتحانشون کنه، نه skip.
"""
mock_send_sms.side_effect = SMSDeliveryError('boom')
campaign = MessageCampaign.objects.create(
title='t', body='b', channel='sms', target_filter={'type': 'all'},
)
execute_campaign(campaign)
self.assertEqual(mock_send_sms.call_count, 2)
self.assertEqual(
set(CampaignDelivery.objects.filter(campaign=campaign).values_list('status', flat=True)),
{CampaignDelivery.STATUS_FAILED},
)
mock_send_sms.side_effect = None
execute_campaign(campaign)
self.assertEqual(mock_send_sms.call_count, 4)
self.assertEqual(CampaignDelivery.objects.filter(campaign=campaign).count(), 4)
self.assertEqual(
CampaignDelivery.objects.filter(
campaign=campaign, status=CampaignDelivery.STATUS_SENT,
).count(),
2,
)
@patch('campaigns.services.notify')
def test_in_app_unexpected_exception_marks_delivery_failed_and_reraises(self, mock_notify):
"""
اگه notify() یک استثنای غیرمنتظره بده، اون خطا نباید catch/hide
بشه (باید بالا بره، مثلِ قبل)، ولی رکوردِ pendingِ همون کاربر
باید قبل از re-raise به failed آپدیت بشه — وگرنه به‌خاطرِ
unique constraintِ CampaignDelivery، این کاربر برای همیشه گیر
می‌کنه و هیچ اجرای بعدی‌ای نمی‌تونه دوباره claim کنه.
"""
mock_notify.side_effect = RuntimeError('boom')
campaign = MessageCampaign.objects.create(
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
)
with self.assertRaises(RuntimeError):
execute_campaign(campaign)
# فقط یک کاربر (اولین موردِ resolve‌شده) رکورد گرفته، چون خطا
# بلافاصله بعد از notify() اول بالا رفته و حلقه متوقف شده —
# دقیقاً همون رفتاری که قبل از این تغییر هم وجود داشت.
deliveries = CampaignDelivery.objects.filter(campaign=campaign)
self.assertEqual(deliveries.count(), 1)
self.assertEqual(deliveries.get().status, CampaignDelivery.STATUS_FAILED)
self.assertEqual(Notification.objects.count(), 0)
@patch('campaigns.services.notify')
def test_in_app_failed_delivery_after_exception_can_be_retried(self, mock_notify):
"""
بعد از اینکه یک اجرا با notify() شکست‌خورده رکوردِ failed ساخته،
اجرای بعدی نباید به‌خاطرِ constraint گیر کنه — باید بتونه دوباره
برای همون کاربر claim کنه و این‌بار اگه notify() موفق باشه، به
sent برسه.
"""
mock_notify.side_effect = RuntimeError('boom')
campaign = MessageCampaign.objects.create(
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
)
with self.assertRaises(RuntimeError):
execute_campaign(campaign)
self.assertEqual(
CampaignDelivery.objects.filter(campaign=campaign).count(), 1,
)
mock_notify.side_effect = None
execute_campaign(campaign)
# هر دو کاربر باید حالا sent باشن: کاربرِ اول (که بارِ قبل failed
# شده بود) با یک رکوردِ جدید retry شده، و کاربرِ دوم هم برای
# اولین‌بار موفق بوده.
self.assertEqual(CampaignDelivery.objects.filter(campaign=campaign).count(), 3)
self.assertEqual(
CampaignDelivery.objects.filter(
campaign=campaign, status=CampaignDelivery.STATUS_SENT,
).count(),
2,
)
class CampaignDeliveryConcurrencyTests(TestCase):
"""
محافظتِ سطحِ دیتابیس در برابرِ race condition بینِ دو اجرای هم‌زمانِ
execute_campaign() برای همون campaign: هر (campaign, user_id,
channel) حداکثر یک رکوردِ «فعال» (pending یا sent) می‌تونه داشته
باشه. رکوردهای failed مستثنا هستن تا رفتارِ retryِ فعلی دست‌نخورده
بمونه (پوشش داده‌شده در ExecuteCampaignIdempotencyTests بالا).
"""
def setUp(self):
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'},
)
def test_second_pending_delivery_for_same_user_is_rejected(self):
CampaignDelivery.objects.create(
campaign=self.campaign, user_id=self.user.id, channel='in_app',
)
with self.assertRaises(IntegrityError):
with transaction.atomic():
CampaignDelivery.objects.create(
campaign=self.campaign, user_id=self.user.id, channel='in_app',
)
def test_second_sent_delivery_for_same_user_is_rejected(self):
first = CampaignDelivery.objects.create(
campaign=self.campaign, user_id=self.user.id, channel='in_app',
)
first.status = CampaignDelivery.STATUS_SENT
first.save(update_fields=['status'])
with self.assertRaises(IntegrityError):
with transaction.atomic():
CampaignDelivery.objects.create(
campaign=self.campaign, user_id=self.user.id, channel='in_app',
)
def test_failed_delivery_does_not_block_retry(self):
first = CampaignDelivery.objects.create(
campaign=self.campaign, user_id=self.user.id, channel='in_app',
)
first.status = CampaignDelivery.STATUS_FAILED
first.save(update_fields=['status'])
# نباید IntegrityError بده: رکوردِ failed دیگه «فعال» حساب
# نمی‌شه، پس جای رکوردِ جدید رو برای retry باز می‌ذاره.
second = CampaignDelivery.objects.create(
campaign=self.campaign, user_id=self.user.id, channel='in_app',
)
self.assertIsNotNone(second.pk)
@patch('campaigns.services.notify')
def test_execute_campaign_skips_user_claimed_by_concurrent_run(self, mock_notify):
"""
شبیه‌سازیِ اجرای هم‌زمان: یک رکوردِ pending از قبل برای این کاربر
وجود داره (انگار یک اجرای دیگرِ همین campaign همین الان داره
روش کار می‌کنه). execute_campaign نباید notify رو صدا بزنه یا
رکوردِ دیگه‌ای برای این کاربر بسازه.
"""
CampaignDelivery.objects.create(
campaign=self.campaign, user_id=self.user.id, channel='in_app',
)
execute_campaign(self.campaign)
mock_notify.assert_not_called()
self.assertEqual(
CampaignDelivery.objects.filter(campaign=self.campaign).count(), 1,
)
class ExecuteCampaignRealConcurrencyTests(TransactionTestCase):
"""
برخلافِ CampaignDeliveryConcurrencyTests (که وضعیتِ «قبلاً claim‌شده»
رو با ساختنِ دستیِ یک رکوردِ pending شبیه‌سازی می‌کنه)، این تست دو
execute_campaign() واقعی رو در دو تردِ جداگانه — هرکدوم با کانکشنِ
دیتابیسِ خودش — دقیقاً هم‌زمان (با threading.Barrier) اجرا می‌کنه.
از TransactionTestCase استفاده شده (نه TestCase) چون TestCase کلِ
تست رو در یک تراکنشِ واحد rollback می‌کنه و بینِ کانکشن‌ها ایزوله
نیست — یعنی race واقعیِ بینِ دو تراکنشِ مستقل رو نشون نمی‌ده.
"""
def setUp(self):
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'},
)
def test_two_concurrent_runs_deliver_notification_exactly_once(self):
barrier = threading.Barrier(2)
errors = []
def run():
try:
barrier.wait(timeout=5)
execute_campaign(self.campaign)
except Exception as exc: # pragma: no cover - surfaced via `errors`
errors.append(exc)
finally:
connection.close()
threads = [threading.Thread(target=run) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
self.assertEqual(errors, [])
self.assertEqual(
CampaignDelivery.objects.filter(
campaign=self.campaign, status=CampaignDelivery.STATUS_SENT,
).count(),
1,
)
self.assertEqual(Notification.objects.count(), 1)
class InAppDeliveryConsistencyTests(TestCase):
"""
برای in_app، notify() و آپدیتِ نهاییِ CampaignDelivery به sent هر دو
تویِ یک transaction.atomic() هستن. این تست ثابت می‌کنه که اگه خودِ
نوشتنِ «sent» (نه notify()) به یک خطای دیتابیسی بخوره، کل تراکنش
rollback می‌شه — یعنی Notificationِ ساخته‌شده توسطِ notify() هم
persist نمی‌مونه — و رکورد به‌جای اینکه برای همیشه pending گیر کنه،
failed می‌شه (و در نتیجه در اجرای بعدی قابلِ retry‌ه).
"""
def setUp(self):
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'},
)
def test_failed_status_update_rolls_back_notification_and_marks_failed(self):
original_save = CampaignDelivery.save
def flaky_save(self_delivery, *args, **kwargs):
# فقط دقیقاً همون save‌ای که داره سعی می‌کنه رکورد رو به sent
# ببره شکست می‌خوره (نه save‌ی claim که status هنوز pendingه،
# و نه save‌ی مسیرِ failedِ بعدی که این تابع دیگه patch نشده).
if self_delivery.status == CampaignDelivery.STATUS_SENT:
raise OperationalError('simulated transient db failure')
return original_save(self_delivery, *args, **kwargs)
with patch.object(CampaignDelivery, 'save', flaky_save):
with self.assertRaises(OperationalError):
execute_campaign(self.campaign)
# notify() واقعاً صدا زده شده بود (نه mock)، ولی چون تویِ همون
# atomic-ی بود که بعداً rollback شد، هیچ Notificationِ persist‌
# شده‌ای نباید وجود داشته باشه.
self.assertEqual(Notification.objects.count(), 0)
delivery = CampaignDelivery.objects.get(campaign=self.campaign, user_id=self.user.id)
self.assertEqual(delivery.status, CampaignDelivery.STATUS_FAILED)
# حالا که رکورد failed شده (نه pendingِ گیرکرده)، اجرای بعدی
# باید بتونه دوباره claim کنه و این‌بار (بدونِ patch) موفق بشه.
execute_campaign(self.campaign)
delivery.refresh_from_db()
self.assertEqual(delivery.status, CampaignDelivery.STATUS_FAILED)
self.assertEqual(
CampaignDelivery.objects.filter(
campaign=self.campaign, user_id=self.user.id,
status=CampaignDelivery.STATUS_SENT,
).count(),
1,
)
self.assertEqual(Notification.objects.count(), 1)
class CampaignsAdminRegistrationTests(TestCase):
"""
اطمینان از این‌که MessageCampaign و CampaignDelivery در Django Admin
ثبت شدن و یک ادمینِ staff می‌تونه changelist هرکدوم رو ببینه.
توجه: get_user_model() اینجا مدلِ auth.User جنگو (برای لاگین به
/admin/) هست، کاملاً جدا از accounts.models.User که مدلِ بیزنسیِ
این پروژه‌ست و در CampaignDelivery.user_id به‌صورت سبک ارجاع
می‌شه.
"""
def setUp(self):
AuthUser = get_user_model()
self.staff_user = AuthUser.objects.create_superuser(
username='admin', email='admin@example.com', password='pass12345',
)
self.client.force_login(self.staff_user)
def test_message_campaign_registered(self):
self.assertIn(MessageCampaign, admin.site._registry)
def test_campaign_delivery_registered(self):
self.assertIn(CampaignDelivery, admin.site._registry)
def test_message_campaign_changelist_accessible(self):
url = reverse('admin:campaigns_messagecampaign_changelist')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_campaign_delivery_changelist_accessible(self):
url = reverse('admin:campaigns_campaigndelivery_changelist')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
class MessageCampaignAdminManagementTests(TestCase):
"""
MessageCampaign باید از ادمین قابلِ ساخت/ویرایش/حذف باشه (بدونِ هیچ
منطقِ اجرا) — یعنی رفتارِ پیش‌فرضِ ModelAdmin دست‌نخورده مونده.
"""
def setUp(self):
AuthUser = get_user_model()
self.staff_user = AuthUser.objects.create_superuser(
username='admin2', email='admin2@example.com', password='pass12345',
)
self.client.force_login(self.staff_user)
def test_add_permission_allowed(self):
request = type('Req', (), {'user': self.staff_user})()
model_admin = MessageCampaignAdmin(MessageCampaign, admin.site)
self.assertTrue(model_admin.has_add_permission(request))
def test_can_create_campaign_via_admin(self):
url = reverse('admin:campaigns_messagecampaign_add')
response = self.client.post(url, {
'title': 'admin-created',
'body': 'body text',
'channel': 'sms',
'target_filter': '{}',
})
# ریدایرکتِ موفق بعدِ ساختِ رکورد
self.assertEqual(response.status_code, 302)
self.assertTrue(MessageCampaign.objects.filter(title='admin-created').exists())
def test_can_delete_campaign_via_admin(self):
campaign = MessageCampaign.objects.create(title='to-delete', body='b', channel='sms')
url = reverse('admin:campaigns_messagecampaign_delete', args=[campaign.pk])
response = self.client.post(url, {'post': 'yes'})
self.assertEqual(response.status_code, 302)
self.assertFalse(MessageCampaign.objects.filter(pk=campaign.pk).exists())
class CampaignDeliveryAdminReadOnlyTests(TestCase):
"""
CampaignDelivery نباید از ادمین قابلِ افزودن/ویرایش/حذفِ دستی باشه؛
فقط باید قابلِ مشاهده باشه. این دقیقاً همون چیزیه که
campaigns.services.execute_campaign برای idempotency/unique
constraint روش تکیه می‌کنه — دستکاریِ دستیِ status می‌تونه اون
تضمین‌ها رو دور بزنه.
"""
def setUp(self):
AuthUser = get_user_model()
self.staff_user = AuthUser.objects.create_superuser(
username='admin3', email='admin3@example.com', password='pass12345',
)
self.client.force_login(self.staff_user)
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',
)
def test_permission_methods_deny_add_change_delete_but_allow_view(self):
request = type('Req', (), {'user': self.staff_user})()
model_admin = CampaignDeliveryAdmin(CampaignDelivery, admin.site)
self.assertFalse(model_admin.has_add_permission(request))
self.assertFalse(model_admin.has_change_permission(request, self.delivery))
self.assertFalse(model_admin.has_delete_permission(request, self.delivery))
self.assertTrue(model_admin.has_view_permission(request, self.delivery))
def test_change_detail_view_is_viewable(self):
"""
صفحه‌ی جزئیاتِ رکورد باید قابلِ مشاهده باشه (GET -> 200، به‌صورتِ
read-only)، حتی اگه has_change_permission همیشه False باشه؛
دیدن نباید به fallbackِ codename پرمیشنِ پیش‌فرضِ Django وابسته
بمونه (رجوع به has_view_permission بالا).
"""
url = reverse('admin:campaigns_campaigndelivery_change', args=[self.delivery.pk])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_all_fields_are_readonly(self):
model_admin = CampaignDeliveryAdmin(CampaignDelivery, admin.site)
self.assertEqual(set(model_admin.readonly_fields), set(model_admin.fields))
def test_add_view_forbidden(self):
url = reverse('admin:campaigns_campaigndelivery_add')
response = self.client.get(url)
self.assertEqual(response.status_code, 403)
def test_delete_view_forbidden(self):
url = reverse('admin:campaigns_campaigndelivery_delete', args=[self.delivery.pk])
response = self.client.get(url)
self.assertEqual(response.status_code, 403)
def test_change_via_post_does_not_modify_status(self):
"""
حتی اگه کسی مستقیماً POST بزنه به change URL، چون
has_change_permission همیشه False برمی‌گردونه، جنگو باید 403
بده و status رکورد نباید تغییر کنه.
"""
url = reverse('admin:campaigns_campaigndelivery_change', args=[self.delivery.pk])
response = self.client.post(url, {
'campaign': self.campaign.pk,
'user_id': self.user.id,
'channel': 'sms',
'status': CampaignDelivery.STATUS_SENT,
'error_message': '',
})
self.assertEqual(response.status_code, 403)
self.delivery.refresh_from_db()
self.assertEqual(self.delivery.status, CampaignDelivery.STATUS_PENDING)

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', ''))

51
config/exceptions.py Normal file
View File

@@ -0,0 +1,51 @@
"""
هندلرِ سفارشیِ exception برای DRF: هندلرِ پیش‌فرض فقط APIException/Http404/
PermissionDenied رو می‌شناسه؛ برای بقیه None برمی‌گردونه و exception خام تا
مدیریتِ خطای جنگو بالا می‌ره که (DEBUG=False) یه صفحه‌ی HTML عمومی می‌ده، نه
JSON. این هندلر اون حالت رو با traceback کامل لاگ می‌کنه و یه پاسخِ JSON
ژنریکِ ۵۰۰ برمی‌گردونه.
"""
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
logger = logging.getLogger(__name__)
def custom_exception_handler(exc, context):
response = drf_exception_handler(exc, context)
if response is not None:
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',
path, user_id,
)
return Response(
{'detail': 'خطای غیرمنتظره‌ای رخ داد؛ لطفاً بعداً دوباره تلاش کنید.'},
status=500,
)

442
config/health.py Normal file
View File

@@ -0,0 +1,442 @@
"""
GET /api/health — «آیا این سرور واقعاً سالم است؟»
چرا لازم بود: تا امروز تنها راهِ فهمیدنِ اینکه دیپلوی درست نشسته یا نه، این
بود که کسی از بیرون اپ را دستی امتحان کند و اگر چیزی غلط بود به آدمی که به
سرور دسترسی دارد پیام بدهد. یعنی خرابی را همیشه آدم کشف می‌کرد، نه ابزار.
این اندپوینت همان بررسی‌ها را از بیرون و بدونِ دسترسیِ SSH ممکن می‌کند.
مهم‌ترین بررسی «مهاجرت‌های اعمال‌نشده» است: شایع‌ترین حالتِ خرابِ دیپلوی این
است که کد pull شده ولی migrate اجرا نشده — اپ بالا می‌آید و بعد زیرِ بارِ
واقعی با خطای ستونِ ناموجود می‌شکند.
عمداً هیچ سکرتی برنمی‌گرداند: فقط بله/خیرِ «تنظیم شده» و شمارشِ محتوا.
"""
import logging
import os
import time
from datetime import timedelta
from django.conf import settings
from django.core.cache import cache
from django.db import connection
from django.db.migrations.executor import MigrationExecutor
from drf_spectacular.utils import extend_schema
from rest_framework.permissions import AllowAny
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:
with connection.cursor() as cursor:
cursor.execute('SELECT 1')
cursor.fetchone()
return {'ok': True, 'engine': connection.vendor}
except Exception as exc: # noqa: BLE001
return {'ok': False, 'error': type(exc).__name__}
def _check_migrations() -> dict:
"""مهاجرتِ اعمال‌نشده یعنی دیپلوی نیمه‌کاره است."""
try:
executor = MigrationExecutor(connection)
targets = executor.loader.graph.leaf_nodes()
pending = executor.migration_plan(targets)
# migration_plan یک فهرست از (migration, backwards) می‌دهد؛ خودِ
# Migration فیلدهای app_label و name دارد (نه key).
return {
'ok': not pending,
'pending': [f'{migration.app_label}.{migration.name}' for migration, _ in pending],
}
except Exception as exc: # noqa: BLE001
return {'ok': False, 'error': type(exc).__name__}
def _check_cache() -> dict:
"""با DEBUG=False کشِ مشترک اجباری است؛ throttle بدونش بی‌اثر می‌شود."""
try:
cache.set('health:probe', 'ok', 10)
return {'ok': cache.get('health:probe') == 'ok', 'backend': settings.CACHES['default']['BACKEND'].rsplit('.', 1)[-1]}
except Exception as exc: # noqa: BLE001
return {'ok': False, 'error': type(exc).__name__}
def _check_content() -> dict:
"""
محتوا روی این سرور هست یا نه — بدونِ این، اپ سالم به نظر می‌رسد ولی
کاربر صفحه‌ی خالی می‌بیند.
"""
try:
from content.models import Grade, Lesson, Question
grades = Grade.objects.count()
lessons = Lesson.objects.count()
questions = Question.objects.count()
return {
'ok': grades > 0 and lessons > 0,
'grades': grades,
'lessons': lessons,
'questions': questions,
}
except Exception as exc: # noqa: BLE001
return {'ok': False, 'error': type(exc).__name__}
def _traffic() -> dict:
"""
آیا کسی واقعاً به این سرور وصل می‌شود؟
تا امروز راهی نبود که از بیرون بفهمیم فرانت به سرور رسیده یا نه — باید
منتظر می‌ماندیم کسی بگوید. این سه عدد همان سیگنال‌اند. فقط شمارش و زمان،
بدونِ هیچ اطلاعاتِ شخصی.
"""
try:
from django.utils import timezone
from accounts.models import User
since = timezone.now() - timedelta(hours=24)
last = User.objects.order_by('-last_activity_at').values_list(
'last_activity_at', flat=True,
).first()
return {
'users': User.objects.count(),
'activeLast24h': User.objects.filter(last_activity_at__gte=since).count(),
'lastActivityAt': last.isoformat() if last else None,
}
except Exception as exc: # noqa: BLE001
return {'error': type(exc).__name__}
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]:
"""
چیزهایی که خرابی نیستند ولی روی یک سرورِ عمومی خطرناک‌اند.
اینجا هستند چون تنها راهِ دیدنشان تا امروز این بود که کسی تنظیماتِ سرور را
از نزدیک بخواند — و ما به سرور دسترسی نداریم.
"""
out = []
if settings.DEBUG:
out.append(
'DEBUG روشن است: صفحه‌های خطا کلِ تنظیمات و traceback را نشان می‌دهند، '
'و کدِ ورودِ آزمایشیِ 0000 برای هر شماره‌ای پذیرفته می‌شود. '
'روی سرورِ عمومی باید خاموش باشد.'
)
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ها مشترک نیست.')
if not getattr(settings, 'CORS_ALLOWED_ORIGINS', None):
out.append('هیچ origin ای برای CORS مجاز نیست — مرورگر همه‌ی درخواست‌های فرانت را بلاک می‌کند.')
return out
from payment.permissions import HasInternalServiceKey # noqa: E402
class HealthView(APIView):
"""
GET /api/health — وضعیتِ این نسخه‌ی مستقرشده.
۲۰۰ یعنی سالم، ۵۰۳ یعنی چیزی واقعاً خراب است (دیتابیس، کش، یا مهاجرتِ
اعمال‌نشده). نبودِ محتوا خرابی حساب نمی‌شود ولی گزارش می‌شود.
"""
permission_classes = [AllowAny]
authentication_classes = []
throttle_classes = [ScopedRateThrottle]
throttle_scope = 'health'
@extend_schema(tags=['System'], responses={200: dict, 503: dict})
def get(self, request):
checks = {
'database': _check_database(),
'migrations': _check_migrations(),
'cache': _check_cache(),
'content': _check_content(),
}
critical_ok = all(checks[k]['ok'] for k in ('database', 'migrations', 'cache'))
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),
'warnings': warnings,
'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=status)

36
config/log_formatters.py Normal file
View File

@@ -0,0 +1,36 @@
"""
فرمت‌کننده‌ی سبکِ JSON برای خروجیِ logging — آماده‌سازیِ پروژه برای
جمع‌آوریِ متمرکزِ لاگ (مثلاً یک agentِ بیرونی مثلِ Promtail/Filebeat که
stdout رو می‌خونه و به Loki/ELK می‌فرسته) در آینده.
⚠️ توجه (محدوده‌ی این تغییر):
- این فایل فقط *فرمتِ* خروجیِ همون لاگ‌های موجودِ اپلیکیشن رو عوض می‌کنه
(یک شیِ JSON در هر خط، به‌جای متنِ ساده‌ی قبلی)؛ هیچ eventِ لاگِ جدیدی
اضافه نمی‌کنه و هیچ loggerِ موجودی رو تغییر نمی‌ده.
- فقط از فیلدهای استانداردِ خودِ LogRecord (زمان، سطح، نامِ logger، پیامِ
از قبل ساخته‌شده، و traceback در صورتِ وجود) استفاده می‌شه؛ یعنی همون
محافظت‌های حساسیتیِ قبلی (بدون OTP/token/رمزعبور/بدنه‌ی خامِ provider و
غیره در پیام‌ها) دست‌نخورده می‌مونه — این فرمت‌کننده چیزی به پیام اضافه
نمی‌کنه، فقط قالب‌بندیِ همون پیامِ از قبل امن رو تغییر می‌ده.
- هیچ وابستگیِ جدید (Elasticsearch/Loki/Grafana client) یا appِ جدید یا
ذخیره‌سازیِ دیتابیسی/APIِ لاگ اضافه نمی‌کنه؛ فقط stdlibِ logging + json.
"""
import json
import logging
from datetime import datetime, timezone
class JSONLogFormatter(logging.Formatter):
"""هر LogRecord رو به یک خطِ JSON (سازگار با collectorهای سبکِ Loki/ELK) تبدیل می‌کنه."""
def format(self, record: logging.LogRecord) -> str:
payload = {
'timestamp': datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
'level': record.levelname,
'logger': record.name,
'message': record.getMessage(),
}
if record.exc_info:
payload['exception'] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False)

224
config/middleware.py Normal file
View File

@@ -0,0 +1,224 @@
"""
میان‌افزارِ سبکِ لاگِ درخواست‌های API — سطحِ پروژه.
⚠️ افزوده شده: پروژه چند logger پراکنده‌ی اپلیکیشنی داشت (accounts.services،
ai_tutor.views، ...) اما هیچ لاگِ یکپارچه‌ای در سطحِ پروژه برای *همه‌ی*
درخواست‌های API وجود نداشت؛ یعنی درخواست‌های عادی، ناموفق، یا الگوهای
مشکوک (مثلاً یک IP/کاربر که پشتِ سرِ هم درخواستِ ناموفق می‌فرسته) به‌صورتِ
یکدست قابلِ ردیابی نبودن. این میان‌افزار فقط یک خط لاگِ خلاصه به‌ازای هر
درخواستِ زیرِ /api/ ثبت می‌کنه: متد، مسیر، کدِ وضعیتِ پاسخ، مدت‌زمان، و
شناسه‌ی کاربرِ احرازهویت‌شده (اگه وجود داشته باشه).
نکاتِ امنیتی/حریمِ خصوصی:
- هیچ‌وقت بدنه‌ی درخواست/پاسخ، query string، هدرها (از جمله Authorization،
Idempotency-Key و غیره)، رمزعبور/OTP، access/refresh token، کلیدِ API، یا
سکرتِ webhook لاگ نمی‌شه — فقط request.path (بدونِ query string) و
متادیتای بی‌ضررِ بالا.
- در صورتِ هر خطای غیرمنتظره در خودِ فرآیندِ لاگ‌کردن (مثلاً یک مقدارِ
غیرمنتظره برای user)، این میان‌افزار هیچ‌وقت نباید جریانِ درخواست/پاسخ
را بشکنه؛ به همین خاطر کل بخشِ لاگ داخلِ try/except قرار گرفته.
- ⚠️ اصلاح: قبلاً اگه self.get_response(request) خودش یک exceptionِ
دست‌نخورده (unhandled) بالا می‌آورد، اون exception قبل از رسیدن به بلوکِ
لاگ از __call__ خارج می‌شد؛ یعنی خطاهای ۵۰۰ اصلاً لاگ نمی‌شدن. حالا
self.get_response در try/except قرار گرفته: در صورتِ exception، یک خطِ
لاگِ سطحِ ERROR (با متد/مسیر/مدت/user_id، بدونِ جزئیاتِ حساس) ثبت می‌شه
و بعد همون exceptionِ اصلی بدونِ تغییر دوباره raise می‌شه (تا رفتارِ
مدیریتِ خطای Django/DRF دست‌نخورده بمونه).
- ⚠️ اصلاحِ این دور: پیامِ لاگِ مسیرِ exception از یک placeholderِ واقعیِ
status=%s استفاده می‌کنه (هم‌سو با مسیرِ موفق)، و status=500 حالا از
طریقِ آرگومانِ status به _log_safely() صریحاً پاس داده می‌شه، نه به‌صورتِ
متنِ ثابت داخلِ رشته. این هم پیام رو با مسیرِ موفق یکدست می‌کنه و هم از
ناهماهنگیِ تعدادِ آرگومان‌های % formatting جلوگیری می‌کنه.
"""
import logging
import time
logger = logging.getLogger(__name__)
# فقط مسیرهای API لاگ می‌شن؛ درخواست‌های استاتیک/غیرِ API (که این پروژه
# اصلاً مسیرِ URL جداگانه‌ای براشون در config/urls.py تعریف نکرده) عمداً
# نادیده گرفته می‌شن تا لاگ شلوغ/بی‌فایده تولید نشه.
_LOGGED_PATH_PREFIX = '/api/'
def _user_id_for_log(request):
"""شناسه‌ی کاربرِ احرازهویت‌شده برای لاگ، یا None (کاربرِ ناشناس/بدونِ user)."""
user = getattr(request, 'user', None)
return user.id if getattr(user, 'is_authenticated', False) else None
class APIRequestLoggingMiddleware:
"""میان‌افزارِ Django برای لاگِ خلاصه‌ی هر درخواستِ API (متد/مسیر/وضعیت/مدت/کاربر)."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if not request.path.startswith(_LOGGED_PATH_PREFIX):
return self.get_response(request)
start = time.monotonic()
try:
response = self.get_response(request)
except Exception:
self._log_safely(
logger.error,
'api_request method=%s path=%s status=%s duration_ms=%.1f user_id=%s (unhandled exception)',
request, start, status=500,
)
raise
self._log_safely(
logger.info,
'api_request method=%s path=%s status=%s duration_ms=%.1f user_id=%s',
request, start, response.status_code,
)
return response
def _log_safely(self, log_func, message, request, start, status=None):
# لاگ‌کردن هیچ‌وقت نباید خودش دلیلِ شکستِ یک درخواست بشه (چه در
# مسیرِ موفق، چه در مسیرِ exception)؛ به همین خاطر کل بخشِ لاگ
# داخلِ try/except قرار گرفته.
try:
duration_ms = (time.monotonic() - start) * 1000
if status is None:
log_func(message, request.method, request.path, duration_ms, _user_id_for_log(request))
else:
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

File diff suppressed because it is too large Load Diff

518
config/tests.py Normal file
View File

@@ -0,0 +1,518 @@
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.test import RequestFactory, SimpleTestCase, TestCase, override_settings
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from config.middleware import APIRequestLoggingMiddleware
class SecretKeyFailClosedTests(SimpleTestCase):
"""
SECRET_KEY در پروداکشن هرگز دیفالتِ قابل‌حدس‌زدن ندارد — این هنوز برقرار است.
آنچه عوض شد: قبلاً نبودنِ DJANGO_SECRET_KEY یعنی اپ اصلاً بالا نمی‌آمد، و
نتیجه‌اش از بیرون فقط 502 بود بدونِ هیچ سرنخی؛ کسی که سرور را بالا می‌آورد
باید حدس می‌زد چه چیزی کم است. حالا اولین اجرا یک کلیدِ تصادفیِ واقعی
می‌سازد و در فایلی کنارِ پروژه نگه می‌دارد — همان‌قدر غیرقابلِ حدس، بدونِ
یک قدمِ دستیِ دیگر. چیزی که باید همچنان تضمین شود این است که آن کلید
تصادفی باشد، نه رشته‌ی هاردکدِ حالتِ توسعه.
مثل SecretDefaultsFailClosedTests در payment/tests.py، چون SECRET_KEY
فقط یک‌بار موقعِ import محاسبه می‌شه (نه چیزی که override_settings
بتونه toggle کنه)، ماژولِ config.settings رو با متغیرهای محیطیِ
کنترل‌شده reload می‌کنیم.
"""
allow_database_queries = True
def _reload_settings_with_env(self, env_overrides, env_removed=()):
from config import settings as settings_module
clean_env = {k: v for k, v in os.environ.items() if k not in env_removed}
clean_env.update(env_overrides)
with patch.dict(os.environ, clean_env, clear=True):
importlib.reload(settings_module)
return settings_module
def tearDown(self):
# همیشه ماژول رو به حالتِ واقعیِ محیطِ تست برمی‌گردونیم تا روی
# بقیه‌ی تست‌ها اثر نذاره.
from config import settings as settings_module
importlib.reload(settings_module)
def test_production_without_the_env_var_generates_a_real_random_key(self):
settings_module = self._reload_settings_with_env(
env_overrides={'DJANGO_DEBUG': ''},
env_removed={'DJANGO_SECRET_KEY'},
)
key = settings_module.SECRET_KEY
self.addCleanup(
lambda: (settings_module.BASE_DIR / '.django_secret_key').unlink(missing_ok=True)
)
self.assertFalse(settings_module.DEBUG)
self.assertNotEqual(
key,
'django-insecure-jn!s*25lz^+bjm101tzmlz8jlj70i0qka++*h-(!0s^^vk-0ss',
'کلیدِ حالتِ توسعه هرگز نباید در پروداکشن استفاده شود',
)
self.assertGreaterEqual(len(key), 50)
def test_the_generated_key_survives_a_restart(self):
"""اگر هر بار کلیدِ تازه ساخته شود، همه‌ی نشست‌ها با هر ری‌استارت باطل می‌شوند."""
first = self._reload_settings_with_env(
env_overrides={'DJANGO_DEBUG': ''}, env_removed={'DJANGO_SECRET_KEY'},
).SECRET_KEY
settings_module = self._reload_settings_with_env(
env_overrides={'DJANGO_DEBUG': ''}, env_removed={'DJANGO_SECRET_KEY'},
)
self.addCleanup(
lambda: (settings_module.BASE_DIR / '.django_secret_key').unlink(missing_ok=True)
)
self.assertEqual(first, settings_module.SECRET_KEY)
def test_production_configured_secret_key_used(self):
# CACHE_URL هم باید ست بشه، وگرنه با DEBUG=False همون
# ImproperlyConfiguredِ CACHE_URL رخ می‌ده، نه رسیدن به assertionِ
# SECRET_KEY که این تست واقعاً می‌سنجه.
settings_module = self._reload_settings_with_env(
env_overrides={
'DJANGO_DEBUG': '',
'DJANGO_SECRET_KEY': 'prod-secret-xyz',
'CACHE_URL': 'redis://localhost:6379/0',
},
)
self.assertFalse(settings_module.DEBUG)
self.assertEqual(settings_module.SECRET_KEY, 'prod-secret-xyz')
def test_debug_mode_missing_secret_key_uses_local_fallback(self):
settings_module = self._reload_settings_with_env(
env_overrides={'DJANGO_DEBUG': '1'},
env_removed={'DJANGO_SECRET_KEY'},
)
self.assertTrue(settings_module.DEBUG)
self.assertEqual(
settings_module.SECRET_KEY,
'django-insecure-jn!s*25lz^+bjm101tzmlz8jlj70i0qka++*h-(!0s^^vk-0ss',
)
class CacheUrlFailClosedTests(SimpleTestCase):
"""
نبودنِ CACHE_URL در پروداکشن یک ضعفِ واقعی‌ست: LocMemCache بینِ workerها
مشترک نیست و throttleها را کم‌اثر می‌کند.
ولی دلیلِ خوبی برای بالا نیامدنِ کلِ سرویس نیست — قبلاً همین‌جا استثنا
پرتاب می‌شد و نتیجه‌اش از بیرون فقط 502 بود. حالا سرویس بالا می‌آید و
/api/health صریحاً هشدار می‌دهد. چیزی که باید تضمین شود این است که این
ضعف **بی‌صدا** نماند.
"""
allow_database_queries = True
def _reload_settings_with_env(self, env_overrides, env_removed=()):
from config import settings as settings_module
clean_env = {k: v for k, v in os.environ.items() if k not in env_removed}
clean_env.update(env_overrides)
with patch.dict(os.environ, clean_env, clear=True):
importlib.reload(settings_module)
return settings_module
def tearDown(self):
from config import settings as settings_module
importlib.reload(settings_module)
def test_production_without_cache_url_boots_but_is_not_silent(self):
settings_module = self._reload_settings_with_env(
env_overrides={'DJANGO_DEBUG': '', 'DJANGO_SECRET_KEY': 'prod-secret-xyz'},
env_removed={'CACHE_URL'},
)
self.assertIn('LocMem', settings_module.CACHES['default']['BACKEND'])
# همان ضعف باید در /api/health دیده شود، وگرنه بی‌صدا می‌ماند.
from config import health
with self.settings(DEBUG=False,
CACHES=settings_module.CACHES,
DEV_MASTER_OTP=None):
warnings = ' '.join(health._warnings())
self.assertIn('کش', warnings)
def test_debug_mode_missing_cache_url_uses_locmem_fallback(self):
settings_module = self._reload_settings_with_env(
env_overrides={'DJANGO_DEBUG': '1'},
env_removed={'CACHE_URL'},
)
self.assertTrue(settings_module.DEBUG)
self.assertEqual(
settings_module.CACHES['default']['BACKEND'],
'django.core.cache.backends.locmem.LocMemCache',
)
class APIRequestLoggingMiddlewareTests(TestCase):
"""
🔒 اثبات میان‌افزارِ جدیدِ لاگِ درخواستِ API (config.middleware.
APIRequestLoggingMiddleware): باید برای مسیرهای /api/ یک خطِ لاگِ خلاصه
(متد/مسیر/وضعیت/مدت/شناسه‌ی کاربر) ثبت کنه، بدونِ اینکه هیچ‌وقت بدنه/
هدر/سکرت رو لاگ کنه؛ و برای مسیرهای غیرِ /api/ (مثلِ /admin/) اصلاً
لاگ نکنه.
"""
def setUp(self):
self.client = APIClient()
def test_authenticated_api_request_is_logged_with_user_id_and_no_secrets(self):
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',
{'gradeId': 10},
format='json',
HTTP_AUTHORIZATION=f'Bearer {token}',
)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(captured.records), 1)
message = captured.records[0].getMessage()
self.assertIn('method=POST', message)
self.assertIn('path=/api/user/grade', message)
self.assertIn('status=200', message)
self.assertIn(f'user_id={user.id}', message)
# هیچ‌وقت نباید هیچ سکرتی در پیامِ لاگ باشه.
self.assertNotIn(token, message)
self.assertNotIn('Bearer', message)
self.assertNotIn('gradeId', message)
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/refresh',
{'refreshToken': 'not-a-real-token'},
format='json',
)
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/refresh', message)
self.assertIn('status=401', message)
self.assertIn('user_id=None', message)
def test_non_api_request_is_not_logged(self):
# /admin/ زیرِ /api/ نیست؛ میان‌افزار عمداً این‌جور مسیرها رو لاگ
# نمی‌کنه تا لاگِ اضافه/بی‌فایده تولید نشه.
logger = logging.getLogger('config.middleware')
handler = logging.Handler()
records = []
handler.emit = records.append
logger.addHandler(handler)
try:
self.client.get('/admin/login/')
finally:
logger.removeHandler(handler)
self.assertEqual(records, [])
def test_unhandled_exception_from_view_is_logged_as_error_and_reraised(self):
# میان‌افزار باید یک خطِ ERROR (متد/مسیر/status=500/مدت/user_id، بدونِ
# جزئیاتِ حساس) ثبت کنه و بعد همون exception رو دوباره raise کنه.
# با logging.Formatter().format(record) دقیقاً همون مسیری که یک
# handlerِ واقعی طی می‌کنه رو صدا می‌زنیم (نه فقط getMessage()، که
# یه mismatchِ %%-args رو با exceptionِ خودش می‌پوشونه، نه fail واقعی).
request = RequestFactory().get('/api/some/failing/path')
def failing_get_response(inner_request):
raise ValueError('boom')
middleware = APIRequestLoggingMiddleware(failing_get_response)
with self.assertLogs('config.middleware', level='ERROR') as captured:
with self.assertRaises(ValueError):
middleware(request)
self.assertEqual(len(captured.records), 1)
record = captured.records[0]
self.assertEqual(record.levelname, 'ERROR')
formatted = logging.Formatter().format(record)
self.assertIn('method=GET', formatted)
self.assertIn('path=/api/some/failing/path', formatted)
self.assertIn('status=500', formatted)
self.assertIn('user_id=None', formatted)
class JSONLogFormatterTests(SimpleTestCase):
"""
🔒 اثبات فرمت‌کننده‌ی جدیدِ JSON (config.log_formatters.JSONLogFormatter)
که پروژه رو برای جمع‌آوریِ متمرکزِ لاگ (Loki/ELK-style) آماده می‌کنه:
خروجی باید یک JSON معتبر با فیلدهای timestamp/level/logger/message
باشه، و در صورتِ وجودِ exception، traceback هم به‌عنوانِ فیلدِ
"exception" اضافه بشه.
"""
def _make_record(self, level=logging.INFO, message='hello world', exc_info=None):
return logging.LogRecord(
name='some.logger',
level=level,
pathname=__file__,
lineno=1,
msg=message,
args=(),
exc_info=exc_info,
)
def test_basic_record_is_formatted_as_valid_json_with_expected_fields(self):
from config.log_formatters import JSONLogFormatter
record = self._make_record(level=logging.INFO, message='api_request method=GET path=/api/x status=200')
formatted = JSONLogFormatter().format(record)
payload = json.loads(formatted)
self.assertEqual(payload['level'], 'INFO')
self.assertEqual(payload['logger'], 'some.logger')
self.assertEqual(payload['message'], 'api_request method=GET path=/api/x status=200')
self.assertIn('timestamp', payload)
self.assertNotIn('exception', payload)
def test_record_with_exception_includes_traceback_field(self):
from config.log_formatters import JSONLogFormatter
try:
raise ValueError('boom')
except ValueError:
import sys
exc_info = sys.exc_info()
record = self._make_record(level=logging.ERROR, message='something failed', exc_info=exc_info)
formatted = JSONLogFormatter().format(record)
payload = json.loads(formatted)
self.assertEqual(payload['level'], 'ERROR')
self.assertEqual(payload['message'], 'something failed')
self.assertIn('exception', payload)
self.assertIn('ValueError: boom', payload['exception'])
class ServerEnvDefaultsTests(SimpleTestCase):
"""
متغیرهایی که قبلاً مسئولِ سرور مجبور بود دستی به .env اضافه کند.
هر سه‌تا یک الگو داشتند: نبودشان یا سرویس را می‌خواباند یا یک مسیر را
بی‌سروصدا می‌شکست، و چون در .env.example هم نبودند، هر بار از نو کشف
می‌شدند. این تست نگهبانِ همان است — نه اینکه مقدارها «قشنگ»اند، بلکه
اینکه بدونِ هیچ متغیرِ محیطی‌ای مقدارِ کارآمد دارند.
"""
# دامنه‌ی 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)
def test_an_incomplete_env_value_does_not_drop_our_domains(self):
"""
.env سرور از قبل DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 دارد.
اگر مقدارِ موجود پیش‌فرض را کنار بزند، همان «Disallowed Host» قدیمی
برمی‌گردد و باز کسی باید دستی فایل را ویرایش کند.
"""
# settings فقط نام‌های بزرگ را نشان می‌دهد؛ تابع از خودِ ماژول می‌آید.
from config.settings import _with_defaults
with patch.dict(os.environ, {'DJANGO_ALLOWED_HOSTS': 'localhost,127.0.0.1'}):
merged = _with_defaults('DJANGO_ALLOWED_HOSTS', settings.DEFAULT_ALLOWED_HOSTS)
self.assertIn(self.HOST, merged)
self.assertIn('localhost', merged, 'مقدارِ موجود نباید حذف شود')
def test_a_present_but_empty_value_still_falls_back(self):
"""کلید در .env هست ولی خالی — os.environ.get پیش‌فرض نمی‌دهد."""
with patch.dict(os.environ, {'PUBLIC_BASE_URL': ''}):
value = (os.environ.get('PUBLIC_BASE_URL', '').strip()
or 'https://mathapi.tadrisino2.ir')
self.assertTrue(value.startswith('https://'))
def test_admin_login_origin_is_trusted_without_any_env_var(self):
self.assertIn(f'https://{self.HOST}', settings.CSRF_TRUSTED_ORIGINS)
def test_the_deployed_frontend_may_call_the_api(self):
self.assertIn('https://math.tadrisino2.ir', settings.CORS_ALLOWED_ORIGINS)
def test_payment_urls_are_absolute_without_any_env_var(self):
"""خالی بودنِ PUBLIC_BASE_URL مسیرِ پرداخت را بی‌صدا می‌شکست."""
self.assertTrue(settings.PUBLIC_BASE_URL.startswith('https://'))
self.assertTrue(settings.ZARINPAL_CALLBACK_URL.startswith('https://'))
self.assertIn('/api/payment/zarinpal/callback', settings.ZARINPAL_CALLBACK_URL)
class SecureCookieTests(SimpleTestCase):
"""
کوکیِ نشستِ ادمین جایی‌ست که اعتبارنامه‌ی واقعی وارد می‌شود؛ روی HTTP
نباید رد و بدل شود.
مثلِ بقیه‌ی تست‌های این فایل ماژول را دوباره بار می‌کنیم: این مقدارها در
زمانِ import محاسبه می‌شوند و اجرای تست خودش DEBUG را خاموش می‌کند، پس
خواندنِ settings در لحظه چیزِ گمراه‌کننده‌ای می‌دهد.
"""
allow_database_queries = True
def _reload(self, env_overrides, env_removed=()):
from config import settings as settings_module
clean_env = {k: v for k, v in os.environ.items() if k not in env_removed}
clean_env.update(env_overrides)
with patch.dict(os.environ, clean_env, clear=True):
importlib.reload(settings_module)
return settings_module
def tearDown(self):
from config import settings as settings_module
importlib.reload(settings_module)
def test_cookies_are_secure_in_production(self):
module = self._reload(
{'DJANGO_DEBUG': '', 'DJANGO_SECRET_KEY': 'x' * 60,
'CACHE_URL': 'redis://localhost:6379/0'},
)
self.assertFalse(module.DEBUG)
self.assertTrue(module.SESSION_COOKIE_SECURE)
self.assertTrue(module.CSRF_COOKIE_SECURE)
self.assertTrue(module.SESSION_COOKIE_HTTPONLY)
def test_cookies_stay_usable_over_plain_http_in_development(self):
"""با DEBUG روشن، کوکیِ امن یعنی ورود به ادمینِ محلی کار نمی‌کند."""
module = self._reload({'DJANGO_DEBUG': '1'})
self.assertTrue(module.DEBUG)
self.assertFalse(module.SESSION_COOKIE_SECURE)
def test_django_trusts_the_proxy_for_https(self):
"""بدونِ این، کوکیِ امن پشتِ nginx هرگز ست نمی‌شود و ورود می‌شکند."""
self.assertEqual(settings.SECURE_PROXY_SSL_HEADER,
('HTTP_X_FORWARDED_PROTO', 'https'))
class CorsDevPortTests(TestCase):
"""
پورتِ سرورِ توسعه نباید در فهرستِ CORS شمرده شود.
فهرستِ ثابتِ قبلی (۳۰۰۰ و ۵۱۷۳) روزی شکست که Vite دید ۵۱۷۳ اشغال است و
بی‌سروصدا رفت روی ۵۱۷۴. فرانت‌کار خطای CORS گرفت، از سمتِ سرور هیچ ردی در
هیچ لاگی نبود، و چون هم‌زمان با تغییراتِ بک‌اند شد به اشتباه گردنِ ما افتاد.
هزینه‌اش یک ویرایشِ .env و یک ری‌استارتِ پروداکشن بود، برای چیزی که اصلاً
به پروداکشن ربطی نداشت.
"""
def _preflight(self, origin):
return self.client.options(
'/api/grades',
HTTP_ORIGIN=origin,
HTTP_ACCESS_CONTROL_REQUEST_METHOD='GET',
)
def test_any_localhost_port_is_allowed_so_a_new_dev_port_never_breaks_again(self):
for origin in ('http://localhost:5173', 'http://localhost:5174',
'http://localhost:3000', 'http://127.0.0.1:8080'):
with self.subTest(origin=origin):
resp = self._preflight(origin)
self.assertEqual(
resp.headers.get('access-control-allow-origin'), origin)
def test_the_deployed_frontend_origin_still_works(self):
resp = self._preflight('https://math.tadrisino2.ir')
self.assertEqual(resp.headers.get('access-control-allow-origin'),
'https://math.tadrisino2.ir')
def test_a_stranger_origin_is_still_refused(self):
# الگو فقط localhost است، نه هر دامنه‌ای؛ و «localhost» به‌عنوانِ
# زیررشته در دامنه‌ی دیگری نباید رد شود.
for origin in ('https://evil.example.com',
'http://localhost.evil.com',
'https://math.tadrisino2.ir.evil.com'):
with self.subTest(origin=origin):
resp = self._preflight(origin)
self.assertIsNone(resp.headers.get('access-control-allow-origin'))
def test_credentials_stay_off(self):
# توکنِ ما در هدر است نه کوکی؛ روشن کردنِ این فقط سطحِ حمله اضافه می‌کند
# و پذیرشِ هر پورتِ 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'})

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