Compare commits
233 Commits
37d66a4a4c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 43e02adf37 | |||
| e25ffb5575 | |||
| eb03b467d4 | |||
| f2c57c1ad1 | |||
| f6d779255d | |||
| 4cd859b5fa | |||
| dd98f16d12 | |||
| bd329f83ae | |||
| 670513b462 | |||
| 133233144e | |||
| a5a93ef6ba | |||
| 5f963e2898 | |||
| 7bd7c637b4 | |||
| ac96f93abc | |||
| 9903e0ec91 | |||
| 759bb0d575 | |||
| f8a93a37c9 | |||
| 9eae99340d | |||
| f57deb9c1d | |||
| 5fbbd371fe | |||
| 82fec6bf75 | |||
| 8a2bf26b16 | |||
| 3f68abe741 | |||
| 7973bf4100 | |||
| 0c6e155232 | |||
| e6e983f970 | |||
| 733796c25a | |||
| 998b68e4bc | |||
| cd4f2ce0b2 | |||
| a4e0c576bb | |||
| eb95209ea8 | |||
| 1c7c4b8eca | |||
| 7b241c1aac | |||
| e3c8962b7b | |||
| ba4ecff29e | |||
| 2a03dc81b5 | |||
| e3bddb3757 | |||
| 78c3669119 | |||
| 3559c43902 | |||
| f4831e93a7 | |||
| 44ad2eabf3 | |||
| 8d8665edf3 | |||
| 947f946909 | |||
| 9c3adc3e60 | |||
| c0fa46be44 | |||
| 76b0ef976e | |||
| 8077ab4350 | |||
| 2e0753587c | |||
| b075b93f2a | |||
| e662914efc | |||
| dd10ff83f4 | |||
| f9eea56895 | |||
| 3187f69714 | |||
| 33478179e2 | |||
| 9c3596a132 | |||
| 8cb1ecb85e | |||
| 3d51b3b83a | |||
| 98bf05fcbd | |||
| 2c708088e4 | |||
| feaa5eb540 | |||
| d32d98d3f6 | |||
| 673a9e7e39 | |||
| 1fe453095f | |||
| 4a41def4b2 | |||
| 293b916544 | |||
| 4821df3914 | |||
| 9024c6b8d8 | |||
| 2e87a81cc5 | |||
| d10e3e4c9f | |||
| a9cccc6550 | |||
| 19072ee534 | |||
| a39643b8a3 | |||
| 64c4c09326 | |||
| 7d81d193bd | |||
| a5feee053a | |||
| 22c3196343 | |||
| d6529956e1 | |||
| c1cc6be0cd | |||
| bda415e579 | |||
| dbf0ff52d2 | |||
| 92dcce901b | |||
| ddc6c7c9b7 | |||
| 042df25738 | |||
| e00f6b80eb | |||
| b93f43ef02 | |||
| 25b3d24eb4 | |||
| ce629e32e4 | |||
| 1d7eb829e2 | |||
| ff370112c0 | |||
| d584d62904 | |||
| c129f64180 | |||
| 277927046d | |||
| 7929f6bd85 | |||
| 4c601087dc | |||
| 3e63bd0860 | |||
| 7ecb337263 | |||
| 7e3854098f | |||
| 5b7e6fb7d7 | |||
| f22b9f2e88 | |||
| 663252a260 | |||
| 8835e01595 | |||
| 03e81abe7e | |||
| 28b8527760 | |||
| fedd10ff22 | |||
| eb4e97753b | |||
| a973c41db2 | |||
| 645baf846b | |||
| b3f624a512 | |||
| 2cf2d14dd4 | |||
| f441cfa96b | |||
| 8cb326ba94 | |||
| ab5e53730a | |||
| 7e027c167c | |||
| 7307bfcadb | |||
| 7e4b8b54a6 | |||
| 7683920b0b | |||
| 8a4753bd15 | |||
| d7d168e82f | |||
| 4700ccbf91 | |||
| 0f0b779a4e | |||
| 3bafd3d9c4 | |||
| 1803c76a8b | |||
| 67cbe05609 | |||
| fe658c697a | |||
| 5fb9f4aaa0 | |||
| 4c7ccf10a0 | |||
| 58c8e6d014 | |||
| 2edc56fc08 | |||
| b3940e87f7 | |||
| 191aba0e25 | |||
| 2f1e257533 | |||
| 63cd1af6f7 | |||
| e9d676ce68 | |||
| 39247f6d53 | |||
| d5dbd1b23d | |||
| 5e1613e431 | |||
| a325def504 | |||
| e3abd675db | |||
| 5f4d7b1ed4 | |||
| 957c9d0f1e | |||
| 93b451d422 | |||
| 8321dc22c3 | |||
| 2c759a7dd7 | |||
| 50df7bd371 | |||
| a8cd681d65 | |||
| 731dea9bb1 | |||
| 14a4d9cbf1 | |||
| 0917ab487f | |||
| 33d8f43d74 | |||
| 74c3ee5e86 | |||
| 4393087fa7 | |||
| ca46e66024 | |||
| 9a1ee36c0a | |||
| 301a6942da | |||
| c1c2a270c2 | |||
| 93334635f2 | |||
| 1f38465d88 | |||
| 8768a2406b | |||
| 4b246e0c70 | |||
| 2f8ed50b86 | |||
| 7d205caa79 | |||
| ff212855a5 | |||
| f6cdfddaa9 | |||
| bdd5aba985 | |||
| 64f88f0bce | |||
| 8763ffd7bf | |||
| 5d490edaf9 | |||
| 64cf018e55 | |||
| a6f91ea915 | |||
| 11bbe1c248 | |||
| e0176ca0a6 | |||
| 4a3ec0280a | |||
| 8b510597a6 | |||
| 0580aec7b0 | |||
| 74aa9aa8ca | |||
| 66066cbe23 | |||
| 42783f9ba4 | |||
| fbdb9495ee | |||
| 81348cee5a | |||
| ca6b2ff4c9 | |||
| 96f0204fcf | |||
| 701d8badcc | |||
| c874c186b1 | |||
| 147af3ac10 | |||
| b32194497c | |||
| 0c6b08c9ff | |||
| d70737dddf | |||
| 7d6abab15c | |||
| 5003b8a852 | |||
| ac3999ea92 | |||
| ae0375c26c | |||
| 5492788bf8 | |||
| 5aa10eb7b7 | |||
| 8fce3b9548 | |||
| 66364e0e92 | |||
| 6a6d0200d2 | |||
| 82edd0ecb0 | |||
| 3026df0397 | |||
| acce78bb03 | |||
| 3156bcbbf3 | |||
| d15a28799a | |||
| 96bda905db | |||
| fa347915c4 | |||
| b115ccd37a | |||
| 8eedbdeac0 | |||
| 6ec82081b5 | |||
| 700b2e4c7f | |||
| 0132235026 | |||
| ae9c33c5de | |||
| 3179ad0b3b | |||
| fecba2011b | |||
| de118db58f | |||
| 07fab4c094 | |||
| 16d3d9f149 | |||
| e89de084e7 | |||
| b26aca0f63 | |||
| 92dc2242f8 | |||
| 1131569b76 | |||
| 0c374fed99 | |||
| 4857f49f92 | |||
| ca983d237f | |||
| 9c3db68cbb | |||
| f3be99e4e2 | |||
| 8b2a92dd3c | |||
| 2218f82938 | |||
| 10ad1e232c | |||
| 99a0cafe24 | |||
| 8effe5b60b | |||
| 39a9f435ac | |||
| a3a47b459d | |||
| 3dacf3a50f | |||
| e5d2427560 | |||
| 3e31fea42e |
194
.env.example
194
.env.example
@@ -1,79 +1,147 @@
|
||||
# این فایل نمونهست؛ در صورتِ نیاز کپی کنید به .env و مقداردهی کنید:
|
||||
# cp .env.example .env
|
||||
# نمونهی تنظیماتِ بکاندِ تدریسینو — برای لپتاپ و سرور.
|
||||
#
|
||||
# اگه .env نسازید، docker-compose.yml از همین مقادیرِ دیفالتِ پایین
|
||||
# استفاده میکنه (که برای اجرای محلی/دِمو کافیه، نه برای پروداکشن).
|
||||
# cp .env.example .env
|
||||
#
|
||||
# هیچکدام برای بالا آمدنِ سرویس اجباری نیست: دامنهها، CORS، کلیدِ امضا و کش در
|
||||
# خودِ config/settings.py پیشفرضِ کارا دارند. مقدارهای این فایل با آن پیشفرضها
|
||||
# ادغام میشوند، جایگزینشان نمیشوند. شرحِ کامل: docs/operations.md.
|
||||
#
|
||||
# مقدارِ واقعیِ کلیدها فقط در .env (که در git نیست)، هرگز در این فایل یا در چت.
|
||||
|
||||
# DEBUG=1 یعنی SECRET_KEY و CACHE_URL دیفالتِ dev دارن و DEV_MASTER_OTP
|
||||
# (کدِ 0000) فعاله؛ برای شبیهسازیِ پروداکشن این رو 0 کنید (اونوقت
|
||||
# DJANGO_SECRET_KEY و CACHE_URL رو هم باید صریحاً بدید).
|
||||
DJANGO_DEBUG=1
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# ۱) پایه
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
# روی لپتاپ 1، روی سرور همیشه 0.
|
||||
# DEBUG=1 روی سرور یعنی کدِ ورودِ 0000 برای هر شمارهای و صفحهی خطا با تنظیمات.
|
||||
DJANGO_DEBUG=0
|
||||
|
||||
# خالی بماند: بارِ اول یک کلیدِ تصادفی ساخته و در .django_secret_key (0600) نگه
|
||||
# داشته میشود. هرگز مقدارِ django-insecure-… داخلِ settings.py را اینجا نگذارید.
|
||||
DJANGO_SECRET_KEY=
|
||||
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
CSRF_TRUSTED_ORIGINS=
|
||||
|
||||
# Redis برای محدودیتِ نرخِ مشترک بینِ workerها؛ خالی = کشِ حافظهی هر worker
|
||||
# (/api/health هشدار میدهد). مثال: redis://127.0.0.1:6379/1
|
||||
CACHE_URL=
|
||||
|
||||
# اختیاری — بدونش OTP واقعی ارسال نمیشه (فقط خطای صریح ۵۰۳ برمیگرده،
|
||||
# طبق رفتارِ موجودِ accounts/services.py)
|
||||
# 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=
|
||||
|
||||
# رمزِ ادمینِ Grafana (کاربرِ admin) — فقط برای دسترسیِ محلی
|
||||
GRAFANA_ADMIN_PASSWORD=admin
|
||||
# شمارههای تستیِ تیم با کدِ ثابت، بدونِ پیامک: «شماره:کد» با کاما.
|
||||
# کد تصادفی باشد؛ پیش از کاربرِ واقعی خالی شود (/api/health تا آنوقت هشدار میدهد).
|
||||
# TEST_LOGIN_PHONES=09120000001:834512,09120000002:110947
|
||||
TEST_LOGIN_PHONES=
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# RAG / AI tutor (اپ rag) — موقعِ تست پر کنید
|
||||
# -----------------------------------------------------------------------
|
||||
# بدونِ OPENAI_API_KEY و QDRANT_API_KEY، مسیرِ RAG غیرفعاله و
|
||||
# POST /api/ai/ask همون پاسخِ stubِ قبلی رو میده (سازگاریِ عقبرو).
|
||||
# فعلاً از GapGPT (پروکسیِ OpenAI برای ایران) استفاده میکنیم.
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# ۴) دستیارِ هوش مصنوعی (RAG)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# بدونِ این دو کلید، POST /api/ai/ask پاسخِ آزمایشیِ ثابت (stub) میدهد.
|
||||
# GapGPT پروکسیِ OpenAI است و فقط از داخلِ ایران جواب میدهد.
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_BASE_URL=https://api.gapgpt.app/v1
|
||||
# اگه بعداً خواستی مستقیم OpenAI واقعی رو تست کنی: کلیدِ واقعی رو این پایین
|
||||
# پارک کن، بعد فقط دو خطِ بالا رو باهاش عوض کن. هیچ منطقِ سوییچی در کد نیست
|
||||
# تا ساده و بیاشتباه بمونه.
|
||||
# OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
OPENAI_API_KEY_OPENAI_DIRECT=
|
||||
OPENAI_CHAT_MODEL=gpt-4o-mini
|
||||
OPENAI_VISION_MODEL=gpt-4o
|
||||
# این رو تغییر نده؛ باید با مدلی که تیمِ دیگه باهاش collectionها رو ساخته یکی باشه.
|
||||
OPENAI_EMBEDDING_MODEL=text-embedding-3-small
|
||||
|
||||
QDRANT_URL=https://qdrant.tadrisino2.ir:443
|
||||
QDRANT_API_KEY=
|
||||
|
||||
# نامِ collection = f"{subject} {grade}".lower() . collectionهای موجود روی
|
||||
# سرورِ تیمِ دیگه (بررسیشده): «علوم تجربی سوم» ، «مطالعات اجتماعی چهارم» ،
|
||||
# «مطالعات اجتماعی ششم» ، «ریاضی سوم» ، «ریاضی ششم» ، «ریاضی هفتم».
|
||||
# یعنی جزءِ گرید فقط «سوم/چهارم/ششم/هفتم» است (بدونِ «دبستان»).
|
||||
#
|
||||
# کلیدِ فیلترِ درس دستی تنظیم نمیشه — rag/services/known_collections.py خودش
|
||||
# تشخیص میده: ریاضی → `chapter` (و شمارهی فصل رو به رشتهی دقیق تبدیل میکنه)،
|
||||
# بقیه → `lesson_number`.
|
||||
RAG_COLLECTION_NAME_TEMPLATE={subject} {grade}
|
||||
RAG_RETRIEVAL_TOP_K=4
|
||||
# RAG_FORCE_MOCK=1 یعنی حتی با وجودِ کلیدها هم مسیرِ RAG خاموش بمونه.
|
||||
RAG_FORCE_MOCK=
|
||||
# مدلها؛ پیشفرضها در settings.py همیناند و سنجیده شدهاند (docs/architecture.md).
|
||||
# OPENAI_CHAT_MODEL=gpt-4.1-mini
|
||||
# OPENAI_VISION_MODEL=gpt-4.1-mini
|
||||
# مدلِ جدا فقط برای پایههای ۱۰ تا ۱۲؛ خالی = همان مدلِ بالا.
|
||||
# OPENAI_CHAT_MODEL_UPPER=
|
||||
# embedding را عوض نکنید مگر collectionهای Qdrant هم با همان مدل بازساخته شوند.
|
||||
# OPENAI_EMBEDDING_MODEL=text-embedding-3-small
|
||||
# سقفِ سوالِ روزانه از دستیار برای کاربرِ بدونِ اشتراک (پیشفرض ۳). سقفِ مشترکان روی
|
||||
# هر پلن در ادمین است (ai_daily_quota). حسابِ هزینه: docs/ai-cost-and-guardrails.md
|
||||
# AI_DAILY_QUOTA_FREE=2
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# زرینپال (اپ payment) — درگاهِ اصلیِ پرداختِ ما
|
||||
# -----------------------------------------------------------------------
|
||||
# بدونِ ZARINPAL_MERCHANT_ID، مسیرِ پرداخت fail-closed میمونه (خطای صریحِ
|
||||
# ۵۰۳ بهجایِ پرداختِ ساختگیِ موفق).
|
||||
PUBLIC_BASE_URL=
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# ۵) پرداخت
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# زرینپال. بدونِ مرچنت، پرداخت ۵۰۳ میدهد. مرچنت باید برای دامنهی PUBLIC_BASE_URL
|
||||
# ثبت شده باشد، وگرنه زرینپال خطای -14 میدهد.
|
||||
ZARINPAL_MERCHANT_ID=
|
||||
# 1 = محیطِ sandbox زرینپال (برای تست بدونِ پرداختِ واقعی)
|
||||
ZARINPAL_SANDBOX=1
|
||||
# اختیاری — بعدِ تاییدِ پرداخت، کاربر به این URL (مثلاً یک deep link اپ) با
|
||||
# ?orderId=...&status=... ریدایرکت میشه. خالی = پاسخِ JSON ساده.
|
||||
ZARINPAL_REDIRECT_URL=
|
||||
# 1 = محیطِ آزمایشیِ زرینپال (بیپولِ واقعی). روی سرورِ فعلی خالی است: درگاهِ واقعی.
|
||||
ZARINPAL_SANDBOX=
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# تنظیماتِ مقیاسپذیری (همه دیفالتِ امن دارن)
|
||||
# -----------------------------------------------------------------------
|
||||
# persistent DB connection (ثانیه). با SQLite بیاثر؛ بعد از Postgres مهم.
|
||||
DB_CONN_MAX_AGE=60
|
||||
# کشِ کوتاهِ ردیفِ کاربر در auth تا هر request یک SELECT نزنه. 0 = خاموش
|
||||
# (پیشفرض). توصیهی پروداکشن با Redis: 30. با هر save/delete روی User باطل میشه.
|
||||
AUTH_USER_CACHE_TTL=0
|
||||
# سقفِ ارزانِ خواندن روی endpointهای پرترافیکِ GET (home/leaderboard/summary/...).
|
||||
READ_THROTTLE_RATE=120/min
|
||||
# کافهبازار (فقط خریدِ درونبرنامهایِ اپِ اندروید). روشِ ترجیحی توکنِ ثابتِ پیشخان
|
||||
# است؛ سه متغیرِ OAuth فقط وقتی آن نیست. بدونِ هیچکدام، مسیرِ بازار بسته میماند.
|
||||
BAZAAR_PISHKHAN_API_SECRET=
|
||||
BAZAAR_CLIENT_ID=
|
||||
BAZAAR_CLIENT_SECRET=
|
||||
BAZAAR_REFRESH_TOKEN=
|
||||
# BAZAAR_PACKAGE_NAME=com.tadrisino.math.app
|
||||
# کلیدِ «تخفیف پویا» از پیشخان (سمتِ سرور؛ قیمتِ ادمین و کدِ تخفیف را به بازار میگوید).
|
||||
BAZAAR_DYNAMIC_PRICE_KEY=
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# ۶) اختیاری — پیشفرضها معمولاً کافیاند
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# کلیدِ مسیرهای داخلیِ سرور-به-سرور؛ خالی = آن مسیرها بستهاند (در DEBUG هم پیشفرضی ندارد).
|
||||
# INTERNAL_SERVICE_KEY=
|
||||
# مهمانهای نمایشی که با «حذفِ حساب» پاک نمیشوند (با کاما). پیشفرض: frontend-test-device.
|
||||
# DEMO_GUEST_DEVICE_IDS=
|
||||
# با صفر قلب جلوی جواب دادن گرفته شود؟ پیشفرض روشن (از ۲۰۲۶-۱۰-۰۲)؛ ۰ خاموشش میکند.
|
||||
# HEARTS_ENFORCED=1
|
||||
# سقفهای کدِ ورود: هر شماره در روز، و کلِ سامانه در روز (ضدِ SMS pumping).
|
||||
# OTP_REQUEST_PHONE_DAILY_MAX=10
|
||||
# OTP_SMS_GLOBAL_DAILY_MAX=300
|
||||
# سقفِ خواندن روی اندپوینتهای پرترافیک، و سقفِ /api/health.
|
||||
# READ_THROTTLE_RATE=120/min
|
||||
# HEALTH_THROTTLE_RATE=60/min
|
||||
# سقفِ تمدیدِ توکن برای هر IP (همهی کاربرانِ پشتِ یک وایفای یا CGNAT با هم).
|
||||
# TOKEN_REFRESH_IP_RATE=600/hour
|
||||
# سقفِ روزانهی کلِ سامانه برای سوالهایی که به مدلِ هوش مصنوعی میرسند (ضدِ خرجِ بیسقف).
|
||||
# AI_GLOBAL_DAILY_MAX=3000
|
||||
# کشِ پاسخِ دستیار (ثانیه)؛ سوالِ تکراری دوباره به مدل نمیرود.
|
||||
# RAG_ANSWER_CACHE_TTL=1800
|
||||
# 1 = دستیار حتی با کلیدها هم پاسخِ آزمایشی بدهد.
|
||||
# RAG_FORCE_MOCK=
|
||||
# لاگ در فایل علاوه بر stdout (فقط داخلِ Docker).
|
||||
# LOG_DIR=
|
||||
# رمزِ ادمینِ Grafana در docker compose (فقط محلی).
|
||||
# GRAFANA_ADMIN_PASSWORD=admin
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# نمونهی فایلِ .env برای سرور. یکبار پر میشود و بعد دیگر کاری با آن نیست.
|
||||
#
|
||||
# بعد از پر کردن، هر آپدیت فقط این است:
|
||||
# git pull && ./deploy.sh
|
||||
|
||||
# ─── اجباری ──────────────────────────────────────────────────────────────
|
||||
# دامنههایی که این سرویس روی آنها سرو میشود (با ویرگول جدا).
|
||||
DJANGO_ALLOWED_HOSTS=api.math.tadrisino2.ir
|
||||
|
||||
# ─── حالتِ پروداکشن ──────────────────────────────────────────────────────
|
||||
# DJANGO_DEBUG را ست نکنید (یا 0 بگذارید).
|
||||
#
|
||||
# ⚠️ با DJANGO_DEBUG=1 روی یک سرورِ عمومی:
|
||||
# • صفحههای خطا کلِ تنظیمات و traceback را نشان میدهند
|
||||
# • کدِ ورودِ آزمایشیِ 0000 برای هر شمارهای پذیرفته میشود،
|
||||
# یعنی هر کسی میتواند بهجای هر کاربری وارد شود
|
||||
DJANGO_DEBUG=0
|
||||
|
||||
# با DEBUG خاموش این دو اجباریاند:
|
||||
DJANGO_SECRET_KEY=
|
||||
# یک رشتهی تصادفیِ بلند. ساختنش:
|
||||
# python -c "import secrets; print(secrets.token_urlsafe(64))"
|
||||
|
||||
CACHE_URL=redis://127.0.0.1:6379/1
|
||||
# بدونِ کشِ مشترک، محدودیتِ نرخ بینِ workerهای gunicorn مشترک نیست.
|
||||
|
||||
# دامنههایی که فرم/ادمین از آنها POST میکند
|
||||
CSRF_TRUSTED_ORIGINS=https://api.math.tadrisino2.ir
|
||||
|
||||
# ─── اختیاری ─────────────────────────────────────────────────────────────
|
||||
# origin های فرانت. اگر خالی باشد از فهرستِ پیشفرضِ داخلِ کد استفاده میشود
|
||||
# که https://math.tadrisino2.ir را شامل است — معمولاً لازم نیست ست شود.
|
||||
# CORS_ALLOWED_ORIGINS=https://math.tadrisino2.ir
|
||||
|
||||
# کلیدهای سرویسهای بیرونی
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_BASE_URL=https://api.gapgpt.app/v1
|
||||
QDRANT_URL=https://qdrant.tadrisino2.ir:443
|
||||
QDRANT_API_KEY=
|
||||
KAVENEGAR_API_KEY=
|
||||
KAVENEGAR_SENDER=
|
||||
|
||||
# نامِ سرویسِ systemd تا deploy.sh خودش ریاستارت کند
|
||||
# TADRISINO_SERVICE=tadrisino
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -10,4 +10,16 @@ data/textbooks/*
|
||||
!data/textbooks/README.md
|
||||
|
||||
# کلیدِ امضای تولیدشدهی خودکار (هرگز کامیت نشود)
|
||||
.django_secret_key
|
||||
.django_secret_key*
|
||||
|
||||
# خروجیِ collectstatic — تولیدی است، در هر دیپلوی از نو ساخته میشود.
|
||||
staticfiles/
|
||||
|
||||
# نسخهی پشتیبانِ تغییرهای دستیِ روی سرور که deploy.sh کنار میگذارد.
|
||||
.deploy-backups/
|
||||
|
||||
# دیتابیسِ فایلیِ تستها (config/settings.py → DATABASES.TEST)
|
||||
.test-db*.sqlite3*
|
||||
tools/fake_openai_requests.jsonl
|
||||
.pg_password
|
||||
.redis_password
|
||||
|
||||
457
CLAUDE.md
457
CLAUDE.md
@@ -1,71 +1,444 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
Guidance for agents working in this repo. **Current state only**: what is true now and the rules
|
||||
that follow from it. History lives in git (`git log -p -- CLAUDE.md`; the long dated status log that
|
||||
used to be here ends at `eb95209`) and in the owner's reports (`~/Desktop/گزارشات Tadrisino/`).
|
||||
When something here stops being true, change the line; do not append a dated paragraph.
|
||||
|
||||
## Project
|
||||
## The project in one paragraph
|
||||
|
||||
Django + DRF backend for Tadrisino, an educational app (Persian/Farsi). Codebase and API responses are in Persian; comments in source are Persian too.
|
||||
Tadrisino is a Duolingo-style math app for Iranian students (grades 3–12): a map of islands per
|
||||
textbook chapter, short quizzes built from the book's own problems, and an AI tutor («پی») that
|
||||
answers from the same book. Two repos, one API contract: this Django + DRF backend
|
||||
(`Tadrisino/MathApp`, live at `https://mathapi.tadrisino2.ir`) and the React frontend
|
||||
(`Tadrisino/MathApp_frontend`, local clone `~/Desktop/MathApp_frontend`, live at
|
||||
`https://math.tadrisino2.ir`, with an Android WebView shell in `android/`). The owner
|
||||
(`AliReza_Zareian`) maintains both. Code, API messages, comments and docs are Persian.
|
||||
|
||||
## Where to look
|
||||
|
||||
| Need | File |
|
||||
|---|---|
|
||||
| Deploy, env vars, backups, nginx, Bazaar sale, admin refunds | `docs/operations.md` |
|
||||
| Design decisions and why (auth, payment, quiz, hearts, tutor prompt) | `docs/architecture.md` |
|
||||
| Formal handover document for humans (also as PDF) | `docs/technical-handbook.md` → `python docs/build_handbook.py` |
|
||||
| What is still open before real users, and the guards that hold what is closed | `docs/security-before-production.md` |
|
||||
| Editing content, bulk questions, seed rules | `docs/content-authoring.md` |
|
||||
| Reading a textbook with a vision model (costs, model choice) | `docs/extraction-playbook.md`, `data/textbooks/README.md` |
|
||||
| Tutor cost per question, subscription margins, guardrails | `docs/ai-cost-and-guardrails.md` |
|
||||
| Measured model prices (append-only ledger: add a dated row, never overwrite) | `docs/model-prices.md` |
|
||||
| Load testing (laptop and the 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
|
||||
python manage.py runserver # needs DJANGO_DEBUG=1 locally (devOtp, DEV_MASTER_OTP)
|
||||
python manage.py migrate
|
||||
python manage.py test # all tests
|
||||
python manage.py test accounts # single app
|
||||
python manage.py test accounts.tests.SomeTestCase.test_something # single test
|
||||
python manage.py test --noinput # whole suite (973 tests), serial, ~95 s
|
||||
python manage.py test --noinput accounts # one app
|
||||
python manage.py test --noinput accounts.tests.SomeTestCase.test_something
|
||||
python manage.py spectacular --file schema.yaml --format openapi # the API contract
|
||||
```
|
||||
|
||||
Local dev requires `DJANGO_DEBUG=1` (enables Swagger auth via `DEV_MASTER_OTP`/`devOtp` in responses). `DEBUG` defaults to `False`. Production also requires `DJANGO_SECRET_KEY`, `DJANGO_ALLOWED_HOSTS` (comma-separated), and `CACHE_URL` (Redis — required whenever `DEBUG=False` since rate limiting needs a shared cache; raises `ImproperlyConfigured` otherwise).
|
||||
**Never run tests with `--parallel` on this Mac, always pass `--noinput`.** The test DB is a SQLite
|
||||
*file* (`.test-db.sqlite3`, needed by the real-concurrency tests); parallel workers fail to open it and
|
||||
the parent waits forever, and a leftover test DB makes Django ask "Type 'yes'" on stdin forever. If a
|
||||
run prints nothing for ~3 minutes it is stuck: `pkill -f "manage.py test"`. No pytest, no linter.
|
||||
|
||||
No pytest; uses Django's built-in test runner. No linter/formatter configured.
|
||||
|
||||
Docker (`docker compose up --build`) adds a local Loki+Grafana Alloy+Grafana log stack; not a production setup (still `runserver`, not gunicorn). See comments in `docker-compose.yml`, `docker/alloy/config.alloy`, and `LOG_DIR` in `config/settings.py`.
|
||||
|
||||
API docs: Swagger `/api/docs/`, Redoc `/api/redoc/`, admin `/admin/`.
|
||||
Docker (`docker compose up --build`) adds a local Loki + Alloy + Grafana log stack; dev only
|
||||
(`docs/docker.md`).
|
||||
|
||||
## Architecture
|
||||
|
||||
**Apps** (each owns its own models, one responsibility):
|
||||
|
||||
| app | responsibility | key models |
|
||||
|---|---|---|
|
||||
| `accounts` | identity, sessions, OTP auth | `User`, `Profile`, `Session`, `OTPCode` |
|
||||
| `content` | lessons/curriculum | `Grade`, `Unit`, `Node`, `Lesson`, `Question`, `Choice`, `NodeProgress` |
|
||||
| `progress` | stats, leaderboard, streaks, gamification characters | `UserStats`, `LeaderboardEntry`, `StreakRecord`, `UserHearts`, `Character` |
|
||||
| `ai_tutor` | AI assistant chat | `Conversation`, `Message`, `MessageFeedback` |
|
||||
| `subscription` | plans | `Plan`, `Subscription` |
|
||||
| `payment` | orders/transactions (Zarinpal gateway + Cafe Bazaar IAP) | `Order`, `Transaction` |
|
||||
| `discount` | discount codes/offers | `DiscountCode`, `Redemption`, `Offer`, `UserOffer` |
|
||||
| `notifications` | notifications | `Notification` |
|
||||
| `affiliate` | referral program | `AffiliateAccount`, `Conversion` |
|
||||
| `support` | tickets/bug reports | `BugReport`, `Ticket`, `TicketMessage` |
|
||||
| `family` | parental control | `FamilyLink`, `ParentalSettings`, `WeeklyReportLog` |
|
||||
| `statuslog` | generic cross-app status-change log | `StatusChangeLog` |
|
||||
| `campaigns` | messaging campaign storage only, no send logic yet | `MessageCampaign` |
|
||||
| `config` | project settings, root urls, middleware | — |
|
||||
| `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 | |
|
||||
|
||||
**Auth is custom, not Django's.** `accounts.User` is deliberately *not* `AUTH_USER_MODEL` — it's an independent business model. Auth is a stateless signed bearer token (`django.core.signing`, no JWT library) issued/verified in `accounts/authentication.py` (`TadrisinoTokenAuthentication`, header `Authorization: Bearer <token>`), backed by `accounts.Session`. Missing/invalid header → anonymous request, not a hard failure; each view's permission class decides if auth is required.
|
||||
Invariants that are not obvious from one file:
|
||||
|
||||
**Apps are decoupled by convention**: instead of real FKs across app boundaries, code uses loose refs (`user_id`, `object_id` + `label`) so any app can be split out later. Don't add cross-app FKs — follow the existing loose-reference pattern.
|
||||
- **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`).
|
||||
|
||||
**Throttling**: DRF `ScopedRateThrottle` with per-endpoint `throttle_scope` strings, rates centralized in `REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']` in `config/settings.py`. Guest users are throttled by IP (`NUM_PROXIES` setting controls how many XFF hops are trusted — must match real proxy count in prod or IP spoofing/throttle-bypass is possible). Adding a new sensitive endpoint means adding both a `throttle_scope` on the view and a matching rate entry in settings.
|
||||
## Current state, by area
|
||||
|
||||
**`POST /api/ai/ask`** currently returns a stub; real model integration point is `generate_tutor_answer` in `ai_tutor/views.py`.
|
||||
### Infrastructure (server 249)
|
||||
|
||||
**Structured logging**: `config.middleware.APIRequestLoggingMiddleware` (last in `MIDDLEWARE`, closest to the view) logs every `/api/` request as JSON (method/path/status/duration/user, no body/headers/secrets) via `config/log_formatters.py`.
|
||||
- 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.
|
||||
|
||||
**SQLite** is current DB (`OPTIONS.timeout=20` to tolerate concurrent-write locks, e.g. campaigns tests); README notes Postgres migration is still pending.
|
||||
### Backups and logs (server 247)
|
||||
|
||||
**SMS** goes through Kavenegar (`KAVENEGAR_API_KEY`/`KAVENEGAR_SENDER`); failures return explicit 503, not a fake success.
|
||||
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.
|
||||
|
||||
**Gamification characters** (`progress.Character`, Duolingo-mascot-style): an admin-managed catalog (like `subscription.Plan` — created via Django admin or `seed_test_data`, not by users). Unlocking is currently just `UserStats.xp >= Character.unlock_xp` (no separate ownership table); `UserStats.selected_character_id` is the user's active pick, set via `POST /api/characters/select` after that check. First real content beyond the model/plumbing (art assets, more characters, purchase-with-coins, etc.) is expected to land incrementally later.
|
||||
### Content
|
||||
|
||||
**Payment (Zarinpal)**: `create_order`/`CreateOrderView` never call Zarinpal directly — `paymentUrl` points at our own `GET /api/payment/orders/{id}/pay` (`PaymentRedirectView`), which calls Zarinpal's PaymentRequest API on first visit (storing `Order.gateway_authority`) and redirects to StartPay; this keeps order creation from depending on Zarinpal's uptime. Zarinpal redirects the user back to `GET /api/payment/zarinpal/callback` (`ZarinpalCallbackView`), which always re-verifies server-to-server via `payment/zarinpal.py::verify_payment` before marking an order paid — a forged `Status=OK` in the query string alone does nothing. Both `payment/zarinpal.py` and `payment/bazaar.py` fail closed (503 / invalid) when their credentials aren't configured, never a fake success.
|
||||
- `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.
|
||||
|
||||
## Known gaps (per README pre-deploy checklist)
|
||||
### Playing: islands, quizzes, hearts
|
||||
|
||||
- Still on SQLite, not Postgres.
|
||||
- Throttle rates need periodic review against real traffic.
|
||||
- `DEV_MASTER_OTP` (`0000`) must stay gated behind `DEBUG` — never enabled in prod.
|
||||
- `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).
|
||||
|
||||
360
README.md
360
README.md
@@ -1,273 +1,139 @@
|
||||
# Tadrisino API
|
||||
|
||||
بکاند اپلیکیشن آموزشی Tadrisino — Django + Django REST Framework.
|
||||
بکاندِ **تدریسینو**، اپِ آموزشِ ریاضی به سبکِ دولینگو برای دانشآموزانِ پایهی سوم تا دوازدهم. دانشآموز روی نقشهای
|
||||
از جزیرهها جلو میرود، هر جزیره آزمونی کوتاه از مسئلههای خودِ کتابِ درسی است، و هر جا گیر کند از دستیارِ هوشِ
|
||||
مصنوعی میپرسد که جوابش را از همان کتاب میسازد.
|
||||
|
||||
احراز هویت با OTP، محتوای درسی و پیشرفت کاربر، دستیار هوش مصنوعی، اشتراک و پرداخت،
|
||||
تخفیف، اعلانها، همکاری در فروش، پشتیبانی و حساب خانواده.
|
||||
این مخزن یک REST API روی Django است: برنامهی درسی و آزمون، پیشرفت و گیمیفیکیشن، دستیارِ مبتنی بر RAG، اشتراک
|
||||
و پرداخت. فرانتاند مخزنِ جداگانهای است ([`Tadrisino/MathApp_frontend`](https://gitea.tadrisino2.ir/Tadrisino/MathApp_frontend)).
|
||||
**قراردادِ میانِ دو طرف اسکیمای OpenAPIِ همین مخزن است**؛ هر تغییری در API از آنجا دیده میشود.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **سرویسِ زنده** | `https://mathapi.tadrisino2.ir` · اپ: `https://math.tadrisino2.ir` |
|
||||
| **فناوری** | Python 3.11 · Django 5.2 · Django REST Framework · PostgreSQL 14 · Redis · Qdrant · gunicorn پشتِ nginx و ArvanCloud |
|
||||
| **API** | ۶۱ عملیات روی ۵۹ مسیر، مستند با OpenAPI (`/api/docs/`) |
|
||||
| **محتوا** | ۱۰ پایه · ۵ کتاب · ۸۹ فصل · ۱٬۱۴۵ جزیره · ۵٬۴۰۶ درس · ۳۹٬۱۵۶ سوال |
|
||||
| **تست** | ۹۷۳ تستِ خودکار، همه سبز |
|
||||
| **سرویسهای بیرونی** | GapGPT (مدلِ زبانی) · Qdrant (جستجوی کتاب) · کاوهنگار (پیامک) · زرینپال و کافهبازار (پرداخت) |
|
||||
|
||||
---
|
||||
|
||||
## امکانات
|
||||
|
||||
| بخش | چه میکند |
|
||||
|---|---|
|
||||
| **ورود** | فقط با شمارهی موبایل و کدِ پیامکی؛ توکنِ دسترسیِ امضاشدهی یکساعته و توکنِ تمدید |
|
||||
| **برنامهی درسی** | پایه ← کتاب ← فصل ← جزیره ← درس ← سوال؛ کلِ محتوا بهصورتِ JSONِ کامیتشده در `content/seed/` |
|
||||
| **بازی** | آزمونِ هر جزیره، قفلِ ترتیبی، قلب، ستاره و امتیاز، زنجیرهی روزانه، جدولِ رتبهبندی، همراهها |
|
||||
| **دستیار** | پاسخِ گامبهگام از روی همان کتاب (Qdrant + GapGPT)، فقط ریاضی، با شکلِ توصیفی که اپ میکشد |
|
||||
| **اشتراک** | فصلِ اولِ هر کتاب رایگان؛ اشتراکِ یک/سه/ششماهه از زرینپال یا کافهبازار، با کدِ تخفیف و برنامهی معرفی |
|
||||
| **پنلِ ادمین** | ویرایشِ محتوا، ورود و خروجِ فلهای سوال، پلنها، سفارشها (لغو و بازپرداخت)، تیکتها، گزارشهای کاربران |
|
||||
| **پایش** | `/api/health` (دیتابیس، مهاجرت، کش، سرویسهای بیرونی، بکاپ)، لاگِ JSON برای هر درخواست، دفترِ رویدادِ پرداخت |
|
||||
|
||||
---
|
||||
|
||||
## شروعِ سریع
|
||||
|
||||
```bash
|
||||
python3.11 -m venv venv && source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env # برای توسعهی محلی DJANGO_DEBUG=1 را ست کنید
|
||||
python manage.py migrate
|
||||
python manage.py seed_content # برنامهی درسی و بانکِ سوال از content/seed/
|
||||
python manage.py createsuperuser # برای /admin/
|
||||
python manage.py runserver # http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
بدونِ `DB_NAME` در `.env` دیتابیسِ محلی SQLite است و نیازی به نصبِ Postgres نیست. `.env` خودکار خوانده میشود.
|
||||
|
||||
| آدرس | چیست |
|
||||
|---|---|
|
||||
| `/api/docs/` · `/api/redoc/` · `/api/schema/` | مستنداتِ زندهی API (روی سرور فقط با ورود به پنلِ ادمین) |
|
||||
| `/admin/` | پنلِ ادمین |
|
||||
| `/api/health` | سلامتِ سرویس |
|
||||
|
||||
**اولین درخواست:** با `DJANGO_DEBUG=1`، `POST /api/auth/otp/request` با یک شماره، سپس `POST /api/auth/login` با
|
||||
کدی که در فیلدِ `devOtp` پاسخ آمده. `token` را در Swagger زیرِ **Authorize** بگذارید.
|
||||
|
||||
```bash
|
||||
python manage.py test --noinput # کلِ تستها (سریال؛ --parallel روی macOS گیر میکند)
|
||||
python manage.py test --noinput content # تستهای یک اپ
|
||||
python manage.py spectacular --file schema.yaml --format openapi # خروجیِ قرارداد برای فرانت
|
||||
```
|
||||
|
||||
برای استکِ لاگِ محلی (Loki و Grafana) [`docs/docker.md`](docs/docker.md) را ببینید.
|
||||
|
||||
---
|
||||
|
||||
## معماری
|
||||
|
||||
یک برنامهی جنگو در قالبِ «مونولیتِ ماژولار»: هر اپ یک مسئولیت دارد و مدلهای خودش را نگه میدارد.
|
||||
|
||||
| اپ | مسئولیت |
|
||||
|---|---|
|
||||
| `accounts` | احراز هویت، session، پروفایل کاربر |
|
||||
| `content` | پایهها، مباحث، درسها، سوالها، پیشرفت |
|
||||
| `progress` | آمار، لیدربرد، streak |
|
||||
| `ai_tutor` | مکالمات دستیار هوش مصنوعی |
|
||||
| `rag` | لایهی RAG: بازیابی از Qdrant + تولید با GPT، پشتِ `POST /api/ai/ask` |
|
||||
| `subscription` / `payment` / `discount` | پلن، سفارش، تراکنش، کد تخفیف |
|
||||
| `notifications`, `affiliate`, `support`, `family` | امکانات جانبی |
|
||||
| `statuslog` | لاگ عمومی تغییر status، مستقل از بقیهی اپها |
|
||||
| `accounts` | هویت، ورود با کدِ پیامکی، نشست، پروفایل |
|
||||
| `content` | کتابها، برنامهی درسی، درس، سوال، آزمونِ جزیره، بازخوردِ کاربر |
|
||||
| `progress` | آمار، زنجیرهی روزانه، قلب، رتبهبندی، همراهها، گزارشِ هفتگی |
|
||||
| `ai_tutor` · `rag` | دستیار: گفتگو، بازیابی از Qdrant، تولید با GapGPT، ورودِ کتاب |
|
||||
| `subscription` · `payment` · `discount` · `affiliate` | پلن و اشتراک، سفارش و تراکنش، کدِ تخفیف، برنامهی معرفی |
|
||||
| `notifications` · `support` · `campaigns` · `statuslog` | اعلان، پشتیبانی، کمپین، لاگِ تغییرِ وضعیت |
|
||||
| `config` | تنظیمات، مسیرها، middleware، سلامت |
|
||||
|
||||
سه قاعده که از خواندنِ یک فایل پیدا نیست:
|
||||
|
||||
- **احرازِ هویت سیستمِ خودِ پروژه است.** `accounts.User` عمداً `AUTH_USER_MODEL` نیست؛ توکن با `django.core.signing`
|
||||
امضا میشود (`accounts/authentication.py`). هدرِ نامعتبر یعنی درخواستِ ناشناس، و هر ویو خودش تصمیم میگیرد ورود
|
||||
لازم است یا نه.
|
||||
- **اپها با قرارداد جدا شدهاند، نه با ForeignKey.** ارجاعِ بینِ اپها با `user_id` یا `object_id` + `label` است تا
|
||||
هر اپ بعداً جداشدنی بماند.
|
||||
- **محدودیتِ نرخ متمرکز است.** هر اندپوینتِ حساس یک `throttle_scope` دارد و نرخش در `config/settings.py` است؛ IPِ
|
||||
واقعی فقط از پروکسیهای ArvanCloud پذیرفته میشود.
|
||||
|
||||
شرحِ کامل و دلیلِ هر تصمیم در [`docs/architecture.md`](docs/architecture.md) و
|
||||
[`docs/technical-handbook.md`](docs/technical-handbook.md) آمده است.
|
||||
|
||||
---
|
||||
|
||||
## ۱. راهاندازی محیط توسعه
|
||||
## استقرار
|
||||
|
||||
### پیشنیازها
|
||||
- پایتون ۳.۱۲
|
||||
- pip
|
||||
|
||||
### تنظیم محیط
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate # ویندوز: venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
`.env.example` (تمام متغیرهایی که واقعاً توسط اپ خونده میشن):
|
||||
```env
|
||||
DJANGO_DEBUG=1
|
||||
DJANGO_SECRET_KEY=
|
||||
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
CACHE_URL=
|
||||
KAVENEGAR_API_KEY=
|
||||
GRAFANA_ADMIN_PASSWORD=admin
|
||||
```
|
||||
مقادیر `.env` رو به شل خودتون export کنید (یا از `direnv`/`python-dotenv` استفاده کنید):
|
||||
```bash
|
||||
export DJANGO_DEBUG=1 # ویندوز (PowerShell): $env:DJANGO_DEBUG=1
|
||||
```
|
||||
> `DEBUG` دیفالتش `False` است. برای فعالکردنِ کمککنندههای dev-only در Swagger
|
||||
> (`DEV_MASTER_OTP` و فیلدِ `devOtp` توی پاسخها)، محلی `DJANGO_DEBUG=1` رو ست کنید.
|
||||
|
||||
### سریعترین راه اجرای پروژه
|
||||
```bash
|
||||
python manage.py migrate
|
||||
python manage.py runserver
|
||||
```
|
||||
اپ روی `http://127.0.0.1:8000` بالا میاد.
|
||||
|
||||
### مایگریشنها
|
||||
```bash
|
||||
python manage.py migrate
|
||||
```
|
||||
|
||||
### دیتای تستی (Seed)
|
||||
یک دیتاست کوچک و idempotent میسازه که همهی اپها رو پوشش میده (کاربر، محتوا، پیشرفت،
|
||||
اشتراک، پرداخت، تخفیف، دستیار هوش مصنوعی، اعلان، افیلیت، پشتیبانی، خانواده). اجرای مکرر
|
||||
امن است — با `get_or_create` هیچ رکورد تکراریای نمیسازه. پیشفرض فقط وقتی `DEBUG=True`
|
||||
اجرا میشه، مگر با `--force`.
|
||||
سرویس روی یک سرورِ مشترکِ شرکت اجرا میشود و مالِ این پروژه فقط پوشهی `~/mathapp-v2` و سرویسِ
|
||||
`zareian-mathapp` است. کلِ انتشار:
|
||||
|
||||
```bash
|
||||
python manage.py seed_test_data # لوکال (DEBUG=True)
|
||||
python manage.py seed_test_data --force # staging (DEBUG=False)
|
||||
python manage.py seed_test_data --reset # پاککردنِ دیتای قبلیِ seed و ساختِ دوباره
|
||||
ssh -t administrator@78.38.35.249 'cd ~/mathapp-v2 && ./deploy.sh; sudo systemctl restart zareian-mathapp'
|
||||
```
|
||||
|
||||
### مستندات API
|
||||
| | |
|
||||
|---|---|
|
||||
| Swagger UI | `http://127.0.0.1:8000/api/docs/` |
|
||||
| Redoc | `http://127.0.0.1:8000/api/redoc/` |
|
||||
| OpenAPI schema | `http://127.0.0.1:8000/api/schema/` |
|
||||
| پنل ادمین | `http://127.0.0.1:8000/admin/` |
|
||||
`deploy.sh` کد را با `origin/main` یکی میکند، وابستگیها را نصب میکند، پیش از `migrate` از دیتابیس بکاپ میگیرد،
|
||||
محتوا را seed و فایلهای استاتیک را جمع میکند و سلامتِ سرویس را میسنجد. سپس از بیرون:
|
||||
|
||||
**شروع سریع در Swagger:**
|
||||
1. `POST /api/auth/session` با `{"type":"guest","deviceId":"d_test1"}` → یک `token` بگیرید.
|
||||
2. روی دکمهی **Authorize** بزنید و فقط خودِ `token` رو (بدون کلمهی Bearer) وارد کنید.
|
||||
3. برای تست لاگین با شماره: `POST /api/auth/otp/request` بزنید، یا در محیط توسعه کد **0000**
|
||||
رو با هر شمارهای در `POST /api/auth/login` امتحان کنید (dev-only، نیاز به `DEBUG=True`).
|
||||
|
||||
### دستورهای پرکاربرد
|
||||
```bash
|
||||
python manage.py createsuperuser # اختیاری، برای /admin
|
||||
python manage.py test # اجرای کل تستسوییت
|
||||
curl https://mathapi.tadrisino2.ir/api/health # {"status": "ok"}
|
||||
```
|
||||
|
||||
### دستیار هوش مصنوعی (RAG)
|
||||
|
||||
`POST /api/ai/ask` پاسخ را از روی محتوای واقعیِ کتاب میسازد: embedding سوال →
|
||||
جستجوی Qdrant محدود به همان درس/فصل → تولید با GPT. تیکههای استفادهشده در
|
||||
`usedChunks` برمیگردند.
|
||||
|
||||
- نیازمندِ `OPENAI_API_KEY` و `QDRANT_API_KEY` در `.env` (اپ `rag`؛ تنظیماتِ
|
||||
`OPENAI_*` / `RAG_*` در `config/settings.py`). بدونِ کلید، همان پاسخِ stub قبلی.
|
||||
- تست در Swagger: `POST /api/ai/ask` با
|
||||
`{"userQuestion":"...","subject":"ریاضی","grade":"ششم","lessonNumber":2}`.
|
||||
- `"stream": true` پاسخ را بهصورت SSE توکنبهتوکن میفرستد (قراردادِ JSON بدونِ این فلگ دستنخورده).
|
||||
- `python manage.py rag_catalog` فهرستِ زندهی collectionهای Qdrant را نشان میدهد (فقطخواندنی).
|
||||
- افزودنِ محتوای خودمان: `POST /api/rag/ingest/text` و `/api/rag/ingest/pdf` (داخلی، هدرِ `X-Internal-Key`).
|
||||
هر تغییرِ دیتابیس لحظهای به سرورِ بکاپِ جداگانهای میرود. رویهی کامل (متغیرهای محیطی، بکاپ و بازیابی، nginx،
|
||||
عوض کردنِ دامنه، فروش در کافهبازار): [`docs/operations.md`](docs/operations.md).
|
||||
|
||||
---
|
||||
|
||||
## ۲. راهنمای دیپلوی پروداکشن
|
||||
## مستندات
|
||||
|
||||
### بالا آوردنِ نسخهی جدید — یک دستور
|
||||
|
||||
```bash
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
همین. اسکریپت به ترتیب کدِ تازه را میگیرد، وابستگیها را نصب میکند، مهاجرتها
|
||||
را اعمال میکند، فایلهای استاتیک را جمع میکند، سرویس را ریاستارت میکند، و در
|
||||
پایان **خودش بررسی میکند که واقعاً بالا آمده یا نه**. اگر بالا نیامد، علتهای
|
||||
رایج را چاپ میکند.
|
||||
|
||||
چرا این مهم است: قبلاً اینها چند کارِ دستی به ترتیبِ درست بودند و فراموش شدنِ
|
||||
هر کدام اپ را میخواباند، با خطایی که گویا نبود. مثلاً اضافه شدنِ یک پکیجِ جدید
|
||||
به `requirements.txt` بدونِ `pip install` باعثِ `ModuleNotFoundError` موقعِ بالا
|
||||
آمدن میشود و کاربر فقط `502 Bad Gateway` میبیند. اسکریپت همیشه `pip install`
|
||||
را اجرا میکند تا این حالت اصلاً پیش نیاید.
|
||||
|
||||
بارِ اول، نامِ سرویس را بدهید تا دفعههای بعد خودکار ریاستارت شود:
|
||||
|
||||
```bash
|
||||
TADRISINO_SERVICE=<نامِ سرویس> ./deploy.sh
|
||||
```
|
||||
|
||||
متغیرهای اختیاری: `SKIP_PULL=1` (بدونِ git pull)، `TADRISINO_HEALTH_URL`،
|
||||
`PIP_INDEX_URL` (اگر pypi.org تایماوت میدهد).
|
||||
|
||||
### بررسیِ سلامت از بیرون
|
||||
|
||||
```bash
|
||||
curl https://<host>/api/health
|
||||
```
|
||||
|
||||
بدونِ احرازِ هویت و بدونِ SSH میگوید دیتابیس سالم است یا نه، مهاجرتِ
|
||||
اعمالنشده مانده یا نه، کش کار میکند یا نه، و چند پایه/درس/سوال روی آن سرور
|
||||
هست. `200` یعنی سالم، `503` یعنی چیزی واقعاً خراب است. هیچ سکرتی برنمیگرداند.
|
||||
|
||||
این همان چیزیست که جلوی «کد رفته ولی مهاجرت اجرا نشده» را میگیرد — حالتی که
|
||||
اپ ظاهراً بالا میآید و بعد زیرِ بارِ واقعی میشکند.
|
||||
|
||||
### مدیریتِ محتوا بدونِ ترمینال
|
||||
|
||||
هیچ محتوایی لازم نیست با دستور روی سرور گذاشته شود.
|
||||
|
||||
| کار | کجا |
|
||||
| سند | موضوع |
|
||||
|---|---|
|
||||
| افزودن/ویرایشِ **یک** سوال | `/admin/content/question/` → «افزودن» (انتخابِ درس با جستجو) |
|
||||
| وارد کردنِ **فلهای** سوال | همان صفحه → «وارد کردنِ سوالها از فایل» |
|
||||
| گرفتنِ خروجیِ سوالها | چند سوال را انتخاب کنید → اکشنِ «دریافتِ فایل» |
|
||||
| وارد کردنِ **برنامهی درسی** (پایه/فصل/درس) | `/admin/content/grade/` → «وارد کردنِ برنامهی درسی از فایل» |
|
||||
| گرفتنِ خروجیِ برنامهی درسی | پایهها را انتخاب کنید → اکشنِ «دریافتِ فایلِ برنامهی درسی» |
|
||||
| [`docs/technical-handbook.md`](docs/technical-handbook.md) · [PDF](docs/technical-handbook.pdf) | مستندِ فنیِ کامل برای تحویل و پیوستنِ توسعهدهندهی تازه |
|
||||
| [`docs/architecture.md`](docs/architecture.md) | تصمیمهای معماری و دلیلشان |
|
||||
| [`docs/operations.md`](docs/operations.md) | استقرار، پیکربندی، سلامت، بکاپ و بازیابی، nginx، پرداخت |
|
||||
| [`docs/security-before-production.md`](docs/security-before-production.md) | آنچه پیش از کاربرِ واقعی باید بسته شود، و حفاظهای موجود |
|
||||
| [`docs/content-authoring.md`](docs/content-authoring.md) | ویرایشِ محتوا از پنلِ ادمین و ساختِ انبوهِ سوال |
|
||||
| [`docs/extraction-playbook.md`](docs/extraction-playbook.md) | خواندنِ کتابِ درسی با مدلِ تصویری، هزینه و انتخابِ مدل |
|
||||
| [`docs/ai-cost-and-guardrails.md`](docs/ai-cost-and-guardrails.md) · [`docs/model-prices.md`](docs/model-prices.md) | هزینهی دستیار، حاشیهی سودِ اشتراکها، دفترِ قیمتِ مدلها |
|
||||
| [`tools/loadtest/README.md`](tools/loadtest/README.md) | آزمونِ بار |
|
||||
| [`CLAUDE.md`](CLAUDE.md) | راهنمای agentهای کدنویس، بهویژه قواعدِ کار روی سرورِ مشترک |
|
||||
|
||||
خروجیِ هر کدام دقیقاً همان قالبیست که ورودیاش میپذیرد، پس چرخهی
|
||||
«بگیر → ویرایش کن → برگردان» بسته است و برای گرفتنِ نمونه لازم نیست کسی
|
||||
مستندات بخواند.
|
||||
---
|
||||
|
||||
دو تضمین:
|
||||
- **سوالها:** فایلِ نامعتبر هیچ چیزی نمینویسد — یا همه وارد میشوند یا هیچکدام.
|
||||
- **برنامهی درسی:** چیزی پاک نمیشود. هر چه در فایل هست ساخته/بهروز میشود و
|
||||
هر چه نیست دستنخورده میماند، پس یک فایلِ ناقص محتوای سرور را از بین نمیبرد.
|
||||
## وضعیت
|
||||
|
||||
### دادهی تستی روی سرور
|
||||
|
||||
`seed_test_data` فقط برای محیطِ لوکال است و **نباید** روی سروری که کاربرِ واقعی
|
||||
دارد اجرا شود؛ ردیفهایی با پیشوندِ `[SEED]`/`[DEMO]` میسازد که در API دیده
|
||||
میشوند. محتوای واقعی از مسیرِ جدولِ بالا میرود، نه از این دستور.
|
||||
|
||||
|
||||
### متغیرهای محیطی موردنیاز
|
||||
| متغیر | لازم در پروداکشن | توضیح |
|
||||
|---|---|---|
|
||||
| `DJANGO_DEBUG` | باید ستنشده یا `0` باشه | دیفالتهای امنیتیِ پروداکشن رو فعال میکنه |
|
||||
| `DJANGO_SECRET_KEY` | **بله** | بدونش وقتی `DEBUG=False` است اپ اصلاً بالا نمیاد |
|
||||
| `DJANGO_ALLOWED_HOSTS` | **بله** | لیستِ host ها، کاما-جدا |
|
||||
| `CACHE_URL` | **بله** | آدرسِ Redis، مثلاً `redis://host:6379/0` — برای rate limitِ مشترک وقتی `DEBUG=False` لازمه |
|
||||
| `KAVENEGAR_API_KEY` | **بله** | ارسال واقعیِ OTP از طریق کاوهنگار |
|
||||
| `GRAFANA_ADMIN_PASSWORD` | فقط اگه از استکِ Grafana استفاده میکنید | بخشِ Docker پایین رو ببینید |
|
||||
|
||||
### تنظیمات پروداکشن
|
||||
- `DEBUG` باید ست **نشه** (یا `0` باشه). با `DEBUG=False`:
|
||||
- `SECRET_KEY` اجباریه — هیچ fallback ناامنی وجود نداره.
|
||||
- `CACHE_URL` (Redis) اجباریه — `LocMemCache` فقط برای dev هست.
|
||||
- `DEV_MASTER_OTP` و فیلدِ `devOtp` توی پاسخها غیرفعال میشن.
|
||||
- برای ترافیک واقعی بهجای SQLیت پیشفرض، از یک دیتابیسِ پروداکشنگرید استفاده کنید
|
||||
(پروژه با همون SQLite شیپ میشه؛ عوضکردنِ engine یک تصمیمِ زیرساختیه، خارج از این ریپو).
|
||||
- از طریق یک سرورِ WSGI/ASGI (مثلاً gunicorn) جلوی اپ سرو کنید — تنظیماتِ Docker موجود
|
||||
همچنان `runserver` اجرا میکنه و فقط برای dev/local مستند شده (پایین رو ببینید).
|
||||
|
||||
### تنظیمات Docker/دیپلوی
|
||||
`docker-compose.yml` موجود، اپ رو **بههمراه** یک استکِ لاگِ متمرکز
|
||||
(Loki + Grafana Alloy + Grafana) اجرا میکنه. این یک تنظیمِ **dev/local** هست، نه دیپلویِ
|
||||
پروداکشن (سرویسِ `django` همچنان `runserver` اجرا میکنه، نه یک سرورِ پروداکشن).
|
||||
|
||||
```bash
|
||||
cp .env.example .env # اختیاری — بدونش compose از دیفالتهای dev استفاده میکنه
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
| سرویس | آدرس | توضیح |
|
||||
|---|---|---|
|
||||
| اپ Django | `http://localhost:8000` | |
|
||||
| Grafana | `http://localhost:3000` | دسترسیِ anonymous admin، فقط محیطِ محلی |
|
||||
| Loki | `http://localhost:3100` | ذخیرهسازیِ لاگ، در معرضِ کاربر نیست |
|
||||
| Alloy | `http://localhost:12345` | رابطِ دیباگِ اختیاری |
|
||||
|
||||
### ترتیبِ لازمِ بالا آمدن
|
||||
Compose خودش این رو مدیریت میکنه:
|
||||
1. اول `loki` بالا میاد.
|
||||
2. `alloy` و `grafana` بعد از `loki` بالا میان (`depends_on`).
|
||||
3. `django` هیچ وابستگیای به استکِ لاگ نداره — خودش `migrate` بعد `runserver` رو
|
||||
اجرا میکنه و منتظرِ Loki/Alloy/Grafana نمیمونه.
|
||||
|
||||
### دستورهای manage.py داخلِ Docker
|
||||
اگه فقط از طریقِ Docker اجرا میکنید (بدونِ venv محلی)، `python manage.py ...` رو مستقیم
|
||||
روی سیستم خودتون اجرا نکنید — باید داخلِ کانتینرِ `django` اجرا بشه:
|
||||
```bash
|
||||
docker compose exec django python manage.py createsuperuser
|
||||
docker compose exec django python manage.py seed_test_data
|
||||
docker compose exec django python manage.py test
|
||||
```
|
||||
(`migrate` نیازی به این نیست — طبقِ بالا، خودکار قبل از `runserver` اجرا میشه.)
|
||||
|
||||
### مایگریشنها
|
||||
```bash
|
||||
python manage.py migrate --noinput
|
||||
```
|
||||
در Docker، این بهصورت خودکار بهعنوان بخشی از دستورِ سرویسِ `django`، قبل از `runserver` اجرا میشه.
|
||||
|
||||
### راهنمای seed برای staging/تست
|
||||
- هرگز `seed_test_data` رو روی یک دیتابیسِ پروداکشنِ واقعی اجرا نکنید.
|
||||
- روی staging (`DEBUG=False`)، صریحاً با `--force` اجرا کنید:
|
||||
```bash
|
||||
python manage.py seed_test_data --force
|
||||
```
|
||||
- برای پاککردن و ساختِ دوبارهی دیتای seed بین چرخههای تست، از `--reset` استفاده کنید.
|
||||
همهی رکوردهای seedشده در فیلدهای متنیِ قابلمشاهده با تگِ `[SEED]` مشخص شدن، برای
|
||||
شناساییِ راحتتر و پاکسازی.
|
||||
|
||||
### چطور API دیپلویشده رو verify کنیم
|
||||
```bash
|
||||
curl -i https://<host>/api/schema/ # باید 200 برگردونه (OpenAPI schema)
|
||||
curl -i https://<host>/api/docs/ # باید 200 برگردونه (Swagger UI)
|
||||
curl -i -X POST https://<host>/api/auth/session \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"guest","deviceId":"verify-1"}' # باید یک token برگردونه
|
||||
```
|
||||
|
||||
### نکات مهمِ پروداکشن
|
||||
- `DEV_MASTER_OTP` (کد `0000`) و فیلدِ `devOtp` توی پاسخها فقط وقتی `DEBUG=True` وجود
|
||||
دارن — قبل از رفتن روی محیطِ واقعی مطمئن بشید `DEBUG` خاموشه.
|
||||
- احراز هویت با Bearer token امضاشده و stateless (`django.core.signing`)، هماهنگ با مدل
|
||||
`Session` — بدون کتابخانهی JWT خارجی.
|
||||
- `accounts.User` یک مدل بیزنسیِ مستقله، **نه** `AUTH_USER_MODEL` جنگو.
|
||||
- همهی لاگهای ساختاریافتهی درخواست (`method`/`path`/`status`/`duration`/`user`، بدونِ
|
||||
بدنه/هدر/سکرت) روی stdout و — وقتی `LOG_DIR` ست باشه (مثلِ داخلِ Docker) — در
|
||||
`/var/log/django/app.log` نوشته میشن؛ قابلِ مشاهده در Grafana از طریقِ استکِ بالا.
|
||||
- `POST /api/ai/ask` با تنظیمِ `OPENAI_API_KEY`/`QDRANT_API_KEY` پاسخِ واقعیِ RAG میدهد
|
||||
(اپ `rag`)؛ بدونِ کلید همان stub. جزئیات: بخشِ «دستیار هوش مصنوعی (RAG)» بالاتر.
|
||||
- ارسالِ OTP از طریقِ کاوهنگار (`KAVENEGAR_API_KEY`) انجام میشه؛ شکستِ ارسال بهجای
|
||||
موفقیتِ ساختگی، خطای صریحِ `503` برمیگردونه.
|
||||
- هر خطای پیشبینینشده (نه فقط خطاهای شناختهشدهی API) همیشه پاسخِ JSON برمیگردونه،
|
||||
نه صفحهی HTML پیشفرضِ جنگو، و کامل با traceback لاگ میشه (`config/exceptions.py`).
|
||||
سرویس زنده است و با حسابهای آزمونگر سنجیده میشود؛ هنوز به روی کاربرِ عمومی باز نشده. آنچه پیش از آن باید بسته
|
||||
شود در [`docs/security-before-production.md`](docs/security-before-production.md) فهرست شده است. پرداختِ واقعی (زرینپال
|
||||
و کافهبازار) تا انتهای مسیر هنوز آزموده نشده است.
|
||||
|
||||
45
accounts/activity.py
Normal file
45
accounts/activity.py
Normal 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
|
||||
@@ -8,7 +8,7 @@ from django.core.exceptions import PermissionDenied
|
||||
from django.shortcuts import render
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import OTPCode, Profile, Session, User
|
||||
from .models import LoginEvent, OTPCode, Profile, Session, User
|
||||
from .services import SMSDeliveryError, get_inactive_users, send_sms
|
||||
|
||||
|
||||
@@ -51,11 +51,74 @@ class SendSMSForm(forms.Form):
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'name', 'phone', 'device_id', 'is_guest', 'grade_id', 'created_at')
|
||||
search_fields = ('name', 'phone', 'device_id')
|
||||
list_filter = ('is_guest',)
|
||||
list_display = ('id', 'public_id', 'name', 'phone', 'grade_id', 'created_at', 'activity_link')
|
||||
search_fields = ('name', 'phone')
|
||||
|
||||
@admin.display(description='پرونده')
|
||||
def activity_link(self, obj):
|
||||
from django.urls import reverse
|
||||
from django.utils.html import format_html
|
||||
return format_html('<a href="{}">فعالیت</a>', reverse('admin:accounts_user_activity', args=[obj.pk]))
|
||||
|
||||
def get_urls(self):
|
||||
from django.urls import path
|
||||
return [
|
||||
path('<int:user_id>/activity/', self.admin_site.admin_view(self.activity_view),
|
||||
name='accounts_user_activity'),
|
||||
] + super().get_urls()
|
||||
|
||||
def activity_view(self, request, user_id):
|
||||
"""ورود/خروج و خریدهای یک کاربر؛ ?format=csv برای اکسل. فقط با اجازهی دیدنِ کاربر و ورود و سفارش."""
|
||||
import csv
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.shortcuts import render
|
||||
from payment.models import Order
|
||||
from .activity import COLUMNS, user_activity
|
||||
from .public_id import encode
|
||||
|
||||
perms = ('accounts.view_user', 'accounts.view_loginevent', 'payment.view_order')
|
||||
if not all(request.user.has_perm(p) for p in perms):
|
||||
raise PermissionDenied
|
||||
user = User.objects.filter(pk=user_id).first()
|
||||
if user is None:
|
||||
raise Http404
|
||||
rows = user_activity(user)
|
||||
if request.GET.get('format') == 'csv':
|
||||
response = HttpResponse(content_type='text/csv; charset=utf-8')
|
||||
response['Content-Disposition'] = f'attachment; filename="user-{user.pk}-activity.csv"'
|
||||
response.write('\ufeff') # BOM: اکسل بیآن فارسی را خراب میخواند
|
||||
writer = csv.writer(response)
|
||||
writer.writerow(COLUMNS)
|
||||
for r in rows:
|
||||
writer.writerow([r['at_local'], r['kind'], r['detail'], r['amount'], r['ip']])
|
||||
return response
|
||||
return render(request, 'admin/accounts_user_activity.html', {
|
||||
**self.admin_site.each_context(request), 'target': user, 'public_id': encode(user.pk),
|
||||
'rows': rows, 'columns': COLUMNS, 'title': 'پروندهی کاربر',
|
||||
})
|
||||
|
||||
@admin.display(description='شناسهی نمایشی')
|
||||
def public_id(self, obj):
|
||||
from .public_id import encode
|
||||
return encode(obj.pk)
|
||||
|
||||
def get_search_results(self, request, queryset, search_term):
|
||||
"""«User482917» (همان که کاربر در پروفایلش میبیند) هم کاربر را پیدا میکند."""
|
||||
from .public_id import decode
|
||||
|
||||
results, may_have_duplicates = super().get_search_results(request, queryset, search_term)
|
||||
user_id = decode(search_term)
|
||||
if user_id:
|
||||
results |= queryset.filter(pk=user_id)
|
||||
return results, may_have_duplicates
|
||||
actions = ['send_sms_to_selected']
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
# شماره کلیدِ ورود است: کارمندی با change_user که شمارهی کاربری را به شمارهی خودش عوض
|
||||
# کند، کد میگیرد و واردِ همان حساب میشود؛ پس فقط ابرکاربر آن را عوض میکند.
|
||||
fields = tuple(super().get_readonly_fields(request, obj))
|
||||
return fields if request.user.is_superuser else fields + ('phone',)
|
||||
|
||||
# permissions=['change']: بدونِ این، کاربرِ فقط-view (گروهِ Supervisor)
|
||||
# میتونست با POSTِ مستقیم این اکشن رو صدا بزنه بدونِ دیدنِ دکمه در UI؛
|
||||
# این پارامتر باعث میشه ModelAdmin فقط برای change_user در دسترس بذارتش.
|
||||
@@ -146,20 +209,34 @@ class UserAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(Profile)
|
||||
class ProfileAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'user', 'birth_year', 'parent_phone')
|
||||
list_display = ('id', 'user', 'birth_date', 'birth_year')
|
||||
|
||||
|
||||
@admin.register(Session)
|
||||
class SessionAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'user', 'device_id', 'expires_at', 'revoked')
|
||||
list_display = ('id', 'user', 'expires_at', 'revoked')
|
||||
list_filter = ('revoked',)
|
||||
|
||||
# ساختن/ویرایشِ دستیِ نشست یعنی ساختنِ ورود برای هر کاربر؛ فقط ابرکاربر.
|
||||
def has_add_permission(self, request):
|
||||
return request.user.is_superuser
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return request.user.is_superuser
|
||||
|
||||
|
||||
@admin.register(OTPCode)
|
||||
class OTPCodeAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'phone', 'code', 'expires_at', 'is_used')
|
||||
list_filter = ('is_used',)
|
||||
|
||||
# ردیفِ OTP که کارمند میسازد = ورود بیپیامک به هر شماره (راهِ تستر)؛ فقط ابرکاربر.
|
||||
def has_add_permission(self, request):
|
||||
return request.user.is_superuser
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return request.user.is_superuser
|
||||
|
||||
|
||||
# صفحهی «ناظر» (Supervisor): یک ایندکسِ read-only از لینکها به
|
||||
# changelistهای موجود (کاربران/پرداختها/اشتراکها)، داخلِ همون AdminSite
|
||||
@@ -216,3 +293,55 @@ def _admin_get_urls_with_supervisor():
|
||||
|
||||
|
||||
admin.site.get_urls = _admin_get_urls_with_supervisor
|
||||
|
||||
|
||||
# حسابهای کارمندانِ پنل (auth.User/Group جنگو، جدا از accounts.User). در پیشفرضِ جنگو کارمندی با
|
||||
# change_group همهی اجازهها را به گروهِ خودش میدهد و با change_user (auth) تیکِ ابرکاربر را برای
|
||||
# خودش میزند. ساختن/تغییر/حذفِ این دو فقط برای ابرکاربر است؛ دیدن همان اجازهی view را میخواهد.
|
||||
from django.contrib.auth import get_user_model # noqa: E402
|
||||
from django.contrib.auth.admin import GroupAdmin as _DjangoGroupAdmin # noqa: E402
|
||||
from django.contrib.auth.admin import UserAdmin as _DjangoUserAdmin # noqa: E402
|
||||
from django.contrib.auth.models import Group # noqa: E402
|
||||
|
||||
|
||||
class _SuperuserWritesOnly:
|
||||
def has_add_permission(self, request):
|
||||
return request.user.is_superuser
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return request.user.is_superuser
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return request.user.is_superuser
|
||||
|
||||
|
||||
class StaffUserAdmin(_SuperuserWritesOnly, _DjangoUserAdmin):
|
||||
pass
|
||||
|
||||
|
||||
class StaffGroupAdmin(_SuperuserWritesOnly, _DjangoGroupAdmin):
|
||||
pass
|
||||
|
||||
|
||||
admin.site.unregister(get_user_model())
|
||||
admin.site.register(get_user_model(), StaffUserAdmin)
|
||||
admin.site.unregister(Group)
|
||||
admin.site.register(Group, StaffGroupAdmin)
|
||||
|
||||
|
||||
@admin.register(LoginEvent)
|
||||
class LoginEventAdmin(admin.ModelAdmin):
|
||||
"""سابقهی ورود/خروج؛ فقطخواندنی (سابقه نباید از پنل عوض شود)."""
|
||||
list_display = ('created_at', 'event', 'user_id', 'phone', 'ip')
|
||||
list_filter = ('event', 'created_at')
|
||||
search_fields = ('phone', 'user_id', 'ip')
|
||||
date_hierarchy = 'created_at'
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
@@ -71,6 +71,14 @@ def decode_access_token(token: str) -> int:
|
||||
return int(value)
|
||||
|
||||
|
||||
def _issued_at(token: str) -> int:
|
||||
"""
|
||||
ثانیهی صدورِ توکن. قالبِ TimestampSigner «مقدار:زمان:امضا» است و زمان
|
||||
base62؛ فقط بعد از unsignِ موفق صدا زده میشود، پس ساختار معتبر است.
|
||||
"""
|
||||
return signing.b62_decode(token.rsplit(':', 2)[-2])
|
||||
|
||||
|
||||
class TadrisinoTokenAuthentication(BaseAuthentication):
|
||||
"""
|
||||
هدر مورد انتظار: Authorization: Bearer <access_token>
|
||||
@@ -91,7 +99,10 @@ class TadrisinoTokenAuthentication(BaseAuthentication):
|
||||
|
||||
token = parts[1]
|
||||
user_id = decode_access_token(token)
|
||||
return (_get_request_user(user_id), token)
|
||||
user = _get_request_user(user_id)
|
||||
if user.tokens_valid_after is not None and _issued_at(token) < int(user.tokens_valid_after.timestamp()):
|
||||
raise AuthenticationFailed('از حساب خارج شدهاید؛ دوباره وارد شوید.')
|
||||
return (user, token)
|
||||
|
||||
def authenticate_header(self, request):
|
||||
return self.keyword
|
||||
|
||||
107
accounts/jalali.py
Normal file
107
accounts/jalali.py
Normal 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
|
||||
139
accounts/management/commands/delete_users.py
Normal file
139
accounts/management/commands/delete_users.py
Normal 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)} کاربر و همهی دادهشان پاک شدند.'))
|
||||
@@ -1,12 +1,12 @@
|
||||
"""
|
||||
یک دیتاست کوچک و واقعی برای تست دستی/API لوکال یا staging میسازه، طوری که
|
||||
اجرای مکرر (repeat) هیچ رکورد تکراریای نسازه — همهچیز با get_or_create روی
|
||||
یک کلید طبیعی/یکتا (phone، device_id، code، idempotency_key و ...) انجام
|
||||
یک کلید طبیعی/یکتا (phone، code، idempotency_key و ...) انجام
|
||||
میشه، نه با create ساده.
|
||||
|
||||
هدف: پوشش کمینهی جریانهای اصلی API در هر ۱۱ اپ (accounts، content،
|
||||
هدف: پوشش کمینهی جریانهای اصلی API در هر ۱۰ اپ (accounts، content،
|
||||
progress، ai_tutor، subscription، payment، discount، notifications،
|
||||
affiliate، support، family) با کمترین حجم دادهی لازم برای اینکه هر
|
||||
affiliate، support) با کمترین حجم دادهی لازم برای اینکه هر
|
||||
endpoint اصلی بدون نیاز به ساختن دستی پیشنیازهاش قابل تست باشه.
|
||||
|
||||
⚠️ این دستور فقط برای محیط لوکال/staging است، نه پروداکشن:
|
||||
@@ -37,17 +37,14 @@ from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import OTPCode, Profile, Session, User
|
||||
from accounts.models import OTPCode, Profile, Session, User, hash_refresh_token
|
||||
|
||||
# --- شناسههای ثابتِ دادهی seed --------------------------------------------
|
||||
# شمارههای 0912000000X عمداً خارج از بازهی واقعیِ اپراتورهای ایران نیستن؛
|
||||
# چون این فقط دیتای لوکال/staging است نه پروداکشن، همین کافیه. با پیشوند
|
||||
# یکسان و بازهی اختصاصی، شناساییِ رکوردهای seed در دیتابیس هم ساده میمونه.
|
||||
MAIN_PHONE = '09120000001'
|
||||
PARENT_PHONE = '09120000002'
|
||||
CHILD_PHONE = '09120000003'
|
||||
REFERRED_PHONE = '09120000004'
|
||||
GUEST_DEVICE_ID = 'seed-guest-device-0001'
|
||||
|
||||
SEED_TAG = '[SEED]'
|
||||
|
||||
@@ -91,7 +88,7 @@ class Command(BaseCommand):
|
||||
self.stdout.write(f' - {label}: user_id={user.pk} phone={user.phone or "-"} token={token}')
|
||||
self.stdout.write(
|
||||
'\nنکته: این توکنها با ACCESS_TOKEN_TTL_SECONDS فعلی امضا شدن و بعد از انقضا '
|
||||
'باید دوباره seed یا از /api/auth/session یا /api/auth/login گرفته بشن.'
|
||||
'باید دوباره seed یا از /api/auth/login گرفته بشن.'
|
||||
)
|
||||
|
||||
# -- reset ----------------------------------------------------------
|
||||
@@ -101,18 +98,14 @@ class Command(BaseCommand):
|
||||
from affiliate.models import AffiliateAccount, Conversion
|
||||
from content.models import Grade, NodeProgress, UserAnswer, UserLessonCompletion
|
||||
from discount.models import DiscountCode, Offer, Redemption, UserOffer
|
||||
from family.models import FamilyLink
|
||||
from notifications.models import Notification
|
||||
from payment.models import Order
|
||||
from progress.models import LeaderboardEntry, StreakRecord, UserHearts, UserStats
|
||||
from subscription.models import Plan, Subscription
|
||||
from support.models import BugReport, Ticket
|
||||
|
||||
phones = [MAIN_PHONE, PARENT_PHONE, CHILD_PHONE, REFERRED_PHONE]
|
||||
phones = [MAIN_PHONE, REFERRED_PHONE]
|
||||
user_ids = list(User.objects.filter(phone__in=phones).values_list('pk', flat=True))
|
||||
guest = User.objects.filter(device_id=GUEST_DEVICE_ID, is_guest=True).first()
|
||||
if guest:
|
||||
user_ids.append(guest.pk)
|
||||
|
||||
if user_ids:
|
||||
UserAnswer.objects.filter(user_id__in=user_ids).delete()
|
||||
@@ -132,7 +125,6 @@ class Command(BaseCommand):
|
||||
Conversion.objects.filter(referred_user_id__in=user_ids).delete()
|
||||
Ticket.objects.filter(user_id__in=user_ids).delete()
|
||||
BugReport.objects.filter(user_id__in=user_ids).delete()
|
||||
FamilyLink.objects.filter(parent_user_id__in=user_ids).delete()
|
||||
|
||||
from progress.models import Character
|
||||
Character.objects.filter(slug__startswith='seed-').delete()
|
||||
@@ -144,17 +136,13 @@ class Command(BaseCommand):
|
||||
|
||||
OTPCode.objects.filter(phone__in=phones).delete()
|
||||
Session.objects.filter(user__phone__in=phones).delete()
|
||||
if guest:
|
||||
Session.objects.filter(user=guest).delete()
|
||||
User.objects.filter(phone__in=phones).delete()
|
||||
if guest:
|
||||
guest.delete()
|
||||
|
||||
self.stdout.write('رکوردهای قبلیِ seed حذف شدن؛ در حال ساختِ دوباره...')
|
||||
|
||||
# -- seed -------------------------------------------------------------
|
||||
def _seed(self):
|
||||
main_user, guest_user, parent_user, child_user, referred_user = self._seed_accounts()
|
||||
main_user, referred_user = self._seed_accounts()
|
||||
self._seed_content_and_progress(main_user)
|
||||
self._seed_characters()
|
||||
self._seed_ai_tutor(main_user)
|
||||
@@ -163,12 +151,9 @@ class Command(BaseCommand):
|
||||
self._seed_notifications(main_user)
|
||||
self._seed_affiliate(main_user, referred_user)
|
||||
self._seed_support(main_user)
|
||||
self._seed_family(parent_user, child_user)
|
||||
|
||||
tokens = [
|
||||
('کاربر اصلی (ثبتنامشده)', main_user, issue_access_token(main_user.pk)),
|
||||
('کاربر مهمان', guest_user, issue_access_token(guest_user.pk)),
|
||||
('والد', parent_user, issue_access_token(parent_user.pk)),
|
||||
('کاربر اصلی', main_user, issue_access_token(main_user.pk)),
|
||||
]
|
||||
return {'tokens': tokens}
|
||||
|
||||
@@ -176,48 +161,26 @@ class Command(BaseCommand):
|
||||
def _seed_accounts(self):
|
||||
main_user, _ = User.objects.get_or_create(
|
||||
phone=MAIN_PHONE,
|
||||
defaults=dict(name=f'{SEED_TAG} کاربر اصلی', is_guest=False, grade_id=None),
|
||||
defaults=dict(name=f'{SEED_TAG} کاربر اصلی', grade_id=None),
|
||||
)
|
||||
Profile.objects.get_or_create(
|
||||
user=main_user, defaults=dict(birth_year=2015, parent_phone=PARENT_PHONE, bio=f'{SEED_TAG} پروفایل تستی')
|
||||
user=main_user, defaults=dict(birth_year=1394, bio=f'{SEED_TAG} پروفایل تستی')
|
||||
)
|
||||
Session.objects.get_or_create(
|
||||
user=main_user,
|
||||
refresh_token=f'seed-refresh-{MAIN_PHONE}',
|
||||
refresh_token=hash_refresh_token(f'seed-refresh-{MAIN_PHONE}'),
|
||||
defaults=dict(
|
||||
device_id='seed-device-main',
|
||||
expires_at=timezone.now() + timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
|
||||
revoked=False,
|
||||
),
|
||||
)
|
||||
|
||||
guest_user, _ = User.objects.get_or_create(
|
||||
device_id=GUEST_DEVICE_ID,
|
||||
is_guest=True,
|
||||
defaults=dict(name=f'{SEED_TAG} کاربر مهمان'),
|
||||
)
|
||||
Session.objects.get_or_create(
|
||||
user=guest_user,
|
||||
refresh_token=f'seed-refresh-{GUEST_DEVICE_ID}',
|
||||
defaults=dict(
|
||||
device_id=GUEST_DEVICE_ID,
|
||||
expires_at=timezone.now() + timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
|
||||
revoked=False,
|
||||
),
|
||||
)
|
||||
|
||||
parent_user, _ = User.objects.get_or_create(
|
||||
phone=PARENT_PHONE, defaults=dict(name=f'{SEED_TAG} والد', is_guest=False)
|
||||
)
|
||||
child_user, _ = User.objects.get_or_create(
|
||||
phone=CHILD_PHONE, defaults=dict(name=f'{SEED_TAG} فرزند', is_guest=False)
|
||||
)
|
||||
referred_user, _ = User.objects.get_or_create(
|
||||
phone=REFERRED_PHONE, defaults=dict(name=f'{SEED_TAG} کاربر معرفیشده', is_guest=False)
|
||||
phone=REFERRED_PHONE, defaults=dict(name=f'{SEED_TAG} کاربر معرفیشده')
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('accounts: 5 کاربر (اصلی/مهمان/والد/فرزند/معرفیشده) آماده شد.'))
|
||||
return main_user, guest_user, parent_user, child_user, referred_user
|
||||
self.stdout.write(self.style.SUCCESS('accounts: ۲ کاربر (اصلی/معرفیشده) آماده شد.'))
|
||||
return main_user, referred_user
|
||||
|
||||
# -- content + progress --------------------------------------------------
|
||||
def _seed_content_and_progress(self, main_user):
|
||||
@@ -469,28 +432,3 @@ class Command(BaseCommand):
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('support: 1 تیکت با یک پیام، و 1 گزارش باگ آماده شد.'))
|
||||
|
||||
# -- family -----------------------------------------------------------------------
|
||||
def _seed_family(self, parent_user, child_user):
|
||||
from family.models import FamilyLink, ParentalSettings, WeeklyReportLog
|
||||
|
||||
link, _ = FamilyLink.objects.get_or_create(
|
||||
parent_user_id=parent_user.pk,
|
||||
child_phone=CHILD_PHONE,
|
||||
defaults=dict(
|
||||
child_user_id=child_user.pk,
|
||||
invite_code='SEEDFAM01',
|
||||
status='accepted',
|
||||
accepted_at=timezone.now(),
|
||||
),
|
||||
)
|
||||
ParentalSettings.objects.get_or_create(
|
||||
family_link=link, defaults=dict(daily_limit_minutes=60)
|
||||
)
|
||||
WeeklyReportLog.objects.get_or_create(
|
||||
family_link=link,
|
||||
week_start=(timezone.now() - timedelta(days=timezone.now().weekday())).date(),
|
||||
defaults=dict(sent_at=timezone.now()),
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('family: 1 اتصالِ خانواده (پذیرفتهشده) با تنظیمات و گزارشِ هفتگی آماده شد.'))
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
40
accounts/migrations/0005_normalize_user_phones.py
Normal file
40
accounts/migrations/0005_normalize_user_phones.py
Normal 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),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
28
accounts/migrations/0007_hash_refresh_tokens.py
Normal file
28
accounts/migrations/0007_hash_refresh_tokens.py
Normal 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),
|
||||
]
|
||||
45
accounts/migrations/0008_remove_guest_login.py
Normal file
45
accounts/migrations/0008_remove_guest_login.py
Normal 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',
|
||||
),
|
||||
]
|
||||
28
accounts/migrations/0009_loginevent.py
Normal file
28
accounts/migrations/0009_loginevent.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
18
accounts/migrations/0010_alter_otpcode_expires_at.py
Normal file
18
accounts/migrations/0010_alter_otpcode_expires_at.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -1,9 +1,11 @@
|
||||
import hashlib
|
||||
|
||||
from django.db import models
|
||||
|
||||
|
||||
class User(models.Model):
|
||||
"""
|
||||
کاربر اصلی سیستم (مهمان یا ثبتنامشده با شماره).
|
||||
کاربر اصلی سیستم: ثبتنامشده با شمارهی موبایل (تنها راه ورود).
|
||||
توجه: این مدل، AUTH_USER_MODEL جنگو نیست؛ یک مدل بیزنسی مستقله که
|
||||
با TadrisinoTokenAuthentication سفارشی احراز هویت میشه.
|
||||
|
||||
@@ -11,30 +13,23 @@ class User(models.Model):
|
||||
این اپ عمداً هیچوقت FK واقعی به مدل اپ دیگه نمیزنه.
|
||||
"""
|
||||
phone = models.CharField(max_length=15, unique=True, null=True, blank=True, db_index=True)
|
||||
device_id = models.CharField(max_length=64, null=True, blank=True, db_index=True)
|
||||
name = models.CharField(max_length=100, blank=True)
|
||||
avatar = models.CharField(max_length=100, blank=True)
|
||||
is_guest = models.BooleanField(default=True)
|
||||
grade_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Grade
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
# آخرین زمانِ فعالیتِ کاربر (لاگین موفق، رفرش توکن موفق، یا سشنِ
|
||||
# مهمانِ واقعی). فقط برای هدفگیریِ آیندهی کمپینهای پیامی نگهداری
|
||||
# آخرین زمانِ فعالیتِ کاربر (لاگین موفق یا رفرش توکن موفق). فقط برای هدفگیریِ آیندهی کمپینهای پیامی نگهداری
|
||||
# میشه؛ در حال حاضر هیچ منطقِ دیگهای به این فیلد وابسته نیست.
|
||||
last_activity_at = models.DateTimeField(null=True, blank=True)
|
||||
# access tokenهای صادرشده پیش از این لحظه رد میشوند (خروج از حساب). توکنِ
|
||||
# access فقط user_id امضاشده است و به Session وصل نیست؛ بدونِ این، خروج
|
||||
# فقط refresh را باطل میکرد و توکنِ فعلی تا یک ساعت کار میکرد.
|
||||
tokens_valid_after = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
constraints = [
|
||||
# جلوگیری از ساخت دو کاربر مهمان همزمان با یک device_id یکسان.
|
||||
models.UniqueConstraint(
|
||||
fields=['device_id'],
|
||||
condition=models.Q(is_guest=True) & ~models.Q(device_id=None) & ~models.Q(device_id=''),
|
||||
name='uniq_guest_device_id',
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name or self.phone or f"guest-{self.pk}"
|
||||
return self.name or self.phone or f"user-{self.pk}"
|
||||
|
||||
@property
|
||||
def is_authenticated(self):
|
||||
@@ -45,23 +40,32 @@ class User(models.Model):
|
||||
|
||||
class Profile(models.Model):
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
|
||||
# سالِ تولدِ شمسی. از ۲۰۲۶-۱۰-۰۱ از birth_date پر میشود؛ برای کاربرهای
|
||||
# قدیمی که فقط سال دادهاند تنها منبع است.
|
||||
birth_year = models.PositiveSmallIntegerField(null=True, blank=True)
|
||||
parent_phone = models.CharField(max_length=15, blank=True)
|
||||
# تاریخِ تولدِ کامل (میلادی ذخیره میشود، در API شمسی است؛ accounts/jalali.py).
|
||||
birth_date = models.DateField(null=True, blank=True)
|
||||
bio = models.CharField(max_length=200, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"profile-{self.user_id}"
|
||||
|
||||
|
||||
def hash_refresh_token(raw: str) -> str:
|
||||
"""آنچه در Session.refresh_token ذخیره و با آن جستوجو میشود؛ خودِ توکن فقط دستِ کلاینت است."""
|
||||
return hashlib.sha256(raw.encode()).hexdigest()
|
||||
|
||||
|
||||
class Session(models.Model):
|
||||
"""
|
||||
نگهداری refresh_token هر ورود. توکن دسترسی (access token) در دیتابیس
|
||||
ذخیره نمیشه؛ بهصورت stateless امضا (signed) میشه — دقیقاً طبق فیلدهای
|
||||
همین مدل که فقط refresh_token داره، نه access token.
|
||||
یک ورود. توکنِ دسترسی ذخیره نمیشود (امضاشده و stateless است، accounts/authentication.py).
|
||||
|
||||
`refresh_token` هشِ SHA-256ِ توکنِ تمدید است، نه خودش (hash_refresh_token): هر کس دیتابیس،
|
||||
یک بکاپ یا پنلِ ادمین را ببیند با آن نمیتواند بهجای کاربر وارد شود. توکن ۶۴ نویسهی تصادفی
|
||||
است، پس هشِ سریعِ بینمک کافی است.
|
||||
"""
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sessions')
|
||||
refresh_token = models.CharField(max_length=200, unique=True)
|
||||
device_id = models.CharField(max_length=64, blank=True)
|
||||
expires_at = models.DateTimeField()
|
||||
revoked = models.BooleanField(default=False)
|
||||
|
||||
@@ -72,8 +76,46 @@ class Session(models.Model):
|
||||
class OTPCode(models.Model):
|
||||
phone = models.CharField(max_length=15, db_index=True)
|
||||
code = models.CharField(max_length=6)
|
||||
expires_at = models.DateTimeField()
|
||||
# ایندکس: سقفِ سراسریِ پیامکِ روزانه در هر درخواستِ کد کدهای ۲۴ ساعتِ اخیر را میشمارد
|
||||
# (accounts/views.py)؛ ردیفها پاک نمیشوند، پس بیایندکس این شمارش با هر ورود کندتر میشد.
|
||||
expires_at = models.DateTimeField(db_index=True)
|
||||
is_used = models.BooleanField(default=False)
|
||||
# تلاشهای غلط روی همین کد. در دیتابیس شمرده میشود، نه در کشِ LocMem که
|
||||
# برای هر worker جداست؛ بعد از OTP_MAX_FAILED_ATTEMPTS کد باطل میشود.
|
||||
failed_attempts = models.PositiveSmallIntegerField(default=0)
|
||||
|
||||
def __str__(self):
|
||||
return f"otp-{self.phone}"
|
||||
|
||||
|
||||
class LoginEvent(models.Model):
|
||||
"""
|
||||
سابقهی ماندگارِ ورود و خروج، برای پرسشهایی مثلِ «این کاربر کی آمد، کی رفت».
|
||||
|
||||
چرا جدول و نه فقط لاگ: لاگِ stdout با ریاستارت یا چرخشِ journald از بین میرود؛ این ردیفها
|
||||
در دیتابیس و بکاپ میمانند. `user_id` ارجاعِ سبک است (نه FK، مثلِ بقیهی پروژه)، و ردیف با حذفِ
|
||||
حساب نمیماند مگر در KEPT_ON_ACCOUNT_DELETE. شمارهی کامل برای ادمینها ذخیره میشود
|
||||
(تصمیمِ مالک، ۲۰۲۶-۱۰-۰۸)؛ در سیاستِ حریمِ خصوصی باید بیاید.
|
||||
"""
|
||||
LOGIN_OK = 'login_ok'
|
||||
LOGIN_FAILED = 'login_failed' # کدِ غلط یا منقضی
|
||||
LOGIN_BLOCKED = 'login_blocked' # سقفِ تلاشِ شماره (429)
|
||||
LOGOUT = 'logout'
|
||||
ACCOUNT_DELETED = 'account_deleted'
|
||||
EVENT_CHOICES = [
|
||||
(LOGIN_OK, 'ورود موفق'), (LOGIN_FAILED, 'ورود ناموفق'), (LOGIN_BLOCKED, 'ورود مسدود'),
|
||||
(LOGOUT, 'خروج'), (ACCOUNT_DELETED, 'حذف حساب'),
|
||||
]
|
||||
|
||||
event = models.CharField(max_length=20, choices=EVENT_CHOICES, db_index=True)
|
||||
user_id = models.IntegerField(null=True, blank=True, db_index=True)
|
||||
phone = models.CharField(max_length=15, blank=True, db_index=True)
|
||||
ip = models.GenericIPAddressField(null=True, blank=True)
|
||||
user_agent = models.CharField(max_length=300, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.event} user={self.user_id} {self.created_at:%Y-%m-%d %H:%M}"
|
||||
|
||||
43
accounts/phone.py
Normal file
43
accounts/phone.py
Normal 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
32
accounts/public_id.py
Normal 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
|
||||
@@ -15,7 +15,7 @@ class TadrisinoTokenAuthenticationScheme(OpenApiAuthenticationExtension):
|
||||
'scheme': 'bearer',
|
||||
'bearerFormat': 'access_token',
|
||||
'description': (
|
||||
'توکن دسترسی که از /api/auth/session یا /api/auth/login گرفته میشه. '
|
||||
'توکن دسترسی که از /api/auth/login گرفته میشه. '
|
||||
'در فیلد Value فقط خودِ توکن رو بذارید (بدون کلمهی Bearer)؛ Swagger '
|
||||
'خودش اضافه میکنه.'
|
||||
),
|
||||
|
||||
@@ -1,16 +1,71 @@
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from .phone import ascii_digits, normalize_phone
|
||||
|
||||
# لینک، آیدیِ شبکهی اجتماعی، و هر رشتهی ۵ رقمی یا بیشتر با هر جداکنندهی غیرِ حرفی (فاصله،
|
||||
# خطتیره، ستاره، ایموجی…). \d در پایتون هر رقمِ یونیکد است (فارسی، عربی، تمامعرض، ریاضی).
|
||||
# این نام در رتبهبندی به بچههای دیگر نشان داده میشود. حرف بینِ رقمها («0x9x1…») و عدد با
|
||||
# حروف («صفر نه یک…») گرفته نمیشود.
|
||||
_CONTACT = re.compile(
|
||||
r'(https?://\S+|www\.\S+|\S+\.(?:com|ir|org|net|me)\S*|@\w+'
|
||||
r'|(?:\d(?:[^\w@]|_)*){5,})',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# «ig: name»، «insta name»، «آیدی name» و مانندش: کلیدواژه و هر چه بعدش آمده حذف میشود.
|
||||
_HANDLE = re.compile(
|
||||
r'(?:\b(?:ig|insta(?:gram)?|tele(?:gram)?|tg|id|whats ?app|rubika|eitaa|bale)\b'
|
||||
r'|اینستا\S*|(?:تلگرام|تل|آیدی|ایدی|واتساپ|روبیکا|ایتا)(?!\w)|بله\s*:)\s*[::]?.*',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def clean_display_name(value: str) -> str:
|
||||
# NFKC: «①»، «²»، «𝟬» و «@» به رقم و @ِ معمولی برمیگردند تا الگوهای پایین آنها را هم بگیرند؛
|
||||
# نامِ ذخیرهشده همین شکلِ نرمال است.
|
||||
value = _HANDLE.sub(' ', unicodedata.normalize('NFKC', value or ''))
|
||||
return ' '.join(_CONTACT.sub(' ', value).split())[:30]
|
||||
|
||||
|
||||
class IranMobileField(serializers.CharField):
|
||||
"""CharField که خروجیاش همیشه 09XXXXXXXXX است."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
kwargs.setdefault('max_length', 20)
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def to_internal_value(self, data):
|
||||
phone = normalize_phone(super().to_internal_value(data))
|
||||
if phone is None:
|
||||
raise serializers.ValidationError('شمارهی موبایل معتبر نیست؛ مثلاً 09121234567.')
|
||||
return phone
|
||||
|
||||
|
||||
# ---- Request serializers ----
|
||||
|
||||
class GuestSessionRequestSerializer(serializers.Serializer):
|
||||
type = serializers.ChoiceField(choices=['guest'])
|
||||
deviceId = serializers.CharField(max_length=64)
|
||||
class OTPCodeField(serializers.CharField):
|
||||
"""
|
||||
کدِ ورود: ۴ تا ۶ رقم (کدِ پیامکی ۶ رقمی است، کدهای TEST_LOGIN_PHONES ۴ تا ۶). رقمِ فارسی و
|
||||
دیگر رقمهای یونیکد به لاتین برمیگردند؛ هر چیزِ دیگر ۴۰۰ است. بدونِ این، کدِ غیرِ ASCII برای
|
||||
شمارهی تست به secrets.compare_digest میرسید و TypeError (۵۰۰) میداد.
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
kwargs.setdefault('max_length', 6)
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def to_internal_value(self, data):
|
||||
code = ascii_digits(super().to_internal_value(data))
|
||||
if not re.fullmatch(r'[0-9]{4,6}', code):
|
||||
raise serializers.ValidationError('کد باید ۴ تا ۶ رقم باشد.')
|
||||
return code
|
||||
|
||||
|
||||
class LoginRequestSerializer(serializers.Serializer):
|
||||
phone = serializers.CharField(max_length=15)
|
||||
otp = serializers.CharField(max_length=6)
|
||||
phone = IranMobileField()
|
||||
otp = OTPCodeField()
|
||||
|
||||
|
||||
class RefreshRequestSerializer(serializers.Serializer):
|
||||
@@ -20,42 +75,80 @@ class RefreshRequestSerializer(serializers.Serializer):
|
||||
class SetGradeRequestSerializer(serializers.Serializer):
|
||||
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` و `isGuest` عمداً اینجا نیستند: شماره فقط از مسیرِ OTP عوض
|
||||
`phone` عمداً اینجا نیست: شماره فقط از مسیرِ OTP عوض
|
||||
میشود و مهمانبودن را کاربر نباید بتواند خودش تغییر دهد.
|
||||
"""
|
||||
name = serializers.CharField(max_length=100, required=False, allow_blank=True)
|
||||
avatar = serializers.CharField(max_length=100, required=False, allow_blank=True)
|
||||
|
||||
def validate_name(self, value):
|
||||
"""
|
||||
نام در جدولِ رتبهبندی به بچههای دیگر نشان داده میشود؛ شماره، لینک و
|
||||
آیدی (راهِ تماس گرفتن با یک کودکِ غریبه) از آن حذف میشود. عمداً خطا
|
||||
نمیدهد تا صفحهی پروفایلِ اپ گیر نکند؛ فقط بقیهی نام ذخیره میشود.
|
||||
"""
|
||||
return clean_display_name(value)
|
||||
# فقط سال (روشِ قدیمِ اپ). birthDate دقیقتر است و ترجیح دارد.
|
||||
birthYear = serializers.IntegerField(required=False, allow_null=True,
|
||||
min_value=1300, max_value=1500)
|
||||
parentPhone = serializers.CharField(max_length=15, required=False, allow_blank=True)
|
||||
# تاریخِ تولدِ شمسی «1385-04-12». سرور میلادی ذخیره میکند و birthYear را هم
|
||||
# از آن پر میکند؛ تبدیل فقط در accounts/jalali.py است.
|
||||
birthDate = serializers.CharField(required=False, allow_null=True, max_length=10)
|
||||
bio = serializers.CharField(max_length=200, required=False, allow_blank=True)
|
||||
|
||||
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):
|
||||
"""اپ کمکی برای گرفتن OTP (در سند اصلی نبود، برای تستپذیری Swagger اضافه شد)."""
|
||||
phone = serializers.CharField(max_length=15)
|
||||
phone = IranMobileField()
|
||||
|
||||
|
||||
# ---- Response serializers ----
|
||||
|
||||
class GuestSessionResponseSerializer(serializers.Serializer):
|
||||
userId = serializers.CharField()
|
||||
token = serializers.CharField()
|
||||
isNewUser = serializers.BooleanField()
|
||||
isGuest = serializers.BooleanField()
|
||||
|
||||
|
||||
class LoginResponseSerializer(serializers.Serializer):
|
||||
userId = serializers.CharField()
|
||||
token = serializers.CharField()
|
||||
@@ -93,11 +186,30 @@ class RequestOTPResponseSerializer(serializers.Serializer):
|
||||
class ProfileResponseSerializer(serializers.Serializer):
|
||||
"""پاسخ GET /api/user/profile — فقط دادهی لازم برای تب Profile اپ موبایل."""
|
||||
userId = serializers.CharField()
|
||||
# شناسهی نمایشی («User482917»، accounts/public_id.py)؛ اپ آن را زیرِ نام نشان میدهد
|
||||
# و پشتیبانی با همین در پنلِ ادمین حساب را پیدا میکند.
|
||||
publicId = serializers.CharField()
|
||||
phone = serializers.CharField(allow_null=True)
|
||||
name = serializers.CharField()
|
||||
avatar = serializers.CharField()
|
||||
isGuest = serializers.BooleanField()
|
||||
gradeId = serializers.IntegerField(allow_null=True)
|
||||
birthYear = serializers.IntegerField(allow_null=True)
|
||||
parentPhone = serializers.CharField()
|
||||
birthDate = serializers.CharField(allow_null=True, help_text='شمسی، «1385-04-12»؛ null اگر فقط سال داده شده.')
|
||||
bio = serializers.CharField()
|
||||
|
||||
|
||||
class DeleteAccountRequestSerializer(serializers.Serializer):
|
||||
# کدِ پیامکیِ تازه (از POST /api/auth/otp/request با همان شماره).
|
||||
otp = OTPCodeField()
|
||||
|
||||
|
||||
class DeleteAccountResponseSerializer(serializers.Serializer):
|
||||
success = serializers.BooleanField()
|
||||
|
||||
|
||||
class LogoutRequestSerializer(serializers.Serializer):
|
||||
refreshToken = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
|
||||
class LogoutResponseSerializer(serializers.Serializer):
|
||||
success = serializers.BooleanField()
|
||||
|
||||
@@ -42,6 +42,14 @@ class SMSDeliveryError(APIException):
|
||||
default_code = 'sms_delivery_failed'
|
||||
|
||||
|
||||
class SMSDeliveryUncertain(SMSDeliveryError):
|
||||
"""
|
||||
درخواست به کاوهنگار رسید ولی جوابش در مهلت نیامد؛ پیامک شاید رفته باشد.
|
||||
۲۰۲۶-۰۹-۲۷ دیده شد: پاسخ ۵٫۴ ثانیه طول کشید، ما با مهلتِ ۵ ثانیه ۵۰۳ دادیم و
|
||||
کد را پاک کردیم، ولی پیامک رسید — کاربر کدی در دست داشت که دیگر کار نمیکرد.
|
||||
"""
|
||||
|
||||
|
||||
class OTPDeliveryError(SMSDeliveryError):
|
||||
"""
|
||||
⚠️ اصلاح: قبلاً send_otp_sms یک استاب بود که فقط لاگ میکرد و همیشه
|
||||
@@ -76,6 +84,36 @@ def send_sms(*, phone: str, message: str) -> None:
|
||||
میزنه، و پاسخ رو اعتبارسنجی میکنه. کلیدِ API هیچوقت داخل کد نیست؛
|
||||
همیشه از env خونده میشه (settings.KAVENEGAR_API_KEY).
|
||||
"""
|
||||
payload = {'receptor': phone, 'message': message}
|
||||
if settings.KAVENEGAR_SENDER:
|
||||
payload['sender'] = settings.KAVENEGAR_SENDER
|
||||
_kavenegar_post(phone=phone, method='sms/send.json', payload=payload)
|
||||
|
||||
|
||||
def send_verify_lookup(*, phone: str, template: str, token: str) -> None:
|
||||
"""
|
||||
ارسال با الگوی تأییدشدهی کاوهنگار (verify/lookup) — متن را الگوی پنل میسازد
|
||||
و ما فقط token را میفرستیم. کاوهنگارِ این حساب پیامکِ متنِ آزاد را از خطِ
|
||||
پیشفرض لغو میکند (وضعیتِ ۱۳، ۲۰۲۶-۰۹-۲۵) و کدِ ورود را فقط از این راه میپذیرد.
|
||||
token نباید فاصله داشته باشد (قاعدهی کاوهنگار)؛ کدِ OTP شش رقمِ پیوسته است.
|
||||
"""
|
||||
_kavenegar_post(
|
||||
phone=phone, method='verify/lookup.json',
|
||||
payload={'receptor': phone, 'template': template, 'token': token},
|
||||
)
|
||||
|
||||
|
||||
# کاوهنگار گاهی بیش از ۵ ثانیه طول میکشد (۵٫۴ ثانیه در ۲۰۲۶-۰۹-۲۷) و شبکهی ایران
|
||||
# کند میشود؛ صاحبِ پروژه ۳۰ ثانیه خواست. جمعِ دو مهلت (۴۰ ثانیه) باید زیرِ مهلتِ
|
||||
# فرانت برای همین درخواست بماند (`requestOtp` در MathApp_frontend، ۴۵ ثانیه)، وگرنه
|
||||
# کاربر «ارتباط برقرار نشد» میبیند در حالی که پیامک رسیده. gunicorn ۱۲۰ ثانیه و
|
||||
# gthread است؛ مهلتِ nginx و ArvanCloud برای این مسیر سنجیده نشده.
|
||||
SMS_CONNECT_TIMEOUT_SECONDS = 10
|
||||
SMS_READ_TIMEOUT_SECONDS = 30
|
||||
|
||||
|
||||
def _kavenegar_post(*, phone: str, method: str, payload: dict) -> None:
|
||||
"""فراخوانیِ مشترکِ کاوهنگار و سنجشِ پاسخ؛ هر شکستی SMSDeliveryError است."""
|
||||
if not settings.KAVENEGAR_API_KEY:
|
||||
logger.error(
|
||||
'SMS misconfigured: KAVENEGAR_API_KEY is not set (phone_suffix=%s)',
|
||||
@@ -83,18 +121,23 @@ def send_sms(*, phone: str, message: str) -> None:
|
||||
)
|
||||
raise SMSDeliveryError('پیکربندیِ سرویسِ پیامک ناقص است.')
|
||||
|
||||
payload = {'receptor': phone, 'message': message}
|
||||
if settings.KAVENEGAR_SENDER:
|
||||
payload['sender'] = settings.KAVENEGAR_SENDER
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f'https://api.kavenegar.com/v1/{settings.KAVENEGAR_API_KEY}/sms/send.json',
|
||||
f'https://api.kavenegar.com/v1/{settings.KAVENEGAR_API_KEY}/{method}',
|
||||
data=payload,
|
||||
timeout=5,
|
||||
# (اتصال، خواندن). وصلنشدن یعنی قطعاً نرفت؛ دیر رسیدنِ جواب یعنی نامعلوم.
|
||||
timeout=(SMS_CONNECT_TIMEOUT_SECONDS, SMS_READ_TIMEOUT_SECONDS),
|
||||
)
|
||||
except requests.RequestException:
|
||||
logger.exception('SMS request to provider failed: phone_suffix=%s', _phone_suffix(phone))
|
||||
except requests.ReadTimeout:
|
||||
logger.warning(
|
||||
'SMS provider reply timed out after the request was sent; delivery unknown: '
|
||||
'phone_suffix=%s method=%s', _phone_suffix(phone), method,
|
||||
)
|
||||
raise SMSDeliveryUncertain('پاسخِ سرویسِ پیامک دیر رسید.')
|
||||
except requests.RequestException as exc:
|
||||
# فقط نوعِ خطا: متنِ خطای requests آدرسِ کامل را دارد و کلیدِ API بخشی از همان آدرس است.
|
||||
logger.error('SMS request to provider failed: phone_suffix=%s method=%s error=%s',
|
||||
_phone_suffix(phone), method, type(exc).__name__)
|
||||
raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
|
||||
|
||||
if response.status_code != 200:
|
||||
@@ -125,6 +168,16 @@ def send_sms(*, phone: str, message: str) -> None:
|
||||
)
|
||||
raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
|
||||
|
||||
# پذیرفتهشدن ≠ رسیدن. شناسه و وضعیتِ پیام لاگ میشود تا اگر کسی گفت «کد
|
||||
# نیامد»، همین شناسه در پنلِ کاوهنگار (گزارشِ ارسال) دنبال شود — مثلاً
|
||||
# شمارهای که پیامکِ تبلیغاتی را مسدود کرده. متنِ پیام لاگ نمیشود.
|
||||
entries = body.get('entries') if isinstance(body, dict) else None
|
||||
first = entries[0] if isinstance(entries, list) and entries and isinstance(entries[0], dict) else {}
|
||||
logger.info(
|
||||
'SMS accepted by provider: phone_suffix=%s messageid=%s message_status=%s',
|
||||
_phone_suffix(phone), first.get('messageid'), first.get('status'),
|
||||
)
|
||||
|
||||
|
||||
def send_otp_sms(*, phone: str, code: str) -> None:
|
||||
"""
|
||||
@@ -136,7 +189,8 @@ def send_otp_sms(*, phone: str, code: str) -> None:
|
||||
واقعیای نمیفرسته — چون در این محیط، RequestOTPView خودِ کد رو در
|
||||
فیلدِ devOtp پاسخ برمیگردونه و SMS واقعی لازم نیست (رفتار قبلی dev
|
||||
حفظ شده).
|
||||
- DEBUG=False (production): send_sms صدا زده میشه؛ هر SMSDeliveryError
|
||||
- DEBUG=False (production): اگر KAVENEGAR_OTP_TEMPLATE تنظیم باشد با الگو
|
||||
(send_verify_lookup)، وگرنه متنِ آزادِ OTP_SMS_TEMPLATE با send_sms؛ هر SMSDeliveryError
|
||||
به OTPDeliveryError نگاشت میشه تا رفتار/نوعِ استثنایی که
|
||||
RequestOTPView انتظارش رو داره حفظ بمونه (کاربر پاسخ ۵۰۳ صریح
|
||||
میگیره، نه یک success دروغین).
|
||||
@@ -148,9 +202,16 @@ def send_otp_sms(*, phone: str, code: str) -> None:
|
||||
)
|
||||
return
|
||||
|
||||
message = settings.OTP_SMS_TEMPLATE.format(code=code)
|
||||
try:
|
||||
send_sms(phone=phone, message=message)
|
||||
if settings.KAVENEGAR_OTP_TEMPLATE:
|
||||
send_verify_lookup(phone=phone, template=settings.KAVENEGAR_OTP_TEMPLATE, token=code)
|
||||
else:
|
||||
send_sms(phone=phone, message=settings.OTP_SMS_TEMPLATE.format(code=code))
|
||||
except SMSDeliveryUncertain:
|
||||
# کد نگه داشته میشود و پاسخ موفق است: اگر پیامک رسیده، همان کد کار میکند؛
|
||||
# اگر نرسیده، کاربر بعد از cooldown کدِ تازه میخواهد. برعکسش (۵۰۳ و پاک کردنِ
|
||||
# کد) کاربری را که پیامک گرفته قفل میکرد. cooldown جلوی دو پیامک را میگیرد.
|
||||
return
|
||||
except SMSDeliveryError as exc:
|
||||
raise OTPDeliveryError(str(exc)) from exc
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
سیگنال ثبتنامِ واقعی کاربر (نه guest session). سایر اپها (مثلاً discount
|
||||
سیگنال ثبتنامِ واقعی کاربر. سایر اپها (مثلاً discount
|
||||
برای اختصاص خودکار آفر) بهجای import کردن مستقیم از accounts، فقط این
|
||||
سیگنال رو در apps.py خودشون connect میکنن؛ این یعنی accounts هیچوقت لازم
|
||||
نیست بدونه discount/offer اصلاً وجود داره — دقیقاً همون درخواست سینیور برای
|
||||
|
||||
@@ -13,7 +13,7 @@ from rest_framework.test import APIClient
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
|
||||
from .authentication import issue_access_token
|
||||
from .models import OTPCode, Profile, Session, User
|
||||
from .models import OTPCode, Profile, Session, User, hash_refresh_token
|
||||
from .services import OTPDeliveryError, SMSDeliveryError, send_otp_sms
|
||||
|
||||
|
||||
@@ -27,14 +27,14 @@ class SendOtpSmsTests(TestCase):
|
||||
@override_settings(DEBUG=True)
|
||||
def test_dev_mode_does_not_call_provider(self):
|
||||
with patch('accounts.services.requests.post') as mock_post:
|
||||
send_otp_sms(phone='+989120000000', code='1234')
|
||||
send_otp_sms(phone='09120000000', code='1234')
|
||||
mock_post.assert_not_called()
|
||||
|
||||
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='')
|
||||
def test_production_without_api_key_raises_instead_of_silent_success(self):
|
||||
with patch('accounts.services.requests.post') as mock_post:
|
||||
with self.assertRaises(OTPDeliveryError):
|
||||
send_otp_sms(phone='+989120000000', code='1234')
|
||||
send_otp_sms(phone='09120000000', code='1234')
|
||||
mock_post.assert_not_called()
|
||||
|
||||
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key', KAVENEGAR_SENDER='')
|
||||
@@ -49,11 +49,11 @@ class SendOtpSmsTests(TestCase):
|
||||
'entries': [{'messageid': 1, 'status': 1}],
|
||||
}
|
||||
with patch('accounts.services.requests.post', return_value=mock_response) as mock_post:
|
||||
send_otp_sms(phone='+989120000000', code='1234')
|
||||
send_otp_sms(phone='09120000000', code='1234')
|
||||
mock_post.assert_called_once()
|
||||
args, kwargs = mock_post.call_args
|
||||
self.assertIn('test-key', args[0])
|
||||
self.assertEqual(kwargs['data']['receptor'], '+989120000000')
|
||||
self.assertEqual(kwargs['data']['receptor'], '09120000000')
|
||||
self.assertIn('1234', kwargs['data']['message'])
|
||||
|
||||
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key')
|
||||
@@ -61,7 +61,7 @@ class SendOtpSmsTests(TestCase):
|
||||
mock_response = Mock(status_code=400, text='bad request')
|
||||
with patch('accounts.services.requests.post', return_value=mock_response):
|
||||
with self.assertRaises(OTPDeliveryError):
|
||||
send_otp_sms(phone='+989120000000', code='1234')
|
||||
send_otp_sms(phone='09120000000', code='1234')
|
||||
|
||||
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key')
|
||||
def test_production_http_200_with_application_level_rejection_raises(self):
|
||||
@@ -77,7 +77,7 @@ class SendOtpSmsTests(TestCase):
|
||||
}
|
||||
with patch('accounts.services.requests.post', return_value=mock_response):
|
||||
with self.assertRaises(OTPDeliveryError):
|
||||
send_otp_sms(phone='+989120000000', code='1234')
|
||||
send_otp_sms(phone='09120000000', code='1234')
|
||||
|
||||
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key')
|
||||
def test_production_malformed_provider_body_raises(self):
|
||||
@@ -87,20 +87,20 @@ class SendOtpSmsTests(TestCase):
|
||||
mock_response.json.side_effect = ValueError('No JSON object could be decoded')
|
||||
with patch('accounts.services.requests.post', return_value=mock_response):
|
||||
with self.assertRaises(OTPDeliveryError):
|
||||
send_otp_sms(phone='+989120000000', code='1234')
|
||||
send_otp_sms(phone='09120000000', code='1234')
|
||||
|
||||
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key')
|
||||
def test_production_network_failure_raises(self):
|
||||
import requests as requests_module
|
||||
with patch('accounts.services.requests.post', side_effect=requests_module.ConnectionError('boom')):
|
||||
with self.assertRaises(OTPDeliveryError):
|
||||
send_otp_sms(phone='+989120000000', code='1234')
|
||||
send_otp_sms(phone='09120000000', code='1234')
|
||||
|
||||
@override_settings(DEBUG=True)
|
||||
def test_dev_mode_log_never_contains_full_phone_only_safe_suffix(self):
|
||||
# 🔒 اثبات فیکسِ این دور: لاگِ OTP نباید هیچوقت شمارهتلفنِ کامل رو
|
||||
# نشون بده؛ فقط ۴ رقمِ آخر (شناسهی امن) باید در پیامِ لاگ باشه.
|
||||
phone = '+989120000000'
|
||||
phone = '09120000000'
|
||||
with self.assertLogs('accounts.services', level='INFO') as captured:
|
||||
send_otp_sms(phone=phone, code='1234')
|
||||
|
||||
@@ -124,7 +124,7 @@ class SendOtpSmsTests(TestCase):
|
||||
with patch('accounts.services.requests.post', return_value=mock_response):
|
||||
with self.assertLogs('accounts.services', level='ERROR') as captured:
|
||||
with self.assertRaises(OTPDeliveryError):
|
||||
send_otp_sms(phone='+989120000000', code='1234')
|
||||
send_otp_sms(phone='09120000000', code='1234')
|
||||
message = captured.records[0].getMessage()
|
||||
self.assertNotIn('bad request body secret-ish', message)
|
||||
self.assertIn('phone_suffix=0000', message)
|
||||
@@ -136,7 +136,7 @@ class SendOtpSmsTests(TestCase):
|
||||
with patch('accounts.services.requests.post', return_value=mock_response):
|
||||
with self.assertLogs('accounts.services', level='ERROR') as captured:
|
||||
with self.assertRaises(OTPDeliveryError):
|
||||
send_otp_sms(phone='+989120000000', code='1234')
|
||||
send_otp_sms(phone='09120000000', code='1234')
|
||||
message = captured.records[0].getMessage()
|
||||
self.assertNotIn('not json body secret-ish', message)
|
||||
self.assertIn('phone_suffix=0000', message)
|
||||
@@ -154,14 +154,13 @@ class SendOtpSmsTests(TestCase):
|
||||
with patch('accounts.services.requests.post', return_value=mock_response):
|
||||
with self.assertLogs('accounts.services', level='ERROR') as captured:
|
||||
with self.assertRaises(OTPDeliveryError):
|
||||
send_otp_sms(phone='+989120000000', code='1234')
|
||||
send_otp_sms(phone='09120000000', code='1234')
|
||||
message = captured.records[0].getMessage()
|
||||
self.assertNotIn('secret-ish detail', message)
|
||||
self.assertIn('phone_suffix=0000', message)
|
||||
self.assertIn('provider_status=401', message)
|
||||
|
||||
|
||||
|
||||
class RequestOTPViewTests(TestCase):
|
||||
"""
|
||||
تستهای سطح API برای RequestOTPView: مطمئن میشیم رفتارِ dev قبلی حفظ
|
||||
@@ -176,38 +175,37 @@ class RequestOTPViewTests(TestCase):
|
||||
def test_dev_mode_returns_otp_in_response_without_provider_call(self):
|
||||
with patch('accounts.views.send_otp_sms') as mock_send:
|
||||
response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': '+989120000001'}, format='json',
|
||||
'/api/auth/otp/request', {'phone': '09120000001'}, format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.data['success'])
|
||||
self.assertTrue(response.data['devOtp'])
|
||||
mock_send.assert_called_once()
|
||||
self.assertTrue(OTPCode.objects.filter(phone='+989120000001').exists())
|
||||
self.assertTrue(OTPCode.objects.filter(phone='09120000001').exists())
|
||||
|
||||
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='')
|
||||
def test_production_delivery_failure_does_not_report_success(self):
|
||||
response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': '+989120000002'}, format='json',
|
||||
'/api/auth/otp/request', {'phone': '09120000002'}, format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 503)
|
||||
# OTP row is created before delivery is attempted; only the SMS leg fails.
|
||||
self.assertTrue(OTPCode.objects.filter(phone='+989120000002').exists())
|
||||
# ردیفِ کدی که هرگز نرسید حذف میشود تا در cooldown و سقفِ روزانهی
|
||||
# دیتابیسی شمرده نشود (۲۰۲۶-۰۹-۲۵).
|
||||
self.assertFalse(OTPCode.objects.filter(phone='09120000002').exists())
|
||||
|
||||
@override_settings(DEBUG=True)
|
||||
def test_generated_otp_is_always_a_4_digit_string_with_leading_zeros(self):
|
||||
# ⚠️ فیکسِ این تست: تولیدِ OTP از random.randint به secrets.randbelow
|
||||
# تغییر کرده؛ این تست مطمئن میشه فرمتِ ۴ رقمی (با صفرهای ابتدایی
|
||||
# حفظشده، نه یک عددِ کوتاهتر) دستنخورده مونده.
|
||||
def test_generated_otp_is_always_a_6_digit_string_with_leading_zeros(self):
|
||||
# ۶ رقم از ۲۰۲۶-۰۹-۲۵ (با ۴ رقم حدس زدن عملی بود)؛ صفرهای ابتدایی باید بمانند.
|
||||
with patch('accounts.views.secrets.randbelow', return_value=7):
|
||||
with patch('accounts.views.send_otp_sms'):
|
||||
response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': '+989120000003'}, format='json',
|
||||
'/api/auth/otp/request', {'phone': '09120000003'}, format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data['devOtp'], '0007')
|
||||
otp = OTPCode.objects.get(phone='+989120000003')
|
||||
self.assertEqual(otp.code, '0007')
|
||||
self.assertEqual(len(otp.code), 4)
|
||||
self.assertEqual(response.data['devOtp'], '000007')
|
||||
otp = OTPCode.objects.get(phone='09120000003')
|
||||
self.assertEqual(otp.code, '000007')
|
||||
self.assertEqual(len(otp.code), 6)
|
||||
self.assertTrue(otp.code.isdigit())
|
||||
|
||||
|
||||
@@ -244,21 +242,21 @@ class RequestOTPStaleInvalidationTests(TestCase):
|
||||
# (RequestOTPPhoneCooldownTests).
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
||||
def test_second_request_invalidates_first_otp(self):
|
||||
phone = '+989121110001'
|
||||
first_code = self._request_otp(phone, '1111')
|
||||
phone = '09121110001'
|
||||
first_code = self._request_otp(phone, '111111')
|
||||
first_otp = OTPCode.objects.get(phone=phone, code=first_code)
|
||||
self.assertFalse(first_otp.is_used)
|
||||
|
||||
self._request_otp(phone, '2222')
|
||||
self._request_otp(phone, '222222')
|
||||
|
||||
first_otp.refresh_from_db()
|
||||
self.assertTrue(first_otp.is_used)
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
||||
def test_newest_otp_still_works_for_login(self):
|
||||
phone = '+989121110002'
|
||||
self._request_otp(phone, '1111')
|
||||
second_code = self._request_otp(phone, '2222')
|
||||
phone = '09121110002'
|
||||
self._request_otp(phone, '111111')
|
||||
second_code = self._request_otp(phone, '222222')
|
||||
|
||||
response = self.client.post(
|
||||
'/api/auth/login', {'phone': phone, 'otp': second_code}, format='json',
|
||||
@@ -267,9 +265,9 @@ class RequestOTPStaleInvalidationTests(TestCase):
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
||||
def test_older_otp_is_rejected_after_new_request(self):
|
||||
phone = '+989121110003'
|
||||
first_code = self._request_otp(phone, '1111')
|
||||
self._request_otp(phone, '2222')
|
||||
phone = '09121110003'
|
||||
first_code = self._request_otp(phone, '111111')
|
||||
self._request_otp(phone, '222222')
|
||||
|
||||
response = self.client.post(
|
||||
'/api/auth/login', {'phone': phone, 'otp': first_code}, format='json',
|
||||
@@ -286,30 +284,30 @@ class RefreshTokenReplayTests(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(is_guest=True, device_id='device-refresh-test')
|
||||
self.user = User.objects.create(phone='09121230201')
|
||||
self.raw = 'test-refresh-token-abc123'
|
||||
self.session = Session.objects.create(
|
||||
user=self.user,
|
||||
refresh_token='test-refresh-token-abc123',
|
||||
device_id='device-refresh-test',
|
||||
refresh_token=hash_refresh_token(self.raw),
|
||||
expires_at=timezone.now() + timezone.timedelta(days=30),
|
||||
revoked=False,
|
||||
)
|
||||
|
||||
def test_valid_refresh_token_succeeds(self):
|
||||
response = self.client.post(
|
||||
'/api/auth/refresh', {'refreshToken': self.session.refresh_token}, format='json',
|
||||
'/api/auth/refresh', {'refreshToken': self.raw}, format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.data['token'])
|
||||
|
||||
def test_same_refresh_token_rejected_on_second_use(self):
|
||||
first_response = self.client.post(
|
||||
'/api/auth/refresh', {'refreshToken': self.session.refresh_token}, format='json',
|
||||
'/api/auth/refresh', {'refreshToken': self.raw}, format='json',
|
||||
)
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
second_response = self.client.post(
|
||||
'/api/auth/refresh', {'refreshToken': self.session.refresh_token}, format='json',
|
||||
'/api/auth/refresh', {'refreshToken': self.raw}, format='json',
|
||||
)
|
||||
self.assertEqual(second_response.status_code, 401)
|
||||
|
||||
@@ -318,15 +316,15 @@ class RefreshTokenReplayTests(TestCase):
|
||||
|
||||
def test_successful_refresh_returns_new_refresh_token(self):
|
||||
response = self.client.post(
|
||||
'/api/auth/refresh', {'refreshToken': self.session.refresh_token}, format='json',
|
||||
'/api/auth/refresh', {'refreshToken': self.raw}, format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
new_refresh_token = response.data['refreshToken']
|
||||
self.assertTrue(new_refresh_token)
|
||||
self.assertNotEqual(new_refresh_token, self.session.refresh_token)
|
||||
self.assertNotEqual(new_refresh_token, self.raw)
|
||||
self.assertTrue(
|
||||
Session.objects.filter(
|
||||
refresh_token=new_refresh_token, user=self.user, revoked=False,
|
||||
refresh_token=hash_refresh_token(new_refresh_token), user=self.user, revoked=False,
|
||||
).exists()
|
||||
)
|
||||
|
||||
@@ -344,16 +342,16 @@ class LoginPhoneRateLimitTests(TestCase):
|
||||
|
||||
@override_settings(DEBUG=True, OTP_LOGIN_PHONE_MAX_ATTEMPTS=3)
|
||||
def test_repeated_attempts_for_same_phone_are_blocked(self):
|
||||
phone = '+989123334444'
|
||||
phone = '09123334444'
|
||||
|
||||
for _ in range(3):
|
||||
response = self.client.post(
|
||||
'/api/auth/login', {'phone': phone, 'otp': 'wrong'}, format='json',
|
||||
'/api/auth/login', {'phone': phone, 'otp': '000000'}, format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
blocked_response = self.client.post(
|
||||
'/api/auth/login', {'phone': phone, 'otp': 'wrong'}, format='json',
|
||||
'/api/auth/login', {'phone': phone, 'otp': '000000'}, format='json',
|
||||
)
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
@@ -376,7 +374,7 @@ class RequestOTPPhoneRateLimitTests(TestCase):
|
||||
# (RequestOTPPhoneCooldownTests).
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_MAX_ATTEMPTS=3, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
||||
def test_repeated_requests_for_same_phone_are_eventually_blocked(self):
|
||||
phone = '+989127778888'
|
||||
phone = '09127778888'
|
||||
|
||||
# otp_request (IP-محور) پیشفرض 5/hour است؛ برای اینکه فقط
|
||||
# محدودیتِ شمارهتلفن رو بسنجیم، throttle IP-محور رو موقتاً باز
|
||||
@@ -396,7 +394,7 @@ class RequestOTPPhoneRateLimitTests(TestCase):
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_MAX_ATTEMPTS=3, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
||||
def test_changing_client_ip_does_not_bypass_the_phone_limit(self):
|
||||
phone = '+989127778889'
|
||||
phone = '09127778889'
|
||||
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'otp_request': '1000/hour'}):
|
||||
with patch('accounts.views.send_otp_sms'):
|
||||
@@ -418,7 +416,7 @@ class RequestOTPPhoneRateLimitTests(TestCase):
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_MAX_ATTEMPTS=1, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
||||
def test_throttled_request_creates_no_new_otp_and_sends_no_sms(self):
|
||||
phone = '+989127778890'
|
||||
phone = '09127778890'
|
||||
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'otp_request': '1000/hour'}):
|
||||
with patch('accounts.views.send_otp_sms') as mock_send:
|
||||
@@ -460,7 +458,7 @@ class RequestOTPPhoneRateLimitConcurrencyTests(TransactionTestCase):
|
||||
def test_two_simultaneous_requests_do_not_both_pass_the_phone_limit(self):
|
||||
import threading
|
||||
|
||||
phone = '+989127778891'
|
||||
phone = '09127778891'
|
||||
barrier = threading.Barrier(2)
|
||||
results = []
|
||||
results_lock = threading.Lock()
|
||||
@@ -504,7 +502,7 @@ class RequestOTPPhoneCooldownTests(TestCase):
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=60)
|
||||
def test_immediate_resend_for_same_phone_is_blocked_by_cooldown(self):
|
||||
phone = '+989127779001'
|
||||
phone = '09127779001'
|
||||
|
||||
with patch('accounts.views.send_otp_sms'):
|
||||
first_response = self.client.post(
|
||||
@@ -523,7 +521,7 @@ class RequestOTPPhoneCooldownTests(TestCase):
|
||||
# موفق بشه، بقیه باید با cooldown (429) بلاک بشن — و چون
|
||||
# درخواستهایِ بلاکشده توسطِ cooldown هیچوقت به شمارندهی ساعتی
|
||||
# نمیرسن، سهمیهی ساعتی مصرف نمیشه.
|
||||
phone = '+989127779002'
|
||||
phone = '09127779002'
|
||||
|
||||
# throttle IP-محورِ otp_request (پیشفرض 5/hour) رو موقتاً باز
|
||||
# میکنیم تا اینجا فقط cooldown/سهمیهی ساعتیِ per-phone سنجیده بشه،
|
||||
@@ -543,7 +541,11 @@ class RequestOTPPhoneCooldownTests(TestCase):
|
||||
|
||||
# سهمیهی ساعتی هنوز دستنخورده است: بعد از پایانِ cooldown،
|
||||
# همچنان میشه تا سقفِ مجاز درخواست داد.
|
||||
# گذشتِ زمان: هم کلیدِ کش و هم cooldownِ دیتابیسی (از expires_at).
|
||||
cache.delete(f'otp_request_phone_cooldown:{phone}')
|
||||
OTPCode.objects.filter(phone=phone).update(
|
||||
expires_at=timezone.now() + timezone.timedelta(seconds=settings.OTP_TTL_SECONDS - 61),
|
||||
)
|
||||
with patch('accounts.views.send_otp_sms') as mock_send_again:
|
||||
allowed_again = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': phone}, format='json',
|
||||
@@ -553,7 +555,7 @@ class RequestOTPPhoneCooldownTests(TestCase):
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=60)
|
||||
def test_cooldown_blocked_request_creates_no_otp_and_sends_no_sms(self):
|
||||
phone = '+989127779003'
|
||||
phone = '09127779003'
|
||||
|
||||
with patch('accounts.views.send_otp_sms') as mock_send:
|
||||
self.client.post('/api/auth/otp/request', {'phone': phone}, format='json')
|
||||
@@ -571,7 +573,7 @@ class RequestOTPPhoneCooldownTests(TestCase):
|
||||
def test_cooldown_expires_after_configured_window(self):
|
||||
import time
|
||||
|
||||
phone = '+989127779004'
|
||||
phone = '09127779004'
|
||||
|
||||
with patch('accounts.views.send_otp_sms'):
|
||||
first_response = self.client.post(
|
||||
@@ -590,10 +592,10 @@ class RequestOTPPhoneCooldownTests(TestCase):
|
||||
def test_cooldown_is_per_phone_not_global(self):
|
||||
with patch('accounts.views.send_otp_sms'):
|
||||
first_response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': '+989127779005'}, format='json',
|
||||
'/api/auth/otp/request', {'phone': '09127779005'}, format='json',
|
||||
)
|
||||
second_response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': '+989127779006'}, format='json',
|
||||
'/api/auth/otp/request', {'phone': '09127779006'}, format='json',
|
||||
)
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
self.assertEqual(second_response.status_code, 200)
|
||||
@@ -606,7 +608,7 @@ class RequestOTPPhoneCooldownTests(TestCase):
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=120)
|
||||
def test_cooldown_response_reports_retry_after_header_and_body(self):
|
||||
phone = '+989127779008'
|
||||
phone = '09127779008'
|
||||
|
||||
with patch('accounts.views.send_otp_sms'):
|
||||
first_response = self.client.post(
|
||||
@@ -635,7 +637,7 @@ class RequestOTPPhoneCooldownTests(TestCase):
|
||||
def test_retry_after_counts_down_as_time_passes(self):
|
||||
import time
|
||||
|
||||
phone = '+989127779009'
|
||||
phone = '09127779009'
|
||||
|
||||
with patch('accounts.views.send_otp_sms'):
|
||||
self.client.post('/api/auth/otp/request', {'phone': phone}, format='json')
|
||||
@@ -675,7 +677,7 @@ class RequestOTPPhoneCooldownConcurrencyTests(TransactionTestCase):
|
||||
def test_two_simultaneous_requests_for_same_phone_only_one_passes_cooldown(self):
|
||||
import threading
|
||||
|
||||
phone = '+989127779007'
|
||||
phone = '09127779007'
|
||||
barrier = threading.Barrier(2)
|
||||
results = []
|
||||
results_lock = threading.Lock()
|
||||
@@ -708,7 +710,7 @@ class RequestOTPPhoneCooldownConcurrencyTests(TransactionTestCase):
|
||||
# هیچ کدی دریافت نکرده بود ولی همچنان تا ۱۲۰ ثانیه بلاک میموند.
|
||||
# این تست تضمین میکنه که با شکستِ ارسال، cooldown پاک میشه و
|
||||
# کاربر میتونه فوراً دوباره تلاش کنه.
|
||||
phone = '+989127779010'
|
||||
phone = '09127779010'
|
||||
|
||||
with patch('accounts.views.send_otp_sms', side_effect=OTPDeliveryError('boom')):
|
||||
first_response = self.client.post(
|
||||
@@ -729,7 +731,7 @@ class RequestOTPPhoneCooldownConcurrencyTests(TransactionTestCase):
|
||||
def test_cooldown_still_active_after_successful_delivery(self):
|
||||
# اثباتِ عدمِ رگرسیون: وقتی ارسال موفق باشه، cooldown همچنان طبقِ
|
||||
# رفتارِ قبلی فعال میمونه (فقط شکستِ تحویل باید cooldown رو پاک کنه).
|
||||
phone = '+989127779011'
|
||||
phone = '09127779011'
|
||||
|
||||
with patch('accounts.views.send_otp_sms'):
|
||||
first_response = self.client.post(
|
||||
@@ -755,23 +757,25 @@ class RefreshTokenThrottleTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(is_guest=True, device_id='device-refresh-throttle-test')
|
||||
self.user = User.objects.create(phone='09121230202')
|
||||
|
||||
def _create_session(self, token_suffix):
|
||||
return Session.objects.create(
|
||||
raw = f'test-refresh-token-{token_suffix}'
|
||||
session = Session.objects.create(
|
||||
user=self.user,
|
||||
refresh_token=f'test-refresh-token-{token_suffix}',
|
||||
device_id='device-refresh-throttle-test',
|
||||
refresh_token=hash_refresh_token(raw),
|
||||
expires_at=timezone.now() + timezone.timedelta(days=30),
|
||||
revoked=False,
|
||||
)
|
||||
session.raw = raw
|
||||
return session
|
||||
|
||||
def test_requests_within_the_limit_still_succeed(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'token_refresh': '3/hour'}):
|
||||
for i in range(3):
|
||||
session = self._create_session(i)
|
||||
response = self.client.post(
|
||||
'/api/auth/refresh', {'refreshToken': session.refresh_token}, format='json',
|
||||
'/api/auth/refresh', {'refreshToken': session.raw}, format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@@ -780,13 +784,13 @@ class RefreshTokenThrottleTests(TestCase):
|
||||
for i in range(3):
|
||||
session = self._create_session(i)
|
||||
response = self.client.post(
|
||||
'/api/auth/refresh', {'refreshToken': session.refresh_token}, format='json',
|
||||
'/api/auth/refresh', {'refreshToken': session.raw}, format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
extra_session = self._create_session('extra')
|
||||
blocked_response = self.client.post(
|
||||
'/api/auth/refresh', {'refreshToken': extra_session.refresh_token}, format='json',
|
||||
'/api/auth/refresh', {'refreshToken': extra_session.raw}, format='json',
|
||||
)
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
@@ -794,7 +798,7 @@ class RefreshTokenThrottleTests(TestCase):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'token_refresh': '1/hour'}):
|
||||
first_session = self._create_session('first')
|
||||
first_response = self.client.post(
|
||||
'/api/auth/refresh', {'refreshToken': first_session.refresh_token}, format='json',
|
||||
'/api/auth/refresh', {'refreshToken': first_session.raw}, format='json',
|
||||
)
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
@@ -803,7 +807,7 @@ class RefreshTokenThrottleTests(TestCase):
|
||||
|
||||
with patch('accounts.views.issue_access_token') as mock_issue_token:
|
||||
blocked_response = self.client.post(
|
||||
'/api/auth/refresh', {'refreshToken': blocked_session.refresh_token}, format='json',
|
||||
'/api/auth/refresh', {'refreshToken': blocked_session.raw}, format='json',
|
||||
)
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
mock_issue_token.assert_not_called()
|
||||
@@ -816,51 +820,6 @@ class RefreshTokenThrottleTests(TestCase):
|
||||
self.assertFalse(blocked_session.revoked)
|
||||
|
||||
|
||||
class GuestSessionThrottleTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات محدودیتِ جدیدِ ScopedRateThrottle (scope='guest_session') روی
|
||||
GuestSessionView: درخواستهای تکراری از همون کلاینت (IP) باید بعد از
|
||||
حدِ مجاز با ۴۲۹ رد بشن.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
cache.clear()
|
||||
|
||||
@override_settings(
|
||||
REST_FRAMEWORK={
|
||||
**settings.REST_FRAMEWORK,
|
||||
'DEFAULT_THROTTLE_RATES': {
|
||||
**settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'],
|
||||
'guest_session': '3/hour',
|
||||
},
|
||||
}
|
||||
)
|
||||
def test_repeated_guest_session_requests_are_eventually_throttled(self):
|
||||
# override_settings(REST_FRAMEWORK=...) بهتنهایی رفتارِ
|
||||
# ScopedRateThrottle رو تغییر نمیده: DRF مقدارِ THROTTLE_RATES رو
|
||||
# یکبار موقعِ import، بهعنوانِ class attribute، از دیکشنریِ اصلیِ
|
||||
# settings.py میگیره؛ override_settings دیکشنریِ settings رو عوض
|
||||
# میکنه ولی این class attribute هنوز به نسخهی قدیمی اشاره میکنه.
|
||||
# برای سنجیدنِ نرخِ واقعی، مستقیماً patch.dict روی همون دیکشنریِ
|
||||
# زندهی ScopedRateThrottle.THROTTLE_RATES لازمه.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'guest_session': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = self.client.post(
|
||||
'/api/auth/session',
|
||||
{'type': 'guest', 'deviceId': f'device-{i}'},
|
||||
format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
blocked_response = self.client.post(
|
||||
'/api/auth/session',
|
||||
{'type': 'guest', 'deviceId': 'device-blocked'},
|
||||
format='json',
|
||||
)
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
|
||||
class UserProfileViewTests(TestCase):
|
||||
"""تستهای GET /api/user/profile — پروفایل کاربر جاری."""
|
||||
|
||||
@@ -877,8 +836,8 @@ class UserProfileViewTests(TestCase):
|
||||
response = self._get_profile()
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_returns_profile_for_guest_without_existing_profile_row(self):
|
||||
user = User.objects.create(device_id='device-profile-1', is_guest=True, name='')
|
||||
def test_returns_profile_for_user_without_existing_profile_row(self):
|
||||
user = User.objects.create(phone='09000000055', name='')
|
||||
token = issue_access_token(user.id)
|
||||
|
||||
response = self._get_profile(token)
|
||||
@@ -886,39 +845,36 @@ class UserProfileViewTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertEqual(data['userId'], str(user.id))
|
||||
self.assertEqual(data['isGuest'], True)
|
||||
self.assertIsNone(data['phone'])
|
||||
self.assertEqual(data['phone'], '09000000055')
|
||||
self.assertIsNone(data['gradeId'])
|
||||
self.assertIsNone(data['birthYear'])
|
||||
self.assertEqual(data['parentPhone'], '')
|
||||
self.assertIsNone(data['birthDate'])
|
||||
self.assertEqual(data['bio'], '')
|
||||
# get_or_create باید یک رکورد Profile واقعی ساخته باشه.
|
||||
self.assertTrue(Profile.objects.filter(user_id=user.id).exists())
|
||||
|
||||
def test_returns_existing_profile_fields_for_registered_user(self):
|
||||
user = User.objects.create(
|
||||
phone='+989120000001', is_guest=False, name='علی', avatar='avatar.png', grade_id=2,
|
||||
phone='09120000001', name='علی', avatar='avatar.png', grade_id=2,
|
||||
)
|
||||
Profile.objects.create(user=user, birth_year=2005, parent_phone='+989120000002', bio='سلام')
|
||||
Profile.objects.create(user=user, birth_year=1384, bio='سلام')
|
||||
token = issue_access_token(user.id)
|
||||
|
||||
response = self._get_profile(token)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertEqual(data['phone'], '+989120000001')
|
||||
self.assertEqual(data['phone'], '09120000001')
|
||||
self.assertEqual(data['name'], 'علی')
|
||||
self.assertEqual(data['avatar'], 'avatar.png')
|
||||
self.assertEqual(data['isGuest'], False)
|
||||
self.assertEqual(data['gradeId'], 2)
|
||||
self.assertEqual(data['birthYear'], 2005)
|
||||
self.assertEqual(data['parentPhone'], '+989120000002')
|
||||
self.assertEqual(data['birthYear'], 1384)
|
||||
self.assertEqual(data['bio'], 'سلام')
|
||||
|
||||
def test_does_not_leak_other_users_profile(self):
|
||||
other = User.objects.create(device_id='device-other', is_guest=True, name='')
|
||||
other = User.objects.create(phone='09000000056', name='')
|
||||
Profile.objects.create(user=other, bio='محرمانه')
|
||||
user = User.objects.create(device_id='device-profile-2', is_guest=True, name='')
|
||||
user = User.objects.create(phone='09000000057', name='')
|
||||
token = issue_access_token(user.id)
|
||||
|
||||
response = self._get_profile(token)
|
||||
@@ -948,13 +904,13 @@ class SendSmsAdminActionTests(TestCase):
|
||||
# inactive/never-active موردنظرِ خودشون رو صریحاً میسازن.
|
||||
now = timezone.now()
|
||||
self.user1 = User.objects.create(
|
||||
name='u1', is_guest=False, phone='0910000001', last_activity_at=now,
|
||||
name='u1', phone='0910000001', last_activity_at=now,
|
||||
)
|
||||
self.user2 = User.objects.create(
|
||||
name='u2', is_guest=False, phone='0910000002', last_activity_at=now,
|
||||
name='u2', phone='0910000002', last_activity_at=now,
|
||||
)
|
||||
self.guest_no_phone = User.objects.create(
|
||||
name='guest', is_guest=True, phone=None, last_activity_at=now,
|
||||
self.no_phone_user = User.objects.create(
|
||||
name='no-phone', phone=None, last_activity_at=now,
|
||||
)
|
||||
self.changelist_url = reverse('admin:accounts_user_changelist')
|
||||
|
||||
@@ -979,10 +935,10 @@ class SendSmsAdminActionTests(TestCase):
|
||||
mock_send_sms.assert_not_called()
|
||||
|
||||
@patch('accounts.admin.send_sms')
|
||||
def test_apply_sends_sms_to_users_with_phone_and_skips_guest(self, mock_send_sms):
|
||||
def test_apply_sends_sms_to_users_with_phone_and_skips_users_without(self, mock_send_sms):
|
||||
response = self.client.post(self.changelist_url, {
|
||||
'action': 'send_sms_to_selected',
|
||||
'_selected_action': [self.user1.pk, self.user2.pk, self.guest_no_phone.pk],
|
||||
'_selected_action': [self.user1.pk, self.user2.pk, self.no_phone_user.pk],
|
||||
'apply': 'ارسال پیامک',
|
||||
'message': 'سلام از طرف ادمین',
|
||||
})
|
||||
@@ -1046,14 +1002,14 @@ class SendSmsAdminActionTests(TestCase):
|
||||
"""
|
||||
now = timezone.now()
|
||||
active_user = User.objects.create(
|
||||
name='active', is_guest=False, phone='0910000009', last_activity_at=now,
|
||||
name='active', phone='0910000009', last_activity_at=now,
|
||||
)
|
||||
inactive_user = User.objects.create(
|
||||
name='inactive', is_guest=False, phone='0910000010',
|
||||
name='inactive', phone='0910000010',
|
||||
last_activity_at=now - timezone.timedelta(days=90),
|
||||
)
|
||||
never_active_user = User.objects.create(
|
||||
name='never-active', is_guest=False, phone='0910000011', last_activity_at=None,
|
||||
name='never-active', phone='0910000011', last_activity_at=None,
|
||||
)
|
||||
cutoff_date = (now - timezone.timedelta(days=30)).date()
|
||||
|
||||
@@ -1101,7 +1057,7 @@ class SendSmsAdminActionTests(TestCase):
|
||||
'inactive' هم مثلِ 'selected' حفظ بشه.
|
||||
"""
|
||||
User.objects.create(
|
||||
name='inactive-no-phone', is_guest=True, phone=None,
|
||||
name='inactive-no-phone', phone=None,
|
||||
last_activity_at=timezone.now() - timezone.timedelta(days=90),
|
||||
)
|
||||
cutoff_date = (timezone.now() - timezone.timedelta(days=30)).date()
|
||||
@@ -1131,7 +1087,7 @@ class SendSmsAdminActionTests(TestCase):
|
||||
"""
|
||||
response = self.client.post(self.changelist_url, {
|
||||
'action': 'send_sms_to_selected',
|
||||
'_selected_action': [self.user1.pk, self.user2.pk, self.guest_no_phone.pk],
|
||||
'_selected_action': [self.user1.pk, self.user2.pk, self.no_phone_user.pk],
|
||||
'apply': 'ارسال پیامک',
|
||||
'message': 'متن',
|
||||
'target': 'selected',
|
||||
@@ -1153,11 +1109,11 @@ class SendSmsAdminActionTests(TestCase):
|
||||
"""
|
||||
now = timezone.now()
|
||||
User.objects.create(
|
||||
name='inactive-1', is_guest=False, phone='0910000021',
|
||||
name='inactive-1', phone='0910000021',
|
||||
last_activity_at=now - timezone.timedelta(days=90),
|
||||
)
|
||||
User.objects.create(
|
||||
name='inactive-2', is_guest=False, phone='0910000022',
|
||||
name='inactive-2', phone='0910000022',
|
||||
last_activity_at=now - timezone.timedelta(days=90),
|
||||
)
|
||||
cutoff_date = (now - timezone.timedelta(days=30)).date()
|
||||
@@ -1201,7 +1157,7 @@ class SendSmsAdminActionTests(TestCase):
|
||||
"""
|
||||
response = self.client.post(self.changelist_url, {
|
||||
'action': 'send_sms_to_selected',
|
||||
'_selected_action': [self.user1.pk, self.guest_no_phone.pk],
|
||||
'_selected_action': [self.user1.pk, self.no_phone_user.pk],
|
||||
'apply': 'ارسال پیامک',
|
||||
'message': 'متن',
|
||||
'target': 'selected',
|
||||
@@ -1242,7 +1198,7 @@ class SupervisorGroupAccessTests(TestCase):
|
||||
is_staff=True,
|
||||
)
|
||||
|
||||
self.target_user = User.objects.create(name='target', is_guest=False, phone='0910000099')
|
||||
self.target_user = User.objects.create(name='target', phone='0910000099')
|
||||
self.plan = Plan.objects.create(title='یکماهه', price=10000)
|
||||
self.order = Order.objects.create(
|
||||
user_id=self.target_user.id, amount=10000, idempotency_key='sup-order-1',
|
||||
@@ -1349,7 +1305,7 @@ class SupervisorGroupAccessTests(TestCase):
|
||||
original_name = self.target_user.name
|
||||
response = self.client.post(
|
||||
reverse('admin:accounts_user_change', args=[self.target_user.pk]),
|
||||
{'name': 'دستکاریشده', 'is_guest': 'on'},
|
||||
{'name': 'دستکاریشده'},
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.target_user.refresh_from_db()
|
||||
@@ -1438,7 +1394,7 @@ class AuthUserRowCacheTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='dev-authcache', is_guest=True, name='n')
|
||||
self.user = User.objects.create(phone='09000000058', name='n')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
# هر endpointِ authenticated؛ /api/user/hearts سبک است.
|
||||
self.url = '/api/user/hearts'
|
||||
@@ -1470,6 +1426,8 @@ class AuthUserRowCacheTests(TestCase):
|
||||
|
||||
@override_settings(AUTH_USER_CACHE_TTL=30)
|
||||
def test_set_grade_still_works_with_cache_on(self):
|
||||
from content.models import Grade
|
||||
Grade.objects.create(pk=3, title='g3', icon='', color='')
|
||||
r = self.client.post(
|
||||
'/api/user/grade', {'gradeId': 3}, format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {self.token}')
|
||||
@@ -1506,7 +1464,7 @@ class UpdateUserProfileTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
cache.clear()
|
||||
self.user = User.objects.create(device_id='device-patch-1', is_guest=True, name='')
|
||||
self.user = User.objects.create(phone='09000000059', name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
|
||||
def tearDown(self):
|
||||
@@ -1550,13 +1508,150 @@ class UpdateUserProfileTests(TestCase):
|
||||
def test_empty_body_is_rejected(self):
|
||||
self.assertEqual(self._patch({}).status_code, 400)
|
||||
|
||||
def test_phone_and_guest_flag_cannot_be_changed(self):
|
||||
response = self._patch({'name': 'رضا', 'phone': '09120000000', 'isGuest': False})
|
||||
def test_phone_cannot_be_changed(self):
|
||||
response = self._patch({'name': 'رضا', 'phone': '09120000000'})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.user.refresh_from_db()
|
||||
self.assertIsNone(self.user.phone)
|
||||
self.assertTrue(self.user.is_guest)
|
||||
self.assertNotEqual(self.user.phone, '09120000000')
|
||||
|
||||
def test_out_of_range_birth_year_is_rejected(self):
|
||||
self.assertEqual(self._patch({'birthYear': 2026}).status_code, 400)
|
||||
|
||||
|
||||
class TestLoginPhoneTests(TestCase):
|
||||
"""
|
||||
🔒 شمارههای تستِ تیم (TEST_LOGIN_PHONES): باید ورودِ بدونِ پیامک را برای
|
||||
همان چند شماره ممکن کنند و برای هیچ شمارهی دیگری نه. این تستها با
|
||||
DEBUG=False اجرا میشوند چون دقیقاً همان حالتی است که روی سرور هست و
|
||||
DEV_MASTER_OTP در آن خاموش است.
|
||||
"""
|
||||
|
||||
PHONE = '09120000001'
|
||||
CODE = '834512'
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
cache.clear()
|
||||
|
||||
@override_settings(DEBUG=False, DEV_MASTER_OTP=None,
|
||||
TEST_LOGIN_PHONES={PHONE: CODE})
|
||||
def test_configured_phone_logs_in_without_sms(self):
|
||||
with patch('accounts.views.send_otp_sms') as mock_send:
|
||||
request = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': self.PHONE}, format='json',
|
||||
)
|
||||
self.assertEqual(request.status_code, 200)
|
||||
# مهمترین نکتهی این مسیر: هیچ پیامکی نباید فرستاده شود، وگرنه بدونِ
|
||||
# کاوهنگار همینجا ۵۰۳ میگرفتیم و کل هدف از بین میرفت.
|
||||
mock_send.assert_not_called()
|
||||
self.assertEqual(request.data['devOtp'], '')
|
||||
|
||||
login = self.client.post(
|
||||
'/api/auth/login', {'phone': self.PHONE, 'otp': self.CODE}, format='json',
|
||||
)
|
||||
self.assertEqual(login.status_code, 200)
|
||||
self.assertTrue(login.data['token'])
|
||||
|
||||
@override_settings(DEBUG=False, DEV_MASTER_OTP=None,
|
||||
TEST_LOGIN_PHONES={PHONE: CODE})
|
||||
def test_code_is_never_returned_in_the_response(self):
|
||||
response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': self.PHONE}, format='json',
|
||||
)
|
||||
self.assertNotIn(self.CODE, str(response.data))
|
||||
|
||||
@override_settings(DEBUG=False, DEV_MASTER_OTP=None,
|
||||
TEST_LOGIN_PHONES={PHONE: CODE})
|
||||
def test_wrong_code_for_a_configured_phone_is_rejected(self):
|
||||
response = self.client.post(
|
||||
'/api/auth/login', {'phone': self.PHONE, 'otp': '000000'}, format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
@override_settings(DEBUG=False, DEV_MASTER_OTP=None,
|
||||
TEST_LOGIN_PHONES={PHONE: CODE})
|
||||
def test_the_code_does_not_work_for_any_other_phone(self):
|
||||
"""تفاوتِ اصلی با DEV_MASTER_OTP: کد فقط مالِ همان یک شماره است."""
|
||||
response = self.client.post(
|
||||
'/api/auth/login', {'phone': '09129999999', 'otp': self.CODE}, format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
@override_settings(DEBUG=False, DEV_MASTER_OTP=None, TEST_LOGIN_PHONES={})
|
||||
def test_empty_configuration_changes_nothing(self):
|
||||
"""بدونِ تنظیم، مسیرِ OTP دقیقاً همان رفتارِ قبلی را دارد."""
|
||||
with patch('accounts.views.send_otp_sms') as mock_send:
|
||||
response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': '09121112233'}, format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
mock_send.assert_called_once()
|
||||
|
||||
@override_settings(DEBUG=False, DEV_MASTER_OTP=None,
|
||||
TEST_LOGIN_PHONES={PHONE: CODE},
|
||||
OTP_LOGIN_PHONE_MAX_ATTEMPTS=3)
|
||||
def test_brute_force_on_a_configured_phone_is_still_rate_limited(self):
|
||||
for _ in range(3):
|
||||
self.client.post(
|
||||
'/api/auth/login', {'phone': self.PHONE, 'otp': '111111'}, format='json',
|
||||
)
|
||||
blocked = self.client.post(
|
||||
'/api/auth/login', {'phone': self.PHONE, 'otp': '111111'}, format='json',
|
||||
)
|
||||
self.assertEqual(blocked.status_code, 429)
|
||||
|
||||
|
||||
class TestLoginPhoneParsingTests(TestCase):
|
||||
"""ورودیِ خرابِ TEST_LOGIN_PHONES باید سرویس را بالا نیاورد، نه اینکه بیصدا رد شود."""
|
||||
|
||||
def _parse(self, raw):
|
||||
from config.settings import _parse_test_login_phones
|
||||
return _parse_test_login_phones(raw)
|
||||
|
||||
def test_valid_pairs_are_parsed(self):
|
||||
self.assertEqual(
|
||||
self._parse(' 09120000001:834512 , +989120000002:110947 '),
|
||||
{'09120000001': '834512', '09120000002': '110947'},
|
||||
)
|
||||
|
||||
def test_empty_value_yields_no_test_phones(self):
|
||||
self.assertEqual(self._parse(''), {})
|
||||
|
||||
def test_malformed_entry_raises(self):
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
for raw in ('09120000001', '09120000001:12', 'abc:123456',
|
||||
'09120000001:abcdef', '09120000001:1234567'):
|
||||
with self.subTest(raw=raw), self.assertRaises(ImproperlyConfigured):
|
||||
self._parse(raw)
|
||||
|
||||
|
||||
class RefreshTokenStoredHashedTests(TestCase):
|
||||
"""دیتابیس، بکاپ یا پنلِ ادمین نباید خودِ توکنِ تمدید را داشته باشد؛ فقط هشش."""
|
||||
|
||||
def test_issued_tokens_are_stored_only_as_hash(self):
|
||||
client = APIClient()
|
||||
user = User.objects.create(phone='09121230203')
|
||||
raw = 'raw-token-for-hash-test'
|
||||
Session.objects.create(user=user, refresh_token=hash_refresh_token(raw),
|
||||
expires_at=timezone.now() + timezone.timedelta(days=1))
|
||||
self.assertFalse(Session.objects.filter(refresh_token=raw).exists())
|
||||
self.assertFalse(Session.objects.filter(refresh_token=raw).exists())
|
||||
self.assertTrue(Session.objects.filter(refresh_token=hash_refresh_token(raw)).exists())
|
||||
|
||||
renewed = client.post('/api/auth/refresh', {'refreshToken': raw}, format='json').json()['refreshToken']
|
||||
self.assertFalse(Session.objects.filter(refresh_token=renewed).exists())
|
||||
self.assertTrue(Session.objects.filter(refresh_token=hash_refresh_token(renewed), revoked=False).exists())
|
||||
|
||||
def test_migration_hashes_existing_rows(self):
|
||||
from importlib import import_module
|
||||
|
||||
from django.apps import apps as global_apps
|
||||
|
||||
user = User.objects.create(phone='09121230204')
|
||||
Session.objects.create(user=user, refresh_token='old-raw-token',
|
||||
expires_at=timezone.now() + timezone.timedelta(days=1))
|
||||
import_module('accounts.migrations.0007_hash_refresh_tokens').hash_existing(global_apps, None)
|
||||
self.assertEqual(Session.objects.get(user=user).refresh_token, hash_refresh_token('old-raw-token'))
|
||||
response = APIClient().post('/api/auth/refresh', {'refreshToken': 'old-raw-token'}, format='json')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
121
accounts/tests_audit_fixes.py
Normal file
121
accounts/tests_audit_fixes.py
Normal 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)
|
||||
104
accounts/tests_birth_date.py
Normal file
104
accounts/tests_birth_date.py
Normal 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'))
|
||||
60
accounts/tests_delete_account.py
Normal file
60
accounts/tests_delete_account.py
Normal 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())
|
||||
75
accounts/tests_delete_users.py
Normal file
75
accounts/tests_delete_users.py
Normal 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)
|
||||
55
accounts/tests_login_events.py
Normal file
55
accounts/tests_login_events.py
Normal 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'})
|
||||
196
accounts/tests_otp_limits.py
Normal file
196
accounts/tests_otp_limits.py
Normal 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))
|
||||
241
accounts/tests_security_review.py
Normal file
241
accounts/tests_security_review.py
Normal 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)
|
||||
46
accounts/tests_user_activity.py
Normal file
46
accounts/tests_user_activity.py
Normal 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)
|
||||
@@ -1,8 +1,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import (
|
||||
GuestSessionView,
|
||||
DeleteAccountView,
|
||||
LoginView,
|
||||
LogoutView,
|
||||
RefreshTokenView,
|
||||
RequestOTPView,
|
||||
SetUserGradeView,
|
||||
@@ -10,10 +11,11 @@ from .views import (
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path('auth/session', GuestSessionView.as_view(), name='auth-session'),
|
||||
path('auth/otp/request', RequestOTPView.as_view(), name='auth-otp-request'),
|
||||
path('auth/login', LoginView.as_view(), name='auth-login'),
|
||||
path('auth/refresh', RefreshTokenView.as_view(), name='auth-refresh'),
|
||||
path('auth/logout', LogoutView.as_view(), name='auth-logout'),
|
||||
path('user/grade', SetUserGradeView.as_view(), name='user-grade'),
|
||||
path('user/profile', UserProfileView.as_view(), name='user-profile'),
|
||||
path('user/delete', DeleteAccountView.as_view(), name='user-delete'),
|
||||
]
|
||||
|
||||
@@ -6,22 +6,27 @@ import uuid
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db import transaction
|
||||
from django.db.models import F
|
||||
from django.utils import timezone
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.throttling import BaseThrottle, ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from .authentication import issue_access_token
|
||||
from .models import OTPCode, Profile, Session, User
|
||||
from config.db import release_db_connection
|
||||
|
||||
from .authentication import invalidate_user_cache, issue_access_token
|
||||
from .models import LoginEvent, OTPCode, Profile, Session, User, hash_refresh_token
|
||||
from .services import OTPDeliveryError, send_otp_sms
|
||||
from .serializers import (
|
||||
GuestSessionRequestSerializer,
|
||||
GuestSessionResponseSerializer,
|
||||
DeleteAccountRequestSerializer,
|
||||
DeleteAccountResponseSerializer,
|
||||
LoginRequestSerializer,
|
||||
LoginResponseSerializer,
|
||||
LogoutRequestSerializer,
|
||||
LogoutResponseSerializer,
|
||||
ProfileResponseSerializer,
|
||||
RefreshRequestSerializer,
|
||||
RefreshResponseSerializer,
|
||||
@@ -35,73 +40,28 @@ from .serializers import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GuestAwareAccountThrottle(ScopedRateThrottle):
|
||||
def record_login_event(request, event: str, *, user_id=None, phone: str = '') -> None:
|
||||
"""
|
||||
⚠️ اضافه شده: همون الگویِ GuestAwareContentThrottle (content/views.py)،
|
||||
GuestAwareAIThrottle (ai_tutor/views.py) و بقیه؛ چون GuestSessionView
|
||||
با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) میسازه، یک
|
||||
ScopedRateThrottleِ سادهٔ per-user توسطِ چرخشِ حساب مهمان دورزدنی
|
||||
بود. اینجا برای SetUserGradeView (که مهمانها هم میتونن صداش بزنن)
|
||||
از همین کلید بر اساسِ IP برای مهمانها استفاده میشه؛ رفتارِ
|
||||
throttle برای کاربرهای غیرمهمان (per-user، مثلِ قبل) دستنخورده
|
||||
میمونه.
|
||||
ردیفِ LoginEvent مینویسد. شکستش ورود/خروج را خراب نمیکند (ثبتِ سابقه نباید کاربر را بیرون
|
||||
بیندازد) ولی با logger.exception دیده میشود. زیرِ بارِ همزمان و روی Postgres سنجیده نشده.
|
||||
"""
|
||||
|
||||
def get_cache_key(self, request, view):
|
||||
user = request.user
|
||||
if user and user.is_authenticated and getattr(user, 'is_guest', False):
|
||||
ident = f'guest-ip:{self.get_ident(request)}'
|
||||
elif user and user.is_authenticated:
|
||||
ident = user.pk
|
||||
else:
|
||||
ident = self.get_ident(request)
|
||||
|
||||
return self.cache_format % {'scope': self.scope, 'ident': ident}
|
||||
|
||||
|
||||
class GuestSessionView(APIView):
|
||||
"""POST /api/auth/session — ایجاد سشن مهمان"""
|
||||
|
||||
throttle_scope = 'guest_session'
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
|
||||
@extend_schema(
|
||||
request=GuestSessionRequestSerializer,
|
||||
responses=GuestSessionResponseSerializer,
|
||||
tags=['Identity & Profile (accounts)'],
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = GuestSessionRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
device_id = serializer.validated_data['deviceId']
|
||||
|
||||
user, is_new_user = User.objects.get_or_create(
|
||||
device_id=device_id,
|
||||
is_guest=True,
|
||||
defaults={'name': ''},
|
||||
try:
|
||||
LoginEvent.objects.create(
|
||||
event=event, user_id=user_id, phone=phone,
|
||||
ip=BaseThrottle().get_ident(request) or None,
|
||||
user_agent=request.META.get('HTTP_USER_AGENT', '')[:300],
|
||||
)
|
||||
|
||||
# ثبتِ فعالیتِ کاربر: ایجاد/استفادهی سشنِ مهمان یک سشنِ واقعیِ
|
||||
# کاربره، پس last_activity_at بهروزرسانی میشه (بدونِ تأثیر روی
|
||||
# سایرِ رفتارِ endpoint).
|
||||
user.last_activity_at = timezone.now()
|
||||
user.save(update_fields=['last_activity_at'])
|
||||
|
||||
token = issue_access_token(user.id)
|
||||
|
||||
data = {
|
||||
'userId': str(user.id),
|
||||
'token': token,
|
||||
'isNewUser': is_new_user,
|
||||
'isGuest': True,
|
||||
}
|
||||
return Response(GuestSessionResponseSerializer(data).data)
|
||||
except Exception:
|
||||
logger.exception('LoginEvent نوشته نشد: event=%s user_id=%s', event, user_id)
|
||||
|
||||
|
||||
class RequestOTPView(APIView):
|
||||
"""
|
||||
POST /api/auth/otp/request — درخواست کد OTP (کمکی، برای تست از طریق Swagger)
|
||||
در محیط توسعه، کد ساختهشده در پاسخ برگردونده میشه تا نیازی به SMS واقعی نباشه.
|
||||
POST /api/auth/otp/request — درخواستِ کدِ ورود.
|
||||
|
||||
کد فقط با پیامک تحویل داده میشود. دو استثنا: در حالتِ DEBUG کد در فیلدِ
|
||||
devOtp برمیگردد تا تستِ محلی به پیامکِ واقعی نیاز نداشته باشد، و برای
|
||||
شمارههای TEST_LOGIN_PHONES کدِ ثابتِ از پیش تعریفشده معتبر است.
|
||||
"""
|
||||
|
||||
throttle_scope = 'otp_request'
|
||||
@@ -117,6 +77,22 @@ class RequestOTPView(APIView):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
phone = serializer.validated_data['phone']
|
||||
|
||||
# شمارهی تستِ تیم: کد ثابت و از قبل معلوم است، پس نه ردیفِ OTP تازهای
|
||||
# لازم است نه پیامکی. cooldown و سقفِ ساعتی هم رد میشوند چون هر دو از
|
||||
# گیرندهی واقعیِ پیامک محافظت میکنند و اینجا پیامکی در کار نیست.
|
||||
#
|
||||
# ⚠️ کد عمداً در پاسخ برنمیگردد، برخلافِ devOtp در حالتِ DEBUG: این
|
||||
# مسیر روی سرورِ عمومی هم فعال است، پس برگرداندنش یعنی هر کسی که شماره
|
||||
# را بداند میتواند وارد شود.
|
||||
if phone in settings.TEST_LOGIN_PHONES:
|
||||
logger.info('OTP request for configured test phone: phone_suffix=%s', phone[-4:])
|
||||
data = {
|
||||
'success': True,
|
||||
'devOtp': '',
|
||||
'note': 'این شمارهی تستِ تیم است؛ کدِ ثابتش در تنظیماتِ سرور تعریف شده و پیامکی ارسال نمیشود.',
|
||||
}
|
||||
return Response(RequestOTPResponseSerializer(data).data)
|
||||
|
||||
# حداقل فاصلهی زمانی بین دو درخواستِ همون شماره (burst protection)؛
|
||||
# قبل از شمارندهی ساعتی چک میشه تا یک درخواستِ بلاکشده سهمیهی
|
||||
# ساعتی رو مصرف نکنه. کلید مقدارِ epoch انقضا رو ذخیره میکنه تا
|
||||
@@ -151,6 +127,8 @@ class RequestOTPView(APIView):
|
||||
# محدودیتِ صدورِ OTP بر اساسِ شماره، مستقل از throttleِ IP-محورِ بالا
|
||||
# (وگرنه یه مهاجم میتونه بینِ چند IP پخش کنه). cache.add فقط وقتی
|
||||
# کلید نیست مقدار میذاره، پس اتمیکه.
|
||||
# شماره پیش از اینجا به 09XXXXXXXXX نرمال شده (accounts/phone.py)، پس
|
||||
# املای دیگرِ همان شماره سهمِ جدا نمیگیرد.
|
||||
phone_requests_key = f'otp_request_phone_attempts:{phone}'
|
||||
if cache.add(phone_requests_key, 1, timeout=settings.OTP_REQUEST_PHONE_WINDOW_SECONDS):
|
||||
attempt_count = 1
|
||||
@@ -172,23 +150,65 @@ class RequestOTPView(APIView):
|
||||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
)
|
||||
|
||||
# فقط آخرین کد صادرشده معتبر است.
|
||||
OTPCode.objects.filter(phone=phone, is_used=False).update(is_used=True)
|
||||
|
||||
code = f'{secrets.randbelow(10000):04d}'
|
||||
OTPCode.objects.create(
|
||||
phone=phone,
|
||||
code=code,
|
||||
expires_at=timezone.now() + timezone.timedelta(minutes=5),
|
||||
)
|
||||
|
||||
# سقفهای دیتابیسی: cooldown و سقفِ ساعتیِ بالا در کشِ LocMem و مالِ هر
|
||||
# workerاند (روی سرور ×۳). اینها از ردیفهای OTPCode شمرده میشوند و بینِ
|
||||
# workerها مشترکاند. زمانِ صدور = expires_at − OTP_TTL_SECONDS (مدل
|
||||
# created_at ندارد). بررسی و ساختِ ردیف در یک تراکنشاند؛ با
|
||||
# transaction_mode=IMMEDIATE دو درخواستِ همزمان پشتِ هم اجرا میشوند
|
||||
# (همزمانیِ بینِ دو workerِ واقعی با تست پوشش داده نشده).
|
||||
# فراخوانیِ کاوهنگار عمداً بیرونِ تراکنش است تا قفلِ نوشتن حینِ شبکه نماند.
|
||||
now = timezone.now()
|
||||
ttl = timezone.timedelta(seconds=settings.OTP_TTL_SECONDS)
|
||||
day_window_expiry = now - timezone.timedelta(days=1) + ttl
|
||||
with transaction.atomic():
|
||||
phone_codes = OTPCode.objects.filter(phone=phone)
|
||||
last_expiry = phone_codes.order_by('-expires_at').values_list('expires_at', flat=True).first()
|
||||
if cooldown_seconds and last_expiry is not None:
|
||||
elapsed = (now - (last_expiry - ttl)).total_seconds()
|
||||
if elapsed < cooldown_seconds:
|
||||
retry_after = max(1, math.ceil(cooldown_seconds - elapsed))
|
||||
return Response(
|
||||
{
|
||||
'detail': f'لطفاً {retry_after} ثانیه صبر کنید و دوباره درخواست کد بدهید.',
|
||||
'retryAfter': retry_after,
|
||||
},
|
||||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
headers={'Retry-After': str(retry_after)},
|
||||
)
|
||||
if phone_codes.filter(expires_at__gt=day_window_expiry).count() >= settings.OTP_REQUEST_PHONE_DAILY_MAX:
|
||||
logger.warning('OTP request phone daily limit exceeded: phone_suffix=%s', phone[-4:])
|
||||
return Response(
|
||||
{'detail': 'سقفِ روزانهی درخواستِ کد برای این شماره پر شده است؛ فردا دوباره تلاش کنید.'},
|
||||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
)
|
||||
if OTPCode.objects.filter(expires_at__gt=day_window_expiry).count() >= settings.OTP_SMS_GLOBAL_DAILY_MAX:
|
||||
# یا حملهی SMS pumping است یا رشدِ واقعی؛ هر دو یعنی کسی باید نگاه کند.
|
||||
logger.error(
|
||||
'OTP global daily SMS cap reached: cap=%s phone_suffix=%s',
|
||||
settings.OTP_SMS_GLOBAL_DAILY_MAX, phone[-4:],
|
||||
)
|
||||
return Response(
|
||||
{'detail': 'ارسالِ پیامک موقتاً ممکن نیست؛ لطفاً کمی بعد دوباره تلاش کنید.'},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
# فقط آخرین کد صادرشده معتبر است.
|
||||
OTPCode.objects.filter(phone=phone, is_used=False).update(is_used=True)
|
||||
# ۶ رقم: با ۴ رقم و محدودیتِ تلاشِ per-worker، گرفتنِ حسابِ یک نفرِ مشخص
|
||||
# روزی چند درصد شانس داشت. فرانت تا ۶ رقم را میپذیرد.
|
||||
code = f'{secrets.randbelow(1_000_000):06d}'
|
||||
otp_row = OTPCode.objects.create(phone=phone, code=code, expires_at=now + ttl)
|
||||
# کاوهنگار تا چند ثانیه طول میکشد؛ روزِ لانچ این پرترافیکترین مسیر است و اتصالِ دیتابیس
|
||||
# در این مدت به pool برمیگردد (config/db.py).
|
||||
release_db_connection()
|
||||
try:
|
||||
send_otp_sms(phone=phone, code=code)
|
||||
except OTPDeliveryError:
|
||||
# ارسال شکست خورده یعنی کاربر کدی نگرفته؛ cooldown رو پاک میکنیم
|
||||
# تا مجبور نشه ۲ دقیقه بدونِ داشتنِ کد منتظر بمونه. سقفِ ساعتی/
|
||||
# throttleِ IP دستنخورده میمونن (محدودیتِ حجم، نه این cooldown).
|
||||
# تا مجبور نشه ۲ دقیقه بدونِ داشتنِ کد منتظر بمونه، و ردیفِ کد رو هم
|
||||
# حذف میکنیم تا در cooldown و سقفِ روزانهی دیتابیسی شمرده نشه.
|
||||
# سقفِ ساعتیِ کش و throttleِ IP دستنخورده میمونن (محدودیتِ حجم).
|
||||
cache.delete(phone_cooldown_key)
|
||||
otp_row.delete()
|
||||
raise
|
||||
|
||||
# devOtp فقط در محیط dev برمیگردد؛ در پروداکشن کد فقط از طریق پیامک تحویل داده میشود.
|
||||
@@ -203,6 +223,57 @@ class RequestOTPView(APIView):
|
||||
return Response(RequestOTPResponseSerializer(data).data)
|
||||
|
||||
|
||||
def verify_phone_otp(phone: str, otp: str) -> Response | None:
|
||||
"""
|
||||
کدِ پیامکیِ این شماره را میسنجد و مصرف میکند؛ None یعنی درست بود، وگرنه پاسخِ خطا.
|
||||
|
||||
مشترکِ ورود (LoginView) و حذفِ حساب (DeleteAccountView): هر دو باید همان سقفِ تلاشِ
|
||||
شماره، همان قفلِ ردیف و همان باطل شدنِ کد پس از چند حدسِ غلط را داشته باشند. پیش از
|
||||
۲۰۲۶-۱۰-۰۲ این منطق فقط داخلِ LoginView بود.
|
||||
"""
|
||||
# محدودیت تلاش بر اساس شماره تلفن (مستقل از throttle IP-محورِ هر view).
|
||||
phone_attempts_key = f'otp_login_phone_attempts:{phone}'
|
||||
if cache.add(phone_attempts_key, 1, timeout=settings.OTP_LOGIN_PHONE_WINDOW_SECONDS):
|
||||
attempt_count = 1
|
||||
else:
|
||||
try:
|
||||
attempt_count = cache.incr(phone_attempts_key)
|
||||
except ValueError:
|
||||
# بینِ add و incr دقیقاً منقضی شده؛ پنجره رو از نو شروع کن.
|
||||
cache.set(phone_attempts_key, 1, timeout=settings.OTP_LOGIN_PHONE_WINDOW_SECONDS)
|
||||
attempt_count = 1
|
||||
|
||||
if attempt_count > settings.OTP_LOGIN_PHONE_MAX_ATTEMPTS:
|
||||
# هیچ سکرتی (OTP، token) لاگ نمیشه؛ از شماره فقط ۴ رقمِ آخر.
|
||||
logger.warning(
|
||||
'OTP login phone attempt limit exceeded: phone_suffix=%s attempt_count=%s max_attempts=%s',
|
||||
phone[-4:], attempt_count, settings.OTP_LOGIN_PHONE_MAX_ATTEMPTS,
|
||||
)
|
||||
return Response(
|
||||
{'detail': 'تعداد تلاشهای ورود برای این شماره بیش از حد مجاز است.'},
|
||||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
)
|
||||
|
||||
# قفل ردیف OTP جلوی مصرف دوبارهی یک کد یکبارمصرف را در درخواستهای همزمان میگیرد.
|
||||
with transaction.atomic():
|
||||
test_code = settings.TEST_LOGIN_PHONES.get(phone, '')
|
||||
if settings.DEV_MASTER_OTP is not None and otp == settings.DEV_MASTER_OTP:
|
||||
return None
|
||||
if test_code and secrets.compare_digest(test_code, otp):
|
||||
# شمارهی تستِ تیم. compare_digest تا نتیجه از روی زمانِ پاسخ قابلِ استنتاج نباشه.
|
||||
logger.info('Login via configured test phone: phone_suffix=%s', phone[-4:])
|
||||
return None
|
||||
otp_obj = OTPCode.objects.select_for_update().filter(
|
||||
phone=phone, code=otp, is_used=False, expires_at__gte=timezone.now()
|
||||
).order_by('-id').first()
|
||||
if otp_obj:
|
||||
otp_obj.is_used = True
|
||||
otp_obj.save(update_fields=['is_used'])
|
||||
return None
|
||||
_count_failed_attempt(phone)
|
||||
return Response({'detail': 'کد OTP نامعتبر یا منقضیشده است.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class LoginView(APIView):
|
||||
"""POST /api/auth/login — ورود با OTP"""
|
||||
|
||||
@@ -220,56 +291,18 @@ class LoginView(APIView):
|
||||
phone = serializer.validated_data['phone']
|
||||
otp = serializer.validated_data['otp']
|
||||
|
||||
# محدودیت تلاش ورود بر اساس شماره تلفن (مستقل از throttle IP-محور بالا).
|
||||
phone_attempts_key = f'otp_login_phone_attempts:{phone}'
|
||||
if cache.add(phone_attempts_key, 1, timeout=settings.OTP_LOGIN_PHONE_WINDOW_SECONDS):
|
||||
attempt_count = 1
|
||||
else:
|
||||
try:
|
||||
attempt_count = cache.incr(phone_attempts_key)
|
||||
except ValueError:
|
||||
# بینِ add و incr دقیقاً منقضی شده؛ پنجره رو از نو شروع کن.
|
||||
cache.set(phone_attempts_key, 1, timeout=settings.OTP_LOGIN_PHONE_WINDOW_SECONDS)
|
||||
attempt_count = 1
|
||||
|
||||
if attempt_count > settings.OTP_LOGIN_PHONE_MAX_ATTEMPTS:
|
||||
# ⚠️ افزوده شده: لاگ هشدارِ امنیتی برای رصد/آلارمِ brute-force روی
|
||||
# این endpoint. هیچ سکرتی (OTP، access/refresh token) لاگ نمیشه؛
|
||||
# شمارهی تلفن هم کامل نوشته نمیشه، فقط ۴ رقم آخرش (برای
|
||||
# correlation در تحلیل/پشتیبانی، بدون افشای کامل PII).
|
||||
logger.warning(
|
||||
'OTP login phone attempt limit exceeded: phone_suffix=%s attempt_count=%s max_attempts=%s',
|
||||
phone[-4:], attempt_count, settings.OTP_LOGIN_PHONE_MAX_ATTEMPTS,
|
||||
)
|
||||
return Response(
|
||||
{'detail': 'تعداد تلاشهای ورود برای این شماره بیش از حد مجاز است.'},
|
||||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
)
|
||||
|
||||
# قفل ردیف OTP جلوی مصرف دوبارهی یک کد یکبارمصرف را در درخواستهای همزمان میگیرد.
|
||||
with transaction.atomic():
|
||||
valid = False
|
||||
if settings.DEV_MASTER_OTP is not None and otp == settings.DEV_MASTER_OTP:
|
||||
valid = True
|
||||
else:
|
||||
otp_obj = OTPCode.objects.select_for_update().filter(
|
||||
phone=phone, code=otp, is_used=False, expires_at__gte=timezone.now()
|
||||
).order_by('-id').first()
|
||||
if otp_obj:
|
||||
otp_obj.is_used = True
|
||||
otp_obj.save(update_fields=['is_used'])
|
||||
valid = True
|
||||
|
||||
if not valid:
|
||||
return Response({'detail': 'کد OTP نامعتبر یا منقضیشده است.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
user, created = User.objects.get_or_create(
|
||||
error = verify_phone_otp(phone, otp)
|
||||
if error is not None:
|
||||
record_login_event(
|
||||
request,
|
||||
LoginEvent.LOGIN_BLOCKED if error.status_code == status.HTTP_429_TOO_MANY_REQUESTS
|
||||
else LoginEvent.LOGIN_FAILED,
|
||||
phone=phone,
|
||||
defaults={'is_guest': False, 'name': ''},
|
||||
)
|
||||
if user.is_guest:
|
||||
user.is_guest = False
|
||||
user.save(update_fields=['is_guest'])
|
||||
return error
|
||||
|
||||
with transaction.atomic():
|
||||
user, created = User.objects.get_or_create(phone=phone, defaults={'name': ''})
|
||||
|
||||
# ثبتِ فعالیتِ کاربر برای ورودِ موفق.
|
||||
user.last_activity_at = timezone.now()
|
||||
@@ -283,10 +316,10 @@ class LoginView(APIView):
|
||||
refresh_token = uuid.uuid4().hex
|
||||
Session.objects.create(
|
||||
user=user,
|
||||
refresh_token=refresh_token,
|
||||
device_id=request.data.get('deviceId', ''),
|
||||
refresh_token=hash_refresh_token(refresh_token),
|
||||
expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
|
||||
)
|
||||
record_login_event(request, LoginEvent.LOGIN_OK, user_id=user.id, phone=phone)
|
||||
access_token = issue_access_token(user.id)
|
||||
|
||||
data = {
|
||||
@@ -297,6 +330,103 @@ class LoginView(APIView):
|
||||
return Response(LoginResponseSerializer(data).data)
|
||||
|
||||
|
||||
def _count_failed_attempt(phone: str) -> None:
|
||||
"""حدسِ غلط روی کدهای فعالِ این شماره؛ کدی که به سقف برسد باطل میشود."""
|
||||
active = OTPCode.objects.filter(phone=phone, is_used=False, expires_at__gte=timezone.now())
|
||||
active.update(failed_attempts=F('failed_attempts') + 1)
|
||||
active.filter(failed_attempts__gte=settings.OTP_MAX_FAILED_ATTEMPTS).update(is_used=True)
|
||||
|
||||
|
||||
# سوابقِ پرداخت سندِ مالیاند و با حذفِ حساب نمیروند (تراکنشها با سفارش میمانند).
|
||||
# Conversion هم میماند: ردیفِ «کاربرِ معرفیشده» سابقهی پورسانتِ *معرف* است، نه دادهی این
|
||||
# کاربر؛ تا ۲۰۲۶-۱۰-۰۳ با حذفِ حسابِ معرفیشده پورسانتِ معرف هم پاک میشد.
|
||||
# Redemption و UserOffer هم میمانند: سقفِ مصرفِ کدِ تخفیف ردیفهای Redemption را میشمارد، پس پاک
|
||||
# شدنشان یک کدِ یکبارمصرف را با «بساز، بخر، حذف کن» دوباره آزاد میکرد.
|
||||
KEPT_ON_ACCOUNT_DELETE = ('payment.Order', 'statuslog.StatusChangeLog', 'affiliate.Conversion',
|
||||
'discount.Redemption', 'discount.UserOffer', 'accounts.LoginEvent')
|
||||
|
||||
|
||||
class DeleteAccountView(APIView):
|
||||
"""
|
||||
POST /api/user/delete — حذفِ همیشگیِ حسابِ خودِ کاربر و همهی دادهاش.
|
||||
|
||||
چرا اینطور (۲۰۲۶-۱۰-۰۲):
|
||||
- کاربرِ شمارهدار باید کدِ پیامکیِ تازه بدهد (همان verify_phone_otpِ ورود، با همان سقفِ حدس).
|
||||
توکنِ access بهتنهایی کافی نیست: گوشیِ بازمانده دستِ یک بچهی دیگر یا توکنِ دزدیدهشده نباید
|
||||
بتواند حساب را برای همیشه پاک کند.
|
||||
- داده در همهی اپها پاک میشود (delete_users)، جز سفارشها و تراکنشهایشان که سندِ مالیاند.
|
||||
- حسابهای دمو (TEST_LOGIN_PHONES) حذف نمیشوند؛ مشترکاند و برای ارائه پُرشدهاند.
|
||||
- بعد از حذف، توکنهای همین کاربر کار نمیکنند چون کاربری با آن شناسه نمانده.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
throttle_scope = 'account_delete'
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
|
||||
@extend_schema(
|
||||
request=DeleteAccountRequestSerializer,
|
||||
responses=DeleteAccountResponseSerializer,
|
||||
tags=['Identity & Profile (accounts)'],
|
||||
)
|
||||
def post(self, request):
|
||||
from .management.commands.delete_users import delete_users
|
||||
|
||||
serializer = DeleteAccountRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
v = serializer.validated_data
|
||||
user = request.user
|
||||
|
||||
if user.phone in settings.TEST_LOGIN_PHONES:
|
||||
return Response({'detail': 'حسابِ نمایشی را نمیشود حذف کرد.'}, status=status.HTTP_403_FORBIDDEN)
|
||||
error = verify_phone_otp(user.phone, v['otp'])
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
logger.info('Account deleted by its owner: user_id=%s', user.id)
|
||||
record_login_event(request, LoginEvent.ACCOUNT_DELETED, user_id=user.id, phone=user.phone or '')
|
||||
# کدهای پیامکیِ شماره میمانند: سقفِ روزانهی پیامکِ هر شماره از همین ردیفها شمرده میشود.
|
||||
delete_users([user.id], keep=KEPT_ON_ACCOUNT_DELETE, delete_otp_codes=False)
|
||||
return Response(DeleteAccountResponseSerializer({'success': True}).data)
|
||||
|
||||
|
||||
class LogoutView(APIView):
|
||||
"""
|
||||
POST /api/auth/logout — خروج از حساب.
|
||||
|
||||
refreshTokenِ این دستگاه (اگر فرستاده شود) باطل میشود و access tokenهای
|
||||
صادرشده *پیش از ثانیهی خروج* رد میشوند. دستگاههای دیگر با refreshِ
|
||||
خودشان بیصدا توکنِ تازه میگیرند؛ فقط همین دستگاه بیرون میماند.
|
||||
|
||||
⚠️ مقایسه ثانیهای است: توکنی که در همان ثانیهی خروج صادر شده معتبر
|
||||
میماند (بازتولید شد). با AUTH_USER_CACHE_TTL>0 و کشِ per-worker، کشِ
|
||||
کاربر فقط در همین worker پاک میشود؛ پیشفرض ۰ است و آن حالت تست نشده.
|
||||
"""
|
||||
|
||||
# سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `user_profile_write` همان نرخِ مسیرهای همخانواده است.
|
||||
throttle_scope = 'user_profile_write'
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
request=LogoutRequestSerializer, responses=LogoutResponseSerializer,
|
||||
tags=['Identity & Profile (accounts)'],
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = LogoutRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
refresh_token = serializer.validated_data.get('refreshToken') or ''
|
||||
with transaction.atomic():
|
||||
if refresh_token:
|
||||
Session.objects.filter(
|
||||
user_id=request.user.id, refresh_token=hash_refresh_token(refresh_token),
|
||||
).update(revoked=True)
|
||||
User.objects.filter(pk=request.user.id).update(tokens_valid_after=timezone.now())
|
||||
invalidate_user_cache(request.user.id)
|
||||
record_login_event(request, LoginEvent.LOGOUT, user_id=request.user.id, phone=request.user.phone or '')
|
||||
return Response(LogoutResponseSerializer({'success': True}).data)
|
||||
|
||||
|
||||
class RefreshTokenView(APIView):
|
||||
"""POST /api/auth/refresh — تمدید توکن"""
|
||||
|
||||
@@ -322,13 +452,13 @@ class RefreshTokenView(APIView):
|
||||
# UPDATE اتمیک جلوی استفادهی همزمان از یک refresh token را میگیرد (replay protection).
|
||||
with transaction.atomic():
|
||||
updated_count = Session.objects.filter(
|
||||
refresh_token=refresh_token, revoked=False, expires_at__gte=timezone.now()
|
||||
refresh_token=hash_refresh_token(refresh_token), revoked=False, expires_at__gte=timezone.now()
|
||||
).update(revoked=True)
|
||||
|
||||
if not updated_count:
|
||||
return Response({'detail': 'رفرشتوکن نامعتبر یا منقضیشده است.'}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
old_session = Session.objects.get(refresh_token=refresh_token)
|
||||
old_session = Session.objects.get(refresh_token=hash_refresh_token(refresh_token))
|
||||
|
||||
# ثبتِ فعالیتِ کاربر برای رفرشِ موفقِ توکن.
|
||||
User.objects.filter(pk=old_session.user_id).update(last_activity_at=timezone.now())
|
||||
@@ -336,8 +466,7 @@ class RefreshTokenView(APIView):
|
||||
new_refresh_token = secrets.token_hex(32)
|
||||
new_session = Session.objects.create(
|
||||
user_id=old_session.user_id,
|
||||
refresh_token=new_refresh_token,
|
||||
device_id=old_session.device_id,
|
||||
refresh_token=hash_refresh_token(new_refresh_token),
|
||||
expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
|
||||
)
|
||||
|
||||
@@ -354,12 +483,10 @@ class SetUserGradeView(APIView):
|
||||
"""POST /api/user/grade — ثبت پایهی کاربر (نیاز به احراز هویت)"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
# ⚠️ اضافه شده: این endpoint قبلاً هیچ throttleای نداشت؛ یک کاربرِ
|
||||
# (از جمله مهمان) احرازهویتشده میتونست تعدادِ نامحدودی درخواستِ
|
||||
# نوشتنِ دیتابیس بفرسته. طبقِ همون الگویِ سایرِ اپها، یک
|
||||
# throttle_scope مجزا با GuestAwareAccountThrottle اضافه شد.
|
||||
# بدونِ throttle، یک کاربرِ احرازهویتشده میتواند
|
||||
# نامحدود درخواستِ نوشتن روی دیتابیس بفرستد.
|
||||
throttle_scope = 'user_profile_write'
|
||||
throttle_classes = [GuestAwareAccountThrottle]
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
|
||||
@extend_schema(
|
||||
request=SetGradeRequestSerializer,
|
||||
@@ -379,6 +506,22 @@ class SetUserGradeView(APIView):
|
||||
return Response(SetGradeResponseSerializer(data).data)
|
||||
|
||||
|
||||
def _profile_payload(user, profile) -> dict:
|
||||
from . import jalali, public_id
|
||||
|
||||
return {
|
||||
'userId': str(user.id),
|
||||
'publicId': public_id.encode(user.id),
|
||||
'phone': user.phone,
|
||||
'name': user.name,
|
||||
'avatar': user.avatar,
|
||||
'gradeId': user.grade_id,
|
||||
'birthYear': profile.birth_year,
|
||||
'birthDate': jalali.to_string(profile.birth_date) if profile.birth_date else None,
|
||||
'bio': profile.bio,
|
||||
}
|
||||
|
||||
|
||||
class UserProfileView(APIView):
|
||||
"""
|
||||
GET /api/user/profile — اطلاعات پروفایل کاربر جاری
|
||||
@@ -392,7 +535,7 @@ class UserProfileView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
# نوشتن است، پس همان throttleِ نوشتنِ پروفایل که SetUserGradeView دارد.
|
||||
throttle_scope = 'user_profile_write'
|
||||
throttle_classes = [GuestAwareAccountThrottle]
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
|
||||
def get_throttles(self):
|
||||
# GET خواندنی است و نباید سهمیهی نوشتن را مصرف کند.
|
||||
@@ -407,23 +550,12 @@ class UserProfileView(APIView):
|
||||
def get(self, request):
|
||||
user = request.user
|
||||
# مدل Profile بهصورت lazy ساخته میشه چون هیچجای دیگهای (نه
|
||||
# GuestSessionView، نه LoginView) رکورد Profile رو از قبل نمیسازه؛
|
||||
# LoginView) رکورد Profile رو از قبل نمیسازه؛
|
||||
# get_or_create اینجا امن است چون فقط توسطِ کاربرِ همون رکورد صدا
|
||||
# زده میشه (OneToOne روی user_id جاری، نه ورودیِ خارجی).
|
||||
profile, _ = Profile.objects.get_or_create(user_id=user.id)
|
||||
|
||||
data = {
|
||||
'userId': str(user.id),
|
||||
'phone': user.phone,
|
||||
'name': user.name,
|
||||
'avatar': user.avatar,
|
||||
'isGuest': user.is_guest,
|
||||
'gradeId': user.grade_id,
|
||||
'birthYear': profile.birth_year,
|
||||
'parentPhone': profile.parent_phone,
|
||||
'bio': profile.bio,
|
||||
}
|
||||
return Response(ProfileResponseSerializer(data).data)
|
||||
return Response(ProfileResponseSerializer(_profile_payload(user, profile)).data)
|
||||
|
||||
@extend_schema(
|
||||
request=UpdateProfileRequestSerializer,
|
||||
@@ -439,7 +571,7 @@ class UserProfileView(APIView):
|
||||
profile, _ = Profile.objects.get_or_create(user_id=user.id)
|
||||
|
||||
user_fields = {'name': 'name', 'avatar': 'avatar'}
|
||||
profile_fields = {'birthYear': 'birth_year', 'parentPhone': 'parent_phone', 'bio': 'bio'}
|
||||
profile_fields = {'birthYear': 'birth_year', 'birthDate': 'birth_date', 'bio': 'bio'}
|
||||
|
||||
changed_user = [attr for key, attr in user_fields.items() if key in v]
|
||||
changed_profile = [attr for key, attr in profile_fields.items() if key in v]
|
||||
@@ -450,6 +582,17 @@ class UserProfileView(APIView):
|
||||
for key, attr in profile_fields.items():
|
||||
if key in v:
|
||||
setattr(profile, attr, v[key])
|
||||
# سال و تاریخ نباید از هم جدا بیفتند: تاریخ سال را میسازد، و سالِ تنها
|
||||
# (اپِ قدیمی) تاریخی را که دیگر با آن نمیخواند پاک میکند.
|
||||
if 'birthDate' in v:
|
||||
from . import jalali
|
||||
profile.birth_year = jalali.from_gregorian(v['birthDate'])[0] if v['birthDate'] else None
|
||||
changed_profile.append('birth_year')
|
||||
elif 'birthYear' in v and profile.birth_date is not None:
|
||||
from . import jalali
|
||||
if jalali.from_gregorian(profile.birth_date)[0] != v['birthYear']:
|
||||
profile.birth_date = None
|
||||
changed_profile.append('birth_date')
|
||||
|
||||
# update_fields: فقط همان ستونهایی که واقعاً عوض شدهاند نوشته شوند تا
|
||||
# PATCH همزمان از یک دستگاهِ دیگر، فیلدهای دستنخورده را بازنویسی نکند.
|
||||
@@ -458,15 +601,4 @@ class UserProfileView(APIView):
|
||||
if changed_profile:
|
||||
profile.save(update_fields=changed_profile)
|
||||
|
||||
data = {
|
||||
'userId': str(user.id),
|
||||
'phone': user.phone,
|
||||
'name': user.name,
|
||||
'avatar': user.avatar,
|
||||
'isGuest': user.is_guest,
|
||||
'gradeId': user.grade_id,
|
||||
'birthYear': profile.birth_year,
|
||||
'parentPhone': profile.parent_phone,
|
||||
'bio': profile.bio,
|
||||
}
|
||||
return Response(ProfileResponseSerializer(data).data)
|
||||
return Response(ProfileResponseSerializer(_profile_payload(user, profile)).data)
|
||||
|
||||
@@ -4,6 +4,9 @@ from rest_framework import serializers
|
||||
class AffiliateMeResponseSerializer(serializers.Serializer):
|
||||
referralCode = serializers.CharField()
|
||||
commissionRate = serializers.FloatField()
|
||||
referredCount = serializers.IntegerField(help_text='کاربرهایی که کدِ شما را ثبت کردهاند.')
|
||||
paidCount = serializers.IntegerField(help_text='از میانِ آنها، کسانی که واقعاً پرداخت کردهاند.')
|
||||
totalCommission = serializers.IntegerField(help_text='جمعِ پورسانتِ پرداختهای واقعی (تومان).')
|
||||
|
||||
|
||||
class TrackRequestSerializer(serializers.Serializer):
|
||||
|
||||
47
affiliate/services.py
Normal file
47
affiliate/services.py
Normal 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
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
@@ -15,42 +15,26 @@ from .serializers import (
|
||||
)
|
||||
|
||||
|
||||
class GuestAwareAffiliateThrottle(ScopedRateThrottle):
|
||||
"""
|
||||
⚠️ اضافه شده: همون الگویِ GuestAwareDiscountThrottle/GuestAwareFamilyThrottle
|
||||
و بقیه؛ چرخشِ حسابِ مهمان (deviceId جدید → pk جدید) یک ScopedRateThrottleِ
|
||||
سادهٔ per-user رو دور میزد. اینجا حساستره چون referralCode یک
|
||||
شناسهی حدسزدنیست (404 برای کدِ نامعتبر = oracle) و یک حدسِ درست
|
||||
یک Conversion واقعی میسازه که به کمیسیونِ مالیِ affiliate صاحبِ کد
|
||||
منجر میشه؛ یعنی بدونِ این throttle، چرخشِ حساب مهمان به مهاجم
|
||||
guessهای عملاً نامحدودی برای enumerate/fraud کردنِ کدهای رفرال
|
||||
میداد. برای کاربرهای مهمان کلیدِ cache از pk به IP کلاینت تغییر
|
||||
میکنه؛ رفتارِ throttle برای کاربرهای غیرمهمان (per-user، مثلِ
|
||||
قبل) دستنخورده میمونه.
|
||||
"""
|
||||
|
||||
def get_cache_key(self, request, view):
|
||||
user = request.user
|
||||
if user and user.is_authenticated and getattr(user, 'is_guest', False):
|
||||
ident = f'guest-ip:{self.get_ident(request)}'
|
||||
elif user and user.is_authenticated:
|
||||
ident = user.pk
|
||||
else:
|
||||
ident = self.get_ident(request)
|
||||
|
||||
return self.cache_format % {'scope': self.scope, 'ident': ident}
|
||||
|
||||
|
||||
class AffiliateMeView(APIView):
|
||||
"""GET /api/affiliate/me — حساب و کد ارجاع کاربر"""
|
||||
|
||||
# سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `read` همان نرخِ مسیرهای همخانواده است.
|
||||
throttle_scope = 'read'
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(responses=AffiliateMeResponseSerializer, tags=['Affiliate (affiliate)'])
|
||||
def get(self, request):
|
||||
account, _ = AffiliateAccount.objects.get_or_create(user_id=request.user.id)
|
||||
|
||||
data = {'referralCode': account.referral_code, 'commissionRate': account.commission_rate}
|
||||
paid = account.conversions.filter(status='paid')
|
||||
data = {
|
||||
'referralCode': account.referral_code,
|
||||
'commissionRate': account.commission_rate,
|
||||
'referredCount': account.conversions.count(),
|
||||
'paidCount': paid.count(),
|
||||
'totalCommission': sum(paid.values_list('commission_amount', flat=True)),
|
||||
}
|
||||
return Response(AffiliateMeResponseSerializer(data).data)
|
||||
|
||||
|
||||
@@ -71,10 +55,9 @@ class TrackConversionView(APIView):
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
# referralCode نامعتبر ۴۰۴ میگیره (یک oracle قابلprobe)، پس throttle
|
||||
# مجزا هم probe رو کند میکنه هم (با GuestAwareAffiliateThrottle) جلویِ
|
||||
# bucket تازه از چرخشِ حساب مهمان رو میگیره.
|
||||
# مجزا probe رو کند میکنه.
|
||||
throttle_scope = 'affiliate_track'
|
||||
throttle_classes = [GuestAwareAffiliateThrottle]
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
|
||||
@extend_schema(
|
||||
request=TrackRequestSerializer, responses=TrackResponseSerializer, tags=['Affiliate (affiliate)']
|
||||
@@ -83,7 +66,6 @@ class TrackConversionView(APIView):
|
||||
serializer = TrackRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
v = serializer.validated_data
|
||||
|
||||
account = get_object_or_404(AffiliateAccount, referral_code=v['referralCode'])
|
||||
referred_user_id = request.user.id
|
||||
|
||||
@@ -102,6 +84,10 @@ class TrackConversionView(APIView):
|
||||
class ConversionsListView(APIView):
|
||||
"""GET /api/affiliate/conversions — لیست تبدیلها و کمیسیون"""
|
||||
|
||||
# سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `read` همان نرخِ مسیرهای همخانواده است.
|
||||
throttle_scope = 'read'
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(responses=ConversionListResponseSerializer, tags=['Affiliate (affiliate)'])
|
||||
|
||||
76
ai_tutor/concurrency.py
Normal file
76
ai_tutor/concurrency.py
Normal 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()
|
||||
18
ai_tutor/migrations/0006_message_diagram.py
Normal file
18
ai_tutor/migrations/0006_message_diagram.py
Normal 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),
|
||||
),
|
||||
]
|
||||
25
ai_tutor/migrations/0007_message_charge.py
Normal file
25
ai_tutor/migrations/0007_message_charge.py
Normal 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),
|
||||
]
|
||||
18
ai_tutor/migrations/0008_alter_message_created_at.py
Normal file
18
ai_tutor/migrations/0008_alter_message_created_at.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -25,7 +25,14 @@ class Message(models.Model):
|
||||
role = models.CharField(max_length=10, choices=ROLE_CHOICES)
|
||||
text = models.TextField()
|
||||
question_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Question
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
# فقط برای پیامِ کاربر: این سوال از کجا پرداخت شد. «free» از سهمیهی روزانه، «none» هیچ (رد در صافیِ
|
||||
# سرور یا جوابِ کششده که به API نرسید). سهمیهی امروز = تعدادِ «free»های امروز. «credit» (اعتبارِ بستهی
|
||||
# سوال) فقط روی پیامهای پیش از subscription.0017 است؛ دیگر نوشته نمیشود ولی ردِ تاریخیِ آن پیامهاست.
|
||||
CHARGE_CHOICES = [('free', 'رایگانِ روزانه'), ('credit', 'اعتبارِ بسته'), ('none', 'بیهزینه')]
|
||||
charge = models.CharField(max_length=6, choices=CHARGE_CHOICES, blank=True, default='')
|
||||
# ایندکس: سقفِ روزانهی کلِ سامانه (_check_global_daily_cap) در هر سوال پیامهای امروزِ همه را
|
||||
# میشمارد؛ این سریعترین جدولِ در حالِ رشد است.
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
|
||||
# --- حاشیهنویسیِ تولید (فقط برای پیامهای role='assistant') --------------
|
||||
# سینیور خواست لایک/دیسلایک بعداً قابلِ تحلیل باشد: «کدام جواب بهتر بوده».
|
||||
@@ -43,6 +50,8 @@ class Message(models.Model):
|
||||
completion_tokens = models.PositiveIntegerField(default=0)
|
||||
latency_ms = models.PositiveIntegerField(default=0)
|
||||
from_cache = models.BooleanField(default=False)
|
||||
# شکلِ پاسخ (rag.services.diagram)؛ فقط توصیفِ JSONِ اعتبارسنجیشده، نه SVG.
|
||||
diagram = models.JSONField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['created_at']
|
||||
|
||||
65
ai_tutor/provider_status.py
Normal file
65
ai_tutor/provider_status.py
Normal 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: ثبتِ بازگشت شکست خورد')
|
||||
@@ -18,7 +18,9 @@ class AskRequestSerializer(serializers.Serializer):
|
||||
# RAG_GRADE_TITLE_OVERRIDES ساخته میشه.
|
||||
grade = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||
lessonNumber = serializers.IntegerField(required=False, allow_null=True)
|
||||
userQuestion = serializers.CharField()
|
||||
# سقفِ طول: کلِ متن به مدلِ پولی میرود؛ بیسقف، یک درخواستِ صدهزار نویسهای
|
||||
# بهتنهایی هزینهی صدها سوالِ عادی را داشت.
|
||||
userQuestion = serializers.CharField(max_length=2000)
|
||||
stream = serializers.BooleanField(required=False, default=False)
|
||||
|
||||
|
||||
@@ -32,7 +34,8 @@ class AskResponseSerializer(serializers.Serializer):
|
||||
messageId = serializers.CharField()
|
||||
answer = serializers.CharField()
|
||||
suggestedFollowUps = serializers.ListField(child=serializers.CharField())
|
||||
remainingQuota = serializers.IntegerField()
|
||||
remainingQuota = serializers.IntegerField(help_text='سوالهای رایگانِ باقیِ امروز.')
|
||||
diagram = serializers.DictField(required=False, allow_null=True)
|
||||
# تکههای درسیِ بازیابیشده از Qdrant که پاسخ بر پایهشون ساخته شده؛
|
||||
# خالی یعنی retrieval انجام نشده (نبودِ subject/lessonNumber یا خاموشبودنِ RAG).
|
||||
usedChunks = UsedChunkSerializer(many=True, required=False, default=list)
|
||||
@@ -44,6 +47,8 @@ class MessageSerializer(serializers.Serializer):
|
||||
messageId = serializers.CharField()
|
||||
role = serializers.CharField()
|
||||
text = serializers.CharField()
|
||||
# شکلِ پاسخ؛ شِمایش در rag/services/diagram.py (plane / numberline / fraction).
|
||||
diagram = serializers.DictField(required=False, allow_null=True)
|
||||
|
||||
|
||||
class ConversationMessagesResponseSerializer(serializers.Serializer):
|
||||
@@ -61,3 +66,8 @@ class FeedbackRequestSerializer(serializers.Serializer):
|
||||
|
||||
class FeedbackResponseSerializer(serializers.Serializer):
|
||||
success = serializers.BooleanField()
|
||||
|
||||
|
||||
class QuotaResponseSerializer(serializers.Serializer):
|
||||
freeDaily = serializers.IntegerField(help_text='سوالهای رایگانِ هر روز برای این کاربر.')
|
||||
remainingQuota = serializers.IntegerField(help_text='سوالهای رایگانِ باقیِ امروز.')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
from django.test import TestCase, override_settings
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
|
||||
@@ -29,7 +29,7 @@ class AskIdempotencyFollowUpsTests(TestCase):
|
||||
# بینِ تستها پاک نمیشه) میتونه از یک تستِ قبلی باقی بمونه.
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-ask-1', is_guest=True, name='')
|
||||
self.user = User.objects.create(phone='09000000021', name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
|
||||
def _ask(self, idempotency_key):
|
||||
@@ -65,7 +65,7 @@ class AskIdempotencyFollowUpsTests(TestCase):
|
||||
|
||||
self.assertEqual(retry_response.status_code, 200)
|
||||
self.assertEqual(retry_response.data['suggestedFollowUps'], original_follow_ups)
|
||||
self.assertEqual(retry_response.data['answer'], first_response.data['answer'])
|
||||
self.assertEqual(retry_response.json()['answer'], first_response.json()['answer'])
|
||||
self.assertEqual(retry_response.data['messageId'], first_response.data['messageId'])
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ class AskAIGenerationFailureTests(TestCase):
|
||||
# ⚠️ افزوده شده: نگاه کنید به توضیحِ مشابه در AskIdempotencyFollowUpsTests.setUp
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-ask-fail-1', is_guest=True, name='')
|
||||
self.user = User.objects.create(phone='09000000022', name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
|
||||
def _ask(self, idempotency_key=None):
|
||||
@@ -111,9 +111,11 @@ class AskAIGenerationFailureTests(TestCase):
|
||||
|
||||
def test_generation_failure_does_not_consume_quota_slot(self):
|
||||
# یک سوالِ ناموفق میفرستیم (که نباید کوتا مصرف کنه)، بعد دقیقاً
|
||||
# DAILY_AI_QUOTA سوالِ موفق پشتِ سرِ هم میفرستیم؛ همه باید ۲۰۰
|
||||
# به اندازهی سقفِ روزانه سوالِ موفق پشتِ سرِ هم میفرستیم؛ همه باید ۲۰۰
|
||||
# بگیرن، یعنی سوالِ ناموفقِ اول اسلاتی از کوتا مصرف نکرده.
|
||||
from .views import DAILY_AI_QUOTA
|
||||
from .views import daily_quota
|
||||
|
||||
DAILY_AI_QUOTA = daily_quota(self.user.id)
|
||||
|
||||
with patch(
|
||||
'ai_tutor.views.generate_tutor_answer',
|
||||
@@ -126,7 +128,7 @@ class AskAIGenerationFailureTests(TestCase):
|
||||
ok_response = self._ask()
|
||||
self.assertEqual(ok_response.status_code, 200)
|
||||
|
||||
# این سوالِ اضافه باید رد بشه چون سقفِ ۱۵ (نه ۱۴) واقعاً پر شده.
|
||||
# این سوالِ اضافه باید رد بشه چون سقف (نه یکی کمتر) واقعاً پر شده.
|
||||
throttled_response = self._ask()
|
||||
self.assertEqual(throttled_response.status_code, 429)
|
||||
|
||||
@@ -175,7 +177,7 @@ class AskAIConcurrentIdempotencyKeyTests(TestCase):
|
||||
# ⚠️ افزوده شده: نگاه کنید به توضیحِ مشابه در AskIdempotencyFollowUpsTests.setUp
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-ask-race-1', is_guest=True, name='')
|
||||
self.user = User.objects.create(phone='09000000023', name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
|
||||
def test_losing_request_cleans_up_and_returns_winners_result(self):
|
||||
@@ -223,7 +225,7 @@ class AskAIConcurrentIdempotencyKeyTests(TestCase):
|
||||
# هر دو پاسخ باید دقیقاً به همون AskIdempotency (نتیجهی برنده)
|
||||
# اشاره کنن.
|
||||
self.assertEqual(outer_response.data['messageId'], inner_response.data['messageId'])
|
||||
self.assertEqual(outer_response.data['answer'], inner_response.data['answer'])
|
||||
self.assertEqual(outer_response.json()['answer'], inner_response.json()['answer'])
|
||||
|
||||
# فقط یک رکوردِ AskIdempotency برای این کلید باید وجود داشته باشه.
|
||||
self.assertEqual(
|
||||
@@ -252,9 +254,9 @@ class AskAIConversationApiContractTests(TestCase):
|
||||
# ⚠️ افزوده شده: نگاه کنید به توضیحِ مشابه در AskIdempotencyFollowUpsTests.setUp
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-contract-1', is_guest=True, name='')
|
||||
self.user = User.objects.create(phone='09000000024', name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
self.other_user = User.objects.create(device_id='device-contract-2', is_guest=True, name='')
|
||||
self.other_user = User.objects.create(phone='09000000025', name='')
|
||||
self.other_token = issue_access_token(self.other_user.id)
|
||||
|
||||
def test_no_conversation_id_creates_new_conversation(self):
|
||||
@@ -346,7 +348,7 @@ class GenerationBoundaryContextTests(TestCase):
|
||||
# فرستاده شدن به مرزِ تولید برسن — این metadataی لازم برای
|
||||
# RAG/Qdrantِ آیندهست، نه تاریخچه.
|
||||
client = APIClient()
|
||||
user = User.objects.create(device_id='device-boundary-grade-1', is_guest=True, name='')
|
||||
user = User.objects.create(phone='09000000026', name='')
|
||||
token = issue_access_token(user.id)
|
||||
|
||||
with patch(
|
||||
@@ -374,7 +376,7 @@ class GenerationBoundaryContextTests(TestCase):
|
||||
# پیاده نشده، context باید None بمونه (فقط gradeId/lessonId/سوال
|
||||
# مجازن).
|
||||
client = APIClient()
|
||||
user = User.objects.create(device_id='device-boundary-1', is_guest=True, name='')
|
||||
user = User.objects.create(phone='09000000027', name='')
|
||||
token = issue_access_token(user.id)
|
||||
|
||||
conversation = Conversation.objects.create(user_id=user.id)
|
||||
@@ -430,7 +432,7 @@ class AskAIViewThrottleTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
cache.clear()
|
||||
self.user = User.objects.create(device_id='device-ai-throttle-1', is_guest=True, name='')
|
||||
self.user = User.objects.create(phone='09000000028', name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
|
||||
def _ask(self, idempotency_key):
|
||||
@@ -442,6 +444,8 @@ class AskAIViewThrottleTests(TestCase):
|
||||
HTTP_IDEMPOTENCY_KEY=idempotency_key,
|
||||
)
|
||||
|
||||
# سقفِ روزانه (پیشفرض ۲) نباید پیش از throttle برسد؛ این تست فقط throttle را میسنجد.
|
||||
@override_settings(AI_DAILY_QUOTA_FREE=10)
|
||||
def test_requests_within_rate_succeed_and_excess_is_throttled(self):
|
||||
# هر درخواست idempotency-key متفاوت دارد تا هر بار واقعاً از کوتای
|
||||
# روزانه مصرف کند (نه یک replay ساده)، و صرفاً throttle سنجیده شود.
|
||||
@@ -454,18 +458,8 @@ class AskAIViewThrottleTests(TestCase):
|
||||
self.assertEqual(throttled_response.status_code, 429)
|
||||
|
||||
|
||||
class AskAIViewGuestThrottleBypassTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='ai_ask' برای هر
|
||||
کاربرِ احرازهویتشده (مهمان یا نه) user.pk بود. چون GuestSessionView
|
||||
با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) میسازه، یک
|
||||
مهاجم میتونست با ساختنِ پیدرپیِ حسابهای مهمان، هر بار یک bucket
|
||||
throttle تازه برای AskAIView بگیره. حالا GuestAwareAIThrottle برای
|
||||
کاربرهای مهمان کلید رو بر اساسِ IP (همون get_ident که guest_session
|
||||
هم استفاده میکنه) میسازه، پس چرخشِ حساب مهمان دیگه bucket تازه
|
||||
نمیده. رفتارِ throttle برای کاربرِ غیرمهمان (per-user، مثلِ قبل)
|
||||
دستنخورده مونده.
|
||||
"""
|
||||
class AskAIViewPerUserThrottleTests(TestCase):
|
||||
"""هر کاربرِ ثبتنامشده سطلِ throttleِ جدای خودش را دارد؛ مصرفِ حسابِ دیگر به او نمیرسد."""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
@@ -480,35 +474,49 @@ class AskAIViewGuestThrottleBypassTests(TestCase):
|
||||
HTTP_IDEMPOTENCY_KEY=idempotency_key,
|
||||
)
|
||||
|
||||
def test_rotating_guest_accounts_share_the_same_throttle_bucket(self):
|
||||
# سه حساب مهمانِ متفاوت (شبیهسازیِ چرخشِ حساب برای دورزدنِ throttle)،
|
||||
# همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'ai_ask': '2/hour'}):
|
||||
guest1 = User.objects.create(device_id='device-guest-rot-1', is_guest=True, name='')
|
||||
response1 = self._ask(issue_access_token(guest1.id), 'guest-rot-key-1')
|
||||
self.assertEqual(response1.status_code, 200)
|
||||
|
||||
guest2 = User.objects.create(device_id='device-guest-rot-2', is_guest=True, name='')
|
||||
response2 = self._ask(issue_access_token(guest2.id), 'guest-rot-key-2')
|
||||
self.assertEqual(response2.status_code, 200)
|
||||
|
||||
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
|
||||
# bucket بر اساسِ IP بهاشتراک گذاشته شده، نه user.pk.
|
||||
guest3 = User.objects.create(device_id='device-guest-rot-3', is_guest=True, name='')
|
||||
response3 = self._ask(issue_access_token(guest3.id), 'guest-rot-key-3')
|
||||
|
||||
self.assertEqual(response3.status_code, 429)
|
||||
|
||||
def test_registered_users_keep_independent_per_user_buckets(self):
|
||||
# عدمِ رگرسیون: کاربرهای غیرمهمان (ثبتنامشده با شماره) باید مثلِ
|
||||
# قبل هرکدوم bucket جداگانهی خودشون رو داشته باشن — throttle
|
||||
# برای اونا هنوز بر اساسِ user.pk است، نه IP.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'ai_ask': '1/hour'}):
|
||||
user_a = User.objects.create(is_guest=False, name='', phone='+989120000101')
|
||||
user_a = User.objects.create(name='', phone='+989120000101')
|
||||
response_a = self._ask(issue_access_token(user_a.id), 'user-a-key-1')
|
||||
self.assertEqual(response_a.status_code, 200)
|
||||
|
||||
user_b = User.objects.create(is_guest=False, name='', phone='+989120000102')
|
||||
user_b = User.objects.create(name='', phone='+989120000102')
|
||||
response_b = self._ask(issue_access_token(user_b.id), 'user-b-key-1')
|
||||
|
||||
self.assertEqual(response_b.status_code, 200)
|
||||
|
||||
|
||||
class StubNeverReachesProductionTests(TestCase):
|
||||
"""بیکلیدِ RAG و بیرون از DEBUG، دانشآموز ۵۰۳ میگیرد، نه متنِ «پاسخ نمونه (stub)»."""
|
||||
|
||||
@override_settings(DEBUG=False, OPENAI_API_KEY='', QDRANT_API_KEY='', RAG_FORCE_MOCK=False)
|
||||
def test_missing_keys_are_a_503_and_cost_nothing(self):
|
||||
cache.clear()
|
||||
user = User.objects.create(phone='09000000029', name='')
|
||||
response = APIClient().post(
|
||||
'/api/ai/ask', {'userQuestion': '۲+۲ چند است؟'}, format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}',
|
||||
)
|
||||
self.assertEqual(response.status_code, 503)
|
||||
self.assertFalse(Message.objects.filter(conversation__user_id=user.id).exists())
|
||||
|
||||
|
||||
class GlobalDailyCapTests(TestCase):
|
||||
"""سقفِ روزانهی کلِ سامانه برای سوالهایی که به مدل میرسند (AI_GLOBAL_DAILY_MAX)."""
|
||||
|
||||
@override_settings(AI_GLOBAL_DAILY_MAX=2)
|
||||
def test_the_whole_system_stops_at_the_daily_cap(self):
|
||||
cache.clear()
|
||||
codes = []
|
||||
for i in range(3):
|
||||
user = User.objects.create(phone=f'0900{i:07d}', name='')
|
||||
codes.append(APIClient().post(
|
||||
'/api/ai/ask', {'userQuestion': f'۲+{i} چند است؟'}, format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}',
|
||||
REMOTE_ADDR=f'10.0.0.{i}',
|
||||
).status_code)
|
||||
self.assertEqual(codes, [200, 200, 503])
|
||||
|
||||
92
ai_tutor/tests_concurrency.py
Normal file
92
ai_tutor/tests_concurrency.py
Normal 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()
|
||||
131
ai_tutor/tests_grade_grounding.py
Normal file
131
ai_tutor/tests_grade_grounding.py
Normal 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))
|
||||
33
ai_tutor/tests_plain_history.py
Normal file
33
ai_tutor/tests_plain_history.py
Normal 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
53
ai_tutor/tests_quota.py
Normal 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، مثلِ پلنهای فروشی
|
||||
138
ai_tutor/tests_security_review.py
Normal file
138
ai_tutor/tests_security_review.py
Normal 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'], [])
|
||||
@@ -1,9 +1,10 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import AskAIView, ConversationMessagesView, MessageFeedbackView
|
||||
from .views import AskAIView, ConversationMessagesView, MessageFeedbackView, QuotaView
|
||||
|
||||
urlpatterns = [
|
||||
path('ai/ask', AskAIView.as_view(), name='ai-ask'),
|
||||
path('ai/quota', QuotaView.as_view(), name='ai-quota'),
|
||||
path('ai/conversations/<int:conversation_id>/messages', ConversationMessagesView.as_view(), name='ai-conversation-messages'),
|
||||
path('ai/messages/<int:message_id>/feedback', MessageFeedbackView.as_view(), name='ai-message-feedback'),
|
||||
]
|
||||
|
||||
@@ -16,8 +16,13 @@ from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from accounts.models import User as AccountUser
|
||||
from config.db import release_db_connection
|
||||
from content.plain_math import convert as plain_convert
|
||||
from rag.services.mathtext import to_plain
|
||||
from . import concurrency, provider_status
|
||||
from .models import AskIdempotency, Conversation, Message, MessageFeedback
|
||||
from .serializers import (
|
||||
QuotaResponseSerializer,
|
||||
AskRequestSerializer,
|
||||
AskResponseSerializer,
|
||||
ConversationMessagesResponseSerializer,
|
||||
@@ -27,7 +32,45 @@ from .serializers import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DAILY_AI_QUOTA = 15
|
||||
def _learner_age(user) -> int | None:
|
||||
"""سنِ کاربر از پروفایلش (accounts)؛ لحنِ دستیار برای ۱۸+ بزرگسالانه است."""
|
||||
from accounts.jalali import age_of
|
||||
from accounts.models import Profile
|
||||
|
||||
profile = Profile.objects.filter(user_id=user.id).only('birth_date', 'birth_year').first()
|
||||
return age_of(profile.birth_date, profile.birth_year) if profile else None
|
||||
|
||||
|
||||
def _cache_grade(grade_title: str, age: int | None) -> str:
|
||||
"""پایه در کلیدِ کشِ جواب، با لحن: جوابِ بزرگسالانه به کودک سرو نشود و برعکس."""
|
||||
from rag.services.generation import tone_for
|
||||
|
||||
return f'{grade_title}|adult' if tone_for(grade_title, age) == 'adult' else grade_title
|
||||
|
||||
|
||||
def quota_state(user_id: int) -> dict:
|
||||
"""
|
||||
{'remainingQuota'}: سوالهای باقیِ امروز، هر بار تازه از دیتابیس. همهی پاسخها از همین میخوانند تا
|
||||
شمارندهی اپ هیچوقت از موجودیِ واقعی جدا نیفتد.
|
||||
"""
|
||||
used_free = Message.objects.filter(
|
||||
conversation__user_id=user_id, role='user', created_at__date=timezone.localdate(), charge='free',
|
||||
).count()
|
||||
return {'remainingQuota': max(daily_quota(user_id) - used_free, 0)}
|
||||
|
||||
|
||||
def _uncharge(user_message) -> None:
|
||||
"""سوالی که به API نرسید (جوابِ کششده): از سهمیه کم نشود."""
|
||||
if user_message.charge == 'free':
|
||||
user_message.charge = 'none'
|
||||
user_message.save(update_fields=['charge'])
|
||||
|
||||
|
||||
def daily_quota(user_id: int) -> int:
|
||||
"""سقفِ سوالِ روزانهی این کاربر؛ رایگان یا از پلنِ اشتراکش (subscription.services)."""
|
||||
from subscription.services import ai_daily_quota
|
||||
|
||||
return ai_daily_quota(user_id)
|
||||
|
||||
|
||||
def _annotation(retrieval_meta: dict, generation_meta: dict, *, from_cache: bool) -> dict:
|
||||
@@ -53,42 +96,11 @@ def _annotation(retrieval_meta: dict, generation_meta: dict, *, from_cache: bool
|
||||
'completion_tokens': int(generation_meta.get('completionTokens') or 0),
|
||||
'latency_ms': int(generation_meta.get('latencyMs') or 0),
|
||||
'from_cache': bool(from_cache),
|
||||
# شکلِ اعتبارسنجیشده در rag.services.diagram؛ کشِ قدیمی آن را ندارد.
|
||||
'diagram': generation_meta.get('diagram') if isinstance(generation_meta.get('diagram'), dict) else None,
|
||||
}
|
||||
|
||||
|
||||
class GuestAwareAIThrottle(ScopedRateThrottle):
|
||||
"""
|
||||
⚠️ اضافه شده: ScopedRateThrottle استاندارد برای کاربرِ احرازهویتشده
|
||||
(مهمان یا نه) از request.user.pk بهعنوانِ کلیدِ throttle استفاده
|
||||
میکنه. چون GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ
|
||||
تازه (pk جدید) میسازه، یک مهاجم میتونست با ساختنِ پیدرپیِ حسابهای
|
||||
مهمان، برای هر حساب یک bucket مستقلِ throttle (و همینطور کوتای
|
||||
روزانهی AskAIView که هم بر اساسِ user_id شمارش میشه) بگیره — یعنی
|
||||
نرخِ واقعیِ استفاده از این endpoint گرونقیمت بهجایِ محدود به نرخِ
|
||||
ai_ask در ساعت، عملاً فقط به نرخِ ساختِ حساب مهمان (guest_session)
|
||||
محدود میشد.
|
||||
|
||||
این کلاس فقط برای کاربرهای مهمان (is_guest=True) کلیدِ cache رو از
|
||||
pk کاربر به IP کلاینت (همون get_ident که guest_session هم استفاده
|
||||
میکنه) تغییر میده؛ یعنی همهی حسابهای مهمانِ ساختهشده از یک IP
|
||||
مشترکِ throttle bucket ai_ask رو دارن و دیگه با عوضکردنِ حساب
|
||||
نمیشه bucket تازه گرفت. رفتارِ throttle برای کاربرهای غیرمهمان
|
||||
(ثبتنامشده با شماره) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی
|
||||
میمونه؛ منطقِ DAILY_AI_QUOTA و بقیهی view دستنخوردهست.
|
||||
"""
|
||||
|
||||
def get_cache_key(self, request, view):
|
||||
user = request.user
|
||||
if user and user.is_authenticated and getattr(user, 'is_guest', False):
|
||||
ident = f'guest-ip:{self.get_ident(request)}'
|
||||
elif user and user.is_authenticated:
|
||||
ident = user.pk
|
||||
else:
|
||||
ident = self.get_ident(request)
|
||||
|
||||
return self.cache_format % {'scope': self.scope, 'ident': ident}
|
||||
|
||||
|
||||
def _is_db_ref_id(value) -> bool:
|
||||
"""
|
||||
⚠️ افزوده شده: lessonId/chapterId/questionId در قراردادِ API بهصورتِ
|
||||
@@ -97,10 +109,80 @@ def _is_db_ref_id(value) -> bool:
|
||||
این تابع فقط تشخیص میده که آیا مقدار برای ذخیرهسازی در همچین
|
||||
فیلدی امن/عددی هست یا نه؛ روی مرزِ generate_tutor_answer اثری نداره
|
||||
(اونجا همیشه مقدارِ خامِ اصلی پاس داده میشه).
|
||||
|
||||
پیامِ دستیار هم از همین مسیر (`_db_ref_or_none`) رد میشود؛ پیش از
|
||||
۲۰۲۶-۰۹-۲۵ نمیشد و questionIdِ غیرعددی *بعد از* تولیدِ جواب ۵۰۰ میداد و
|
||||
سهمِ روزانه مصرفشده میماند.
|
||||
"""
|
||||
if value in (None, ''):
|
||||
return False
|
||||
return str(value).strip().lstrip('-').isdigit()
|
||||
# isascii: '²'.isdigit() هم True است ولی int('²') خطا میدهد.
|
||||
text = str(value).strip().lstrip('-')
|
||||
return text.isascii() and text.isdigit()
|
||||
|
||||
|
||||
def _db_ref_or_none(value):
|
||||
"""مقدارِ عددی برای ستونِ IntegerField، وگرنه None (نه خطای ORM)."""
|
||||
return value if _is_db_ref_id(value) else None
|
||||
|
||||
|
||||
def _conversation_history(conversation, before_message) -> list[dict]:
|
||||
"""
|
||||
چند پیامِ آخرِ همین گفتگو، پیش از سوالِ تازه، به ترتیبِ زمان.
|
||||
|
||||
بدونِ این، هر سوال جدا از قبلیها جواب داده میشد: «نفهمیدم، سادهتر بگو»
|
||||
برای مدل هیچ مرجعی نداشت.
|
||||
"""
|
||||
from rag.services.generation import HISTORY_TURNS
|
||||
|
||||
rows = list(
|
||||
Message.objects.filter(conversation=conversation, id__lt=before_message.id)
|
||||
.order_by('-id').values('role', 'text')[:HISTORY_TURNS]
|
||||
)
|
||||
return list(reversed(rows))
|
||||
|
||||
|
||||
def _retrieval_query(question: str, history: list[dict], focus: str = '') -> str:
|
||||
"""
|
||||
متنی که embedding میشود. «یه مثال دیگه بزن» بهتنهایی به هیچ تکهای از
|
||||
کتاب نزدیک نیست؛ سوالِ قبلیِ کاربر (یا سوالِ تمرینیِ اپ) موضوع را معلوم میکند.
|
||||
"""
|
||||
if focus:
|
||||
return f'{focus}\n{question}'
|
||||
previous = next((t['text'] for t in reversed(history) if t['role'] == 'user'), '')
|
||||
return f'{previous}\n{question}' if previous else question
|
||||
|
||||
|
||||
def _question_focus(v: dict, user) -> str:
|
||||
"""اگر اپ questionId فرستاده، متنِ همان سوال و جوابِ دانشآموز (rag.generation)."""
|
||||
question_id = v.get('questionId')
|
||||
if not _is_db_ref_id(question_id):
|
||||
return ''
|
||||
from rag.services.generation import question_focus
|
||||
|
||||
return question_focus(question_id, user.id)
|
||||
|
||||
|
||||
class AIDailyCapReached(APIException):
|
||||
status_code = 503
|
||||
default_detail = 'دستیار امروز به سقفِ استفاده رسیده؛ لطفاً فردا دوباره بپرس.'
|
||||
default_code = 'ai_daily_cap'
|
||||
|
||||
|
||||
def _check_global_daily_cap() -> None:
|
||||
"""
|
||||
سقفِ روزانهی کلِ سامانه (AI_GLOBAL_DAILY_MAX): سوالهای امروز که به مدل رفتهاند یا با کش
|
||||
برگشتهاند ولی هنوز «free» ماندهاند. در دیتابیس شمرده میشود، پس بینِ workerها
|
||||
مشترک است. داخلِ همان تراکنشِ IMMEDIATEِ فازِ ۱ صدا زده میشود؛ روی Postgres دو درخواستِ
|
||||
همزمان ممکن است هر دو از لبه رد شوند (چند سوال بیشتر از سقف، نه بیشتر).
|
||||
"""
|
||||
from django.conf import settings
|
||||
|
||||
start = timezone.localtime().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
used = Message.objects.filter(role='user', created_at__gte=start, charge='free').count()
|
||||
if used >= settings.AI_GLOBAL_DAILY_MAX:
|
||||
logger.error('AI global daily cap reached: cap=%s used=%s', settings.AI_GLOBAL_DAILY_MAX, used)
|
||||
raise AIDailyCapReached()
|
||||
|
||||
|
||||
class AIProviderUnavailable(APIException):
|
||||
@@ -136,10 +218,14 @@ def generate_tutor_answer(
|
||||
تولید میکنه. مالکیتِ تاریخچهی مکالمه همیشه دستِ دیتابیسِ خودمونه، نه
|
||||
provider.
|
||||
|
||||
اگه RAG پیکربندی نشده باشه (نبودِ OPENAI_API_KEY/QDRANT_API_KEY یا
|
||||
RAG_FORCE_MOCK)، همون پاسخِ stubِ قطعیِ قبلی برمیگرده تا اجرای بدونِ
|
||||
کلید و تستها دستنخورده کار کنن.
|
||||
اگه RAG پیکربندی نشده باشه، پاسخِ stub فقط در DEBUG یا با RAG_FORCE_MOCK
|
||||
(تستها) برمیگرده. بیرون از آن خطا میدهد و AskAIView آن را ۵۰۳ میکند.
|
||||
تا ۲۰۲۶-۱۰-۰۳ stub در پروداکشن هم برمیگشت: اگر کلیدی از .env سرور میافتاد،
|
||||
دانشآموز با ۲۰۰ متنِ «این یک پاسخ نمونه (stub)… OPENAI_API_KEY را تنظیم
|
||||
کنید» میگرفت و سهمیهی روزانهاش هم کم میشد (با تستِ دورریختنی بازتولید شد).
|
||||
"""
|
||||
from django.conf import settings
|
||||
|
||||
from rag.services.config import rag_is_configured
|
||||
|
||||
if rag_is_configured():
|
||||
@@ -153,6 +239,9 @@ def generate_tutor_answer(
|
||||
answer, follow_ups, _meta = generate_answer(user_question, context_chunks)
|
||||
return answer, follow_ups
|
||||
|
||||
if not (settings.DEBUG or settings.RAG_FORCE_MOCK):
|
||||
raise RuntimeError('RAG پیکربندی نشده (OPENAI_API_KEY/QDRANT_API_KEY)؛ stub فقط در DEBUG.')
|
||||
|
||||
context_note = f' (با context: {context})' if context else ''
|
||||
answer = (
|
||||
f'این یک پاسخ نمونه (stub) برای سوال «{user_question}»{context_note} است. '
|
||||
@@ -163,14 +252,23 @@ def generate_tutor_answer(
|
||||
return answer, follow_ups
|
||||
|
||||
|
||||
class AIBusy(APIException):
|
||||
"""همهی جاهای همزمانِ دستیار در این worker پرند (ai_tutor/concurrency.py). سهمیه دست نخورده."""
|
||||
status_code = 503
|
||||
default_detail = 'دستیار الان خیلی شلوغه؛ چند ثانیهی دیگه دوباره بپرس.'
|
||||
default_code = 'assistant_busy'
|
||||
# هندلرِ DRF از `wait` هدرِ Retry-After میسازد.
|
||||
wait = 10
|
||||
|
||||
|
||||
class AskAIView(APIView):
|
||||
"""POST /api/ai/ask — سوال از AI (استریم اختیاری؛ در این فاز استریم پیاده نشده)"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
# DAILY_AI_QUOTA فقط سقفِ روزانهست و جلوی burstِ کوتاهمدت رو نمیگیره؛
|
||||
# daily_quota فقط سقفِ روزانهست و جلوی burstِ کوتاهمدت رو نمیگیره؛
|
||||
# این throttle_scope مجزا اون burst رو محدود میکنه.
|
||||
throttle_scope = 'ai_ask'
|
||||
throttle_classes = [GuestAwareAIThrottle]
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
|
||||
@extend_schema(
|
||||
request=AskRequestSerializer,
|
||||
@@ -189,6 +287,24 @@ class AskAIView(APIView):
|
||||
tags=['AI Tutor (ai_tutor)'],
|
||||
)
|
||||
def post(self, request):
|
||||
# پیش از هر کارِ دیگری، حتی رزروِ سهمیه: درخواستِ ردشده نباید از سهمیهی روزانه کم کند.
|
||||
if not concurrency.try_acquire():
|
||||
logger.warning('ai_busy user_id=%s in_flight=%s limit=%s',
|
||||
request.user.id, concurrency.in_flight(), concurrency.limit())
|
||||
raise AIBusy()
|
||||
try:
|
||||
response = self._ask(request)
|
||||
except BaseException:
|
||||
concurrency.release()
|
||||
raise
|
||||
if isinstance(response, StreamingHttpResponse):
|
||||
# جا تا پایانِ استریم دستِ همین درخواست میماند (ReleaseWhenDone).
|
||||
response.streaming_content = concurrency.ReleaseWhenDone(response.streaming_content)
|
||||
else:
|
||||
concurrency.release()
|
||||
return response
|
||||
|
||||
def _ask(self, request):
|
||||
serializer = AskRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
v = serializer.validated_data
|
||||
@@ -207,15 +323,39 @@ class AskAIView(APIView):
|
||||
# فاز ۲ (فراخوانی AI) کاملاً بیرون از هر قفل/تراکنشی اجرا میشود تا
|
||||
# تأخیر شبکه قفل کاربر را نگه ندارد، فاز ۳ پیام دستیار و idempotency
|
||||
# را ثبت میکند. اگر فاز ۲/۳ شکست بخورد، رزرو فاز ۱ آزاد میشود.
|
||||
# محافظِ دستیار، لایهی ۱ (rag/services/guardrails.py): ترفندِ آشکار برای عوض کردنِ
|
||||
# قواعد، یا جملهای دربارهی آسیب به خود، اصلاً به مدل نمیرسد؛ پس هزینهای ندارد و از
|
||||
# سهمیه هم کم نمیشود. سقفِ burst (throttle_scope=ai_ask) جلوی تکرارش را میگیرد.
|
||||
from rag.services import guardrails
|
||||
|
||||
screened = guardrails.screen(v['userQuestion'])
|
||||
|
||||
with transaction.atomic():
|
||||
AccountUser.objects.select_for_update().get(pk=user.id)
|
||||
|
||||
# هر سوالی که به API میرسد پول است و از سقفِ روزانه کم میشود (رایگان: AI_DAILY_QUOTA_FREE،
|
||||
# مشترک: Plan.ai_daily_quota). قفلِ ردیفِ کاربر جلوی دو سوالِ همزمان روی آخرین سهم را میگیرد.
|
||||
today = timezone.localdate()
|
||||
used_today = Message.objects.filter(
|
||||
conversation__user_id=user.id, role='user', created_at__date=today
|
||||
conversation__user_id=user.id, role='user', created_at__date=today, charge='free',
|
||||
).count()
|
||||
if used_today >= DAILY_AI_QUOTA:
|
||||
raise Throttled(detail='سقف روزانهی سوال از هوش مصنوعی به پایان رسیده است.')
|
||||
quota = daily_quota(user.id)
|
||||
if not screened:
|
||||
_check_global_daily_cap()
|
||||
if screened:
|
||||
charge = 'none'
|
||||
elif used_today < quota:
|
||||
charge = 'free'
|
||||
else:
|
||||
from subscription.services import has_active_subscription
|
||||
|
||||
n = str(quota).translate(str.maketrans('0123456789', '۰۱۲۳۴۵۶۷۸۹'))
|
||||
raise Throttled(detail=(
|
||||
# مشترک تا فردا صبر میکند، بیاشتراک اشتراک میخرد (سوالِ جدا فروخته نمیشود).
|
||||
f'سقفِ امروزِ سوال از دستیار ({n} سوال) پر شد. فردا دوباره میتونی بپرسی.'
|
||||
if has_active_subscription(user.id) else
|
||||
f'سوالهای رایگانِ امروز ({n} سوال) تمام شد. با خریدِ اشتراک هر روز سوالِ بیشتری بپرس.'
|
||||
))
|
||||
|
||||
conversation_id = v.get('conversationId')
|
||||
conversation_is_new = not conversation_id
|
||||
@@ -246,6 +386,7 @@ class AskAIView(APIView):
|
||||
role='user',
|
||||
text=v['userQuestion'],
|
||||
question_id=raw_question_id if _is_db_ref_id(raw_question_id) else None,
|
||||
charge=charge,
|
||||
)
|
||||
# قفل ردیف کاربر اینجا آزاد شده؛ فراخوانی AI بیرون از هر تراکنشی اجرا میشود.
|
||||
# لایهی RAG (اپ rag) اینجا context را میسازد: پرسش → embedding →
|
||||
@@ -257,10 +398,14 @@ class AskAIView(APIView):
|
||||
# فقط conversationId میفرستد و دیگر lessonId/gradeId نمیآید.
|
||||
grade_id = v.get('gradeId') or conversation.grade_id
|
||||
lesson_id = v.get('lessonId') or conversation.lesson_id
|
||||
subject = (v.get('subject') or conversation.subject or '').strip()
|
||||
# ⚠️ درسِ تازهی همین درخواست از کتابِ ذخیرهشدهی مکالمه جلو میافتد: از وقتی یک پایه چند
|
||||
# کتاب دارد، مکالمهای که با درسِ هندسه شروع شده میتواند با درسِ حسابان ادامه پیدا کند و
|
||||
# `conversation.subject`/`lesson_number`ِ قدیمی جواب را از کتابِ غلط میگرفت.
|
||||
explicit_lesson = bool(v.get('lessonId'))
|
||||
subject = (v.get('subject') or ('' if explicit_lesson else conversation.subject) or '').strip()
|
||||
grade_title = (v.get('grade') or '').strip()
|
||||
lesson_number = v.get('lessonNumber')
|
||||
if lesson_number is None:
|
||||
if lesson_number is None and not explicit_lesson:
|
||||
lesson_number = conversation.lesson_number
|
||||
|
||||
# اگر اپ فقط lessonId (یا فقط conversationId) داده — حالتِ طبیعی، چون
|
||||
@@ -287,6 +432,31 @@ class AskAIView(APIView):
|
||||
conversation.lesson_number = lesson_number
|
||||
conversation.save(update_fields=['subject', 'lesson_number'])
|
||||
|
||||
# کتاب صریح آمده (فرانت کتابِ انتخابیِ دانشآموز را میفرستد) ولی درس و عنوانِ پایه نه:
|
||||
# عنوانِ پایه از پایهی خودِ کاربر درمیآید، وگرنه resolve_target هیچ collectionی نمیسازد
|
||||
# و جواب بیصدا عمومی میشد.
|
||||
if subject and not grade_title:
|
||||
from rag.services.config import resolve_grade_title
|
||||
|
||||
grade_title = resolve_grade_title(grade_id or getattr(user, 'grade_id', None)) or ''
|
||||
|
||||
# نه درسی آمده نه subject — حالتِ عادیِ صفحهی «از پی بپرس» که از نوارِ
|
||||
# پایین باز میشود. پایهی ذخیرهشدهی خودِ کاربر کتاب را معلوم میکند؛
|
||||
# بدونِ این، جوابهای آن صفحه هیچوقت به کتاب مستند نمیشدند.
|
||||
if not subject:
|
||||
from rag.services.lesson_context import resolve_from_grade
|
||||
|
||||
grade_id = grade_id or getattr(user, 'grade_id', None)
|
||||
derived = resolve_from_grade(grade_id)
|
||||
subject = derived.get('subject', '')
|
||||
grade_title = grade_title or derived.get('gradeTitle', '')
|
||||
if subject and not conversation.subject:
|
||||
conversation.subject = subject
|
||||
conversation.grade_id = conversation.grade_id or grade_id
|
||||
conversation.save(update_fields=['subject', 'grade_id'])
|
||||
|
||||
age = _learner_age(user)
|
||||
|
||||
# --- مسیرِ استریم (SSE) --- فقط وقتی stream=true و RAG پیکربندیشده.
|
||||
# قراردادِ JSONِ غیراستریم دستنخورده میماند؛ Swagger که stream
|
||||
# نمیفرسته همون مسیرِ قبلی رو میگیره.
|
||||
@@ -299,7 +469,7 @@ class AskAIView(APIView):
|
||||
user_message=user_message, conversation_is_new=conversation_is_new,
|
||||
idempotency_key=idempotency_key, used_today=used_today,
|
||||
subject=subject, grade_id=grade_id, grade_title=grade_title,
|
||||
lesson_number=lesson_number,
|
||||
lesson_number=lesson_number, screened=screened, quota=quota, age=age,
|
||||
)
|
||||
|
||||
used_chunks: list[dict] = []
|
||||
@@ -310,14 +480,20 @@ class AskAIView(APIView):
|
||||
try:
|
||||
from rag.services.config import rag_is_configured
|
||||
|
||||
if rag_is_configured():
|
||||
if screened:
|
||||
answer_text, follow_ups = screened[1], []
|
||||
generation_meta = {'promptVersion': f'guardrail-{screened[0]}'}
|
||||
elif rag_is_configured():
|
||||
from rag.services import answer_cache
|
||||
from rag.services.generation import generate_answer
|
||||
from rag.services.retrieval import resolve_target, retrieve_context
|
||||
|
||||
history = _conversation_history(conversation, user_message)
|
||||
focus = _question_focus(v, user)
|
||||
cacheable = answer_cache.cacheable(history) and not focus
|
||||
_col, _fk, _fv = resolve_target(subject, grade_title, grade_id, lesson_number)
|
||||
cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'], grade_title)
|
||||
cached = answer_cache.get(cache_key)
|
||||
cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'], _cache_grade(grade_title, age))
|
||||
cached = answer_cache.get(cache_key) if cacheable else None
|
||||
if cached is not None:
|
||||
answer_text = cached['answer']
|
||||
follow_ups = list(cached['followUps'])
|
||||
@@ -325,9 +501,13 @@ class AskAIView(APIView):
|
||||
retrieval_meta = cached.get('retrievalMeta') or {}
|
||||
generation_meta = cached.get('generationMeta') or {}
|
||||
answer_from_cache = True
|
||||
_uncharge(user_message)
|
||||
else:
|
||||
# Qdrant و مدل چند ثانیه طول میکشند؛ اتصالِ دیتابیس در این مدت به pool برمیگردد.
|
||||
release_db_connection()
|
||||
used_chunks, retrieval_meta = retrieve_context(
|
||||
v['userQuestion'], subject=subject, grade_id=grade_id,
|
||||
_retrieval_query(v['userQuestion'], history, focus),
|
||||
subject=subject, grade_id=grade_id,
|
||||
grade_title=grade_title, lesson_number=lesson_number,
|
||||
)
|
||||
used_chunks = [
|
||||
@@ -337,18 +517,25 @@ class AskAIView(APIView):
|
||||
answer_text, follow_ups, generation_meta = generate_answer(
|
||||
v['userQuestion'], used_chunks or None,
|
||||
retrieval_meta.get('gradeTitle') or grade_title or '',
|
||||
history=history, focus=focus, age=age,
|
||||
)
|
||||
answer_cache.set(cache_key, {
|
||||
'answer': answer_text, 'followUps': follow_ups,
|
||||
'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta,
|
||||
'generationMeta': generation_meta,
|
||||
})
|
||||
provider_status.record_success()
|
||||
# ردِ «غیرِ ریاضی» نه منبعِ کتاب نشان میدهد نه کش میشود.
|
||||
if generation_meta.get('refused'):
|
||||
used_chunks = []
|
||||
elif cacheable:
|
||||
answer_cache.set(cache_key, {
|
||||
'answer': answer_text, 'followUps': follow_ups,
|
||||
'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta,
|
||||
'generationMeta': generation_meta,
|
||||
})
|
||||
else:
|
||||
answer_text, follow_ups = generate_tutor_answer(
|
||||
v['userQuestion'], grade_id, lesson_id, context
|
||||
)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.exception('generate_tutor_answer failed for user_id=%s', user.id)
|
||||
provider_status.record_failure(exc)
|
||||
self._release_reserved_slot(conversation, user_message, conversation_is_new)
|
||||
raise AIProviderUnavailable()
|
||||
|
||||
@@ -358,7 +545,7 @@ class AskAIView(APIView):
|
||||
conversation=conversation,
|
||||
role='assistant',
|
||||
text=answer_text,
|
||||
question_id=v.get('questionId') or None,
|
||||
question_id=_db_ref_or_none(v.get('questionId')),
|
||||
**_annotation(retrieval_meta, generation_meta, from_cache=answer_from_cache),
|
||||
)
|
||||
|
||||
@@ -382,15 +569,15 @@ class AskAIView(APIView):
|
||||
self._release_reserved_slot(conversation, user_message, conversation_is_new)
|
||||
return Response(self._build_ask_response(existing).data)
|
||||
|
||||
used_today += 1 # سوال همین درخواست هم جزو مصرف امروز است
|
||||
remaining_quota = max(DAILY_AI_QUOTA - used_today, 0)
|
||||
state = quota_state(user.id)
|
||||
|
||||
data = {
|
||||
'conversationId': str(conversation.id),
|
||||
'messageId': str(assistant_message.id),
|
||||
'answer': answer_text,
|
||||
'suggestedFollowUps': follow_ups,
|
||||
'remainingQuota': remaining_quota,
|
||||
'remainingQuota': state['remainingQuota'],
|
||||
'diagram': assistant_message.diagram,
|
||||
'usedChunks': [
|
||||
{'title': c.get('title', ''), 'summary': c.get('summary', '')}
|
||||
for c in used_chunks
|
||||
@@ -411,17 +598,20 @@ class AskAIView(APIView):
|
||||
|
||||
def _streaming_response(self, *, request, v, user, conversation, user_message,
|
||||
conversation_is_new, idempotency_key, used_today,
|
||||
subject, grade_id, grade_title, lesson_number):
|
||||
subject, grade_id, grade_title, lesson_number, screened=None, quota=None,
|
||||
age=None):
|
||||
"""
|
||||
نسخهی SSE از post(): همون منطقِ retrieval + کوتا + idempotency، ولی
|
||||
پاسخ توکنبهتوکن استریم میشه. رویدادها:
|
||||
event: chunk data: {"delta": "..."}
|
||||
event: done data: {conversationId, messageId, remainingQuota, usedChunks[, retrievalDebug]}
|
||||
event: done data: {conversationId, messageId, remainingQuota, diagram, usedChunks[, retrievalDebug]}
|
||||
event: error data: {"detail": "..."}
|
||||
فاز ۱ (رزرو کوتا + conversation + user_message) قبلاً در post() انجام شده.
|
||||
"""
|
||||
from rag.services import answer_cache
|
||||
from rag.services.generation import PROMPT_VERSION, default_follow_ups, stream_answer
|
||||
from rag.services import answer_cache, guardrails
|
||||
from rag.services.generation import (
|
||||
PROMPT_VERSION, chat_model_for, default_follow_ups, stream_answer,
|
||||
)
|
||||
from rag.services.retrieval import resolve_target, retrieve_context
|
||||
|
||||
def _sse(event: str, payload: dict) -> str:
|
||||
@@ -432,20 +622,32 @@ class AskAIView(APIView):
|
||||
generation_meta: dict = {}
|
||||
answer_from_cache = False
|
||||
_col, _fk, _fv = resolve_target(subject, grade_title, grade_id, lesson_number)
|
||||
cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'], grade_title)
|
||||
cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'], _cache_grade(grade_title, age))
|
||||
try:
|
||||
cached = answer_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
history = _conversation_history(conversation, user_message)
|
||||
focus = _question_focus(v, user)
|
||||
cacheable = answer_cache.cacheable(history) and not focus
|
||||
cached = answer_cache.get(cache_key) if cacheable and not screened else None
|
||||
if screened:
|
||||
answer_text, follow_ups = screened[1], []
|
||||
used_chunks, retrieval_meta = [], {}
|
||||
generation_meta = {'promptVersion': f'guardrail-{screened[0]}'}
|
||||
yield _sse('chunk', {'delta': answer_text})
|
||||
elif cached is not None:
|
||||
answer_text = cached['answer']
|
||||
follow_ups = list(cached['followUps'])
|
||||
used_chunks = cached['usedChunks']
|
||||
retrieval_meta = cached.get('retrievalMeta') or {}
|
||||
generation_meta = cached.get('generationMeta') or {}
|
||||
answer_from_cache = True
|
||||
_uncharge(user_message)
|
||||
yield _sse('chunk', {'delta': answer_text})
|
||||
else:
|
||||
# کلِ استریم (تا ۴۰ ثانیه) بیاتصالِ دیتابیس؛ ثبتِ پیامِ آخر یکی تازه قرض میگیرد.
|
||||
release_db_connection()
|
||||
used_chunks, retrieval_meta = retrieve_context(
|
||||
v['userQuestion'], subject=subject, grade_id=grade_id,
|
||||
_retrieval_query(v['userQuestion'], history, focus),
|
||||
subject=subject, grade_id=grade_id,
|
||||
grade_title=grade_title, lesson_number=lesson_number,
|
||||
)
|
||||
used_chunks = [
|
||||
@@ -453,31 +655,49 @@ class AskAIView(APIView):
|
||||
for c in used_chunks
|
||||
]
|
||||
parts: list[str] = []
|
||||
stream_result: dict = {}
|
||||
started = time.monotonic()
|
||||
for delta in stream_answer(
|
||||
v['userQuestion'], used_chunks or None,
|
||||
retrieval_meta.get('gradeTitle') or grade_title or '',
|
||||
):
|
||||
parts.append(delta)
|
||||
yield _sse('chunk', {'delta': delta})
|
||||
effective_grade = retrieval_meta.get('gradeTitle') or grade_title or ''
|
||||
try:
|
||||
for delta in stream_answer(
|
||||
v['userQuestion'], used_chunks or None, effective_grade,
|
||||
history=history, focus=focus, result=stream_result, age=age,
|
||||
):
|
||||
parts.append(delta)
|
||||
yield _sse('chunk', {'delta': delta})
|
||||
except GeneratorExit:
|
||||
# کاربر صفحه را وسطِ جواب بست. سوال خرج میماند (مدل هزینهاش را گرفته)، ولی
|
||||
# همان بخشی که ساخته شد ثبت میشود تا گفتگو سوالِ بیجواب نداشته باشد.
|
||||
self._save_partial_answer(conversation, user, v, ''.join(parts))
|
||||
raise
|
||||
provider_status.record_success()
|
||||
answer_text = ''.join(parts).strip() or 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم.'
|
||||
follow_ups = default_follow_ups()
|
||||
# مسیرِ استریم usage برنمیگرداند (پاسخ توکنبهتوکن است)،
|
||||
# پس فقط چیزی را ثبت میکنیم که واقعاً میدانیم؛ توکنها صفر
|
||||
# میمانند و گزارشِ هزینه هم همین را نشان میدهد.
|
||||
# توکنها از آخرین تکهی استریم میآیند (stream_options.include_usage).
|
||||
# تا ۲۰۲۶-۱۰-۰۱ اینجا صفر ثبت میشد، یعنی هزینهی واقعیِ اپ هیچجا
|
||||
# اندازهگیری نمیشد؛ providerی که usage نفرستد هنوز صفر میدهد.
|
||||
usage = stream_result.get('usage') or {}
|
||||
generation_meta = {
|
||||
'model': settings.OPENAI_CHAT_MODEL,
|
||||
'model': chat_model_for(effective_grade),
|
||||
'promptVersion': PROMPT_VERSION,
|
||||
'latencyMs': int((time.monotonic() - started) * 1000),
|
||||
'hadContext': bool(used_chunks),
|
||||
'diagram': stream_result.get('diagram'),
|
||||
'promptTokens': usage.get('promptTokens', 0),
|
||||
'completionTokens': usage.get('completionTokens', 0),
|
||||
}
|
||||
answer_cache.set(cache_key, {
|
||||
'answer': answer_text, 'followUps': follow_ups,
|
||||
'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta,
|
||||
'generationMeta': generation_meta,
|
||||
})
|
||||
except Exception:
|
||||
if stream_result.get('refused'):
|
||||
used_chunks = []
|
||||
answer_text = guardrails.REFUSAL
|
||||
elif cacheable:
|
||||
answer_cache.set(cache_key, {
|
||||
'answer': answer_text, 'followUps': follow_ups,
|
||||
'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta,
|
||||
'generationMeta': generation_meta,
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.exception('stream generate failed for user_id=%s', user.id)
|
||||
provider_status.record_failure(exc)
|
||||
self._release_reserved_slot(conversation, user_message, conversation_is_new)
|
||||
yield _sse('error', {'detail': AIProviderUnavailable.default_detail})
|
||||
return
|
||||
@@ -487,7 +707,7 @@ class AskAIView(APIView):
|
||||
with transaction.atomic():
|
||||
assistant_message = Message.objects.create(
|
||||
conversation=conversation, role='assistant', text=answer_text,
|
||||
question_id=v.get('questionId') or None,
|
||||
question_id=_db_ref_or_none(v.get('questionId')),
|
||||
**_annotation(retrieval_meta, generation_meta, from_cache=answer_from_cache),
|
||||
)
|
||||
if idempotency_key:
|
||||
@@ -510,17 +730,18 @@ class AskAIView(APIView):
|
||||
'conversationId': str(existing.conversation_id),
|
||||
'messageId': str(existing.assistant_message_id),
|
||||
'suggestedFollowUps': existing.suggested_follow_ups,
|
||||
'remainingQuota': max(DAILY_AI_QUOTA - used_today, 0),
|
||||
**quota_state(user.id),
|
||||
'diagram': existing.assistant_message.diagram,
|
||||
'usedChunks': [],
|
||||
})
|
||||
return
|
||||
|
||||
used_today += 1
|
||||
done = {
|
||||
'conversationId': str(conversation.id),
|
||||
'messageId': str(assistant_message.id),
|
||||
'suggestedFollowUps': follow_ups,
|
||||
'remainingQuota': max(DAILY_AI_QUOTA - used_today, 0),
|
||||
**quota_state(user.id),
|
||||
'diagram': assistant_message.diagram,
|
||||
'usedChunks': [
|
||||
{'title': c.get('title', ''), 'summary': c.get('summary', '')}
|
||||
for c in used_chunks
|
||||
@@ -542,6 +763,20 @@ class AskAIView(APIView):
|
||||
response['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _save_partial_answer(conversation, user, v, text):
|
||||
logger.info('stream closed by client mid-answer: user_id=%s conversation_id=%s chars=%s',
|
||||
user.id, conversation.id, len(text))
|
||||
if not text.strip():
|
||||
return
|
||||
try:
|
||||
Message.objects.create(
|
||||
conversation=conversation, role='assistant', text=text.strip() + ' …',
|
||||
question_id=_db_ref_or_none(v.get('questionId')),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('saving partial answer failed: conversation_id=%s', conversation.id)
|
||||
|
||||
def _release_reserved_slot(self, conversation, user_message, conversation_is_new):
|
||||
"""پاککردن رزرو فاز ۱ وقتی فاز بعدی شکست میخورد، تا نه پیام یتیم بماند و نه کوتا مصرفشده حساب شود."""
|
||||
with transaction.atomic():
|
||||
@@ -571,18 +806,15 @@ class AskAIView(APIView):
|
||||
idempotent بهخودیخود چیزی مصرف نکرده.
|
||||
"""
|
||||
conversation = existing.conversation
|
||||
today = timezone.localdate()
|
||||
used_today = Message.objects.filter(
|
||||
conversation__user_id=existing.user_id, role='user', created_at__date=today
|
||||
).count()
|
||||
remaining_quota = max(DAILY_AI_QUOTA - used_today, 0)
|
||||
state = quota_state(existing.user_id)
|
||||
|
||||
data = {
|
||||
'conversationId': str(conversation.id),
|
||||
'messageId': str(existing.assistant_message.id),
|
||||
'answer': existing.assistant_message.text,
|
||||
'answer': to_plain(existing.assistant_message.text),
|
||||
'suggestedFollowUps': existing.suggested_follow_ups,
|
||||
'remainingQuota': remaining_quota,
|
||||
'remainingQuota': state['remainingQuota'],
|
||||
'diagram': existing.assistant_message.diagram,
|
||||
}
|
||||
return AskResponseSerializer(data)
|
||||
|
||||
@@ -590,6 +822,10 @@ class AskAIView(APIView):
|
||||
class ConversationMessagesView(APIView):
|
||||
"""GET /api/ai/conversations/{id}/messages — تاریخچهی مکالمه"""
|
||||
|
||||
# سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `read` همان نرخِ مسیرهای همخانواده است.
|
||||
throttle_scope = 'read'
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(responses=ConversationMessagesResponseSerializer, tags=['AI Tutor (ai_tutor)'])
|
||||
@@ -603,7 +839,10 @@ class ConversationMessagesView(APIView):
|
||||
'conversationId': str(conversation.id),
|
||||
'lessonId': str(conversation.lesson_id) if conversation.lesson_id else None,
|
||||
'messages': [
|
||||
{'messageId': str(m.id), 'role': m.role, 'text': m.text} for m in messages
|
||||
{'messageId': str(m.id), 'role': m.role,
|
||||
# تورِ ایمنی: پیامِ قدیمی با LaTeX/markdown هم هرگز خام نمایش داده نمیشود.
|
||||
'text': to_plain(m.text) if m.role == 'assistant' else plain_convert(m.text), 'diagram': m.diagram}
|
||||
for m in messages
|
||||
],
|
||||
}
|
||||
return Response(ConversationMessagesResponseSerializer(data).data)
|
||||
@@ -612,6 +851,10 @@ class ConversationMessagesView(APIView):
|
||||
class MessageFeedbackView(APIView):
|
||||
"""POST /api/ai/messages/{id}/feedback — فیدبک 👍/👎"""
|
||||
|
||||
# سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `user_profile_write` همان نرخِ مسیرهای همخانواده است.
|
||||
throttle_scope = 'user_profile_write'
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
@@ -632,3 +875,19 @@ class MessageFeedbackView(APIView):
|
||||
defaults['reason'] = serializer.validated_data['reason']
|
||||
MessageFeedback.objects.update_or_create(message=message, defaults=defaults)
|
||||
return Response(FeedbackResponseSerializer({'success': True}).data)
|
||||
|
||||
|
||||
class QuotaView(APIView):
|
||||
"""
|
||||
GET /api/ai/quota — «امروز X سوال». اپ پیش از اولین سوال این را نشان میدهد؛ بعد از هر جواب همین عدد
|
||||
در خودِ پاسخ هم میآید (remainingQuota).
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
throttle_scope = 'read'
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
|
||||
@extend_schema(responses=QuotaResponseSerializer, tags=['AI Tutor (ai_tutor)'])
|
||||
def get(self, request):
|
||||
data = {'freeDaily': daily_quota(request.user.id), **quota_state(request.user.id)}
|
||||
return Response(QuotaResponseSerializer(data).data)
|
||||
|
||||
@@ -33,8 +33,8 @@ class MessageCampaignAdmin(admin.ModelAdmin):
|
||||
@admin.register(CampaignDelivery)
|
||||
class CampaignDeliveryAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
نمایشِ read-only: لیست و صفحهی جزئیاتِ هر رکورد همیشه قابلِ
|
||||
مشاهدهست (has_view_permission صریحاً True)، ولی هیچ راهی برای
|
||||
نمایشِ read-only: لیست و صفحهی جزئیات فقط با اجازهی view_campaigndelivery
|
||||
(رفتارِ پیشفرضِ جنگو)، و هیچ راهی برای
|
||||
افزودن، ویرایش یا حذفِ رکورد از ادمین وجود نداره تا وضعیتِ تحویل
|
||||
(که فقط باید توسطِ campaigns.services.execute_campaign آپدیت بشه)
|
||||
از ادمین دستکاری نشه.
|
||||
@@ -46,13 +46,6 @@ class CampaignDeliveryAdmin(admin.ModelAdmin):
|
||||
fields = ('campaign', 'user_id', 'channel', 'status', 'error_message', 'created_at')
|
||||
readonly_fields = fields
|
||||
|
||||
def has_view_permission(self, request, obj=None):
|
||||
# صریحاً True: بدونِ override، fallbackِ پیشفرضِ Django به کاربرِ
|
||||
# staffِ بدونِ پرمیشنِ view/change حتی اجازهی دیدنِ این صفحهی
|
||||
# read-only رو هم نمیده — یعنی مدل عملاً غیرقابلِدسترس میشه، نه
|
||||
# فقط view-only.
|
||||
return True
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
|
||||
@@ -20,16 +20,20 @@ from notifications.services import notify
|
||||
from .models import CampaignDelivery, MessageCampaign
|
||||
|
||||
|
||||
def _retry_on_sqlite_lock(fn, *, retries=5, base_delay=0.05):
|
||||
def _retry_on_sqlite_lock(fn, *, retries=8, base_delay=0.05, max_delay=0.4):
|
||||
"""
|
||||
روی SQLite (مخصوصاً دیتابیسِ حافظهایِ shared-cache که تستها ازش
|
||||
استفاده میکنن)، وقتی دو اتصالِ همزمان میخوان همون جدول رو بنویسن،
|
||||
ممکنه OperationalError('database table is locked') بگیریم. این
|
||||
SQLITE_LOCKEDه نه SQLITE_BUSY، پس busy_timeoutِ معمولیِ اتصال کمکی
|
||||
نمیکنه و بلافاصله raise میشه؛ یک خطای واقعیِ داده نیست، فقط
|
||||
تناقضِ لحظهایِ لاکه. این تابع فقط همین خطای گذرا رو با چند بار
|
||||
تلاشِ سریع (backoffِ کوچیکِ فزاینده) دوباره امتحان میکنه؛ هر
|
||||
روی SQLite وقتی دو اتصالِ همزمان میخوان همون جدول رو بنویسن، ممکنه
|
||||
OperationalError('database table is locked') بگیریم. این SQLITE_LOCKEDه
|
||||
نه SQLITE_BUSY، پس busy_timeoutِ اتصال کمکی نمیکنه و بلافاصله raise
|
||||
میشه؛ خطای واقعیِ داده نیست، فقط تناقضِ لحظهایِ لاک. هر
|
||||
OperationalError دیگه (یا شکستِ تلاشِ آخر) دستنخورده بالا میره.
|
||||
|
||||
⚠️ پنجرهی انتظار باید از خودِ قفل درازتر باشد، وگرنه محافظ فقط *گاهی*
|
||||
کار میکند. نسخهی اول ۵ تلاش با backoffِ خطی داشت — رویِهم ۰٫۵ ثانیه —
|
||||
و تستِ همزمانیِ campaigns را در حدودِ نیمی از اجراهای کاملِ سوئیت قرمز
|
||||
میکرد، چون زیرِ بارِ کلِ تستها قفل بیشتر از آن طول میکشد. حالا
|
||||
backoff نمایی است و تا ~۲ ثانیه صبر میکند؛ سقفِ max_delay هم هست تا
|
||||
یک قفلِ طولانی، درخواستِ کاربر را بینهایت معلق نگه ندارد.
|
||||
"""
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
@@ -37,7 +41,7 @@ def _retry_on_sqlite_lock(fn, *, retries=5, base_delay=0.05):
|
||||
except OperationalError as exc:
|
||||
if 'locked' not in str(exc).lower() or attempt == retries - 1:
|
||||
raise
|
||||
time.sleep(base_delay * (attempt + 1))
|
||||
time.sleep(min(base_delay * (2 ** attempt), max_delay))
|
||||
|
||||
# عمداً به notifications.models.Notification.TYPE_CHOICES اضافه نشده: آن
|
||||
# choices فقط رویدادهاییه که notifications بهعنوانِ listener گوش میده،
|
||||
@@ -220,16 +224,26 @@ def execute_campaign(campaign: MessageCampaign) -> None:
|
||||
already_sent_user_ids = _already_sent_user_ids(campaign)
|
||||
|
||||
if campaign.channel == 'in_app':
|
||||
for user in users.only('id').iterator():
|
||||
if user.id in already_sent_user_ids:
|
||||
# ⚠️ عمداً list() و نه .iterator(): کرسرِ باز باید *پیش از* اولین
|
||||
# نوشتن بسته شود. با .iterator() خواندنِ کاربرها در تمامِ طولِ حلقه
|
||||
# باز میماند و روی SQLite همان، جدول را برای هر نویسندهی همزمان
|
||||
# قفل میکند — قفلی که تا پایانِ حلقه رها نمیشود، پس هیچ مقدار
|
||||
# retry هم نجاتش نمیدهد. این دقیقاً همان چیزی بود که تستِ
|
||||
# همزمانیِ campaigns را در حدودِ نیمی از اجراها قرمز میکرد.
|
||||
#
|
||||
# حافظه: اینجا فقط idها نگه داشته میشوند و این ماژول طبقِ
|
||||
# docstringِ بالا برای لیستهای کوچک است، نه کمپینِ انبوه.
|
||||
for user_id in list(users.values_list('id', flat=True)):
|
||||
if user_id in already_sent_user_ids:
|
||||
continue
|
||||
delivery = _claim_delivery(campaign, user.id)
|
||||
delivery = _claim_delivery(campaign, user_id)
|
||||
if delivery is None:
|
||||
continue
|
||||
def _notify_and_mark_sent():
|
||||
|
||||
def _notify_and_mark_sent(user_id=user_id, delivery=delivery):
|
||||
with transaction.atomic():
|
||||
notify(
|
||||
user_id=user.id,
|
||||
user_id=user_id,
|
||||
type=CAMPAIGN_NOTIFICATION_TYPE,
|
||||
title=campaign.title,
|
||||
body=campaign.body,
|
||||
@@ -251,16 +265,19 @@ def execute_campaign(campaign: MessageCampaign) -> None:
|
||||
return
|
||||
|
||||
if campaign.channel == 'sms':
|
||||
for user in users.only('id', 'phone').iterator():
|
||||
if user.id in already_sent_user_ids:
|
||||
# همان دلیلِ شاخهی in_app بالا، و اینجا حتی مهمتر: وسطِ این حلقه
|
||||
# یک فراخوانیِ شبکهای به کاوهنگار انجام میشود، پس کرسرِ باز
|
||||
# میتوانست بهاندازهی کلِ ارسالِ پیامکها جدول را قفل نگه دارد.
|
||||
for user_id, phone in list(users.values_list('id', 'phone')):
|
||||
if user_id in already_sent_user_ids:
|
||||
continue
|
||||
if not user.phone:
|
||||
if not phone:
|
||||
continue
|
||||
delivery = _claim_delivery(campaign, user.id)
|
||||
delivery = _claim_delivery(campaign, user_id)
|
||||
if delivery is None:
|
||||
continue
|
||||
try:
|
||||
account_services.send_sms(phone=user.phone, message=campaign.body)
|
||||
account_services.send_sms(phone=phone, message=campaign.body)
|
||||
except SMSDeliveryError as exc:
|
||||
_mark_delivery_result(delivery, error=exc)
|
||||
else:
|
||||
|
||||
@@ -52,8 +52,8 @@ class ExecuteCampaignIdempotencyTests(TestCase):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.user1 = User.objects.create(name='u1', is_guest=False, phone='0910000001')
|
||||
self.user2 = User.objects.create(name='u2', is_guest=False, phone='0910000002')
|
||||
self.user1 = User.objects.create(name='u1', phone='0910000001')
|
||||
self.user2 = User.objects.create(name='u2', phone='0910000002')
|
||||
|
||||
def test_in_app_second_run_skips_already_sent_users(self):
|
||||
campaign = MessageCampaign.objects.create(
|
||||
@@ -187,7 +187,7 @@ class CampaignDeliveryConcurrencyTests(TestCase):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(name='u', is_guest=False, phone='0910000003')
|
||||
self.user = User.objects.create(name='u', phone='0910000003')
|
||||
self.campaign = MessageCampaign.objects.create(
|
||||
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
|
||||
)
|
||||
@@ -261,7 +261,7 @@ class ExecuteCampaignRealConcurrencyTests(TransactionTestCase):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(name='u', is_guest=False, phone='0910000004')
|
||||
self.user = User.objects.create(name='u', phone='0910000004')
|
||||
self.campaign = MessageCampaign.objects.create(
|
||||
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
|
||||
)
|
||||
@@ -306,7 +306,7 @@ class InAppDeliveryConsistencyTests(TestCase):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(name='u', is_guest=False, phone='0910000005')
|
||||
self.user = User.objects.create(name='u', phone='0910000005')
|
||||
self.campaign = MessageCampaign.objects.create(
|
||||
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
|
||||
)
|
||||
@@ -437,7 +437,7 @@ class CampaignDeliveryAdminReadOnlyTests(TestCase):
|
||||
username='admin3', email='admin3@example.com', password='pass12345',
|
||||
)
|
||||
self.client.force_login(self.staff_user)
|
||||
self.user = User.objects.create(name='u', is_guest=False, phone='0910000009')
|
||||
self.user = User.objects.create(name='u', phone='0910000009')
|
||||
self.campaign = MessageCampaign.objects.create(title='t', body='b', channel='sms')
|
||||
self.delivery = CampaignDelivery.objects.create(
|
||||
campaign=self.campaign, user_id=self.user.id, channel='sms',
|
||||
|
||||
30
campaigns/tests_security_review.py
Normal file
30
campaigns/tests_security_review.py
Normal 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
129
config/cache.py
Normal 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
53
config/db.py
Normal 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', ''))
|
||||
@@ -7,6 +7,9 @@ JSON. این هندلر اون حالت رو با traceback کامل لاگ می
|
||||
"""
|
||||
import logging
|
||||
|
||||
from django.core.exceptions import RequestDataTooBig, SuspiciousOperation
|
||||
|
||||
from config.db import SERVER_BUSY_BODY, SERVER_BUSY_RETRY_AFTER, is_pool_timeout, log_server_busy
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import exception_handler as drf_exception_handler
|
||||
|
||||
@@ -19,11 +22,28 @@ def custom_exception_handler(exc, context):
|
||||
return response
|
||||
|
||||
request = context.get('request')
|
||||
path = getattr(request, 'path', '')
|
||||
|
||||
# بدنهی بزرگتر از DATA_UPLOAD_MAX_MEMORY_SIZE (≈ ۲٫۵ مگابایت): Django خودش این را ۴۰۰ میکند، ولی
|
||||
# چون DRF آن را نمیشناسد پیشتر اینجا «۵۰۰ + ERRORِ لاگ با traceback» میشد و هر درخواستِ بزرگ لاگ را پر
|
||||
# میکرد (۲۰۲۶-۱۰-۰۷ روی سرورِ زنده دیده شد). باقیِ SuspiciousOperationها هم همان ۴۰۰ِ Django را میگیرند.
|
||||
# poolِ دیتابیس پر (config/db.py): ۵۰۳ با Retry-After. پیش از خواندنِ request.user پایینتر، که اگر
|
||||
# احرازِ هویت خودش همین خطا را داده بود دوباره منتظرِ pool میماند.
|
||||
if is_pool_timeout(exc):
|
||||
log_server_busy(request)
|
||||
return Response(SERVER_BUSY_BODY, status=503, headers={'Retry-After': str(SERVER_BUSY_RETRY_AFTER)})
|
||||
if isinstance(exc, RequestDataTooBig):
|
||||
logger.warning('request_too_large path=%s', path)
|
||||
return Response({'detail': 'حجمِ درخواست بیش از حدِ مجاز است.'}, status=413)
|
||||
if isinstance(exc, SuspiciousOperation):
|
||||
logger.warning('suspicious_operation path=%s type=%s', path, type(exc).__name__)
|
||||
return Response({'detail': 'درخواستِ نامعتبر.'}, status=400)
|
||||
|
||||
user = getattr(request, 'user', None)
|
||||
user_id = user.id if getattr(user, 'is_authenticated', False) else None
|
||||
logger.exception(
|
||||
'unhandled_exception path=%s user_id=%s',
|
||||
getattr(request, 'path', ''), user_id,
|
||||
path, user_id,
|
||||
)
|
||||
return Response(
|
||||
{'detail': 'خطای غیرمنتظرهای رخ داد؛ لطفاً بعداً دوباره تلاش کنید.'},
|
||||
|
||||
306
config/health.py
306
config/health.py
@@ -12,6 +12,11 @@ GET /api/health — «آیا این سرور واقعاً سالم است؟»
|
||||
|
||||
عمداً هیچ سکرتی برنمیگرداند: فقط بله/خیرِ «تنظیم شده» و شمارشِ محتوا.
|
||||
"""
|
||||
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
|
||||
@@ -22,6 +27,8 @@ from rest_framework.response import Response
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _check_database() -> dict:
|
||||
try:
|
||||
@@ -39,10 +46,11 @@ def _check_migrations() -> dict:
|
||||
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'{app}.{name}' for (app, name), _ in
|
||||
((m.key, m) for m, _ in 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__}
|
||||
@@ -78,15 +86,258 @@ def _check_content() -> dict:
|
||||
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]:
|
||||
"""
|
||||
چیزهایی که خرابی نیستند ولی روی یک سرورِ عمومی خطرناکاند.
|
||||
@@ -103,6 +354,29 @@ def _warnings() -> list[str]:
|
||||
)
|
||||
if getattr(settings, 'DEV_MASTER_OTP', None) is not None:
|
||||
out.append('کدِ یکسانِ ورود (DEV_MASTER_OTP) فعال است — هر کسی میتواند بهجای هر کاربری وارد شود.')
|
||||
test_phones = getattr(settings, 'TEST_LOGIN_PHONES', {})
|
||||
if test_phones:
|
||||
# نه خودِ شماره و نه کد برنمیگردد؛ فقط تعداد، تا کسی که تنظیماتِ سرور
|
||||
# را ندیده هم بداند چند حسابِ تست با کدِ ثابت باز است و بتواند ببنددشان.
|
||||
# تعداد عمداً گفته نمیشود: این صفحه عمومی است و «۷ حسابِ کدِ ثابت» برای
|
||||
# مهاجم یعنی هدفِ مشخص برای حدس زدن.
|
||||
out.append(
|
||||
'شمارههای تستِ ورود با کدِ ثابت فعالاند (TEST_LOGIN_PHONES). روی سرور باید خالی باشد: '
|
||||
'هر کس کد را دیده باشد وارد میشود. برای آزمون یک OTPCode در پنلِ ادمین بسازید.'
|
||||
)
|
||||
if not settings.DEBUG and settings.SECRET_KEY.startswith('django-insecure'):
|
||||
# بزرگترین ریسکِ باز (docs/security-before-production.md): این کلید در گیت است و هر
|
||||
# کس ریپو را ببیند برای هر کاربری توکن میسازد. فقط بله/خیر، نه خودِ کلید.
|
||||
out.append(
|
||||
'کلیدِ امضای توکن همان کلیدِ کامیتشده در ریپو است (django-insecure): هر کس ریپو را '
|
||||
'ببیند میتواند بهجای هر کاربری وارد شود. راهِ رفع در docs/security-before-production.md.'
|
||||
)
|
||||
if _ai_provider_state().get('state') == 'quota_exhausted':
|
||||
out.append(
|
||||
'اعتبارِ سرویسِ هوش مصنوعی (GapGPT) تمام شده: دستیار برای همهی کاربرها '
|
||||
'«در دسترس نیست» میدهد تا حساب شارژ شود.'
|
||||
)
|
||||
out.extend(_app_log_warnings())
|
||||
backend = settings.CACHES['default']['BACKEND']
|
||||
if not settings.DEBUG and 'LocMem' in backend:
|
||||
out.append('کش محلیِ هر پروسه است؛ محدودیتِ نرخ بینِ workerها مشترک نیست.')
|
||||
@@ -111,6 +385,9 @@ def _warnings() -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
from payment.permissions import HasInternalServiceKey # noqa: E402
|
||||
|
||||
|
||||
class HealthView(APIView):
|
||||
"""
|
||||
GET /api/health — وضعیتِ این نسخهی مستقرشده.
|
||||
@@ -134,13 +411,32 @@ class HealthView(APIView):
|
||||
}
|
||||
critical_ok = all(checks[k]['ok'] for k in ('database', 'migrations', 'cache'))
|
||||
|
||||
warnings = _warnings()
|
||||
status = 200 if critical_ok else 503
|
||||
# عموم فقط ok/error میبینند؛ جزئیات (هشدارها، ترافیک، سرویسهای وصلشده، IP) پشتِ هدرِ
|
||||
# X-Internal-Key. کد ۲۰۰/۵۰۳ عمومی میماند تا پایشِ بیرونی کار کند. اگر INTERNAL_SERVICE_KEY
|
||||
# در .env نباشد، هیچکس جزئیات را نمیبیند (نه اینکه همه ببینند).
|
||||
if not HasInternalServiceKey().has_permission(request, self):
|
||||
return Response({'status': 'ok' if critical_ok else 'error'}, status=status)
|
||||
|
||||
offsite = _offsite_backup()
|
||||
db_pool = _db_pool()
|
||||
warnings = _warnings() + _offsite_warnings(offsite) + _db_pool_warnings(db_pool)
|
||||
payload = {
|
||||
'status': 'ok' if critical_ok else 'error',
|
||||
'debug': bool(settings.DEBUG),
|
||||
'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=200 if critical_ok else 503)
|
||||
return Response(payload, status=status)
|
||||
|
||||
@@ -89,3 +89,136 @@ class APIRequestLoggingMiddleware:
|
||||
log_func(message, request.method, request.path, status, duration_ms, _user_id_for_log(request))
|
||||
except Exception:
|
||||
logger.exception('api_request logging failed')
|
||||
|
||||
|
||||
class TrustedProxyMiddleware:
|
||||
"""
|
||||
X-Forwarded-For فقط وقتی پذیرفته میشود که درخواست واقعاً از ArvanCloud آمده باشد.
|
||||
|
||||
NUM_PROXIES=2 فرض میکند همیشه آروان و nginx جلوی ما هستند؛ ولی nginxِ این ماشین روی IPِ خودش
|
||||
(پورت ۸۰ و ۴۴۳) هم جواب میدهد. کسی که مستقیم به IP بزند و در هر درخواست یک XFFِ تازه بفرستد،
|
||||
برای DRF هر بار «کاربرِ تازه» بود و از همهی سقفهای IP-محور (درخواستِ کد، حدسِ کد، ورودِ ادمین)
|
||||
رد میشد (۲۰۲۶-۱۰-۱۳ روی سرور بازتولید شد: ۶۵ از ۶۵ درخواستِ health با XFFِ متفاوت ۲۰۰).
|
||||
|
||||
X-Real-IP را nginx خودش از آدرسِ TCP میگذارد (`proxy_params`ِ سایتِ `mathapp`؛ مقدارِ کلاینت را
|
||||
بازنویسی میکند)، پس جعلپذیر نیست. اگر آن آدرس در TRUSTED_PROXY_NETWORKS نیست، XFF دور ریخته و
|
||||
خودِ آن آدرس IPِ کاربر میشود. بی X-Real-IP (runserverِ محلی، تستها) دست نمیزند.
|
||||
سنجیده نشد: رنجهای IPv6ِ آروان (فهرستِ آروان در این تاریخ فقط IPv4 داشت).
|
||||
"""
|
||||
|
||||
WARN_EVERY_SECONDS = 60 * 60
|
||||
|
||||
def __init__(self, get_response):
|
||||
from django.conf import settings
|
||||
self.get_response = get_response
|
||||
self.networks = settings.TRUSTED_PROXY_NETWORKS
|
||||
|
||||
def _trusted(self, peer: str) -> bool:
|
||||
import ipaddress
|
||||
try:
|
||||
address = ipaddress.ip_address(peer)
|
||||
except ValueError:
|
||||
return False
|
||||
return any(address in network for network in self.networks)
|
||||
|
||||
def __call__(self, request):
|
||||
peer = request.META.get('HTTP_X_REAL_IP', '').strip()
|
||||
if peer and not self._trusted(peer):
|
||||
forwarded = request.META.get('HTTP_X_FORWARDED_FOR', '')
|
||||
# XFF با دو مقدار یعنی پراکسیِ دیگری جلوی nginx بوده: یا کسی XFF جعل کرده، یا آروان رنجِ
|
||||
# تازهای گرفته که در فهرست نیست. دومی همهی کاربرانِ آن edge را یک IP میکند و سقفها برایشان
|
||||
# زود پر میشود؛ پس هر peer ساعتی یک بار WARNING میگیرد تا در لاگ دیده شود.
|
||||
if forwarded.count(',') >= 1:
|
||||
from django.core.cache import cache
|
||||
if cache.add(f'untrusted_proxy_warned:{peer}', 1, self.WARN_EVERY_SECONDS):
|
||||
logger.warning('X-Forwarded-For from a peer outside TRUSTED_PROXY_NETWORKS ignored: '
|
||||
'peer=%s (a new ArvanCloud range would also look like this)', peer)
|
||||
request.META['HTTP_X_FORWARDED_FOR'] = peer
|
||||
return self.get_response(request)
|
||||
|
||||
|
||||
class APINoStoreMiddleware:
|
||||
"""
|
||||
هر پاسخِ `/api/` هدرِ `Cache-Control: no-store` میگیرد، مگر خودِ view
|
||||
چیزِ دیگری گذاشته باشد (مثلاً استریمِ دستیار که `no-cache` دارد).
|
||||
|
||||
ArvanCloud جلوی سرور است و الان برای API کش نمیکند (`x-cache: BYPASS`)،
|
||||
ولی این فقط به تنظیمِ پنلِ آروان بند است. پاسخهای API به کاربر وابستهاند
|
||||
(توکنِ Bearer)؛ اگر روزی کسی کش را در پنل روشن کند، بدونِ این هدر یک
|
||||
دانشآموز میتوانست `/api/home` یا پروفایلِ دیگری را ببیند.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
response = self.get_response(request)
|
||||
if request.path.startswith(_LOGGED_PATH_PREFIX) and not response.has_header('Cache-Control'):
|
||||
response['Cache-Control'] = 'private, no-store'
|
||||
return response
|
||||
|
||||
|
||||
class DatabaseBusyMiddleware:
|
||||
"""
|
||||
poolِ دیتابیسِ پر → ۵۰۳ با Retry-After بهجای ۵۰۰ (config/db.py) برای viewهای غیرِ DRF مثلِ /admin/؛
|
||||
viewهای DRF را هندلرِ خطای DRF (config/exceptions.py) جواب میدهد، چون هر خطا را خودش ۵۰۰ میکند
|
||||
و استثنا هیچوقت به اینجا نمیرسد. فقط خطای pool را میگیرد؛ هر OperationalErrorِ دیگری (Postgres
|
||||
پایین، کوئریِ timeoutخورده) همان ۵۰۰ِ قبلی میماند.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
return self.get_response(request)
|
||||
|
||||
def process_exception(self, request, exception):
|
||||
from django.http import JsonResponse
|
||||
|
||||
from config.db import SERVER_BUSY_BODY, SERVER_BUSY_RETRY_AFTER, is_pool_timeout, log_server_busy
|
||||
|
||||
if not is_pool_timeout(exception):
|
||||
return None
|
||||
log_server_busy(request)
|
||||
response = JsonResponse(SERVER_BUSY_BODY, status=503)
|
||||
response['Retry-After'] = str(SERVER_BUSY_RETRY_AFTER)
|
||||
return response
|
||||
|
||||
|
||||
class AdminLoginThrottleMiddleware:
|
||||
"""
|
||||
سقفِ تلاشِ ورود به /admin/ برای هر IP (ADMIN_LOGIN_MAX_ATTEMPTS در ۱۵ دقیقه).
|
||||
|
||||
پنلِ ادمین روی دامنهی عمومی است و فرمِ ورودِ جنگو هیچ محدودیتی ندارد؛
|
||||
یعنی رمزِ ادمین (که همهی محتوا و پرداختها را کنترل میکند) بیسقف قابلِ
|
||||
حدس زدن بود. شمارش در کشِ پیشفرض است: تا CACHE_URL (Redis) تنظیم نشده،
|
||||
برای هر worker جدا — یعنی سقفِ واقعی تا سه برابر. IP با همان منطقِ
|
||||
NUM_PROXIESِ throttleهای DRF خوانده میشود.
|
||||
"""
|
||||
|
||||
WINDOW_SECONDS = 15 * 60
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
if request.method == 'POST' and request.path.startswith('/admin/login'):
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpResponse
|
||||
from rest_framework.throttling import BaseThrottle
|
||||
|
||||
key = f'admin_login:{BaseThrottle().get_ident(request)}'
|
||||
cache.add(key, 0, self.WINDOW_SECONDS)
|
||||
try:
|
||||
attempts = cache.incr(key)
|
||||
except ValueError:
|
||||
cache.set(key, 1, self.WINDOW_SECONDS)
|
||||
attempts = 1
|
||||
if attempts > getattr(settings, 'ADMIN_LOGIN_MAX_ATTEMPTS', 10):
|
||||
logger.warning('admin login throttled: ident=%s attempts=%s', key, attempts)
|
||||
return HttpResponse(
|
||||
'تلاشِ ورود بیش از حد مجاز است؛ ۱۵ دقیقهی دیگر دوباره امتحان کنید.',
|
||||
status=429, content_type='text/plain; charset=utf-8',
|
||||
)
|
||||
return self.get_response(request)
|
||||
|
||||
24
config/renderers.py
Normal file
24
config/renderers.py
Normal 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
|
||||
@@ -10,8 +10,11 @@ For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
@@ -51,38 +54,129 @@ if not SECRET_KEY:
|
||||
else:
|
||||
import secrets
|
||||
|
||||
import time
|
||||
|
||||
# سه workerِ gunicorn همزمان به اینجا میرسند. فایل فقط یک بار و اتمی ساخته میشود
|
||||
# (O_EXCL، و نوشتن در فایلِ موقت + rename)، و بقیه همان را میخوانند؛ در غیرِ این صورت هر
|
||||
# worker کلیدِ خودش را میساخت و توکن فقط روی یکی از آنها معتبر بود. سنجیده با سه
|
||||
# پروسهی همزمان، نه با gunicornِ واقعی.
|
||||
_key_file = BASE_DIR / '.django_secret_key'
|
||||
try:
|
||||
SECRET_KEY = _key_file.read_text().strip()
|
||||
except OSError:
|
||||
SECRET_KEY = ''
|
||||
if not SECRET_KEY:
|
||||
SECRET_KEY = secrets.token_urlsafe(64)
|
||||
_lock_file = BASE_DIR / '.django_secret_key.lock'
|
||||
SECRET_KEY = ''
|
||||
for _attempt in range(50):
|
||||
try:
|
||||
_key_file.write_text(SECRET_KEY)
|
||||
_key_file.chmod(0o600)
|
||||
SECRET_KEY = _key_file.read_text().strip()
|
||||
except FileNotFoundError:
|
||||
SECRET_KEY = ''
|
||||
except OSError as exc:
|
||||
raise ImproperlyConfigured(f'خواندنِ {_key_file} ممکن نشد ({exc}).') from exc
|
||||
if SECRET_KEY:
|
||||
break
|
||||
try:
|
||||
_fd = os.open(_lock_file, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||
except FileExistsError:
|
||||
time.sleep(0.1) # پروسهی دیگری در حالِ ساختنِ کلید است
|
||||
continue
|
||||
except OSError as exc:
|
||||
raise ImproperlyConfigured(
|
||||
f'DJANGO_SECRET_KEY ست نیست و نوشتنِ {_key_file} هم ممکن نشد ({exc}). '
|
||||
f'DJANGO_SECRET_KEY ست نیست و ساختنِ {_key_file} هم ممکن نشد ({exc}). '
|
||||
'یا متغیر را ست کنید یا اجازهی نوشتن در پوشهی پروژه بدهید.'
|
||||
) from exc
|
||||
try:
|
||||
os.close(_fd)
|
||||
if not _key_file.exists():
|
||||
_tmp = BASE_DIR / '.django_secret_key.tmp'
|
||||
_tmp_fd = os.open(_tmp, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
|
||||
with os.fdopen(_tmp_fd, 'w') as fh:
|
||||
fh.write(secrets.token_urlsafe(64))
|
||||
os.replace(_tmp, _key_file)
|
||||
finally:
|
||||
_lock_file.unlink(missing_ok=True)
|
||||
if not SECRET_KEY:
|
||||
raise ImproperlyConfigured(
|
||||
f'کلیدِ امضا ساخته نشد: {_lock_file} مانده است. اگر هیچ پروسهای در حالِ اجرا نیست، '
|
||||
'آن را پاک کنید و دوباره راهاندازی کنید.'
|
||||
)
|
||||
|
||||
# دامنههای شناختهشدهی خودمان پیشفرضاند تا یک متغیرِ فراموششده باعثِ
|
||||
# DisallowedHost نشود. متغیرِ محیطی، اگر ست باشد، کاملاً جایگزینشان میکند.
|
||||
def _env_list(name: str) -> list[str]:
|
||||
return [v.strip() for v in os.environ.get(name, '').split(',') if v.strip()]
|
||||
|
||||
|
||||
def _with_defaults(name: str, defaults: list[str]) -> list[str]:
|
||||
"""
|
||||
مقدارِ محیطی + پیشفرضهای خودمان، بدونِ تکرار و با حفظِ ترتیب.
|
||||
|
||||
چرا اضافه و نه جایگزین: فایلِ .env روی سرور از قبل وجود دارد و مقدارهای
|
||||
ناقص دارد (مثلاً DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1). اگر مقدارِ
|
||||
موجود پیشفرض را کنار میزد، همان خطای قدیمی برمیگشت و باز کسی باید
|
||||
دستی فایل را ویرایش میکرد. دامنههای خودمان همیشه باید مجاز باشند.
|
||||
"""
|
||||
seen, out = set(), []
|
||||
for value in _env_list(name) + defaults:
|
||||
if value not in seen:
|
||||
seen.add(value)
|
||||
out.append(value)
|
||||
return out
|
||||
|
||||
|
||||
# --- دامنهها ----------------------------------------------------------------
|
||||
# دو آدرسِ عمومی؛ تنها جای کد که دامنه در آن نوشته شده. ALLOWED_HOSTS، CSRF،
|
||||
# CORS، callbackِ زرینپال، برگشت از پرداخت و redirectِ بازار همه از همین دو
|
||||
# ساخته میشوند. برای جابهجایی دامنه فقط این دو را در .env عوض کنید؛ جزئیات
|
||||
# و ترتیبِ کار در docs/operations.md («عوض کردنِ دامنه»).
|
||||
#
|
||||
# پیشفرض دارند چون خالی بودنشان سرویس را نمیخواباند، فقط مسیرِ پرداخت و
|
||||
# فرانت را بیصدا میشکند. `.strip() or` لازم است: کلید ممکن است در .env
|
||||
# باشد ولی خالی، و os.environ.get فقط وقتی کلید *نباشد* پیشفرض میدهد.
|
||||
PUBLIC_BASE_URL = (
|
||||
os.environ.get('PUBLIC_BASE_URL', '').strip() or 'https://mathapi.tadrisino2.ir'
|
||||
).rstrip('/')
|
||||
# آدرسِ خودِ اپ (فرانت)؛ برگشت از درگاه و CORS از آن ساخته میشوند.
|
||||
FRONTEND_URL = (
|
||||
os.environ.get('FRONTEND_URL', '').strip() or 'https://math.tadrisino2.ir'
|
||||
).rstrip('/')
|
||||
|
||||
|
||||
def _origin(url):
|
||||
parts = urlsplit(url)
|
||||
return f'{parts.scheme}://{parts.netloc}' if parts.scheme and parts.netloc else ''
|
||||
|
||||
|
||||
def _hostname(url):
|
||||
return urlsplit(url).hostname or ''
|
||||
|
||||
|
||||
# دامنههای خودمان (از دو آدرسِ بالا) همیشه مجازند تا یک .env قدیمی یا ناقص
|
||||
# باعثِ DisallowedHost نشود؛ هر چه در محیط باشد به اینها اضافه میشود. در دورانِ
|
||||
# جابهجاییِ دامنه، دامنهی قبلی را در DJANGO_ALLOWED_HOSTS نگه دارید تا تا
|
||||
# عوض شدنِ DNS هر دو جواب بدهند.
|
||||
DEFAULT_ALLOWED_HOSTS = [
|
||||
'api.math.tadrisino2.ir',
|
||||
'math.tadrisino2.ir',
|
||||
'localhost', '127.0.0.1',
|
||||
]
|
||||
ALLOWED_HOSTS = [
|
||||
h for h in os.environ.get('DJANGO_ALLOWED_HOSTS', '').split(',') if h
|
||||
] or DEFAULT_ALLOWED_HOSTS
|
||||
h for h in (_hostname(PUBLIC_BASE_URL), _hostname(FRONTEND_URL)) if h
|
||||
] + ['localhost', '127.0.0.1']
|
||||
ALLOWED_HOSTS = _with_defaults('DJANGO_ALLOWED_HOSTS', DEFAULT_ALLOWED_HOSTS)
|
||||
|
||||
# لازم برای ورود به پنلِ ادمین از پشتِ پراکسیِ HTTPS؛ بدونش فرمِ ورود
|
||||
# «CSRF verification failed» میدهد و کسی نمیفهمد چرا.
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
o for o in os.environ.get('CSRF_TRUSTED_ORIGINS', '').split(',') if o
|
||||
] or ['https://api.math.tadrisino2.ir', 'https://math.tadrisino2.ir']
|
||||
CSRF_TRUSTED_ORIGINS = _with_defaults(
|
||||
'CSRF_TRUSTED_ORIGINS',
|
||||
[o for o in (_origin(PUBLIC_BASE_URL), _origin(FRONTEND_URL)) if o],
|
||||
)
|
||||
|
||||
# --- امنیتِ پشتِ پراکسی ---------------------------------------------------
|
||||
# TLS روی nginx/ArvanCloud تمام میشود و جنگو درخواست را HTTP میبیند؛ بدونِ
|
||||
# این هدر، کوکیهای امن هرگز ست نمیشوند و ورود به پنلِ ادمین میشکند.
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
|
||||
# فقط در پروداکشن: کوکیِ نشستِ ادمین و توکنِ CSRF نباید روی HTTP رد و بدل شوند.
|
||||
# (احرازِ هویتِ API ما کوکی نیست — اینها برای پنلِ ادمیناند، یعنی همانجایی
|
||||
# که اعتبارنامهی واقعی وارد میشود.)
|
||||
SESSION_COOKIE_SECURE = not DEBUG
|
||||
CSRF_COOKIE_SECURE = not DEBUG
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
|
||||
# SECURE_SSL_REDIRECT و HSTS عمداً روشن نشدهاند: اولی پشتِ پراکسیِ بدتنظیم
|
||||
# حلقهی ریدایرکت میسازد و کلِ سایت را میخواباند، دومی هم در مرورگرِ کاربر
|
||||
# کش میشود و عملاً برگشتناپذیر است. هر دو تصمیمِ زیرساختیاند، نه کد.
|
||||
|
||||
# --- CORS -------------------------------------------------------------------
|
||||
# فرانتاند (Vite/React) از یک origin دیگر صدا میزند، پس بدونِ این هدرها مرورگر
|
||||
@@ -96,14 +190,25 @@ CSRF_TRUSTED_ORIGINS = [
|
||||
# تنها راهش env بود، یک دیپلویِ فراموششده یعنی بکاندِ سالم که مرورگر همهی
|
||||
# درخواستهایش را بلاک میکند — خطایی که در سمتِ سرور هیچ ردی نمیگذارد و فقط
|
||||
# در کنسولِ مرورگر دیده میشود.
|
||||
CORS_DEFAULT_ORIGINS = [
|
||||
'https://math.tadrisino2.ir', # فرانتِ مستقر
|
||||
'http://localhost:3000', 'http://127.0.0.1:3000',
|
||||
'http://localhost:5173', 'http://127.0.0.1:5173', # Vite/CRA محلی
|
||||
CORS_DEFAULT_ORIGINS = [o for o in (_origin(FRONTEND_URL),) if o] # فرانتِ مستقر
|
||||
CORS_ALLOWED_ORIGINS = _with_defaults('CORS_ALLOWED_ORIGINS', CORS_DEFAULT_ORIGINS)
|
||||
|
||||
# ⚠️ پورتِ سرورِ توسعه را نباید فهرست کرد. فهرستِ ثابتِ قبلی (۳۰۰۰ و ۵۱۷۳) روزی
|
||||
# شکست که Vite دید ۵۱۷۳ اشغال است و بیسروصدا رفت روی ۵۱۷۴ — رفتارِ پیشفرضِ
|
||||
# خودش. آنوقت فرانتکار خطای CORS گرفت، از سمتِ سرور هیچ ردی در هیچ لاگی
|
||||
# نبود، و چون همزمان با تغییراتِ ما شد، به اشتباه گردنِ بکاند افتاد.
|
||||
# هر بار که این تکرار شود یعنی یک ویرایشِ .env و یک ریاستارتِ پروداکشن برای
|
||||
# چیزی که اصلاً به پروداکشن ربطی ندارد.
|
||||
#
|
||||
# پس بهجای شمردنِ پورتها، الگوی «هر پورتی روی خودِ ماشینِ توسعهدهنده» را
|
||||
# میپذیریم. این سطحِ حمله اضافه نمیکند: احرازِ هویتِ ما توکنِ Bearer در هدر
|
||||
# است و `CORS_ALLOW_CREDENTIALS` خاموش، پس صفحهای که روی localhostِ یک مهاجم
|
||||
# باز است هیچ اعتبارنامهای از مرورگرِ قربانی به دست نمیآورد؛ CORS هم اصلاً
|
||||
# سدِ سرور-به-سرور نیست.
|
||||
CORS_ALLOWED_ORIGIN_REGEXES = [
|
||||
r'^http://localhost:\d+$',
|
||||
r'^http://127\.0\.0\.1:\d+$',
|
||||
]
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
o for o in os.environ.get('CORS_ALLOWED_ORIGINS', '').split(',') if o
|
||||
] or CORS_DEFAULT_ORIGINS
|
||||
CORS_ALLOW_CREDENTIALS = False
|
||||
# فقط مسیرهای API؛ /admin/ و فایلهای استاتیک لازم نیست از origin دیگر خوانده شوند.
|
||||
CORS_URLS_REGEX = r'^/api/.*$'
|
||||
@@ -130,30 +235,27 @@ INSTALLED_APPS = [
|
||||
'drf_spectacular',
|
||||
'corsheaders',
|
||||
|
||||
# Tadrisino apps (فاز اول - ۴ از ۱۱ اپ)
|
||||
# اپهای خودمان. هر کدام یک مسئولیت و مدلهای خودش را دارد؛ ارجاعِ بینِ
|
||||
# اپها با user_id/object_id انجام میشود نه ForeignKey، تا هر کدام بعداً
|
||||
# قابلِ جدا شدن بماند (docs/architecture.md).
|
||||
|
||||
# مسیرِ اصلیِ کاربر: ورود، محتوا، پیشرفت، دستیار
|
||||
'accounts',
|
||||
'content',
|
||||
'progress',
|
||||
'ai_tutor',
|
||||
|
||||
# Tadrisino apps (فاز دوم - بخش ۱ از ۳: subscription, payment, discount)
|
||||
# کسبِ درآمد
|
||||
'subscription',
|
||||
'payment',
|
||||
'discount',
|
||||
|
||||
# Tadrisino apps (فاز دوم - بخش ۲: notifications)
|
||||
# ارتباط با کاربر و پشتیبانی
|
||||
'notifications',
|
||||
|
||||
# Tadrisino apps (فاز دوم - بخش ۳: affiliate)
|
||||
'affiliate',
|
||||
|
||||
# Tadrisino apps (فاز دوم - بخش ۴: support)
|
||||
'support',
|
||||
|
||||
# Tadrisino apps (فاز دوم - بخش ۵: family)
|
||||
'family',
|
||||
|
||||
# Tadrisino apps (سیستم پرداخت بازار + آفر/تخفیف + لاگ عمومی تغییر Status)
|
||||
# لاگِ عمومیِ تغییرِ وضعیت، مستقل از بقیهی اپها
|
||||
'statuslog',
|
||||
|
||||
# Tadrisino apps (مدلِ مینیمالِ ذخیرهسازیِ کمپینهای پیامی، بدونِ منطقِ ارسال)
|
||||
@@ -164,19 +266,43 @@ INSTALLED_APPS = [
|
||||
'rag',
|
||||
]
|
||||
|
||||
# شبکههایی که X-Forwarded-Forشان پذیرفته میشود (config.middleware.TrustedProxyMiddleware).
|
||||
# پیشفرض: فهرستِ edgeهای ArvanCloud از https://www.arvancloud.ir/fa/ips.txt (۲۰۲۶-۱۰-۱۳). اگر آروان رنجِ
|
||||
# تازه بگیرد و اینجا نباشد، کاربرانِ پشتِ آن edge یک IP دیده میشوند و لاگ WARNINGِ
|
||||
# «outside TRUSTED_PROXY_NETWORKS» میدهد؛ آن وقت فهرست را از همان آدرس تازه کنید (یا در .env بگذارید).
|
||||
# مقدارِ نامعتبر بالا آمدن را میشکند، نه اینکه بیصدا نادیده گرفته شود.
|
||||
_ARVAN_EDGE_NETWORKS = (
|
||||
'185.143.232.0/22,188.229.116.16/30,94.101.182.0/27,2.144.3.128/28,37.32.16.0/27,37.32.17.0/27,'
|
||||
'37.32.18.0/27,37.32.19.0/27,185.215.232.0/22,178.131.120.48/28,94.101.183.0/28,78.157.36.112/28,'
|
||||
'95.38.61.80/28,193.24.119.0/29'
|
||||
)
|
||||
TRUSTED_PROXY_NETWORKS = [
|
||||
ipaddress.ip_network(cidr.strip())
|
||||
for cidr in os.environ.get('TRUSTED_PROXY_NETWORKS', _ARVAN_EDGE_NETWORKS).split(',')
|
||||
if cidr.strip()
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
# پیش از هر چیزی که IPِ کاربر را میخواند (سقفِ ورودِ ادمین، throttleهای DRF).
|
||||
'config.middleware.TrustedProxyMiddleware',
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
# ⚠️ CorsMiddleware باید تا حدِ ممکن بالا و حتماً قبل از CommonMiddleware
|
||||
# باشد، وگرنه پاسخهایی که زودتر برمیگردند (ریدایرکتِ APPEND_SLASH، خطای
|
||||
# ۴۰۰) هدرِ CORS نمیگیرند و در مرورگر بهشکلِ خطای مبهمِ شبکه دیده میشوند.
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
# سقفِ حدسِ رمزِ /admin/ برای هر IP (config/middleware.py).
|
||||
'config.middleware.AdminLoginThrottleMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
# پاسخِ API هرگز در کشِ ArvanCloud/مرورگر نماند (config/middleware.py).
|
||||
'config.middleware.APINoStoreMiddleware',
|
||||
# poolِ دیتابیسِ پر → ۵۰۳ با Retry-After بهجای ۵۰۰ برای viewهای غیرِ DRF (config/db.py).
|
||||
'config.middleware.DatabaseBusyMiddleware',
|
||||
# ⚠️ افزوده شده: لاگِ سبکِ هر درخواستِ API (متد/مسیر/وضعیت/مدت/کاربر)؛
|
||||
# عمداً آخرِ لیست قرار گرفته تا نزدیکترین میانافزار به خودِ view باشه
|
||||
# (مدتزمانِ دقیقتر، و request.user که توسطِ DRF authentication در
|
||||
@@ -189,7 +315,9 @@ ROOT_URLCONF = 'config.urls'
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [],
|
||||
# قالبهای سطحِ پروژه (که به هیچ اپی تعلق ندارند) — فعلاً فقط
|
||||
# پوستهی راستبهچپِ صفحههای مستنداتِ API.
|
||||
'DIRS': [BASE_DIR / 'templates'],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
@@ -207,23 +335,102 @@ WSGI_APPLICATION = 'config.wsgi.application'
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
# timeout بالاتر از پیشفرض: وقتی دو کانکشنِ همزمان (مثلِ تستِ
|
||||
# concurrency واقعیِ campaigns) به قفلِ نوشتنِ SQLite برخورد
|
||||
# میکنن، این باعث میشه کانکشنِ منتظر تا ۲۰ ثانیه صبر کنه بهجای
|
||||
# اینکه فوراً OperationalError('database table is locked') بده.
|
||||
'OPTIONS': {
|
||||
'timeout': 20,
|
||||
},
|
||||
# کانکشنِ DB بهازای هر request باز/بسته نشه؛ تا این تعداد ثانیه
|
||||
# persistent بمونه. با SQLite بیاثرِ عملی ولی بیضرره؛ بعد از
|
||||
# مهاجرت به Postgres جلوی هزینهی TCP+auth در هر request رو میگیره.
|
||||
'CONN_MAX_AGE': int(os.environ.get('DB_CONN_MAX_AGE', '60')),
|
||||
# Postgres وقتی DB_NAME ست باشد؛ وگرنه SQLite (وضعِ فعلیِ سرور تا روزِ انتقال).
|
||||
# ستبودنِ DB_NAME با ناقصبودنِ بقیه (کاربر/رمز) عمداً خطا میدهد، نه اتصالِ بیرمز.
|
||||
DB_NAME = os.environ.get('DB_NAME', '')
|
||||
|
||||
if DB_NAME:
|
||||
_missing = [k for k in ('DB_USER', 'DB_PASSWORD') if not os.environ.get(k)]
|
||||
if _missing:
|
||||
raise RuntimeError('DB_NAME ست شده ولی %s خالی است.' % ', '.join(_missing))
|
||||
if 'DB_CONN_MAX_AGE' in os.environ:
|
||||
# pool جای اتصالِ دائمی را گرفته و جنگو این دو را با هم نمیپذیرد؛ مقدارِ قدیمی بیصدا
|
||||
# نادیده گرفته نمیشود تا کسی خیال نکند هنوز اثر دارد.
|
||||
raise RuntimeError('DB_CONN_MAX_AGE دیگر معنی ندارد (اتصالها از pool میآیند)؛ از .env برش دارید.')
|
||||
|
||||
# ── pool اتصالها ──
|
||||
# تا ۲۰۲۶-۱۰-۱۱ هر نخِ gunicorn (۳ worker × ۳۲ نخ = ۹۶) با CONN_MAX_AGE یک اتصالِ دائمیِ جدا
|
||||
# نگه میداشت، ولی Postgresِ ما max_connections=60 دارد (۵۷ تا برای غیرِ ابرکاربر). روی
|
||||
# Postgresِ محلی با همان سقف، ۱۵۰ درخواستِ همزمان روی /api/home یکسومِ درخواستها را ۵۰۰
|
||||
# کرد («remaining connection slots are reserved»). حالا هر worker یک pool دارد و نخها اتصال
|
||||
# را فقط تا پایانِ درخواست قرض میگیرند؛ زیرِ ازدحام نخها در صفِ pool منتظر میمانند و پس از
|
||||
# DB_POOL_TIMEOUT ثانیه ۵۰۳ با Retry-After میگیرند (config/db.py)،
|
||||
# نه ۵۰۰. بودجه: workers × DB_POOL_MAX_SIZE باید زیرِ max_connections بماند و جا برای
|
||||
# pg_receivewal، pg_dump و migrateِ دیپلو، و psqlِ دستی بگذارد؛ /api/health آن را میسنجد.
|
||||
# ۸ عدد: هر کوئری اینجا چند میلیثانیه است، پس ۸ اتصال صدها درخواست در ثانیه را برای یک
|
||||
# worker (که با GIL حداکثر یک هسته است) میکشد؛ در لودتستِ محلی از ۸ به ۱۶ گذرشی بیشتر نشد.
|
||||
DB_POOL_MIN_SIZE = int(os.environ.get('DB_POOL_MIN_SIZE', '2'))
|
||||
DB_POOL_MAX_SIZE = int(os.environ.get('DB_POOL_MAX_SIZE', '8'))
|
||||
DB_POOL_TIMEOUT = float(os.environ.get('DB_POOL_TIMEOUT', '10'))
|
||||
if not 1 <= DB_POOL_MIN_SIZE <= DB_POOL_MAX_SIZE:
|
||||
# psycopg_pool این را تازه سرِ اولین کوئری میفهمد و آنوقت همهی درخواستها ۵۰۰ میشوند.
|
||||
raise RuntimeError(f'DB_POOL_MIN_SIZE ({DB_POOL_MIN_SIZE}) باید بینِ ۱ و DB_POOL_MAX_SIZE ({DB_POOL_MAX_SIZE}) باشد.')
|
||||
# کوئریِ بیش از ۳۰ ثانیه در درخواستِ وب یعنی باگ یا قفلِ گیرکرده؛ بدونِ سقف اتصالِ pool و نخ را
|
||||
# تا timeoutِ gunicorn (۱۲۰ ثانیه) گرو میگرفت. تراکنشِ بازِ بیکار (کدی که atomic را رها نکرده)
|
||||
# بعد از ۶۰ ثانیه بسته میشود تا قفلهایش بقیه را نخواباند. برای یک فرمانِ مدیریتیِ طولانیِ دستی:
|
||||
# DB_STATEMENT_TIMEOUT_MS=0 python manage.py ...
|
||||
DB_STATEMENT_TIMEOUT_MS = int(os.environ.get('DB_STATEMENT_TIMEOUT_MS', '30000'))
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
'NAME': DB_NAME,
|
||||
'USER': os.environ['DB_USER'],
|
||||
'PASSWORD': os.environ['DB_PASSWORD'],
|
||||
'HOST': os.environ.get('DB_HOST', '127.0.0.1'),
|
||||
'PORT': os.environ.get('DB_PORT', '5432'),
|
||||
'CONN_MAX_AGE': 0,
|
||||
# اتصالِ مرده (مثلاً بعد از ریاستارتِ Postgres) پیش از تحویل از pool کنار گذاشته میشود،
|
||||
# نه اینکه اولین درخواستِ بعدی رویش ۵۰۰ بگیرد. هزینه: یک رفتوبرگشتِ محلی برای هر قرض.
|
||||
'CONN_HEALTH_CHECKS': True,
|
||||
'OPTIONS': {
|
||||
'pool': {
|
||||
'min_size': DB_POOL_MIN_SIZE,
|
||||
'max_size': DB_POOL_MAX_SIZE,
|
||||
'timeout': DB_POOL_TIMEOUT,
|
||||
'max_idle': 300,
|
||||
'max_lifetime': 1800,
|
||||
},
|
||||
'connect_timeout': 5,
|
||||
'options': (f'-c statement_timeout={DB_STATEMENT_TIMEOUT_MS} '
|
||||
'-c idle_in_transaction_session_timeout=60000'),
|
||||
},
|
||||
# قفلِ ردیف روی Postgres واقعی است (select_for_update)؛ transaction_mode
|
||||
# فقط برای SQLite بود. با تستهای همزمانی روی Postgres سنجیده میشود.
|
||||
}
|
||||
}
|
||||
}
|
||||
else:
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
# timeout بالاتر از پیشفرض: وقتی دو کانکشنِ همزمان (مثلِ تستِ
|
||||
# concurrency واقعیِ campaigns) به قفلِ نوشتنِ SQLite برخورد
|
||||
# میکنن، این باعث میشه کانکشنِ منتظر تا ۲۰ ثانیه صبر کنه بهجای
|
||||
# اینکه فوراً OperationalError('database table is locked') بده.
|
||||
'OPTIONS': {
|
||||
'timeout': 20,
|
||||
# هر atomic() قفلِ نوشتن را از اول میگیرد (BEGIN IMMEDIATE).
|
||||
# SQLite از SELECT ... FOR UPDATE پشتیبانی نمیکند و جنگو
|
||||
# select_for_update را رویش بیصدا نادیده میگیرد؛ پس محافظهای
|
||||
# «قفلِ ردیف» در پرداخت، تخفیف، قلب و سهمیهی دستیار بدونِ این
|
||||
# عملاً قفلی نداشتند. با IMMEDIATE کلِ تراکنش سریالی میشود (قفلِ
|
||||
# کلِ دیتابیس، نه ردیف) — روی SQLite درشتتر ولی درست. هزینه:
|
||||
# تراکنشها پشتِ هم صف میکشند؛ فراخوانیهای شبکهای (AI، زرینپال)
|
||||
# عمداً بیرون از atomic() هستند. بعد از Postgres این را بردارید.
|
||||
'transaction_mode': 'IMMEDIATE',
|
||||
},
|
||||
# کانکشنِ DB بهازای هر request باز/بسته نشه؛ تا این تعداد ثانیه
|
||||
# persistent بمونه. با SQLite بیاثرِ عملی ولی بیضرره؛ بعد از
|
||||
# مهاجرت به Postgres جلوی هزینهی TCP+auth در هر request رو میگیره.
|
||||
'CONN_MAX_AGE': int(os.environ.get('DB_CONN_MAX_AGE', '60')),
|
||||
# تستها روی فایل، مثلِ پروداکشن. دیتابیسِ پیشفرضِ تست در حافظه با
|
||||
# shared-cache است که قفلش جدولبهجدول و بیانتظار است («table is
|
||||
# locked») — رفتاری که روی فایلِ واقعی پیش نمیآید؛ تستِ همزمانیِ
|
||||
# campaigns با BEGIN IMMEDIATE فقط همینجا میافتاد.
|
||||
'TEST': {'NAME': str(BASE_DIR / '.test-db.sqlite3')},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# In production a shared cache is required for rate limiting to work across
|
||||
# workers/instances; falls back to LocMemCache only when DEBUG=True.
|
||||
@@ -232,8 +439,13 @@ CACHE_URL = os.environ.get('CACHE_URL', '')
|
||||
if CACHE_URL:
|
||||
CACHES = {
|
||||
'default': {
|
||||
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
|
||||
# ResilientRedisCache: قطعیِ Redis API را ۵۰۰ نمیکند (config/cache.py شرح میدهد چه چیزی در
|
||||
# عوضش اعمال نمیشود). روی سرور فعلاً CACHE_URL خالی است: Redisِ روی آن ماشین مالِ تیمهای
|
||||
# دیگر است و عمداً به آن وصل نمیشویم؛ این مسیر برای Redisِ اختصاصیِ خودمان آماده است.
|
||||
'BACKEND': 'config.cache.ResilientRedisCache',
|
||||
'LOCATION': CACHE_URL,
|
||||
# بدونِ timeout، Redisِ گیرکرده هر درخواست را تا پایانِ timeoutِ gunicorn (۱۲۰ ثانیه) نگه میدارد.
|
||||
'OPTIONS': {'socket_connect_timeout': 1, 'socket_timeout': 1},
|
||||
}
|
||||
}
|
||||
elif DEBUG:
|
||||
@@ -333,7 +545,7 @@ REST_FRAMEWORK = {
|
||||
# BrowsableAPIRenderer فقط در dev؛ در پروداکشن هم overhead داره هم یک
|
||||
# مرورگرِ HTMLِ API رو در معرض دید میذاره.
|
||||
'DEFAULT_RENDERER_CLASSES': [
|
||||
'rest_framework.renderers.JSONRenderer',
|
||||
'config.renderers.PlainPersianJSONRenderer',
|
||||
*(['rest_framework.renderers.BrowsableAPIRenderer'] if DEBUG else []),
|
||||
],
|
||||
'DEFAULT_THROTTLE_CLASSES': [
|
||||
@@ -341,15 +553,34 @@ REST_FRAMEWORK = {
|
||||
],
|
||||
# بدونِ NUM_PROXIES، DRF هدرِ X-Forwarded-For رو بدونچک بهعنوانِ IP
|
||||
# کلاینت قبول میکنه؛ یعنی هر کلاینت میتونست با XFF دلخواه، bucket
|
||||
# تازه از GuestAware*Throttleها بگیره (bypassِ چرخشِ حساب مهمان). این
|
||||
# تازه از throttleهای IP-محور بگیره. این
|
||||
# مقدار باید دقیقاً با تعدادِ لایهی پراکسی/LB جلوی اپ یکی باشه (مثلاً
|
||||
# Cloudflare+Nginx=۲)، وگرنه IP واقعی اشتباه خونده میشه.
|
||||
'NUM_PROXIES': int(os.environ.get('NUM_PROXIES', '1')),
|
||||
#
|
||||
# پروداکشن دو لایه دارد: ArvanCloud (TLS، `server: ArvanCloud`) → nginx روی
|
||||
# همین ماشین → gunicorn. XFF در جنگو «IPِ کاربر, IPِ آروان» است؛ با ۱
|
||||
# آخرین مقدار، یعنی IPِ آروان، IPِ کاربر حساب میشد و همهی مهمانها و همهی
|
||||
# درخواستهای OTP پشتِ یک edgeِ آروان یک سهمِ مشترک داشتند (ورودِ مهمان
|
||||
# ۲۰ در ساعت برای *همه*). برای اجرای محلی بیپراکسی هم بیخطر است: وقتی
|
||||
# XFF نیست یا کوتاهتر است، DRF همان REMOTE_ADDR/اولین مقدار را میگیرد.
|
||||
# `/api/health` در `clientIp` نشان میدهد سرور چه IPای میبیند.
|
||||
'NUM_PROXIES': int(os.environ.get('NUM_PROXIES', '2')),
|
||||
'DEFAULT_THROTTLE_RATES': {
|
||||
'otp_request': '5/hour',
|
||||
'otp_login': '10/hour',
|
||||
'guest_session': '20/hour',
|
||||
# سقفهای per-IP عمداً گشادهاند: پشتِ CGNATِ اپراتورهای موبایل، وایفایِ
|
||||
# مدرسه یا شبکهی یک دفتر، دهها نفر یک IP دارند و با ۵ در ساعت فقط ۵ نفر
|
||||
# میتوانستند وارد شوند. حفاظِ اصلی per-phone و per-code است (پایینتر و
|
||||
# در دیتابیس)؛ اینها فقط جلوی یک ماشینِ تنها را میگیرند.
|
||||
# ۲۰ تا ۲۰۲۶-۱۰-۱۱ (تصمیمِ مالک): کلاسی که روی یک وایفای با هم وارد میشود سقف را پر میکرد. ۱۰۰ در
|
||||
# ساعت از یک IP هنوز جلوی یک ماشینِ تنها را میگیرد؛ هزینهی پیامک را سقفِ هر شماره (۱۰ در روز) و سقفِ
|
||||
# روزانهی کلِ سامانه (OTP_SMS_GLOBAL_DAILY_MAX) میبندند.
|
||||
'otp_request': os.environ.get('OTP_REQUEST_IP_RATE', '100/hour'),
|
||||
# ۳۰ تا ۲۰۲۶-۱۰-۱۱: یک کلاسِ سینفره روی وایفایِ مدرسه با چند غلطِ تایپی سقف را پر میکرد. حدسِ کد
|
||||
# را سقفِ هر کد (OTP_MAX_FAILED_ATTEMPTS=۵، در دیتابیس) و هر شماره میبندد، نه این.
|
||||
'otp_login': os.environ.get('OTP_LOGIN_IP_RATE', '300/hour'),
|
||||
'bazaar_verify': '10/hour',
|
||||
# شروعِ خریدِ بازار (امضای قیمت)؛ بیفراخوانیِ بیرونی، ولی هر بار یک توکنِ تازه.
|
||||
'bazaar_start': '30/hour',
|
||||
'bazaar_oauth': '20/hour',
|
||||
'ai_ask': '20/hour',
|
||||
'rag_ingest': '30/hour',
|
||||
# سقفِ ارزانِ خواندن روی endpointهای پرترافیکِ GET (home/leaderboard/
|
||||
@@ -357,17 +588,26 @@ REST_FRAMEWORK = {
|
||||
# به UX عادی دست نمیزنه.
|
||||
'read': os.environ.get('READ_THROTTLE_RATE', '120/min'),
|
||||
'purchase': '10/hour',
|
||||
'token_refresh': '30/hour',
|
||||
# بیتوکن صدا زده میشود، پس سهمِ همهی کاربرانِ یک IP مشترک است و هر کاربرِ فعال
|
||||
# ساعتی یک بار تمدید میکند (توکنِ دسترسی یکساعته). تا ۲۰۲۶-۱۰-۰۳ سی بود: سیویکمین
|
||||
# دانشآموزِ یک وایفایِ مدرسه یا یک CGNATِ موبایل ۴۲۹ میگرفت (بازتولید شد) و فرانت
|
||||
# بیرونش میانداخت. refresh token یکبارمصرف و ۶۴ نویسهی تصادفی است، پس حدس زدنش با
|
||||
# سقفِ بالاتر هم ممکن نیست؛ این سقف فقط جلوی یک ماشینِ تنها را میگیرد.
|
||||
'token_refresh': os.environ.get('TOKEN_REFRESH_IP_RATE', '600/hour'),
|
||||
'question_answer': '60/min',
|
||||
'question_report': '20/hour',
|
||||
'lesson_complete': '10/hour',
|
||||
'content_vote': '300/hour',
|
||||
# هر درس اینجا یک نوع مسئله است با یکی-دو سوال، نه یک جلسهی درسی؛
|
||||
# ۱۰ در ساعت را دانشآموزِ عادی میزد و پیشرفتش ۴۲۹ میگرفت.
|
||||
'lesson_complete': '120/hour',
|
||||
'node_complete': '60/hour',
|
||||
'support_create': '30/hour',
|
||||
'offer_redeem': '10/hour',
|
||||
'discount_validate': '20/hour',
|
||||
'discount_redeem': '10/hour',
|
||||
'family_invite_accept': '10/hour',
|
||||
'family_invite_create': '5/hour',
|
||||
'user_profile_write': '60/hour',
|
||||
# حذفِ حساب با کدِ پیامکی؛ سقفِ حدسِ کد جدا در verify_phone_otp است.
|
||||
'account_delete': '5/hour',
|
||||
'affiliate_track': '10/hour',
|
||||
'payment_webhook': '60/hour',
|
||||
'payment_pay': '30/hour',
|
||||
@@ -379,14 +619,104 @@ REST_FRAMEWORK = {
|
||||
|
||||
SPECTACULAR_SETTINGS = {
|
||||
'TITLE': 'Tadrisino API',
|
||||
# این متن قراردادِ بینِ ما و تیمِ فرانت است و تنها جاییست که هر دو طرف
|
||||
# میبینند. پس بهجای فهرستِ امکانات، به سه سوالِ اولِ هر مصرفکننده جواب
|
||||
# میدهد: چطور توکن بگیرم، خطا چه شکلی برمیگردد، و کِی محدود میشوم.
|
||||
# Swagger UI و Redoc هر دو Markdown را رندر میکنند.
|
||||
'DESCRIPTION': (
|
||||
'بکاند اپلیکیشن آموزشی Tadrisino — احراز هویت با OTP، محتوای درسی '
|
||||
'و پیشرفت کاربر، دستیار هوش مصنوعی، اشتراک و پرداخت، تخفیف، '
|
||||
'اعلانها، همکاری در فروش، پشتیبانی و حساب خانواده.'
|
||||
'بکاندِ اپلیکیشنِ آموزشیِ تدریسینو: برنامهی درسی، آزمون، پیشرفتِ '
|
||||
'کاربر، دستیارِ هوشِ مصنوعی، اشتراک و پرداخت.\n'
|
||||
'\n'
|
||||
'## احرازِ هویت\n'
|
||||
'\n'
|
||||
'همهی درخواستهای احرازشده با یک توکنِ Bearer انجام میشوند:\n'
|
||||
'\n'
|
||||
'```\n'
|
||||
'Authorization: Bearer <token>\n'
|
||||
'```\n'
|
||||
'\n'
|
||||
'توکن را فقط با شمارهی موبایل میگیرید: `POST /api/auth/otp/request` '
|
||||
'و بعد `POST /api/auth/login`. ورودِ مهمان نداریم؛ هر کاربر حداقل ثبتنامِ '
|
||||
'اولیه با شماره دارد. توکنِ دسترسی یک ساعت اعتبار دارد؛ با '
|
||||
'`POST /api/auth/refresh` تازهاش کنید.\n'
|
||||
'\n'
|
||||
'**در همین صفحه:** روی **Authorize** بزنید و *فقط خودِ توکن* را وارد '
|
||||
'کنید — کلمهی `Bearer` را Swagger خودش اضافه میکند.\n'
|
||||
'\n'
|
||||
'اندپوینتی که توکن نگیرد، درخواست را ناشناس میبیند؛ اینکه ورود لازم '
|
||||
'باشد یا نه، به خودِ آن اندپوینت بستگی دارد.\n'
|
||||
'\n'
|
||||
'## خطاها\n'
|
||||
'\n'
|
||||
'هر خطا — حتی خطای پیشبینینشده — یک پاسخِ JSON است، نه صفحهی HTML. '
|
||||
'پیامِ `detail` فارسی و قابلِ نمایش به کاربرِ نهایی است.\n'
|
||||
'\n'
|
||||
'```json\n'
|
||||
'{"detail": "کد OTP نامعتبر یا منقضیشده است."}\n'
|
||||
'```\n'
|
||||
'\n'
|
||||
'| کد | معنی |\n'
|
||||
'|---|---|\n'
|
||||
'| `400` | ورودی نامعتبر است |\n'
|
||||
'| `401` | توکن نیست، نامعتبر است یا منقضی شده |\n'
|
||||
'| `403` | توکن معتبر است ولی اجازهی این کار را ندارد |\n'
|
||||
'| `404` | چیزی با این شناسه وجود ندارد |\n'
|
||||
'| `429` | از محدودیتِ نرخ گذشتید — بخشِ بعد |\n'
|
||||
'| `503` | سرویسِ بیرونی (پیامک، پرداخت) در دسترس نیست |\n'
|
||||
'\n'
|
||||
'`503` عمدیست: وقتی سرویسِ بیرونی تنظیم یا در دسترس نباشد، پاسخِ '
|
||||
'صریح میگیرید، نه موفقیتِ ساختگی.\n'
|
||||
'\n'
|
||||
'## محدودیتِ نرخ\n'
|
||||
'\n'
|
||||
'اندپوینتهای حساس (ورود، درخواستِ کد، پرسش از دستیار) سقفِ مستقل '
|
||||
'دارند. عبور از سقف یعنی `429`، و هر جا که معنی داشته باشد هدرِ '
|
||||
'`Retry-After` هم میآید. کاربرِ مهمان بر اساسِ IP شمرده میشود.\n'
|
||||
'\n'
|
||||
'مسیرِ ورود سقفِ جداگانهای بر اساسِ *شماره* هم دارد، مستقل از IP، تا '
|
||||
'پخش کردنِ تلاش بینِ چند IP فایدهای نداشته باشد.\n'
|
||||
'\n'
|
||||
'## قراردادها\n'
|
||||
'\n'
|
||||
'- نامِ فیلدها `camelCase` است (`refreshToken`, `nodeId`).\n'
|
||||
'- زمانها ISO 8601 با منطقهی زمانیاند.\n'
|
||||
'- متنِ رو به کاربر فارسی است؛ شناسهها و نامِ فیلدها انگلیسی.\n'
|
||||
'- این اسکیما **مرجعِ قرارداد** است. کلاینت را از روی حافظه ننویسید — '
|
||||
'از `/api/schema/` تولیدش کنید.\n'
|
||||
),
|
||||
'VERSION': '1.0.0',
|
||||
'CONTACT': {'name': 'تیمِ بکاندِ تدریسینو'},
|
||||
'EXTERNAL_DOCS': {
|
||||
'description': 'فرانتاند: تنها کلاینتِ این API (src/api/)',
|
||||
'url': 'https://gitea.tadrisino2.ir/Tadrisino/MathApp_frontend',
|
||||
},
|
||||
# ترتیبِ تگها در Swagger به همین ترتیب است، نه الفبایی — یعنی مسیرِ
|
||||
# واقعیِ یک کاربر: اول وارد شو، بعد محتوا بگیر، بعد پیشرفت و بقیه.
|
||||
'TAGS': [
|
||||
{'name': 'Identity & Profile (accounts)', 'description': 'ورود، session، پروفایل'},
|
||||
{'name': 'Curriculum (content)', 'description': 'پایه، فصل، نود، درس، سوال، آزمون'},
|
||||
{'name': 'Progress & Gamification (progress)', 'description': 'آمار، streak، لیدربرد، شخصیتها'},
|
||||
{'name': 'AI Tutor (ai_tutor)', 'description': 'پرسش از دستیار و بازخورد روی پاسخ'},
|
||||
{'name': 'RAG (rag)', 'description': 'بازیابی از محتوای کتاب — داخلی'},
|
||||
{'name': 'Subscription (subscription)', 'description': 'پلنها و اشتراکِ کاربر'},
|
||||
{'name': 'Payment (payment)', 'description': 'سفارش و تراکنش (زرینپال و کافهبازار)'},
|
||||
{'name': 'Discount (discount)', 'description': 'کدِ تخفیف و آفرها'},
|
||||
{'name': 'Notifications (notifications)', 'description': 'اعلانهای کاربر'},
|
||||
{'name': 'Support (support)', 'description': 'تیکت و گزارشِ اشکال'},
|
||||
{'name': 'Affiliate (affiliate)', 'description': 'برنامهی معرفی و پورسانت'},
|
||||
{'name': 'System', 'description': 'سلامتِ سرویس'},
|
||||
],
|
||||
'SERVE_INCLUDE_SCHEMA': False,
|
||||
# مستندات فقط برای ادمینِ واردشده به /admin/. IsAdminUser بهتنهایی کافی نیست: احرازِ پیشفرضِ DRF
|
||||
# فقط Bearer است و ادمینِ پنل ۴۰۱ میگرفت؛ SessionAuthentication کوکیِ پنل را میشناسد.
|
||||
# فایلِ schema برای فرانت: `python manage.py spectacular --file schema.yaml` (بینیاز از HTTP).
|
||||
'SERVE_PERMISSIONS': ['rest_framework.permissions.IsAdminUser'],
|
||||
'SERVE_AUTHENTICATION': ['rest_framework.authentication.SessionAuthentication'],
|
||||
'COMPONENT_SPLIT_REQUEST': True,
|
||||
# نامِ صریحِ enumها؛ بدونِ آن drf-spectacular نامی مثلِ «KindEd8Enum» میسازد و هشدار میدهد.
|
||||
'ENUM_NAME_OVERRIDES': {
|
||||
'LessonKindEnum': [('quiz', 'quiz'), ('video', 'video')],
|
||||
},
|
||||
'SWAGGER_UI_SETTINGS': {
|
||||
'deepLinking': True,
|
||||
'displayRequestDuration': True,
|
||||
@@ -395,6 +725,12 @@ SPECTACULAR_SETTINGS = {
|
||||
'filter': True,
|
||||
'persistAuthorization': True,
|
||||
},
|
||||
# نسخهی ثابت، نه @latest: این صفحهها روی همان دامنهی /admin/ اجرا میشوند، و نسخهی منتشرشده
|
||||
# در npm عوضشدنی نیست؛ با @latest هر نسخهی آلودهی تازهی این بستهها در مرورگرِ کارمند اجرا میشد.
|
||||
# SRI ندارد. ارتقا یعنی عوض کردنِ همین دو عدد.
|
||||
'SWAGGER_UI_DIST': 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1',
|
||||
'SWAGGER_UI_FAVICON_HREF': 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/favicon-32x32.png',
|
||||
'REDOC_DIST': 'https://cdn.jsdelivr.net/npm/redoc@2.5.4',
|
||||
}
|
||||
|
||||
# اعتبار توکن دسترسی (ثانیه) و رفرشتوکن (روز)
|
||||
@@ -429,10 +765,89 @@ OTP_REQUEST_PHONE_WINDOW_SECONDS = 3600
|
||||
# باید با countdownِ فرانت یکی باشه — countdown صرفاً UX است، نه مرجعِ امنیتی.
|
||||
OTP_REQUEST_PHONE_COOLDOWN_SECONDS = 120
|
||||
|
||||
# عمرِ هر کدِ ورود. سقفهای دیتابیسیِ پایین زمانِ صدور را از expires_at منهای
|
||||
# همین مقدار میخوانند، پس عوض کردنش آن سقفها را هم (برای کدهای قدیمی) جابهجا میکند.
|
||||
OTP_TTL_SECONDS = 300
|
||||
|
||||
# سقفهایی که در دیتابیس شمرده میشوند، نه در کش. کشِ پیشفرض LocMem و مالِ
|
||||
# هر worker است، پس cooldown و سقفِ ساعتیِ بالا روی سرور (۳ worker) عملاً سه
|
||||
# برابرند؛ این دو بینِ همهی workerها مشترکاند و بدونِ Redis هم درست کار میکنند.
|
||||
# - روزانهی هر شماره: جلوی بمبارانِ پیامکیِ یک گوشی در طولِ روز.
|
||||
# - روزانهی کلِ سامانه: سقفِ خرجِ اعتبارِ کاوهنگار اگر کسی با شمارههای تصادفی
|
||||
# و IPهای زیاد پیامک بکشد (SMS pumping). رسیدن به آن ۵۰۳ و لاگِ ERROR میدهد؛
|
||||
# با رشدِ کاربرِ واقعی از .env بالا ببرید.
|
||||
OTP_REQUEST_PHONE_DAILY_MAX = int(os.environ.get('OTP_REQUEST_PHONE_DAILY_MAX', '10'))
|
||||
OTP_SMS_GLOBAL_DAILY_MAX = int(os.environ.get('OTP_SMS_GLOBAL_DAILY_MAX', '300'))
|
||||
# هر کدِ ورود بعد از این تعداد حدسِ غلط باطل میشود (شمارش در دیتابیس).
|
||||
# با کدِ ۶رقمی یعنی شانسِ حدس زدنِ هر کد ۵ در یک میلیون.
|
||||
OTP_MAX_FAILED_ATTEMPTS = 5
|
||||
|
||||
# در محیط dev، این کد OTP همیشه معتبر است تا بدون SMS واقعی تست شود.
|
||||
# فقط وقتی DEBUG=True مقدار دارد؛ در پروداکشن None است.
|
||||
DEV_MASTER_OTP = '0000' if DEBUG else None
|
||||
|
||||
# شمارههای تستِ ورود: «شماره:کد» با کامای جداکننده. برای این شمارهها پیامک
|
||||
# ارسال نمیشود و همان کدِ ثابت معتبر است — تا ورودِ تیمِ توسعه به تنظیم بودنِ
|
||||
# کاوهنگار یا به یک آدمِ خاص وابسته نباشد. برخلافِ DEV_MASTER_OTP «هر
|
||||
# شمارهای» نیست و به DEBUG گره نخورده. روالِ استفاده در docs/operations.md.
|
||||
#
|
||||
# ⚠️ شماره باید *دقیقاً* همان رشتهای باشد که کلاینت میفرستد: این کدبیس شماره
|
||||
# را normalize نمیکند (accounts/serializers.py فقط CharField است)، پس
|
||||
# «09121111111» و «+989121111111» دو کاربرِ جدا هستند.
|
||||
def _parse_test_login_phones(raw: str) -> dict[str, str]:
|
||||
from accounts.phone import normalize_phone
|
||||
|
||||
pairs = {}
|
||||
for item in raw.split(','):
|
||||
item = item.strip()
|
||||
if not item:
|
||||
continue
|
||||
phone, sep, code = item.partition(':')
|
||||
phone, code = phone.strip(), code.strip()
|
||||
# سقفِ ۶ رقمِ کد دلبخواه نیست: LoginRequestSerializer فیلدِ otp را
|
||||
# max_length=6 گرفته، پس کدِ بلندتر پیش از رسیدن به اینجا ۴۰۰ میشد و
|
||||
# بهنظر میآمد کدِ تست کار نمیکند.
|
||||
if not sep or not re.fullmatch(r'\+?\d{10,15}', phone) or not re.fullmatch(r'\d{4,6}', code):
|
||||
raise ImproperlyConfigured(
|
||||
f'TEST_LOGIN_PHONES مقدارِ نامعتبر دارد: «{item}». '
|
||||
'قالبِ درست «شماره:کد» است؛ شماره ۱۰ تا ۱۵ رقم (با + اختیاری) '
|
||||
'و کد ۴ تا ۶ رقم. مثال: TEST_LOGIN_PHONES=09120000001:834512'
|
||||
)
|
||||
# همان نرمالسازیِ ورودیِ API (accounts/phone.py)، وگرنه شمارهای که
|
||||
# اینجا +98… نوشته شده با ورودیِ نرمالشدهی 09… هرگز جور نمیشد.
|
||||
normalized = normalize_phone(phone)
|
||||
if normalized is None:
|
||||
raise ImproperlyConfigured(
|
||||
f'TEST_LOGIN_PHONES شمارهی موبایلِ ایران نیست: «{item}». مثال: 09120000001:834512'
|
||||
)
|
||||
pairs[normalized] = code
|
||||
return pairs
|
||||
|
||||
|
||||
TEST_LOGIN_PHONES = _parse_test_login_phones(os.environ.get('TEST_LOGIN_PHONES', ''))
|
||||
|
||||
# شمارههای آزمونگر: برای این کاربرها هیچ جزیرهای روی نقشه قفل نیست (قفلِ «جزیرهی قبلی را تمام کن»).
|
||||
# فقط قفلِ ترتیب را برمیدارد؛ اشتراکِ فصلها، قلب و سقفِ دستیار همان قواعدِ عادیاند و برای آزمونگر
|
||||
# با یک ردیفِ Subscription در ادمین داده میشود. چرا env و نه فیلدِ کاربر: تیمِ تست عوض میشود و
|
||||
# نباید برای هر نفر migration یا دسترسیِ ادمین لازم باشد؛ چرا نه DEBUG: روی سرورِ واقعی لازم است.
|
||||
# شمارهی نامعتبر راهاندازی را میشکند (بیصدا نادیده گرفته نمیشود).
|
||||
def _parse_unlock_all_phones(raw: str) -> frozenset:
|
||||
from accounts.phone import normalize_phone
|
||||
|
||||
phones = set()
|
||||
for item in raw.split(','):
|
||||
item = item.strip()
|
||||
if not item:
|
||||
continue
|
||||
normalized = normalize_phone(item)
|
||||
if normalized is None:
|
||||
raise ImproperlyConfigured(f'UNLOCK_ALL_PHONES شمارهی موبایلِ ایران نیست: «{item}».')
|
||||
phones.add(normalized)
|
||||
return frozenset(phones)
|
||||
|
||||
|
||||
UNLOCK_ALL_PHONES = _parse_unlock_all_phones(os.environ.get('UNLOCK_ALL_PHONES', ''))
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# OTP SMS delivery (accounts/services.py: send_otp_sms)
|
||||
# -----------------------------------------------------------------------
|
||||
@@ -440,7 +855,15 @@ DEV_MASTER_OTP = '0000' if DEBUG else None
|
||||
# بدون تنظیم این env، ارسال باید صراحتاً خطا بدهد، نه silently موفق باشد.
|
||||
KAVENEGAR_API_KEY = os.environ.get('KAVENEGAR_API_KEY', '')
|
||||
KAVENEGAR_SENDER = os.environ.get('KAVENEGAR_SENDER', '')
|
||||
OTP_SMS_TEMPLATE = os.environ.get('OTP_SMS_TEMPLATE', 'کد ورود شما به تدریسینو: {code}')
|
||||
# نامِ الگوی تأییدشده در پنلِ کاوهنگار برای کدِ ورود (verify/lookup، با %token%).
|
||||
# وقتی تنظیم باشد OTP_SMS_TEMPLATE پایین استفاده نمیشود و متن را خودِ الگو میسازد.
|
||||
# خطِ پیشفرضِ حسابِ فعلی پیامکِ متنِ آزاد را لغو میکند، پس روی سرور لازم است.
|
||||
KAVENEGAR_OTP_TEMPLATE = os.environ.get('KAVENEGAR_OTP_TEMPLATE', '').strip()
|
||||
# جملهی دوم هشدارِ مهندسیِ اجتماعی است (رایج در پیامکهای بانکی)؛ کلِ متن زیرِ
|
||||
# ۷۰ نویسه میماند تا یک صفحه پیامکِ فارسی حساب شود، نه دو صفحه.
|
||||
OTP_SMS_TEMPLATE = os.environ.get(
|
||||
'OTP_SMS_TEMPLATE', 'کد ورود شما به تدریسینو: {code}\nاین کد را به هیچکس ندهید.',
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Content progress (NodeProgress status)
|
||||
@@ -453,24 +876,25 @@ OTP_SMS_TEMPLATE = os.environ.get('OTP_SMS_TEMPLATE', 'کد ورود شما به
|
||||
# میشه که نباید فوراً بهعنوان «رهاکردن درس» حساب بشه.
|
||||
NODE_PROGRESS_ABANDON_DAYS = 14
|
||||
|
||||
# قلبِ صفر جلوی جواب دادن را در سرور بگیرد؟ (content.views.QuestionAnswerView)
|
||||
# از ۲۰۲۶-۱۰-۰۲ روشن است (خواستِ صاحبِ پروژه): فرانت ۴۰۳ِ `no_hearts` را با صفحهی «قلبهایت
|
||||
# تمام شد» و زمانِ پر شدن نشان میدهد؛ قلبِ نامحدود فقط با اشتراک است.
|
||||
# HEARTS_ENFORCED=0 در .env آن را خاموش میکند.
|
||||
HEARTS_ENFORCED = os.environ.get('HEARTS_ENFORCED', '1') == '1'
|
||||
|
||||
# تلاشِ ورودِ ادمین در ۱۵ دقیقه برای هر IP.
|
||||
ADMIN_LOGIN_MAX_ATTEMPTS = 10
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Payment security
|
||||
# -----------------------------------------------------------------------
|
||||
# POST /api/payment/orders باید فقط سرور-به-سرور (از subscription) صدا زده شود؛
|
||||
# کلاینت باید هدر X-Internal-Key برابر همین مقدار بفرستد. بدون env در
|
||||
# پروداکشن مقدار خالی میماند و HasInternalServiceKey صراحتاً رد میکند.
|
||||
INTERNAL_SERVICE_KEY = os.environ.get(
|
||||
'INTERNAL_SERVICE_KEY',
|
||||
'dev-internal-service-key-change-me' if DEBUG else '',
|
||||
)
|
||||
# هیچ پیشفرضی، حتی در DEBUG: رشتهی پیشفرضِ داخلِ ریپو هر لپتاپِ توسعهای را که روی شبکه
|
||||
# باز است به ingest در Qdrant راه میداد.
|
||||
INTERNAL_SERVICE_KEY = os.environ.get('INTERNAL_SERVICE_KEY', '')
|
||||
|
||||
# POST /api/payment/webhook باید از درگاه پرداخت بیاید، نه یک کلاینت دلخواه.
|
||||
# فعلاً یک shared-secret ساده (X-Webhook-Secret) استفاده میشود؛ در اتصال
|
||||
# واقعی باید با بررسی امضای HMAC درگاه جایگزین شود.
|
||||
PAYMENT_WEBHOOK_SECRET = os.environ.get(
|
||||
'PAYMENT_WEBHOOK_SECRET',
|
||||
'dev-webhook-secret-change-me' if DEBUG else '',
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Bazaar In-App Billing (پرداخت درونبرنامهای کافهبازار — REST API v2)
|
||||
@@ -479,19 +903,37 @@ PAYMENT_WEBHOOK_SECRET = os.environ.get(
|
||||
# گرفته میشه، بعد بکاند با همون REFRESH_TOKEN، ACCESS_TOKEN تازه میگیره.
|
||||
# قبل از اتصال واقعی، مسیرِ endpointها رو از پنل devapi تایید کنید — بازار
|
||||
# این روش رو قبلاً تغییر داده.
|
||||
BAZAAR_PACKAGE_NAME = os.environ.get('BAZAAR_PACKAGE_NAME', 'ir.tadrisino.app')
|
||||
# applicationIdِ APK و پکیجِ پیشخان (com.tadrisino.math.app روی سرور؛ ۲۰۲۶-۱۰-۱۲ بازار محصولهای ۱۰۳۴۲–۱۰۳۴۴ را
|
||||
# زیرِ همین نام شناخت). پیشفرض همان است تا لپتاپ و سرور یکی باشند.
|
||||
BAZAAR_PACKAGE_NAME = os.environ.get('BAZAAR_PACKAGE_NAME', 'com.tadrisino.math.app')
|
||||
BAZAAR_CLIENT_ID = os.environ.get('BAZAAR_CLIENT_ID', '')
|
||||
BAZAAR_CLIENT_SECRET = os.environ.get('BAZAAR_CLIENT_SECRET', '')
|
||||
BAZAAR_REFRESH_TOKEN = os.environ.get('BAZAAR_REFRESH_TOKEN', '')
|
||||
# روشِ جدیدِ بازار: توکنِ ثابتِ پیشخان (API پیشخان بازار ← دریافت توکن جدید).
|
||||
# اگر ست باشد بر OAuthِ بالا (CLIENT_ID/SECRET/REFRESH_TOKEN) مقدم است.
|
||||
BAZAAR_PISHKHAN_API_SECRET = os.environ.get('BAZAAR_PISHKHAN_API_SECRET', '').strip()
|
||||
BAZAAR_API_BASE = os.environ.get('BAZAAR_API_BASE', 'https://pardakht.cafebazaar.ir/devapi/v2')
|
||||
# Redirect URI که در پنل بازار ثبت شده؛ باید با آن *عیناً* یکی باشد. به
|
||||
# خواستِ مدیرِ حسابِ بازار (۲۰۲۶-۰۹-۲۶) همان آدرسِ verify است: GETِ آن codeِ
|
||||
# بازار را میگیرد و POSTِ آن خریدِ اپ را. با عوض شدنِ PUBLIC_BASE_URL این هم
|
||||
# عوض میشود، و آنوقت در پنلِ بازار هم باید عوض شود.
|
||||
BAZAAR_REDIRECT_URI = (
|
||||
os.environ.get('BAZAAR_REDIRECT_URI', '').strip()
|
||||
or f'{PUBLIC_BASE_URL}/api/payment/bazaar/verify'
|
||||
)
|
||||
# کلیدِ «تخفیف پویا» از پیشخانِ بازار (پرداخت درونبرنامهای ← تخفیف پویا). با آن سرور برای هر خرید
|
||||
# یک JWTِ یکبارمصرف امضا میکند که قیمتِ همان سفارش (پس از کدِ تخفیف) را به بازار میگوید؛ پس قیمتِ
|
||||
# ادمین ملاک است، نه قیمتِ پیشخان (که باید از آن کمتر نباشد). بیآن بازار قیمتِ پیشخان را میگیرد و
|
||||
# کدِ تخفیف روی سفارشِ بازار ۴۰۰ است. فقط سمتِ سرور؛ هرگز در اپ.
|
||||
BAZAAR_DYNAMIC_PRICE_KEY = os.environ.get('BAZAAR_DYNAMIC_PRICE_KEY', '').strip()
|
||||
# چند ساعت لینکِ «اجازه»ی بازار باز میماند؛ بعد از اتصالِ موفق خودش بسته میشود.
|
||||
BAZAAR_AUTHORIZE_WINDOW_HOURS = int(os.environ.get('BAZAAR_AUTHORIZE_WINDOW_HOURS', '72'))
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Zarinpal (درگاهِ اصلیِ پرداختِ ما — Payment API v4)
|
||||
# -----------------------------------------------------------------------
|
||||
# آدرسِ عمومیِ خودِ بکاند؛ هم برای ساختنِ paymentUrlی که به کلاینت برمیگردد
|
||||
# (PaymentRedirectView) و هم برای callback_url که به زرینپال میفرستیم لازم
|
||||
# است. بدونش این دو URL شکسته میمانند — سیگنالِ واضحی که هنوز ست نشده.
|
||||
PUBLIC_BASE_URL = os.environ.get('PUBLIC_BASE_URL', '').rstrip('/')
|
||||
# paymentUrlی که به کلاینت برمیگردد و callback_urlی که به زرینپال میرود
|
||||
# هر دو از PUBLIC_BASE_URL (بخشِ «دامنهها» در بالای همین فایل) ساخته میشوند.
|
||||
|
||||
ZARINPAL_MERCHANT_ID = os.environ.get('ZARINPAL_MERCHANT_ID', '')
|
||||
ZARINPAL_SANDBOX = os.environ.get('ZARINPAL_SANDBOX') == '1'
|
||||
@@ -505,14 +947,19 @@ ZARINPAL_STARTPAY_BASE = os.environ.get(
|
||||
'https://sandbox.zarinpal.com/pg/StartPay' if ZARINPAL_SANDBOX
|
||||
else 'https://www.zarinpal.com/pg/StartPay',
|
||||
)
|
||||
ZARINPAL_CALLBACK_URL = os.environ.get(
|
||||
'ZARINPAL_CALLBACK_URL',
|
||||
f'{PUBLIC_BASE_URL}/api/payment/zarinpal/callback' if PUBLIC_BASE_URL else '',
|
||||
# `.strip() or` مثلِ بقیه: کلیدِ خالی در .env نباید callback را خالی کند (آنوقت
|
||||
# هر پرداختی بیصدا ۵۰۳ میگرفت).
|
||||
ZARINPAL_CALLBACK_URL = (
|
||||
os.environ.get('ZARINPAL_CALLBACK_URL', '').strip()
|
||||
or f'{PUBLIC_BASE_URL}/api/payment/zarinpal/callback'
|
||||
)
|
||||
# اختیاری: بعدِ verify، مرورگر/کلاینت را به این URL (مثلاً یک deep link اپ)
|
||||
# با ?orderId=...&status=... ریدایرکت میکنیم. اگه خالی باشه، همونجا یک
|
||||
# پاسخِ JSON ساده برمیگرده.
|
||||
ZARINPAL_REDIRECT_URL = os.environ.get('ZARINPAL_REDIRECT_URL', '')
|
||||
# بعدِ verify، مرورگر را به این URL با ?orderId=...&status=... برمیگردانیم
|
||||
# (برای اپِ موبایل میتواند یک deep link باشد). پیشفرض خودِ اپِ وب است؛ قبلاً
|
||||
# خالی بود و دانشآموز بعد از پرداختِ واقعی روی یک صفحهی JSONِ خام روی دامنهی
|
||||
# API میماند. مقدارِ صریحِ `-` یعنی همان پاسخِ JSON (برای تست/ابزار).
|
||||
ZARINPAL_REDIRECT_URL = os.environ.get('ZARINPAL_REDIRECT_URL', '').strip() or f'{FRONTEND_URL}/'
|
||||
if ZARINPAL_REDIRECT_URL == '-':
|
||||
ZARINPAL_REDIRECT_URL = ''
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# RAG / AI provider (اپ rag + ai_tutor.generate_tutor_answer)
|
||||
@@ -523,14 +970,43 @@ ZARINPAL_REDIRECT_URL = os.environ.get('ZARINPAL_REDIRECT_URL', '')
|
||||
# تستهای موجود و اجرای بدونِ کلید دستنخورده کار میکنن).
|
||||
OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY', '')
|
||||
OPENAI_BASE_URL = os.environ.get('OPENAI_BASE_URL', 'https://api.gapgpt.app/v1')
|
||||
OPENAI_CHAT_MODEL = os.environ.get('OPENAI_CHAT_MODEL', 'gpt-4o-mini')
|
||||
OPENAI_VISION_MODEL = os.environ.get('OPENAI_VISION_MODEL', 'gpt-4o')
|
||||
# gpt-4.1-mini از ۲۰۲۶-۰۹-۲۶ (تصمیمِ صاحبِ پروژه): در سنجشِ ۱۰۲ مسئله ۹۶ درست در برابرِ
|
||||
# ۸۵–۹۰ِ gpt-4o-mini، با ۲٫۹ برابر هزینه (۰٫۱۲ سنت برای هر پاسخ). شرح: گزارش ۲۳ و
|
||||
# docs/architecture.md. پیشفرض همینجاست تا دیپلوی به ویرایشِ دستیِ .env نیاز نداشته باشد.
|
||||
OPENAI_CHAT_MODEL = os.environ.get('OPENAI_CHAT_MODEL', 'gpt-4.1-mini')
|
||||
# سقفِ سوالِ روزانه از دستیار برای کاربرِ بدونِ اشتراک (مشترکان: Plan.ai_daily_quota).
|
||||
# ۲ (تصمیمِ مالک): سهمِ رایگان فقط برای آشنایی است و هزینهی خالص دارد (۲ × ~۷۰۰ تومان برای هر کاربرِ فعال در
|
||||
# روز؛ docs/ai-cost-and-guardrails.md «اشتراکها»). سوالِ بیشتر فقط با اشتراک.
|
||||
AI_DAILY_QUOTA_FREE = int(os.environ.get('AI_DAILY_QUOTA_FREE', '2'))
|
||||
# سقفِ روزانهی *کلِ سامانه* برای سوالهایی که به مدل میرسند، همان الگوی
|
||||
# OTP_SMS_GLOBAL_DAILY_MAX. سقفهای بالا برای هر کاربر و هر IPاند؛ کسی با چند IP و مهمانهای
|
||||
# تازه میتوانست خرجِ GapGPT را بیسقف بالا ببرد. ۳۰۰۰ با ~۰٫۲ سنت برای هر سوال یعنی حدودِ ۶
|
||||
# دلار در روز؛ رسیدن به آن ۵۰۳ و لاگِ ERROR میدهد. با رشدِ کاربرِ واقعی از .env بالا ببرید.
|
||||
AI_GLOBAL_DAILY_MAX = int(os.environ.get('AI_GLOBAL_DAILY_MAX', '3000'))
|
||||
# حداکثر جوابِ همزمانِ دستیار در هر worker (ai_tutor/concurrency.py). هر جواب یک نخ را ۳ تا ۴۰ ثانیه
|
||||
# نگه میدارد؛ بیسقف، رگبارِ سوال هر ۳۲ نخِ worker را میگرفت و نقشه و آزمون پشتش صف میکشیدند. با ۱۲،
|
||||
# دستکم ۲۰ نخ برای بقیهی اپ میماند؛ ۴ worker × ۱۲ = ۴۸ جوابِ همزمان. بیشتر از آن ۵۰۳ «شلوغ است»
|
||||
# میگیرد، پیش از رزروِ سهمیه. سقفِ همزمانیِ خودِ GapGPT را نمیدانیم و نسنجیدهایم.
|
||||
AI_MAX_CONCURRENT_PER_WORKER = int(os.environ.get('AI_MAX_CONCURRENT_PER_WORKER', '12'))
|
||||
# اختیاری: مدلِ جدا فقط برای پایههای دهم تا دوازدهم (حسابان/مثلثات). خالی یعنی
|
||||
# همهی پایهها OPENAI_CHAT_MODEL. انتخابش با `rag_model_benchmark` سنجیده میشود.
|
||||
OPENAI_CHAT_MODEL_UPPER = os.environ.get('OPENAI_CHAT_MODEL_UPPER', '')
|
||||
# فقط برای مدلهای reasoning (o-series/gpt-5): low | medium | high؛ خالی = پیشفرضِ مدل.
|
||||
OPENAI_CHAT_REASONING_EFFORT = os.environ.get('OPENAI_CHAT_REASONING_EFFORT', 'low')
|
||||
# gpt-4.1-mini در سنجشِ ۲۰۲۶-۰۹-۲۰ هم ارزانتر و هم دقیقتر از gpt-4o بود
|
||||
# (docs/extraction-playbook.md)؛ پیشفرض همان است تا به .env وابسته نباشد.
|
||||
OPENAI_VISION_MODEL = os.environ.get('OPENAI_VISION_MODEL', 'gpt-4.1-mini')
|
||||
# نباید تغییر کنه مگر اینکه collectionهای Qdrant هم با همون مدلِ جدید بازساخته بشن.
|
||||
OPENAI_EMBEDDING_MODEL = os.environ.get('OPENAI_EMBEDDING_MODEL', 'text-embedding-3-small')
|
||||
OPENAI_EMBEDDING_DIM = int(os.environ.get('OPENAI_EMBEDDING_DIM', '1536'))
|
||||
OPENAI_TIMEOUT_SECONDS = int(os.environ.get('OPENAI_TIMEOUT_SECONDS', '60'))
|
||||
|
||||
QDRANT_URL = os.environ.get('QDRANT_URL', 'https://qdrant.tadrisino2.ir:443')
|
||||
# پیشفرضِ خودِ qdrant-client پنج ثانیه است و برای این سرور کم است: ingestِ
|
||||
# کتابِ هشتم روی همان اولین فراخوانی (ساختِ collection) افتاد و صفر صفحه رفت.
|
||||
# سی ثانیه در مسیرِ ingest که دقیقهها طول میکشد هزینهای ندارد، و جلوی
|
||||
# مردنِ یک اجرای طولانی بابتِ یک کندیِ لحظهای را میگیرد.
|
||||
QDRANT_TIMEOUT_SECONDS = int(os.environ.get('QDRANT_TIMEOUT_SECONDS', '30'))
|
||||
QDRANT_API_KEY = os.environ.get('QDRANT_API_KEY', '')
|
||||
|
||||
# نامِ collection در دادهٔ تیمِ دیگه = f"{subject} {grade}".lower() . کلیدِ
|
||||
@@ -553,9 +1029,10 @@ RAG_ANSWER_CACHE_TTL = int(os.environ.get('RAG_ANSWER_CACHE_TTL', '1800'))
|
||||
# در اجرای تستها همیشه RAG خاموش است تا هیچ تستی به OpenAI/Qdrant واقعی
|
||||
# نزند (سرعت + پایداری). تستهایی که مسیرِ RAG را میآزمایند خودشان با
|
||||
# override_settings + mockِ کلاینتها این را برمیگردانند (rag/tests.py).
|
||||
# فقط خودِ دستورِ `manage.py test`؛ هر آرگومانی به اسمِ «test» (مثلاً نامِ یک collection) نه.
|
||||
import sys as _sys
|
||||
|
||||
if 'test' in _sys.argv:
|
||||
if _sys.argv[1:2] == ['test']:
|
||||
RAG_FORCE_MOCK = True
|
||||
RAG_ANSWER_CACHE_TTL = 0
|
||||
|
||||
@@ -596,13 +1073,40 @@ LOGGING = {
|
||||
# Promtail بتونه tail کنه. بدونش (اجرای عادی خارج از Docker)، فقط stdout.
|
||||
LOG_DIR = os.environ.get('LOG_DIR', '')
|
||||
|
||||
# کپیِ زندهی دیتابیس روی سرورِ دیگر (ops/pgbackup/): نامِ اسلاتِ تکثیری که آن سرور از آن میخواند.
|
||||
# خالی روی Postgres یعنی کپیِ بیرونی نداریم و /api/health هشدار میدهد.
|
||||
OFFSITE_WAL_SLOT = os.environ.get('OFFSITE_WAL_SLOT', '')
|
||||
# بیش از این عقبماندن هشدار است. ۶۴ مگ ≈ چهار فایلِ WAL؛ در ترافیکِ عادی عقبماندگی نزدیکِ صفر است.
|
||||
OFFSITE_WAL_MAX_LAG_BYTES = 64 * 2**20
|
||||
# سرورِ اختصاصی (۲۱۶): بکاپِ رمزشدهی pgBackRest؛ `tadrisino-offsite-sync` هر دقیقه آن را به ۲۴۷ میفرستد و زمانِ
|
||||
# آخرین ارسالِ موفق (epoch) را در این فایل مینویسد (docs/operations.md «سرورِ اختصاصیِ جدید»). ست باشد، بر
|
||||
# OFFSITE_WAL_SLOT مقدم است. OFFSITE_WAL_SLOT را فقط ۲۴۹ دارد و با سوئیچ به ۲۱۶ همراهِ _slot_state حذف میشود.
|
||||
OFFSITE_SYNC_STATUS_FILE = os.environ.get('OFFSITE_SYNC_STATUS_FILE', '')
|
||||
# ارسال هر دقیقه است؛ ۱۰ دقیقه بیارسال یعنی چند اجرای پشتِ سرِ هم شکست خورده.
|
||||
OFFSITE_SYNC_MAX_AGE_SECONDS = 600
|
||||
|
||||
# چرخشِ فایلِ لاگ: 'size' (پیشفرض، Docker و محلی) = ۶ فایلِ ۱۰ مگی و قدیمیها پاک میشوند؛
|
||||
# 'external' (سرور) = برنامه هرگز پاک نمیکند، `ops/applogs/archive-app-log.sh` هر شب app.log را جابهجا و
|
||||
# فشرده میکند و ۲۴۷ آن را برای همیشه نگه میدارد. WatchedFileHandler پس از جابهجایی فایلِ تازه باز میکند،
|
||||
# که با ۴ پروسهی gunicorn امنتر از چرخشِ داخلیِ RotatingFileHandler است (آن هر پروسه جدا میچرخاند).
|
||||
LOG_FILE_ROTATION = os.environ.get('LOG_FILE_ROTATION', 'size')
|
||||
if LOG_FILE_ROTATION not in ('size', 'external'):
|
||||
raise ImproperlyConfigured(f"LOG_FILE_ROTATION باید 'size' یا 'external' باشد، نه {LOG_FILE_ROTATION!r}")
|
||||
|
||||
if LOG_DIR:
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
LOGGING['handlers']['file'] = {
|
||||
'class': 'logging.handlers.RotatingFileHandler',
|
||||
'filename': os.path.join(LOG_DIR, 'app.log'),
|
||||
'maxBytes': 10 * 1024 * 1024, # 10MB در هر فایل، قبل از rotate
|
||||
'backupCount': 5,
|
||||
'formatter': 'json',
|
||||
}
|
||||
if LOG_FILE_ROTATION == 'external':
|
||||
LOGGING['handlers']['file'] = {
|
||||
'class': 'logging.handlers.WatchedFileHandler',
|
||||
'filename': os.path.join(LOG_DIR, 'app.log'),
|
||||
'formatter': 'json',
|
||||
}
|
||||
else:
|
||||
LOGGING['handlers']['file'] = {
|
||||
'class': 'logging.handlers.RotatingFileHandler',
|
||||
'filename': os.path.join(LOG_DIR, 'app.log'),
|
||||
'maxBytes': 10 * 1024 * 1024, # 10MB در هر فایل، قبل از rotate
|
||||
'backupCount': 5,
|
||||
'formatter': 'json',
|
||||
}
|
||||
LOGGING['root']['handlers'].append('file')
|
||||
|
||||
226
config/tests.py
226
config/tests.py
@@ -2,10 +2,11 @@ import importlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from urllib.parse import urlsplit
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.test import RequestFactory, SimpleTestCase, TestCase
|
||||
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
|
||||
@@ -170,9 +171,12 @@ class APIRequestLoggingMiddlewareTests(TestCase):
|
||||
self.client = APIClient()
|
||||
|
||||
def test_authenticated_api_request_is_logged_with_user_id_and_no_secrets(self):
|
||||
user = User.objects.create(device_id='device-mw-log-1', is_guest=True, name='')
|
||||
user = User.objects.create(phone='09000000005', name='')
|
||||
token = issue_access_token(user.id)
|
||||
|
||||
from content.models import Grade
|
||||
Grade.objects.create(pk=10, title='g10', icon='', color='')
|
||||
|
||||
with self.assertLogs('config.middleware', level='INFO') as captured:
|
||||
response = self.client.post(
|
||||
'/api/user/grade',
|
||||
@@ -198,18 +202,18 @@ class APIRequestLoggingMiddlewareTests(TestCase):
|
||||
def test_anonymous_api_request_is_logged_with_null_user_id(self):
|
||||
with self.assertLogs('config.middleware', level='INFO') as captured:
|
||||
response = self.client.post(
|
||||
'/api/auth/session',
|
||||
{'type': 'guest', 'deviceId': 'device-mw-log-anon-1'},
|
||||
'/api/auth/refresh',
|
||||
{'refreshToken': 'not-a-real-token'},
|
||||
format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.status_code, 401)
|
||||
self.assertEqual(len(captured.records), 1)
|
||||
message = captured.records[0].getMessage()
|
||||
|
||||
self.assertIn('method=POST', message)
|
||||
self.assertIn('path=/api/auth/session', message)
|
||||
self.assertIn('status=200', message)
|
||||
self.assertIn('path=/api/auth/refresh', message)
|
||||
self.assertIn('status=401', message)
|
||||
self.assertIn('user_id=None', message)
|
||||
|
||||
def test_non_api_request_is_not_logged(self):
|
||||
@@ -306,3 +310,209 @@ class JSONLogFormatterTests(SimpleTestCase):
|
||||
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'})
|
||||
|
||||
26
config/tests_app_log_archive.py
Normal file
26
config/tests_app_log_archive.py
Normal 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'), [])
|
||||
126
config/tests_audit_round2.py
Normal file
126
config/tests_audit_round2.py
Normal 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)
|
||||
178
config/tests_cache_resilience.py
Normal file
178
config/tests_cache_resilience.py
Normal 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
192
config/tests_db_pool.py
Normal 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
62
config/tests_domains.py
Normal 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'])
|
||||
20
config/tests_no_fixed_logins.py
Normal file
20
config/tests_no_fixed_logins.py
Normal 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()))
|
||||
77
config/tests_offsite_backup.py
Normal file
77
config/tests_offsite_backup.py
Normal 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'})
|
||||
49
config/tests_plain_persian.py
Normal file
49
config/tests_plain_persian.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
اعراب و «ی»ِ اضافه به کاربر نمیرسد (تصمیمِ شرکت، ۲۰۲۶-۱۰-۱۴): قاعده (`content.plain_math.plain_persian`) و لایهی
|
||||
خروجیِ API (`config.renderers.PlainPersianJSONRenderer`). دیتابیس متنِ اصلی را نگه میدارد.
|
||||
"""
|
||||
from django.core.cache import cache
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from content.models import Grade
|
||||
from content.plain_math import plain_persian, plain_persian_utf8
|
||||
|
||||
|
||||
class PlainPersianRuleTests(SimpleTestCase):
|
||||
def test_kasra_and_written_ezafe_ye_go(self):
|
||||
self.assertEqual(plain_persian('از پایهی سوم، ریاضیِ کتابِ خودت'), 'از پایه سوم، ریاضی کتاب خودت')
|
||||
|
||||
def test_fatha_damma_shadda_sukun_and_hamza_above_go(self):
|
||||
self.assertEqual(plain_persian('اَوّلُ خانهٔ مْ'), 'اول خانه م')
|
||||
|
||||
def test_tanwin_stays_because_it_is_spelling(self):
|
||||
self.assertEqual(plain_persian('قبلاً، موقتاً'), 'قبلاً، موقتاً')
|
||||
|
||||
def test_ye_that_is_part_of_a_word_stays(self):
|
||||
# «های» (نکره) و «هیی…» که حرفِ دیگری به «ی» چسبیده، اضافه نیستند.
|
||||
self.assertEqual(plain_persian('جزیرهای تازه'), 'جزیرهای تازه')
|
||||
self.assertEqual(plain_persian('خانهیمان'), 'خانهیمان')
|
||||
|
||||
def test_idempotent_and_non_strings_untouched(self):
|
||||
once = plain_persian('همهی جزیرههای پایهی هشتمِ')
|
||||
self.assertEqual(plain_persian(once), once)
|
||||
self.assertIsNone(plain_persian(None))
|
||||
self.assertEqual(plain_persian(5), 5)
|
||||
|
||||
def test_utf8_version_matches_the_text_version(self):
|
||||
sample = '{"t": "همهی جزیرهی", "u": "خانهیمان، قبلاً، اَوّلُ، پایهی ۸", "id": 12}'
|
||||
self.assertEqual(plain_persian_utf8(sample.encode()).decode(), plain_persian(sample))
|
||||
|
||||
|
||||
class ApiOutputTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
|
||||
def test_api_json_is_clean_while_the_database_keeps_the_original(self):
|
||||
Grade.objects.create(id=8, number=8, title='پایهی هشتمِ', icon='x', color='#000')
|
||||
body = APIClient().get('/api/grades').content.decode('utf-8')
|
||||
self.assertIn('پایه هشتم', body)
|
||||
self.assertNotIn('ِ', body)
|
||||
self.assertNotIn('هی', body)
|
||||
self.assertEqual(Grade.objects.get(id=8).title, 'پایهی هشتمِ')
|
||||
31
config/tests_request_size.py
Normal file
31
config/tests_request_size.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
بدنهی درخواستِ بزرگ ۵۰۰ و ERRORِ لاگ نمیدهد (config/exceptions.py). روی سرورِ زنده، JSONِ ۳ مگابایتی به
|
||||
/api/auth/login ۵۰۰ داده بود؛ ۲۰۰ کیلوبایت درست ۴۰۰ میداد.
|
||||
"""
|
||||
import json
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase, override_settings
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
|
||||
@override_settings(DATA_UPLOAD_MAX_MEMORY_SIZE=2_621_440)
|
||||
class OversizedBodyTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
|
||||
def _post(self, path, size):
|
||||
body = json.dumps({'phone': '09120000000', 'otp': '1' * size})
|
||||
return APIClient().post(path, data=body, content_type='application/json')
|
||||
|
||||
def test_a_body_over_the_limit_is_413_with_a_persian_message(self):
|
||||
for path in ('/api/auth/login', '/api/auth/otp/request'):
|
||||
with self.assertNoLogs('config.exceptions', 'ERROR'):
|
||||
response = self._post(path, 3_000_000)
|
||||
self.assertEqual(response.status_code, 413, path)
|
||||
self.assertIn('حجم', response.json()['detail'])
|
||||
|
||||
def test_a_large_but_allowed_body_still_gets_the_normal_validation_error(self):
|
||||
response = self._post('/api/auth/login', 200_000)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn('otp', response.json())
|
||||
73
config/tests_trusted_proxy.py
Normal file
73
config/tests_trusted_proxy.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
X-Forwarded-For فقط از ArvanCloud پذیرفته میشود (config.middleware.TrustedProxyMiddleware).
|
||||
|
||||
بازتولیدِ ۲۰۲۶-۱۰-۱۳ روی سرور: درخواستِ مستقیم به IPِ سرور با XFFِ تازه در هر بار، همهی سقفهای IP-محور را
|
||||
دور میزد. X-Real-IP همان چیزی است که nginx از آدرسِ TCP میگذارد.
|
||||
"""
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import RequestFactory, TestCase, override_settings
|
||||
from rest_framework.throttling import BaseThrottle
|
||||
|
||||
from config.middleware import TrustedProxyMiddleware
|
||||
|
||||
ARVAN_EDGE = '37.32.19.8'
|
||||
|
||||
|
||||
def _ident_after_middleware(**meta):
|
||||
request = RequestFactory().get('/api/health', **meta)
|
||||
seen = {}
|
||||
|
||||
def view(req):
|
||||
seen['ident'] = BaseThrottle().get_ident(req)
|
||||
return None
|
||||
|
||||
TrustedProxyMiddleware(view)(request)
|
||||
return seen['ident']
|
||||
|
||||
|
||||
class TrustedProxyIdentTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
|
||||
def test_request_through_arvan_uses_the_client_address(self):
|
||||
ident = _ident_after_middleware(
|
||||
HTTP_X_REAL_IP=ARVAN_EDGE, HTTP_X_FORWARDED_FOR=f'5.5.5.5, {ARVAN_EDGE}',
|
||||
)
|
||||
self.assertEqual(ident, '5.5.5.5')
|
||||
|
||||
def test_direct_hit_ignores_a_forged_forwarded_for(self):
|
||||
for forged in ('1.2.3.4', '9.9.9.9, 1.2.3.4'):
|
||||
ident = _ident_after_middleware(
|
||||
HTTP_X_REAL_IP='203.0.113.7', HTTP_X_FORWARDED_FOR=f'{forged}, 203.0.113.7',
|
||||
)
|
||||
self.assertEqual(ident, '203.0.113.7')
|
||||
|
||||
def test_direct_hit_is_logged_once_per_peer(self):
|
||||
with self.assertLogs('config.middleware', level='WARNING') as logs:
|
||||
for _ in range(3):
|
||||
_ident_after_middleware(
|
||||
HTTP_X_REAL_IP='203.0.113.9', HTTP_X_FORWARDED_FOR='1.1.1.1, 203.0.113.9',
|
||||
)
|
||||
self.assertEqual(len(logs.records), 1)
|
||||
|
||||
def test_without_nginx_header_nothing_changes(self):
|
||||
# runserverِ محلی: nginx نیست، پس X-Real-IP هم نیست.
|
||||
ident = _ident_after_middleware(REMOTE_ADDR='127.0.0.1')
|
||||
self.assertEqual(ident, '127.0.0.1')
|
||||
|
||||
|
||||
@override_settings(ADMIN_LOGIN_MAX_ATTEMPTS=2)
|
||||
class AdminLoginThrottleCannotBeDodgedTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
|
||||
def test_rotating_forwarded_for_on_a_direct_hit_still_hits_the_limit(self):
|
||||
codes = [
|
||||
self.client.post(
|
||||
'/admin/login/', {'username': 'x', 'password': 'y'},
|
||||
HTTP_X_REAL_IP='203.0.113.7', HTTP_X_FORWARDED_FOR=f'10.0.{i}.1, 203.0.113.7',
|
||||
).status_code
|
||||
for i in range(3)
|
||||
]
|
||||
self.assertEqual(codes[-1], 429)
|
||||
@@ -10,13 +10,21 @@ from django.contrib import admin
|
||||
from django.http import JsonResponse
|
||||
from django.urls import include, path
|
||||
from django.views import defaults as django_default_views
|
||||
from drf_spectacular.utils import extend_schema
|
||||
|
||||
from config.health import HealthView
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from drf_spectacular.views import (
|
||||
SpectacularAPIView,
|
||||
SpectacularRedocView,
|
||||
SpectacularSwaggerView,
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(exclude=True)
|
||||
class HealthViewSlashAlias(HealthView):
|
||||
"""نسخهی اسلشدارِ `/api/health`، فقط برای پایشِ بیرونی — بیرون از اسکیما."""
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -39,38 +47,54 @@ def handler500(request):
|
||||
return django_default_views.server_error(request)
|
||||
|
||||
|
||||
class ThrottledSchemaView(SpectacularAPIView):
|
||||
"""بیورود و بیکش: هر بار ~۵۶ms CPU و ۱۱۰KB؛ بیthrottle چند ده درخواست در ثانیه workerها را پر میکرد."""
|
||||
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
throttle_scope = 'read'
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
|
||||
# بررسیِ سلامتِ نسخهی مستقرشده — بدونِ احراز، برای پایشِ بیرونی.
|
||||
#
|
||||
# هر دو شکل ثبت میشود، با و بدونِ اسلشِ آخر. دلیلش تجربهی واقعی است:
|
||||
# پایشِ سرور `/api/health/` صدا میزد و چون فقط شکلِ بیاسلش ثبت بود،
|
||||
# کسی روی سرور همین فایل را دستی ویرایش کرد تا کار کند — و از آن لحظه
|
||||
# هر `git pull` بعدی روی همین فایل گیر میکرد. یک مسیرِ اضافه ارزانش
|
||||
# است در برابرِ یک فایلِ دستکاریشده روی پروداکشن.
|
||||
path('api/health', HealthView.as_view(), name='health'),
|
||||
# همان ویو، فقط برای پایشی که با اسلش صدا میزند. از اسکیما بیرون است تا
|
||||
# دو مسیرِ یکسان operationIdهای health_retrieve و health_retrieve_2 نسازند.
|
||||
#
|
||||
# ⚠️ زیرکلاس لازم است، نه exclude روی خودِ HealthView. extend_schema روی
|
||||
# یک کلاس همان کلاس را تغییر میدهد، و چون هر دو مسیر به یک کلاس اشاره
|
||||
# میکردند، `/api/health` هم با خودش از اسکیما بیرون میافتاد — یعنی
|
||||
# اندپوینتِ سلامت اصلاً در Swagger دیده نمیشد.
|
||||
path('api/health/', HealthViewSlashAlias.as_view(), name='health-slash'),
|
||||
|
||||
# OpenAPI schema + Swagger UI + Redoc
|
||||
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
|
||||
path('api/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
|
||||
path('api/schema/', ThrottledSchemaView.as_view(), name='schema'),
|
||||
# قالبِ خودمان فقط یک پوستهی راستبهچپ روی قالبِ drf-spectacular است؛
|
||||
# بدونش متنِ فارسی در یک بلوکِ LTR رندر میشود و نقطهی جمله و ترتیبِ
|
||||
# ستونهای جدول جابهجا میشوند. توضیح در خودِ قالب.
|
||||
path('api/docs/', SpectacularSwaggerView.as_view(
|
||||
url_name='schema', template_name='tadrisino/swagger_ui.html'), name='swagger-ui'),
|
||||
path('api/redoc/', SpectacularRedocView.as_view(
|
||||
url_name='schema', template_name='tadrisino/redoc.html'), name='redoc'),
|
||||
|
||||
# اپهای فاز ۱
|
||||
# همه زیرِ /api/ جمع میشوند؛ هر اپ مسیرهای خودش را نگه میدارد، پس
|
||||
# ترتیبِ این فهرست اهمیتی ندارد و تداخلی بینشان نیست.
|
||||
path('api/', include('accounts.urls')),
|
||||
path('api/', include('content.urls')),
|
||||
path('api/', include('progress.urls')),
|
||||
path('api/', include('ai_tutor.urls')),
|
||||
path('api/', include('rag.urls')),
|
||||
|
||||
# اپهای فاز ۲ / بخش ۱ (subscription, payment, discount)
|
||||
path('api/', include('subscription.urls')),
|
||||
path('api/', include('payment.urls')),
|
||||
path('api/', include('discount.urls')),
|
||||
|
||||
# اپهای فاز ۲ / بخش ۲ (notifications)
|
||||
path('api/', include('notifications.urls')),
|
||||
|
||||
# اپهای فاز ۲ / بخش ۳ (affiliate)
|
||||
path('api/', include('affiliate.urls')),
|
||||
|
||||
# اپهای فاز ۲ / بخش ۴ (support)
|
||||
path('api/', include('support.urls')),
|
||||
|
||||
# اپهای فاز ۲ / بخش ۵ (family)
|
||||
path('api/', include('family.urls')),
|
||||
]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
|
||||
from django.contrib import admin, messages
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models import Count, Q
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import redirect, render
|
||||
@@ -14,8 +15,8 @@ from .curriculum_io import parse as parse_curriculum
|
||||
from .question_io import ImportError_, export_questions, import_questions, parse
|
||||
|
||||
from .models import (
|
||||
Choice, Grade, Lesson, Node, NodeProgress, Question, QuestionReport, Unit, UserAnswer,
|
||||
UserLessonCompletion,
|
||||
Choice, ContentVote, Grade, Lesson, LessonReport, Node, NodeProgress, Question, QuestionReport, Subject, Unit,
|
||||
UserAnswer, UserLessonCompletion,
|
||||
)
|
||||
|
||||
|
||||
@@ -32,7 +33,7 @@ class GradeAdmin(admin.ModelAdmin):
|
||||
سرور بود که به Qdrant و به دسترسیِ SSH وابسته بودند.
|
||||
"""
|
||||
|
||||
list_display = ('id', 'title', 'icon', 'color')
|
||||
list_display = ('number', 'title', 'id', 'icon', 'color')
|
||||
search_fields = ('title',)
|
||||
actions = ['export_curriculum_action']
|
||||
change_list_template = 'admin/content/grade/change_list.html'
|
||||
@@ -54,6 +55,9 @@ class GradeAdmin(admin.ModelAdmin):
|
||||
return response
|
||||
|
||||
def import_curriculum_view(self, request):
|
||||
# admin_view فقط is_staff را میسنجد؛ این صفحه پایه/فصل/درس میسازد و متنِ درس را عوض میکند.
|
||||
if not (self.has_add_permission(request) and self.has_change_permission(request)):
|
||||
raise PermissionDenied
|
||||
if request.method == 'POST':
|
||||
uploaded = request.FILES.get('file')
|
||||
if uploaded is None:
|
||||
@@ -80,10 +84,21 @@ class GradeAdmin(admin.ModelAdmin):
|
||||
})
|
||||
|
||||
|
||||
@admin.register(Subject)
|
||||
class SubjectAdmin(admin.ModelAdmin):
|
||||
"""کتابهای ریاضی (ریاضی، حسابان، هندسه، …) که نوارِ جابهجاییِ اپ از آنها ساخته میشود."""
|
||||
|
||||
list_display = ('order', 'title', 'key', 'icon', 'color')
|
||||
ordering = ('order',)
|
||||
|
||||
|
||||
@admin.register(Unit)
|
||||
class UnitAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'title', 'grade', 'order')
|
||||
list_filter = ('grade',)
|
||||
list_display = ('id', 'title', 'grade', 'subject', 'order')
|
||||
list_filter = ('grade', 'subject')
|
||||
# فیلترِ «فصل» در صفحهی سوالها هم از همین ترتیب میآید: پایهبهپایه، نه
|
||||
# همهی «فصل ۱»ها پشتِ هم.
|
||||
ordering = ('grade__number', 'grade_id', 'order')
|
||||
|
||||
|
||||
@admin.register(Node)
|
||||
@@ -93,9 +108,57 @@ class NodeAdmin(admin.ModelAdmin):
|
||||
search_fields = ('title', 'unit__title')
|
||||
|
||||
|
||||
def _vote_counts(kind):
|
||||
"""Subquery: (likes, dislikes) برای هر هدف؛ ContentVote ارجاعِ سبک است، پس annotate مستقیم ممکن نیست."""
|
||||
from django.db.models import IntegerField, OuterRef, Subquery
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
def sub(value):
|
||||
return Coalesce(Subquery(
|
||||
ContentVote.objects.filter(kind=kind, target_id=OuterRef('pk'), value=value)
|
||||
.order_by().values('target_id').annotate(n=Count('id')).values('n'),
|
||||
output_field=IntegerField()), 0)
|
||||
return sub(1), sub(-1)
|
||||
|
||||
|
||||
class VoteColumnsMixin:
|
||||
vote_kind = ''
|
||||
|
||||
def get_queryset(self, request):
|
||||
likes, dislikes = _vote_counts(self.vote_kind)
|
||||
return super().get_queryset(request).annotate(_likes=likes, _dislikes=dislikes)
|
||||
|
||||
@admin.display(description='👍', ordering='_likes')
|
||||
def likes(self, obj):
|
||||
return obj._likes or '—'
|
||||
|
||||
@admin.display(description='👎', ordering='_dislikes')
|
||||
def dislikes(self, obj):
|
||||
if not obj._dislikes:
|
||||
return '—'
|
||||
return format_html('<span style="color:#b91c1c;font-weight:600">{}</span>', obj._dislikes)
|
||||
|
||||
|
||||
@admin.register(LessonReport)
|
||||
class LessonReportAdmin(admin.ModelAdmin):
|
||||
"""گزارشِ دانشآموزان روی متنِ درسها."""
|
||||
|
||||
list_display = ('id', 'lesson', 'reason', 'status', 'note', 'created_at')
|
||||
list_filter = ('status', 'reason', 'lesson__node__unit__grade')
|
||||
search_fields = ('lesson__title', 'note')
|
||||
autocomplete_fields = ['lesson']
|
||||
actions = ['mark_resolved']
|
||||
|
||||
@admin.action(description='علامت زدن بهعنوانِ بررسیشده', permissions=['change'])
|
||||
def mark_resolved(self, request, queryset):
|
||||
updated = queryset.update(status='resolved', resolved_at=timezone.now())
|
||||
self.message_user(request, f'{updated} گزارش بررسیشده علامت خورد.', messages.SUCCESS)
|
||||
|
||||
|
||||
@admin.register(Lesson)
|
||||
class LessonAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'title', 'node')
|
||||
class LessonAdmin(VoteColumnsMixin, admin.ModelAdmin):
|
||||
vote_kind = 'lesson'
|
||||
list_display = ('id', 'title', 'node', 'likes', 'dislikes')
|
||||
list_filter = ('node',)
|
||||
# بدونِ این، انتخابِ درس در فرمِ سوال یک دراپداونِ بیش از هزارتایی است
|
||||
# که عملاً غیرقابلِ استفاده است.
|
||||
@@ -103,7 +166,8 @@ class LessonAdmin(admin.ModelAdmin):
|
||||
|
||||
|
||||
@admin.register(Question)
|
||||
class QuestionAdmin(admin.ModelAdmin):
|
||||
class QuestionAdmin(VoteColumnsMixin, admin.ModelAdmin):
|
||||
vote_kind = 'question'
|
||||
"""
|
||||
سوالها علاوه بر ویرایشِ تکی، از همینجا فلهای وارد و خارج میشوند.
|
||||
|
||||
@@ -112,7 +176,7 @@ class QuestionAdmin(admin.ModelAdmin):
|
||||
میپذیرد، پس چرخهی ویرایش بسته است.
|
||||
"""
|
||||
|
||||
list_display = ('id', 'prompt', 'lesson', 'type', 'open_reports', 'wrong_percent')
|
||||
list_display = ('id', 'prompt', 'lesson', 'type', 'open_reports', 'likes', 'dislikes', 'wrong_percent')
|
||||
list_filter = ('type', 'lesson__node__unit__grade', 'lesson__node__unit')
|
||||
inlines = [ChoiceInline]
|
||||
actions = ['export_selected']
|
||||
@@ -175,6 +239,9 @@ class QuestionAdmin(admin.ModelAdmin):
|
||||
return response
|
||||
|
||||
def import_view(self, request):
|
||||
# admin_view فقط is_staff را میسنجد؛ «جایگزینی» سوالها و جوابهای دانشآموزان به آنها را پاک میکند.
|
||||
if not self.has_add_permission(request):
|
||||
raise PermissionDenied
|
||||
if request.method == 'POST':
|
||||
uploaded = request.FILES.get('file')
|
||||
if uploaded is None:
|
||||
@@ -182,6 +249,8 @@ class QuestionAdmin(admin.ModelAdmin):
|
||||
return redirect(request.path)
|
||||
|
||||
replace = request.POST.get('replace') == 'on'
|
||||
if replace and not self.has_delete_permission(request):
|
||||
raise PermissionDenied
|
||||
try:
|
||||
rows = parse(uploaded.read())
|
||||
result = import_questions(rows, replace=replace)
|
||||
@@ -235,7 +304,7 @@ class QuestionReportAdmin(admin.ModelAdmin):
|
||||
autocomplete_fields = ['question']
|
||||
actions = ['mark_resolved']
|
||||
|
||||
@admin.action(description='علامت زدن بهعنوانِ بررسیشده')
|
||||
@admin.action(description='علامت زدن بهعنوانِ بررسیشده', permissions=['change'])
|
||||
def mark_resolved(self, request, queryset):
|
||||
updated = queryset.update(status='resolved', resolved_at=timezone.now())
|
||||
self.message_user(request, f'{updated} گزارش بررسیشده علامت خورد.', messages.SUCCESS)
|
||||
|
||||
@@ -10,9 +10,12 @@
|
||||
گزارشهای بازِ کاربران، و درصدِ پاسخِ غلط — یعنی همان سیگنالی که میگوید کدام
|
||||
سوال ایراد دارد یا بیش از حد سخت است.
|
||||
|
||||
فقط برای کارمندی که اجازهی دیدنِ سوال دارد، چون کلیدِ جوابها را نشان میدهد.
|
||||
|
||||
وابستگیِ تازهای اضافه نمیکند: چند اندپوینتِ کوچکِ JSON و JavaScript ساده.
|
||||
"""
|
||||
from django.contrib.admin.views.decorators import staff_member_required
|
||||
from django.contrib.auth.decorators import permission_required
|
||||
from django.db.models import Count, Q
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import render
|
||||
@@ -22,6 +25,7 @@ from .models import Grade, Lesson, Node, Question, Unit
|
||||
|
||||
|
||||
@staff_member_required
|
||||
@permission_required('content.view_question', raise_exception=True)
|
||||
def browser_view(request):
|
||||
return render(request, 'admin/content/browser.html', {
|
||||
'title': 'مرورِ محتوا',
|
||||
@@ -30,6 +34,7 @@ def browser_view(request):
|
||||
|
||||
|
||||
@staff_member_required
|
||||
@permission_required('content.view_question', raise_exception=True)
|
||||
def units_json(request, grade_id: int):
|
||||
rows = (
|
||||
Unit.objects.filter(grade_id=grade_id)
|
||||
@@ -41,6 +46,7 @@ def units_json(request, grade_id: int):
|
||||
|
||||
|
||||
@staff_member_required
|
||||
@permission_required('content.view_question', raise_exception=True)
|
||||
def nodes_json(request, unit_id: int):
|
||||
rows = (
|
||||
Node.objects.filter(unit_id=unit_id)
|
||||
@@ -52,6 +58,7 @@ def nodes_json(request, unit_id: int):
|
||||
|
||||
|
||||
@staff_member_required
|
||||
@permission_required('content.view_question', raise_exception=True)
|
||||
def lessons_json(request, node_id: int):
|
||||
rows = (
|
||||
Lesson.objects.filter(node_id=node_id)
|
||||
@@ -63,6 +70,7 @@ def lessons_json(request, node_id: int):
|
||||
|
||||
|
||||
@staff_member_required
|
||||
@permission_required('content.view_question', raise_exception=True)
|
||||
def questions_json(request, lesson_id: int):
|
||||
"""
|
||||
هر سوال با دو سیگنال: گزارشِ بازِ کاربران، و درصدِ پاسخِ غلط.
|
||||
|
||||
@@ -92,14 +92,20 @@ def parse(raw: bytes) -> list[dict]:
|
||||
'lessons': [
|
||||
{
|
||||
'title': _text(lesson.get('title'), 'title', f'{nwhere} › نوع {li + 1}'),
|
||||
'intro_text': (lesson.get('introText') or lesson.get('intro_text') or ''),
|
||||
'intro_text': (lesson.get('introText') or ''),
|
||||
'example': str(lesson.get('example') or ''),
|
||||
'steps': [str(x) for x in (lesson.get('steps') or []) if str(x).strip()][:12],
|
||||
'figures': lesson.get('figures') or [],
|
||||
}
|
||||
for li, lesson in enumerate(lessons)
|
||||
],
|
||||
})
|
||||
|
||||
clean_units.append({
|
||||
# کتاب صریح است: فایلِ بیکتاب رد میشود تا فصلِ حسابان بیصدا «ریاضی» نشود.
|
||||
'subject': _text(unit.get('subject'), 'subject', uwhere),
|
||||
'title': _text(unit.get('title'), 'title', uwhere),
|
||||
'blurb': str(unit.get('blurb') or '')[:120],
|
||||
'order': int(unit.get('order') or ui + 1),
|
||||
'nodes': clean_nodes,
|
||||
})
|
||||
@@ -113,6 +119,16 @@ def parse(raw: bytes) -> list[dict]:
|
||||
return cleaned
|
||||
|
||||
|
||||
def _subject_or_error(title: str):
|
||||
"""کتابِ فایل باید از قبل در جدولِ Subject باشد (ادمین ← کتابها)؛ نامِ ناشناخته خطا است، نه ساختنِ بیصدا."""
|
||||
from .models import Subject
|
||||
subject = Subject.objects.filter(title=title).first()
|
||||
if subject is None:
|
||||
known = '، '.join(Subject.objects.values_list('title', flat=True))
|
||||
raise CurriculumError(f'کتابِ «{title}» در جدولِ کتابها نیست. موجود: {known}')
|
||||
return subject
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def import_curriculum(grades: list[dict]) -> dict:
|
||||
"""
|
||||
@@ -131,11 +147,16 @@ def import_curriculum(grades: list[dict]) -> dict:
|
||||
counts['grades'] += 1 if created else 0
|
||||
|
||||
for unit_row in grade_row['units']:
|
||||
subject = _subject_or_error(unit_row['subject'])
|
||||
unit, created = Unit.objects.get_or_create(
|
||||
grade=grade, title=unit_row['title'],
|
||||
defaults={'order': unit_row['order']},
|
||||
defaults={'order': unit_row['order'], 'subject': subject},
|
||||
)
|
||||
counts['units'] += 1 if created else 0
|
||||
# متنِ ادمین برنده است: فقط خانهی خالی از فایل پر میشود
|
||||
if unit_row.get('blurb') and not unit.blurb:
|
||||
unit.blurb = unit_row['blurb']
|
||||
unit.save(update_fields=['blurb'])
|
||||
|
||||
for node_row in unit_row['nodes']:
|
||||
node, created = Node.objects.get_or_create(
|
||||
@@ -149,12 +170,27 @@ def import_curriculum(grades: list[dict]) -> dict:
|
||||
node=node, title=lesson_row['title'],
|
||||
defaults={'intro_text': lesson_row['intro_text']},
|
||||
)
|
||||
changed = []
|
||||
if created:
|
||||
counts['lessons'] += 1
|
||||
elif lesson_row['intro_text'] and lesson.intro_text != lesson_row['intro_text']:
|
||||
lesson.intro_text = lesson_row['intro_text']
|
||||
lesson.save(update_fields=['intro_text'])
|
||||
counts['updated'] += 1
|
||||
changed.append('intro_text')
|
||||
# مثال و قدمها فقط وقتی از فایل میآیند که در دیتابیس خالیاند؛ آنچه
|
||||
# کسی از پنلِ ادمین نوشته با دیپلوی بازنویسی نمیشود.
|
||||
if lesson_row.get('example') and not lesson.example:
|
||||
lesson.example = lesson_row['example']
|
||||
changed.append('example')
|
||||
if lesson_row.get('steps') and not lesson.steps:
|
||||
lesson.steps = lesson_row['steps']
|
||||
changed.append('steps')
|
||||
if lesson_row.get('figures') and not lesson.figures:
|
||||
lesson.figures = lesson_row['figures']
|
||||
changed.append('figures')
|
||||
if changed:
|
||||
lesson.save(update_fields=changed)
|
||||
if not created:
|
||||
counts['updated'] += 1
|
||||
|
||||
return counts
|
||||
|
||||
@@ -162,21 +198,28 @@ def import_curriculum(grades: list[dict]) -> dict:
|
||||
def export_curriculum(grades_queryset) -> dict:
|
||||
"""خروجی دقیقاً همان قالبیست که import میخواند."""
|
||||
out = []
|
||||
for grade in grades_queryset.prefetch_related('units__nodes__lessons'):
|
||||
for grade in grades_queryset.prefetch_related('units__subject', 'units__nodes__lessons'):
|
||||
out.append({
|
||||
'title': grade.title,
|
||||
'icon': grade.icon,
|
||||
'color': grade.color,
|
||||
'units': [
|
||||
{
|
||||
'subject': unit.subject.title,
|
||||
'title': unit.title,
|
||||
'order': unit.order,
|
||||
**({'blurb': unit.blurb} if unit.blurb else {}),
|
||||
'nodes': [
|
||||
{
|
||||
'title': node.title,
|
||||
'order': node.order,
|
||||
'lessons': [
|
||||
{'title': lesson.title, 'introText': lesson.intro_text}
|
||||
{
|
||||
'title': lesson.title, 'introText': lesson.intro_text,
|
||||
**({'example': lesson.example} if lesson.example else {}),
|
||||
**({'steps': lesson.steps} if lesson.steps else {}),
|
||||
**({'figures': lesson.figures} if lesson.figures else {}),
|
||||
}
|
||||
for lesson in node.lessons.all()
|
||||
],
|
||||
}
|
||||
|
||||
175
content/management/commands/fill_lesson_texts.py
Normal file
175
content/management/commands/fill_lesson_texts.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
پر کردنِ متنهای خالیِ درس و فصل از خودِ کتاب — بدونِ هیچ فراخوانیِ پولی.
|
||||
|
||||
python manage.py fill_lesson_texts # همهی پایهها
|
||||
python manage.py fill_lesson_texts --dry-run # فقط گزارش
|
||||
|
||||
- `Unit.blurb`: یک جمله از عنوانِ جزیرههای همان فصل («در این فصل: …»).
|
||||
- `Lesson.example` و `Lesson.steps`: یک مسئلهی حلشدهی خودِ کتاب برای همان درس،
|
||||
فقط از تکههایی که راهحلشان بازبینی و تأیید شده (`solution_verdict='agrees'`)،
|
||||
با جوابِ بازسنجیشده (`checked_answer`) در قدمِ آخر.
|
||||
|
||||
دو قاعده که عمداً اینجاست:
|
||||
1. **فقط خانهی خالی پر میشود.** متنی که کسی از پنلِ ادمین نوشته دست نمیخورد، و
|
||||
اجرای دوباره چیزی را عوض نمیکند.
|
||||
2. **مثال همان مسئلهی آزمونِ درس نیست.** سوالِ آزمون از اولین تکهی تأییدشدهی درس
|
||||
ساخته شده (generate_questions_from_chunks)؛ اینجا از دومی به بعد برداشته میشود و
|
||||
تکهای که جوابش با جوابِ درستِ سوالِ همان درس یکی است کنار میرود. بدونِ این،
|
||||
صفحهی درس جوابِ آزمون را پیشاپیش لو میداد. آزموده نشده: اینکه بازنویسیِ مدل در
|
||||
ساختِ سوال جواب را عوض کرده باشد و این مقایسه آن را نبیند.
|
||||
"""
|
||||
import re
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from content.management.commands.generate_questions_from_chunks import Command as QuestionCommand
|
||||
from content.models import Choice, Grade, Lesson, Unit
|
||||
from rag.models import LessonChunk
|
||||
from rag.services.config import build_collection_name
|
||||
from rag.services.mathtext import to_plain
|
||||
from rag.services.textbooks import TEXTBOOKS
|
||||
|
||||
BLURB_MAX = 120
|
||||
STEP_MAX = 6
|
||||
STEP_CHARS = 320
|
||||
EXAMPLE_CHARS = 700
|
||||
_DIGITS = str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789')
|
||||
|
||||
|
||||
def _norm(text: str) -> str:
|
||||
"""برای مقایسهی جوابها: رقمِ لاتین، بیفاصله، بیعلامتِ اضافه."""
|
||||
return re.sub(r'[\s٫.,،:]+', '', (text or '').translate(_DIGITS)).lower()
|
||||
|
||||
|
||||
# کلمههای عنوانِ درس که چیزی دربارهی موضوع نمیگویند
|
||||
_STOP = {'و', 'از', 'با', 'در', 'به', 'برای', 'یک', 'دو', 'های', 'ها', 'آن', 'این', 'حل', 'محاسبه', 'تعیین',
|
||||
'پیدا', 'کردن', 'نوشتن', 'رسم', 'مسئله', 'مسائل', 'تمرین', 'مقدار', 'عدد', 'اعداد', 'شکل', 'کمک',
|
||||
'داده', 'شده', 'تعداد', 'ممکن', 'شرایط', 'درست', 'نادرست', 'عبارت', 'عبارتهای', 'استفاده', 'رابطه',
|
||||
'بررسی', 'تشخیص', 'یافتن', 'نمایش', 'مقایسه', 'کاربرد', 'مفهوم', 'روش', 'انواع', 'ساده', 'طول'}
|
||||
# مسئلهای که به شکل یا جدولی اشاره میکند که در متن نیست، بهعنوانِ مثال به کار نمیآید
|
||||
_FIGURE_REF = re.compile(r'شکل|روبهرو|روبرو|مقابل|صفحه(?:ٔ|ی)?\s*قبل|فعالیت|داده شده را|جدول زیر|نمودار(?: زیر|\s*ون|\s*توابع)|زیر را|توابع f و g|با توجه به نمودار')
|
||||
|
||||
|
||||
def related(lesson_title: str, chunk) -> bool:
|
||||
"""
|
||||
آیا مسئله به همین درس مربوط است؟ جفتشدن از روی بازهی صفحه گاهی مسئلهی نامربوطِ همان
|
||||
صفحهها را میداد (برای «نمودارِ قدرمطلق» مسئلهای دربارهی نمایشِ چندجملهای). دستِکم
|
||||
یک کلمهی معنادارِ عنوان باید در نوع یا متنِ مسئله باشد.
|
||||
"""
|
||||
words = [w for w in re.split(r'[\s،,()«»\-/]+', lesson_title) if len(w) >= 3 and w not in _STOP]
|
||||
if not words:
|
||||
return False
|
||||
haystack = f'{chunk.problem_type} {chunk.problem}'
|
||||
return sum(w in haystack for w in words) >= min(2, len(words))
|
||||
|
||||
|
||||
def unit_blurb(unit: Unit) -> str:
|
||||
titles = [n.title.strip() for n in unit.nodes.order_by('order') if n.title.strip()]
|
||||
if not titles:
|
||||
return ''
|
||||
head = 'در این فصل: '
|
||||
parts: list[str] = []
|
||||
for t in titles:
|
||||
candidate = '، '.join(parts + [t])
|
||||
if len(head) + len(candidate) + 1 > BLURB_MAX:
|
||||
break
|
||||
parts.append(t)
|
||||
if not parts:
|
||||
return (head + titles[0])[:BLURB_MAX - 1] + '…'
|
||||
text = '، '.join(parts[:-1]) + (f' و {parts[-1]}' if len(parts) > 1 else parts[0])
|
||||
if len(parts) < len(titles):
|
||||
text += ' و …'
|
||||
return f'{head}{text}.'[:BLURB_MAX]
|
||||
|
||||
|
||||
def split_steps(solution: str) -> list[str]:
|
||||
"""راهحلِ کتاب → چند قدمِ کوتاه. اول با خطهای جدا، بعد با جملهها."""
|
||||
plain = to_plain(solution or '').strip()
|
||||
lines = [ln.strip(' -•\t') for ln in plain.splitlines() if ln.strip(' -•\t')]
|
||||
if len(lines) <= 1:
|
||||
lines = [s.strip() for s in re.split(r'(?<=[.؛!؟])\s+', plain) if s.strip()]
|
||||
# شمارهی قدم را فرانت خودش میگذارد
|
||||
lines = [re.sub(r'^(?:(?:گام|مرحله|قدم)\s*[0-9۰-۹]{1,2}\s*[::.\-)]?|[0-9۰-۹]{1,2}[).\-])\s*', '', ln) for ln in lines]
|
||||
if len(lines) > STEP_MAX:
|
||||
# قدمهای اضافه در قدمِ آخر ادغام میشوند تا چیزی گم نشود
|
||||
lines = lines[:STEP_MAX - 1] + [' '.join(lines[STEP_MAX - 1:])]
|
||||
return [ln[:STEP_CHARS] for ln in lines if ln]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'پر کردنِ Unit.blurb و Lesson.example/steps از کتاب (فقط خانههای خالی؛ بدونِ هزینه).'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--dry-run', action='store_true', help='فقط گزارش بده؛ چیزی ننویس.')
|
||||
|
||||
def handle(self, *args, **opts):
|
||||
dry = opts['dry_run']
|
||||
counts = {'blurbs': 0, 'examples': 0, 'no_chunk': 0, 'kept': 0}
|
||||
with transaction.atomic():
|
||||
for unit in Unit.objects.prefetch_related('nodes'):
|
||||
if unit.blurb.strip():
|
||||
counts['kept'] += 1
|
||||
continue
|
||||
blurb = unit_blurb(unit)
|
||||
if blurb:
|
||||
unit.blurb = blurb
|
||||
unit.save(update_fields=['blurb'])
|
||||
counts['blurbs'] += 1
|
||||
|
||||
finder = QuestionCommand()
|
||||
for book in TEXTBOOKS.values():
|
||||
grade = Grade.objects.filter(title=f'پایه {book.grade}').first()
|
||||
if grade is None:
|
||||
continue
|
||||
finder._book = book
|
||||
collection = build_collection_name(book.subject, book.grade)
|
||||
lessons = (Lesson.objects.filter(node__unit__grade=grade, node__unit__subject__title=book.subject)
|
||||
.select_related('node__unit').order_by('node__unit__order', 'node__order', 'id'))
|
||||
for lesson in lessons:
|
||||
if lesson.example.strip() or lesson.steps:
|
||||
continue
|
||||
chunk = self._example_chunk(finder, lesson, collection)
|
||||
if chunk is None:
|
||||
counts['no_chunk'] += 1
|
||||
continue
|
||||
steps = split_steps(chunk.solution)
|
||||
answer = (chunk.checked_answer or '').strip()
|
||||
if answer and not any(_norm(answer) in _norm(s) for s in steps[-2:]):
|
||||
steps = (steps[:STEP_MAX - 1] if len(steps) >= STEP_MAX else steps) + [f'✅ جواب: {to_plain(answer)}']
|
||||
lesson.example = to_plain(chunk.problem)[:EXAMPLE_CHARS]
|
||||
lesson.steps = steps
|
||||
lesson.save(update_fields=['example', 'steps'])
|
||||
counts['examples'] += 1
|
||||
if dry:
|
||||
transaction.set_rollback(True)
|
||||
|
||||
prefix = 'اگر اجرا شود: ' if dry else ''
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'{prefix}{counts["blurbs"]} توضیحِ فصل و {counts["examples"]} مثالِ درس پر شد؛ '
|
||||
f'{counts["no_chunk"]} درس مسئلهی تأییدشدهی دوم نداشت؛ '
|
||||
f'{counts["kept"]} فصل از قبل متن داشت و دست نخورد.'
|
||||
))
|
||||
|
||||
def _example_chunk(self, finder, lesson, collection):
|
||||
"""دومین مسئلهی تأییدشدهی درس، که جوابش جوابِ سوالِ آزمونِ همین درس نباشد."""
|
||||
by_title = list(
|
||||
LessonChunk.objects.filter(collection=collection, problem_type=lesson.title,
|
||||
solution_verdict=LessonChunk.VERDICT_AGREES)
|
||||
.exclude(problem='').exclude(solution='').order_by('source_page', 'order')[:6]
|
||||
)
|
||||
candidates = by_title or finder._chunks_by_page(
|
||||
lesson, collection, need=6, opts={'verified_only': True}, used=set(), skip=0)
|
||||
quiz_answers = {
|
||||
_norm(v) for v in Choice.objects.filter(question__lesson=lesson, is_correct=True).values_list('value', flat=True)
|
||||
}
|
||||
# اولی مالِ سوالِ آزمون است (همان ترتیبِ قطعیِ ساختِ سوال)
|
||||
for chunk in candidates[1:]:
|
||||
if not (chunk.solution or '').strip() or _FIGURE_REF.search(chunk.problem):
|
||||
continue
|
||||
if not by_title and not related(lesson.title, chunk):
|
||||
continue
|
||||
if _norm(chunk.checked_answer) and _norm(chunk.checked_answer) in quiz_answers:
|
||||
continue
|
||||
return chunk
|
||||
return None
|
||||
293
content/management/commands/gen_lesson_figures.py
Normal file
293
content/management/commands/gen_lesson_figures.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""
|
||||
شکلِ ثابتِ درسها را یک بار میسازد و در `Lesson.figures` مینویسد.
|
||||
|
||||
همان مسیرِ ارزانِ دستیار: همان مدلِ چت، همان کاتالوگِ <diagram>، همان اعتبارسنجِ
|
||||
`rag.services.diagram.validate`. فرقش این است که نتیجه ذخیره میشود و بعد از تأییدِ چشمی با
|
||||
`seed_content --export` به curriculum.json میرود.
|
||||
|
||||
سه چیز که هزینه و خطا را پایین میآورد:
|
||||
1. **پیشصافیِ رایگان:** اگر متنِ درس به هیچ بخشِ کاتالوگ نمیخورد (حسابِ عددیِ ساده)، مدل صدا
|
||||
زده نمیشود و درس «بینیازِ شکل» ثبت میشود.
|
||||
2. **فقط بخشهای مربوطِ کاتالوگ** (نه کلِ آن، ~۳٬۵۰۰ توکن) کنارِ درس میرود.
|
||||
3. **گامِ وارسیِ مستقل:** هر شکلی که مدل ساخت، مدلِ دوم با متنِ درس مقایسه میکند؛ شکلی که
|
||||
همان چیزِ متن را نشان نمیدهد (عدد/برچسبِ ناهمخوان، قدمِ اشتباه) ذخیره نمیشود.
|
||||
|
||||
سقفِ هزینه با فرمولِ رسمیِ gpt-4.1-mini محاسبه میشود (۰٫۴۰ و ۱٫۶۰ دلار در هر میلیون توکن). صورتحسابِ
|
||||
واقعیِ GapGPT در سنجشهای قبلی چند برابر کمتر بود، پس سقف محافظهکارانه است. آزموده نشده: درستیِ
|
||||
هندسی/ریاضیِ شکل؛ وارسیِ مدلِ دوم جایگزینِ دیدنِ چشمی نیست.
|
||||
|
||||
python manage.py gen_lesson_figures --grade 12 --limit 20 --max-usd 2 --ledger fig_ledger.json
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
import openai
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from content.models import Lesson
|
||||
from rag.services import clients, diagram as diagram_mod
|
||||
from rag.services.generation import chat_model_for, completion_kwargs
|
||||
|
||||
USD_PER_M_IN = 0.40
|
||||
USD_PER_M_OUT = 1.60
|
||||
MAX_FIGURES = 3
|
||||
|
||||
PROMPT = (
|
||||
'تو برای یک درسِ ریاضی (اپِ آموزشی) شکلهای آموزشی میسازی. متنِ درس: «توضیح»، «مثال» و «قدمها» '
|
||||
'(شمارهی قدمها از ۱، همان شمارهای که کاربر میبیند).\n'
|
||||
'قاعدهها:\n'
|
||||
f'- فقط جایی شکل بگذار که دیدنش مفهوم یا مسئله را روشن میکند؛ حداکثر {MAX_FIGURES} شکل. '
|
||||
'شکلِ تزیینی یا تکراری نساز. اگر هیچجا لازم نبود []. ترجیح با «example» و «step» است؛ '
|
||||
'«intro» فقط وقتی متنِ توضیح خودش عدد یا نمادِ مشخص میدهد.\n'
|
||||
'- هر شکل باید دقیقاً همان را نشان دهد که آن بخش میگوید، با همان اعداد و نامها؛ چیزی که در '
|
||||
'متن نیست اختراع نکن. برای بخشی که در متن نیست (مثال/قدم) شکل نساز.\n'
|
||||
'- نمودارِ خطشکسته یا تکهای را با type=segment بین نقطههای متوالی بکش، نه type=line. '
|
||||
'برای نمودارِ تابع بازهی x را کوچک بگیر (مقدارِ y از حدود ۹ بیشتر نشود) و فقط items لازم را بگذار؛ '
|
||||
'marks فقط برای یک منحنی. نقطههایی که در items نمیآیند تعریف نکن.\n'
|
||||
'- شکلِ فقطنقطه (چند نقطهی بیارتباط) نساز؛ نقطه را روی نمودار یا شکلِ مربوطش بگذار. ضابطهی تابع را '
|
||||
'عیناً از متن بردار (ضریب و انتقال را حذف نکن). برای مثلثِ متساویالاضلاع/متساویالساقین و مانندش '
|
||||
'طولها را با نوعش سازگار بده. نوعِ pattern/groups را فقط وقتی بگذار که خودِ متن شکل یا شمارشِ مشخصی میدهد.\n'
|
||||
'- شکلِ هر قدم همان را نشان دهد که متنِ همان قدم میگوید (نه قدمِ قبل یا بعد).\n'
|
||||
'- مثالهای کاتالوگ فقط قالباند؛ نقطه، تابع یا آیتمی را که این درس لازم ندارد کپی نکن.\n'
|
||||
'خروجی فقط یک آرایهی JSON: [{"where":"intro"|"example"|"step","index":1,"diagram":{...}}] '
|
||||
'(index فقط برای step و همان شمارهی ۱بهبعدِ قدم). بدونِ توضیح و بدونِ ```.\n\n'
|
||||
)
|
||||
|
||||
CHECK = (
|
||||
'بازبینِ سختگیرِ ریاضی هستی. یک درس و شکلهایی که برایش ساختهاند (هر شکل یک JSON) داده میشود. '
|
||||
'برای هر شکل ابتدا در یک جمله بنویس «shows»: دقیقاً چه چیزی میکشد (ضابطهی هر تابع، مختصات نقطهها، '
|
||||
'طول ضلعها و زاویهها، بازهها، عددهای جدول، موقعیت و برچسبها). بعد با متنِ همان بخشِ درس مقایسه کن. '
|
||||
'ok=false بده اگر: ضابطه یا عددی با متن نمیخواند (مثلاً ضریب یا انتقال گم شده)؛ شکل از نظر ریاضی/هندسی '
|
||||
'ناسازگار است (مثلاً مثلثِ متساویالاضلاع با ضلعهای نابرابر)؛ موقعیتِ نقطهها با برچسبشان نمیخواند؛ '
|
||||
'شکل مفهومِ اصلیِ آن بخش را نشان نمیدهد یا فقط نقطههای پراکنده/برچسبِ بیمعنی است؛ چیزی اختراع شده؛ '
|
||||
'شکلِ قدم به همان قدم مربوط نیست. هر شکِ جدی → ok=false. سلیقهای ایراد نگیر. '
|
||||
'خروجی فقط JSON: {"verdicts":[{"i":0,"shows":"...","ok":true|false,"why":"یک جمله"}]} به ترتیبِ شکلها.'
|
||||
)
|
||||
|
||||
|
||||
def _lesson_text(lesson: Lesson) -> str:
|
||||
steps = '\n'.join(f'قدم {i}: {s}' for i, s in enumerate(lesson.steps or [], 1))
|
||||
return f'عنوان: {lesson.title}\nتوضیح: {lesson.intro_text}\nمثال: {lesson.example}\n{steps}'
|
||||
|
||||
|
||||
def _spec_for(text: str) -> str:
|
||||
"""فقط بخشهایی از کاتالوگ که کلمههایشان در متن هست؛ بیبخش → '' (مدل صدا زده نمیشود)."""
|
||||
parts = [section for pattern, section in diagram_mod.SECTIONS if pattern.search(text)]
|
||||
return '\n'.join([diagram_mod._HEAD, *parts]) if parts else ''
|
||||
|
||||
|
||||
_SUBSTANTIVE = {'function', 'segment', 'line', 'ray', 'polygon', 'triangle', 'quad', 'regularPolygon', 'circle',
|
||||
'angle', 'exteriorAngle', 'arc', 'vector'}
|
||||
|
||||
|
||||
def _is_thin(diagram: dict) -> bool:
|
||||
"""
|
||||
شکلِ صفحهای که هیچ منحنی، پارهخط یا شکلی ندارد و فقط نقطهی پراکنده است چیزی نشان نمیدهد
|
||||
(مثلاً چند نقطهی A و B و C بیارتباط). نوعِ تازهای که این فهرست نمیشناسد «محتوادار» حساب میشود.
|
||||
"""
|
||||
if diagram.get('kind') != 'plane':
|
||||
return False
|
||||
items = diagram.get('items') or []
|
||||
if any(i.get('type') in _SUBSTANTIVE or i.get('type') not in ('point', 'label', None) for i in items):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _json_list(raw: str):
|
||||
raw = re.sub(r'^```(?:json)?|```$', '', (raw or '').strip()).strip()
|
||||
start = raw.index('[')
|
||||
items, _ = json.JSONDecoder().raw_decode(raw[start:])
|
||||
if not isinstance(items, list):
|
||||
raise ValueError('not a list')
|
||||
return items
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'ساختِ شکلِ ثابتِ درسها (یک بار، ذخیره در Lesson.figures).'
|
||||
|
||||
def add_arguments(self, p):
|
||||
p.add_argument('--grade', type=int, help='بدونِ آن همهی پایهها')
|
||||
p.add_argument('--subject', help='عنوانِ کتاب (مثلاً هندسه)؛ بدونِ آن همهی کتابها')
|
||||
p.add_argument('--limit', type=int, default=4)
|
||||
p.add_argument('--lesson', type=int, help='فقط همین شناسه')
|
||||
p.add_argument('--redo', action='store_true', help='درسهایی که شکل دارند هم')
|
||||
p.add_argument('--dry-run', action='store_true')
|
||||
p.add_argument('--workers', type=int, default=4)
|
||||
p.add_argument('--max-usd', type=float, default=2.0, help='سقفِ هزینه (فرمولِ رسمی)؛ رسید، میایستد')
|
||||
p.add_argument('--ledger', help='JSON از درسهای بررسیشده ({id: نتیجه}) تا دوباره پول نگیرند')
|
||||
p.add_argument('--quiet', action='store_true')
|
||||
p.add_argument('--recheck', action='store_true',
|
||||
help='شکلهای ذخیرهشده را دوباره با وارسیِ سختگیر بسنج و ناموفقها را پاک کن (بیساخت)')
|
||||
p.add_argument('--no-check', action='store_true', help='گامِ وارسیِ مستقل را رد کن (فقط برای آزمایش)')
|
||||
|
||||
def handle(self, *a, **o):
|
||||
if o['recheck']:
|
||||
return self._recheck(o)
|
||||
qs = Lesson.objects.exclude(example='').order_by(
|
||||
'node__unit__grade__number', 'node__unit__order', 'node__order', 'id')
|
||||
if o['grade']:
|
||||
qs = qs.filter(node__unit__grade__number=o['grade'])
|
||||
if o['subject']:
|
||||
qs = qs.filter(node__unit__subject__title=o['subject'])
|
||||
if o['lesson']:
|
||||
qs = qs.filter(pk=o['lesson'])
|
||||
if not o['redo']:
|
||||
qs = qs.filter(figures=[])
|
||||
ledger_path = Path(o['ledger']) if o['ledger'] else None
|
||||
ledger = json.loads(ledger_path.read_text()) if ledger_path and ledger_path.exists() else {}
|
||||
if not o['redo']:
|
||||
qs = qs.exclude(pk__in=[int(k) for k in ledger])
|
||||
lessons = list(qs[:o['limit']])
|
||||
if not lessons:
|
||||
raise CommandError('درسی پیدا نشد.')
|
||||
|
||||
model = chat_model_for('')
|
||||
client = clients.get_openai_client()
|
||||
lock = threading.Lock()
|
||||
usage = {'in': 0, 'out': 0}
|
||||
stats = {'no_trigger': 0, 'none': 0, 'saved': 0, 'figures': 0, 'rejected': 0, 'invalid': 0, 'stopped': 0}
|
||||
|
||||
def spent():
|
||||
return usage['in'] / 1e6 * USD_PER_M_IN + usage['out'] / 1e6 * USD_PER_M_OUT
|
||||
|
||||
def ask(system, user, **extra):
|
||||
r = client.chat.completions.create(
|
||||
model=model, **completion_kwargs(model), **extra,
|
||||
messages=[{'role': 'system', 'content': system}, {'role': 'user', 'content': user}])
|
||||
u = getattr(r, 'usage', None)
|
||||
with lock:
|
||||
usage['in'] += getattr(u, 'prompt_tokens', 0) or 0
|
||||
usage['out'] += getattr(u, 'completion_tokens', 0) or 0
|
||||
return r.choices[0].message.content or ''
|
||||
|
||||
def record(lesson, outcome):
|
||||
with lock:
|
||||
if ledger_path and not o['dry_run']:
|
||||
ledger[str(lesson.id)] = outcome
|
||||
ledger_path.write_text(json.dumps(ledger, ensure_ascii=False))
|
||||
|
||||
def work(lesson):
|
||||
text = _lesson_text(lesson)
|
||||
spec = _spec_for(text)
|
||||
if not spec:
|
||||
with lock:
|
||||
stats['no_trigger'] += 1
|
||||
record(lesson, 'no_trigger')
|
||||
return
|
||||
if spent() >= o['max_usd']:
|
||||
with lock:
|
||||
stats['stopped'] += 1
|
||||
return
|
||||
try:
|
||||
items = _json_list(ask(PROMPT + spec, text))
|
||||
# خطای شبکه/درگاه یک درس را رد میکند، نه کلِ اجرا را؛ در ledger ثبت نمیشود تا دفعهی بعد دوباره برود.
|
||||
except (ValueError, KeyError, openai.OpenAIError) as e:
|
||||
with lock:
|
||||
stats['invalid'] += 1
|
||||
self.stderr.write(f'درس {lesson.id}: خروجیِ نامعتبر ({e}); ذخیره نشد.')
|
||||
return
|
||||
figures, dropped = [], 0
|
||||
for it in items[:MAX_FIGURES + 2]:
|
||||
where = it.get('where') if isinstance(it, dict) else None
|
||||
s = diagram_mod.validate(it.get('diagram')) if where else None
|
||||
idx = it.get('index')
|
||||
if where == 'step' and isinstance(idx, int):
|
||||
idx -= 1 # مدل ۱بهبعد مینویسد؛ ذخیره از صفر
|
||||
if (s is None or where not in ('intro', 'example', 'step')
|
||||
or (where == 'step' and not (isinstance(idx, int) and 0 <= idx < len(lesson.steps or [])))
|
||||
or (where == 'example' and not lesson.example)):
|
||||
dropped += 1
|
||||
continue
|
||||
if _is_thin(s):
|
||||
dropped += 1
|
||||
continue
|
||||
for item in s.get('items', []):
|
||||
if item.get('type') == 'segment':
|
||||
item.pop('ticks', None) # علامتِ «ضلع مساوی» روی نمودارِ تابع فقط شلوغی است
|
||||
figures.append({'where': where, **({'index': idx} if where == 'step' else {}), 'diagram': s})
|
||||
figures = figures[:MAX_FIGURES]
|
||||
if not figures:
|
||||
with lock:
|
||||
stats['none'] += 1
|
||||
record(lesson, 'none' if not items else 'invalid_all')
|
||||
return
|
||||
kept, notes = figures, []
|
||||
if not o['no_check']:
|
||||
try:
|
||||
verdicts = json.loads(ask(CHECK, text + '\n\nشکلها:\n' + '\n'.join(
|
||||
f'{i}: ' + json.dumps(f, ensure_ascii=False) for i, f in enumerate(figures)),
|
||||
response_format={'type': 'json_object'})).get('verdicts', [])
|
||||
ok = {v.get('i'): v for v in verdicts if isinstance(v, dict)}
|
||||
kept = [f for i, f in enumerate(figures) if ok.get(i, {}).get('ok') is True]
|
||||
notes = [f"{i}: {v.get('why', '')}" for i, v in ok.items() if v.get('ok') is not True]
|
||||
except (ValueError, KeyError, AttributeError, openai.OpenAIError) as e:
|
||||
kept, notes = [], [f'وارسی ناموفق: {e}']
|
||||
with lock:
|
||||
stats['rejected'] += len(figures) - len(kept)
|
||||
stats['figures'] += len(kept)
|
||||
if kept:
|
||||
stats['saved'] += 1
|
||||
self.stdout.write(f'درس {lesson.id} «{lesson.title}»: {len(kept)} شکل از {len(figures)} '
|
||||
f'({dropped} ردشده در اعتبارسنج)' + (f' | رد: {"; ".join(notes)}' if notes else ''))
|
||||
if not o['quiet']:
|
||||
for f in kept:
|
||||
self.stdout.write(' ' + json.dumps(f, ensure_ascii=False))
|
||||
if kept and not o['dry_run']:
|
||||
lesson.figures = kept
|
||||
lesson.save(update_fields=['figures'])
|
||||
record(lesson, f'figures:{len(kept)}' if kept else 'rejected')
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max(1, o['workers'])) as ex:
|
||||
list(ex.map(work, lessons))
|
||||
self.stdout.write(f'{stats}\nتوکن: ورودی {usage["in"]}، خروجی {usage["out"]}؛ هزینهی فرمولی ≈ ${spent():.3f}')
|
||||
|
||||
def _recheck(self, o):
|
||||
qs = Lesson.objects.exclude(figures=[]).order_by('node__unit__grade__number', 'id')
|
||||
if o['grade']:
|
||||
qs = qs.filter(node__unit__grade__number=o['grade'])
|
||||
lessons = list(qs[:o['limit']])
|
||||
model = chat_model_for('')
|
||||
client = clients.get_openai_client()
|
||||
lock = threading.Lock()
|
||||
usage = {'in': 0, 'out': 0}
|
||||
stats = {'lessons': 0, 'kept': 0, 'dropped': 0, 'errors': 0}
|
||||
|
||||
def work(lesson):
|
||||
figures = [f for f in lesson.figures if not _is_thin(f['diagram'])]
|
||||
thin = len(lesson.figures) - len(figures)
|
||||
text = _lesson_text(lesson)
|
||||
try:
|
||||
r = client.chat.completions.create(
|
||||
model=model, **completion_kwargs(model), response_format={'type': 'json_object'},
|
||||
messages=[{'role': 'system', 'content': CHECK}, {'role': 'user', 'content': text + '\n\nشکلها:\n' + '\n'.join(
|
||||
f'{i}: ' + json.dumps(f, ensure_ascii=False) for i, f in enumerate(figures))}])
|
||||
u = getattr(r, 'usage', None)
|
||||
ok = {v.get('i'): v for v in json.loads(r.choices[0].message.content or '{}').get('verdicts', [])
|
||||
if isinstance(v, dict)} if figures else {}
|
||||
except (ValueError, KeyError, AttributeError) as e:
|
||||
with lock:
|
||||
stats['errors'] += 1
|
||||
self.stderr.write(f'درس {lesson.id}: وارسی ناموفق ({e}); دست نخورد.')
|
||||
return
|
||||
kept = [f for i, f in enumerate(figures) if ok.get(i, {}).get('ok') is True]
|
||||
with lock:
|
||||
usage['in'] += getattr(u, 'prompt_tokens', 0) or 0
|
||||
usage['out'] += getattr(u, 'completion_tokens', 0) or 0
|
||||
stats['lessons'] += 1
|
||||
stats['kept'] += len(kept)
|
||||
stats['dropped'] += len(lesson.figures) - len(kept)
|
||||
why = '; '.join(f"{i}: {v.get('why', '')}" for i, v in ok.items() if v.get('ok') is not True)
|
||||
if len(kept) != len(lesson.figures) and not o['quiet']:
|
||||
self.stdout.write(f'درس {lesson.id} «{lesson.title}»: {len(kept)}/{len(lesson.figures)}'
|
||||
f' (بیمحتوا {thin}) | {why}')
|
||||
if len(kept) != len(lesson.figures) and not o['dry_run']:
|
||||
lesson.figures = kept
|
||||
lesson.save(update_fields=['figures'])
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max(1, o['workers'])) as ex:
|
||||
list(ex.map(work, lessons))
|
||||
cost = usage['in'] / 1e6 * USD_PER_M_IN + usage['out'] / 1e6 * USD_PER_M_OUT
|
||||
self.stdout.write(f'{stats}\nتوکن: ورودی {usage["in"]}، خروجی {usage["out"]}؛ هزینهی فرمولی ≈ ${cost:.3f}')
|
||||
@@ -13,10 +13,20 @@
|
||||
python manage.py generate_questions_from_chunks --grade نهم --limit 5 --dry-run
|
||||
python manage.py generate_questions_from_chunks --grade نهم --limit 20
|
||||
python manage.py generate_questions_from_chunks --grade نهم
|
||||
|
||||
مسیرِ رایگان (و پیشفرضِ عملی): `--export DIR` هیچ فراخوانیِ پولی نمیکند و
|
||||
فقط جفتهای (درس، مسئلهی تأییدشده) را در چند فایلِ JSON میریزد تا subagentها
|
||||
سوالها را بنویسند و خروجی با `content.question_io` وارد شود. اندازهگیریِ
|
||||
۱۴۰۵/۰۶/۲۹: o4-mini روی این پروکسی ۰٫۷۷ سنت برای هر سوال، subagentِ Sonnet
|
||||
صفر — با بازدهی ۹۲٪. متنِ پرامپت برای هر دو مسیر یکی است (همین PROMPT پایین)،
|
||||
پس کیفیت دو شاخه از هم جدا نمیافتد.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
@@ -26,29 +36,54 @@ from rag.services import clients
|
||||
from rag.services.config import build_collection_name, rag_is_configured
|
||||
from rag.services.textbooks import TEXTBOOKS, get_textbook
|
||||
|
||||
PROMPT = """
|
||||
تو یک معلمِ ریاضیِ پایهی {grade} هستی.
|
||||
def _is_account_limit(exc) -> bool:
|
||||
"""
|
||||
آیا این خطا یعنی «حسابِ سرویس دیگر جواب نمیدهد» یا فقط کندیِ لحظهای؟
|
||||
|
||||
از روی «مسئله» و «راهحل»ِ زیر — که مستقیماً از کتابِ درسی درآمده — یک سوالِ
|
||||
چهارگزینهای بساز.
|
||||
دو شکلِ واقعی که دیدیم و هر دو باید اجرا را متوقف کنند:
|
||||
429 initial_usage_limit_exceeded — سقفِ حسابِ تازه، ساعتها
|
||||
403 insufficient user quota — اعتبار تمام شده
|
||||
|
||||
قواعد:
|
||||
- صورتِ سوال باید کوتاه، خودکفا و به فارسی باشد؛ دانشآموز بدونِ دیدنِ کتاب
|
||||
بتواند جواب دهد.
|
||||
- دقیقاً چهار گزینه، و دقیقاً یکی درست.
|
||||
- سه گزینهی نادرست باید **خطاهای رایجِ همین مبحث** باشند (مثلاً اشتباه در
|
||||
علامتِ توان، جا انداختنِ یک مرحله، مخرجِ مشترک نگرفتن) — نه عددِ تصادفی و
|
||||
نه گزینهی آشکارا مسخره.
|
||||
- هر گزینه حداکثر ۴۰ نویسه.
|
||||
- اگر از روی این متن نمیشود سوالِ سالمی ساخت، دقیقاً {{"skip": true}} برگردان.
|
||||
نکته: متنِ دومی «insufficient user quota» است با فاصله، نه زیرخط. محافظِ
|
||||
اولِ ما دنبالِ `insufficient_quota` میگشت و نمیگرفتش، و اجرا ۷۷۰ فراخوانیِ
|
||||
محکومبهشکست زد بهجای اینکه سرِ اولی بایستد.
|
||||
"""
|
||||
text = str(exc).lower()
|
||||
if 'usage_limit' in text or 'quota' in text:
|
||||
return True
|
||||
return '403' in text and 'insufficient' in text
|
||||
|
||||
|
||||
PROMPT = """تو یک معلمِ ریاضیِ باتجربهی پایهی {grade} هستی.
|
||||
|
||||
از روی «مسئله» و «راهحل»ِ زیر — که مستقیماً از کتابِ درسی درآمده و درستیِ
|
||||
راهحلش جداگانه بررسی و تأیید شده — یک سوالِ چهارگزینهای بساز.
|
||||
|
||||
⚠️ نکتهی نگارشی: «/» بینِ دو عدد در این متنها جداکنندهی اعشار است، نه خطِ
|
||||
کسر. یعنی «۲/۵» عددِ ۲٫۵ است.
|
||||
|
||||
قواعد — نقضِ هرکدام یعنی سوالِ بیارزش:
|
||||
|
||||
- **خودکفا:** تمامِ دادهی لازم باید داخلِ صورتِ سوال باشد. «مساحتِ شکل چند
|
||||
است؟» بیارزش است چون دانشآموز شکل را نمیبیند. اگر مسئله بدونِ تصویرِ کتاب
|
||||
قابلِ بازنویسی نیست، دقیقاً {{"skip": true}} برگردان.
|
||||
- **دقیقاً یک گزینهی درست.** هر سه گزینهی دیگر را جداگانه بسنج و مطمئن شو
|
||||
قطعاً غلطاند. تلهی رایج: در «کدام مجموعه تهی است؟» چند گزینه تهی از آب
|
||||
دربیایند، یا دو گزینه از نظر عددی برابر باشند و فقط شکلِ نوشتنشان فرق کند
|
||||
(مثلاً «√۷/√۶» و «√۴۲/۶» یک عددند).
|
||||
- **گزینههای غلط باید خطای واقعیِ دانشآموز باشند** — جابهجایی علامتِ توان،
|
||||
مخرجِ مشترک نگرفتن، یک مرحله جا انداختن. نه عددِ تصادفی، نه گزینهی مسخره.
|
||||
- هیچ دو گزینهای هممعنا نباشند.
|
||||
- **بدونِ LaTeX و بدونِ markdown.** بهجای \\frac{{a}}{{b}} بنویس (a)/(b).
|
||||
متن عیناً همانطور که مینویسی به دانشآموز نشان داده میشود.
|
||||
- فارسی، با ارقامِ فارسی. هر گزینه حداکثر ۴۰ نویسه.
|
||||
|
||||
نوعِ مسئله: {problem_type}
|
||||
مسئله: {problem}
|
||||
راهحل: {solution}
|
||||
|
||||
فقط این JSON را برگردان، بدونِ markdown و بدونِ توضیح:
|
||||
{{"prompt": "صورتِ سوال", "options": ["الف", "ب", "ج", "د"], "correctIndex": 0}}
|
||||
"""
|
||||
{{"prompt": "صورتِ سوال", "options": ["الف", "ب", "ج", "د"], "correctIndex": 0}}"""
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -64,6 +99,23 @@ class Command(BaseCommand):
|
||||
help='چند سوال برای هر درس.')
|
||||
parser.add_argument('--dry-run', action='store_true',
|
||||
help='فقط نشان بده چند سوال ساخته میشود؛ هیچ فراخوانیِ پولی نکن.')
|
||||
parser.add_argument('--export', metavar='DIR',
|
||||
help='بهجای فراخوانیِ مدل، جفتهای (درس، مسئله) را در این '
|
||||
'پوشه به JSON بنویس تا subagent سوالها را بسازد. رایگان.')
|
||||
parser.add_argument('--batch-size', type=int, default=30,
|
||||
help='چند جفت در هر فایلِ --export. پیشفرض ۳۰.')
|
||||
parser.add_argument('--model', default='o4-mini',
|
||||
help='مدلِ سازنده. پیشفرض o4-mini؛ gpt-4o-mini کیفیتِ '
|
||||
'قابلِ قبولی نداد (۴ سوال از ۶ خراب).')
|
||||
parser.add_argument('--all-chunks', dest='verified_only', action='store_false',
|
||||
help='از chunkهای بازبینینشده هم بساز (پیشفرض: فقط تأییدشدهها).')
|
||||
parser.set_defaults(verified_only=True)
|
||||
parser.add_argument('--workers', type=int, default=4,
|
||||
help='فراخوانیِ همزمان. بیش از ۴ روی این پروکسی ۴۲۹ میگیرد.')
|
||||
parser.add_argument('--strict-title', dest='by_page', action='store_false',
|
||||
help='فقط chunkهایی که عنوانشان دقیقاً با عنوانِ درس یکی است '
|
||||
'(رفتارِ قدیم؛ بعد از بازخوانیِ کتابها تقریباً هیچچیز پیدا نمیکند).')
|
||||
parser.set_defaults(by_page=True)
|
||||
|
||||
def handle(self, *args, **opts):
|
||||
book = get_textbook(opts['subject'], opts['grade'])
|
||||
@@ -81,6 +133,7 @@ class Command(BaseCommand):
|
||||
)
|
||||
|
||||
collection = build_collection_name(book.subject, book.grade)
|
||||
self._book = book
|
||||
targets = self._targets(grade, collection, opts)
|
||||
if not targets:
|
||||
self.stdout.write(self.style.WARNING('هیچ درسی بدونِ سوال پیدا نشد — کاری لازم نیست.'))
|
||||
@@ -94,10 +147,15 @@ class Command(BaseCommand):
|
||||
if len(targets) > 10:
|
||||
self.stdout.write(f' … و {len(targets) - 10} مورد دیگر')
|
||||
|
||||
if opts['export']:
|
||||
self._export(targets, opts)
|
||||
return
|
||||
|
||||
# ~۹۰۰ توکن ورودی و ~۱۵۰ خروجی برای هر سوال با gpt-4o-mini.
|
||||
cost = len(targets) * (900 / 1e6 * 0.15 + 150 / 1e6 * 0.60)
|
||||
self.model = opts['model']
|
||||
cost = len(targets) * 0.0018 # اندازهگیریِ واقعیِ o4-mini
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f'\nبرآوردِ هزینه با {settings.OPENAI_CHAT_MODEL}: ≈ ${cost:.3f}'
|
||||
f'\nبرآوردِ هزینه با {self.model}: ≈ ${cost:.2f}'
|
||||
))
|
||||
if opts['dry_run']:
|
||||
self.stdout.write(self.style.WARNING('--dry-run: چیزی ساخته نشد.'))
|
||||
@@ -106,32 +164,141 @@ class Command(BaseCommand):
|
||||
if not rag_is_configured():
|
||||
raise CommandError('RAG پیکربندی نشده (کلیدها خالیاند یا RAG_FORCE_MOCK روشن است).')
|
||||
|
||||
# سقفِ مصرفِ حسابِ سرویس با فشارِ لحظهای فرق دارد: اولی ساعتها باز
|
||||
# نمیشود و تلاشِ دوباره فقط پنجره را طولانیتر میکند.
|
||||
stop = threading.Event()
|
||||
|
||||
def work(pair):
|
||||
lesson, chunk = pair
|
||||
if stop.is_set():
|
||||
return lesson, None, None
|
||||
delay = 4.0
|
||||
last = None
|
||||
for _ in range(4):
|
||||
try:
|
||||
return lesson, self._ask_model(chunk, book.grade), None
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last = exc
|
||||
text = str(exc).lower()
|
||||
if _is_account_limit(exc):
|
||||
stop.set()
|
||||
return lesson, None, exc
|
||||
if '429' not in text and 'rate' not in text:
|
||||
break
|
||||
time.sleep(delay)
|
||||
delay *= 2
|
||||
return lesson, None, last
|
||||
|
||||
created = skipped = failed = 0
|
||||
for lesson, chunk in targets:
|
||||
try:
|
||||
data = self._ask_model(chunk, book.grade)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failed += 1
|
||||
self.stdout.write(self.style.ERROR(f' ✗ {lesson.title}: {str(exc)[:90]}'))
|
||||
continue
|
||||
if data is None:
|
||||
skipped += 1
|
||||
self.stdout.write(f' — {lesson.title}: مدل گفت از این متن سوال درنمیآید')
|
||||
continue
|
||||
self._persist(lesson, data)
|
||||
created += 1
|
||||
self.stdout.write(self.style.SUCCESS(f' ✓ {lesson.title}: {data["prompt"][:60]}'))
|
||||
with ThreadPoolExecutor(max_workers=opts['workers']) as pool:
|
||||
for lesson, data, err in pool.map(work, targets):
|
||||
if err is not None:
|
||||
failed += 1
|
||||
self.stdout.write(self.style.ERROR(f' ✗ {lesson.title}: {str(err)[:85]}'))
|
||||
continue
|
||||
if data is None:
|
||||
skipped += 1
|
||||
continue
|
||||
self._persist(lesson, data)
|
||||
created += 1
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f' ✓ {lesson.title}: {data["prompt"][:55]}'))
|
||||
if stop.is_set():
|
||||
self.stdout.write(self.style.ERROR(
|
||||
'\nسقفِ مصرفِ حسابِ سرویس پر شد؛ اجرا نیمهکاره ماند. '
|
||||
'اجرای بعدی از همینجا ادامه میدهد.'))
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'\n{created} سوال ساخته شد، {skipped} رد شد، {failed} خطا.'
|
||||
))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _export(self, targets, opts):
|
||||
"""
|
||||
جفتها را در فایلهای کوچکِ JSON بنویس؛ هیچ مدلی صدا زده نمیشود.
|
||||
|
||||
چرا تکهتکه و نه یک فایلِ بزرگ: هر فایل سهمِ یک subagent است و باید در
|
||||
یک نشست جا شود. و چرا `prompt` را هم کنارش مینویسیم: تا کسی که
|
||||
سوالها را میسازد قواعد را از حافظه بازنویسی نکند — همان متنی که به
|
||||
مدلِ پولی میرفت عیناً در دسترسِ subagent است.
|
||||
"""
|
||||
directory = opts['export']
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
book = self._book
|
||||
size = max(1, opts['batch_size'])
|
||||
slug = f'{book.subject}-{book.grade}'
|
||||
|
||||
written = []
|
||||
for index in range(0, len(targets), size):
|
||||
rows = [
|
||||
{
|
||||
'lessonId': lesson.pk,
|
||||
'unit': lesson.node.unit.title,
|
||||
'node': lesson.node.title,
|
||||
'lessonTitle': lesson.title,
|
||||
'problemType': chunk.problem_type,
|
||||
'problem': chunk.problem,
|
||||
'solution': chunk.solution,
|
||||
}
|
||||
for lesson, chunk in targets[index:index + size]
|
||||
]
|
||||
path = os.path.join(directory, f'{slug}-{index // size + 1:02d}.json')
|
||||
with open(path, 'w', encoding='utf-8') as handle:
|
||||
json.dump(rows, handle, ensure_ascii=False, indent=1)
|
||||
written.append((path, len(rows)))
|
||||
|
||||
rules = os.path.join(directory, f'{slug}-RULES.txt')
|
||||
with open(rules, 'w', encoding='utf-8') as handle:
|
||||
handle.write(PROMPT.format(
|
||||
grade=book.grade, problem_type='<problemType>',
|
||||
problem='<problem>', solution='<solution>',
|
||||
))
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'\n{len(targets)} جفت در {len(written)} فایل نوشته شد: {directory}'
|
||||
))
|
||||
self.stdout.write(f' قواعدِ ساختِ سوال: {rules}')
|
||||
self.stdout.write(
|
||||
' خروجیِ هر subagent باید قالبِ content.question_io باشد '
|
||||
'({"lessonId", "prompt", "choices", "correct"}) و با '
|
||||
'«ورودِ سوال» در پنلِ ادمین یا import_questions وارد شود.'
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _page_span(self, lesson):
|
||||
"""بازهی صفحهی PDF برای نودِ این درس، از فهرستِ خودِ کتاب."""
|
||||
node = lesson.node
|
||||
book = self._book
|
||||
if book is None:
|
||||
return None
|
||||
for chapter in book.chapters:
|
||||
if chapter.title != node.unit.title:
|
||||
continue
|
||||
for section in chapter.resolved_sections():
|
||||
if section.title == node.title:
|
||||
off = book.page_offset
|
||||
return section.first_page + off, section.last_page + off
|
||||
# نودی که بخشِ متناظر ندارد: کلِ فصل را بازه بگیر.
|
||||
return book.pdf_pages(chapter)
|
||||
return None
|
||||
|
||||
def _chunks_by_page(self, lesson, collection, need, opts, used, skip=0):
|
||||
span = self._page_span(lesson)
|
||||
if span is None:
|
||||
return []
|
||||
first, last = span
|
||||
qs = (LessonChunk.objects
|
||||
.filter(collection=collection, source_page__gte=first, source_page__lte=last)
|
||||
.exclude(problem='').exclude(pk__in=used))
|
||||
if opts['verified_only']:
|
||||
qs = qs.filter(solution_verdict=LessonChunk.VERDICT_AGREES)
|
||||
return list(qs.order_by('source_page', 'order')[skip:skip + need])
|
||||
|
||||
def _targets(self, grade, collection, opts):
|
||||
"""[(lesson, chunk), ...] — فقط درسهایی که هنوز سوال کم دارند."""
|
||||
lessons = (
|
||||
Lesson.objects
|
||||
.filter(node__unit__grade=grade)
|
||||
.filter(node__unit__grade=grade, node__unit__subject__title=self._book.subject)
|
||||
.select_related('node__unit')
|
||||
.order_by('node__unit__order', 'node__order', 'id')
|
||||
)
|
||||
@@ -139,37 +306,59 @@ class Command(BaseCommand):
|
||||
lessons = lessons.filter(node__unit__order__in=opts['chapters'])
|
||||
|
||||
targets = []
|
||||
used: set[int] = set() # یک chunk فقط به یک درس داده شود
|
||||
for lesson in lessons:
|
||||
have = lesson.questions.count()
|
||||
need = opts['per_lesson'] - have
|
||||
if need <= 0:
|
||||
continue
|
||||
chunks = list(
|
||||
LessonChunk.objects
|
||||
.filter(collection=collection, problem_type=lesson.title)
|
||||
.exclude(problem='')
|
||||
.order_by('source_page', 'order')[:need]
|
||||
)
|
||||
# فقط chunkهایی که راهحلشان بازبینی و تأیید شده. ساختنِ سوال از
|
||||
# راهحلی که میدانیم غلط است، خطای کتاب را به دانشآموز منتقل
|
||||
# میکند — و ۱۲۳ موردِ اینچنینی در همین کتابها پیدا شد.
|
||||
candidates = LessonChunk.objects.filter(
|
||||
collection=collection, problem_type=lesson.title).exclude(problem='')
|
||||
if opts['verified_only']:
|
||||
candidates = candidates.filter(
|
||||
solution_verdict=LessonChunk.VERDICT_AGREES)
|
||||
# از `have` تا به بعد، نه از اول: ترتیب قطعی است، پس اجرای قبلی
|
||||
# دقیقاً `have` تکهی نخست را مصرف کرده. بدونِ این پرش، هر اجرای
|
||||
# دوم با --per-lesson بالاتر همان مسئله را دوباره تحویل میداد و
|
||||
# سوالِ تقریباً تکراری میساخت — که نه خطا میدهد و نه در import
|
||||
# گیر میافتد، چون صورتِ سوال بازنویسی شده و رشتهاش یکی نیست.
|
||||
chunks = list(candidates.order_by('source_page', 'order')[have:have + need])
|
||||
|
||||
# تطبیقِ عنوان شکننده است: عنوانِ درس از یک استخراجِ قدیمی آمده و
|
||||
# هر بار که کتاب دوباره خوانده شود مدل عنوانِ تازه میسازد. بعد از
|
||||
# بازخوانیِ ۱۴۰۵/۰۶/۲۹ فقط ۵ تا ۸ درصدِ عنوانها میخوردند، یعنی
|
||||
# ۹۵٪ درسها بیسروصدا هیچ نامزدی پیدا نمیکردند.
|
||||
#
|
||||
# لنگرِ پایدار، صفحه است: هر Node دقیقاً یک بخشِ فهرستِ کتاب با
|
||||
# بازهی صفحهی مشخص است، و هر chunk هم `source_page` دارد. پس
|
||||
# chunkهای همان بازه به درسهای همان نود میخورند — بدونِ هیچ
|
||||
# فراخوانیِ مدلی.
|
||||
if not chunks and opts['by_page']:
|
||||
chunks = self._chunks_by_page(lesson, collection, need, opts, used,
|
||||
skip=have)
|
||||
|
||||
for chunk in chunks:
|
||||
used.add(chunk.pk)
|
||||
targets.append((lesson, chunk))
|
||||
if opts['limit'] and len(targets) >= opts['limit']:
|
||||
return targets
|
||||
return targets
|
||||
|
||||
def _ask_model(self, chunk, grade):
|
||||
def _ask_model(self, chunk, grade): # noqa: D401
|
||||
prompt = PROMPT.format(
|
||||
grade=grade, problem_type=chunk.problem_type,
|
||||
problem=chunk.problem, solution=chunk.solution,
|
||||
)
|
||||
client = clients.get_openai_client()
|
||||
completion = client.chat.completions.create(
|
||||
model=settings.OPENAI_CHAT_MODEL,
|
||||
model=self.model,
|
||||
messages=[
|
||||
{'role': 'system', 'content': 'You always answer with a single valid JSON object.'},
|
||||
{'role': 'user', 'content': prompt},
|
||||
],
|
||||
temperature=0.4,
|
||||
max_tokens=400,
|
||||
)
|
||||
raw = (completion.choices[0].message.content or '').strip()
|
||||
for fence in ('```json', '```'):
|
||||
|
||||
169
content/management/commands/seed_content.py
Normal file
169
content/management/commands/seed_content.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
بالا آوردنِ محتوای پایه از فایلهایی که در خودِ ریپو کامیت شدهاند.
|
||||
|
||||
چرا: تا امروز تنها راهِ داشتنِ محتوا روی یک سرورِ تازه، اجرای
|
||||
`rag_import_collection` و بعد `seed_curriculum_from_textbook` روی همان سرور
|
||||
بود — یعنی وابستگی به Qdrant، به اعتبارِ AI، و به آدمی با دسترسیِ SSH. نتیجه
|
||||
این شد که production ماههاست `{"grades": []}` برمیگرداند و هیچکس نمیتواند
|
||||
چیزی را تست کند.
|
||||
|
||||
محتوا یکبار لوکال ساخته میشود، با `--export` در ریپو مینشیند، و از آن پس هر
|
||||
دیپلوی خودش آن را بالا میآورد. سرورِ تازه نه Qdrant میخواهد نه ترمینال.
|
||||
|
||||
python manage.py seed_content # وارد کردن (در deploy.sh)
|
||||
python manage.py seed_content --dry-run # فقط گزارش، بدونِ نوشتن
|
||||
python manage.py seed_content --export # گرفتنِ خروجی از دیتابیسِ فعلی
|
||||
|
||||
ایمن برای اجرای تکراری: کوریکولوم با عنوان همسانسازی میشود و سوالِ تکراری
|
||||
رد میشود. هیچچیز پاک نمیشود — روی سروری که کاربرِ واقعی دارد، دیپلوی نباید
|
||||
بتواند محتوا یا پاسخی را از بین ببرد.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
from content import curriculum_io, question_io, seed_sync
|
||||
from content.models import Grade, Lesson, Question
|
||||
|
||||
# ردیفهای نمونه هرگز نباید در فایلِ ریپو بنشینند: این فایل مستقیم روی
|
||||
# production لود میشود و «[DEMO] پایهٔ نمونه» همان چیزی است که در
|
||||
# `/api/grades` جلوِ چشمِ کاربرِ واقعی میآید. purge_demo_data دقیقاً برای
|
||||
# پاک کردنشان نوشته شد؛ اینجا از همان ابتدا بیرون گذاشته میشوند.
|
||||
DEMO_PREFIXES = ('[SEED]', '[DEMO]')
|
||||
|
||||
SEED_DIR = Path(__file__).resolve().parents[2] / 'seed'
|
||||
CURRICULUM_FILE = SEED_DIR / 'curriculum.json'
|
||||
QUESTIONS_FILE = SEED_DIR / 'questions.json'
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'وارد کردنِ محتوای پایه از content/seed/ (ایدمپوتنت، بدونِ حذف).'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--export', action='store_true',
|
||||
help='بهجای وارد کردن، از دیتابیسِ فعلی خروجی بگیر.')
|
||||
parser.add_argument('--only', choices=('curriculum', 'questions'), default=None,
|
||||
help='فقط یکی از دو فایل را خروجی بگیر.')
|
||||
parser.add_argument('--dry-run', action='store_true',
|
||||
help='فقط گزارش بده؛ چیزی ننویس.')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.only = options.get('only')
|
||||
if options['export']:
|
||||
return self._export()
|
||||
return self._load(dry_run=options['dry_run'])
|
||||
|
||||
# ─── خروجی گرفتن ────────────────────────────────────────────────────────
|
||||
def _export(self):
|
||||
SEED_DIR.mkdir(exist_ok=True)
|
||||
|
||||
grades = Grade.objects.order_by('pk')
|
||||
questions = Question.objects.order_by('pk')
|
||||
for prefix in DEMO_PREFIXES:
|
||||
grades = grades.exclude(title__startswith=prefix)
|
||||
questions = questions.exclude(
|
||||
lesson__node__unit__grade__title__startswith=prefix
|
||||
)
|
||||
|
||||
curriculum = curriculum_io.export_curriculum(grades)
|
||||
rows = question_io.export_questions_by_path(questions)
|
||||
|
||||
# محافظ در برابرِ پاک شدنِ خاموشِ سوالها: فایلِ سوالها دستنویس است و
|
||||
# ممکن است در دیتابیسِ این ماشین نباشد (مثلاً وقتی فقط کوریکولوم را
|
||||
# محلی ساختهایم). یک export بیاحتیاط آن را با آرایهی خالی بازنویسی
|
||||
# میکرد و کلِ کار از بین میرفت — بدونِ خطا.
|
||||
only = self.only
|
||||
if only != 'questions':
|
||||
_write_json(CURRICULUM_FILE, curriculum)
|
||||
if only != 'curriculum':
|
||||
if not rows and _has_questions(QUESTIONS_FILE):
|
||||
raise CommandError(
|
||||
f'{QUESTIONS_FILE.name} سوال دارد ولی دیتابیسِ این ماشین ندارد؛ '
|
||||
'بازنویسیاش یعنی پاک شدنشان. اگر کوریکولوم را میخواهید: '
|
||||
'--export --only curriculum'
|
||||
)
|
||||
_write_json(QUESTIONS_FILE, {'questions': rows})
|
||||
|
||||
lessons = Lesson.objects.filter(node__unit__grade__in=grades).count()
|
||||
written = []
|
||||
if only != 'questions':
|
||||
written.append(f'{CURRICULUM_FILE.name} '
|
||||
f'({len(curriculum["grades"])} پایه، {lessons} درس)')
|
||||
if only != 'curriculum':
|
||||
written.append(f'{QUESTIONS_FILE.name} ({len(rows)} سوال)')
|
||||
self.stdout.write(self.style.SUCCESS('نوشته شد: ' + ' و '.join(written)))
|
||||
skipped = Grade.objects.count() - grades.count()
|
||||
if skipped:
|
||||
self.stdout.write(
|
||||
f' {skipped} پایهی نمونه ([DEMO]/[SEED]) کنار گذاشته شد.'
|
||||
)
|
||||
if not rows and only != 'curriculum':
|
||||
self.stdout.write(self.style.WARNING(
|
||||
' هشدار: هیچ سوالِ واقعیای برای خروجی نبود — '
|
||||
'برنامهی درسی بدونِ سوال منتشر میشود.'
|
||||
))
|
||||
|
||||
# ─── وارد کردن ──────────────────────────────────────────────────────────
|
||||
def _load(self, *, dry_run: bool):
|
||||
if not CURRICULUM_FILE.exists():
|
||||
raise CommandError(
|
||||
f'{CURRICULUM_FILE} نیست. اول با --export خروجی بگیرید.'
|
||||
)
|
||||
|
||||
try:
|
||||
grades = curriculum_io.parse(CURRICULUM_FILE.read_bytes())
|
||||
except curriculum_io.CurriculumError as exc:
|
||||
raise CommandError(f'{CURRICULUM_FILE.name}: {exc}')
|
||||
|
||||
# dry-run با rollback انجام میشود، نه با شمارشِ تقریبی: تنها چیزی که
|
||||
# واقعاً میگوید چه اتفاقی میافتد، خودِ اتفاق است.
|
||||
# seed_sync کارِ ادمین (حذف، تغییرِ نام، اصلاحِ متن) را برنمیگرداند؛ شرح در خودِ ماژول.
|
||||
try:
|
||||
with transaction.atomic():
|
||||
counts = seed_sync.sync(grades, self._question_rows())
|
||||
if dry_run:
|
||||
transaction.set_rollback(True)
|
||||
except (curriculum_io.CurriculumError, question_io.ImportError_) as exc:
|
||||
raise CommandError(str(exc))
|
||||
|
||||
prefix = 'اگر اجرا شود: ' if dry_run else ''
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'{prefix}'
|
||||
f'{counts["grades"]} پایه، {counts["units"]} فصل، '
|
||||
f'{counts["nodes"]} مبحث، {counts["lessons"]} درسِ تازه '
|
||||
f'({counts["updated"]} درس بهروز شد)؛ '
|
||||
f'{counts["created"]} سوالِ تازه '
|
||||
f'({counts["skipped"]} سوال از قبل بود)'
|
||||
))
|
||||
if counts['retired'] or counts['kept_admin_text']:
|
||||
self.stdout.write(
|
||||
f' {counts["retired"]} موردِ فایل ساخته نشد چون ادمین پاکش کرده یا نامش را عوض کرده؛ '
|
||||
f'{counts["kept_admin_text"]} متنِ درس که ادمین ویرایش کرده دست نخورد.'
|
||||
)
|
||||
|
||||
def _question_rows(self) -> list:
|
||||
if not QUESTIONS_FILE.exists():
|
||||
return []
|
||||
raw = json.loads(QUESTIONS_FILE.read_text(encoding='utf-8'))
|
||||
return (raw.get('questions', raw) if isinstance(raw, dict) else raw) or []
|
||||
|
||||
|
||||
def _has_questions(path: Path) -> bool:
|
||||
if not path.exists():
|
||||
return False
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding='utf-8'))
|
||||
except json.JSONDecodeError:
|
||||
return False
|
||||
return bool(data.get('questions') if isinstance(data, dict) else data)
|
||||
|
||||
|
||||
def _write_json(path: Path, payload) -> None:
|
||||
# ensure_ascii=False تا فارسی در دیفِ گیت خوانا بماند، و newline انتهایی
|
||||
# تا هر بار export یک دیفِ تمیز بدهد نه یک خطِ تغییرکردهی الکی.
|
||||
path.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=1) + '\n',
|
||||
encoding='utf-8',
|
||||
)
|
||||
@@ -21,17 +21,24 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
from content.models import Grade, Lesson, Node, Unit
|
||||
from content.models import Grade, Lesson, Node, Subject, Unit
|
||||
from rag.models import LessonChunk
|
||||
from rag.services.config import build_collection_name
|
||||
from rag.services.textbooks import TEXTBOOKS, get_textbook
|
||||
|
||||
GRADE_STYLE = {
|
||||
# چهارم/پنجم (۲۰۲۶-۱۰-۰۳): رنگهای گرمِ روشن، کنارِ سبزِ ششم، تا سه پایهی دبستان یک خانواده باشند.
|
||||
'چهارم': ('grade-4', '#FFB300'),
|
||||
'پنجم': ('grade-5', '#FF7043'),
|
||||
'ششم': ('grade-6', '#4CAF50'),
|
||||
'هفتم': ('grade-7', '#2196F3'),
|
||||
'هشتم': ('grade-8', '#9C27B0'),
|
||||
'نهم': ('grade-9', '#FF9800'),
|
||||
'دهم': ('grade-10', '#E91E63'),
|
||||
# حسابان: رشتهی ریاضیفیزیک، پس عمداً رنگِ سرد و جدا از شش پایهی عمومی
|
||||
# تا در نقشهی بازی بهچشم یک مسیرِ دیگر بیاید، نه ادامهی همانها.
|
||||
'یازدهم': ('grade-11', '#00BCD4'),
|
||||
'دوازدهم': ('grade-12', '#3F51B5'),
|
||||
}
|
||||
|
||||
|
||||
@@ -172,11 +179,17 @@ class Command(BaseCommand):
|
||||
title=grade_title, defaults={'icon': icon, 'color': color},
|
||||
)
|
||||
|
||||
# کتاب باید در جدولِ Subject باشد (migrationِ 0015 پنجتایی را میسازد؛ کتابِ تازه از ادمین).
|
||||
subject = Subject.objects.filter(title=book.subject).first()
|
||||
if subject is None:
|
||||
raise CommandError(
|
||||
f'کتابِ «{book.subject}» در جدولِ Subject نیست؛ از ادمین (محتوا ← کتابها) بسازید.'
|
||||
)
|
||||
counts = {'units': 0, 'nodes': 0, 'lessons': 0}
|
||||
for chapter, groups in plan:
|
||||
unit, _ = Unit.objects.update_or_create(
|
||||
grade=grade, title=chapter.title,
|
||||
defaults={'order': chapter.number},
|
||||
defaults={'order': chapter.number, 'subject': subject},
|
||||
)
|
||||
counts['units'] += 1
|
||||
|
||||
|
||||
36
content/management/commands/split_long_nodes.py
Normal file
36
content/management/commands/split_long_nodes.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
جزیرههای بلند را به جزیرههای حداکثر ۶ درسی میشکند (منطق در `content/node_split.py`).
|
||||
|
||||
python manage.py split_long_nodes --grade 8 --dry-run
|
||||
python manage.py split_long_nodes --grade 4 --grade 5
|
||||
|
||||
پایههای ۳ تا ۱۲ را migrationِ 0018 یک بار شکسته است؛ این دستور برای پایهی تازه یا آستانهی دیگر است و اجرای
|
||||
دوبارهاش بیاثر. بعد از اجرا فایلهای seed را دوباره بسازید (`seed_content --export`)، وگرنه
|
||||
`seed_content` جزیرههای بلندِ قدیمی را کنارِ بخشهای تازه دوباره میسازد.
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from content.models import Lesson, Node, NodeProgress, Unit, UserNodeCompletion
|
||||
from content.node_split import split_long_nodes
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'شکستنِ جزیرههای بلند به جزیرههای کوتاهتر، با حفظِ پیشرفتِ کاربران.'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--grade', type=int, action='append', required=True,
|
||||
help='شمارهی پایه؛ چند بار قابلِ تکرار.')
|
||||
parser.add_argument('--max-lessons', type=int, default=7)
|
||||
parser.add_argument('--part-cap', type=int, default=6)
|
||||
parser.add_argument('--dry-run', action='store_true')
|
||||
|
||||
def handle(self, *args, **opts):
|
||||
units = Unit.objects.filter(grade__number__in=opts['grade']).order_by('id')
|
||||
with transaction.atomic():
|
||||
counts = split_long_nodes(
|
||||
Node=Node, Lesson=Lesson, UserNodeCompletion=UserNodeCompletion,
|
||||
NodeProgress=NodeProgress, units=units,
|
||||
max_lessons=opts['max_lessons'], part_cap=opts['part_cap'], dry_run=opts['dry_run'],
|
||||
)
|
||||
self.stdout.write(f"{'(dry-run) ' if opts['dry_run'] else ''}{counts}")
|
||||
@@ -0,0 +1,53 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-13 22:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('content', '0005_questionreport'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='lesson',
|
||||
name='duration_minutes',
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='lesson',
|
||||
name='example',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='lesson',
|
||||
name='kind',
|
||||
field=models.CharField(choices=[('quiz', 'آزمون'), ('video', 'ویدیو')], default='quiz', max_length=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='lesson',
|
||||
name='steps',
|
||||
field=models.JSONField(blank=True, default=list),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='lesson',
|
||||
name='video_url',
|
||||
field=models.URLField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='node',
|
||||
name='color',
|
||||
field=models.CharField(blank=True, max_length=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='node',
|
||||
name='icon',
|
||||
field=models.CharField(blank=True, max_length=100),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='unit',
|
||||
name='blurb',
|
||||
field=models.CharField(blank=True, max_length=120),
|
||||
),
|
||||
]
|
||||
22
content/migrations/0007_grade_number.py
Normal file
22
content/migrations/0007_grade_number.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-23 19:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('content', '0006_lesson_duration_minutes_lesson_example_lesson_kind_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='grade',
|
||||
options={'ordering': [models.OrderBy(models.F('number'), nulls_last=True), 'id']},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='grade',
|
||||
name='number',
|
||||
field=models.PositiveSmallIntegerField(blank=True, help_text='شماره\u200cی پایه، ۱ تا ۱۲. خالی بماند از عنوان حدس زده می\u200cشود.', null=True, unique=True),
|
||||
),
|
||||
]
|
||||
105
content/migrations/0008_grade_id_equals_number.py
Normal file
105
content/migrations/0008_grade_id_equals_number.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
id هر پایه را برابرِ شمارهاش میکند (پایه هشتم → 8) و همهی ارجاعها را همراهش.
|
||||
|
||||
تا پیش از این id ترتیبِ واردشدنِ کتابها بود (نهم=۱، ششم=۲، …، هشتم=۵)، پس
|
||||
`gradeId: 5` یعنی هشتم و همهجا — ادمین، فیلترِ سوالها، API — پایهها نامرتب
|
||||
بودند. شمارهی پایه از عنوان خوانده میشود، نه از id، پس روی هر دیتابیسی (با
|
||||
هر ترتیبِ idِ قبلی) درست کار میکند.
|
||||
|
||||
ارجاعها: FKِ `Unit.grade`، سه ارجاعِ سبکِ `grade_id` در accounts، ai_tutor و
|
||||
rag، و `grade_id` داخلِ JSONِ `target_filter` کمپینهای پیامکی. پایهای که عنوانش شماره ندارد ([DEMO]/[SEED]) اگر idش در بازهی ۱ تا ۱۲
|
||||
باشد به بالای ۱۰۰ میرود تا جا برای پایههای واقعی باز شود.
|
||||
|
||||
برگشتناپذیر بهمعنای «idهای قدیمی برنمیگردند»؛ برگشتِ migration فقط فیلد را
|
||||
برمیدارد و idهای تازه میمانند، که برای همهی کدها بیتفاوت است.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
from content.models import grade_number_from_title
|
||||
|
||||
LOOSE_REFS = [('accounts', 'User'), ('ai_tutor', 'Conversation'), ('rag', 'LessonChunk')]
|
||||
TEMP_OFFSET = 1_000_000
|
||||
FIRST_UNNUMBERED_ID = 101
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
Grade = apps.get_model('content', 'Grade')
|
||||
Unit = apps.get_model('content', 'Unit')
|
||||
refs = [apps.get_model(app, model) for app, model in LOOSE_REFS]
|
||||
|
||||
grades = list(Grade.objects.order_by('id'))
|
||||
taken = set()
|
||||
for grade in grades:
|
||||
number = grade_number_from_title(grade.title)
|
||||
if number and number not in taken:
|
||||
taken.add(number)
|
||||
Grade.objects.filter(pk=grade.pk).update(number=number)
|
||||
grade.number = number
|
||||
|
||||
next_free = max([g.id for g in grades] + [FIRST_UNNUMBERED_ID - 1]) + 1
|
||||
moves = {}
|
||||
for grade in grades:
|
||||
if grade.number:
|
||||
target = grade.number
|
||||
elif grade.id <= 12:
|
||||
target, next_free = next_free, next_free + 1
|
||||
else:
|
||||
continue
|
||||
if target != grade.id:
|
||||
moves[grade.id] = target
|
||||
|
||||
def move(old, new):
|
||||
Grade.objects.filter(id=old).update(id=new)
|
||||
Unit.objects.filter(grade_id=old).update(grade_id=new)
|
||||
for model in refs:
|
||||
model.objects.filter(grade_id=old).update(grade_id=new)
|
||||
|
||||
# دو مرحله، چون مقصدِ یکی ممکن است idِ فعلیِ دیگری باشد (هشتم ۵→۸ درحالیکه
|
||||
# دوازدهم هنوز ۸ است).
|
||||
for old in moves:
|
||||
move(old, old + TEMP_OFFSET)
|
||||
for old, new in moves.items():
|
||||
move(old + TEMP_OFFSET, new)
|
||||
|
||||
# کمپینِ «همهی کاربرانِ پایهی X» idِ پایه را در JSON نگه میدارد
|
||||
Campaign = apps.get_model('campaigns', 'MessageCampaign')
|
||||
for campaign in Campaign.objects.all():
|
||||
target = campaign.target_filter or {}
|
||||
if target.get('type') == 'grade' and target.get('grade_id') in moves:
|
||||
target['grade_id'] = moves[target['grade_id']]
|
||||
Campaign.objects.filter(pk=campaign.pk).update(target_filter=target)
|
||||
|
||||
# پایهی بیشمارهای که بعداً ساخته شود idِ خودکار میگیرد؛ شمارنده باید از
|
||||
# بازهی ۱ تا ۱۲ بالاتر باشد وگرنه با «پایه نهم» (id=9) برخورد میکند.
|
||||
table = Grade._meta.db_table
|
||||
floor = max([next_free - 1] + list(moves.values()) + [FIRST_UNNUMBERED_ID - 1])
|
||||
with schema_editor.connection.cursor() as cursor:
|
||||
vendor = schema_editor.connection.vendor
|
||||
if vendor == 'sqlite':
|
||||
cursor.execute('UPDATE sqlite_sequence SET seq = MAX(seq, %s) WHERE name = %s', [floor, table])
|
||||
cursor.execute(
|
||||
'INSERT INTO sqlite_sequence (name, seq) SELECT %s, %s '
|
||||
'WHERE NOT EXISTS (SELECT 1 FROM sqlite_sequence WHERE name = %s)',
|
||||
[table, floor, table],
|
||||
)
|
||||
elif vendor == 'postgresql':
|
||||
cursor.execute(
|
||||
f"SELECT setval(pg_get_serial_sequence('{table}', 'id'), "
|
||||
f"GREATEST(%s, (SELECT COALESCE(MAX(id), 1) FROM {table})))",
|
||||
[floor],
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('content', '0007_grade_number'),
|
||||
('accounts', '0003_user_last_activity_at'),
|
||||
('ai_tutor', '0005_message_chunk_count_message_completion_tokens_and_more'),
|
||||
('rag', '0006_remove_lessonchunk_solution_agrees_and_more'),
|
||||
('campaigns', '0003_campaigndelivery_one_active_per_user_channel'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forwards, migrations.RunPython.noop),
|
||||
]
|
||||
33
content/migrations/0009_usernodecompletion.py
Normal file
33
content/migrations/0009_usernodecompletion.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-23 23:10
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('content', '0008_grade_id_equals_number'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='UserNodeCompletion',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('user_id', models.IntegerField(db_index=True)),
|
||||
('correct_count', models.PositiveIntegerField()),
|
||||
('total_questions', models.PositiveIntegerField()),
|
||||
('stars', models.PositiveSmallIntegerField()),
|
||||
('xp_earned', models.PositiveIntegerField(default=0)),
|
||||
('coins_earned', models.PositiveIntegerField(default=0)),
|
||||
('idempotency_key', models.CharField(blank=True, max_length=100, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('node', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='completions', to='content.node')),
|
||||
],
|
||||
options={
|
||||
'indexes': [models.Index(fields=['user_id', 'node'], name='content_use_user_id_f372a4_idx')],
|
||||
'constraints': [models.UniqueConstraint(condition=models.Q(('idempotency_key__isnull', False), models.Q(('idempotency_key', ''), _negated=True)), fields=('user_id', 'node', 'idempotency_key'), name='uniq_node_completion_idempotency_key')],
|
||||
},
|
||||
),
|
||||
]
|
||||
23
content/migrations/0010_seedrecord.py
Normal file
23
content/migrations/0010_seedrecord.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.17 on 2026-10-03 08:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('content', '0009_usernodecompletion'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SeedRecord',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.CharField(max_length=40, unique=True)),
|
||||
('kind', models.CharField(max_length=10)),
|
||||
('value', models.CharField(blank=True, max_length=40)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
]
|
||||
18
content/migrations/0011_lesson_figures.py
Normal file
18
content/migrations/0011_lesson_figures.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.17 on 2026-10-07 12:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('content', '0010_seedrecord'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='lesson',
|
||||
name='figures',
|
||||
field=models.JSONField(blank=True, default=list),
|
||||
),
|
||||
]
|
||||
18
content/migrations/0012_choice_value_300.py
Normal file
18
content/migrations/0012_choice_value_300.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.17 on 2026-10-08 08:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('content', '0011_lesson_figures'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='choice',
|
||||
name='value',
|
||||
field=models.CharField(max_length=300),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.17 on 2026-10-11 16:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('content', '0012_choice_value_300'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='userlessoncompletion',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, db_index=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='usernodecompletion',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, db_index=True),
|
||||
),
|
||||
]
|
||||
23
content/migrations/0014_subject.py
Normal file
23
content/migrations/0014_subject.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
"""مرحلهی ۱ از ۴ (جدولِ کتابها). چهار مرحلهی جدا چون PostgreSQL اجازه نمیدهد در یک تراکنش
|
||||
هم داده نوشت و هم همان جدول را ALTER کرد (pending trigger events)."""
|
||||
|
||||
dependencies = [('content', '0013_alter_userlessoncompletion_created_at_and_more')]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Subject',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.SlugField(max_length=30, unique=True)),
|
||||
('title', models.CharField(max_length=50, unique=True)),
|
||||
('icon', models.CharField(blank=True, max_length=100)),
|
||||
('color', models.CharField(blank=True, max_length=10)),
|
||||
('order', models.PositiveSmallIntegerField(default=0)),
|
||||
],
|
||||
options={'ordering': ['order', 'id']},
|
||||
),
|
||||
]
|
||||
24
content/migrations/0015_seed_subjects.py
Normal file
24
content/migrations/0015_seed_subjects.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from django.db import migrations
|
||||
|
||||
# رنگها جدا از رنگِ پایهها (Grade.color) و از هماند تا دکمههای نوارِ کتابها در نقشه
|
||||
# بهچشم یک مسیرِ جدا بیایند. آیکونها نامِ lucideاند (همان فهرستی که نقشه میشناسد).
|
||||
SUBJECTS = [
|
||||
('math', 'ریاضی', 'Sigma', '#ED6956', 1),
|
||||
('calculus', 'حسابان', 'LineChart', '#00BCD4', 2),
|
||||
('geometry', 'هندسه', 'Compass', '#9B7BFF', 3),
|
||||
('stats', 'آمار و احتمال', 'BarChart3', '#FF7A3D', 4),
|
||||
('discrete', 'گسسته', 'Network', '#5FBF8F', 5),
|
||||
]
|
||||
|
||||
|
||||
def create_subjects(apps, schema_editor):
|
||||
Subject = apps.get_model('content', 'Subject')
|
||||
for key, title, icon, color, order in SUBJECTS:
|
||||
Subject.objects.get_or_create(
|
||||
key=key, defaults={'title': title, 'icon': icon, 'color': color, 'order': order},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [('content', '0014_subject')]
|
||||
operations = [migrations.RunPython(create_subjects, migrations.RunPython.noop)]
|
||||
20
content/migrations/0016_unit_subject.py
Normal file
20
content/migrations/0016_unit_subject.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import content.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [('content', '0015_seed_subjects')]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='unit',
|
||||
name='subject',
|
||||
field=models.ForeignKey(
|
||||
default=content.models.default_subject_pk,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name='units', to='content.subject',
|
||||
),
|
||||
),
|
||||
]
|
||||
19
content/migrations/0017_calculus_units.py
Normal file
19
content/migrations/0017_calculus_units.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def mark_calculus(apps, schema_editor):
|
||||
"""
|
||||
فصلهای پایهی یازدهم و دوازدهمِ موجود از کتابِ «حسابان»اند (`CALCULUS_11/12`)، نه ریاضیِ عمومی.
|
||||
۰۰۱۶ همه را «ریاضی» گذاشت؛ اینجا درست میشود. پایه از `number` شناخته میشود، نه از id.
|
||||
بررسینشده: دیتابیسی که در آن فصلِ یازدهم/دوازدهم از کتابی جز حسابان آمده باشد (روی سرور و
|
||||
لپتاپ نیست؛ هیچ کتابِ دیگری برای آن پایهها پیش از این migration ingest نشده بود).
|
||||
"""
|
||||
Subject = apps.get_model('content', 'Subject')
|
||||
Unit = apps.get_model('content', 'Unit')
|
||||
calculus = Subject.objects.get(key='calculus')
|
||||
Unit.objects.filter(grade__number__in=(11, 12)).update(subject=calculus)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [('content', '0016_unit_subject')]
|
||||
operations = [migrations.RunPython(mark_calculus, migrations.RunPython.noop)]
|
||||
25
content/migrations/0018_split_long_calculus_nodes.py
Normal file
25
content/migrations/0018_split_long_calculus_nodes.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from django.db import migrations
|
||||
|
||||
from content.node_split import split_long_nodes
|
||||
|
||||
|
||||
def split(apps, schema_editor):
|
||||
"""
|
||||
جزیرههای بلندِ (بیش از ۷ درس) پایههای سوم تا دوازدهم را به جزیرههای حداکثر ۶ درسی میشکند (`content/node_split.py`).
|
||||
روی دیتابیسِ خالی (تست، سرورِ تازه) هیچ کاری نمیکند؛ آنجا `seed_content` از فایلِ بازساختهشده میسازد.
|
||||
"""
|
||||
Unit = apps.get_model('content', 'Unit')
|
||||
stats = split_long_nodes(
|
||||
Node=apps.get_model('content', 'Node'),
|
||||
Lesson=apps.get_model('content', 'Lesson'),
|
||||
UserNodeCompletion=apps.get_model('content', 'UserNodeCompletion'),
|
||||
NodeProgress=apps.get_model('content', 'NodeProgress'),
|
||||
units=Unit.objects.filter(grade__number__in=range(3, 13)).order_by('id'),
|
||||
)
|
||||
if stats['nodes_split']:
|
||||
print(f'\n split_long_nodes: {stats}')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [('content', '0017_calculus_units')]
|
||||
operations = [migrations.RunPython(split, migrations.RunPython.noop)]
|
||||
12
content/migrations/0019_question_figure.py
Normal file
12
content/migrations/0019_question_figure.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [('content', '0018_split_long_calculus_nodes')]
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='question',
|
||||
name='figure',
|
||||
field=models.JSONField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user