Compare commits
276 Commits
ab1560aba5
...
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 | |||
| 37d66a4a4c | |||
| 67361f6711 | |||
| 9cee574fa9 | |||
| eaa4b761dd | |||
| 190bff3d0f | |||
| cbe9b63dc4 | |||
| 5f3aae0756 | |||
| ac1b1af398 | |||
| fa56308a35 | |||
| 78130e7194 | |||
| c79be0a336 | |||
| 52d9694583 | |||
| f0e3e3c96c | |||
| 092fcf13f2 | |||
| 37a1741e0e | |||
| b5476e1c5d | |||
| a275eac7fd | |||
| a367685b2e | |||
| 4839123698 | |||
| 40b4dd53a9 | |||
| d25ecab0b3 | |||
| ff57adbc5f | |||
| 35218bf6a1 | |||
| eeb6bd6138 | |||
| 40d358ecb5 | |||
| 149f1ad9ae | |||
| 5416fbb2ba | |||
| ff47c8d4ba | |||
| ac93e17305 | |||
| 38999f3541 | |||
| a33e575cfb | |||
| 683bd40169 | |||
| 83435c5a5f | |||
| 49d596f077 | |||
| 05ab6ffc36 | |||
| f322d27e4f | |||
| a9200772a3 | |||
| 7b9ab3adc3 | |||
| 80298b47b1 | |||
| cb18c93a3e | |||
| f945f960c9 | |||
| 02d40e1238 | |||
| 331148037e |
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.git/
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
db.sqlite3
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
147
.env.example
Normal file
147
.env.example
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# نمونهی تنظیماتِ بکاندِ تدریسینو — برای لپتاپ و سرور.
|
||||||
|
#
|
||||||
|
# cp .env.example .env
|
||||||
|
#
|
||||||
|
# هیچکدام برای بالا آمدنِ سرویس اجباری نیست: دامنهها، CORS، کلیدِ امضا و کش در
|
||||||
|
# خودِ config/settings.py پیشفرضِ کارا دارند. مقدارهای این فایل با آن پیشفرضها
|
||||||
|
# ادغام میشوند، جایگزینشان نمیشوند. شرحِ کامل: docs/operations.md.
|
||||||
|
#
|
||||||
|
# مقدارِ واقعیِ کلیدها فقط در .env (که در git نیست)، هرگز در این فایل یا در چت.
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# ۱) پایه
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# روی لپتاپ 1، روی سرور همیشه 0.
|
||||||
|
# DEBUG=1 روی سرور یعنی کدِ ورودِ 0000 برای هر شمارهای و صفحهی خطا با تنظیمات.
|
||||||
|
DJANGO_DEBUG=0
|
||||||
|
|
||||||
|
# خالی بماند: بارِ اول یک کلیدِ تصادفی ساخته و در .django_secret_key (0600) نگه
|
||||||
|
# داشته میشود. هرگز مقدارِ django-insecure-… داخلِ settings.py را اینجا نگذارید.
|
||||||
|
DJANGO_SECRET_KEY=
|
||||||
|
|
||||||
|
# Redis برای محدودیتِ نرخِ مشترک بینِ workerها؛ خالی = کشِ حافظهی هر worker
|
||||||
|
# (/api/health هشدار میدهد). مثال: redis://127.0.0.1:6379/1
|
||||||
|
CACHE_URL=
|
||||||
|
|
||||||
|
# Postgres: با ستبودنِ DB_NAME برنامه روی Postgres بالا میآید (DB_USER و DB_PASSWORD الزامی)؛ خالی = SQLite.
|
||||||
|
DB_NAME=
|
||||||
|
DB_USER=
|
||||||
|
DB_PASSWORD=
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=5432
|
||||||
|
# pool اتصالها (config/settings.py): هر worker حداکثر DB_POOL_MAX_SIZE اتصال؛ بعد از DB_POOL_TIMEOUT ثانیه انتظار ۵۰۳.
|
||||||
|
# workers × DB_POOL_MAX_SIZE + 6 باید زیرِ max_connectionsِ Postgres بماند (/api/health → dbPool).
|
||||||
|
# DB_POOL_MAX_SIZE=8
|
||||||
|
# DB_POOL_TIMEOUT=10
|
||||||
|
# سقفِ هر کوئری (میلیثانیه)؛ 0 برای فرمانِ مدیریتیِ طولانیِ دستی.
|
||||||
|
# DB_STATEMENT_TIMEOUT_MS=30000
|
||||||
|
# اسلاتِ تکثیری که سرورِ بکاپ از آن کپیِ زنده میگیرد (ops/pgbackup/). روی سرور: zareian_247.
|
||||||
|
# خالی روی Postgres = کپیِ بیرونی نداریم و /api/health هشدار میدهد. روی لپتاپ خالی.
|
||||||
|
OFFSITE_WAL_SLOT=
|
||||||
|
# سرورِ اختصاصی (۲۱۶): فایلی که tadrisino-offsite-sync زمانِ آخرین ارسالِ بکاپِ رمزشده به ۲۴۷ را در آن مینویسد.
|
||||||
|
# روی ۲۱۶: /var/lib/tadrisino-backup/offsite-synced-at (بر OFFSITE_WAL_SLOT مقدم است). روی لپتاپ خالی.
|
||||||
|
OFFSITE_SYNC_STATUS_FILE=
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# ۲) دامنهها — دو آدرس، بقیه از آنها ساخته میشوند
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# ALLOWED_HOSTS، CSRF، CORS، callbackِ زرینپال، برگشت از پرداخت و redirectِ بازار
|
||||||
|
# همه از این دو ساخته میشوند. پیشفرضشان همین دامنههای فعلی است.
|
||||||
|
# جابهجاییِ دامنه: docs/operations.md، بخشِ «عوض کردنِ دامنه».
|
||||||
|
#
|
||||||
|
# PUBLIC_BASE_URL=https://mathapi.tadrisino2.ir
|
||||||
|
# FRONTEND_URL=https://math.tadrisino2.ir
|
||||||
|
#
|
||||||
|
# فقط برای دامنهی *اضافه* (مثلاً دامنهی قبلی در دورانِ جابهجایی):
|
||||||
|
# DJANGO_ALLOWED_HOSTS=old.example.ir
|
||||||
|
# CSRF_TRUSTED_ORIGINS=https://old.example.ir
|
||||||
|
# CORS_ALLOWED_ORIGINS=https://old-app.example.ir
|
||||||
|
|
||||||
|
# تعدادِ پروکسیهای جلوی اپ. سرورِ فعلی: ArvanCloud → nginx، یعنی 2.
|
||||||
|
# بیشتر از واقعیت = جعلِ IP و دور زدنِ محدودیتِ نرخ؛ کمتر = همهی مهمانها یک IP.
|
||||||
|
NUM_PROXIES=2
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# ۳) پیامکِ کدِ ورود (کاوهنگار)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# بدونِ کلید، درخواستِ کد خطای صریحِ ۵۰۳ میدهد (نه موفقیتِ ساختگی).
|
||||||
|
KAVENEGAR_API_KEY=
|
||||||
|
# نامِ الگوی تأییدشده در پنلِ کاوهنگار (verify/lookup). روی سرورِ فعلی SMSVerify.
|
||||||
|
# خطِ این حساب پیامکِ متنِ آزاد را لغو میکند، پس بدونِ الگو کدی نمیرسد.
|
||||||
|
KAVENEGAR_OTP_TEMPLATE=
|
||||||
|
# خطِ اختصاصی؛ خالی = خطِ پیشفرضِ حساب.
|
||||||
|
KAVENEGAR_SENDER=
|
||||||
|
|
||||||
|
# شمارههای تستیِ تیم با کدِ ثابت، بدونِ پیامک: «شماره:کد» با کاما.
|
||||||
|
# کد تصادفی باشد؛ پیش از کاربرِ واقعی خالی شود (/api/health تا آنوقت هشدار میدهد).
|
||||||
|
# TEST_LOGIN_PHONES=09120000001:834512,09120000002:110947
|
||||||
|
TEST_LOGIN_PHONES=
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# ۴) دستیارِ هوش مصنوعی (RAG)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# بدونِ این دو کلید، POST /api/ai/ask پاسخِ آزمایشیِ ثابت (stub) میدهد.
|
||||||
|
# GapGPT پروکسیِ OpenAI است و فقط از داخلِ ایران جواب میدهد.
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
OPENAI_BASE_URL=https://api.gapgpt.app/v1
|
||||||
|
QDRANT_URL=https://qdrant.tadrisino2.ir:443
|
||||||
|
QDRANT_API_KEY=
|
||||||
|
|
||||||
|
# مدلها؛ پیشفرضها در settings.py همیناند و سنجیده شدهاند (docs/architecture.md).
|
||||||
|
# OPENAI_CHAT_MODEL=gpt-4.1-mini
|
||||||
|
# OPENAI_VISION_MODEL=gpt-4.1-mini
|
||||||
|
# مدلِ جدا فقط برای پایههای ۱۰ تا ۱۲؛ خالی = همان مدلِ بالا.
|
||||||
|
# OPENAI_CHAT_MODEL_UPPER=
|
||||||
|
# embedding را عوض نکنید مگر collectionهای Qdrant هم با همان مدل بازساخته شوند.
|
||||||
|
# OPENAI_EMBEDDING_MODEL=text-embedding-3-small
|
||||||
|
# سقفِ سوالِ روزانه از دستیار برای کاربرِ بدونِ اشتراک (پیشفرض ۳). سقفِ مشترکان روی
|
||||||
|
# هر پلن در ادمین است (ai_daily_quota). حسابِ هزینه: docs/ai-cost-and-guardrails.md
|
||||||
|
# AI_DAILY_QUOTA_FREE=2
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# ۵) پرداخت
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# زرینپال. بدونِ مرچنت، پرداخت ۵۰۳ میدهد. مرچنت باید برای دامنهی PUBLIC_BASE_URL
|
||||||
|
# ثبت شده باشد، وگرنه زرینپال خطای -14 میدهد.
|
||||||
|
ZARINPAL_MERCHANT_ID=
|
||||||
|
# 1 = محیطِ آزمایشیِ زرینپال (بیپولِ واقعی). روی سرورِ فعلی خالی است: درگاهِ واقعی.
|
||||||
|
ZARINPAL_SANDBOX=
|
||||||
|
|
||||||
|
# کافهبازار (فقط خریدِ درونبرنامهایِ اپِ اندروید). روشِ ترجیحی توکنِ ثابتِ پیشخان
|
||||||
|
# است؛ سه متغیرِ OAuth فقط وقتی آن نیست. بدونِ هیچکدام، مسیرِ بازار بسته میماند.
|
||||||
|
BAZAAR_PISHKHAN_API_SECRET=
|
||||||
|
BAZAAR_CLIENT_ID=
|
||||||
|
BAZAAR_CLIENT_SECRET=
|
||||||
|
BAZAAR_REFRESH_TOKEN=
|
||||||
|
# BAZAAR_PACKAGE_NAME=com.tadrisino.math.app
|
||||||
|
# کلیدِ «تخفیف پویا» از پیشخان (سمتِ سرور؛ قیمتِ ادمین و کدِ تخفیف را به بازار میگوید).
|
||||||
|
BAZAAR_DYNAMIC_PRICE_KEY=
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# ۶) اختیاری — پیشفرضها معمولاً کافیاند
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# کلیدِ مسیرهای داخلیِ سرور-به-سرور؛ خالی = آن مسیرها بستهاند (در DEBUG هم پیشفرضی ندارد).
|
||||||
|
# INTERNAL_SERVICE_KEY=
|
||||||
|
# مهمانهای نمایشی که با «حذفِ حساب» پاک نمیشوند (با کاما). پیشفرض: frontend-test-device.
|
||||||
|
# DEMO_GUEST_DEVICE_IDS=
|
||||||
|
# با صفر قلب جلوی جواب دادن گرفته شود؟ پیشفرض روشن (از ۲۰۲۶-۱۰-۰۲)؛ ۰ خاموشش میکند.
|
||||||
|
# HEARTS_ENFORCED=1
|
||||||
|
# سقفهای کدِ ورود: هر شماره در روز، و کلِ سامانه در روز (ضدِ SMS pumping).
|
||||||
|
# OTP_REQUEST_PHONE_DAILY_MAX=10
|
||||||
|
# OTP_SMS_GLOBAL_DAILY_MAX=300
|
||||||
|
# سقفِ خواندن روی اندپوینتهای پرترافیک، و سقفِ /api/health.
|
||||||
|
# READ_THROTTLE_RATE=120/min
|
||||||
|
# HEALTH_THROTTLE_RATE=60/min
|
||||||
|
# سقفِ تمدیدِ توکن برای هر IP (همهی کاربرانِ پشتِ یک وایفای یا CGNAT با هم).
|
||||||
|
# TOKEN_REFRESH_IP_RATE=600/hour
|
||||||
|
# سقفِ روزانهی کلِ سامانه برای سوالهایی که به مدلِ هوش مصنوعی میرسند (ضدِ خرجِ بیسقف).
|
||||||
|
# AI_GLOBAL_DAILY_MAX=3000
|
||||||
|
# کشِ پاسخِ دستیار (ثانیه)؛ سوالِ تکراری دوباره به مدل نمیرود.
|
||||||
|
# RAG_ANSWER_CACHE_TTL=1800
|
||||||
|
# 1 = دستیار حتی با کلیدها هم پاسخِ آزمایشی بدهد.
|
||||||
|
# RAG_FORCE_MOCK=
|
||||||
|
# لاگ در فایل علاوه بر stdout (فقط داخلِ Docker).
|
||||||
|
# LOG_DIR=
|
||||||
|
# رمزِ ادمینِ Grafana در docker compose (فقط محلی).
|
||||||
|
# GRAFANA_ADMIN_PASSWORD=admin
|
||||||
19
.gitignore
vendored
19
.gitignore
vendored
@@ -4,3 +4,22 @@ __pycache__/
|
|||||||
db.sqlite3
|
db.sqlite3
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# کتابهای درسی: خودِ PDFها در گیت نمیآیند (حجیماند)
|
||||||
|
data/textbooks/*
|
||||||
|
!data/textbooks/README.md
|
||||||
|
|
||||||
|
# کلیدِ امضای تولیدشدهی خودکار (هرگز کامیت نشود)
|
||||||
|
.django_secret_key*
|
||||||
|
|
||||||
|
# خروجیِ collectstatic — تولیدی است، در هر دیپلوی از نو ساخته میشود.
|
||||||
|
staticfiles/
|
||||||
|
|
||||||
|
# نسخهی پشتیبانِ تغییرهای دستیِ روی سرور که deploy.sh کنار میگذارد.
|
||||||
|
.deploy-backups/
|
||||||
|
|
||||||
|
# دیتابیسِ فایلیِ تستها (config/settings.py → DATABASES.TEST)
|
||||||
|
.test-db*.sqlite3*
|
||||||
|
tools/fake_openai_requests.jsonl
|
||||||
|
.pg_password
|
||||||
|
.redis_password
|
||||||
|
|||||||
13
.vscode/settings.json
vendored
Normal file
13
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"python.defaultInterpreterPath": "${workspaceFolder}/venv/bin/python",
|
||||||
|
"python.analysis.typeCheckingMode": "basic",
|
||||||
|
"python.analysis.diagnosticSeverityOverrides": {
|
||||||
|
// جنگو خیلی از attributeها (reverse relation ها مثل account.conversions،
|
||||||
|
// related_name، فیلدهای اضافهشده توسط متاکلاس) رو موقع اجرا میسازه،
|
||||||
|
// نه در زمان نوشتن کد؛ Pylance (برخلاف mypy) هیچوقت نمیتونه اینها
|
||||||
|
// رو بشناسه، حتی با django-stubs. این محدودیت واقعی نیست، پس خاموشش
|
||||||
|
// میکنیم؛ بقیهی هشدارهای واقعی (typo، آرگومان اشتباه، ایمپورت گمشده)
|
||||||
|
// دستنخورده میمونن.
|
||||||
|
"reportAttributeAccessIssue": "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
444
CLAUDE.md
Normal file
444
CLAUDE.md
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
Guidance for agents working in this repo. **Current state only**: what is true now and the rules
|
||||||
|
that follow from it. History lives in git (`git log -p -- CLAUDE.md`; the long dated status log that
|
||||||
|
used to be here ends at `eb95209`) and in the owner's reports (`~/Desktop/گزارشات Tadrisino/`).
|
||||||
|
When something here stops being true, change the line; do not append a dated paragraph.
|
||||||
|
|
||||||
|
## The project in one paragraph
|
||||||
|
|
||||||
|
Tadrisino is a Duolingo-style math app for Iranian students (grades 3–12): a map of islands per
|
||||||
|
textbook chapter, short quizzes built from the book's own problems, and an AI tutor («پی») that
|
||||||
|
answers from the same book. Two repos, one API contract: this Django + DRF backend
|
||||||
|
(`Tadrisino/MathApp`, live at `https://mathapi.tadrisino2.ir`) and the React frontend
|
||||||
|
(`Tadrisino/MathApp_frontend`, local clone `~/Desktop/MathApp_frontend`, live at
|
||||||
|
`https://math.tadrisino2.ir`, with an Android WebView shell in `android/`). The owner
|
||||||
|
(`AliReza_Zareian`) maintains both. Code, API messages, comments and docs are Persian.
|
||||||
|
|
||||||
|
## Where to look
|
||||||
|
|
||||||
|
| Need | File |
|
||||||
|
|---|---|
|
||||||
|
| Deploy, env vars, backups, nginx, Bazaar sale, admin refunds | `docs/operations.md` |
|
||||||
|
| Design decisions and why (auth, payment, quiz, hearts, tutor prompt) | `docs/architecture.md` |
|
||||||
|
| Formal handover document for humans (also as PDF) | `docs/technical-handbook.md` → `python docs/build_handbook.py` |
|
||||||
|
| What is still open before real users, and the guards that hold what is closed | `docs/security-before-production.md` |
|
||||||
|
| Editing content, bulk questions, seed rules | `docs/content-authoring.md` |
|
||||||
|
| Reading a textbook with a vision model (costs, model choice) | `docs/extraction-playbook.md`, `data/textbooks/README.md` |
|
||||||
|
| Tutor cost per question, subscription margins, guardrails | `docs/ai-cost-and-guardrails.md` |
|
||||||
|
| Measured model prices (append-only ledger: add a dated row, never overwrite) | `docs/model-prices.md` |
|
||||||
|
| Load testing (laptop and the shared server) | `tools/loadtest/README.md` |
|
||||||
|
| Frontend structure and its deploy | `~/Desktop/MathApp_frontend/README.md` |
|
||||||
|
| Android build, signing, what the next APK must carry | `~/Desktop/MathApp_frontend/android/README.md` |
|
||||||
|
|
||||||
|
Before writing a new doc, check this table: the same fact in three places drifts apart.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⛔ Working on the servers — read before every SSH session
|
||||||
|
|
||||||
|
The owner often says "put it on the server" / "deploy it" and expects you to do it yourself. You
|
||||||
|
may, but these machines are **shared company servers**, not ours. Our app is one tenant among many.
|
||||||
|
A careless command can take down the whole company's git, mail and other teams' production apps,
|
||||||
|
and there is no technical guard rail: our account has full root. **The only guard rail is you.**
|
||||||
|
|
||||||
|
### What is ours — and nothing else is
|
||||||
|
|
||||||
|
**`78.38.35.249` (`haghzad`, app server)**, account `administrator`:
|
||||||
|
|
||||||
|
| Ours | Note |
|
||||||
|
|---|---|
|
||||||
|
| `/home/administrator/mathapp-v2` | backend checkout; `.env`, `.django_secret_key`, `logs/`, `.deploy-backups/` are untracked and live here |
|
||||||
|
| `/home/administrator/mathapp_frontend_new` | frontend checkout; nginx serves its `dist/` |
|
||||||
|
| `gunicorn-mathapp.service` = alias **`zareian-mathapp`** | always type the alias |
|
||||||
|
| `zareian-redis.service` (port 6390) | our own Redis |
|
||||||
|
| Postgres cluster `14/zareian` (port 5440, DB `zareian_mathapp`) | our own cluster |
|
||||||
|
| `zareian-applog-archive.timer` | nightly log archive |
|
||||||
|
| nginx: `sites-enabled/mathapp`, `sites-available/mathapp-front`, `snippets/zareian-*.conf` | source of truth is `ops/nginx/` |
|
||||||
|
|
||||||
|
**Not ours, never touch:** `~/mathapp` (old, not git, not what runs), the generic `gunicorn.service`
|
||||||
|
(another team's app), `gitea`, `postfix`/`dovecot`, `named`, `mariadb`, `postgresql@14-main` (port 5432),
|
||||||
|
`redis-server` (6379), `rabbitmq`, `metabase`, `apirag`, `artinafront`, `metaverse`, `celerybeat`,
|
||||||
|
other people's home directories (`aria`, `artina`, `behdad`, `zahra`, `git`, `gitea`) and every
|
||||||
|
other nginx site. `metabase` and Artina's `celerybeat` crash-loop and eat ~3 of 8 cores; that is a
|
||||||
|
request to the server admin, not something to fix.
|
||||||
|
|
||||||
|
**`78.38.35.247` (backup server)**, same account and password: ours is only `~/zareian-pgbackup/` (249's
|
||||||
|
live copy), `~/zareian-pgbackrest-216/` (216's backups, encrypted on 216; 247 never holds the key), our lines in
|
||||||
|
`~/.ssh/authorized_keys` and `~/.pgpass`, `~/.ssh/zareian_*`, and the `zareian-pg-*` / `zareian-applog-pull` units. It also runs other teams' services and its
|
||||||
|
own `postgresql@14-main`, which we never use.
|
||||||
|
|
||||||
|
**`78.38.35.216` (hostname `mathapp`, new dedicated VM, not yet serving users)**, account `zareian` (sudo; the
|
||||||
|
senior and the company owner share `mathapp-admin`, also sudo; no other logins): the whole machine is ours. The app runs as the non-sudo user `mathapp` under `/home/mathapp/`; same unit names as on 249.
|
||||||
|
Both accounts use the password on the `AdminUser-Password` line of the password file (the `Password` line under
|
||||||
|
`NewServer` is older). State and the cut-over order:
|
||||||
|
`docs/operations.md` «سرورِ اختصاصیِ جدید». Until cut-over, 249 stays the live server and every rule below applies
|
||||||
|
there unchanged; on 216, rules 1, 5, 6, 8 and 10 still apply.
|
||||||
|
|
||||||
|
### Rules (not negotiable)
|
||||||
|
|
||||||
|
1. **Read-only first.** Before changing anything, look: `systemctl status`, `ls`, `git -C … log -1`,
|
||||||
|
`df -h /`. Know what is there before you act on it.
|
||||||
|
2. **No destructive command outside our directories.** No `rm -rf`, `chown -R`, `chmod -R`,
|
||||||
|
`find … -delete`, `DROP`, `docker system prune`, package upgrades, reboots, firewall changes.
|
||||||
|
Inside our directories, write paths out in full (`/home/administrator/mathapp-v2/...`), never
|
||||||
|
rely on the current directory.
|
||||||
|
3. **Never `git clean`, never delete and re-clone the project.** `.env`, `.django_secret_key`, logs
|
||||||
|
and backups are untracked; `git reset --hard origin/main` is the way back to a known tree.
|
||||||
|
4. **Restart only `zareian-mathapp`.** `systemctl restart gunicorn` is another team's app. nginx is
|
||||||
|
shared: `sudo nginx -t` then `reload`, never `restart`.
|
||||||
|
5. **Back up before editing anything under `/etc` or `.env`**, into
|
||||||
|
`~/mathapp-v2/.deploy-backups/<what>-before-<change>-<YYYYMMDD-HHMMSS>`, and say where it is.
|
||||||
|
Database: `deploy.sh` already takes a `pg_dump` before `migrate`; for any manual data change take
|
||||||
|
one yourself first.
|
||||||
|
6. **Disk is the standing risk.** Check `df -h /` before anything that writes a lot. A full disk
|
||||||
|
takes Gitea and mail down with us.
|
||||||
|
7. **Do not use other teams' resources**, even "just an empty database" on their Redis or Postgres,
|
||||||
|
their queues or their nginx sites. Ask the owner; it becomes a request to the server admin.
|
||||||
|
8. **Secrets stay put.** Read `.env` only through Django (`venv/bin/python manage.py shell -c` printing
|
||||||
|
booleans), never `cat .env`, never paste values into chat. `DEBUG` must never be `1` on the server
|
||||||
|
(it enables `DEV_MASTER_OTP=0000`, a login for any phone).
|
||||||
|
9. **Load tests and bulk jobs** follow `tools/loadtest/README.md`: never touch production throttles,
|
||||||
|
stay under the connection budget, clean up.
|
||||||
|
10. **When something is unexpected** (a file you did not expect, a service in a state you cannot
|
||||||
|
explain, a command that wants to prompt), stop and tell the owner. Do not improvise on a shared box.
|
||||||
|
|
||||||
|
### Announce before you act
|
||||||
|
|
||||||
|
Before every command that runs on a server, or that deletes anything (even locally), write one
|
||||||
|
short Persian line saying what it touches, where (local scratchpad or which directory on which
|
||||||
|
server), and that nothing outside our own files is affected. Never combine server commands and
|
||||||
|
local cleanup in one call: once, an agent closing its local SSH socket and destroying its local
|
||||||
|
password copy in the same command looked like deleting files on the server, and the owner (who
|
||||||
|
answers for that machine) was rightly alarmed. «سرور رو خاموش کن» means end the SSH session and
|
||||||
|
destroy the password copy, **never** power anything off.
|
||||||
|
|
||||||
|
### The password
|
||||||
|
|
||||||
|
Access is a password for the shared `administrator` account (same for 249 and 247). The owner
|
||||||
|
keeps it in `~/Desktop/رمز سرور.txt` and wants agents to read it from there rather than ask in
|
||||||
|
chat; the auto-mode classifier may still block that read until the owner confirms in the
|
||||||
|
session. Take only the password line, everything after the first `: ` (stripping only the label
|
||||||
|
once sent the whole line and SSH refused it):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -n '/^Password/{s/^[^:]*: *//;p;q;}' ~/Desktop/'رمز سرور.txt' | tr -d '\r\n' > "$SCRATCH/.pw" && chmod 600 "$SCRATCH/.pw" # 249/247 (first Password line)
|
||||||
|
awk '/^AdminUser-Password/{sub(/^[^:]*: */,"");printf "%s",$0;exit}' ~/Desktop/'رمز سرور.txt' | tr -d '\r\n' > "$SCRATCH/.pwn" && chmod 600 "$SCRATCH/.pwn" # 216
|
||||||
|
sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 '<command>'
|
||||||
|
sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 'sudo -S -p "" <command>' < "$SCRATCH/.pw"
|
||||||
|
rm -P "$SCRATCH/.pw" # as soon as the work is done (macOS has no shred); recreate it next time
|
||||||
|
```
|
||||||
|
|
||||||
|
Never put it on a command line (`ps` shows argv to every user on the server), in the repo, a
|
||||||
|
memory, a commit or a note; never echo it. Many SSH connections in a row can trip a lockout: open
|
||||||
|
one `ControlMaster` connection (socket under `/tmp`, macOS limits the path to 104 bytes). Do not
|
||||||
|
create SSH keys, edit `sshd_config` or change the password unless the owner asks for exactly that.
|
||||||
|
|
||||||
|
### "Deploy it": the procedure
|
||||||
|
|
||||||
|
Backend (commit and push to Gitea first; the server deploys what is on `origin/main`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 'cd /home/administrator/mathapp-v2 && ./deploy.sh'
|
||||||
|
sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 'sudo -S -p "" systemctl restart zareian-mathapp' < "$SCRATCH/.pw"
|
||||||
|
```
|
||||||
|
|
||||||
|
`deploy.sh` saves any hand edits as a patch, resets to `origin/main`, installs, backs up the DB,
|
||||||
|
migrates, runs `seed_content`, collectstatic, and checks health through the gunicorn socket. The
|
||||||
|
restart is separate because `sudo` inside the script cannot prompt. Then **verify; never assume**:
|
||||||
|
|
||||||
|
- `systemctl show zareian-mathapp -p ActiveEnterTimestamp` is after your deploy (a file on disk is not
|
||||||
|
a deployed file: once the server served two-day-old code while every file was current);
|
||||||
|
- `git -C /home/administrator/mathapp-v2 log -1 --format=%h` equals your local `main`;
|
||||||
|
- `curl https://mathapi.tadrisino2.ir/api/health` → `{"status":"ok"}` (details need `X-Internal-Key`);
|
||||||
|
- the thing you changed, exercised once from outside.
|
||||||
|
|
||||||
|
If the server cannot reach Gitea (`git fetch` 504 from the server, 200 from the laptop), ship a bundle:
|
||||||
|
`DEPLOY_BUNDLE` in `docs/operations.md` «دیپلوی».
|
||||||
|
|
||||||
|
Frontend: push to `upstream` (`Tadrisino/MathApp_frontend`) `main` (the owner authorized pushing and
|
||||||
|
deploying it without asking), then `cd /home/administrator/mathapp_frontend_new && ./deploy.sh` (builds
|
||||||
|
into `dist.next`, swaps, keeps `dist.prev`). `config.js` and `index.html` are cached by ArvanCloud;
|
||||||
|
purge them there when they change.
|
||||||
|
|
||||||
|
Report back: the commit hashes now live, the backup paths created, what you verified and what you did
|
||||||
|
not.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to work in this codebase
|
||||||
|
|
||||||
|
**Comments are claims, not evidence.** Most comments were written by earlier agents to justify their
|
||||||
|
own change. Real bugs have been found directly under comments calling the code "safe" or "race-free".
|
||||||
|
When asked to find problems, review adversarially and judge by behaviour: write a throwaway test or call
|
||||||
|
the endpoint. Ask how a cheating student, an attacker or a confused parent would break it. A finding
|
||||||
|
counts once reproduced; say which ones you only read. When writing comments, report, don't vouch: say
|
||||||
|
what the code does, why, and what was *not* checked.
|
||||||
|
|
||||||
|
**No silent fallbacks, no speculative backward compatibility.** "Helpful" fallbacks (`x or default`,
|
||||||
|
`except: pass`, accepting an old field name "just in case") caused the hardest bugs here because the
|
||||||
|
failure never showed anywhere (a stub tutor answer served with 200 in production; a yearly buyer
|
||||||
|
silently given 30 days).
|
||||||
|
|
||||||
|
1. Fail loudly over guessing. A missing setting, row or field in production is an error the user and
|
||||||
|
`/api/health` can see. A default is fine only when it is a real product choice, and its comment says
|
||||||
|
why that value is right.
|
||||||
|
2. Demo/stub paths only behind `DEBUG` or `RAG_FORCE_MOCK`; never reachable on the server.
|
||||||
|
3. No backward compatibility without a named client. The only API client is the frontend
|
||||||
|
(`~/Desktop/MathApp_frontend/src/api/`); check what it and the server DB actually send. If nobody
|
||||||
|
sends it, delete it. If you must keep one, write who uses it and when it can go.
|
||||||
|
4. Never `except: pass`. At least `logger.exception`, and swallow only where the comment names the
|
||||||
|
bigger thing it protects (e.g. affiliate commission must not undo a user's payment).
|
||||||
|
5. Removing a fallback means the old input fails visibly (400/503), not silently routing elsewhere.
|
||||||
|
|
||||||
|
Fallbacks that remain on purpose (re-check before relying on them): settings boot with default
|
||||||
|
hosts/CORS/cache/secret key (`/api/health` warns); `LocMemCache` without `CACHE_URL`; `_annotation`
|
||||||
|
defaults for answer-cache entries older than a field; Bazaar's OAuth path beside the Pishkhan token
|
||||||
|
(the server uses OAuth); `birthYear` alone in `PATCH /api/user/profile` (frontend type still allows it);
|
||||||
|
`totalQuestions` accepted but not trusted (frontend sends it); orders without a `plan_id` or without the
|
||||||
|
`payment.0011` snapshot fields grant and refund from the plan's current values; a paid order whose plan
|
||||||
|
was deleted activates for 30 days and logs ERROR.
|
||||||
|
|
||||||
|
**The owner** is early in their career: explain the *why* with the *what*, flag product decisions as
|
||||||
|
product decisions, and never assume a step was done because it was described. **Reply in Persian**, every
|
||||||
|
sentence including progress lines between tool calls (code, paths and identifiers stay Latin). Keep chat
|
||||||
|
replies short; depth goes into the deliverable. Docs describe the current state only; reasons and dates belong in
|
||||||
|
commit messages. Commit in batches, not per small change.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source venv/bin/activate
|
||||||
|
python manage.py runserver # needs DJANGO_DEBUG=1 locally (devOtp, DEV_MASTER_OTP)
|
||||||
|
python manage.py migrate
|
||||||
|
python manage.py test --noinput # whole suite (973 tests), serial, ~95 s
|
||||||
|
python manage.py test --noinput accounts # one app
|
||||||
|
python manage.py test --noinput accounts.tests.SomeTestCase.test_something
|
||||||
|
python manage.py spectacular --file schema.yaml --format openapi # the API contract
|
||||||
|
```
|
||||||
|
|
||||||
|
**Never run tests with `--parallel` on this Mac, always pass `--noinput`.** The test DB is a SQLite
|
||||||
|
*file* (`.test-db.sqlite3`, needed by the real-concurrency tests); parallel workers fail to open it and
|
||||||
|
the parent waits forever, and a leftover test DB makes Django ask "Type 'yes'" on stdin forever. If a
|
||||||
|
run prints nothing for ~3 minutes it is stuck: `pkill -f "manage.py test"`. No pytest, no linter.
|
||||||
|
|
||||||
|
Docker (`docker compose up --build`) adds a local Loki + Alloy + Grafana log stack; dev only
|
||||||
|
(`docs/docker.md`).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
| app | responsibility | key models |
|
||||||
|
|---|---|---|
|
||||||
|
| `accounts` | identity, OTP login, sessions, profile | `User`, `Profile`, `Session`, `OTPCode`, `LoginEvent` |
|
||||||
|
| `content` | subjects, curriculum, lessons, questions, quiz, votes/reports | `Grade`, `Subject`, `Unit`, `Node`, `Lesson`, `Question`, `Choice`, `UserAnswer`, `UserNodeCompletion`, `ContentVote`, `SeedRecord` |
|
||||||
|
| `progress` | stats, streaks, hearts, leaderboard, companions, weekly report | `UserStats`, `StreakRecord`, `UserHearts`, `LeaderboardEntry`, `Character` |
|
||||||
|
| `ai_tutor` · `rag` | tutor chat; retrieval from Qdrant + generation via GapGPT; textbook ingest | `Conversation`, `Message`, `LessonChunk`, `IngestedPage` (book list: `rag/services/textbooks.py`) |
|
||||||
|
| `subscription` | plans and subscriptions | `Plan`, `Subscription` |
|
||||||
|
| `payment` | orders, transactions, Zarinpal, Cafe Bazaar, payment event ledger | `Order`, `Transaction`, `PaymentEvent` |
|
||||||
|
| `discount` · `affiliate` | discount codes/offers; referral commissions | `DiscountCode`, `Redemption`, `Offer`, `AffiliateAccount`, `Conversion` |
|
||||||
|
| `notifications` · `support` · `campaigns` | notifications; tickets/bug reports; campaign storage (no sending) | |
|
||||||
|
| `statuslog` | cross-app status-change log | `StatusChangeLog` |
|
||||||
|
| `config` | settings, urls, middleware, health, renderers | |
|
||||||
|
|
||||||
|
Invariants that are not obvious from one file:
|
||||||
|
|
||||||
|
- **Auth is ours, not Django's.** `accounts.User` is deliberately not `AUTH_USER_MODEL`. Access token =
|
||||||
|
signed `user_id` (1 h, `django.core.signing`, `accounts/authentication.py`); refresh tokens live
|
||||||
|
(hashed) in `Session`; logout sets `User.tokens_valid_after`. A missing/invalid header means an anonymous
|
||||||
|
request; each view's permission class decides. Login is phone + SMS code only (6 digits, 5 guesses);
|
||||||
|
there is no guest login. The signing key is `.django_secret_key` (generated on the server, mode 600);
|
||||||
|
the `django-insecure-…` fallback in `config/settings.py` must never sign tokens on a server.
|
||||||
|
- **No cross-app foreign keys.** Apps refer to each other with `user_id` / `object_id` + `label` so any app
|
||||||
|
can be split out. Follow the pattern.
|
||||||
|
- **Throttling**: every sensitive view has a `throttle_scope` and a matching rate in
|
||||||
|
`REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']`. New endpoint = both. Anonymous requests are limited by IP;
|
||||||
|
`config.middleware.TrustedProxyMiddleware` trusts `X-Forwarded-For` only from ArvanCloud ranges
|
||||||
|
(`TRUSTED_PROXY_NETWORKS`), and nginx refuses anything not from Arvan.
|
||||||
|
- **Grade id = grade number** (8 = eighth). Units belong to a `Subject` (ریاضی، حسابان، هندسه، آمار و احتمال،
|
||||||
|
گسسته); `/api/home?subject=` picks the book, an unrelated subject is 400.
|
||||||
|
- **Domains are configuration**: only `PUBLIC_BASE_URL` and `FRONTEND_URL`; everything else derives from
|
||||||
|
them (`config/tests_domains.py`). Domain-move procedure: `docs/operations.md`.
|
||||||
|
- **Output is plain Persian** (company decision): `config.renderers.PlainPersianJSONRenderer` strips
|
||||||
|
diacritics and the written ezafe «ی» from every JSON response, and `rag.services.mathtext.to_plain` does
|
||||||
|
the same for the tutor (stream included). The DB, seed and admin keep the original text, because rewriting
|
||||||
|
titles would break question→lesson binding by title path. No LaTeX/markdown reaches a student: seed and
|
||||||
|
import convert (`content/plain_math.py`), the tutor converts, and the frontend's `plainMath.ts` is the
|
||||||
|
last net; the two converters must behave alike.
|
||||||
|
- **Structured logging**: `APIRequestLoggingMiddleware` logs every `/api/` request as one JSON line (no
|
||||||
|
body, headers or secrets). Unexpected errors always return JSON (`config/exceptions.py`).
|
||||||
|
- **`.iterator()` never wraps a loop that writes or does network I/O**: the open read cursor held locks
|
||||||
|
for the whole loop (a "flaky" test that was a real bug). Materialize with `list(...)`.
|
||||||
|
- **Network calls stay outside `transaction.atomic()`**, and the tutor and the OTP SMS call hand their DB
|
||||||
|
connection back before the network wait (`release_db_connection`).
|
||||||
|
|
||||||
|
## Current state, by area
|
||||||
|
|
||||||
|
### Infrastructure (server 249)
|
||||||
|
|
||||||
|
- Postgres cluster `14/zareian`, port 5440, localhost only (`DB_*` in `.env`; without `DB_NAME` the app
|
||||||
|
uses SQLite, which is what local dev and tests use). `max_connections=60`.
|
||||||
|
- Connections come from a per-worker pool (`DB_POOL_MAX_SIZE` 8); a full pool answers 503 + `Retry-After`
|
||||||
|
(`config/db.py`), and `/api/health` → `dbPool` checks `workers × pool + 6 ≤ max_connections`.
|
||||||
|
`DB_CONN_MAX_AGE` in `.env` stops startup on purpose.
|
||||||
|
- gunicorn: `gunicorn.conf.py`, 4 `gthread` workers × 32 threads (the unit passes no `--workers`). The tutor
|
||||||
|
gets at most `AI_MAX_CONCURRENT_PER_WORKER` (12) threads per worker; beyond that `/api/ai/ask` answers 503
|
||||||
|
`Retry-After: 10` before reserving quota.
|
||||||
|
- Cache: our own Redis (`zareian-redis`, 6390, password, 256 MB `allkeys-lru`, no persistence) via
|
||||||
|
`CACHE_URL`. `config.cache.ResilientRedisCache` fails open on a Redis outage (logged, health 503, cache-based
|
||||||
|
limits stop applying); not tested against a real outage on the server.
|
||||||
|
- nginx (versions in `ops/nginx/`): only ArvanCloud and the machine itself get through (direct IP → 403);
|
||||||
|
CSP and security headers on the frontend; API body limit 20 MB; HSTS. Verified in Chrome; **not** verified
|
||||||
|
in the Android WebView or through `pay.html` to the gateway.
|
||||||
|
- Capacity (load test on the server, `tools/loadtest/play.py`): 300 players ≈ 88 req/s, median 0.1 s;
|
||||||
|
600 ≈ 137 req/s, median 1.4 s, no errors; 1,000 hits the ceiling (~135 req/s, p95 15 s). The bottleneck
|
||||||
|
is the shared CPU, not the DB pool. More capacity needs the neighbours' crash-loops fixed plus more cores
|
||||||
|
(then 8 workers with `DB_POOL_MAX_SIZE` 6).
|
||||||
|
- `/api/health`: public answer is only ok/error; full details (DB, migrations, cache, pool, integrations,
|
||||||
|
`aiProvider`, `offsiteBackup`, warnings, `clientIp`) need `X-Internal-Key`. Swagger/ReDoc/schema need an
|
||||||
|
admin session.
|
||||||
|
|
||||||
|
### Backups and logs (server 247)
|
||||||
|
|
||||||
|
Every Postgres change streams live to 247 (`pg_receivewal` over a restricted SSH tunnel, slot
|
||||||
|
`zareian_247`, async; `max_slot_wal_keep_size=1GB` protects 249's disk) plus a daily base backup (7 kept).
|
||||||
|
`deploy.sh` also takes a `pg_dump -Fc` before `migrate` (last 2 kept). Payment events are exported to CSV on
|
||||||
|
247 every 6 hours; the app log is archived nightly on 249 and pulled hourly to 247, where it is kept forever.
|
||||||
|
Restore procedure, monitoring and what was tested: `docs/operations.md` «بکاپ و بازیابی». Restoring onto 249
|
||||||
|
has been done before.
|
||||||
|
|
||||||
|
### Content
|
||||||
|
|
||||||
|
- `content/seed/` is the whole curriculum and question bank as committed JSON: 10 grades (3–12), 5 subjects,
|
||||||
|
89 units, 1,145 islands, 5,406 lessons (every one has at least one question), 39,156 questions. `deploy.sh` runs `seed_content` on every release.
|
||||||
|
- `seed_content` is idempotent, never deletes, and does not undo admin work: the `SeedRecord` ledger remembers
|
||||||
|
what it delivered, so anything the admin deleted or renamed is not recreated; lesson text, `example`,
|
||||||
|
`steps` and `figures` are only filled when empty. Questions bind to lessons **by title path**
|
||||||
|
(grade › unit › node › lesson), never by id. Practice islands («تمرین: …») are the exception: their
|
||||||
|
options are synced from the file in place (`seed_sync._refresh_practice`).
|
||||||
|
- Content changes reach the server only as committed seed files (`seed_content --export`); pushing code
|
||||||
|
alone moves no content. Lesson ids differ between laptop and server.
|
||||||
|
- Islands longer than 7 lessons are split into parts of ≤6 (`content/node_split.py`). 160 practice islands
|
||||||
|
close each chapter (grades 3–12) with computed-answer questions.
|
||||||
|
- `Lesson.figures` and `Question.figure` are fixed diagrams (same spec as the tutor's, validated by
|
||||||
|
`rag.services.diagram.validate`); nothing calls a model at display time. Geometry/statistics/discrete
|
||||||
|
figures were hand-written and checked by eye; API-generated figures (`gen_lesson_figures`) were about half
|
||||||
|
wrong on geometry and are only used with visual review (`--dry-run` still calls the API).
|
||||||
|
- Students vote 👍/👎 and report problems on every question and lesson (`ContentVote`, admin columns and
|
||||||
|
report queues).
|
||||||
|
- Not yet filled: `Unit.blurb` for some units, `Plan.features`/`badge`; no video content exists although
|
||||||
|
`Lesson.kind`/`video_url` can express it.
|
||||||
|
|
||||||
|
### Playing: islands, quizzes, hearts
|
||||||
|
|
||||||
|
- `GET /api/nodes/{id}/quiz`: at most one question per lesson, shuffled on `nodeId:userId` only, so a student
|
||||||
|
sees the same quiz every time (owner's decision; `NodeQuizStabilityTests`).
|
||||||
|
**Figured questions win:** inside a lesson the quiz takes a question with a `figure` over one without
|
||||||
|
(`NodeQuizPrefersFigureTests`). So adding figured questions to a lesson replaces its figureless ones in
|
||||||
|
the quiz without deleting anything; the old rows stay (seed never deletes, answers may reference them).
|
||||||
|
Owner's standing plan (2026-10-14): keep writing more figured questions and re-review the whole bank over
|
||||||
|
time; this rule is the mechanism, so new figures need no other switch. Write them with
|
||||||
|
`tools/figures/batch.py` (`docs/content-authoring.md` «سوالِ شکلدار»). The lesson-level list
|
||||||
|
(`/api/lessons/{id}/questions`) does not reorder; only the island quiz does.
|
||||||
|
**A figure must not give the answer away** (title, labels, or drawing the thing the question asks for); the
|
||||||
|
rules and the fix-up tool (`tools/figures/apply_fixups.py`) are in `docs/content-authoring.md`.
|
||||||
|
- `POST /api/nodes/{id}/quiz/complete` completes the island at any score (score sets stars/XP) and returns
|
||||||
|
`nextNodeId`. It refuses a locked island (403 `node_locked`, same rule as `/api/home`) and a user who has
|
||||||
|
not answered every question (409 `answers_missing`; required = `max(client totalQuestions, min(4, questions
|
||||||
|
in node))`). An island with no questions passes when unlocked. `POST /api/lessons/{id}/complete` has the same
|
||||||
|
rules. XP counts only answers recorded in `UserAnswer`.
|
||||||
|
- Hearts are enforced (`HEARTS_ENFORCED`): a wrong answer costs one, one returns every 5 h, all return 24 h
|
||||||
|
after the first loss; zero hearts → 403 `no_hearts`. An active subscription means unlimited hearts.
|
||||||
|
- `UNLOCK_ALL_PHONES` (server `.env`: the owner's and a tester's numbers, users 34 and 35) removes the island
|
||||||
|
order lock for testers; their content access comes from a `Subscription` row.
|
||||||
|
- Streaks and the leaderboard are computed live from activity (Tehran time); companions (sigma, delta, pi,
|
||||||
|
omega) unlock by XP.
|
||||||
|
|
||||||
|
### Paywall, subscriptions, payment
|
||||||
|
|
||||||
|
- Only the first unit of **each book** is free. Elsewhere, without an active subscription, lesson questions,
|
||||||
|
island quizzes, answers and completion return 402 `subscription_required`; `/api/home` marks each unit
|
||||||
|
`requiresSubscription`.
|
||||||
|
- Only subscriptions are sold (question packs were removed at the root): 1/3/6 months at
|
||||||
|
598/898/1,398 thousand toman; the tutor allows 4 questions a day for subscribers and
|
||||||
|
`AI_DAILY_QUOTA_FREE` (2) for free users; `AI_GLOBAL_DAILY_MAX` 3,000 for the whole system.
|
||||||
|
- Zarinpal: `paymentUrl` points at our `GET /api/payment/orders/{id}/pay`, which calls PaymentRequest and
|
||||||
|
redirects; the callback always re-verifies server-to-server. Unverified paid orders are re-checked when the
|
||||||
|
app reads status and by `payment_reconcile`. The merchant is the one `Tadrisino_backend` uses (registered
|
||||||
|
for `tadrisino2.ir`); PaymentRequest returns 100. **No real payment has gone through to verify yet.**
|
||||||
|
- Cafe Bazaar: subscription SKUs `10342`/`10343`/`10344` under package `com.tadrisino.math.app`, set on the
|
||||||
|
plans in admin; dynamic-price discounts via `BAZAAR_DYNAMIC_PRICE_KEY`; renewals are polled from Bazaar
|
||||||
|
(`sync_bazaar_renewals`). The server talks to Bazaar through the company account's OAuth. Payment methods
|
||||||
|
differ only in `payment/providers.py`. **No real Bazaar purchase or renewal has been tested.**
|
||||||
|
- Every purchase step writes a `PaymentEvent` row. Cancel and refund are admin actions on
|
||||||
|
`/admin/payment/order/` (the money itself is returned in the gateway's panel).
|
||||||
|
- Both gateways and SMS fail closed (503 / invalid) without credentials, never a fake success.
|
||||||
|
|
||||||
|
### AI tutor
|
||||||
|
|
||||||
|
- `POST /api/ai/ask` is RAG: Qdrant retrieval filtered by book and chapter → GapGPT generation
|
||||||
|
(`OPENAI_BASE_URL`, model `gpt-4.1-mini`, prompt `tutor-v12-age-tone`). `usedChunks` names the book chunks
|
||||||
|
used. Without `OPENAI_API_KEY`/`QDRANT_API_KEY` and `DEBUG=0` it answers 503, never a stub.
|
||||||
|
- Answers are plain text, step by step, math-only (`rag/services/guardrails.py`; self-harm messages get a
|
||||||
|
kind reply with helpline numbers), tone by age, with an optional `<diagram>` JSON block that the frontend
|
||||||
|
draws (`rag/services/diagram.py`, frontend `src/diagram/`). Solutions marked `disagrees` never reach the
|
||||||
|
model. With `questionId` it sees the quiz question and the student's last answer.
|
||||||
|
- Cost ≈ 0.2 cents per question (plan with ~700 toman). `/api/health` → `aiProvider` shows when GapGPT credit
|
||||||
|
runs out; every live call spends the same production credit, laptop included.
|
||||||
|
|
||||||
|
### Accounts, SMS, users
|
||||||
|
|
||||||
|
- Kavenegar sends OTP only through the approved template `SMSVerify` (`verify/lookup`,
|
||||||
|
`KAVENEGAR_OTP_TEMPLATE`); plain `sms/send` is cancelled on this line, so never unset the template. Every
|
||||||
|
OTP costs credit. Limits are counted in the DB: per-phone cooldown and 10/day, system-wide
|
||||||
|
`OTP_SMS_GLOBAL_DAILY_MAX` (2,000 on the server), per-IP 100 requests and 300 logins per hour.
|
||||||
|
- No fixed-code test logins on the server (`TEST_LOGIN_PHONES` empty; health warns if set) and no demo users.
|
||||||
|
To test without SMS, a superuser creates an `OTPCode` in `/admin/accounts/otpcode/add/` (random code, a few
|
||||||
|
minutes), then `POST /api/auth/logout` afterwards. For a browser check, put the tokens in
|
||||||
|
`localStorage['tadrisino.tokens']` and drive Chrome with `puppeteer-core` from the scratchpad; never print
|
||||||
|
the access token.
|
||||||
|
- Profile: Jalali `birthDate`, display id `User482917` (`accounts/public_id.py`), weekly report
|
||||||
|
(`/api/user/weekly-report`, minutes are estimated), invoices, account deletion with a fresh SMS code
|
||||||
|
(frontend: «حساب کاربری»).
|
||||||
|
|
||||||
|
### Frontend and Android
|
||||||
|
|
||||||
|
- Frontend: React 18 + TypeScript (strict) + Vite + Tailwind; the client is `src/api/` and is the only
|
||||||
|
place that calls the server. Its README is the map. Never write the client from memory: read the schema and
|
||||||
|
check shapes against a running server (`content/tests_frontend_contract.py` is the backend-side guard).
|
||||||
|
- Android: WebView shell plus Poolakey for Bazaar. The installed APK lags the code; what the next build must
|
||||||
|
carry and what to check on a phone is in `android/README.md`. Signing belongs to teammate MH-W90; never
|
||||||
|
commit a keystore. Until the new APK, card payment from the app goes through the frontend's `pay.html`
|
||||||
|
(Zarinpal refuses StartPay without our domain as Referer).
|
||||||
|
|
||||||
|
### Still open before real users
|
||||||
|
|
||||||
|
`docs/security-before-production.md` is the list (public `/admin/`, plaintext OTP in the admin, SMS-cap
|
||||||
|
exhaustion, shared server account, …). Do not call this deployment production-ready until it is empty. When
|
||||||
|
one is closed, move it to that file's «حفاظهایی که هست» table with the test that holds it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lessons that were expensive
|
||||||
|
|
||||||
|
- **GapGPT** (OpenAI-compatible proxy reachable from Iran; it answers 504 from outside Iran). Two failures
|
||||||
|
look alike: `429 initial_usage_limit_exceeded` is an hours-long account cap that retries *extend*;
|
||||||
|
`403 insufficient user quota` (with spaces) is exhausted credit. `is_account_limit()` in
|
||||||
|
`rag_verify_solutions` is the shared guard. `/dashboard/billing/usage` (cents) is the only reliable balance;
|
||||||
|
probe it before any bulk run. Credit has run dry more than once.
|
||||||
|
- **GapGPT spend stays at cents per session**: the laptop key is the production tutor's account (another
|
||||||
|
person uses it too); one benchmark of ~250 calls once emptied it and the live tutor returned 503. Estimate
|
||||||
|
first, test plumbing with `tools/fake_openai.py`, ask before anything bigger.
|
||||||
|
- **Measure models; don't read price tables.** GapGPT prices differ from official ones; the judge model in
|
||||||
|
`rag_model_benchmark` misjudges ~7%, so hand-check samples before deciding. Vision ingest uses
|
||||||
|
`gpt-4.1-mini` (`docs/extraction-playbook.md`).
|
||||||
|
- **Bulk content is written by Claude Code subagents, not the paid API**: Sonnet authors questions well
|
||||||
|
(~88% yield), Haiku does not (~40% broken). The weekly Sonnet limit arrives suddenly: predict the cost and
|
||||||
|
import results between batches. Every generated batch gets an independent blind re-solve.
|
||||||
|
- **Verification is three-state** (`agrees`/`disagrees`/`unjudgeable`); a boolean verdict reported ~3× the
|
||||||
|
real error rate. Question generation reads only `agrees` chunks, and `agrees` is a first filter, not a
|
||||||
|
guarantee. `solution` is never rewritten; the independent answer is in `checked_answer`. In these books
|
||||||
|
`۳/۱۰۴` is the decimal 3.104, and in primary books «/» is both decimal separator and fraction bar.
|
||||||
|
- `rag_verify_solutions --recheck` resends everything; to re-check a subset, clear its verdicts. Never run two
|
||||||
|
writing management commands at once on local SQLite ("database is locked" left a run half done, silently).
|
||||||
|
- In shell wait loops use `pgrep -f`, not `ps | grep` (it counts itself).
|
||||||
|
- A load test or wait loop on the server must not `wait` on a background gunicorn (it hangs forever).
|
||||||
31
Dockerfile
Normal file
31
Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# Dockerfile برای اپلیکیشن Django (Tadrisino).
|
||||||
|
#
|
||||||
|
# ⚠️ این ایمیج فقط برای اجرای محلی/dev در کنارِ استکِ لاگِ متمرکز
|
||||||
|
# (docker-compose.yml) ساخته شده، نه یک تنظیمِ پروداکشن. عمداً همون چیزی
|
||||||
|
# که پروژه از قبل با `python manage.py runserver` اجرا میکرد رو نگه
|
||||||
|
# داشته (بدونِ اضافهکردنِ gunicorn/uWSGI یا هر dependency جدید)، چون
|
||||||
|
# هدفِ این تغییر فقط معرفیِ زیرساختِ Docker + لاگِ متمرکزه، نه بازطراحیِ
|
||||||
|
# نحوهی serve شدنِ اپلیکیشن.
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
# جلوگیری از نوشتنِ فایلهای .pyc و بافر نشدنِ stdout/stderr — بدونِ این
|
||||||
|
# دومی، لاگهای JSON ممکنه با تاخیر (یا فقط موقعِ خروج) چاپ بشن.
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# اول فقط requirements.txt کپی میشه تا وقتی صرفاً کدِ اپلیکیشن تغییر
|
||||||
|
# میکنه (نه dependency ها)، لایهی pip install از کشِ Docker استفاده کنه
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# دستورِ واقعیِ اجرا (migrate + runserver) در docker-compose.yml تعریف
|
||||||
|
# شده، نه اینجا؛ این CMD فقط یک fallback برای اجرای مستقیمِ ایمیجه.
|
||||||
|
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||||
157
README.md
157
README.md
@@ -1,72 +1,139 @@
|
|||||||
Tadrisino API
|
# Tadrisino API
|
||||||
|
|
||||||
این پروژه بخش API و سرویسهای سمت سرور سامانه Tadrisino را شامل میشود و ساختار آن بهصورت ماژولار طراحی شده است.
|
بکاندِ **تدریسینو**، اپِ آموزشِ ریاضی به سبکِ دولینگو برای دانشآموزانِ پایهی سوم تا دوازدهم. دانشآموز روی نقشهای
|
||||||
|
از جزیرهها جلو میرود، هر جزیره آزمونی کوتاه از مسئلههای خودِ کتابِ درسی است، و هر جا گیر کند از دستیارِ هوشِ
|
||||||
|
مصنوعی میپرسد که جوابش را از همان کتاب میسازد.
|
||||||
|
|
||||||
ساختار پروژه به گونهای است که هر بخش اصلی سیستم در یک اپلیکیشن مستقل قرار گرفته و مدلها، منطق مربوط به API، Serializerها، Viewها، URLها و Migrationهای موردنیاز هر بخش در همان اپ مدیریت میشوند.
|
این مخزن یک REST API روی Django است: برنامهی درسی و آزمون، پیشرفت و گیمیفیکیشن، دستیارِ مبتنی بر RAG، اشتراک
|
||||||
|
و پرداخت. فرانتاند مخزنِ جداگانهای است ([`Tadrisino/MathApp_frontend`](https://gitea.tadrisino2.ir/Tadrisino/MathApp_frontend)).
|
||||||
|
**قراردادِ میانِ دو طرف اسکیمای OpenAPIِ همین مخزن است**؛ هر تغییری در API از آنجا دیده میشود.
|
||||||
|
|
||||||
اجرای پروژه
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **سرویسِ زنده** | `https://mathapi.tadrisino2.ir` · اپ: `https://math.tadrisino2.ir` |
|
||||||
|
| **فناوری** | Python 3.11 · Django 5.2 · Django REST Framework · PostgreSQL 14 · Redis · Qdrant · gunicorn پشتِ nginx و ArvanCloud |
|
||||||
|
| **API** | ۶۱ عملیات روی ۵۹ مسیر، مستند با OpenAPI (`/api/docs/`) |
|
||||||
|
| **محتوا** | ۱۰ پایه · ۵ کتاب · ۸۹ فصل · ۱٬۱۴۵ جزیره · ۵٬۴۰۶ درس · ۳۹٬۱۵۶ سوال |
|
||||||
|
| **تست** | ۹۷۳ تستِ خودکار، همه سبز |
|
||||||
|
| **سرویسهای بیرونی** | GapGPT (مدلِ زبانی) · Qdrant (جستجوی کتاب) · کاوهنگار (پیامک) · زرینپال و کافهبازار (پرداخت) |
|
||||||
|
|
||||||
ابتدا محیط مجازی را ایجاد و فعال کنید:
|
---
|
||||||
|
|
||||||
python3 -m venv venv
|
## امکانات
|
||||||
source venv/bin/activate
|
|
||||||
|
|
||||||
سپس وابستگیهای پروژه را نصب کنید:
|
| بخش | چه میکند |
|
||||||
|
|---|---|
|
||||||
|
| **ورود** | فقط با شمارهی موبایل و کدِ پیامکی؛ توکنِ دسترسیِ امضاشدهی یکساعته و توکنِ تمدید |
|
||||||
|
| **برنامهی درسی** | پایه ← کتاب ← فصل ← جزیره ← درس ← سوال؛ کلِ محتوا بهصورتِ JSONِ کامیتشده در `content/seed/` |
|
||||||
|
| **بازی** | آزمونِ هر جزیره، قفلِ ترتیبی، قلب، ستاره و امتیاز، زنجیرهی روزانه، جدولِ رتبهبندی، همراهها |
|
||||||
|
| **دستیار** | پاسخِ گامبهگام از روی همان کتاب (Qdrant + GapGPT)، فقط ریاضی، با شکلِ توصیفی که اپ میکشد |
|
||||||
|
| **اشتراک** | فصلِ اولِ هر کتاب رایگان؛ اشتراکِ یک/سه/ششماهه از زرینپال یا کافهبازار، با کدِ تخفیف و برنامهی معرفی |
|
||||||
|
| **پنلِ ادمین** | ویرایشِ محتوا، ورود و خروجِ فلهای سوال، پلنها، سفارشها (لغو و بازپرداخت)، تیکتها، گزارشهای کاربران |
|
||||||
|
| **پایش** | `/api/health` (دیتابیس، مهاجرت، کش، سرویسهای بیرونی، بکاپ)، لاگِ JSON برای هر درخواست، دفترِ رویدادِ پرداخت |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## شروعِ سریع
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3.11 -m venv venv && source venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
cp .env.example .env # برای توسعهی محلی DJANGO_DEBUG=1 را ست کنید
|
||||||
اجرای Migrationها:
|
|
||||||
|
|
||||||
python manage.py migrate
|
python manage.py migrate
|
||||||
|
python manage.py seed_content # برنامهی درسی و بانکِ سوال از content/seed/
|
||||||
|
python manage.py createsuperuser # برای /admin/
|
||||||
|
python manage.py runserver # http://127.0.0.1:8000
|
||||||
|
```
|
||||||
|
|
||||||
در صورت نیاز میتوان یک کاربر مدیریتی ایجاد کرد:
|
بدونِ `DB_NAME` در `.env` دیتابیسِ محلی SQLite است و نیازی به نصبِ Postgres نیست. `.env` خودکار خوانده میشود.
|
||||||
|
|
||||||
python manage.py createsuperuser
|
| آدرس | چیست |
|
||||||
|
|---|---|
|
||||||
|
| `/api/docs/` · `/api/redoc/` · `/api/schema/` | مستنداتِ زندهی API (روی سرور فقط با ورود به پنلِ ادمین) |
|
||||||
|
| `/admin/` | پنلِ ادمین |
|
||||||
|
| `/api/health` | سلامتِ سرویس |
|
||||||
|
|
||||||
در نهایت پروژه را اجرا کنید:
|
**اولین درخواست:** با `DJANGO_DEBUG=1`، `POST /api/auth/otp/request` با یک شماره، سپس `POST /api/auth/login` با
|
||||||
|
کدی که در فیلدِ `devOtp` پاسخ آمده. `token` را در Swagger زیرِ **Authorize** بگذارید.
|
||||||
|
|
||||||
python manage.py runserver
|
```bash
|
||||||
|
python manage.py test --noinput # کلِ تستها (سریال؛ --parallel روی macOS گیر میکند)
|
||||||
|
python manage.py test --noinput content # تستهای یک اپ
|
||||||
|
python manage.py spectacular --file schema.yaml --format openapi # خروجیِ قرارداد برای فرانت
|
||||||
|
```
|
||||||
|
|
||||||
مستندات API
|
برای استکِ لاگِ محلی (Loki و Grafana) [`docs/docker.md`](docs/docker.md) را ببینید.
|
||||||
|
|
||||||
پس از اجرای پروژه، مستندات API از طریق Swagger در دسترس است:
|
---
|
||||||
|
|
||||||
http://127.0.0.1:8000/api/docs/
|
## معماری
|
||||||
|
|
||||||
مستندات Redoc:
|
یک برنامهی جنگو در قالبِ «مونولیتِ ماژولار»: هر اپ یک مسئولیت دارد و مدلهای خودش را نگه میدارد.
|
||||||
|
|
||||||
http://127.0.0.1:8000/api/redoc/
|
| اپ | مسئولیت |
|
||||||
|
|---|---|
|
||||||
|
| `accounts` | هویت، ورود با کدِ پیامکی، نشست، پروفایل |
|
||||||
|
| `content` | کتابها، برنامهی درسی، درس، سوال، آزمونِ جزیره، بازخوردِ کاربر |
|
||||||
|
| `progress` | آمار، زنجیرهی روزانه، قلب، رتبهبندی، همراهها، گزارشِ هفتگی |
|
||||||
|
| `ai_tutor` · `rag` | دستیار: گفتگو، بازیابی از Qdrant، تولید با GapGPT، ورودِ کتاب |
|
||||||
|
| `subscription` · `payment` · `discount` · `affiliate` | پلن و اشتراک، سفارش و تراکنش، کدِ تخفیف، برنامهی معرفی |
|
||||||
|
| `notifications` · `support` · `campaigns` · `statuslog` | اعلان، پشتیبانی، کمپین، لاگِ تغییرِ وضعیت |
|
||||||
|
| `config` | تنظیمات، مسیرها، middleware، سلامت |
|
||||||
|
|
||||||
پنل مدیریت:
|
سه قاعده که از خواندنِ یک فایل پیدا نیست:
|
||||||
|
|
||||||
http://127.0.0.1:8000/admin/
|
- **احرازِ هویت سیستمِ خودِ پروژه است.** `accounts.User` عمداً `AUTH_USER_MODEL` نیست؛ توکن با `django.core.signing`
|
||||||
|
امضا میشود (`accounts/authentication.py`). هدرِ نامعتبر یعنی درخواستِ ناشناس، و هر ویو خودش تصمیم میگیرد ورود
|
||||||
|
لازم است یا نه.
|
||||||
|
- **اپها با قرارداد جدا شدهاند، نه با ForeignKey.** ارجاعِ بینِ اپها با `user_id` یا `object_id` + `label` است تا
|
||||||
|
هر اپ بعداً جداشدنی بماند.
|
||||||
|
- **محدودیتِ نرخ متمرکز است.** هر اندپوینتِ حساس یک `throttle_scope` دارد و نرخش در `config/settings.py` است؛ IPِ
|
||||||
|
واقعی فقط از پروکسیهای ArvanCloud پذیرفته میشود.
|
||||||
|
|
||||||
ساختار پروژه
|
شرحِ کامل و دلیلِ هر تصمیم در [`docs/architecture.md`](docs/architecture.md) و
|
||||||
|
[`docs/technical-handbook.md`](docs/technical-handbook.md) آمده است.
|
||||||
|
|
||||||
config/
|
---
|
||||||
accounts/
|
|
||||||
content/
|
|
||||||
progress/
|
|
||||||
ai_tutor/
|
|
||||||
subscription/
|
|
||||||
payment/
|
|
||||||
discount/
|
|
||||||
affiliate/
|
|
||||||
support/
|
|
||||||
notifications/
|
|
||||||
family/
|
|
||||||
|
|
||||||
بخشهای مختلف پروژه شامل مدیریت حساب کاربری، محتوای آموزشی، پیشرفت و عملکرد کاربر، سیستم آموزشی، اشتراک، پرداخت، تخفیف، همکاری در فروش، پشتیبانی، اعلانها و مدیریت اعضای خانواده هستند.
|
## استقرار
|
||||||
|
|
||||||
نکات فنی
|
سرویس روی یک سرورِ مشترکِ شرکت اجرا میشود و مالِ این پروژه فقط پوشهی `~/mathapp-v2` و سرویسِ
|
||||||
|
`zareian-mathapp` است. کلِ انتشار:
|
||||||
|
|
||||||
* ساختار پروژه بهصورت ماژولار و قابل توسعه طراحی شده است.
|
```bash
|
||||||
* APIها از طریق مسیرهای مشخص و مستند قابل دسترسی هستند.
|
ssh -t administrator@78.38.35.249 'cd ~/mathapp-v2 && ./deploy.sh; sudo systemctl restart zareian-mathapp'
|
||||||
* احراز هویت و مدیریت Session برای استفاده از APIها در نظر گرفته شده است.
|
```
|
||||||
* پنل مدیریت برای مدیریت دادههای اصلی سیستم فراهم شده است.
|
|
||||||
* Migrationهای مربوط به مدلهای پروژه در هر اپ نگهداری میشوند.
|
|
||||||
* ساختار فعلی پروژه امکان توسعه و اضافه شدن قابلیتهای جدید را بدون ایجاد تغییرات گسترده در بخشهای موجود فراهم میکند.
|
|
||||||
|
|
||||||
تست
|
`deploy.sh` کد را با `origin/main` یکی میکند، وابستگیها را نصب میکند، پیش از `migrate` از دیتابیس بکاپ میگیرد،
|
||||||
|
محتوا را seed و فایلهای استاتیک را جمع میکند و سلامتِ سرویس را میسنجد. سپس از بیرون:
|
||||||
|
|
||||||
برای بررسی و تست APIها میتوان مستقیماً از Swagger استفاده کرد. همچنین برای ایجاد و مدیریت دادههای اولیه، پنل مدیریت Django در دسترس است.
|
```bash
|
||||||
|
curl https://mathapi.tadrisino2.ir/api/health # {"status": "ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
هر تغییرِ دیتابیس لحظهای به سرورِ بکاپِ جداگانهای میرود. رویهی کامل (متغیرهای محیطی، بکاپ و بازیابی، nginx،
|
||||||
|
عوض کردنِ دامنه، فروش در کافهبازار): [`docs/operations.md`](docs/operations.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مستندات
|
||||||
|
|
||||||
|
| سند | موضوع |
|
||||||
|
|---|---|
|
||||||
|
| [`docs/technical-handbook.md`](docs/technical-handbook.md) · [PDF](docs/technical-handbook.pdf) | مستندِ فنیِ کامل برای تحویل و پیوستنِ توسعهدهندهی تازه |
|
||||||
|
| [`docs/architecture.md`](docs/architecture.md) | تصمیمهای معماری و دلیلشان |
|
||||||
|
| [`docs/operations.md`](docs/operations.md) | استقرار، پیکربندی، سلامت، بکاپ و بازیابی، nginx، پرداخت |
|
||||||
|
| [`docs/security-before-production.md`](docs/security-before-production.md) | آنچه پیش از کاربرِ واقعی باید بسته شود، و حفاظهای موجود |
|
||||||
|
| [`docs/content-authoring.md`](docs/content-authoring.md) | ویرایشِ محتوا از پنلِ ادمین و ساختِ انبوهِ سوال |
|
||||||
|
| [`docs/extraction-playbook.md`](docs/extraction-playbook.md) | خواندنِ کتابِ درسی با مدلِ تصویری، هزینه و انتخابِ مدل |
|
||||||
|
| [`docs/ai-cost-and-guardrails.md`](docs/ai-cost-and-guardrails.md) · [`docs/model-prices.md`](docs/model-prices.md) | هزینهی دستیار، حاشیهی سودِ اشتراکها، دفترِ قیمتِ مدلها |
|
||||||
|
| [`tools/loadtest/README.md`](tools/loadtest/README.md) | آزمونِ بار |
|
||||||
|
| [`CLAUDE.md`](CLAUDE.md) | راهنمای agentهای کدنویس، بهویژه قواعدِ کار روی سرورِ مشترک |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## وضعیت
|
||||||
|
|
||||||
|
سرویس زنده است و با حسابهای آزمونگر سنجیده میشود؛ هنوز به روی کاربرِ عمومی باز نشده. آنچه پیش از آن باید بسته
|
||||||
|
شود در [`docs/security-before-production.md`](docs/security-before-production.md) فهرست شده است. پرداختِ واقعی (زرینپال
|
||||||
|
و کافهبازار) تا انتهای مسیر هنوز آزموده نشده است.
|
||||||
|
|||||||
45
accounts/activity.py
Normal file
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
|
||||||
@@ -1,27 +1,347 @@
|
|||||||
from django.contrib import admin
|
from datetime import datetime
|
||||||
|
|
||||||
from .models import OTPCode, Profile, Session, User
|
from django import forms
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib import admin, messages
|
||||||
|
from django.contrib.admin import helpers
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .models import LoginEvent, OTPCode, Profile, Session, User
|
||||||
|
from .services import SMSDeliveryError, get_inactive_users, send_sms
|
||||||
|
|
||||||
|
|
||||||
|
class SendSMSForm(forms.Form):
|
||||||
|
TARGET_SELECTED = 'selected'
|
||||||
|
TARGET_INACTIVE = 'inactive'
|
||||||
|
TARGET_CHOICES = [
|
||||||
|
(TARGET_SELECTED, 'کاربرانِ انتخابشده (همانهایی که در لیست تیک خوردهاند)'),
|
||||||
|
(TARGET_INACTIVE, 'کاربرانِ غیرفعال (بر اساسِ تاریخِ آخرین فعالیت)'),
|
||||||
|
]
|
||||||
|
|
||||||
|
message = forms.CharField(
|
||||||
|
label='متن پیامک', widget=forms.Textarea(attrs={'rows': 4}), max_length=500,
|
||||||
|
)
|
||||||
|
target = forms.ChoiceField(
|
||||||
|
label='گیرندگان',
|
||||||
|
choices=TARGET_CHOICES,
|
||||||
|
widget=forms.RadioSelect,
|
||||||
|
initial=TARGET_SELECTED,
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
inactive_since = forms.DateField(
|
||||||
|
label='غیرفعال از این تاریخ به بعد',
|
||||||
|
help_text='فقط وقتی گزینهی «کاربرانِ غیرفعال» انتخاب شده باشد استفاده میشود.',
|
||||||
|
required=False,
|
||||||
|
widget=forms.DateInput(attrs={'type': 'date'}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
target = cleaned_data.get('target')
|
||||||
|
inactive_since = cleaned_data.get('inactive_since')
|
||||||
|
if target == self.TARGET_INACTIVE and not inactive_since:
|
||||||
|
self.add_error(
|
||||||
|
'inactive_since',
|
||||||
|
'برای گیرندگانِ «کاربرانِ غیرفعال» وارد کردنِ تاریخ الزامی است.',
|
||||||
|
)
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
@admin.register(User)
|
@admin.register(User)
|
||||||
class UserAdmin(admin.ModelAdmin):
|
class UserAdmin(admin.ModelAdmin):
|
||||||
list_display = ('id', 'name', 'phone', 'device_id', 'is_guest', 'grade_id', 'created_at')
|
list_display = ('id', 'public_id', 'name', 'phone', 'grade_id', 'created_at', 'activity_link')
|
||||||
search_fields = ('name', 'phone', 'device_id')
|
search_fields = ('name', 'phone')
|
||||||
list_filter = ('is_guest',)
|
|
||||||
|
@admin.display(description='پرونده')
|
||||||
|
def activity_link(self, obj):
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.html import format_html
|
||||||
|
return format_html('<a href="{}">فعالیت</a>', reverse('admin:accounts_user_activity', args=[obj.pk]))
|
||||||
|
|
||||||
|
def get_urls(self):
|
||||||
|
from django.urls import path
|
||||||
|
return [
|
||||||
|
path('<int:user_id>/activity/', self.admin_site.admin_view(self.activity_view),
|
||||||
|
name='accounts_user_activity'),
|
||||||
|
] + super().get_urls()
|
||||||
|
|
||||||
|
def activity_view(self, request, user_id):
|
||||||
|
"""ورود/خروج و خریدهای یک کاربر؛ ?format=csv برای اکسل. فقط با اجازهی دیدنِ کاربر و ورود و سفارش."""
|
||||||
|
import csv
|
||||||
|
from django.http import Http404, HttpResponse
|
||||||
|
from django.shortcuts import render
|
||||||
|
from payment.models import Order
|
||||||
|
from .activity import COLUMNS, user_activity
|
||||||
|
from .public_id import encode
|
||||||
|
|
||||||
|
perms = ('accounts.view_user', 'accounts.view_loginevent', 'payment.view_order')
|
||||||
|
if not all(request.user.has_perm(p) for p in perms):
|
||||||
|
raise PermissionDenied
|
||||||
|
user = User.objects.filter(pk=user_id).first()
|
||||||
|
if user is None:
|
||||||
|
raise Http404
|
||||||
|
rows = user_activity(user)
|
||||||
|
if request.GET.get('format') == 'csv':
|
||||||
|
response = HttpResponse(content_type='text/csv; charset=utf-8')
|
||||||
|
response['Content-Disposition'] = f'attachment; filename="user-{user.pk}-activity.csv"'
|
||||||
|
response.write('\ufeff') # BOM: اکسل بیآن فارسی را خراب میخواند
|
||||||
|
writer = csv.writer(response)
|
||||||
|
writer.writerow(COLUMNS)
|
||||||
|
for r in rows:
|
||||||
|
writer.writerow([r['at_local'], r['kind'], r['detail'], r['amount'], r['ip']])
|
||||||
|
return response
|
||||||
|
return render(request, 'admin/accounts_user_activity.html', {
|
||||||
|
**self.admin_site.each_context(request), 'target': user, 'public_id': encode(user.pk),
|
||||||
|
'rows': rows, 'columns': COLUMNS, 'title': 'پروندهی کاربر',
|
||||||
|
})
|
||||||
|
|
||||||
|
@admin.display(description='شناسهی نمایشی')
|
||||||
|
def public_id(self, obj):
|
||||||
|
from .public_id import encode
|
||||||
|
return encode(obj.pk)
|
||||||
|
|
||||||
|
def get_search_results(self, request, queryset, search_term):
|
||||||
|
"""«User482917» (همان که کاربر در پروفایلش میبیند) هم کاربر را پیدا میکند."""
|
||||||
|
from .public_id import decode
|
||||||
|
|
||||||
|
results, may_have_duplicates = super().get_search_results(request, queryset, search_term)
|
||||||
|
user_id = decode(search_term)
|
||||||
|
if user_id:
|
||||||
|
results |= queryset.filter(pk=user_id)
|
||||||
|
return results, may_have_duplicates
|
||||||
|
actions = ['send_sms_to_selected']
|
||||||
|
|
||||||
|
def get_readonly_fields(self, request, obj=None):
|
||||||
|
# شماره کلیدِ ورود است: کارمندی با change_user که شمارهی کاربری را به شمارهی خودش عوض
|
||||||
|
# کند، کد میگیرد و واردِ همان حساب میشود؛ پس فقط ابرکاربر آن را عوض میکند.
|
||||||
|
fields = tuple(super().get_readonly_fields(request, obj))
|
||||||
|
return fields if request.user.is_superuser else fields + ('phone',)
|
||||||
|
|
||||||
|
# permissions=['change']: بدونِ این، کاربرِ فقط-view (گروهِ Supervisor)
|
||||||
|
# میتونست با POSTِ مستقیم این اکشن رو صدا بزنه بدونِ دیدنِ دکمه در UI؛
|
||||||
|
# این پارامتر باعث میشه ModelAdmin فقط برای change_user در دسترس بذارتش.
|
||||||
|
@admin.action(description='ارسال پیامک به کاربران انتخابشده', permissions=['change'])
|
||||||
|
def send_sms_to_selected(self, request, queryset):
|
||||||
|
"""اکشنِ ادمین برای ارسالِ پیامک به کاربرانِ انتخابشده یا غیرفعال؛
|
||||||
|
گیرنده با فیلدِ «target» انتخاب میشه (cutoffِ «غیرفعال» رو خودِ ادمین
|
||||||
|
در فرم وارد میکنه، چیزی hardcode نیست).
|
||||||
|
|
||||||
|
ارسال کاملاً synchronous و توی همون requestه (نه صفِ پسزمینه)، پس
|
||||||
|
اگر گیرندهها از settings.ADMIN_SMS_MAX_RECIPIENTS بیشتر باشن، اکشن
|
||||||
|
هیچ پیامکی نمیفرسته تا request تایماوت نکنه یا provider overload نشه.
|
||||||
|
شکستِ ارسال برای یک کاربر بقیه را متوقف نمیکنه.
|
||||||
|
"""
|
||||||
|
if 'apply' in request.POST:
|
||||||
|
form = SendSMSForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
message = form.cleaned_data['message']
|
||||||
|
target = form.cleaned_data['target']
|
||||||
|
if target == SendSMSForm.TARGET_INACTIVE:
|
||||||
|
since = timezone.make_aware(
|
||||||
|
datetime.combine(form.cleaned_data['inactive_since'], datetime.min.time())
|
||||||
|
)
|
||||||
|
recipients = get_inactive_users(since)
|
||||||
|
else:
|
||||||
|
recipients = queryset
|
||||||
|
|
||||||
|
max_recipients = settings.ADMIN_SMS_MAX_RECIPIENTS
|
||||||
|
recipients_count = recipients.count()
|
||||||
|
if recipients_count > max_recipients:
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
(
|
||||||
|
f'تعدادِ گیرندگان ({recipients_count} کاربر) بیش از سقفِ مجازِ '
|
||||||
|
f'ارسالِ همزمان از ادمین ({max_recipients} کاربر) است؛ هیچ '
|
||||||
|
f'پیامکی ارسال نشد. لطفاً بازه/انتخاب را محدودتر کنید.'
|
||||||
|
),
|
||||||
|
level=messages.ERROR,
|
||||||
|
)
|
||||||
|
# سقوط به رندرِ مشترکِ پایین (نه return None) چون None از
|
||||||
|
# یک اکشنِ ادمین باعثِ ریدایرکتِ ۳۰۲ میشه، نه رندرِ مستقیم.
|
||||||
|
else:
|
||||||
|
sent_count = 0
|
||||||
|
failed_count = 0
|
||||||
|
skipped_no_phone = 0
|
||||||
|
for user in recipients:
|
||||||
|
if not user.phone:
|
||||||
|
skipped_no_phone += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
send_sms(phone=user.phone, message=message)
|
||||||
|
except SMSDeliveryError:
|
||||||
|
failed_count += 1
|
||||||
|
else:
|
||||||
|
sent_count += 1
|
||||||
|
|
||||||
|
if sent_count:
|
||||||
|
self.message_user(
|
||||||
|
request, f'پیامک با موفقیت به {sent_count} کاربر ارسال شد.',
|
||||||
|
level=messages.SUCCESS,
|
||||||
|
)
|
||||||
|
if failed_count:
|
||||||
|
self.message_user(
|
||||||
|
request, f'ارسالِ پیامک برای {failed_count} کاربر ناموفق بود.',
|
||||||
|
level=messages.ERROR,
|
||||||
|
)
|
||||||
|
if skipped_no_phone:
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
f'{skipped_no_phone} کاربر بدونِ شمارهتلفن رد شدن (پیامک ارسال نشد).',
|
||||||
|
level=messages.WARNING,
|
||||||
|
)
|
||||||
|
# سقوط به رندرِ مشترکِ پایین (نه return None)، همالگو با
|
||||||
|
# شاخهی cap-exceeded.
|
||||||
|
else:
|
||||||
|
form = SendSMSForm()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
**self.admin_site.each_context(request),
|
||||||
|
'title': 'ارسال پیامک به کاربران انتخابشده',
|
||||||
|
'opts': self.model._meta,
|
||||||
|
'users': queryset,
|
||||||
|
'form': form,
|
||||||
|
'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME,
|
||||||
|
}
|
||||||
|
return render(request, 'accounts/send_sms_form.html', context)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Profile)
|
@admin.register(Profile)
|
||||||
class ProfileAdmin(admin.ModelAdmin):
|
class ProfileAdmin(admin.ModelAdmin):
|
||||||
list_display = ('id', 'user', 'birth_year', 'parent_phone')
|
list_display = ('id', 'user', 'birth_date', 'birth_year')
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Session)
|
@admin.register(Session)
|
||||||
class SessionAdmin(admin.ModelAdmin):
|
class SessionAdmin(admin.ModelAdmin):
|
||||||
list_display = ('id', 'user', 'device_id', 'expires_at', 'revoked')
|
list_display = ('id', 'user', 'expires_at', 'revoked')
|
||||||
list_filter = ('revoked',)
|
list_filter = ('revoked',)
|
||||||
|
|
||||||
|
# ساختن/ویرایشِ دستیِ نشست یعنی ساختنِ ورود برای هر کاربر؛ فقط ابرکاربر.
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return request.user.is_superuser
|
||||||
|
|
||||||
|
def has_change_permission(self, request, obj=None):
|
||||||
|
return request.user.is_superuser
|
||||||
|
|
||||||
|
|
||||||
@admin.register(OTPCode)
|
@admin.register(OTPCode)
|
||||||
class OTPCodeAdmin(admin.ModelAdmin):
|
class OTPCodeAdmin(admin.ModelAdmin):
|
||||||
list_display = ('id', 'phone', 'code', 'expires_at', 'is_used')
|
list_display = ('id', 'phone', 'code', 'expires_at', 'is_used')
|
||||||
list_filter = ('is_used',)
|
list_filter = ('is_used',)
|
||||||
|
|
||||||
|
# ردیفِ OTP که کارمند میسازد = ورود بیپیامک به هر شماره (راهِ تستر)؛ فقط ابرکاربر.
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return request.user.is_superuser
|
||||||
|
|
||||||
|
def has_change_permission(self, request, obj=None):
|
||||||
|
return request.user.is_superuser
|
||||||
|
|
||||||
|
|
||||||
|
# صفحهی «ناظر» (Supervisor): یک ایندکسِ read-only از لینکها به
|
||||||
|
# changelistهای موجود (کاربران/پرداختها/اشتراکها)، داخلِ همون AdminSite
|
||||||
|
# پیشفرض — نه AdminSite جدا و بدون هیچ ModelAdmin جدید.
|
||||||
|
#
|
||||||
|
# read-only بودن با پنهونکردنِ لینک تضمین نمیشه، بلکه با پرمیشنِ استانداردِ
|
||||||
|
# جنگو: هر لینک فقط با view_<model> نشون داده میشه و حتی دسترسیِ مستقیم به
|
||||||
|
# URLِ change/add/delete هم توسطِ پرمیشنِ پیشفرضِ همون ModelAdminها رد میشه
|
||||||
|
# (گروهِ Supervisor فقط view_* داره). is_staff بهتنهایی برای ورود کافی
|
||||||
|
# نیست؛ عضویت در گروهِ Supervisor هم چک میشه، وگرنه PermissionDenied.
|
||||||
|
|
||||||
|
SUPERVISOR_GROUP_NAME = 'Supervisor'
|
||||||
|
|
||||||
|
|
||||||
|
def supervisor_dashboard(request):
|
||||||
|
from payment.models import Order, Transaction
|
||||||
|
from subscription.models import Subscription
|
||||||
|
|
||||||
|
if not request.user.groups.filter(name=SUPERVISOR_GROUP_NAME).exists():
|
||||||
|
raise PermissionDenied
|
||||||
|
|
||||||
|
def _link(label, model):
|
||||||
|
opts = model._meta
|
||||||
|
return {
|
||||||
|
'label': label,
|
||||||
|
'url_name': f'admin:{opts.app_label}_{opts.model_name}_changelist',
|
||||||
|
'has_perm': request.user.has_perm(f'{opts.app_label}.view_{opts.model_name}'),
|
||||||
|
}
|
||||||
|
|
||||||
|
sections = [
|
||||||
|
{'title': 'کاربران', 'links': [_link('کاربران', User)]},
|
||||||
|
{'title': 'پرداختها', 'links': [_link('سفارشها', Order), _link('تراکنشها', Transaction)]},
|
||||||
|
{'title': 'اشتراکها', 'links': [_link('اشتراکها', Subscription)]},
|
||||||
|
]
|
||||||
|
|
||||||
|
context = {
|
||||||
|
**admin.site.each_context(request),
|
||||||
|
'title': 'ناظر (Supervisor)',
|
||||||
|
'sections': sections,
|
||||||
|
}
|
||||||
|
return render(request, 'admin/supervisor_dashboard.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
_original_admin_get_urls = admin.site.get_urls
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_get_urls_with_supervisor():
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
custom_urls = [
|
||||||
|
path('supervisor/', admin.site.admin_view(supervisor_dashboard), name='supervisor-dashboard'),
|
||||||
|
]
|
||||||
|
return custom_urls + _original_admin_get_urls()
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.get_urls = _admin_get_urls_with_supervisor
|
||||||
|
|
||||||
|
|
||||||
|
# حسابهای کارمندانِ پنل (auth.User/Group جنگو، جدا از accounts.User). در پیشفرضِ جنگو کارمندی با
|
||||||
|
# change_group همهی اجازهها را به گروهِ خودش میدهد و با change_user (auth) تیکِ ابرکاربر را برای
|
||||||
|
# خودش میزند. ساختن/تغییر/حذفِ این دو فقط برای ابرکاربر است؛ دیدن همان اجازهی view را میخواهد.
|
||||||
|
from django.contrib.auth import get_user_model # noqa: E402
|
||||||
|
from django.contrib.auth.admin import GroupAdmin as _DjangoGroupAdmin # noqa: E402
|
||||||
|
from django.contrib.auth.admin import UserAdmin as _DjangoUserAdmin # noqa: E402
|
||||||
|
from django.contrib.auth.models import Group # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class _SuperuserWritesOnly:
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return request.user.is_superuser
|
||||||
|
|
||||||
|
def has_change_permission(self, request, obj=None):
|
||||||
|
return request.user.is_superuser
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
return request.user.is_superuser
|
||||||
|
|
||||||
|
|
||||||
|
class StaffUserAdmin(_SuperuserWritesOnly, _DjangoUserAdmin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class StaffGroupAdmin(_SuperuserWritesOnly, _DjangoGroupAdmin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.unregister(get_user_model())
|
||||||
|
admin.site.register(get_user_model(), StaffUserAdmin)
|
||||||
|
admin.site.unregister(Group)
|
||||||
|
admin.site.register(Group, StaffGroupAdmin)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(LoginEvent)
|
||||||
|
class LoginEventAdmin(admin.ModelAdmin):
|
||||||
|
"""سابقهی ورود/خروج؛ فقطخواندنی (سابقه نباید از پنل عوض شود)."""
|
||||||
|
list_display = ('created_at', 'event', 'user_id', 'phone', 'ip')
|
||||||
|
list_filter = ('event', 'created_at')
|
||||||
|
search_fields = ('phone', 'user_id', 'ip')
|
||||||
|
date_hierarchy = 'created_at'
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_change_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ class AccountsConfig(AppConfig):
|
|||||||
name = 'accounts'
|
name = 'accounts'
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
# ثبت OpenApiAuthenticationExtension برای دکمهی Authorize توی Swagger UI
|
# Registers OpenApiAuthenticationExtension for the Swagger UI Authorize button.
|
||||||
from . import schema # noqa: F401
|
from . import schema # noqa: F401
|
||||||
|
from . import receivers # noqa: F401 (باطلکردنِ کشِ ردیفِ کاربر)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core import signing
|
from django.core import signing
|
||||||
|
from django.core.cache import cache
|
||||||
from rest_framework.authentication import BaseAuthentication
|
from rest_framework.authentication import BaseAuthentication
|
||||||
from rest_framework.exceptions import AuthenticationFailed
|
from rest_framework.exceptions import AuthenticationFailed
|
||||||
|
|
||||||
@@ -7,6 +8,48 @@ from .models import User
|
|||||||
|
|
||||||
ACCESS_TOKEN_SALT = 'tadrisino-access-token'
|
ACCESS_TOKEN_SALT = 'tadrisino-access-token'
|
||||||
|
|
||||||
|
# فیلدهای غیرِرابطهایِ User که کش میشن؛ همهی concrete fieldها تا بازسازیِ
|
||||||
|
# instance برای save(update_fields=...) امن بمونه.
|
||||||
|
_USER_CACHE_FIELDS = [f.attname for f in User._meta.concrete_fields]
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_user_cache_key(user_id: int) -> str:
|
||||||
|
return f'auth:user:{user_id}'
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_user_cache(user_id: int) -> None:
|
||||||
|
cache.delete(_auth_user_cache_key(user_id))
|
||||||
|
|
||||||
|
|
||||||
|
def _load_user_or_fail(user_id: int) -> User:
|
||||||
|
try:
|
||||||
|
return User.objects.get(pk=user_id)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
raise AuthenticationFailed('کاربر پیدا نشد.')
|
||||||
|
|
||||||
|
|
||||||
|
def _get_request_user(user_id: int) -> User:
|
||||||
|
"""
|
||||||
|
ردیفِ کاربر برای request جاری. با AUTH_USER_CACHE_TTL>0 از کش خونده
|
||||||
|
میشه (یک SELECT کمتر در هر request authenticated). instanceِ بازسازیشده
|
||||||
|
کاملِ همهی فیلدهاست، پس save(update_fields=[...]) روش امنه.
|
||||||
|
"""
|
||||||
|
ttl = getattr(settings, 'AUTH_USER_CACHE_TTL', 0)
|
||||||
|
if not ttl:
|
||||||
|
return _load_user_or_fail(user_id)
|
||||||
|
|
||||||
|
key = _auth_user_cache_key(user_id)
|
||||||
|
data = cache.get(key)
|
||||||
|
if data is None:
|
||||||
|
user = _load_user_or_fail(user_id)
|
||||||
|
cache.set(key, {f: getattr(user, f) for f in _USER_CACHE_FIELDS}, ttl)
|
||||||
|
return user
|
||||||
|
|
||||||
|
user = User(**data)
|
||||||
|
user._state.adding = False
|
||||||
|
user._state.db = 'default'
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
def issue_access_token(user_id: int) -> str:
|
def issue_access_token(user_id: int) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -28,6 +71,14 @@ def decode_access_token(token: str) -> int:
|
|||||||
return int(value)
|
return int(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _issued_at(token: str) -> int:
|
||||||
|
"""
|
||||||
|
ثانیهی صدورِ توکن. قالبِ TimestampSigner «مقدار:زمان:امضا» است و زمان
|
||||||
|
base62؛ فقط بعد از unsignِ موفق صدا زده میشود، پس ساختار معتبر است.
|
||||||
|
"""
|
||||||
|
return signing.b62_decode(token.rsplit(':', 2)[-2])
|
||||||
|
|
||||||
|
|
||||||
class TadrisinoTokenAuthentication(BaseAuthentication):
|
class TadrisinoTokenAuthentication(BaseAuthentication):
|
||||||
"""
|
"""
|
||||||
هدر مورد انتظار: Authorization: Bearer <access_token>
|
هدر مورد انتظار: Authorization: Bearer <access_token>
|
||||||
@@ -48,12 +99,9 @@ class TadrisinoTokenAuthentication(BaseAuthentication):
|
|||||||
|
|
||||||
token = parts[1]
|
token = parts[1]
|
||||||
user_id = decode_access_token(token)
|
user_id = decode_access_token(token)
|
||||||
|
user = _get_request_user(user_id)
|
||||||
try:
|
if user.tokens_valid_after is not None and _issued_at(token) < int(user.tokens_valid_after.timestamp()):
|
||||||
user = User.objects.get(pk=user_id)
|
raise AuthenticationFailed('از حساب خارج شدهاید؛ دوباره وارد شوید.')
|
||||||
except User.DoesNotExist:
|
|
||||||
raise AuthenticationFailed('کاربر پیدا نشد.')
|
|
||||||
|
|
||||||
return (user, token)
|
return (user, token)
|
||||||
|
|
||||||
def authenticate_header(self, request):
|
def authenticate_header(self, request):
|
||||||
|
|||||||
107
accounts/jalali.py
Normal file
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)} کاربر و همهی دادهشان پاک شدند.'))
|
||||||
434
accounts/management/commands/seed_test_data.py
Normal file
434
accounts/management/commands/seed_test_data.py
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
"""
|
||||||
|
یک دیتاست کوچک و واقعی برای تست دستی/API لوکال یا staging میسازه، طوری که
|
||||||
|
اجرای مکرر (repeat) هیچ رکورد تکراریای نسازه — همهچیز با get_or_create روی
|
||||||
|
یک کلید طبیعی/یکتا (phone، code، idempotency_key و ...) انجام
|
||||||
|
میشه، نه با create ساده.
|
||||||
|
|
||||||
|
هدف: پوشش کمینهی جریانهای اصلی API در هر ۱۰ اپ (accounts، content،
|
||||||
|
progress، ai_tutor، subscription، payment، discount، notifications،
|
||||||
|
affiliate، support) با کمترین حجم دادهی لازم برای اینکه هر
|
||||||
|
endpoint اصلی بدون نیاز به ساختن دستی پیشنیازهاش قابل تست باشه.
|
||||||
|
|
||||||
|
⚠️ این دستور فقط برای محیط لوکال/staging است، نه پروداکشن:
|
||||||
|
- هیچ سکرت واقعی (کلید API واقعی کاوهنگار، gateway واقعی و ...) نمیسازه؛
|
||||||
|
Order/Transaction با provider/gateway_ref_id ساختگی و مقدارِ ثابت ساخته
|
||||||
|
میشن (شبیهسازیِ یک پرداختِ موفق، نه فراخوانیِ واقعیِ درگاه).
|
||||||
|
- برای جلوگیری از اجرای تصادفی روی یک دیتابیسِ پروداکشن، پیشفرض فقط وقتی
|
||||||
|
DEBUG=True اجرا میشه؛ برای اجرای صریح در حالت DEBUG=False (مثلاً یک
|
||||||
|
محیط staging که عمداً DEBUG خاموش نگه داشته میشه) باید `--force` بدید.
|
||||||
|
|
||||||
|
اجرا:
|
||||||
|
|
||||||
|
python manage.py seed_test_data
|
||||||
|
python manage.py seed_test_data --force # وقتی DEBUG=False (staging)
|
||||||
|
python manage.py seed_test_data --reset # پاککردن دیتای قبلیِ همین seed و ساختِ دوباره
|
||||||
|
|
||||||
|
idempotent است: اجرای دوباره (بدون --reset) نه رکورد تکراری میسازه و نه
|
||||||
|
خطا میده؛ فقط همون رکوردهای قبلی رو پیدا میکنه (get_or_create) و دوباره
|
||||||
|
گزارششون میده. تمام رکوردهای ساختهشده با پیشوند «[SEED]» در فیلدهای
|
||||||
|
متنیِ قابلمشاهده (عنوان/نام) مشخص شدن تا با دیتای واقعی اشتباه گرفته
|
||||||
|
نشن و پاکسازیِ دستی هم راحت باشه.
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from accounts.authentication import issue_access_token
|
||||||
|
from accounts.models import OTPCode, Profile, Session, User, hash_refresh_token
|
||||||
|
|
||||||
|
# --- شناسههای ثابتِ دادهی seed --------------------------------------------
|
||||||
|
# شمارههای 0912000000X عمداً خارج از بازهی واقعیِ اپراتورهای ایران نیستن؛
|
||||||
|
# چون این فقط دیتای لوکال/staging است نه پروداکشن، همین کافیه. با پیشوند
|
||||||
|
# یکسان و بازهی اختصاصی، شناساییِ رکوردهای seed در دیتابیس هم ساده میمونه.
|
||||||
|
MAIN_PHONE = '09120000001'
|
||||||
|
REFERRED_PHONE = '09120000004'
|
||||||
|
|
||||||
|
SEED_TAG = '[SEED]'
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = (
|
||||||
|
'یک دیتاست کوچک و idempotent برای تستِ دستی/API روی محیطِ لوکال یا '
|
||||||
|
'staging میسازه (کاربر، محتوای درسی، پیشرفت، اشتراک/پرداخت، تخفیف، '
|
||||||
|
'دستیار هوش مصنوعی، اعلان، افیلیت، پشتیبانی، خانواده). اجرای مکرر '
|
||||||
|
'رکورد تکراری نمیسازه.'
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--force',
|
||||||
|
action='store_true',
|
||||||
|
help='اجرای دستور حتی وقتی DEBUG=False است (مثلاً staging عمدی).',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--reset',
|
||||||
|
action='store_true',
|
||||||
|
help='قبل از ساخت، فقط همون رکوردهای seed (بر اساس شماره/شناسههای ثابتِ بالا) حذف میشه.',
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
if not settings.DEBUG and not options['force']:
|
||||||
|
raise CommandError(
|
||||||
|
'DEBUG=False است — این دستور برای دیتای پروداکشن نیست. اگه '
|
||||||
|
'واقعاً میخواید روی این محیط (مثلاً staging) اجرا بشه، '
|
||||||
|
'--force بدید.'
|
||||||
|
)
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
if options['reset']:
|
||||||
|
self._reset()
|
||||||
|
summary = self._seed()
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS('\nseed_test_data با موفقیت تمام شد.'))
|
||||||
|
self.stdout.write('\nکاربرهای تستی (برای Authorize در Swagger، فقط خودِ token را بدون "Bearer" وارد کنید):')
|
||||||
|
for label, user, token in summary['tokens']:
|
||||||
|
self.stdout.write(f' - {label}: user_id={user.pk} phone={user.phone or "-"} token={token}')
|
||||||
|
self.stdout.write(
|
||||||
|
'\nنکته: این توکنها با ACCESS_TOKEN_TTL_SECONDS فعلی امضا شدن و بعد از انقضا '
|
||||||
|
'باید دوباره seed یا از /api/auth/login گرفته بشن.'
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- reset ----------------------------------------------------------
|
||||||
|
def _reset(self):
|
||||||
|
"""فقط دقیقاً همون رکوردهایی که این دستور میسازه رو (بر اساس کلیدهای ثابتِ بالا) حذف میکنه."""
|
||||||
|
from ai_tutor.models import Conversation
|
||||||
|
from affiliate.models import AffiliateAccount, Conversion
|
||||||
|
from content.models import Grade, NodeProgress, UserAnswer, UserLessonCompletion
|
||||||
|
from discount.models import DiscountCode, Offer, Redemption, UserOffer
|
||||||
|
from notifications.models import Notification
|
||||||
|
from payment.models import Order
|
||||||
|
from progress.models import LeaderboardEntry, StreakRecord, UserHearts, UserStats
|
||||||
|
from subscription.models import Plan, Subscription
|
||||||
|
from support.models import BugReport, Ticket
|
||||||
|
|
||||||
|
phones = [MAIN_PHONE, REFERRED_PHONE]
|
||||||
|
user_ids = list(User.objects.filter(phone__in=phones).values_list('pk', flat=True))
|
||||||
|
|
||||||
|
if user_ids:
|
||||||
|
UserAnswer.objects.filter(user_id__in=user_ids).delete()
|
||||||
|
UserLessonCompletion.objects.filter(user_id__in=user_ids).delete()
|
||||||
|
NodeProgress.objects.filter(user_id__in=user_ids).delete()
|
||||||
|
UserStats.objects.filter(user_id__in=user_ids).delete()
|
||||||
|
LeaderboardEntry.objects.filter(user_id__in=user_ids).delete()
|
||||||
|
StreakRecord.objects.filter(user_id__in=user_ids).delete()
|
||||||
|
UserHearts.objects.filter(user_id__in=user_ids).delete()
|
||||||
|
Conversation.objects.filter(user_id__in=user_ids).delete()
|
||||||
|
Subscription.objects.filter(user_id__in=user_ids).delete()
|
||||||
|
Order.objects.filter(user_id__in=user_ids).delete()
|
||||||
|
UserOffer.objects.filter(user_id__in=user_ids).delete()
|
||||||
|
Redemption.objects.filter(user_id__in=user_ids).delete()
|
||||||
|
Notification.objects.filter(user_id__in=user_ids).delete()
|
||||||
|
AffiliateAccount.objects.filter(user_id__in=user_ids).delete()
|
||||||
|
Conversion.objects.filter(referred_user_id__in=user_ids).delete()
|
||||||
|
Ticket.objects.filter(user_id__in=user_ids).delete()
|
||||||
|
BugReport.objects.filter(user_id__in=user_ids).delete()
|
||||||
|
|
||||||
|
from progress.models import Character
|
||||||
|
Character.objects.filter(slug__startswith='seed-').delete()
|
||||||
|
|
||||||
|
Plan.objects.filter(title__startswith=SEED_TAG).delete()
|
||||||
|
DiscountCode.objects.filter(code='SEEDTEST10').delete()
|
||||||
|
Offer.objects.filter(code='SEEDTRIAL7').delete()
|
||||||
|
Grade.objects.filter(title__startswith=SEED_TAG).delete() # cascades units/nodes/lessons/questions/choices
|
||||||
|
|
||||||
|
OTPCode.objects.filter(phone__in=phones).delete()
|
||||||
|
Session.objects.filter(user__phone__in=phones).delete()
|
||||||
|
User.objects.filter(phone__in=phones).delete()
|
||||||
|
|
||||||
|
self.stdout.write('رکوردهای قبلیِ seed حذف شدن؛ در حال ساختِ دوباره...')
|
||||||
|
|
||||||
|
# -- seed -------------------------------------------------------------
|
||||||
|
def _seed(self):
|
||||||
|
main_user, referred_user = self._seed_accounts()
|
||||||
|
self._seed_content_and_progress(main_user)
|
||||||
|
self._seed_characters()
|
||||||
|
self._seed_ai_tutor(main_user)
|
||||||
|
plan_monthly, plan_yearly = self._seed_subscription_and_payment(main_user)
|
||||||
|
self._seed_discount(main_user)
|
||||||
|
self._seed_notifications(main_user)
|
||||||
|
self._seed_affiliate(main_user, referred_user)
|
||||||
|
self._seed_support(main_user)
|
||||||
|
|
||||||
|
tokens = [
|
||||||
|
('کاربر اصلی', main_user, issue_access_token(main_user.pk)),
|
||||||
|
]
|
||||||
|
return {'tokens': tokens}
|
||||||
|
|
||||||
|
# -- accounts -----------------------------------------------------------
|
||||||
|
def _seed_accounts(self):
|
||||||
|
main_user, _ = User.objects.get_or_create(
|
||||||
|
phone=MAIN_PHONE,
|
||||||
|
defaults=dict(name=f'{SEED_TAG} کاربر اصلی', grade_id=None),
|
||||||
|
)
|
||||||
|
Profile.objects.get_or_create(
|
||||||
|
user=main_user, defaults=dict(birth_year=1394, bio=f'{SEED_TAG} پروفایل تستی')
|
||||||
|
)
|
||||||
|
Session.objects.get_or_create(
|
||||||
|
user=main_user,
|
||||||
|
refresh_token=hash_refresh_token(f'seed-refresh-{MAIN_PHONE}'),
|
||||||
|
defaults=dict(
|
||||||
|
expires_at=timezone.now() + timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
|
||||||
|
revoked=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
referred_user, _ = User.objects.get_or_create(
|
||||||
|
phone=REFERRED_PHONE, defaults=dict(name=f'{SEED_TAG} کاربر معرفیشده')
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS('accounts: ۲ کاربر (اصلی/معرفیشده) آماده شد.'))
|
||||||
|
return main_user, referred_user
|
||||||
|
|
||||||
|
# -- content + progress --------------------------------------------------
|
||||||
|
def _seed_content_and_progress(self, main_user):
|
||||||
|
from content.models import Choice, Grade, Lesson, Node, NodeProgress, Question, Unit, UserAnswer, UserLessonCompletion
|
||||||
|
from progress.models import ActiveDay, LeaderboardEntry, StreakRecord, UserHearts, UserStats
|
||||||
|
|
||||||
|
grade, _ = Grade.objects.get_or_create(
|
||||||
|
title=f'{SEED_TAG} پایه سوم', defaults=dict(icon='grade-3', color='#4CAF50')
|
||||||
|
)
|
||||||
|
unit, _ = Unit.objects.get_or_create(grade=grade, title=f'{SEED_TAG} جمع و تفریق', defaults=dict(order=1))
|
||||||
|
|
||||||
|
node1, _ = Node.objects.get_or_create(unit=unit, title=f'{SEED_TAG} جمع اعداد یکرقمی', defaults=dict(order=1))
|
||||||
|
node2, _ = Node.objects.get_or_create(unit=unit, title=f'{SEED_TAG} تفریق اعداد یکرقمی', defaults=dict(order=2))
|
||||||
|
|
||||||
|
lesson1, _ = Lesson.objects.get_or_create(
|
||||||
|
node=node1,
|
||||||
|
title=f'{SEED_TAG} درس اول جمع',
|
||||||
|
defaults=dict(intro_text='دو عدد را با هم جمع کن.', mascot_animation='explain'),
|
||||||
|
)
|
||||||
|
lesson2, _ = Lesson.objects.get_or_create(
|
||||||
|
node=node2,
|
||||||
|
title=f'{SEED_TAG} درس اول تفریق',
|
||||||
|
defaults=dict(intro_text='یک عدد را از عدد دیگر کم کن.', mascot_animation='explain'),
|
||||||
|
)
|
||||||
|
|
||||||
|
question1, _ = Question.objects.get_or_create(
|
||||||
|
lesson=lesson1, prompt=f'{SEED_TAG} 2 + 3 = ?', defaults=dict(type='multiple_choice', asset_group='')
|
||||||
|
)
|
||||||
|
choices1 = [('4', False), ('5', True), ('6', False), ('7', False)]
|
||||||
|
correct_choice1 = None
|
||||||
|
for value, is_correct in choices1:
|
||||||
|
choice, _ = Choice.objects.get_or_create(question=question1, value=value, defaults=dict(is_correct=is_correct))
|
||||||
|
if is_correct:
|
||||||
|
correct_choice1 = choice
|
||||||
|
|
||||||
|
question2, _ = Question.objects.get_or_create(
|
||||||
|
lesson=lesson2, prompt=f'{SEED_TAG} 9 - 4 = ?', defaults=dict(type='multiple_choice', asset_group='')
|
||||||
|
)
|
||||||
|
for value, is_correct in [('4', False), ('5', True), ('6', False)]:
|
||||||
|
Choice.objects.get_or_create(question=question2, value=value, defaults=dict(is_correct=is_correct))
|
||||||
|
|
||||||
|
# پیشرفتِ کاربرِ اصلی: درسِ اول تمامشده، Nodeِ اول in_progress
|
||||||
|
UserAnswer.objects.get_or_create(
|
||||||
|
user_id=main_user.pk,
|
||||||
|
question=question1,
|
||||||
|
idempotency_key='seed-answer-1',
|
||||||
|
defaults=dict(choice=correct_choice1, correct=True, xp_earned=10),
|
||||||
|
)
|
||||||
|
UserLessonCompletion.objects.get_or_create(
|
||||||
|
user_id=main_user.pk,
|
||||||
|
lesson=lesson1,
|
||||||
|
idempotency_key='seed-completion-1',
|
||||||
|
defaults=dict(correct_count=1, stars=3, xp_earned=10, coins_earned=5),
|
||||||
|
)
|
||||||
|
NodeProgress.objects.get_or_create(
|
||||||
|
user_id=main_user.pk,
|
||||||
|
node=node1,
|
||||||
|
defaults=dict(
|
||||||
|
status='in_progress',
|
||||||
|
lessons_completed_count=1,
|
||||||
|
started_at=timezone.now() - timedelta(days=1),
|
||||||
|
last_activity_at=timezone.now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
NodeProgress.objects.get_or_create(
|
||||||
|
user_id=main_user.pk,
|
||||||
|
node=node2,
|
||||||
|
defaults=dict(status='not_started', lessons_completed_count=0),
|
||||||
|
)
|
||||||
|
|
||||||
|
stats, _ = UserStats.objects.get_or_create(
|
||||||
|
user_id=main_user.pk, defaults=dict(xp=10, coins=5, progress_percent=12.5)
|
||||||
|
)
|
||||||
|
streak, _ = StreakRecord.objects.get_or_create(
|
||||||
|
user_id=main_user.pk, defaults=dict(current_streak=1, longest_streak=1)
|
||||||
|
)
|
||||||
|
ActiveDay.objects.get_or_create(streak=streak, date=timezone.now().date())
|
||||||
|
UserHearts.objects.get_or_create(user_id=main_user.pk, defaults=dict(current_hearts=5, max_hearts=5))
|
||||||
|
LeaderboardEntry.objects.get_or_create(
|
||||||
|
user_id=main_user.pk, period='weekly', defaults=dict(xp_in_period=10, rank=1)
|
||||||
|
)
|
||||||
|
LeaderboardEntry.objects.get_or_create(
|
||||||
|
user_id=main_user.pk, period='all_time', defaults=dict(xp_in_period=10, rank=1)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
'content/progress: 1 پایه، 1 مبحث، 2 Node، 2 درس، 2 سوال و پیشرفتِ کاربرِ اصلی آماده شد.'
|
||||||
|
))
|
||||||
|
|
||||||
|
# -- گیمیفیکیشن: کاراکترها -------------------------------------------------
|
||||||
|
def _seed_characters(self):
|
||||||
|
from progress.models import Character
|
||||||
|
|
||||||
|
# unlock_xp پلکانی، هماهنگ با xp=10ی کاربرِ اصلی که بالاتر ساخته شد:
|
||||||
|
# فقط اولین کاراکتر (unlock_xp=0) براش باز است، بقیه هنوز قفلن —
|
||||||
|
# برای اینکه هم مسیرِ «باز» و هم مسیرِ «قفل» در تست دستی قابلدیدن باشه.
|
||||||
|
characters = [
|
||||||
|
('seed-owl', f'{SEED_TAG} جغد کنجکاو', 'owl', 0, 1),
|
||||||
|
('seed-fox', f'{SEED_TAG} روباه باهوش', 'fox', 100, 2),
|
||||||
|
('seed-turtle', f'{SEED_TAG} لاکپشت صبور', 'turtle', 300, 3),
|
||||||
|
('seed-lion', f'{SEED_TAG} شیر شجاع', 'lion', 600, 4),
|
||||||
|
]
|
||||||
|
for slug, name, icon, unlock_xp, order in characters:
|
||||||
|
Character.objects.get_or_create(
|
||||||
|
slug=slug, defaults=dict(name=name, icon=icon, unlock_xp=unlock_xp, order=order),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(f'progress: {len(characters)} کاراکترِ گیمیفیکیشن آماده شد.'))
|
||||||
|
|
||||||
|
# -- ai_tutor -------------------------------------------------------------
|
||||||
|
def _seed_ai_tutor(self, main_user):
|
||||||
|
from ai_tutor.models import Conversation, Message, MessageFeedback
|
||||||
|
|
||||||
|
conversation, _ = Conversation.objects.get_or_create(
|
||||||
|
user_id=main_user.pk, lesson_id=None, chapter_id=None, grade_id=None
|
||||||
|
)
|
||||||
|
user_msg, _ = Message.objects.get_or_create(
|
||||||
|
conversation=conversation,
|
||||||
|
role='user',
|
||||||
|
text=f'{SEED_TAG} چرا حاصل 2+3 برابر 5 است؟',
|
||||||
|
)
|
||||||
|
assistant_msg, created = Message.objects.get_or_create(
|
||||||
|
conversation=conversation,
|
||||||
|
role='assistant',
|
||||||
|
text=f'{SEED_TAG} چون وقتی 2 واحد و 3 واحد را کنار هم بگذاریم، 5 واحد میشود.',
|
||||||
|
)
|
||||||
|
MessageFeedback.objects.get_or_create(message=assistant_msg, defaults=dict(is_helpful=True))
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS('ai_tutor: 1 مکالمه با یک پیامِ کاربر و یک پاسخِ دستیار آماده شد.'))
|
||||||
|
|
||||||
|
# -- subscription + payment -----------------------------------------------
|
||||||
|
def _seed_subscription_and_payment(self, main_user):
|
||||||
|
from payment.models import Order, Transaction
|
||||||
|
from subscription.models import Plan, Subscription
|
||||||
|
|
||||||
|
plan_monthly, _ = Plan.objects.get_or_create(
|
||||||
|
title=f'{SEED_TAG} ماهانه',
|
||||||
|
defaults=dict(price=99000, currency='IRT', discount_percent=0, popular=False, duration_days=30),
|
||||||
|
)
|
||||||
|
plan_yearly, _ = Plan.objects.get_or_create(
|
||||||
|
title=f'{SEED_TAG} سالانه',
|
||||||
|
defaults=dict(price=799000, currency='IRT', discount_percent=20, popular=True, duration_days=365),
|
||||||
|
)
|
||||||
|
|
||||||
|
order, _ = Order.objects.get_or_create(
|
||||||
|
idempotency_key='seed-order-1',
|
||||||
|
defaults=dict(
|
||||||
|
user_id=main_user.pk,
|
||||||
|
amount=plan_monthly.price,
|
||||||
|
currency='IRT',
|
||||||
|
status='paid',
|
||||||
|
provider='gateway',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Transaction.objects.get_or_create(
|
||||||
|
order=order,
|
||||||
|
gateway='zarinpal',
|
||||||
|
gateway_ref_id='SEED-REF-0001',
|
||||||
|
defaults=dict(status='paid', paid_at=timezone.now()),
|
||||||
|
)
|
||||||
|
subscription, _ = Subscription.objects.get_or_create(
|
||||||
|
user_id=main_user.pk,
|
||||||
|
defaults=dict(
|
||||||
|
plan=plan_monthly,
|
||||||
|
status='active',
|
||||||
|
expires_at=timezone.now() + timedelta(days=plan_monthly.duration_days),
|
||||||
|
order_id=order.pk,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if order.subscription_id != subscription.pk:
|
||||||
|
order.subscription_id = subscription.pk
|
||||||
|
order.save(update_fields=['subscription_id'])
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
'subscription/payment: 2 پلن، 1 سفارشِ پرداختشده و 1 اشتراکِ فعال آماده شد.'
|
||||||
|
))
|
||||||
|
return plan_monthly, plan_yearly
|
||||||
|
|
||||||
|
# -- discount ---------------------------------------------------------------
|
||||||
|
def _seed_discount(self, main_user):
|
||||||
|
from discount.models import DiscountCode, Offer
|
||||||
|
|
||||||
|
DiscountCode.objects.get_or_create(
|
||||||
|
code='SEEDTEST10',
|
||||||
|
defaults=dict(type='percent', value=10, max_uses=100, expires_at=timezone.now() + timedelta(days=90)),
|
||||||
|
)
|
||||||
|
Offer.objects.get_or_create(
|
||||||
|
code='SEEDTRIAL7',
|
||||||
|
defaults=dict(
|
||||||
|
name=f'{SEED_TAG} هفت روز رایگان',
|
||||||
|
kind='trial',
|
||||||
|
duration_days=7,
|
||||||
|
auto_assign_on_signup=False,
|
||||||
|
is_active=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS('discount: 1 کدتخفیفِ فعال و 1 آفرِ trial آماده شد.'))
|
||||||
|
|
||||||
|
# -- notifications ------------------------------------------------------------
|
||||||
|
def _seed_notifications(self, main_user):
|
||||||
|
from notifications.models import Notification
|
||||||
|
|
||||||
|
Notification.objects.get_or_create(
|
||||||
|
user_id=main_user.pk,
|
||||||
|
type='xp_earned',
|
||||||
|
title=f'{SEED_TAG} امتیاز گرفتی',
|
||||||
|
defaults=dict(body='10 امتیاز از درس اول جمع گرفتی!', related_id=None, is_read=False),
|
||||||
|
)
|
||||||
|
Notification.objects.get_or_create(
|
||||||
|
user_id=main_user.pk,
|
||||||
|
type='order_paid',
|
||||||
|
title=f'{SEED_TAG} پرداخت موفق',
|
||||||
|
defaults=dict(body='اشتراک ماهانهت فعال شد.', related_id=None, is_read=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS('notifications: 2 اعلان (یکی خواندهنشده، یکی خواندهشده) آماده شد.'))
|
||||||
|
|
||||||
|
# -- affiliate ------------------------------------------------------------------
|
||||||
|
def _seed_affiliate(self, main_user, referred_user):
|
||||||
|
from affiliate.models import AffiliateAccount, Conversion
|
||||||
|
|
||||||
|
account, _ = AffiliateAccount.objects.get_or_create(
|
||||||
|
user_id=main_user.pk, defaults=dict(referral_code='SEEDREF01', commission_rate=0.1)
|
||||||
|
)
|
||||||
|
Conversion.objects.get_or_create(
|
||||||
|
referred_user_id=referred_user.pk,
|
||||||
|
defaults=dict(affiliate_account=account, status='pending', commission_amount=0),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS('affiliate: 1 حساب افیلیت و 1 کانورژنِ در انتظار آماده شد.'))
|
||||||
|
|
||||||
|
# -- support ----------------------------------------------------------------------
|
||||||
|
def _seed_support(self, main_user):
|
||||||
|
from support.models import BugReport, Ticket, TicketMessage
|
||||||
|
|
||||||
|
ticket, _ = Ticket.objects.get_or_create(
|
||||||
|
user_id=main_user.pk,
|
||||||
|
subject=f'{SEED_TAG} مشکل در نمایش درس',
|
||||||
|
defaults=dict(category='content', status='open', priority='normal'),
|
||||||
|
)
|
||||||
|
TicketMessage.objects.get_or_create(
|
||||||
|
ticket=ticket, sender='user', text=f'{SEED_TAG} درسِ اول باز نمیشه.'
|
||||||
|
)
|
||||||
|
BugReport.objects.get_or_create(
|
||||||
|
user_id=main_user.pk,
|
||||||
|
title=f'{SEED_TAG} کرش هنگام باز کردن درس',
|
||||||
|
defaults=dict(description='بعد از زدن دکمهی شروع، اپ بسته میشه.', status='open'),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS('support: 1 تیکت با یک پیام، و 1 گزارش باگ آماده شد.'))
|
||||||
68
accounts/management/commands/setup_supervisor_group.py
Normal file
68
accounts/management/commands/setup_supervisor_group.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""
|
||||||
|
گروهِ Supervisor رو (اگه از قبل نبود) میسازه و *فقط* پرمیشنِ استانداردِ
|
||||||
|
view_* رو، دقیقاً روی همون چهار مدلی که صفحهی /admin/supervisor/ بهشون
|
||||||
|
لینک میده، بهش میده: accounts.User، payment.Order، payment.Transaction،
|
||||||
|
subscription.Subscription. هیچ پرمیشنِ add/change/deleteای ست نمیشه.
|
||||||
|
|
||||||
|
اجرا (یکبار، بعد از migrate):
|
||||||
|
|
||||||
|
python manage.py setup_supervisor_group
|
||||||
|
|
||||||
|
idempotent است: هر بار که دوباره اجرا بشه، مجموعهی پرمیشنهای گروه رو
|
||||||
|
دقیقاً با همین لیست sync میکنه (group.permissions.set)، یعنی اگه کسی
|
||||||
|
دستی یک پرمیشنِ change/delete به این گروه اضافه کرده باشه، اجرای دوبارهی
|
||||||
|
این دستور خودش رو تصحیح میکنه.
|
||||||
|
|
||||||
|
پرمیشنهای view_* توسطِ خودِ جنگو، بعد از migrate کاملِ هر اپ، اتوماتیک
|
||||||
|
ساخته میشن (django.contrib.auth's post_migrate signal)؛ برای همین این
|
||||||
|
منطق در یک management command جداگونهست، نه یک data migration — چون در
|
||||||
|
لحظهی اجرای یک data migration ممکنه اون پرمیشنها هنوز ساخته نشده باشن.
|
||||||
|
"""
|
||||||
|
from django.contrib.auth.models import Group, Permission
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from accounts.admin import SUPERVISOR_GROUP_NAME
|
||||||
|
|
||||||
|
SUPERVISOR_VIEW_PERMISSIONS = [
|
||||||
|
('accounts', 'user'),
|
||||||
|
('payment', 'order'),
|
||||||
|
('payment', 'transaction'),
|
||||||
|
('subscription', 'subscription'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = (
|
||||||
|
'گروهِ Supervisor رو میسازه/sync میکنه و فقط پرمیشنِ view_* رو '
|
||||||
|
'روی User/Order/Transaction/Subscription بهش میده (بدونِ '
|
||||||
|
'add/change/delete). idempotent است.'
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
group, created = Group.objects.get_or_create(name=SUPERVISOR_GROUP_NAME)
|
||||||
|
|
||||||
|
permissions = []
|
||||||
|
missing = []
|
||||||
|
for app_label, model_name in SUPERVISOR_VIEW_PERMISSIONS:
|
||||||
|
codename = f'view_{model_name}'
|
||||||
|
try:
|
||||||
|
permissions.append(
|
||||||
|
Permission.objects.get(content_type__app_label=app_label, codename=codename)
|
||||||
|
)
|
||||||
|
except Permission.DoesNotExist:
|
||||||
|
missing.append(f'{app_label}.{codename}')
|
||||||
|
|
||||||
|
group.permissions.set(permissions)
|
||||||
|
|
||||||
|
if created:
|
||||||
|
self.stdout.write(self.style.SUCCESS(f'گروهِ "{SUPERVISOR_GROUP_NAME}" ساخته شد.'))
|
||||||
|
else:
|
||||||
|
self.stdout.write(f'گروهِ "{SUPERVISOR_GROUP_NAME}" از قبل وجود داشت؛ پرمیشنها sync شدن.')
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
f'{len(permissions)} پرمیشنِ view-only به گروه اختصاص داده شد (بدونِ add/change/delete).'
|
||||||
|
)
|
||||||
|
if missing:
|
||||||
|
self.stderr.write(
|
||||||
|
'این پرمیشنها پیدا نشدن (migrate کامل اجرا شده؟): ' + ', '.join(missing)
|
||||||
|
)
|
||||||
17
accounts/migrations/0002_user_uniq_guest_device_id.py
Normal file
17
accounts/migrations/0002_user_uniq_guest_device_id.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.2.17 on 2026-08-19 10:10
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='user',
|
||||||
|
constraint=models.UniqueConstraint(condition=models.Q(('is_guest', True), models.Q(('device_id', None), _negated=True), models.Q(('device_id', ''), _negated=True)), fields=('device_id',), name='uniq_guest_device_id'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
accounts/migrations/0003_user_last_activity_at.py
Normal file
18
accounts/migrations/0003_user_last_activity_at.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.17 on 2026-08-24 16:41
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0002_user_uniq_guest_device_id'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='last_activity_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
class User(models.Model):
|
class User(models.Model):
|
||||||
"""
|
"""
|
||||||
کاربر اصلی سیستم (مهمان یا ثبتنامشده با شماره).
|
کاربر اصلی سیستم: ثبتنامشده با شمارهی موبایل (تنها راه ورود).
|
||||||
توجه: این مدل، AUTH_USER_MODEL جنگو نیست؛ یک مدل بیزنسی مستقله که
|
توجه: این مدل، AUTH_USER_MODEL جنگو نیست؛ یک مدل بیزنسی مستقله که
|
||||||
با TadrisinoTokenAuthentication سفارشی احراز هویت میشه.
|
با TadrisinoTokenAuthentication سفارشی احراز هویت میشه.
|
||||||
|
|
||||||
@@ -11,18 +13,23 @@ class User(models.Model):
|
|||||||
این اپ عمداً هیچوقت FK واقعی به مدل اپ دیگه نمیزنه.
|
این اپ عمداً هیچوقت FK واقعی به مدل اپ دیگه نمیزنه.
|
||||||
"""
|
"""
|
||||||
phone = models.CharField(max_length=15, unique=True, null=True, blank=True, db_index=True)
|
phone = models.CharField(max_length=15, unique=True, null=True, blank=True, db_index=True)
|
||||||
device_id = models.CharField(max_length=64, null=True, blank=True, db_index=True)
|
|
||||||
name = models.CharField(max_length=100, blank=True)
|
name = models.CharField(max_length=100, blank=True)
|
||||||
avatar = models.CharField(max_length=100, blank=True)
|
avatar = models.CharField(max_length=100, blank=True)
|
||||||
is_guest = models.BooleanField(default=True)
|
|
||||||
grade_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Grade
|
grade_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Grade
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
# آخرین زمانِ فعالیتِ کاربر (لاگین موفق یا رفرش توکن موفق). فقط برای هدفگیریِ آیندهی کمپینهای پیامی نگهداری
|
||||||
|
# میشه؛ در حال حاضر هیچ منطقِ دیگهای به این فیلد وابسته نیست.
|
||||||
|
last_activity_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
# access tokenهای صادرشده پیش از این لحظه رد میشوند (خروج از حساب). توکنِ
|
||||||
|
# access فقط user_id امضاشده است و به Session وصل نیست؛ بدونِ این، خروج
|
||||||
|
# فقط refresh را باطل میکرد و توکنِ فعلی تا یک ساعت کار میکرد.
|
||||||
|
tokens_valid_after = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name or self.phone or f"guest-{self.pk}"
|
return self.name or self.phone or f"user-{self.pk}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_authenticated(self):
|
def is_authenticated(self):
|
||||||
@@ -33,23 +40,32 @@ class User(models.Model):
|
|||||||
|
|
||||||
class Profile(models.Model):
|
class Profile(models.Model):
|
||||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
|
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
|
||||||
|
# سالِ تولدِ شمسی. از ۲۰۲۶-۱۰-۰۱ از birth_date پر میشود؛ برای کاربرهای
|
||||||
|
# قدیمی که فقط سال دادهاند تنها منبع است.
|
||||||
birth_year = models.PositiveSmallIntegerField(null=True, blank=True)
|
birth_year = models.PositiveSmallIntegerField(null=True, blank=True)
|
||||||
parent_phone = models.CharField(max_length=15, blank=True)
|
# تاریخِ تولدِ کامل (میلادی ذخیره میشود، در API شمسی است؛ accounts/jalali.py).
|
||||||
|
birth_date = models.DateField(null=True, blank=True)
|
||||||
bio = models.CharField(max_length=200, blank=True)
|
bio = models.CharField(max_length=200, blank=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"profile-{self.user_id}"
|
return f"profile-{self.user_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def hash_refresh_token(raw: str) -> str:
|
||||||
|
"""آنچه در Session.refresh_token ذخیره و با آن جستوجو میشود؛ خودِ توکن فقط دستِ کلاینت است."""
|
||||||
|
return hashlib.sha256(raw.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
class Session(models.Model):
|
class Session(models.Model):
|
||||||
"""
|
"""
|
||||||
نگهداری refresh_token هر ورود. توکن دسترسی (access token) در دیتابیس
|
یک ورود. توکنِ دسترسی ذخیره نمیشود (امضاشده و stateless است، accounts/authentication.py).
|
||||||
ذخیره نمیشه؛ بهصورت stateless امضا (signed) میشه — دقیقاً طبق فیلدهای
|
|
||||||
همین مدل که فقط refresh_token داره، نه access token.
|
`refresh_token` هشِ SHA-256ِ توکنِ تمدید است، نه خودش (hash_refresh_token): هر کس دیتابیس،
|
||||||
|
یک بکاپ یا پنلِ ادمین را ببیند با آن نمیتواند بهجای کاربر وارد شود. توکن ۶۴ نویسهی تصادفی
|
||||||
|
است، پس هشِ سریعِ بینمک کافی است.
|
||||||
"""
|
"""
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sessions')
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sessions')
|
||||||
refresh_token = models.CharField(max_length=200, unique=True)
|
refresh_token = models.CharField(max_length=200, unique=True)
|
||||||
device_id = models.CharField(max_length=64, blank=True)
|
|
||||||
expires_at = models.DateTimeField()
|
expires_at = models.DateTimeField()
|
||||||
revoked = models.BooleanField(default=False)
|
revoked = models.BooleanField(default=False)
|
||||||
|
|
||||||
@@ -60,8 +76,46 @@ class Session(models.Model):
|
|||||||
class OTPCode(models.Model):
|
class OTPCode(models.Model):
|
||||||
phone = models.CharField(max_length=15, db_index=True)
|
phone = models.CharField(max_length=15, db_index=True)
|
||||||
code = models.CharField(max_length=6)
|
code = models.CharField(max_length=6)
|
||||||
expires_at = models.DateTimeField()
|
# ایندکس: سقفِ سراسریِ پیامکِ روزانه در هر درخواستِ کد کدهای ۲۴ ساعتِ اخیر را میشمارد
|
||||||
|
# (accounts/views.py)؛ ردیفها پاک نمیشوند، پس بیایندکس این شمارش با هر ورود کندتر میشد.
|
||||||
|
expires_at = models.DateTimeField(db_index=True)
|
||||||
is_used = models.BooleanField(default=False)
|
is_used = models.BooleanField(default=False)
|
||||||
|
# تلاشهای غلط روی همین کد. در دیتابیس شمرده میشود، نه در کشِ LocMem که
|
||||||
|
# برای هر worker جداست؛ بعد از OTP_MAX_FAILED_ATTEMPTS کد باطل میشود.
|
||||||
|
failed_attempts = models.PositiveSmallIntegerField(default=0)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"otp-{self.phone}"
|
return f"otp-{self.phone}"
|
||||||
|
|
||||||
|
|
||||||
|
class LoginEvent(models.Model):
|
||||||
|
"""
|
||||||
|
سابقهی ماندگارِ ورود و خروج، برای پرسشهایی مثلِ «این کاربر کی آمد، کی رفت».
|
||||||
|
|
||||||
|
چرا جدول و نه فقط لاگ: لاگِ stdout با ریاستارت یا چرخشِ journald از بین میرود؛ این ردیفها
|
||||||
|
در دیتابیس و بکاپ میمانند. `user_id` ارجاعِ سبک است (نه FK، مثلِ بقیهی پروژه)، و ردیف با حذفِ
|
||||||
|
حساب نمیماند مگر در KEPT_ON_ACCOUNT_DELETE. شمارهی کامل برای ادمینها ذخیره میشود
|
||||||
|
(تصمیمِ مالک، ۲۰۲۶-۱۰-۰۸)؛ در سیاستِ حریمِ خصوصی باید بیاید.
|
||||||
|
"""
|
||||||
|
LOGIN_OK = 'login_ok'
|
||||||
|
LOGIN_FAILED = 'login_failed' # کدِ غلط یا منقضی
|
||||||
|
LOGIN_BLOCKED = 'login_blocked' # سقفِ تلاشِ شماره (429)
|
||||||
|
LOGOUT = 'logout'
|
||||||
|
ACCOUNT_DELETED = 'account_deleted'
|
||||||
|
EVENT_CHOICES = [
|
||||||
|
(LOGIN_OK, 'ورود موفق'), (LOGIN_FAILED, 'ورود ناموفق'), (LOGIN_BLOCKED, 'ورود مسدود'),
|
||||||
|
(LOGOUT, 'خروج'), (ACCOUNT_DELETED, 'حذف حساب'),
|
||||||
|
]
|
||||||
|
|
||||||
|
event = models.CharField(max_length=20, choices=EVENT_CHOICES, db_index=True)
|
||||||
|
user_id = models.IntegerField(null=True, blank=True, db_index=True)
|
||||||
|
phone = models.CharField(max_length=15, blank=True, db_index=True)
|
||||||
|
ip = models.GenericIPAddressField(null=True, blank=True)
|
||||||
|
user_agent = models.CharField(max_length=300, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.event} user={self.user_id} {self.created_at:%Y-%m-%d %H:%M}"
|
||||||
|
|||||||
43
accounts/phone.py
Normal file
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
|
||||||
22
accounts/receivers.py
Normal file
22
accounts/receivers.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"""
|
||||||
|
باطلکردنِ کشِ ردیفِ کاربر (auth:user:<id>) با هر تغییرِ User.
|
||||||
|
|
||||||
|
این سیگنالها همهی مسیرهای نوشتنِ داخلِ اپ (SetUserGrade، لاگین، رفرشتوکن،
|
||||||
|
last_activity_at، ...) را پوشش میدهند؛ فقط تغییری که کاملاً بیرون از ORM
|
||||||
|
اعمال شود (مثلاً UPDATE مستقیمِ SQL) تا سقفِ AUTH_USER_CACHE_TTL کهنه میماند.
|
||||||
|
"""
|
||||||
|
from django.db.models.signals import post_delete, post_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
from .authentication import invalidate_user_cache
|
||||||
|
from .models import User
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=User, dispatch_uid='accounts_invalidate_user_cache_on_save')
|
||||||
|
def _invalidate_on_save(sender, instance, **kwargs):
|
||||||
|
invalidate_user_cache(instance.pk)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_delete, sender=User, dispatch_uid='accounts_invalidate_user_cache_on_delete')
|
||||||
|
def _invalidate_on_delete(sender, instance, **kwargs):
|
||||||
|
invalidate_user_cache(instance.pk)
|
||||||
@@ -15,7 +15,7 @@ class TadrisinoTokenAuthenticationScheme(OpenApiAuthenticationExtension):
|
|||||||
'scheme': 'bearer',
|
'scheme': 'bearer',
|
||||||
'bearerFormat': 'access_token',
|
'bearerFormat': 'access_token',
|
||||||
'description': (
|
'description': (
|
||||||
'توکن دسترسی که از /api/auth/session یا /api/auth/login گرفته میشه. '
|
'توکن دسترسی که از /api/auth/login گرفته میشه. '
|
||||||
'در فیلد Value فقط خودِ توکن رو بذارید (بدون کلمهی Bearer)؛ Swagger '
|
'در فیلد Value فقط خودِ توکن رو بذارید (بدون کلمهی Bearer)؛ Swagger '
|
||||||
'خودش اضافه میکنه.'
|
'خودش اضافه میکنه.'
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,16 +1,71 @@
|
|||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from .phone import ascii_digits, normalize_phone
|
||||||
|
|
||||||
|
# لینک، آیدیِ شبکهی اجتماعی، و هر رشتهی ۵ رقمی یا بیشتر با هر جداکنندهی غیرِ حرفی (فاصله،
|
||||||
|
# خطتیره، ستاره، ایموجی…). \d در پایتون هر رقمِ یونیکد است (فارسی، عربی، تمامعرض، ریاضی).
|
||||||
|
# این نام در رتبهبندی به بچههای دیگر نشان داده میشود. حرف بینِ رقمها («0x9x1…») و عدد با
|
||||||
|
# حروف («صفر نه یک…») گرفته نمیشود.
|
||||||
|
_CONTACT = re.compile(
|
||||||
|
r'(https?://\S+|www\.\S+|\S+\.(?:com|ir|org|net|me)\S*|@\w+'
|
||||||
|
r'|(?:\d(?:[^\w@]|_)*){5,})',
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# «ig: name»، «insta name»، «آیدی name» و مانندش: کلیدواژه و هر چه بعدش آمده حذف میشود.
|
||||||
|
_HANDLE = re.compile(
|
||||||
|
r'(?:\b(?:ig|insta(?:gram)?|tele(?:gram)?|tg|id|whats ?app|rubika|eitaa|bale)\b'
|
||||||
|
r'|اینستا\S*|(?:تلگرام|تل|آیدی|ایدی|واتساپ|روبیکا|ایتا)(?!\w)|بله\s*:)\s*[::]?.*',
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_display_name(value: str) -> str:
|
||||||
|
# NFKC: «①»، «²»، «𝟬» و «@» به رقم و @ِ معمولی برمیگردند تا الگوهای پایین آنها را هم بگیرند؛
|
||||||
|
# نامِ ذخیرهشده همین شکلِ نرمال است.
|
||||||
|
value = _HANDLE.sub(' ', unicodedata.normalize('NFKC', value or ''))
|
||||||
|
return ' '.join(_CONTACT.sub(' ', value).split())[:30]
|
||||||
|
|
||||||
|
|
||||||
|
class IranMobileField(serializers.CharField):
|
||||||
|
"""CharField که خروجیاش همیشه 09XXXXXXXXX است."""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
kwargs.setdefault('max_length', 20)
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
def to_internal_value(self, data):
|
||||||
|
phone = normalize_phone(super().to_internal_value(data))
|
||||||
|
if phone is None:
|
||||||
|
raise serializers.ValidationError('شمارهی موبایل معتبر نیست؛ مثلاً 09121234567.')
|
||||||
|
return phone
|
||||||
|
|
||||||
|
|
||||||
# ---- Request serializers ----
|
# ---- Request serializers ----
|
||||||
|
|
||||||
class GuestSessionRequestSerializer(serializers.Serializer):
|
class OTPCodeField(serializers.CharField):
|
||||||
type = serializers.ChoiceField(choices=['guest'])
|
"""
|
||||||
deviceId = serializers.CharField(max_length=64)
|
کدِ ورود: ۴ تا ۶ رقم (کدِ پیامکی ۶ رقمی است، کدهای TEST_LOGIN_PHONES ۴ تا ۶). رقمِ فارسی و
|
||||||
|
دیگر رقمهای یونیکد به لاتین برمیگردند؛ هر چیزِ دیگر ۴۰۰ است. بدونِ این، کدِ غیرِ ASCII برای
|
||||||
|
شمارهی تست به secrets.compare_digest میرسید و TypeError (۵۰۰) میداد.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
kwargs.setdefault('max_length', 6)
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
def to_internal_value(self, data):
|
||||||
|
code = ascii_digits(super().to_internal_value(data))
|
||||||
|
if not re.fullmatch(r'[0-9]{4,6}', code):
|
||||||
|
raise serializers.ValidationError('کد باید ۴ تا ۶ رقم باشد.')
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
class LoginRequestSerializer(serializers.Serializer):
|
class LoginRequestSerializer(serializers.Serializer):
|
||||||
phone = serializers.CharField(max_length=15)
|
phone = IranMobileField()
|
||||||
otp = serializers.CharField(max_length=6)
|
otp = OTPCodeField()
|
||||||
|
|
||||||
|
|
||||||
class RefreshRequestSerializer(serializers.Serializer):
|
class RefreshRequestSerializer(serializers.Serializer):
|
||||||
@@ -20,21 +75,80 @@ class RefreshRequestSerializer(serializers.Serializer):
|
|||||||
class SetGradeRequestSerializer(serializers.Serializer):
|
class SetGradeRequestSerializer(serializers.Serializer):
|
||||||
gradeId = serializers.IntegerField()
|
gradeId = serializers.IntegerField()
|
||||||
|
|
||||||
|
def validate_gradeId(self, value):
|
||||||
|
# پایهی ناموجود ۴۰۰ است، نه ذخیرهی بیصدا (نقشهی خالی). عددِ بزرگتر از ستونِ SQLite هم
|
||||||
|
# پیشتر هنگامِ ذخیره OverflowError (۵۰۰) میداد؛ exists() آن را بیخطا «نیست» میبیند.
|
||||||
|
from content.models import Grade
|
||||||
|
|
||||||
|
if not Grade.objects.filter(pk=value).exists():
|
||||||
|
raise serializers.ValidationError('این پایه وجود ندارد.')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# همان بازهای که پیشتر برای «سن» پذیرفته میشد. بزرگسال هم میآید (سینیور،
|
||||||
|
# ۲۰۲۶-۱۰-۰۱): کسی که فقط یک مبحثِ ریاضی را میخواهد یاد بگیرد.
|
||||||
|
MIN_AGE = 5
|
||||||
|
MAX_AGE = 99
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateProfileRequestSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
ورودی PATCH /api/user/profile — همهی فیلدها اختیاریاند و فقط همانهایی
|
||||||
|
که فرستاده شوند تغییر میکنند (partial update واقعی).
|
||||||
|
|
||||||
|
`phone` عمداً اینجا نیست: شماره فقط از مسیرِ OTP عوض
|
||||||
|
میشود و مهمانبودن را کاربر نباید بتواند خودش تغییر دهد.
|
||||||
|
"""
|
||||||
|
name = serializers.CharField(max_length=100, required=False, allow_blank=True)
|
||||||
|
avatar = serializers.CharField(max_length=100, required=False, allow_blank=True)
|
||||||
|
|
||||||
|
def validate_name(self, value):
|
||||||
|
"""
|
||||||
|
نام در جدولِ رتبهبندی به بچههای دیگر نشان داده میشود؛ شماره، لینک و
|
||||||
|
آیدی (راهِ تماس گرفتن با یک کودکِ غریبه) از آن حذف میشود. عمداً خطا
|
||||||
|
نمیدهد تا صفحهی پروفایلِ اپ گیر نکند؛ فقط بقیهی نام ذخیره میشود.
|
||||||
|
"""
|
||||||
|
return clean_display_name(value)
|
||||||
|
# فقط سال (روشِ قدیمِ اپ). birthDate دقیقتر است و ترجیح دارد.
|
||||||
|
birthYear = serializers.IntegerField(required=False, allow_null=True,
|
||||||
|
min_value=1300, max_value=1500)
|
||||||
|
# تاریخِ تولدِ شمسی «1385-04-12». سرور میلادی ذخیره میکند و birthYear را هم
|
||||||
|
# از آن پر میکند؛ تبدیل فقط در accounts/jalali.py است.
|
||||||
|
birthDate = serializers.CharField(required=False, allow_null=True, max_length=10)
|
||||||
|
bio = serializers.CharField(max_length=200, required=False, allow_blank=True)
|
||||||
|
|
||||||
|
def validate_birthDate(self, value):
|
||||||
|
if value in (None, ''):
|
||||||
|
return None
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from . import jalali
|
||||||
|
|
||||||
|
try:
|
||||||
|
born = jalali.parse(value)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise serializers.ValidationError(str(exc))
|
||||||
|
today = timezone.localdate()
|
||||||
|
age = today.year - born.year - ((today.month, today.day) < (born.month, born.day))
|
||||||
|
if not MIN_AGE <= age <= MAX_AGE:
|
||||||
|
raise serializers.ValidationError(f'سن باید بینِ {MIN_AGE} و {MAX_AGE} سال باشد.')
|
||||||
|
return born
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
if not attrs:
|
||||||
|
raise serializers.ValidationError('حداقل یکی از فیلدها باید ارسال شود.')
|
||||||
|
if 'birthDate' in attrs and 'birthYear' in attrs:
|
||||||
|
raise serializers.ValidationError('یکی از birthDate یا birthYear را بفرستید، نه هر دو را.')
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class RequestOTPRequestSerializer(serializers.Serializer):
|
class RequestOTPRequestSerializer(serializers.Serializer):
|
||||||
"""اپ کمکی برای گرفتن OTP (در سند اصلی نبود، برای تستپذیری Swagger اضافه شد)."""
|
"""اپ کمکی برای گرفتن OTP (در سند اصلی نبود، برای تستپذیری Swagger اضافه شد)."""
|
||||||
phone = serializers.CharField(max_length=15)
|
phone = IranMobileField()
|
||||||
|
|
||||||
|
|
||||||
# ---- Response serializers ----
|
# ---- Response serializers ----
|
||||||
|
|
||||||
class GuestSessionResponseSerializer(serializers.Serializer):
|
|
||||||
userId = serializers.CharField()
|
|
||||||
token = serializers.CharField()
|
|
||||||
isNewUser = serializers.BooleanField()
|
|
||||||
isGuest = serializers.BooleanField()
|
|
||||||
|
|
||||||
|
|
||||||
class LoginResponseSerializer(serializers.Serializer):
|
class LoginResponseSerializer(serializers.Serializer):
|
||||||
userId = serializers.CharField()
|
userId = serializers.CharField()
|
||||||
token = serializers.CharField()
|
token = serializers.CharField()
|
||||||
@@ -43,6 +157,7 @@ class LoginResponseSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
class RefreshResponseSerializer(serializers.Serializer):
|
class RefreshResponseSerializer(serializers.Serializer):
|
||||||
token = serializers.CharField()
|
token = serializers.CharField()
|
||||||
|
refreshToken = serializers.CharField()
|
||||||
expiresIn = serializers.IntegerField()
|
expiresIn = serializers.IntegerField()
|
||||||
|
|
||||||
|
|
||||||
@@ -66,3 +181,35 @@ class RequestOTPResponseSerializer(serializers.Serializer):
|
|||||||
success = serializers.BooleanField()
|
success = serializers.BooleanField()
|
||||||
devOtp = serializers.CharField()
|
devOtp = serializers.CharField()
|
||||||
note = serializers.CharField()
|
note = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileResponseSerializer(serializers.Serializer):
|
||||||
|
"""پاسخ GET /api/user/profile — فقط دادهی لازم برای تب Profile اپ موبایل."""
|
||||||
|
userId = serializers.CharField()
|
||||||
|
# شناسهی نمایشی («User482917»، accounts/public_id.py)؛ اپ آن را زیرِ نام نشان میدهد
|
||||||
|
# و پشتیبانی با همین در پنلِ ادمین حساب را پیدا میکند.
|
||||||
|
publicId = serializers.CharField()
|
||||||
|
phone = serializers.CharField(allow_null=True)
|
||||||
|
name = serializers.CharField()
|
||||||
|
avatar = serializers.CharField()
|
||||||
|
gradeId = serializers.IntegerField(allow_null=True)
|
||||||
|
birthYear = serializers.IntegerField(allow_null=True)
|
||||||
|
birthDate = serializers.CharField(allow_null=True, help_text='شمسی، «1385-04-12»؛ null اگر فقط سال داده شده.')
|
||||||
|
bio = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteAccountRequestSerializer(serializers.Serializer):
|
||||||
|
# کدِ پیامکیِ تازه (از POST /api/auth/otp/request با همان شماره).
|
||||||
|
otp = OTPCodeField()
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteAccountResponseSerializer(serializers.Serializer):
|
||||||
|
success = serializers.BooleanField()
|
||||||
|
|
||||||
|
|
||||||
|
class LogoutRequestSerializer(serializers.Serializer):
|
||||||
|
refreshToken = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class LogoutResponseSerializer(serializers.Serializer):
|
||||||
|
success = serializers.BooleanField()
|
||||||
|
|||||||
265
accounts/services.py
Normal file
265
accounts/services.py
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
"""
|
||||||
|
منطق کمکیِ اپ Accounts که از views.py همین اپ صدا زده میشه.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework.exceptions import APIException
|
||||||
|
|
||||||
|
from .models import User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _phone_suffix(phone: str) -> str:
|
||||||
|
"""
|
||||||
|
⚠️ اصلاح: قبلاً لاگهای مربوط به OTP، شمارهتلفنِ کامل رو لاگ میکردن
|
||||||
|
(PIIِ غیرضروری در لاگهای اپلیکیشن). حالا فقط ۴ رقمِ آخر (برای
|
||||||
|
correlation/دیباگ، بدونِ افشای کاملِ شماره) لاگ میشه — همون الگویی که
|
||||||
|
قبلاً در accounts/views.py::LoginView هم برای همین منظور استفاده شده.
|
||||||
|
|
||||||
|
⚠️ اصلاحِ این دور: لاگهای خطای ارائهدهنده (provider) دیگه
|
||||||
|
response.text خام رو لاگ نمیکنن — چون بدنهی پاسخِ کاوهنگار میتونه
|
||||||
|
دادهی حساس/غیرضروری (مثلاً جزئیاتِ گیرنده یا پیامِ متنی) داشته باشه.
|
||||||
|
بهجاش فقط context مفید و بیخطر (phone_suffix، status، provider_status)
|
||||||
|
لاگ میشه.
|
||||||
|
"""
|
||||||
|
return phone[-4:] if phone else phone
|
||||||
|
|
||||||
|
|
||||||
|
class SMSDeliveryError(APIException):
|
||||||
|
"""
|
||||||
|
خطای عمومیِ ارسالِ پیامک، برای هر فراخوانیای از send_sms (نه فقط OTP).
|
||||||
|
اگه پیکربندیِ سرویسِ پیامک ناقص باشه یا خودِ درخواست به ارائهدهنده
|
||||||
|
شکست بخوره، همین استثنا raise میشه (نه یک پاسخِ ساختگیِ موفق).
|
||||||
|
"""
|
||||||
|
|
||||||
|
status_code = 503
|
||||||
|
default_detail = 'در حال حاضر امکان ارسال پیامک وجود ندارد؛ لطفاً بعداً دوباره تلاش کنید.'
|
||||||
|
default_code = 'sms_delivery_failed'
|
||||||
|
|
||||||
|
|
||||||
|
class SMSDeliveryUncertain(SMSDeliveryError):
|
||||||
|
"""
|
||||||
|
درخواست به کاوهنگار رسید ولی جوابش در مهلت نیامد؛ پیامک شاید رفته باشد.
|
||||||
|
۲۰۲۶-۰۹-۲۷ دیده شد: پاسخ ۵٫۴ ثانیه طول کشید، ما با مهلتِ ۵ ثانیه ۵۰۳ دادیم و
|
||||||
|
کد را پاک کردیم، ولی پیامک رسید — کاربر کدی در دست داشت که دیگر کار نمیکرد.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class OTPDeliveryError(SMSDeliveryError):
|
||||||
|
"""
|
||||||
|
⚠️ اصلاح: قبلاً send_otp_sms یک استاب بود که فقط لاگ میکرد و همیشه
|
||||||
|
(بیقیدوشرط) به RequestOTPView «موفقیت» برمیگردوند؛ یعنی در پروداکشن
|
||||||
|
(DEBUG=False، جایی که devOtp دیگه توی پاسخ برنمیگرده) هیچ پیامکِ
|
||||||
|
واقعیای ارسال نمیشد ولی API همچنان success=True میداد — کاربرِ واقعی
|
||||||
|
هیچوقت کدش رو نمیگرفت و حتی نمیفهمید که مشکلی پیش اومده. حالا اگه
|
||||||
|
پیکربندیِ سرویسِ پیامک ناقص باشه یا خودِ درخواست به ارائهدهنده شکست
|
||||||
|
بخوره، همین استثنا raise میشه و RequestOTPView یک خطای صریح (نه یک
|
||||||
|
پاسخِ ساختگیِ موفق) برمیگردونه.
|
||||||
|
|
||||||
|
این کلاس زیرکلاسِ SMSDeliveryError است تا رفتار/نوعِ استثنایی که
|
||||||
|
RequestOTPView (و تستها) انتظارش رو دارن دقیقاً حفظ بشه، در حالی که
|
||||||
|
منطقِ واقعیِ ارتباط با ارائهدهنده در send_sms (عمومی) پیاده شده.
|
||||||
|
"""
|
||||||
|
|
||||||
|
default_detail = 'در حال حاضر امکان ارسال پیامک OTP وجود ندارد؛ لطفاً بعداً دوباره تلاش کنید.'
|
||||||
|
default_code = 'otp_sms_delivery_failed'
|
||||||
|
|
||||||
|
|
||||||
|
def send_sms(*, phone: str, message: str) -> None:
|
||||||
|
"""
|
||||||
|
⚙️ Reusable هلپر: تنها بخشِ «ارتباط با ارائهدهنده» (کاوهنگار،
|
||||||
|
kavenegar.com) که قبلاً داخلِ send_otp_sms با منطقِ OTP قاطی شده بود،
|
||||||
|
به این تابعِ عمومی منتقل شده — بدونِ هیچ تغییری در رفتار/پیامهای
|
||||||
|
provider. هر فیچرِ آینده (مثلاً notifications) هم میتونه بدونِ
|
||||||
|
تکرارِ کدِ ارائهدهنده، مستقیماً از همین تابع برای ارسالِ پیامک استفاده
|
||||||
|
کنه.
|
||||||
|
|
||||||
|
این تابع هیچ منطقِ مخصوصِ OTP (dev-mode short-circuit، تمپلیتِ پیامک،
|
||||||
|
یا OTPDeliveryError) نداره؛ فقط payload میسازه، به کاوهنگار درخواست
|
||||||
|
میزنه، و پاسخ رو اعتبارسنجی میکنه. کلیدِ API هیچوقت داخل کد نیست؛
|
||||||
|
همیشه از env خونده میشه (settings.KAVENEGAR_API_KEY).
|
||||||
|
"""
|
||||||
|
payload = {'receptor': phone, 'message': message}
|
||||||
|
if settings.KAVENEGAR_SENDER:
|
||||||
|
payload['sender'] = settings.KAVENEGAR_SENDER
|
||||||
|
_kavenegar_post(phone=phone, method='sms/send.json', payload=payload)
|
||||||
|
|
||||||
|
|
||||||
|
def send_verify_lookup(*, phone: str, template: str, token: str) -> None:
|
||||||
|
"""
|
||||||
|
ارسال با الگوی تأییدشدهی کاوهنگار (verify/lookup) — متن را الگوی پنل میسازد
|
||||||
|
و ما فقط token را میفرستیم. کاوهنگارِ این حساب پیامکِ متنِ آزاد را از خطِ
|
||||||
|
پیشفرض لغو میکند (وضعیتِ ۱۳، ۲۰۲۶-۰۹-۲۵) و کدِ ورود را فقط از این راه میپذیرد.
|
||||||
|
token نباید فاصله داشته باشد (قاعدهی کاوهنگار)؛ کدِ OTP شش رقمِ پیوسته است.
|
||||||
|
"""
|
||||||
|
_kavenegar_post(
|
||||||
|
phone=phone, method='verify/lookup.json',
|
||||||
|
payload={'receptor': phone, 'template': template, 'token': token},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# کاوهنگار گاهی بیش از ۵ ثانیه طول میکشد (۵٫۴ ثانیه در ۲۰۲۶-۰۹-۲۷) و شبکهی ایران
|
||||||
|
# کند میشود؛ صاحبِ پروژه ۳۰ ثانیه خواست. جمعِ دو مهلت (۴۰ ثانیه) باید زیرِ مهلتِ
|
||||||
|
# فرانت برای همین درخواست بماند (`requestOtp` در MathApp_frontend، ۴۵ ثانیه)، وگرنه
|
||||||
|
# کاربر «ارتباط برقرار نشد» میبیند در حالی که پیامک رسیده. gunicorn ۱۲۰ ثانیه و
|
||||||
|
# gthread است؛ مهلتِ nginx و ArvanCloud برای این مسیر سنجیده نشده.
|
||||||
|
SMS_CONNECT_TIMEOUT_SECONDS = 10
|
||||||
|
SMS_READ_TIMEOUT_SECONDS = 30
|
||||||
|
|
||||||
|
|
||||||
|
def _kavenegar_post(*, phone: str, method: str, payload: dict) -> None:
|
||||||
|
"""فراخوانیِ مشترکِ کاوهنگار و سنجشِ پاسخ؛ هر شکستی SMSDeliveryError است."""
|
||||||
|
if not settings.KAVENEGAR_API_KEY:
|
||||||
|
logger.error(
|
||||||
|
'SMS misconfigured: KAVENEGAR_API_KEY is not set (phone_suffix=%s)',
|
||||||
|
_phone_suffix(phone),
|
||||||
|
)
|
||||||
|
raise SMSDeliveryError('پیکربندیِ سرویسِ پیامک ناقص است.')
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f'https://api.kavenegar.com/v1/{settings.KAVENEGAR_API_KEY}/{method}',
|
||||||
|
data=payload,
|
||||||
|
# (اتصال، خواندن). وصلنشدن یعنی قطعاً نرفت؛ دیر رسیدنِ جواب یعنی نامعلوم.
|
||||||
|
timeout=(SMS_CONNECT_TIMEOUT_SECONDS, SMS_READ_TIMEOUT_SECONDS),
|
||||||
|
)
|
||||||
|
except requests.ReadTimeout:
|
||||||
|
logger.warning(
|
||||||
|
'SMS provider reply timed out after the request was sent; delivery unknown: '
|
||||||
|
'phone_suffix=%s method=%s', _phone_suffix(phone), method,
|
||||||
|
)
|
||||||
|
raise SMSDeliveryUncertain('پاسخِ سرویسِ پیامک دیر رسید.')
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
# فقط نوعِ خطا: متنِ خطای requests آدرسِ کامل را دارد و کلیدِ API بخشی از همان آدرس است.
|
||||||
|
logger.error('SMS request to provider failed: phone_suffix=%s method=%s error=%s',
|
||||||
|
_phone_suffix(phone), method, type(exc).__name__)
|
||||||
|
raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.error(
|
||||||
|
'SMS provider returned non-200: phone_suffix=%s status=%s',
|
||||||
|
_phone_suffix(phone), response.status_code,
|
||||||
|
)
|
||||||
|
raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
|
||||||
|
|
||||||
|
# کاوهنگار حتی برای شکستهای سطحِ اپلیکیشن (کلیدِ نامعتبر، اعتبارِ ناکافی و…)
|
||||||
|
# HTTP 200 برمیگردونه؛ نتیجهی واقعی توی body.return.status هست. فقط
|
||||||
|
# اون فیلد موفقیت رو تعیین میکنه، نه status_code.
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
provider_status = body['return']['status']
|
||||||
|
except (ValueError, KeyError, TypeError):
|
||||||
|
logger.error(
|
||||||
|
'SMS provider returned malformed response body: phone_suffix=%s status=%s',
|
||||||
|
_phone_suffix(phone), response.status_code,
|
||||||
|
)
|
||||||
|
raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
|
||||||
|
|
||||||
|
if provider_status != 200:
|
||||||
|
logger.error(
|
||||||
|
'SMS provider rejected message at application level: phone_suffix=%s '
|
||||||
|
'provider_status=%s',
|
||||||
|
_phone_suffix(phone), provider_status,
|
||||||
|
)
|
||||||
|
raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
|
||||||
|
|
||||||
|
# پذیرفتهشدن ≠ رسیدن. شناسه و وضعیتِ پیام لاگ میشود تا اگر کسی گفت «کد
|
||||||
|
# نیامد»، همین شناسه در پنلِ کاوهنگار (گزارشِ ارسال) دنبال شود — مثلاً
|
||||||
|
# شمارهای که پیامکِ تبلیغاتی را مسدود کرده. متنِ پیام لاگ نمیشود.
|
||||||
|
entries = body.get('entries') if isinstance(body, dict) else None
|
||||||
|
first = entries[0] if isinstance(entries, list) and entries and isinstance(entries[0], dict) else {}
|
||||||
|
logger.info(
|
||||||
|
'SMS accepted by provider: phone_suffix=%s messageid=%s message_status=%s',
|
||||||
|
_phone_suffix(phone), first.get('messageid'), first.get('status'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_otp_sms(*, phone: str, code: str) -> None:
|
||||||
|
"""
|
||||||
|
ارسالِ پیامکِ OTP. منطقِ مخصوصِ OTP (dev-mode short-circuit، تمپلیتِ
|
||||||
|
پیامک از OTP_SMS_TEMPLATE، و نگاشتِ خطا به OTPDeliveryError) اینجا
|
||||||
|
میمونه؛ ارتباطِ واقعی با ارائهدهنده به send_sms عمومی سپرده شده.
|
||||||
|
|
||||||
|
- DEBUG=True (dev/test): دقیقاً مثل قبل، فقط لاگ میکنه و هیچ پیامکِ
|
||||||
|
واقعیای نمیفرسته — چون در این محیط، RequestOTPView خودِ کد رو در
|
||||||
|
فیلدِ devOtp پاسخ برمیگردونه و SMS واقعی لازم نیست (رفتار قبلی dev
|
||||||
|
حفظ شده).
|
||||||
|
- DEBUG=False (production): اگر KAVENEGAR_OTP_TEMPLATE تنظیم باشد با الگو
|
||||||
|
(send_verify_lookup)، وگرنه متنِ آزادِ OTP_SMS_TEMPLATE با send_sms؛ هر SMSDeliveryError
|
||||||
|
به OTPDeliveryError نگاشت میشه تا رفتار/نوعِ استثنایی که
|
||||||
|
RequestOTPView انتظارش رو داره حفظ بمونه (کاربر پاسخ ۵۰۳ صریح
|
||||||
|
میگیره، نه یک success دروغین).
|
||||||
|
"""
|
||||||
|
if settings.DEBUG:
|
||||||
|
logger.info(
|
||||||
|
'OTP sms dispatch (dev, no real SMS sent): phone_suffix=%s code_len=%d',
|
||||||
|
_phone_suffix(phone), len(code),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if settings.KAVENEGAR_OTP_TEMPLATE:
|
||||||
|
send_verify_lookup(phone=phone, template=settings.KAVENEGAR_OTP_TEMPLATE, token=code)
|
||||||
|
else:
|
||||||
|
send_sms(phone=phone, message=settings.OTP_SMS_TEMPLATE.format(code=code))
|
||||||
|
except SMSDeliveryUncertain:
|
||||||
|
# کد نگه داشته میشود و پاسخ موفق است: اگر پیامک رسیده، همان کد کار میکند؛
|
||||||
|
# اگر نرسیده، کاربر بعد از cooldown کدِ تازه میخواهد. برعکسش (۵۰۳ و پاک کردنِ
|
||||||
|
# کد) کاربری را که پیامک گرفته قفل میکرد. cooldown جلوی دو پیامک را میگیرد.
|
||||||
|
return
|
||||||
|
except SMSDeliveryError as exc:
|
||||||
|
raise OTPDeliveryError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
# Targeting helpers (internal, read-only): کوئریهای قابلاستفادهی مجدد برای
|
||||||
|
# انتخابِ زیرمجموعهای از کاربران (مثلاً برای کمپینهای پیامی)؛ فقط SELECT،
|
||||||
|
# بدونِ ارسالِ پیام. ارجاع به subscription.Subscription با import محلی و
|
||||||
|
# user_id انجام میشه چون accounts.User هیچوقت FK واقعی به اپهای دیگه نمیزنه.
|
||||||
|
|
||||||
|
def get_all_users():
|
||||||
|
"""همهی کاربران (مهمان و ثبتنامشده)."""
|
||||||
|
return User.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
def get_users_by_grade(grade_id: int):
|
||||||
|
"""کاربرانی که پایهی (grade_id) مشخصی دارن."""
|
||||||
|
return User.objects.filter(grade_id=grade_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_inactive_users(since):
|
||||||
|
"""
|
||||||
|
کاربرانی که از زمانِ `since` به بعد فعالیتی (لاگین/رفرشتوکن/سشنِ
|
||||||
|
مهمانِ واقعی) نداشتن — یعنی last_activity_at قبل از `since` بوده یا
|
||||||
|
اصلاً هیچوقت ثبت نشده (last_activity_at is null، مثلِ کاربرهایی که
|
||||||
|
این فیلد قبل از افزودهشدنش ساخته شدن یا هیچوقت فعالیتِ ثبتشدهای
|
||||||
|
نداشتن).
|
||||||
|
|
||||||
|
`since` باید یک datetime (تایمزوندار، همراستا با USE_TZ پروژه)
|
||||||
|
باشه؛ کالر مسئولِ محاسبهی cutoff (مثلاً timezone.now() - timedelta(...))
|
||||||
|
هست — این تابع خودش هیچ عددِ روزِ hardcodeشدهای نداره.
|
||||||
|
"""
|
||||||
|
return User.objects.filter(models.Q(last_activity_at__lt=since) | models.Q(last_activity_at__isnull=True))
|
||||||
|
|
||||||
|
|
||||||
|
def get_users_with_active_subscription():
|
||||||
|
"""
|
||||||
|
کاربرانی که اشتراکِ فعال دارن. «فعال» یعنی هم status='active' باشه و
|
||||||
|
هم منقضی نشده باشه (expires_at خالیه یعنی نامحدود، یا هنوز نرسیده).
|
||||||
|
فقط user_id از subscription.Subscription خونده میشه (بدونِ FK واقعی،
|
||||||
|
طبقِ همون قراردادِ accounts.User) و برای فیلترِ accounts.User استفاده
|
||||||
|
میشه.
|
||||||
|
"""
|
||||||
|
from subscription.models import Subscription
|
||||||
|
|
||||||
|
active_user_ids = Subscription.objects.filter(
|
||||||
|
status='active',
|
||||||
|
).filter(
|
||||||
|
models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=timezone.now())
|
||||||
|
).values_list('user_id', flat=True)
|
||||||
|
|
||||||
|
return User.objects.filter(id__in=active_user_ids)
|
||||||
11
accounts/signals.py
Normal file
11
accounts/signals.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"""
|
||||||
|
سیگنال ثبتنامِ واقعی کاربر. سایر اپها (مثلاً discount
|
||||||
|
برای اختصاص خودکار آفر) بهجای import کردن مستقیم از accounts، فقط این
|
||||||
|
سیگنال رو در apps.py خودشون connect میکنن؛ این یعنی accounts هیچوقت لازم
|
||||||
|
نیست بدونه discount/offer اصلاً وجود داره — دقیقاً همون درخواست سینیور برای
|
||||||
|
signal-based بودن منطق اعمال آفر.
|
||||||
|
"""
|
||||||
|
from django.dispatch import Signal
|
||||||
|
|
||||||
|
# kwargs: user_id (int)
|
||||||
|
user_registered = Signal()
|
||||||
24
accounts/templates/accounts/send_sms_form.html
Normal file
24
accounts/templates/accounts/send_sms_form.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{% extends "admin/base_site.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p>{{ users|length }} کاربر انتخاب شدهاند. متنِ پیامک را وارد کنید:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{% for user in users %}
|
||||||
|
<li>{{ user }}{% if not user.phone %} (بدونِ شمارهتلفن — رد میشه){% endif %}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% for user in users %}
|
||||||
|
<input type="hidden" name="{{ action_checkbox_name }}" value="{{ user.pk }}">
|
||||||
|
{% endfor %}
|
||||||
|
<input type="hidden" name="action" value="send_sms_to_selected">
|
||||||
|
|
||||||
|
{{ form.as_p }}
|
||||||
|
|
||||||
|
<input type="submit" name="apply" value="ارسال پیامک">
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
28
accounts/templates/admin/supervisor_dashboard.html
Normal file
28
accounts/templates/admin/supervisor_dashboard.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{% extends "admin/base_site.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p>
|
||||||
|
این صفحه فقط یک ایندکسِ خواندنی (read-only) از صفحاتِ Adminِ موجود است؛
|
||||||
|
هر بخش دقیقاً همون changelist همیشگی رو باز میکنه، بدون هیچ صفحهی
|
||||||
|
موازیِ جدید. دسترسیِ افزودن/ویرایش/حذف، مستقل از این صفحه، بر اساسِ
|
||||||
|
پرمیشنهای شما محدود شده است.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% for section in sections %}
|
||||||
|
<div class="module" style="margin-bottom: 20px;">
|
||||||
|
<table>
|
||||||
|
<caption>{{ section.title }}</caption>
|
||||||
|
<tbody>
|
||||||
|
{% for link in section.links %}
|
||||||
|
{% if link.has_perm %}
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><a href="{% url link.url_name %}">{{ link.label }}</a></th>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
1658
accounts/tests.py
1658
accounts/tests.py
File diff suppressed because it is too large
Load Diff
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,17 +1,21 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import (
|
from .views import (
|
||||||
GuestSessionView,
|
DeleteAccountView,
|
||||||
LoginView,
|
LoginView,
|
||||||
|
LogoutView,
|
||||||
RefreshTokenView,
|
RefreshTokenView,
|
||||||
RequestOTPView,
|
RequestOTPView,
|
||||||
SetUserGradeView,
|
SetUserGradeView,
|
||||||
|
UserProfileView,
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('auth/session', GuestSessionView.as_view(), name='auth-session'),
|
|
||||||
path('auth/otp/request', RequestOTPView.as_view(), name='auth-otp-request'),
|
path('auth/otp/request', RequestOTPView.as_view(), name='auth-otp-request'),
|
||||||
path('auth/login', LoginView.as_view(), name='auth-login'),
|
path('auth/login', LoginView.as_view(), name='auth-login'),
|
||||||
path('auth/refresh', RefreshTokenView.as_view(), name='auth-refresh'),
|
path('auth/refresh', RefreshTokenView.as_view(), name='auth-refresh'),
|
||||||
|
path('auth/logout', LogoutView.as_view(), name='auth-logout'),
|
||||||
path('user/grade', SetUserGradeView.as_view(), name='user-grade'),
|
path('user/grade', SetUserGradeView.as_view(), name='user-grade'),
|
||||||
|
path('user/profile', UserProfileView.as_view(), name='user-profile'),
|
||||||
|
path('user/delete', DeleteAccountView.as_view(), name='user-delete'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,66 +1,72 @@
|
|||||||
import random
|
import logging
|
||||||
|
import math
|
||||||
|
import secrets
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import F
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from drf_spectacular.utils import extend_schema
|
from drf_spectacular.utils import extend_schema
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.throttling import BaseThrottle, ScopedRateThrottle
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from .authentication import issue_access_token
|
from config.db import release_db_connection
|
||||||
from .models import OTPCode, Session, User
|
|
||||||
|
from .authentication import invalidate_user_cache, issue_access_token
|
||||||
|
from .models import LoginEvent, OTPCode, Profile, Session, User, hash_refresh_token
|
||||||
|
from .services import OTPDeliveryError, send_otp_sms
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
GuestSessionRequestSerializer,
|
DeleteAccountRequestSerializer,
|
||||||
GuestSessionResponseSerializer,
|
DeleteAccountResponseSerializer,
|
||||||
LoginRequestSerializer,
|
LoginRequestSerializer,
|
||||||
LoginResponseSerializer,
|
LoginResponseSerializer,
|
||||||
|
LogoutRequestSerializer,
|
||||||
|
LogoutResponseSerializer,
|
||||||
|
ProfileResponseSerializer,
|
||||||
RefreshRequestSerializer,
|
RefreshRequestSerializer,
|
||||||
RefreshResponseSerializer,
|
RefreshResponseSerializer,
|
||||||
RequestOTPRequestSerializer,
|
RequestOTPRequestSerializer,
|
||||||
RequestOTPResponseSerializer,
|
RequestOTPResponseSerializer,
|
||||||
SetGradeRequestSerializer,
|
SetGradeRequestSerializer,
|
||||||
|
UpdateProfileRequestSerializer,
|
||||||
SetGradeResponseSerializer,
|
SetGradeResponseSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class GuestSessionView(APIView):
|
|
||||||
"""POST /api/auth/session — ایجاد سشن مهمان"""
|
|
||||||
|
|
||||||
@extend_schema(
|
def record_login_event(request, event: str, *, user_id=None, phone: str = '') -> None:
|
||||||
request=GuestSessionRequestSerializer,
|
"""
|
||||||
responses=GuestSessionResponseSerializer,
|
ردیفِ LoginEvent مینویسد. شکستش ورود/خروج را خراب نمیکند (ثبتِ سابقه نباید کاربر را بیرون
|
||||||
tags=['Identity & Profile (accounts)'],
|
بیندازد) ولی با logger.exception دیده میشود. زیرِ بارِ همزمان و روی Postgres سنجیده نشده.
|
||||||
)
|
"""
|
||||||
def post(self, request):
|
try:
|
||||||
serializer = GuestSessionRequestSerializer(data=request.data)
|
LoginEvent.objects.create(
|
||||||
serializer.is_valid(raise_exception=True)
|
event=event, user_id=user_id, phone=phone,
|
||||||
device_id = serializer.validated_data['deviceId']
|
ip=BaseThrottle().get_ident(request) or None,
|
||||||
|
user_agent=request.META.get('HTTP_USER_AGENT', '')[:300],
|
||||||
user, is_new_user = User.objects.get_or_create(
|
|
||||||
device_id=device_id,
|
|
||||||
is_guest=True,
|
|
||||||
defaults={'name': ''},
|
|
||||||
)
|
)
|
||||||
|
except Exception:
|
||||||
token = issue_access_token(user.id)
|
logger.exception('LoginEvent نوشته نشد: event=%s user_id=%s', event, user_id)
|
||||||
|
|
||||||
data = {
|
|
||||||
'userId': str(user.id),
|
|
||||||
'token': token,
|
|
||||||
'isNewUser': is_new_user,
|
|
||||||
'isGuest': True,
|
|
||||||
}
|
|
||||||
return Response(GuestSessionResponseSerializer(data).data)
|
|
||||||
|
|
||||||
|
|
||||||
class RequestOTPView(APIView):
|
class RequestOTPView(APIView):
|
||||||
"""
|
"""
|
||||||
POST /api/auth/otp/request — درخواست کد OTP (کمکی، برای تست از طریق Swagger)
|
POST /api/auth/otp/request — درخواستِ کدِ ورود.
|
||||||
در محیط توسعه، کد ساختهشده در پاسخ برگردونده میشه تا نیازی به SMS واقعی نباشه.
|
|
||||||
|
کد فقط با پیامک تحویل داده میشود. دو استثنا: در حالتِ DEBUG کد در فیلدِ
|
||||||
|
devOtp برمیگردد تا تستِ محلی به پیامکِ واقعی نیاز نداشته باشد، و برای
|
||||||
|
شمارههای TEST_LOGIN_PHONES کدِ ثابتِ از پیش تعریفشده معتبر است.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
throttle_scope = 'otp_request'
|
||||||
|
throttle_classes = [ScopedRateThrottle]
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
request=RequestOTPRequestSerializer,
|
request=RequestOTPRequestSerializer,
|
||||||
responses=RequestOTPResponseSerializer,
|
responses=RequestOTPResponseSerializer,
|
||||||
@@ -71,23 +77,209 @@ class RequestOTPView(APIView):
|
|||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
phone = serializer.validated_data['phone']
|
phone = serializer.validated_data['phone']
|
||||||
|
|
||||||
code = f'{random.randint(0, 9999):04d}'
|
# شمارهی تستِ تیم: کد ثابت و از قبل معلوم است، پس نه ردیفِ OTP تازهای
|
||||||
OTPCode.objects.create(
|
# لازم است نه پیامکی. cooldown و سقفِ ساعتی هم رد میشوند چون هر دو از
|
||||||
phone=phone,
|
# گیرندهی واقعیِ پیامک محافظت میکنند و اینجا پیامکی در کار نیست.
|
||||||
code=code,
|
#
|
||||||
expires_at=timezone.now() + timezone.timedelta(minutes=5),
|
# ⚠️ کد عمداً در پاسخ برنمیگردد، برخلافِ devOtp در حالتِ DEBUG: این
|
||||||
)
|
# مسیر روی سرورِ عمومی هم فعال است، پس برگرداندنش یعنی هر کسی که شماره
|
||||||
|
# را بداند میتواند وارد شود.
|
||||||
|
if phone in settings.TEST_LOGIN_PHONES:
|
||||||
|
logger.info('OTP request for configured test phone: phone_suffix=%s', phone[-4:])
|
||||||
|
data = {
|
||||||
|
'success': True,
|
||||||
|
'devOtp': '',
|
||||||
|
'note': 'این شمارهی تستِ تیم است؛ کدِ ثابتش در تنظیماتِ سرور تعریف شده و پیامکی ارسال نمیشود.',
|
||||||
|
}
|
||||||
|
return Response(RequestOTPResponseSerializer(data).data)
|
||||||
|
|
||||||
|
# حداقل فاصلهی زمانی بین دو درخواستِ همون شماره (burst protection)؛
|
||||||
|
# قبل از شمارندهی ساعتی چک میشه تا یک درخواستِ بلاکشده سهمیهی
|
||||||
|
# ساعتی رو مصرف نکنه. کلید مقدارِ epoch انقضا رو ذخیره میکنه تا
|
||||||
|
# retryAfter/هدرِ Retry-After بدونِ خواندنِ اضافه محاسبه بشه.
|
||||||
|
phone_cooldown_key = f'otp_request_phone_cooldown:{phone}'
|
||||||
|
cooldown_seconds = settings.OTP_REQUEST_PHONE_COOLDOWN_SECONDS
|
||||||
|
now_epoch = timezone.now().timestamp()
|
||||||
|
cooldown_expires_at_epoch = now_epoch + cooldown_seconds
|
||||||
|
if not cache.add(phone_cooldown_key, cooldown_expires_at_epoch, timeout=cooldown_seconds):
|
||||||
|
stored_expiry_epoch = cache.get(phone_cooldown_key)
|
||||||
|
if stored_expiry_epoch is None:
|
||||||
|
# بینِ شکستِ add و این get دقیقاً منقضی شده؛ عملاً دیگه
|
||||||
|
# cooldownی در کار نیست، اما برای رفتارِ یکدست یک
|
||||||
|
# retry_after حداقلی برمیگردونیم (کلاینت با تلاشِ بعدی
|
||||||
|
# مسیرِ عادی رو طی میکنه).
|
||||||
|
retry_after = 1
|
||||||
|
else:
|
||||||
|
retry_after = max(1, math.ceil(stored_expiry_epoch - now_epoch))
|
||||||
|
logger.warning(
|
||||||
|
'OTP request phone cooldown active: phone_suffix=%s retry_after_seconds=%s',
|
||||||
|
phone[-4:], retry_after,
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
'detail': f'لطفاً {retry_after} ثانیه صبر کنید و دوباره درخواست کد بدهید.',
|
||||||
|
'retryAfter': retry_after,
|
||||||
|
},
|
||||||
|
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
headers={'Retry-After': str(retry_after)},
|
||||||
|
)
|
||||||
|
|
||||||
|
# محدودیتِ صدورِ OTP بر اساسِ شماره، مستقل از throttleِ IP-محورِ بالا
|
||||||
|
# (وگرنه یه مهاجم میتونه بینِ چند IP پخش کنه). cache.add فقط وقتی
|
||||||
|
# کلید نیست مقدار میذاره، پس اتمیکه.
|
||||||
|
# شماره پیش از اینجا به 09XXXXXXXXX نرمال شده (accounts/phone.py)، پس
|
||||||
|
# املای دیگرِ همان شماره سهمِ جدا نمیگیرد.
|
||||||
|
phone_requests_key = f'otp_request_phone_attempts:{phone}'
|
||||||
|
if cache.add(phone_requests_key, 1, timeout=settings.OTP_REQUEST_PHONE_WINDOW_SECONDS):
|
||||||
|
attempt_count = 1
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
attempt_count = cache.incr(phone_requests_key)
|
||||||
|
except ValueError:
|
||||||
|
# بینِ add و incr دقیقاً منقضی شده؛ پنجره رو از نو شروع کن.
|
||||||
|
cache.set(phone_requests_key, 1, timeout=settings.OTP_REQUEST_PHONE_WINDOW_SECONDS)
|
||||||
|
attempt_count = 1
|
||||||
|
|
||||||
|
if attempt_count > settings.OTP_REQUEST_PHONE_MAX_ATTEMPTS:
|
||||||
|
logger.warning(
|
||||||
|
'OTP request phone limit exceeded: phone_suffix=%s attempt_count=%s max_attempts=%s',
|
||||||
|
phone[-4:], attempt_count, settings.OTP_REQUEST_PHONE_MAX_ATTEMPTS,
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
{'detail': 'تعداد درخواستهای کد OTP برای این شماره بیش از حد مجاز است.'},
|
||||||
|
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
# سقفهای دیتابیسی: cooldown و سقفِ ساعتیِ بالا در کشِ LocMem و مالِ هر
|
||||||
|
# workerاند (روی سرور ×۳). اینها از ردیفهای OTPCode شمرده میشوند و بینِ
|
||||||
|
# workerها مشترکاند. زمانِ صدور = expires_at − OTP_TTL_SECONDS (مدل
|
||||||
|
# created_at ندارد). بررسی و ساختِ ردیف در یک تراکنشاند؛ با
|
||||||
|
# transaction_mode=IMMEDIATE دو درخواستِ همزمان پشتِ هم اجرا میشوند
|
||||||
|
# (همزمانیِ بینِ دو workerِ واقعی با تست پوشش داده نشده).
|
||||||
|
# فراخوانیِ کاوهنگار عمداً بیرونِ تراکنش است تا قفلِ نوشتن حینِ شبکه نماند.
|
||||||
|
now = timezone.now()
|
||||||
|
ttl = timezone.timedelta(seconds=settings.OTP_TTL_SECONDS)
|
||||||
|
day_window_expiry = now - timezone.timedelta(days=1) + ttl
|
||||||
|
with transaction.atomic():
|
||||||
|
phone_codes = OTPCode.objects.filter(phone=phone)
|
||||||
|
last_expiry = phone_codes.order_by('-expires_at').values_list('expires_at', flat=True).first()
|
||||||
|
if cooldown_seconds and last_expiry is not None:
|
||||||
|
elapsed = (now - (last_expiry - ttl)).total_seconds()
|
||||||
|
if elapsed < cooldown_seconds:
|
||||||
|
retry_after = max(1, math.ceil(cooldown_seconds - elapsed))
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
'detail': f'لطفاً {retry_after} ثانیه صبر کنید و دوباره درخواست کد بدهید.',
|
||||||
|
'retryAfter': retry_after,
|
||||||
|
},
|
||||||
|
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
headers={'Retry-After': str(retry_after)},
|
||||||
|
)
|
||||||
|
if phone_codes.filter(expires_at__gt=day_window_expiry).count() >= settings.OTP_REQUEST_PHONE_DAILY_MAX:
|
||||||
|
logger.warning('OTP request phone daily limit exceeded: phone_suffix=%s', phone[-4:])
|
||||||
|
return Response(
|
||||||
|
{'detail': 'سقفِ روزانهی درخواستِ کد برای این شماره پر شده است؛ فردا دوباره تلاش کنید.'},
|
||||||
|
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
)
|
||||||
|
if OTPCode.objects.filter(expires_at__gt=day_window_expiry).count() >= settings.OTP_SMS_GLOBAL_DAILY_MAX:
|
||||||
|
# یا حملهی SMS pumping است یا رشدِ واقعی؛ هر دو یعنی کسی باید نگاه کند.
|
||||||
|
logger.error(
|
||||||
|
'OTP global daily SMS cap reached: cap=%s phone_suffix=%s',
|
||||||
|
settings.OTP_SMS_GLOBAL_DAILY_MAX, phone[-4:],
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
{'detail': 'ارسالِ پیامک موقتاً ممکن نیست؛ لطفاً کمی بعد دوباره تلاش کنید.'},
|
||||||
|
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
# فقط آخرین کد صادرشده معتبر است.
|
||||||
|
OTPCode.objects.filter(phone=phone, is_used=False).update(is_used=True)
|
||||||
|
# ۶ رقم: با ۴ رقم و محدودیتِ تلاشِ per-worker، گرفتنِ حسابِ یک نفرِ مشخص
|
||||||
|
# روزی چند درصد شانس داشت. فرانت تا ۶ رقم را میپذیرد.
|
||||||
|
code = f'{secrets.randbelow(1_000_000):06d}'
|
||||||
|
otp_row = OTPCode.objects.create(phone=phone, code=code, expires_at=now + ttl)
|
||||||
|
# کاوهنگار تا چند ثانیه طول میکشد؛ روزِ لانچ این پرترافیکترین مسیر است و اتصالِ دیتابیس
|
||||||
|
# در این مدت به pool برمیگردد (config/db.py).
|
||||||
|
release_db_connection()
|
||||||
|
try:
|
||||||
|
send_otp_sms(phone=phone, code=code)
|
||||||
|
except OTPDeliveryError:
|
||||||
|
# ارسال شکست خورده یعنی کاربر کدی نگرفته؛ cooldown رو پاک میکنیم
|
||||||
|
# تا مجبور نشه ۲ دقیقه بدونِ داشتنِ کد منتظر بمونه، و ردیفِ کد رو هم
|
||||||
|
# حذف میکنیم تا در cooldown و سقفِ روزانهی دیتابیسی شمرده نشه.
|
||||||
|
# سقفِ ساعتیِ کش و throttleِ IP دستنخورده میمونن (محدودیتِ حجم).
|
||||||
|
cache.delete(phone_cooldown_key)
|
||||||
|
otp_row.delete()
|
||||||
|
raise
|
||||||
|
|
||||||
|
# devOtp فقط در محیط dev برمیگردد؛ در پروداکشن کد فقط از طریق پیامک تحویل داده میشود.
|
||||||
|
note = 'در پروداکشن این کد پیامک میشه.'
|
||||||
|
if settings.DEV_MASTER_OTP is not None:
|
||||||
|
note = f'در پروداکشن این کد پیامک میشه؛ برای تست همیشه {settings.DEV_MASTER_OTP} هم قبول میشه.'
|
||||||
data = {
|
data = {
|
||||||
'success': True,
|
'success': True,
|
||||||
'devOtp': code,
|
'devOtp': code if settings.DEBUG else '',
|
||||||
'note': f'در پروداکشن این کد پیامک میشه؛ برای تست همیشه {settings.DEV_MASTER_OTP} هم قبول میشه.',
|
'note': note,
|
||||||
}
|
}
|
||||||
return Response(RequestOTPResponseSerializer(data).data)
|
return Response(RequestOTPResponseSerializer(data).data)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_phone_otp(phone: str, otp: str) -> Response | None:
|
||||||
|
"""
|
||||||
|
کدِ پیامکیِ این شماره را میسنجد و مصرف میکند؛ None یعنی درست بود، وگرنه پاسخِ خطا.
|
||||||
|
|
||||||
|
مشترکِ ورود (LoginView) و حذفِ حساب (DeleteAccountView): هر دو باید همان سقفِ تلاشِ
|
||||||
|
شماره، همان قفلِ ردیف و همان باطل شدنِ کد پس از چند حدسِ غلط را داشته باشند. پیش از
|
||||||
|
۲۰۲۶-۱۰-۰۲ این منطق فقط داخلِ LoginView بود.
|
||||||
|
"""
|
||||||
|
# محدودیت تلاش بر اساس شماره تلفن (مستقل از throttle IP-محورِ هر view).
|
||||||
|
phone_attempts_key = f'otp_login_phone_attempts:{phone}'
|
||||||
|
if cache.add(phone_attempts_key, 1, timeout=settings.OTP_LOGIN_PHONE_WINDOW_SECONDS):
|
||||||
|
attempt_count = 1
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
attempt_count = cache.incr(phone_attempts_key)
|
||||||
|
except ValueError:
|
||||||
|
# بینِ add و incr دقیقاً منقضی شده؛ پنجره رو از نو شروع کن.
|
||||||
|
cache.set(phone_attempts_key, 1, timeout=settings.OTP_LOGIN_PHONE_WINDOW_SECONDS)
|
||||||
|
attempt_count = 1
|
||||||
|
|
||||||
|
if attempt_count > settings.OTP_LOGIN_PHONE_MAX_ATTEMPTS:
|
||||||
|
# هیچ سکرتی (OTP، token) لاگ نمیشه؛ از شماره فقط ۴ رقمِ آخر.
|
||||||
|
logger.warning(
|
||||||
|
'OTP login phone attempt limit exceeded: phone_suffix=%s attempt_count=%s max_attempts=%s',
|
||||||
|
phone[-4:], attempt_count, settings.OTP_LOGIN_PHONE_MAX_ATTEMPTS,
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
{'detail': 'تعداد تلاشهای ورود برای این شماره بیش از حد مجاز است.'},
|
||||||
|
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
# قفل ردیف OTP جلوی مصرف دوبارهی یک کد یکبارمصرف را در درخواستهای همزمان میگیرد.
|
||||||
|
with transaction.atomic():
|
||||||
|
test_code = settings.TEST_LOGIN_PHONES.get(phone, '')
|
||||||
|
if settings.DEV_MASTER_OTP is not None and otp == settings.DEV_MASTER_OTP:
|
||||||
|
return None
|
||||||
|
if test_code and secrets.compare_digest(test_code, otp):
|
||||||
|
# شمارهی تستِ تیم. compare_digest تا نتیجه از روی زمانِ پاسخ قابلِ استنتاج نباشه.
|
||||||
|
logger.info('Login via configured test phone: phone_suffix=%s', phone[-4:])
|
||||||
|
return None
|
||||||
|
otp_obj = OTPCode.objects.select_for_update().filter(
|
||||||
|
phone=phone, code=otp, is_used=False, expires_at__gte=timezone.now()
|
||||||
|
).order_by('-id').first()
|
||||||
|
if otp_obj:
|
||||||
|
otp_obj.is_used = True
|
||||||
|
otp_obj.save(update_fields=['is_used'])
|
||||||
|
return None
|
||||||
|
_count_failed_attempt(phone)
|
||||||
|
return Response({'detail': 'کد OTP نامعتبر یا منقضیشده است.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
class LoginView(APIView):
|
class LoginView(APIView):
|
||||||
"""POST /api/auth/login — ورود با OTP"""
|
"""POST /api/auth/login — ورود با OTP"""
|
||||||
|
|
||||||
|
throttle_scope = 'otp_login'
|
||||||
|
throttle_classes = [ScopedRateThrottle]
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
request=LoginRequestSerializer,
|
request=LoginRequestSerializer,
|
||||||
responses=LoginResponseSerializer,
|
responses=LoginResponseSerializer,
|
||||||
@@ -99,37 +291,35 @@ class LoginView(APIView):
|
|||||||
phone = serializer.validated_data['phone']
|
phone = serializer.validated_data['phone']
|
||||||
otp = serializer.validated_data['otp']
|
otp = serializer.validated_data['otp']
|
||||||
|
|
||||||
valid = False
|
error = verify_phone_otp(phone, otp)
|
||||||
if otp == settings.DEV_MASTER_OTP:
|
if error is not None:
|
||||||
valid = True
|
record_login_event(
|
||||||
else:
|
request,
|
||||||
otp_qs = OTPCode.objects.filter(
|
LoginEvent.LOGIN_BLOCKED if error.status_code == status.HTTP_429_TOO_MANY_REQUESTS
|
||||||
phone=phone, code=otp, is_used=False, expires_at__gte=timezone.now()
|
else LoginEvent.LOGIN_FAILED,
|
||||||
).order_by('-id')
|
phone=phone,
|
||||||
otp_obj = otp_qs.first()
|
)
|
||||||
if otp_obj:
|
return error
|
||||||
otp_obj.is_used = True
|
|
||||||
otp_obj.save(update_fields=['is_used'])
|
|
||||||
valid = True
|
|
||||||
|
|
||||||
if not valid:
|
with transaction.atomic():
|
||||||
return Response({'detail': 'کد OTP نامعتبر یا منقضیشده است.'}, status=status.HTTP_400_BAD_REQUEST)
|
user, created = User.objects.get_or_create(phone=phone, defaults={'name': ''})
|
||||||
|
|
||||||
user, _ = User.objects.get_or_create(
|
# ثبتِ فعالیتِ کاربر برای ورودِ موفق.
|
||||||
phone=phone,
|
user.last_activity_at = timezone.now()
|
||||||
defaults={'is_guest': False, 'name': ''},
|
user.save(update_fields=['last_activity_at'])
|
||||||
)
|
|
||||||
if user.is_guest:
|
|
||||||
user.is_guest = False
|
|
||||||
user.save(update_fields=['is_guest'])
|
|
||||||
|
|
||||||
refresh_token = uuid.uuid4().hex
|
if created:
|
||||||
Session.objects.create(
|
# آفرهای ثبتنام (مثل یک هفته رایگان) از طریق discount/receivers.py روی این سیگنال اعمال میشوند.
|
||||||
user=user,
|
from .signals import user_registered
|
||||||
refresh_token=refresh_token,
|
user_registered.send(sender=User, user_id=user.id)
|
||||||
device_id=request.data.get('deviceId', ''),
|
|
||||||
expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
|
refresh_token = uuid.uuid4().hex
|
||||||
)
|
Session.objects.create(
|
||||||
|
user=user,
|
||||||
|
refresh_token=hash_refresh_token(refresh_token),
|
||||||
|
expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
|
||||||
|
)
|
||||||
|
record_login_event(request, LoginEvent.LOGIN_OK, user_id=user.id, phone=phone)
|
||||||
access_token = issue_access_token(user.id)
|
access_token = issue_access_token(user.id)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@@ -140,9 +330,115 @@ class LoginView(APIView):
|
|||||||
return Response(LoginResponseSerializer(data).data)
|
return Response(LoginResponseSerializer(data).data)
|
||||||
|
|
||||||
|
|
||||||
|
def _count_failed_attempt(phone: str) -> None:
|
||||||
|
"""حدسِ غلط روی کدهای فعالِ این شماره؛ کدی که به سقف برسد باطل میشود."""
|
||||||
|
active = OTPCode.objects.filter(phone=phone, is_used=False, expires_at__gte=timezone.now())
|
||||||
|
active.update(failed_attempts=F('failed_attempts') + 1)
|
||||||
|
active.filter(failed_attempts__gte=settings.OTP_MAX_FAILED_ATTEMPTS).update(is_used=True)
|
||||||
|
|
||||||
|
|
||||||
|
# سوابقِ پرداخت سندِ مالیاند و با حذفِ حساب نمیروند (تراکنشها با سفارش میمانند).
|
||||||
|
# Conversion هم میماند: ردیفِ «کاربرِ معرفیشده» سابقهی پورسانتِ *معرف* است، نه دادهی این
|
||||||
|
# کاربر؛ تا ۲۰۲۶-۱۰-۰۳ با حذفِ حسابِ معرفیشده پورسانتِ معرف هم پاک میشد.
|
||||||
|
# Redemption و UserOffer هم میمانند: سقفِ مصرفِ کدِ تخفیف ردیفهای Redemption را میشمارد، پس پاک
|
||||||
|
# شدنشان یک کدِ یکبارمصرف را با «بساز، بخر، حذف کن» دوباره آزاد میکرد.
|
||||||
|
KEPT_ON_ACCOUNT_DELETE = ('payment.Order', 'statuslog.StatusChangeLog', 'affiliate.Conversion',
|
||||||
|
'discount.Redemption', 'discount.UserOffer', 'accounts.LoginEvent')
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteAccountView(APIView):
|
||||||
|
"""
|
||||||
|
POST /api/user/delete — حذفِ همیشگیِ حسابِ خودِ کاربر و همهی دادهاش.
|
||||||
|
|
||||||
|
چرا اینطور (۲۰۲۶-۱۰-۰۲):
|
||||||
|
- کاربرِ شمارهدار باید کدِ پیامکیِ تازه بدهد (همان verify_phone_otpِ ورود، با همان سقفِ حدس).
|
||||||
|
توکنِ access بهتنهایی کافی نیست: گوشیِ بازمانده دستِ یک بچهی دیگر یا توکنِ دزدیدهشده نباید
|
||||||
|
بتواند حساب را برای همیشه پاک کند.
|
||||||
|
- داده در همهی اپها پاک میشود (delete_users)، جز سفارشها و تراکنشهایشان که سندِ مالیاند.
|
||||||
|
- حسابهای دمو (TEST_LOGIN_PHONES) حذف نمیشوند؛ مشترکاند و برای ارائه پُرشدهاند.
|
||||||
|
- بعد از حذف، توکنهای همین کاربر کار نمیکنند چون کاربری با آن شناسه نمانده.
|
||||||
|
"""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
throttle_scope = 'account_delete'
|
||||||
|
throttle_classes = [ScopedRateThrottle]
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
request=DeleteAccountRequestSerializer,
|
||||||
|
responses=DeleteAccountResponseSerializer,
|
||||||
|
tags=['Identity & Profile (accounts)'],
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
from .management.commands.delete_users import delete_users
|
||||||
|
|
||||||
|
serializer = DeleteAccountRequestSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
v = serializer.validated_data
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
if user.phone in settings.TEST_LOGIN_PHONES:
|
||||||
|
return Response({'detail': 'حسابِ نمایشی را نمیشود حذف کرد.'}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
error = verify_phone_otp(user.phone, v['otp'])
|
||||||
|
if error is not None:
|
||||||
|
return error
|
||||||
|
|
||||||
|
logger.info('Account deleted by its owner: user_id=%s', user.id)
|
||||||
|
record_login_event(request, LoginEvent.ACCOUNT_DELETED, user_id=user.id, phone=user.phone or '')
|
||||||
|
# کدهای پیامکیِ شماره میمانند: سقفِ روزانهی پیامکِ هر شماره از همین ردیفها شمرده میشود.
|
||||||
|
delete_users([user.id], keep=KEPT_ON_ACCOUNT_DELETE, delete_otp_codes=False)
|
||||||
|
return Response(DeleteAccountResponseSerializer({'success': True}).data)
|
||||||
|
|
||||||
|
|
||||||
|
class LogoutView(APIView):
|
||||||
|
"""
|
||||||
|
POST /api/auth/logout — خروج از حساب.
|
||||||
|
|
||||||
|
refreshTokenِ این دستگاه (اگر فرستاده شود) باطل میشود و access tokenهای
|
||||||
|
صادرشده *پیش از ثانیهی خروج* رد میشوند. دستگاههای دیگر با refreshِ
|
||||||
|
خودشان بیصدا توکنِ تازه میگیرند؛ فقط همین دستگاه بیرون میماند.
|
||||||
|
|
||||||
|
⚠️ مقایسه ثانیهای است: توکنی که در همان ثانیهی خروج صادر شده معتبر
|
||||||
|
میماند (بازتولید شد). با AUTH_USER_CACHE_TTL>0 و کشِ per-worker، کشِ
|
||||||
|
کاربر فقط در همین worker پاک میشود؛ پیشفرض ۰ است و آن حالت تست نشده.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `user_profile_write` همان نرخِ مسیرهای همخانواده است.
|
||||||
|
throttle_scope = 'user_profile_write'
|
||||||
|
throttle_classes = [ScopedRateThrottle]
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
request=LogoutRequestSerializer, responses=LogoutResponseSerializer,
|
||||||
|
tags=['Identity & Profile (accounts)'],
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
serializer = LogoutRequestSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
refresh_token = serializer.validated_data.get('refreshToken') or ''
|
||||||
|
with transaction.atomic():
|
||||||
|
if refresh_token:
|
||||||
|
Session.objects.filter(
|
||||||
|
user_id=request.user.id, refresh_token=hash_refresh_token(refresh_token),
|
||||||
|
).update(revoked=True)
|
||||||
|
User.objects.filter(pk=request.user.id).update(tokens_valid_after=timezone.now())
|
||||||
|
invalidate_user_cache(request.user.id)
|
||||||
|
record_login_event(request, LoginEvent.LOGOUT, user_id=request.user.id, phone=request.user.phone or '')
|
||||||
|
return Response(LogoutResponseSerializer({'success': True}).data)
|
||||||
|
|
||||||
|
|
||||||
class RefreshTokenView(APIView):
|
class RefreshTokenView(APIView):
|
||||||
"""POST /api/auth/refresh — تمدید توکن"""
|
"""POST /api/auth/refresh — تمدید توکن"""
|
||||||
|
|
||||||
|
# این endpoint احرازهویت نمیخواد (خودِ refresh token شناسه است)، پس
|
||||||
|
# ScopedRateThrottle طبیعتاً روی IP کلید میخوره. یک توکنِ رفرش عادتاً
|
||||||
|
# فقط گاهبهگاه (نه با فرکانسِ بالا) باید استفاده بشه؛ این throttle
|
||||||
|
# جلوی سوءاستفاده از این endpoint بهعنوان یک عملیاتِ پرتکرارِ
|
||||||
|
# دیتابیس/تولیدِ توکن رو میگیره، بدون تغییری در rotation یا محافظتِ
|
||||||
|
# اتمیکِ replay که پایینتر پیادهسازی شده.
|
||||||
|
throttle_scope = 'token_refresh'
|
||||||
|
throttle_classes = [ScopedRateThrottle]
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
request=RefreshRequestSerializer,
|
request=RefreshRequestSerializer,
|
||||||
responses=RefreshResponseSerializer,
|
responses=RefreshResponseSerializer,
|
||||||
@@ -153,14 +449,33 @@ class RefreshTokenView(APIView):
|
|||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
refresh_token = serializer.validated_data['refreshToken']
|
refresh_token = serializer.validated_data['refreshToken']
|
||||||
|
|
||||||
session = Session.objects.filter(
|
# UPDATE اتمیک جلوی استفادهی همزمان از یک refresh token را میگیرد (replay protection).
|
||||||
refresh_token=refresh_token, revoked=False, expires_at__gte=timezone.now()
|
with transaction.atomic():
|
||||||
).first()
|
updated_count = Session.objects.filter(
|
||||||
if not session:
|
refresh_token=hash_refresh_token(refresh_token), revoked=False, expires_at__gte=timezone.now()
|
||||||
return Response({'detail': 'رفرشتوکن نامعتبر یا منقضیشده است.'}, status=status.HTTP_401_UNAUTHORIZED)
|
).update(revoked=True)
|
||||||
|
|
||||||
access_token = issue_access_token(session.user_id)
|
if not updated_count:
|
||||||
data = {'token': access_token, 'expiresIn': settings.ACCESS_TOKEN_TTL_SECONDS}
|
return Response({'detail': 'رفرشتوکن نامعتبر یا منقضیشده است.'}, status=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
old_session = Session.objects.get(refresh_token=hash_refresh_token(refresh_token))
|
||||||
|
|
||||||
|
# ثبتِ فعالیتِ کاربر برای رفرشِ موفقِ توکن.
|
||||||
|
User.objects.filter(pk=old_session.user_id).update(last_activity_at=timezone.now())
|
||||||
|
|
||||||
|
new_refresh_token = secrets.token_hex(32)
|
||||||
|
new_session = Session.objects.create(
|
||||||
|
user_id=old_session.user_id,
|
||||||
|
refresh_token=hash_refresh_token(new_refresh_token),
|
||||||
|
expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = issue_access_token(new_session.user_id)
|
||||||
|
data = {
|
||||||
|
'token': access_token,
|
||||||
|
'refreshToken': new_refresh_token,
|
||||||
|
'expiresIn': settings.ACCESS_TOKEN_TTL_SECONDS,
|
||||||
|
}
|
||||||
return Response(RefreshResponseSerializer(data).data)
|
return Response(RefreshResponseSerializer(data).data)
|
||||||
|
|
||||||
|
|
||||||
@@ -168,6 +483,10 @@ class SetUserGradeView(APIView):
|
|||||||
"""POST /api/user/grade — ثبت پایهی کاربر (نیاز به احراز هویت)"""
|
"""POST /api/user/grade — ثبت پایهی کاربر (نیاز به احراز هویت)"""
|
||||||
|
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
# بدونِ throttle، یک کاربرِ احرازهویتشده میتواند
|
||||||
|
# نامحدود درخواستِ نوشتن روی دیتابیس بفرستد.
|
||||||
|
throttle_scope = 'user_profile_write'
|
||||||
|
throttle_classes = [ScopedRateThrottle]
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
request=SetGradeRequestSerializer,
|
request=SetGradeRequestSerializer,
|
||||||
@@ -185,3 +504,101 @@ class SetUserGradeView(APIView):
|
|||||||
|
|
||||||
data = {'success': True, 'gradeId': grade_id}
|
data = {'success': True, 'gradeId': grade_id}
|
||||||
return Response(SetGradeResponseSerializer(data).data)
|
return Response(SetGradeResponseSerializer(data).data)
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_payload(user, profile) -> dict:
|
||||||
|
from . import jalali, public_id
|
||||||
|
|
||||||
|
return {
|
||||||
|
'userId': str(user.id),
|
||||||
|
'publicId': public_id.encode(user.id),
|
||||||
|
'phone': user.phone,
|
||||||
|
'name': user.name,
|
||||||
|
'avatar': user.avatar,
|
||||||
|
'gradeId': user.grade_id,
|
||||||
|
'birthYear': profile.birth_year,
|
||||||
|
'birthDate': jalali.to_string(profile.birth_date) if profile.birth_date else None,
|
||||||
|
'bio': profile.bio,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileView(APIView):
|
||||||
|
"""
|
||||||
|
GET /api/user/profile — اطلاعات پروفایل کاربر جاری
|
||||||
|
PATCH /api/user/profile — ویرایشِ همان اطلاعات (نیاز به احراز هویت)
|
||||||
|
|
||||||
|
PATCH برای صفحهی «بیا باهم آشنا بشیم» در اپ لازم است: آنجا اسم (و بعداً
|
||||||
|
آواتار) گرفته میشود و تا پیش از این هیچ مسیری برای نوشتنشان وجود نداشت —
|
||||||
|
فقط پایهی تحصیلی endpoint اختصاصیِ خودش را داشت.
|
||||||
|
"""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
# نوشتن است، پس همان throttleِ نوشتنِ پروفایل که SetUserGradeView دارد.
|
||||||
|
throttle_scope = 'user_profile_write'
|
||||||
|
throttle_classes = [ScopedRateThrottle]
|
||||||
|
|
||||||
|
def get_throttles(self):
|
||||||
|
# GET خواندنی است و نباید سهمیهی نوشتن را مصرف کند.
|
||||||
|
if self.request.method == 'GET':
|
||||||
|
return []
|
||||||
|
return super().get_throttles()
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
responses=ProfileResponseSerializer,
|
||||||
|
tags=['Identity & Profile (accounts)'],
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
user = request.user
|
||||||
|
# مدل Profile بهصورت lazy ساخته میشه چون هیچجای دیگهای (نه
|
||||||
|
# LoginView) رکورد Profile رو از قبل نمیسازه؛
|
||||||
|
# get_or_create اینجا امن است چون فقط توسطِ کاربرِ همون رکورد صدا
|
||||||
|
# زده میشه (OneToOne روی user_id جاری، نه ورودیِ خارجی).
|
||||||
|
profile, _ = Profile.objects.get_or_create(user_id=user.id)
|
||||||
|
|
||||||
|
return Response(ProfileResponseSerializer(_profile_payload(user, profile)).data)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
request=UpdateProfileRequestSerializer,
|
||||||
|
responses=ProfileResponseSerializer,
|
||||||
|
tags=['Identity & Profile (accounts)'],
|
||||||
|
)
|
||||||
|
def patch(self, request):
|
||||||
|
serializer = UpdateProfileRequestSerializer(data=request.data, partial=True)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
v = serializer.validated_data
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
profile, _ = Profile.objects.get_or_create(user_id=user.id)
|
||||||
|
|
||||||
|
user_fields = {'name': 'name', 'avatar': 'avatar'}
|
||||||
|
profile_fields = {'birthYear': 'birth_year', 'birthDate': 'birth_date', 'bio': 'bio'}
|
||||||
|
|
||||||
|
changed_user = [attr for key, attr in user_fields.items() if key in v]
|
||||||
|
changed_profile = [attr for key, attr in profile_fields.items() if key in v]
|
||||||
|
|
||||||
|
for key, attr in user_fields.items():
|
||||||
|
if key in v:
|
||||||
|
setattr(user, attr, v[key])
|
||||||
|
for key, attr in profile_fields.items():
|
||||||
|
if key in v:
|
||||||
|
setattr(profile, attr, v[key])
|
||||||
|
# سال و تاریخ نباید از هم جدا بیفتند: تاریخ سال را میسازد، و سالِ تنها
|
||||||
|
# (اپِ قدیمی) تاریخی را که دیگر با آن نمیخواند پاک میکند.
|
||||||
|
if 'birthDate' in v:
|
||||||
|
from . import jalali
|
||||||
|
profile.birth_year = jalali.from_gregorian(v['birthDate'])[0] if v['birthDate'] else None
|
||||||
|
changed_profile.append('birth_year')
|
||||||
|
elif 'birthYear' in v and profile.birth_date is not None:
|
||||||
|
from . import jalali
|
||||||
|
if jalali.from_gregorian(profile.birth_date)[0] != v['birthYear']:
|
||||||
|
profile.birth_date = None
|
||||||
|
changed_profile.append('birth_date')
|
||||||
|
|
||||||
|
# update_fields: فقط همان ستونهایی که واقعاً عوض شدهاند نوشته شوند تا
|
||||||
|
# PATCH همزمان از یک دستگاهِ دیگر، فیلدهای دستنخورده را بازنویسی نکند.
|
||||||
|
if changed_user:
|
||||||
|
user.save(update_fields=changed_user)
|
||||||
|
if changed_profile:
|
||||||
|
profile.save(update_fields=changed_profile)
|
||||||
|
|
||||||
|
return Response(ProfileResponseSerializer(_profile_payload(user, profile)).data)
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ from rest_framework import serializers
|
|||||||
class AffiliateMeResponseSerializer(serializers.Serializer):
|
class AffiliateMeResponseSerializer(serializers.Serializer):
|
||||||
referralCode = serializers.CharField()
|
referralCode = serializers.CharField()
|
||||||
commissionRate = serializers.FloatField()
|
commissionRate = serializers.FloatField()
|
||||||
|
referredCount = serializers.IntegerField(help_text='کاربرهایی که کدِ شما را ثبت کردهاند.')
|
||||||
|
paidCount = serializers.IntegerField(help_text='از میانِ آنها، کسانی که واقعاً پرداخت کردهاند.')
|
||||||
|
totalCommission = serializers.IntegerField(help_text='جمعِ پورسانتِ پرداختهای واقعی (تومان).')
|
||||||
|
|
||||||
|
|
||||||
class TrackRequestSerializer(serializers.Serializer):
|
class TrackRequestSerializer(serializers.Serializer):
|
||||||
referralCode = serializers.CharField()
|
referralCode = serializers.CharField()
|
||||||
referredUserId = serializers.CharField()
|
|
||||||
|
|
||||||
|
|
||||||
class TrackResponseSerializer(serializers.Serializer):
|
class TrackResponseSerializer(serializers.Serializer):
|
||||||
|
|||||||
47
affiliate/services.py
Normal file
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,3 +1 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
# Create your tests here.
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from drf_spectacular.utils import extend_schema
|
from drf_spectacular.utils import extend_schema
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.throttling import ScopedRateThrottle
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from .models import AffiliateAccount, Conversion
|
from .models import AffiliateAccount, Conversion
|
||||||
@@ -17,13 +18,23 @@ from .serializers import (
|
|||||||
class AffiliateMeView(APIView):
|
class AffiliateMeView(APIView):
|
||||||
"""GET /api/affiliate/me — حساب و کد ارجاع کاربر"""
|
"""GET /api/affiliate/me — حساب و کد ارجاع کاربر"""
|
||||||
|
|
||||||
|
# سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `read` همان نرخِ مسیرهای همخانواده است.
|
||||||
|
throttle_scope = 'read'
|
||||||
|
throttle_classes = [ScopedRateThrottle]
|
||||||
|
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
@extend_schema(responses=AffiliateMeResponseSerializer, tags=['Affiliate (affiliate)'])
|
@extend_schema(responses=AffiliateMeResponseSerializer, tags=['Affiliate (affiliate)'])
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
account, _ = AffiliateAccount.objects.get_or_create(user_id=request.user.id)
|
account, _ = AffiliateAccount.objects.get_or_create(user_id=request.user.id)
|
||||||
|
paid = account.conversions.filter(status='paid')
|
||||||
data = {'referralCode': account.referral_code, 'commissionRate': account.commission_rate}
|
data = {
|
||||||
|
'referralCode': account.referral_code,
|
||||||
|
'commissionRate': account.commission_rate,
|
||||||
|
'referredCount': account.conversions.count(),
|
||||||
|
'paidCount': paid.count(),
|
||||||
|
'totalCommission': sum(paid.values_list('commission_amount', flat=True)),
|
||||||
|
}
|
||||||
return Response(AffiliateMeResponseSerializer(data).data)
|
return Response(AffiliateMeResponseSerializer(data).data)
|
||||||
|
|
||||||
|
|
||||||
@@ -31,12 +42,23 @@ class TrackConversionView(APIView):
|
|||||||
"""
|
"""
|
||||||
POST /api/affiliate/track — ثبت کلیک/ارجاع
|
POST /api/affiliate/track — ثبت کلیک/ارجاع
|
||||||
|
|
||||||
طبق سند، این اپ فقط با referralCode به AffiliateAccount وصل میشه
|
طبق سند، این اپ فقط با referralCode به AffiliateAccount وصل میشه.
|
||||||
(نه توکن کاربر)، چون referredUserId خودش کاربر تازهارجاعشدهست که
|
referredUserId دیگه ورودیِ کلاینت نیست: چون این درخواست باید توسطِ
|
||||||
لزوماً صاحب حساب افیلیت نیست؛ پس اینجا permission پیشفرض (AllowAny)
|
خودِ کاربرِ تازهارجاعشده (بعد از لاگین/ثبتنام) زده بشه، هویتش رو
|
||||||
نگه داشته شده، دقیقاً مثل ValidateDiscountView در اپ discount.
|
از توکن احرازشده (request.user) میگیریم، نه از بدنهی درخواست.
|
||||||
|
|
||||||
|
# هویت از request.user گرفته میشه (نه بدنهی درخواست) چون یک
|
||||||
|
# referredUserIdِ کلاینتدار یعنی هر کلاینتِ ناشناس میتونست برای هر
|
||||||
|
# کاربرِ دیگهای Conversion بسازه یا با اشغالِ همهی userId ها (قیدِ
|
||||||
|
# unique رو دورزدن) مسیرِ ارجاعِ واقعی رو مسدود کنه.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
# referralCode نامعتبر ۴۰۴ میگیره (یک oracle قابلprobe)، پس throttle
|
||||||
|
# مجزا probe رو کند میکنه.
|
||||||
|
throttle_scope = 'affiliate_track'
|
||||||
|
throttle_classes = [ScopedRateThrottle]
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
request=TrackRequestSerializer, responses=TrackResponseSerializer, tags=['Affiliate (affiliate)']
|
request=TrackRequestSerializer, responses=TrackResponseSerializer, tags=['Affiliate (affiliate)']
|
||||||
)
|
)
|
||||||
@@ -44,18 +66,8 @@ class TrackConversionView(APIView):
|
|||||||
serializer = TrackRequestSerializer(data=request.data)
|
serializer = TrackRequestSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
v = serializer.validated_data
|
v = serializer.validated_data
|
||||||
|
|
||||||
account = get_object_or_404(AffiliateAccount, referral_code=v['referralCode'])
|
account = get_object_or_404(AffiliateAccount, referral_code=v['referralCode'])
|
||||||
referred_user_id = int(v['referredUserId'])
|
referred_user_id = request.user.id
|
||||||
|
|
||||||
# ⚠️ اصلاح امنیتی: قبلاً referredUserId بدون هیچ اعتبارسنجیای
|
|
||||||
# مستقیم مصرف میشد؛ یعنی میشد برای یک کاربر ناموجود Conversion
|
|
||||||
# ساخت یا با فرستادن userId خودِ صاحب حساب affiliate، به خودش
|
|
||||||
# ارجاع داد (self-referral). حالا هر دو حالت رد میشن.
|
|
||||||
from accounts.models import User as AccountUser
|
|
||||||
|
|
||||||
if not AccountUser.objects.filter(pk=referred_user_id).exists():
|
|
||||||
raise ValidationError({'referredUserId': 'چنین کاربری وجود ندارد.'})
|
|
||||||
|
|
||||||
if referred_user_id == account.user_id:
|
if referred_user_id == account.user_id:
|
||||||
raise ValidationError({'referredUserId': 'امکان ثبت ارجاع برای خودِ صاحب کد وجود ندارد.'})
|
raise ValidationError({'referredUserId': 'امکان ثبت ارجاع برای خودِ صاحب کد وجود ندارد.'})
|
||||||
@@ -72,6 +84,10 @@ class TrackConversionView(APIView):
|
|||||||
class ConversionsListView(APIView):
|
class ConversionsListView(APIView):
|
||||||
"""GET /api/affiliate/conversions — لیست تبدیلها و کمیسیون"""
|
"""GET /api/affiliate/conversions — لیست تبدیلها و کمیسیون"""
|
||||||
|
|
||||||
|
# سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `read` همان نرخِ مسیرهای همخانواده است.
|
||||||
|
throttle_scope = 'read'
|
||||||
|
throttle_classes = [ScopedRateThrottle]
|
||||||
|
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
@extend_schema(responses=ConversionListResponseSerializer, tags=['Affiliate (affiliate)'])
|
@extend_schema(responses=ConversionListResponseSerializer, tags=['Affiliate (affiliate)'])
|
||||||
|
|||||||
76
ai_tutor/concurrency.py
Normal file
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()
|
||||||
0
ai_tutor/management/__init__.py
Normal file
0
ai_tutor/management/__init__.py
Normal file
0
ai_tutor/management/commands/__init__.py
Normal file
0
ai_tutor/management/commands/__init__.py
Normal file
135
ai_tutor/management/commands/ai_feedback_report.py
Normal file
135
ai_tutor/management/commands/ai_feedback_report.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
"""
|
||||||
|
گزارشِ کیفیت و مصرفِ دستیارِ هوش مصنوعی.
|
||||||
|
|
||||||
|
سینیور دو چیز خواست و هر دو از همین یک جدول درمیآیند:
|
||||||
|
«یک ساینتیشن برای لایک/دیسلایک بگیریم که بعداً بشود چک کرد کدام بهتر بوده»
|
||||||
|
«اگر لیمیت خورد یا تمام شد بهم بگید که شارژش کنیم»
|
||||||
|
|
||||||
|
نمونه:
|
||||||
|
python manage.py ai_feedback_report
|
||||||
|
python manage.py ai_feedback_report --days 7 --by grade_title
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db.models import Avg, Count, Q, Sum
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from ai_tutor.models import Message
|
||||||
|
|
||||||
|
# قیمتِ تقریبیِ هر یک میلیون توکن (دلار) برای برآوردِ هزینه. عدد دقیق نیست و
|
||||||
|
# فقط برای «چقدر مانده / کِی باید شارژ کنیم» است.
|
||||||
|
_PRICE_PER_MTOK = {
|
||||||
|
'gpt-4o-mini': (0.15, 0.60),
|
||||||
|
'gpt-4o': (2.50, 10.00),
|
||||||
|
}
|
||||||
|
_GROUPABLE = ['prompt_version', 'model_name', 'grade_title', 'retrieval_collection']
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'نسبتِ 👍/👎 و مصرفِ توکنِ پاسخهای دستیار، به تفکیکِ نسخهی پرامپت/مدل/پایه.'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--days', type=int, default=30)
|
||||||
|
parser.add_argument('--by', choices=_GROUPABLE, default='prompt_version')
|
||||||
|
|
||||||
|
def handle(self, *args, **opts):
|
||||||
|
since = timezone.now() - timedelta(days=opts['days'])
|
||||||
|
field = opts['by']
|
||||||
|
|
||||||
|
qs = Message.objects.filter(role='assistant', created_at__gte=since)
|
||||||
|
total = qs.count()
|
||||||
|
if not total:
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
f'در {opts["days"]} روزِ گذشته هیچ پاسخِ دستیاری ثبت نشده است.'
|
||||||
|
))
|
||||||
|
return
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
qs.values(field)
|
||||||
|
.annotate(
|
||||||
|
answers=Count('id'),
|
||||||
|
liked=Count('id', filter=Q(feedback__is_helpful=True)),
|
||||||
|
disliked=Count('id', filter=Q(feedback__is_helpful=False)),
|
||||||
|
with_context=Count('id', filter=Q(chunk_count__gt=0)),
|
||||||
|
avg_latency=Avg('latency_ms'),
|
||||||
|
tok_in=Sum('prompt_tokens'),
|
||||||
|
tok_out=Sum('completion_tokens'),
|
||||||
|
)
|
||||||
|
.order_by('-answers')
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.MIGRATE_HEADING(
|
||||||
|
f'\n{opts["days"]} روزِ گذشته — {total} پاسخِ دستیار، گروهبندی بر اساسِ {field}\n'
|
||||||
|
))
|
||||||
|
header = f'{field:<28} {"پاسخ":>6} {"👍":>5} {"👎":>5} {"رضایت":>8} {"با منبع":>9} {"میانگین ms":>11}'
|
||||||
|
self.stdout.write(header)
|
||||||
|
self.stdout.write('─' * len(header))
|
||||||
|
for r in rows:
|
||||||
|
rated = r['liked'] + r['disliked']
|
||||||
|
ratio = f"{100 * r['liked'] / rated:.0f}%" if rated else '—'
|
||||||
|
ctx = f"{100 * r['with_context'] / r['answers']:.0f}%"
|
||||||
|
label = (r[field] or '(خالی)')[:27]
|
||||||
|
self.stdout.write(
|
||||||
|
f"{label:<28} {r['answers']:>6} {r['liked']:>5} {r['disliked']:>5} "
|
||||||
|
f"{ratio:>8} {ctx:>9} {(r['avg_latency'] or 0):>11.0f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
rated_total = qs.filter(feedback__isnull=False).count()
|
||||||
|
self.stdout.write('')
|
||||||
|
self.stdout.write(
|
||||||
|
f'رأی دادهشده: {rated_total} از {total} پاسخ '
|
||||||
|
f'({100 * rated_total / total:.0f}%).'
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- مصرف و هزینه ---------------------------------------------------
|
||||||
|
self.stdout.write(self.style.MIGRATE_HEADING('\nمصرفِ توکن (برای پیگیریِ شارژ)\n'))
|
||||||
|
# ⚠️ پاسخِ کششده توکنِ فراخوانیِ اصلی را کپی میکند و هیچ هزینهای
|
||||||
|
# نداشته؛ اگر بشماریمش، گزارشی که کارش تصمیمگیری دربارهی شارژ است
|
||||||
|
# به اندازهی نرخِ اصابتِ کش هزینه را بیشتر نشان میدهد.
|
||||||
|
usage = (
|
||||||
|
qs.exclude(model_name='').filter(from_cache=False)
|
||||||
|
.values('model_name')
|
||||||
|
.annotate(tok_in=Sum('prompt_tokens'), tok_out=Sum('completion_tokens'), n=Count('id'))
|
||||||
|
.order_by('-n')
|
||||||
|
)
|
||||||
|
grand = 0.0
|
||||||
|
for u in usage:
|
||||||
|
tin, tout = u['tok_in'] or 0, u['tok_out'] or 0
|
||||||
|
price = _PRICE_PER_MTOK.get(u['model_name'])
|
||||||
|
if price:
|
||||||
|
cost = tin / 1e6 * price[0] + tout / 1e6 * price[1]
|
||||||
|
grand += cost
|
||||||
|
cost_text = f'≈ ${cost:.2f}'
|
||||||
|
else:
|
||||||
|
cost_text = 'قیمتِ این مدل در جدول نیست'
|
||||||
|
self.stdout.write(
|
||||||
|
f" {u['model_name']:<20} {u['n']:>5} پاسخ ورودی {tin:>9,} خروجی {tout:>9,} {cost_text}"
|
||||||
|
)
|
||||||
|
if grand:
|
||||||
|
self.stdout.write(self.style.WARNING(f'\n جمعِ تقریبی: ${grand:.2f}'))
|
||||||
|
cached = qs.filter(from_cache=True).count()
|
||||||
|
self.stdout.write(self.style.HTTP_INFO(
|
||||||
|
' توجه: پاسخهای استریمی usage برنمیگردانند و توکنشان صفر شمرده میشود، '
|
||||||
|
'پس عددِ واقعی از این بیشتر است.'
|
||||||
|
))
|
||||||
|
if cached:
|
||||||
|
self.stdout.write(self.style.HTTP_INFO(
|
||||||
|
f' {cached} پاسخ از کش آمده و در هزینه شمرده نشده (فراخوانیِ تازهای نداشته).'
|
||||||
|
))
|
||||||
|
|
||||||
|
# --- بدترین پاسخها ---------------------------------------------------
|
||||||
|
worst = (
|
||||||
|
qs.filter(feedback__is_helpful=False)
|
||||||
|
.select_related('feedback')
|
||||||
|
.order_by('-created_at')[:10]
|
||||||
|
)
|
||||||
|
if worst:
|
||||||
|
self.stdout.write(self.style.MIGRATE_HEADING('\nآخرین پاسخهای 👎 (برای بازبینیِ دستی)\n'))
|
||||||
|
for m in worst:
|
||||||
|
reason = (m.feedback.reason or '').strip()
|
||||||
|
self.stdout.write(
|
||||||
|
f" #{m.id} [{m.prompt_version or '—'} / {m.grade_title or '—'} / "
|
||||||
|
f"chunk={m.chunk_count}] {m.text[:70]}…"
|
||||||
|
+ (f'\n دلیلِ کاربر: {reason}' if reason else '')
|
||||||
|
)
|
||||||
29
ai_tutor/migrations/0002_askidempotency.py
Normal file
29
ai_tutor/migrations/0002_askidempotency.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 5.2.17 on 2026-08-19 11:36
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('ai_tutor', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AskIdempotency',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('user_id', models.IntegerField(db_index=True)),
|
||||||
|
('idempotency_key', models.CharField(max_length=100)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('assistant_message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='ai_tutor.message')),
|
||||||
|
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='ai_tutor.conversation')),
|
||||||
|
('user_message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='ai_tutor.message')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'constraints': [models.UniqueConstraint(fields=('user_id', 'idempotency_key'), name='uniq_ask_idempotency_key')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.17
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('ai_tutor', '0002_askidempotency'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='askidempotency',
|
||||||
|
name='suggested_follow_ups',
|
||||||
|
field=models.JSONField(default=list),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.17 on 2026-08-28 18:56
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('ai_tutor', '0003_askidempotency_suggested_follow_ups'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='conversation',
|
||||||
|
name='lesson_number',
|
||||||
|
field=models.IntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='conversation',
|
||||||
|
name='subject',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=100),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Generated by Django 5.2.17 on 2026-09-12 14:26
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('ai_tutor', '0004_conversation_lesson_number_conversation_subject'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='message',
|
||||||
|
name='chunk_count',
|
||||||
|
field=models.PositiveSmallIntegerField(default=0),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='message',
|
||||||
|
name='completion_tokens',
|
||||||
|
field=models.PositiveIntegerField(default=0),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='message',
|
||||||
|
name='from_cache',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='message',
|
||||||
|
name='grade_title',
|
||||||
|
field=models.CharField(blank=True, max_length=50),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='message',
|
||||||
|
name='latency_ms',
|
||||||
|
field=models.PositiveIntegerField(default=0),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='message',
|
||||||
|
name='model_name',
|
||||||
|
field=models.CharField(blank=True, max_length=100),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='message',
|
||||||
|
name='prompt_tokens',
|
||||||
|
field=models.PositiveIntegerField(default=0),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='message',
|
||||||
|
name='prompt_version',
|
||||||
|
field=models.CharField(blank=True, db_index=True, max_length=50),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='message',
|
||||||
|
name='retrieval_collection',
|
||||||
|
field=models.CharField(blank=True, max_length=200),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='message',
|
||||||
|
name='retrieval_filter_value',
|
||||||
|
field=models.CharField(blank=True, max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='messagefeedback',
|
||||||
|
name='created_at',
|
||||||
|
field=models.DateTimeField(auto_now_add=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='messagefeedback',
|
||||||
|
name='reason',
|
||||||
|
field=models.CharField(blank=True, max_length=300),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='messagefeedback',
|
||||||
|
name='updated_at',
|
||||||
|
field=models.DateTimeField(auto_now=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -6,6 +6,12 @@ class Conversation(models.Model):
|
|||||||
lesson_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Lesson
|
lesson_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Lesson
|
||||||
chapter_id = models.IntegerField(null=True, blank=True) # شناسهی فصل RAG (خارج از content/)
|
chapter_id = models.IntegerField(null=True, blank=True) # شناسهی فصل RAG (خارج از content/)
|
||||||
grade_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Grade
|
grade_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Grade
|
||||||
|
# metadataِ retrieval برای لایهی RAG (اپ rag): نامِ درس (رشته) و
|
||||||
|
# شمارهی درس. اولین بار از بدنهی POST /api/ai/ask میآن و اینجا ذخیره
|
||||||
|
# میشن تا ادامهی همون مکالمه دوباره لازم نباشه فرستاده بشن. نامِ
|
||||||
|
# collectionِ Qdrant از روی subject + عنوانِ گرید ساخته میشه.
|
||||||
|
subject = models.CharField(max_length=100, blank=True, default='')
|
||||||
|
lesson_number = models.IntegerField(null=True, blank=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@@ -19,7 +25,33 @@ class Message(models.Model):
|
|||||||
role = models.CharField(max_length=10, choices=ROLE_CHOICES)
|
role = models.CharField(max_length=10, choices=ROLE_CHOICES)
|
||||||
text = models.TextField()
|
text = models.TextField()
|
||||||
question_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Question
|
question_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Question
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
# فقط برای پیامِ کاربر: این سوال از کجا پرداخت شد. «free» از سهمیهی روزانه، «none» هیچ (رد در صافیِ
|
||||||
|
# سرور یا جوابِ کششده که به API نرسید). سهمیهی امروز = تعدادِ «free»های امروز. «credit» (اعتبارِ بستهی
|
||||||
|
# سوال) فقط روی پیامهای پیش از subscription.0017 است؛ دیگر نوشته نمیشود ولی ردِ تاریخیِ آن پیامهاست.
|
||||||
|
CHARGE_CHOICES = [('free', 'رایگانِ روزانه'), ('credit', 'اعتبارِ بسته'), ('none', 'بیهزینه')]
|
||||||
|
charge = models.CharField(max_length=6, choices=CHARGE_CHOICES, blank=True, default='')
|
||||||
|
# ایندکس: سقفِ روزانهی کلِ سامانه (_check_global_daily_cap) در هر سوال پیامهای امروزِ همه را
|
||||||
|
# میشمارد؛ این سریعترین جدولِ در حالِ رشد است.
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||||
|
|
||||||
|
# --- حاشیهنویسیِ تولید (فقط برای پیامهای role='assistant') --------------
|
||||||
|
# سینیور خواست لایک/دیسلایک بعداً قابلِ تحلیل باشد: «کدام جواب بهتر بوده».
|
||||||
|
# فیدبکِ خالی (یک بولین) این را نمیگوید — باید بدانیم آن جوابِ خاص با کدام
|
||||||
|
# مدل، کدام نسخهی پرامپت و با چه context ای ساخته شده بود. این فیلدها
|
||||||
|
# هنگامِ تولید پر میشوند، نه هنگامِ فیدبک، تا همهی پاسخها (حتی آنهایی که
|
||||||
|
# کسی رأی نداده) قابلِ مقایسه بمانند.
|
||||||
|
model_name = models.CharField(max_length=100, blank=True)
|
||||||
|
prompt_version = models.CharField(max_length=50, blank=True, db_index=True)
|
||||||
|
grade_title = models.CharField(max_length=50, blank=True)
|
||||||
|
retrieval_collection = models.CharField(max_length=200, blank=True)
|
||||||
|
retrieval_filter_value = models.CharField(max_length=255, blank=True)
|
||||||
|
chunk_count = models.PositiveSmallIntegerField(default=0)
|
||||||
|
prompt_tokens = models.PositiveIntegerField(default=0)
|
||||||
|
completion_tokens = models.PositiveIntegerField(default=0)
|
||||||
|
latency_ms = models.PositiveIntegerField(default=0)
|
||||||
|
from_cache = models.BooleanField(default=False)
|
||||||
|
# شکلِ پاسخ (rag.services.diagram)؛ فقط توصیفِ JSONِ اعتبارسنجیشده، نه SVG.
|
||||||
|
diagram = models.JSONField(null=True, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['created_at']
|
ordering = ['created_at']
|
||||||
@@ -31,6 +63,48 @@ class Message(models.Model):
|
|||||||
class MessageFeedback(models.Model):
|
class MessageFeedback(models.Model):
|
||||||
message = models.OneToOneField(Message, on_delete=models.CASCADE, related_name='feedback')
|
message = models.OneToOneField(Message, on_delete=models.CASCADE, related_name='feedback')
|
||||||
is_helpful = models.BooleanField()
|
is_helpful = models.BooleanField()
|
||||||
|
# دلیلِ اختیاریِ کاربر. «چرا بد بود» تنها چیزیست که از خودِ عدد درنمیآید
|
||||||
|
# و برای بهترکردنِ پرامپت واقعاً به کار میآید.
|
||||||
|
reason = models.CharField(max_length=300, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, null=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True, null=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"feedback-msg{self.message_id}"
|
return f"feedback-msg{self.message_id}"
|
||||||
|
|
||||||
|
|
||||||
|
class AskIdempotency(models.Model):
|
||||||
|
"""
|
||||||
|
⚛️🔒 اصلاح idempotency برای POST /api/ai/ask (همون الگویی که
|
||||||
|
UserAnswer.idempotency_key/UserLessonCompletion.idempotency_key در
|
||||||
|
content و Order.idempotency_key در payment دارن): چون نتیجهی هر
|
||||||
|
«سوال» شاملِ دو ردیفِ Message (کاربر + دستیار) و مصرفِ یک اسلاتِ
|
||||||
|
کوتای روزانهست (نه یک ردیفِ تکی مثلِ UserAnswer)، این رکورد پل بین
|
||||||
|
(user, Idempotency-Key) و نتیجهی از قبل ساختهشده رو نگه میداره تا
|
||||||
|
رتری/دابلکلیکِ همون کلید، دوباره پیام یا کوتای تکراری نسازه و بهجاش
|
||||||
|
نتیجهی همون درخواستِ قبلی برگردونده بشه. UniqueConstraint سطح DB روی
|
||||||
|
(user_id, idempotency_key) هم دفاع در عمق در برابر دو submit همزمانِ
|
||||||
|
دقیقاً با همون کلیده (مشابه uniq_user_answer_idempotency_key).
|
||||||
|
"""
|
||||||
|
user_id = models.IntegerField(db_index=True)
|
||||||
|
idempotency_key = models.CharField(max_length=100)
|
||||||
|
conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name='+')
|
||||||
|
user_message = models.ForeignKey(Message, on_delete=models.CASCADE, related_name='+')
|
||||||
|
assistant_message = models.ForeignKey(Message, on_delete=models.CASCADE, related_name='+')
|
||||||
|
# ذخیره میشه تا replayِ idempotent بتونه بدونِ فراخوانیِ دوبارهی
|
||||||
|
# generate_tutor_answer همون مقدارِ اصلی رو برگردونه — وقتی provider
|
||||||
|
# واقعی جایگزینِ stub بشه، هر replay یعنی هزینه/تأخیرِ تکراری و
|
||||||
|
# follow-upهای متفاوت در هر retry، برخلافِ هدفِ idempotency.
|
||||||
|
suggested_follow_ups = models.JSONField(default=list)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=['user_id', 'idempotency_key'],
|
||||||
|
name='uniq_ask_idempotency_key',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"ask-idem-user{self.user_id}-{self.idempotency_key}"
|
||||||
|
|||||||
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: ثبتِ بازگشت شکست خورد')
|
||||||
@@ -7,22 +7,48 @@ class AskRequestSerializer(serializers.Serializer):
|
|||||||
chapterId = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
chapterId = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||||
gradeId = serializers.IntegerField(required=False, allow_null=True)
|
gradeId = serializers.IntegerField(required=False, allow_null=True)
|
||||||
questionId = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
questionId = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||||
userQuestion = serializers.CharField()
|
# metadataِ retrieval برای لایهی RAG (اپ rag): برای زدن به دیتای واقعیِ
|
||||||
|
# کتابها در Qdrant باید subject (مثلاً «علوم تجربی») + gradeId + lessonNumber
|
||||||
|
# همگی مطابقِ دادهٔ موجود ارسال بشن؛ اگه نباشن، پاسخ بهصورتِ تدریسِ عمومی
|
||||||
|
# (بدونِ استناد به کتاب) تولید میشه. در ادامهی همون مکالمه لازم نیست
|
||||||
|
# دوباره فرستاده بشن (روی Conversation ذخیره میشن).
|
||||||
|
subject = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||||
|
# جزءِ «پایه» در نامِ collectionِ Qdrant (مثلاً «ششم»). اگه بیاد مستقیم
|
||||||
|
# استفاده میشه؛ اگه نه، از روی gradeId و content.Grade.title / نگاشتِ
|
||||||
|
# RAG_GRADE_TITLE_OVERRIDES ساخته میشه.
|
||||||
|
grade = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||||
|
lessonNumber = serializers.IntegerField(required=False, allow_null=True)
|
||||||
|
# سقفِ طول: کلِ متن به مدلِ پولی میرود؛ بیسقف، یک درخواستِ صدهزار نویسهای
|
||||||
|
# بهتنهایی هزینهی صدها سوالِ عادی را داشت.
|
||||||
|
userQuestion = serializers.CharField(max_length=2000)
|
||||||
stream = serializers.BooleanField(required=False, default=False)
|
stream = serializers.BooleanField(required=False, default=False)
|
||||||
|
|
||||||
|
|
||||||
|
class UsedChunkSerializer(serializers.Serializer):
|
||||||
|
title = serializers.CharField(allow_blank=True)
|
||||||
|
summary = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
class AskResponseSerializer(serializers.Serializer):
|
class AskResponseSerializer(serializers.Serializer):
|
||||||
conversationId = serializers.CharField()
|
conversationId = serializers.CharField()
|
||||||
messageId = serializers.CharField()
|
messageId = serializers.CharField()
|
||||||
answer = serializers.CharField()
|
answer = serializers.CharField()
|
||||||
suggestedFollowUps = serializers.ListField(child=serializers.CharField())
|
suggestedFollowUps = serializers.ListField(child=serializers.CharField())
|
||||||
remainingQuota = serializers.IntegerField()
|
remainingQuota = serializers.IntegerField(help_text='سوالهای رایگانِ باقیِ امروز.')
|
||||||
|
diagram = serializers.DictField(required=False, allow_null=True)
|
||||||
|
# تکههای درسیِ بازیابیشده از Qdrant که پاسخ بر پایهشون ساخته شده؛
|
||||||
|
# خالی یعنی retrieval انجام نشده (نبودِ subject/lessonNumber یا خاموشبودنِ RAG).
|
||||||
|
usedChunks = UsedChunkSerializer(many=True, required=False, default=list)
|
||||||
|
# فقط وقتی DEBUG=True: از کدام collection/فصل چانکها برداشته شد (بازبینیِ داخلی).
|
||||||
|
retrievalDebug = serializers.DictField(required=False)
|
||||||
|
|
||||||
|
|
||||||
class MessageSerializer(serializers.Serializer):
|
class MessageSerializer(serializers.Serializer):
|
||||||
messageId = serializers.CharField()
|
messageId = serializers.CharField()
|
||||||
role = serializers.CharField()
|
role = serializers.CharField()
|
||||||
text = serializers.CharField()
|
text = serializers.CharField()
|
||||||
|
# شکلِ پاسخ؛ شِمایش در rag/services/diagram.py (plane / numberline / fraction).
|
||||||
|
diagram = serializers.DictField(required=False, allow_null=True)
|
||||||
|
|
||||||
|
|
||||||
class ConversationMessagesResponseSerializer(serializers.Serializer):
|
class ConversationMessagesResponseSerializer(serializers.Serializer):
|
||||||
@@ -33,7 +59,15 @@ class ConversationMessagesResponseSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
class FeedbackRequestSerializer(serializers.Serializer):
|
class FeedbackRequestSerializer(serializers.Serializer):
|
||||||
isHelpful = serializers.BooleanField()
|
isHelpful = serializers.BooleanField()
|
||||||
|
# اختیاری: «چرا». تنها چیزی که از خودِ 👍/👎 درنمیآید و برای اصلاحِ
|
||||||
|
# پرامپت واقعاً به کار میآید.
|
||||||
|
reason = serializers.CharField(max_length=300, required=False, allow_blank=True)
|
||||||
|
|
||||||
|
|
||||||
class FeedbackResponseSerializer(serializers.Serializer):
|
class FeedbackResponseSerializer(serializers.Serializer):
|
||||||
success = serializers.BooleanField()
|
success = serializers.BooleanField()
|
||||||
|
|
||||||
|
|
||||||
|
class QuotaResponseSerializer(serializers.Serializer):
|
||||||
|
freeDaily = serializers.IntegerField(help_text='سوالهای رایگانِ هر روز برای این کاربر.')
|
||||||
|
remainingQuota = serializers.IntegerField(help_text='سوالهای رایگانِ باقیِ امروز.')
|
||||||
|
|||||||
@@ -1,3 +1,522 @@
|
|||||||
from django.test import TestCase
|
from unittest.mock import patch
|
||||||
|
|
||||||
# Create your tests here.
|
from django.core.cache import cache
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
from rest_framework.throttling import ScopedRateThrottle
|
||||||
|
|
||||||
|
from accounts.authentication import issue_access_token
|
||||||
|
from accounts.models import User
|
||||||
|
|
||||||
|
from .models import AskIdempotency, Conversation, Message
|
||||||
|
from .views import generate_tutor_answer
|
||||||
|
|
||||||
|
|
||||||
|
class AskIdempotencyFollowUpsTests(TestCase):
|
||||||
|
"""
|
||||||
|
🔒 تستِ رگرسیونِ فیکس: _build_ask_response قبلاً برای بازسازیِ
|
||||||
|
suggestedFollowUps دوباره generate_tutor_answer رو صدا میزد — که با
|
||||||
|
یک AI provider واقعی یعنی یک فراخوانیِ واقعیِ دیگه بهازای هر replay.
|
||||||
|
حالا suggested_follow_ups در همون درخواستِ اولِ موفق ذخیره میشه و
|
||||||
|
replay بدونِ هیچ فراخوانیِ دیگری به generate_tutor_answer همون مقدار
|
||||||
|
رو برمیگردونه.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# ⚠️ افزوده شده: بعد از اضافهشدنِ ScopedRateThrottle (scope='ai_ask')
|
||||||
|
# به AskAIView، cache باید بینِ تستها پاک بشه؛ وگرنه شمارشگرِ throttle
|
||||||
|
# (که در محیطِ تست روی LocMemCache نگهداری میشه و بهصورتِ خودکار
|
||||||
|
# بینِ تستها پاک نمیشه) میتونه از یک تستِ قبلی باقی بمونه.
|
||||||
|
cache.clear()
|
||||||
|
self.client = APIClient()
|
||||||
|
self.user = User.objects.create(phone='09000000021', name='')
|
||||||
|
self.token = issue_access_token(self.user.id)
|
||||||
|
|
||||||
|
def _ask(self, idempotency_key):
|
||||||
|
return self.client.post(
|
||||||
|
'/api/ai/ask',
|
||||||
|
{'userQuestion': 'یک بهعلاوه یک چند میشود؟'},
|
||||||
|
format='json',
|
||||||
|
HTTP_AUTHORIZATION=f'Bearer {self.token}',
|
||||||
|
HTTP_IDEMPOTENCY_KEY=idempotency_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_initial_request_stores_generated_follow_ups(self):
|
||||||
|
response = self._ask('ask-key-1')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
record = AskIdempotency.objects.get(user_id=self.user.id, idempotency_key='ask-key-1')
|
||||||
|
self.assertEqual(record.suggested_follow_ups, response.data['suggestedFollowUps'])
|
||||||
|
self.assertTrue(record.suggested_follow_ups)
|
||||||
|
|
||||||
|
def test_idempotent_retry_returns_same_follow_ups_without_calling_generator_again(self):
|
||||||
|
first_response = self._ask('ask-key-2')
|
||||||
|
self.assertEqual(first_response.status_code, 200)
|
||||||
|
original_follow_ups = first_response.data['suggestedFollowUps']
|
||||||
|
|
||||||
|
# ⚠️ در مسیرِ replay، generate_tutor_answer نباید اصلاً صدا زده
|
||||||
|
# بشه؛ اگه صدا زده بشه، این patch باعثِ خطا میشه و تست fail
|
||||||
|
# میکنه.
|
||||||
|
with patch(
|
||||||
|
'ai_tutor.views.generate_tutor_answer',
|
||||||
|
side_effect=AssertionError('generate_tutor_answer must not be called on idempotent replay'),
|
||||||
|
):
|
||||||
|
retry_response = self._ask('ask-key-2')
|
||||||
|
|
||||||
|
self.assertEqual(retry_response.status_code, 200)
|
||||||
|
self.assertEqual(retry_response.data['suggestedFollowUps'], original_follow_ups)
|
||||||
|
self.assertEqual(retry_response.json()['answer'], first_response.json()['answer'])
|
||||||
|
self.assertEqual(retry_response.data['messageId'], first_response.data['messageId'])
|
||||||
|
|
||||||
|
|
||||||
|
class AskAIGenerationFailureTests(TestCase):
|
||||||
|
"""
|
||||||
|
🔒 تستِ رگرسیونِ فیکسِ اصلیِ این تسک: جداکردنِ فراخوانیِ
|
||||||
|
generate_tutor_answer از قفلِ ردیفِ کاربر نباید رفتارِ صحتیِ سیستم رو
|
||||||
|
خراب کنه. اگه generate_tutor_answer خطا بده (مثلاً چون یک AI provider
|
||||||
|
واقعی موقتاً در دسترس نیست)، نباید:
|
||||||
|
- هیچ Message یتیمی (پیامِ کاربر بدونِ پاسخ) در دیتابیس بمونه؛
|
||||||
|
- اسلاتِ کوتای روزانه برای همیشه مصرفشده حساب بشه.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# ⚠️ افزوده شده: نگاه کنید به توضیحِ مشابه در AskIdempotencyFollowUpsTests.setUp
|
||||||
|
cache.clear()
|
||||||
|
self.client = APIClient()
|
||||||
|
self.user = User.objects.create(phone='09000000022', name='')
|
||||||
|
self.token = issue_access_token(self.user.id)
|
||||||
|
|
||||||
|
def _ask(self, idempotency_key=None):
|
||||||
|
headers = {}
|
||||||
|
if idempotency_key:
|
||||||
|
headers['HTTP_IDEMPOTENCY_KEY'] = idempotency_key
|
||||||
|
return self.client.post(
|
||||||
|
'/api/ai/ask',
|
||||||
|
{'userQuestion': 'دو بهعلاوه دو چند میشود؟'},
|
||||||
|
format='json',
|
||||||
|
HTTP_AUTHORIZATION=f'Bearer {self.token}',
|
||||||
|
**headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_generation_failure_leaves_no_orphan_message_and_returns_503(self):
|
||||||
|
with patch(
|
||||||
|
'ai_tutor.views.generate_tutor_answer',
|
||||||
|
side_effect=ConnectionError('provider unreachable'),
|
||||||
|
):
|
||||||
|
response = self._ask()
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 503)
|
||||||
|
self.assertEqual(Message.objects.filter(conversation__user_id=self.user.id).count(), 0)
|
||||||
|
self.assertEqual(Conversation.objects.filter(user_id=self.user.id).count(), 0)
|
||||||
|
|
||||||
|
def test_generation_failure_does_not_consume_quota_slot(self):
|
||||||
|
# یک سوالِ ناموفق میفرستیم (که نباید کوتا مصرف کنه)، بعد دقیقاً
|
||||||
|
# به اندازهی سقفِ روزانه سوالِ موفق پشتِ سرِ هم میفرستیم؛ همه باید ۲۰۰
|
||||||
|
# بگیرن، یعنی سوالِ ناموفقِ اول اسلاتی از کوتا مصرف نکرده.
|
||||||
|
from .views import daily_quota
|
||||||
|
|
||||||
|
DAILY_AI_QUOTA = daily_quota(self.user.id)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
'ai_tutor.views.generate_tutor_answer',
|
||||||
|
side_effect=ConnectionError('provider unreachable'),
|
||||||
|
):
|
||||||
|
failed_response = self._ask()
|
||||||
|
self.assertEqual(failed_response.status_code, 503)
|
||||||
|
|
||||||
|
for _ in range(DAILY_AI_QUOTA):
|
||||||
|
ok_response = self._ask()
|
||||||
|
self.assertEqual(ok_response.status_code, 200)
|
||||||
|
|
||||||
|
# این سوالِ اضافه باید رد بشه چون سقف (نه یکی کمتر) واقعاً پر شده.
|
||||||
|
throttled_response = self._ask()
|
||||||
|
self.assertEqual(throttled_response.status_code, 429)
|
||||||
|
|
||||||
|
def test_generation_failure_on_existing_conversation_keeps_conversation(self):
|
||||||
|
# وقتی conversationId از قبل وجود داره (متعلق به این کاربر)، در
|
||||||
|
# صورتِ شکستِ generate_tutor_answer فقط user_messageِ همین
|
||||||
|
# درخواست باید حذف بشه، نه خودِ conversation (که ممکنه پیامهای
|
||||||
|
# دیگهای هم داشته باشه).
|
||||||
|
conversation = Conversation.objects.create(user_id=self.user.id)
|
||||||
|
Message.objects.create(conversation=conversation, role='user', text='سوالِ قدیمی')
|
||||||
|
Message.objects.create(conversation=conversation, role='assistant', text='پاسخِ قدیمی')
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
'ai_tutor.views.generate_tutor_answer',
|
||||||
|
side_effect=ConnectionError('provider unreachable'),
|
||||||
|
):
|
||||||
|
response = self.client.post(
|
||||||
|
'/api/ai/ask',
|
||||||
|
{'userQuestion': 'یک سوالِ جدید', 'conversationId': str(conversation.id)},
|
||||||
|
format='json',
|
||||||
|
HTTP_AUTHORIZATION=f'Bearer {self.token}',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 503)
|
||||||
|
self.assertTrue(Conversation.objects.filter(pk=conversation.id).exists())
|
||||||
|
# فقط دو پیامِ قدیمی باید باقی مونده باشن؛ پیامِ کاربرِ رزروشدهی
|
||||||
|
# همین درخواستِ ناموفق باید پاک شده باشه.
|
||||||
|
self.assertEqual(conversation.messages.count(), 2)
|
||||||
|
|
||||||
|
|
||||||
|
class AskAIConcurrentIdempotencyKeyTests(TestCase):
|
||||||
|
"""
|
||||||
|
🔒⚛️ تستِ رگرسیونِ فیکسِ این تسک برای حالتِ خیلی نادرِ رقابتِ همزمانِ
|
||||||
|
دو درخواست با دقیقاً همون Idempotency-Key، بعد از اینکه فراخوانیِ AI
|
||||||
|
از زیرِ قفلِ ردیفِ کاربر بیرون اومده. این تست بهجای threadهای واقعی
|
||||||
|
(که روی SQLite ناپایدارن)، این رقابت رو بهصورتِ قطعی/تکرارپذیر شبیهسازی
|
||||||
|
میکنه: داخلِ side_effectِ generate_tutor_answerِ درخواستِ «اول»، یک
|
||||||
|
درخواستِ HTTP کاملاً جداگانه (با همون کلید) فرستاده میشه که کاملاً
|
||||||
|
پردازش میشه و AskIdempotency رو زودتر میسازه (برنده)؛ درخواستِ
|
||||||
|
«اول» بعد از برگشتن از generate_tutor_answer باید در فازِ نهایی به
|
||||||
|
IntegrityError بخوره، رزروِ خودش رو پاک کنه، و نتیجهی همون برنده رو
|
||||||
|
برگردونه.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# ⚠️ افزوده شده: نگاه کنید به توضیحِ مشابه در AskIdempotencyFollowUpsTests.setUp
|
||||||
|
cache.clear()
|
||||||
|
self.client = APIClient()
|
||||||
|
self.user = User.objects.create(phone='09000000023', name='')
|
||||||
|
self.token = issue_access_token(self.user.id)
|
||||||
|
|
||||||
|
def test_losing_request_cleans_up_and_returns_winners_result(self):
|
||||||
|
key = 'race-key-1'
|
||||||
|
inner_client = APIClient()
|
||||||
|
# ⚠️ چون generate_tutor_answer برای کلِ طولِ این with-block پچ
|
||||||
|
# شده، درخواستِ داخلیِ زیر هم به همین side_effect برمیخوره؛ برای
|
||||||
|
# جلوگیری از بازگشتِ بینهایت، فقط اولین فراخوانی (درخواستِ
|
||||||
|
# بیرونی) درخواستِ داخلی رو میسازه؛ خودِ فراخوانیِ generate_tutor_
|
||||||
|
# answerِ درخواستِ داخلی (فراخوانیِ دوم) صرفاً یک پاسخِ قطعی
|
||||||
|
# برمیگردونه.
|
||||||
|
call_count = {'n': 0}
|
||||||
|
|
||||||
|
def side_effect(user_question, grade_id, lesson_id, context=None):
|
||||||
|
call_count['n'] += 1
|
||||||
|
if call_count['n'] == 1:
|
||||||
|
# این «درخواستِ داخلی» با همون idempotency key، در طولِ
|
||||||
|
# فراخوانیِ AIِ درخواستِ بیرونی (که قفلِ ردیفِ کاربر رو
|
||||||
|
# دیگه نگه نداره) کاملاً اجرا و کامل میشه — یعنی برندهی
|
||||||
|
# رقابت میشه.
|
||||||
|
resp = inner_client.post(
|
||||||
|
'/api/ai/ask',
|
||||||
|
{'userQuestion': user_question},
|
||||||
|
format='json',
|
||||||
|
HTTP_AUTHORIZATION=f'Bearer {self.token}',
|
||||||
|
HTTP_IDEMPOTENCY_KEY=key,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.content
|
||||||
|
side_effect.inner_response = resp
|
||||||
|
return 'پاسخِ واقعی برای سوال', ['ادامه بده']
|
||||||
|
return 'پاسخِ دستیار برای درخواستِ داخلی', ['ادامه بده']
|
||||||
|
|
||||||
|
with patch('ai_tutor.views.generate_tutor_answer', side_effect=side_effect):
|
||||||
|
outer_response = self.client.post(
|
||||||
|
'/api/ai/ask',
|
||||||
|
{'userQuestion': 'سوالِ رقابتی'},
|
||||||
|
format='json',
|
||||||
|
HTTP_AUTHORIZATION=f'Bearer {self.token}',
|
||||||
|
HTTP_IDEMPOTENCY_KEY=key,
|
||||||
|
)
|
||||||
|
|
||||||
|
inner_response = side_effect.inner_response
|
||||||
|
self.assertEqual(outer_response.status_code, 200)
|
||||||
|
|
||||||
|
# هر دو پاسخ باید دقیقاً به همون AskIdempotency (نتیجهی برنده)
|
||||||
|
# اشاره کنن.
|
||||||
|
self.assertEqual(outer_response.data['messageId'], inner_response.data['messageId'])
|
||||||
|
self.assertEqual(outer_response.json()['answer'], inner_response.json()['answer'])
|
||||||
|
|
||||||
|
# فقط یک رکوردِ AskIdempotency برای این کلید باید وجود داشته باشه.
|
||||||
|
self.assertEqual(
|
||||||
|
AskIdempotency.objects.filter(user_id=self.user.id, idempotency_key=key).count(), 1
|
||||||
|
)
|
||||||
|
# فقط یک پیامِ کاربر و یک پیامِ دستیار باید نهایتاً باقی مونده
|
||||||
|
# باشن؛ پیامِ رزروشدهی درخواستِ بازنده باید پاک شده باشه (یعنی
|
||||||
|
# کوتای روزانه فقط یک بار مصرف شده، نه دوبار).
|
||||||
|
self.assertEqual(
|
||||||
|
Message.objects.filter(conversation__user_id=self.user.id, role='user').count(), 1
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
Message.objects.filter(conversation__user_id=self.user.id, role='assistant').count(), 1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AskAIConversationApiContractTests(TestCase):
|
||||||
|
"""
|
||||||
|
🔒 تستِ رگرسیونِ این تسک: اضافهشدنِ مرزِ context به generate_tutor_answer
|
||||||
|
نباید هیچ تغییری در قراردادِ API endpoint (POST /api/ai/ask) بده — نه
|
||||||
|
برای شروعِ مکالمهی جدید (بدونِ conversationId)، نه برای ادامهی
|
||||||
|
مکالمهی موجود (با conversationId)، و نه در چکهای مالکیت/احراز هویت.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# ⚠️ افزوده شده: نگاه کنید به توضیحِ مشابه در AskIdempotencyFollowUpsTests.setUp
|
||||||
|
cache.clear()
|
||||||
|
self.client = APIClient()
|
||||||
|
self.user = User.objects.create(phone='09000000024', name='')
|
||||||
|
self.token = issue_access_token(self.user.id)
|
||||||
|
self.other_user = User.objects.create(phone='09000000025', name='')
|
||||||
|
self.other_token = issue_access_token(self.other_user.id)
|
||||||
|
|
||||||
|
def test_no_conversation_id_creates_new_conversation(self):
|
||||||
|
self.assertEqual(Conversation.objects.filter(user_id=self.user.id).count(), 0)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
'/api/ai/ask',
|
||||||
|
{'userQuestion': 'سلام'},
|
||||||
|
format='json',
|
||||||
|
HTTP_AUTHORIZATION=f'Bearer {self.token}',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
conversations = Conversation.objects.filter(user_id=self.user.id)
|
||||||
|
self.assertEqual(conversations.count(), 1)
|
||||||
|
self.assertEqual(response.data['conversationId'], str(conversations.first().id))
|
||||||
|
|
||||||
|
def test_existing_conversation_id_continues_same_conversation(self):
|
||||||
|
conversation = Conversation.objects.create(user_id=self.user.id)
|
||||||
|
Message.objects.create(conversation=conversation, role='user', text='سوالِ قبلی')
|
||||||
|
Message.objects.create(conversation=conversation, role='assistant', text='پاسخِ قبلی')
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
'/api/ai/ask',
|
||||||
|
{'userQuestion': 'سوالِ دوم', 'conversationId': str(conversation.id)},
|
||||||
|
format='json',
|
||||||
|
HTTP_AUTHORIZATION=f'Bearer {self.token}',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data['conversationId'], str(conversation.id))
|
||||||
|
# هنوز فقط همون یک conversation باید وجود داشته باشه؛ چیزِ جدیدی
|
||||||
|
# ساخته نشده.
|
||||||
|
self.assertEqual(Conversation.objects.filter(user_id=self.user.id).count(), 1)
|
||||||
|
self.assertEqual(conversation.messages.count(), 4)
|
||||||
|
|
||||||
|
def test_conversation_id_belonging_to_another_user_is_rejected(self):
|
||||||
|
other_conversation = Conversation.objects.create(user_id=self.other_user.id)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
'/api/ai/ask',
|
||||||
|
{'userQuestion': 'سوال', 'conversationId': str(other_conversation.id)},
|
||||||
|
format='json',
|
||||||
|
HTTP_AUTHORIZATION=f'Bearer {self.token}',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
self.assertEqual(other_conversation.messages.count(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationBoundaryContextTests(TestCase):
|
||||||
|
"""
|
||||||
|
🏗️ تستِ رگرسیونِ اصلیِ این تسک: مرزِ تولیدِ پاسخ
|
||||||
|
(generate_tutor_answer(user_question, grade_id, lesson_id, context))
|
||||||
|
باید:
|
||||||
|
- همچنان gradeId/lessonId رو بگیره (metadataِ لازم برای RAG/Qdrantِ
|
||||||
|
آینده)، دقیقاً همونهایی که در بدنهی API درخواست اومدن؛
|
||||||
|
- بتونه یک context اپلیکیشن-ساخته (رشته/شیء/لیستِ پیام) هم کنارِ اونها
|
||||||
|
بگیره، بدونِ اینکه بدونه اون context چطور retrieve شده؛
|
||||||
|
و AskAIView (که هنوز لایهی RAG/Qdrant رو پیاده نمیکنه) نباید خودش
|
||||||
|
تاریخچهی خامِ مکالمه رو بسازه و به این مرز بفرسته.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# ⚠️ افزوده شده: نگاه کنید به توضیحِ مشابه در AskIdempotencyFollowUpsTests.setUp
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
def test_generate_tutor_answer_accepts_grade_lesson_and_application_provided_context(self):
|
||||||
|
# مرز باید grade_id/lesson_id رو (بهعنوانِ metadataِ RAGِ آینده) و
|
||||||
|
# context رو (چه رشته باشه، چه شیء/دیکشنری، چه لیستِ پیامهای
|
||||||
|
# retrieve-شده) بهعنوانِ آرگومانهایی مستقل کنارِ سوالِ کاربر قبول
|
||||||
|
# کنه.
|
||||||
|
answer_text, follow_ups = generate_tutor_answer(
|
||||||
|
'مشتق تابع sin(x) چیست؟',
|
||||||
|
7,
|
||||||
|
'42',
|
||||||
|
context={'retrieved_snippets': ['قاعدهی مشتقگیریِ توابعِ مثلثاتی']},
|
||||||
|
)
|
||||||
|
self.assertIn('مشتق تابع sin(x) چیست؟', answer_text)
|
||||||
|
self.assertTrue(follow_ups)
|
||||||
|
|
||||||
|
# بدونِ context هم (رفتارِ فعلی/پیشفرض) باید عادی کار کنه؛ context
|
||||||
|
# باید کاملاً اختیاری بمونه.
|
||||||
|
answer_text_no_context, _ = generate_tutor_answer('یک سوال بدون context', None, None)
|
||||||
|
self.assertIn('یک سوال بدون context', answer_text_no_context)
|
||||||
|
|
||||||
|
def test_ask_ai_view_passes_grade_and_lesson_id_from_request_to_generation_boundary(self):
|
||||||
|
# gradeId/lessonIdِ همون درخواستِ API باید دقیقاً همونطور که
|
||||||
|
# فرستاده شدن به مرزِ تولید برسن — این metadataی لازم برای
|
||||||
|
# RAG/Qdrantِ آیندهست، نه تاریخچه.
|
||||||
|
client = APIClient()
|
||||||
|
user = User.objects.create(phone='09000000026', name='')
|
||||||
|
token = issue_access_token(user.id)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
'ai_tutor.views.generate_tutor_answer',
|
||||||
|
wraps=generate_tutor_answer,
|
||||||
|
) as mocked_generate:
|
||||||
|
response = client.post(
|
||||||
|
'/api/ai/ask',
|
||||||
|
{
|
||||||
|
'userQuestion': 'مشتق چیست؟',
|
||||||
|
'gradeId': 10,
|
||||||
|
'lessonId': 'lesson-55',
|
||||||
|
},
|
||||||
|
format='json',
|
||||||
|
HTTP_AUTHORIZATION=f'Bearer {token}',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
mocked_generate.assert_called_once_with('مشتق چیست؟', 10, 'lesson-55', None)
|
||||||
|
|
||||||
|
def test_ask_ai_view_does_not_send_conversation_history_to_generation_boundary(self):
|
||||||
|
# این تستِ کلیدیِ این تسکه: وقتی AskAIView مرزِ تولید رو صدا میزنه،
|
||||||
|
# نباید هیچ تاریخچهی خامِ Message/Conversation (لیستی از پیامهای
|
||||||
|
# قبلی، یا خودِ QuerySet پیامها) بهش پاس بده — چون RAG هنوز اینجا
|
||||||
|
# پیاده نشده، context باید None بمونه (فقط gradeId/lessonId/سوال
|
||||||
|
# مجازن).
|
||||||
|
client = APIClient()
|
||||||
|
user = User.objects.create(phone='09000000027', name='')
|
||||||
|
token = issue_access_token(user.id)
|
||||||
|
|
||||||
|
conversation = Conversation.objects.create(user_id=user.id)
|
||||||
|
Message.objects.create(conversation=conversation, role='user', text='سوالِ قدیمیِ اول')
|
||||||
|
Message.objects.create(conversation=conversation, role='assistant', text='پاسخِ قدیمیِ اول')
|
||||||
|
Message.objects.create(conversation=conversation, role='user', text='سوالِ قدیمیِ دوم')
|
||||||
|
Message.objects.create(conversation=conversation, role='assistant', text='پاسخِ قدیمیِ دوم')
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
'ai_tutor.views.generate_tutor_answer',
|
||||||
|
wraps=generate_tutor_answer,
|
||||||
|
) as mocked_generate:
|
||||||
|
response = client.post(
|
||||||
|
'/api/ai/ask',
|
||||||
|
{'userQuestion': 'سوالِ جدید', 'conversationId': str(conversation.id)},
|
||||||
|
format='json',
|
||||||
|
HTTP_AUTHORIZATION=f'Bearer {token}',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
mocked_generate.assert_called_once()
|
||||||
|
call_args, call_kwargs = mocked_generate.call_args
|
||||||
|
all_args = list(call_args) + list(call_kwargs.values())
|
||||||
|
|
||||||
|
# آرگومانِ اول باید فقط همین سوالِ تازه باشه، نه چیزِ دیگهای.
|
||||||
|
self.assertEqual(call_args[0], 'سوالِ جدید')
|
||||||
|
|
||||||
|
# هیچ آرگومانی نباید حاویِ متنِ پیامهای قدیمی یا خودِ آبجکتهای
|
||||||
|
# Message/Conversation باشه — یعنی هیچ تاریخچهی خامی به مرزِ تولید
|
||||||
|
# پاس داده نشده.
|
||||||
|
for arg in all_args:
|
||||||
|
self.assertNotIsInstance(arg, (Message, Conversation))
|
||||||
|
if isinstance(arg, str):
|
||||||
|
self.assertNotIn('قدیمی', arg)
|
||||||
|
|
||||||
|
# context (چهارمین آرگومان) در این تسک باید None باشه (RAG/Qdrant
|
||||||
|
# هنوز پیاده نشده)؛ gradeId/lessonId هم چون در این درخواست فرستاده
|
||||||
|
# نشدن، None هستن.
|
||||||
|
context_arg = call_args[3] if len(call_args) > 3 else call_kwargs.get('context')
|
||||||
|
self.assertIsNone(context_arg)
|
||||||
|
|
||||||
|
|
||||||
|
class AskAIViewThrottleTests(TestCase):
|
||||||
|
"""
|
||||||
|
🔒 اثبات محدودیتِ جدیدِ ScopedRateThrottle (scope='ai_ask') روی
|
||||||
|
AskAIView: قبلاً این endpoint هیچ throttle مخصوصی نداشت (فقط
|
||||||
|
DAILY_AI_QUOTA که سقفِ روزانه است، نه محدودیتِ burst کوتاهمدت).
|
||||||
|
این تست ثابت میکند که درخواستهای مجاز داخلِ نرخ همچنان کار میکنند
|
||||||
|
و درخواستِ بیش از حد با 429 رد میشود — بدون اینکه به کوتای روزانه یا
|
||||||
|
idempotency دست بزند.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
cache.clear()
|
||||||
|
self.user = User.objects.create(phone='09000000028', name='')
|
||||||
|
self.token = issue_access_token(self.user.id)
|
||||||
|
|
||||||
|
def _ask(self, idempotency_key):
|
||||||
|
return self.client.post(
|
||||||
|
'/api/ai/ask',
|
||||||
|
{'userQuestion': 'یک بهعلاوه یک چند میشود؟'},
|
||||||
|
format='json',
|
||||||
|
HTTP_AUTHORIZATION=f'Bearer {self.token}',
|
||||||
|
HTTP_IDEMPOTENCY_KEY=idempotency_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
# سقفِ روزانه (پیشفرض ۲) نباید پیش از throttle برسد؛ این تست فقط throttle را میسنجد.
|
||||||
|
@override_settings(AI_DAILY_QUOTA_FREE=10)
|
||||||
|
def test_requests_within_rate_succeed_and_excess_is_throttled(self):
|
||||||
|
# هر درخواست idempotency-key متفاوت دارد تا هر بار واقعاً از کوتای
|
||||||
|
# روزانه مصرف کند (نه یک replay ساده)، و صرفاً throttle سنجیده شود.
|
||||||
|
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'ai_ask': '3/hour'}):
|
||||||
|
for i in range(3):
|
||||||
|
response = self._ask(f'ai-ask-throttle-key-{i}')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
throttled_response = self._ask('ai-ask-throttle-key-3')
|
||||||
|
self.assertEqual(throttled_response.status_code, 429)
|
||||||
|
|
||||||
|
|
||||||
|
class AskAIViewPerUserThrottleTests(TestCase):
|
||||||
|
"""هر کاربرِ ثبتنامشده سطلِ throttleِ جدای خودش را دارد؛ مصرفِ حسابِ دیگر به او نمیرسد."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
self.client = APIClient()
|
||||||
|
|
||||||
|
def _ask(self, token, idempotency_key):
|
||||||
|
return self.client.post(
|
||||||
|
'/api/ai/ask',
|
||||||
|
{'userQuestion': 'یک بهعلاوه یک چند میشود؟'},
|
||||||
|
format='json',
|
||||||
|
HTTP_AUTHORIZATION=f'Bearer {token}',
|
||||||
|
HTTP_IDEMPOTENCY_KEY=idempotency_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_registered_users_keep_independent_per_user_buckets(self):
|
||||||
|
# عدمِ رگرسیون: کاربرهای غیرمهمان (ثبتنامشده با شماره) باید مثلِ
|
||||||
|
# قبل هرکدوم bucket جداگانهی خودشون رو داشته باشن — throttle
|
||||||
|
# برای اونا هنوز بر اساسِ user.pk است، نه IP.
|
||||||
|
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'ai_ask': '1/hour'}):
|
||||||
|
user_a = User.objects.create(name='', phone='+989120000101')
|
||||||
|
response_a = self._ask(issue_access_token(user_a.id), 'user-a-key-1')
|
||||||
|
self.assertEqual(response_a.status_code, 200)
|
||||||
|
|
||||||
|
user_b = User.objects.create(name='', phone='+989120000102')
|
||||||
|
response_b = self._ask(issue_access_token(user_b.id), 'user-b-key-1')
|
||||||
|
|
||||||
|
self.assertEqual(response_b.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
|
class StubNeverReachesProductionTests(TestCase):
|
||||||
|
"""بیکلیدِ RAG و بیرون از DEBUG، دانشآموز ۵۰۳ میگیرد، نه متنِ «پاسخ نمونه (stub)»."""
|
||||||
|
|
||||||
|
@override_settings(DEBUG=False, OPENAI_API_KEY='', QDRANT_API_KEY='', RAG_FORCE_MOCK=False)
|
||||||
|
def test_missing_keys_are_a_503_and_cost_nothing(self):
|
||||||
|
cache.clear()
|
||||||
|
user = User.objects.create(phone='09000000029', name='')
|
||||||
|
response = APIClient().post(
|
||||||
|
'/api/ai/ask', {'userQuestion': '۲+۲ چند است؟'}, format='json',
|
||||||
|
HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}',
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 503)
|
||||||
|
self.assertFalse(Message.objects.filter(conversation__user_id=user.id).exists())
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalDailyCapTests(TestCase):
|
||||||
|
"""سقفِ روزانهی کلِ سامانه برای سوالهایی که به مدل میرسند (AI_GLOBAL_DAILY_MAX)."""
|
||||||
|
|
||||||
|
@override_settings(AI_GLOBAL_DAILY_MAX=2)
|
||||||
|
def test_the_whole_system_stops_at_the_daily_cap(self):
|
||||||
|
cache.clear()
|
||||||
|
codes = []
|
||||||
|
for i in range(3):
|
||||||
|
user = User.objects.create(phone=f'0900{i:07d}', name='')
|
||||||
|
codes.append(APIClient().post(
|
||||||
|
'/api/ai/ask', {'userQuestion': f'۲+{i} چند است؟'}, format='json',
|
||||||
|
HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}',
|
||||||
|
REMOTE_ADDR=f'10.0.0.{i}',
|
||||||
|
).status_code)
|
||||||
|
self.assertEqual(codes, [200, 200, 503])
|
||||||
|
|||||||
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 django.urls import path
|
||||||
|
|
||||||
from .views import AskAIView, ConversationMessagesView, MessageFeedbackView
|
from .views import AskAIView, ConversationMessagesView, MessageFeedbackView, QuotaView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('ai/ask', AskAIView.as_view(), name='ai-ask'),
|
path('ai/ask', AskAIView.as_view(), name='ai-ask'),
|
||||||
|
path('ai/quota', QuotaView.as_view(), name='ai-quota'),
|
||||||
path('ai/conversations/<int:conversation_id>/messages', ConversationMessagesView.as_view(), name='ai-conversation-messages'),
|
path('ai/conversations/<int:conversation_id>/messages', ConversationMessagesView.as_view(), name='ai-conversation-messages'),
|
||||||
path('ai/messages/<int:message_id>/feedback', MessageFeedbackView.as_view(), name='ai-message-feedback'),
|
path('ai/messages/<int:message_id>/feedback', MessageFeedbackView.as_view(), name='ai-message-feedback'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,15 +1,28 @@
|
|||||||
from django.db import transaction
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import IntegrityError, transaction
|
||||||
|
from django.http import Http404, StreamingHttpResponse
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from drf_spectacular.utils import extend_schema
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from rest_framework.exceptions import Throttled
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||||
|
from rest_framework.exceptions import APIException, Throttled
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.throttling import ScopedRateThrottle
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from accounts.models import User as AccountUser
|
from accounts.models import User as AccountUser
|
||||||
from .models import Conversation, Message, MessageFeedback
|
from config.db import release_db_connection
|
||||||
|
from content.plain_math import convert as plain_convert
|
||||||
|
from rag.services.mathtext import to_plain
|
||||||
|
from . import concurrency, provider_status
|
||||||
|
from .models import AskIdempotency, Conversation, Message, MessageFeedback
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
|
QuotaResponseSerializer,
|
||||||
AskRequestSerializer,
|
AskRequestSerializer,
|
||||||
AskResponseSerializer,
|
AskResponseSerializer,
|
||||||
ConversationMessagesResponseSerializer,
|
ConversationMessagesResponseSerializer,
|
||||||
@@ -17,109 +30,819 @@ from .serializers import (
|
|||||||
FeedbackResponseSerializer,
|
FeedbackResponseSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
DAILY_AI_QUOTA = 15
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def _learner_age(user) -> int | None:
|
||||||
|
"""سنِ کاربر از پروفایلش (accounts)؛ لحنِ دستیار برای ۱۸+ بزرگسالانه است."""
|
||||||
|
from accounts.jalali import age_of
|
||||||
|
from accounts.models import Profile
|
||||||
|
|
||||||
|
profile = Profile.objects.filter(user_id=user.id).only('birth_date', 'birth_year').first()
|
||||||
|
return age_of(profile.birth_date, profile.birth_year) if profile else None
|
||||||
|
|
||||||
|
|
||||||
def generate_tutor_answer(user_question: str, grade_id, lesson_id) -> tuple[str, list[str]]:
|
def _cache_grade(grade_title: str, age: int | None) -> str:
|
||||||
|
"""پایه در کلیدِ کشِ جواب، با لحن: جوابِ بزرگسالانه به کودک سرو نشود و برعکس."""
|
||||||
|
from rag.services.generation import tone_for
|
||||||
|
|
||||||
|
return f'{grade_title}|adult' if tone_for(grade_title, age) == 'adult' else grade_title
|
||||||
|
|
||||||
|
|
||||||
|
def quota_state(user_id: int) -> dict:
|
||||||
"""
|
"""
|
||||||
استاب پاسخ AI Tutor. اینجا محل اتصال به مدل واقعی (مثلاً Claude API از طریق
|
{'remainingQuota'}: سوالهای باقیِ امروز، هر بار تازه از دیتابیس. همهی پاسخها از همین میخوانند تا
|
||||||
Anthropic SDK) هست؛ فعلاً برای اینکه بدون نیاز به کلید API هم بشه کل جریان
|
شمارندهی اپ هیچوقت از موجودیِ واقعی جدا نیفتد.
|
||||||
رو با Swagger تست کرد، یک پاسخ ساده و قطعی برمیگردونه.
|
|
||||||
"""
|
"""
|
||||||
|
used_free = Message.objects.filter(
|
||||||
|
conversation__user_id=user_id, role='user', created_at__date=timezone.localdate(), charge='free',
|
||||||
|
).count()
|
||||||
|
return {'remainingQuota': max(daily_quota(user_id) - used_free, 0)}
|
||||||
|
|
||||||
|
|
||||||
|
def _uncharge(user_message) -> None:
|
||||||
|
"""سوالی که به API نرسید (جوابِ کششده): از سهمیه کم نشود."""
|
||||||
|
if user_message.charge == 'free':
|
||||||
|
user_message.charge = 'none'
|
||||||
|
user_message.save(update_fields=['charge'])
|
||||||
|
|
||||||
|
|
||||||
|
def daily_quota(user_id: int) -> int:
|
||||||
|
"""سقفِ سوالِ روزانهی این کاربر؛ رایگان یا از پلنِ اشتراکش (subscription.services)."""
|
||||||
|
from subscription.services import ai_daily_quota
|
||||||
|
|
||||||
|
return ai_daily_quota(user_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _annotation(retrieval_meta: dict, generation_meta: dict, *, from_cache: bool) -> dict:
|
||||||
|
"""
|
||||||
|
متادیتای بازیابی/تولید → فیلدهای Message.
|
||||||
|
|
||||||
|
همهی مقادیر به str/int امن تبدیل میشوند و هر کلیدِ غایب مقدارِ پیشفرض
|
||||||
|
میگیرد: این متادیتا از cache هم میآید و cacheهای قدیمی (که پیش از افزودنِ
|
||||||
|
این فیلدها ست شدهاند) کلیدها را ندارند — نبودشان نباید ثبتِ پیام را
|
||||||
|
بشکند، چون آنوقت خودِ پاسخِ کاربر هم از بین میرود.
|
||||||
|
"""
|
||||||
|
retrieval_meta = retrieval_meta or {}
|
||||||
|
generation_meta = generation_meta or {}
|
||||||
|
filter_value = retrieval_meta.get('filterValue')
|
||||||
|
return {
|
||||||
|
'model_name': str(generation_meta.get('model') or '')[:100],
|
||||||
|
'prompt_version': str(generation_meta.get('promptVersion') or '')[:50],
|
||||||
|
'grade_title': str(retrieval_meta.get('gradeTitle') or '')[:50],
|
||||||
|
'retrieval_collection': str(retrieval_meta.get('collection') or '')[:200],
|
||||||
|
'retrieval_filter_value': ('' if filter_value is None else str(filter_value))[:255],
|
||||||
|
'chunk_count': int(retrieval_meta.get('chunkCount') or 0),
|
||||||
|
'prompt_tokens': int(generation_meta.get('promptTokens') or 0),
|
||||||
|
'completion_tokens': int(generation_meta.get('completionTokens') or 0),
|
||||||
|
'latency_ms': int(generation_meta.get('latencyMs') or 0),
|
||||||
|
'from_cache': bool(from_cache),
|
||||||
|
# شکلِ اعتبارسنجیشده در rag.services.diagram؛ کشِ قدیمی آن را ندارد.
|
||||||
|
'diagram': generation_meta.get('diagram') if isinstance(generation_meta.get('diagram'), dict) else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_db_ref_id(value) -> bool:
|
||||||
|
"""
|
||||||
|
⚠️ افزوده شده: lessonId/chapterId/questionId در قراردادِ API بهصورتِ
|
||||||
|
رشته (CharField) پذیرفته میشن، اما وقتی بهعنوانِ ارجاعِ سبکِ (لایهی
|
||||||
|
DB) روی Conversation/Message ذخیره میشن، فیلدِ مقصد IntegerFieldه.
|
||||||
|
این تابع فقط تشخیص میده که آیا مقدار برای ذخیرهسازی در همچین
|
||||||
|
فیلدی امن/عددی هست یا نه؛ روی مرزِ generate_tutor_answer اثری نداره
|
||||||
|
(اونجا همیشه مقدارِ خامِ اصلی پاس داده میشه).
|
||||||
|
|
||||||
|
پیامِ دستیار هم از همین مسیر (`_db_ref_or_none`) رد میشود؛ پیش از
|
||||||
|
۲۰۲۶-۰۹-۲۵ نمیشد و questionIdِ غیرعددی *بعد از* تولیدِ جواب ۵۰۰ میداد و
|
||||||
|
سهمِ روزانه مصرفشده میماند.
|
||||||
|
"""
|
||||||
|
if value in (None, ''):
|
||||||
|
return False
|
||||||
|
# isascii: '²'.isdigit() هم True است ولی int('²') خطا میدهد.
|
||||||
|
text = str(value).strip().lstrip('-')
|
||||||
|
return text.isascii() and text.isdigit()
|
||||||
|
|
||||||
|
|
||||||
|
def _db_ref_or_none(value):
|
||||||
|
"""مقدارِ عددی برای ستونِ IntegerField، وگرنه None (نه خطای ORM)."""
|
||||||
|
return value if _is_db_ref_id(value) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _conversation_history(conversation, before_message) -> list[dict]:
|
||||||
|
"""
|
||||||
|
چند پیامِ آخرِ همین گفتگو، پیش از سوالِ تازه، به ترتیبِ زمان.
|
||||||
|
|
||||||
|
بدونِ این، هر سوال جدا از قبلیها جواب داده میشد: «نفهمیدم، سادهتر بگو»
|
||||||
|
برای مدل هیچ مرجعی نداشت.
|
||||||
|
"""
|
||||||
|
from rag.services.generation import HISTORY_TURNS
|
||||||
|
|
||||||
|
rows = list(
|
||||||
|
Message.objects.filter(conversation=conversation, id__lt=before_message.id)
|
||||||
|
.order_by('-id').values('role', 'text')[:HISTORY_TURNS]
|
||||||
|
)
|
||||||
|
return list(reversed(rows))
|
||||||
|
|
||||||
|
|
||||||
|
def _retrieval_query(question: str, history: list[dict], focus: str = '') -> str:
|
||||||
|
"""
|
||||||
|
متنی که embedding میشود. «یه مثال دیگه بزن» بهتنهایی به هیچ تکهای از
|
||||||
|
کتاب نزدیک نیست؛ سوالِ قبلیِ کاربر (یا سوالِ تمرینیِ اپ) موضوع را معلوم میکند.
|
||||||
|
"""
|
||||||
|
if focus:
|
||||||
|
return f'{focus}\n{question}'
|
||||||
|
previous = next((t['text'] for t in reversed(history) if t['role'] == 'user'), '')
|
||||||
|
return f'{previous}\n{question}' if previous else question
|
||||||
|
|
||||||
|
|
||||||
|
def _question_focus(v: dict, user) -> str:
|
||||||
|
"""اگر اپ questionId فرستاده، متنِ همان سوال و جوابِ دانشآموز (rag.generation)."""
|
||||||
|
question_id = v.get('questionId')
|
||||||
|
if not _is_db_ref_id(question_id):
|
||||||
|
return ''
|
||||||
|
from rag.services.generation import question_focus
|
||||||
|
|
||||||
|
return question_focus(question_id, user.id)
|
||||||
|
|
||||||
|
|
||||||
|
class AIDailyCapReached(APIException):
|
||||||
|
status_code = 503
|
||||||
|
default_detail = 'دستیار امروز به سقفِ استفاده رسیده؛ لطفاً فردا دوباره بپرس.'
|
||||||
|
default_code = 'ai_daily_cap'
|
||||||
|
|
||||||
|
|
||||||
|
def _check_global_daily_cap() -> None:
|
||||||
|
"""
|
||||||
|
سقفِ روزانهی کلِ سامانه (AI_GLOBAL_DAILY_MAX): سوالهای امروز که به مدل رفتهاند یا با کش
|
||||||
|
برگشتهاند ولی هنوز «free» ماندهاند. در دیتابیس شمرده میشود، پس بینِ workerها
|
||||||
|
مشترک است. داخلِ همان تراکنشِ IMMEDIATEِ فازِ ۱ صدا زده میشود؛ روی Postgres دو درخواستِ
|
||||||
|
همزمان ممکن است هر دو از لبه رد شوند (چند سوال بیشتر از سقف، نه بیشتر).
|
||||||
|
"""
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
start = timezone.localtime().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
used = Message.objects.filter(role='user', created_at__gte=start, charge='free').count()
|
||||||
|
if used >= settings.AI_GLOBAL_DAILY_MAX:
|
||||||
|
logger.error('AI global daily cap reached: cap=%s used=%s', settings.AI_GLOBAL_DAILY_MAX, used)
|
||||||
|
raise AIDailyCapReached()
|
||||||
|
|
||||||
|
|
||||||
|
class AIProviderUnavailable(APIException):
|
||||||
|
"""
|
||||||
|
⚠️ افزوده شده: وقتی generate_tutor_answer به یک AI provider واقعی
|
||||||
|
(OpenAI/GapGPT/...) وصل بشه، شکستِ فراخوانیِ شبکهای (timeout، خطای
|
||||||
|
provider و غیره) باید یک خطای واضح و قابلِ retry برای کلاینت باشه، نه
|
||||||
|
یک 500 خام. جزئیاتِ فنیِ خطای اصلی در لاگ سرور ثبت میشه (نگاه کنید به
|
||||||
|
AskAIView.post)، نه در پاسخِ کلاینت.
|
||||||
|
"""
|
||||||
|
|
||||||
|
status_code = 503
|
||||||
|
default_detail = 'سرویس هوش مصنوعی موقتاً در دسترس نیست. لطفاً دوباره تلاش کنید.'
|
||||||
|
default_code = 'ai_provider_unavailable'
|
||||||
|
|
||||||
|
|
||||||
|
def generate_tutor_answer(
|
||||||
|
user_question: str, grade_id, lesson_id, context=None
|
||||||
|
) -> tuple[str, list[str]]:
|
||||||
|
"""
|
||||||
|
مرزِ (boundary) تولیدِ پاسخِ AI Tutor.
|
||||||
|
|
||||||
|
امضا: (user_question, grade_id, lesson_id, context). context یک پارامترِ
|
||||||
|
عمومیِ اختیاریِ application-generated است که این تابع صرفاً مصرف میکنه،
|
||||||
|
بدونِ اینکه بدونه از کجا retrieve شده. جریان:
|
||||||
|
|
||||||
|
gradeId + subject + lessonNumber + userQuestion → لایهی RAG (اپ rag،
|
||||||
|
Qdrant) → ~۴ chunkِ انتخابشده بهعنوانِ context → این تابع (لایهی
|
||||||
|
تولید) → provider (OpenAI از طریقِ GapGPT)
|
||||||
|
|
||||||
|
AskAIView خودش retrieval را انجام میده و نتیجه را بهعنوانِ context
|
||||||
|
(لیستی از dictهای {title, summary, score}) پاس میده؛ این تابع فقط
|
||||||
|
تولید میکنه. مالکیتِ تاریخچهی مکالمه همیشه دستِ دیتابیسِ خودمونه، نه
|
||||||
|
provider.
|
||||||
|
|
||||||
|
اگه RAG پیکربندی نشده باشه، پاسخِ stub فقط در DEBUG یا با RAG_FORCE_MOCK
|
||||||
|
(تستها) برمیگرده. بیرون از آن خطا میدهد و AskAIView آن را ۵۰۳ میکند.
|
||||||
|
تا ۲۰۲۶-۱۰-۰۳ stub در پروداکشن هم برمیگشت: اگر کلیدی از .env سرور میافتاد،
|
||||||
|
دانشآموز با ۲۰۰ متنِ «این یک پاسخ نمونه (stub)… OPENAI_API_KEY را تنظیم
|
||||||
|
کنید» میگرفت و سهمیهی روزانهاش هم کم میشد (با تستِ دورریختنی بازتولید شد).
|
||||||
|
"""
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from rag.services.config import rag_is_configured
|
||||||
|
|
||||||
|
if rag_is_configured():
|
||||||
|
from rag.services.generation import generate_answer
|
||||||
|
|
||||||
|
context_chunks = context if isinstance(context, list) else None
|
||||||
|
# استثناهای شبکه/provider اینجا خفه نمیشن؛ AskAIView اونها رو به
|
||||||
|
# 503 AIProviderUnavailable تبدیل میکنه (و اسلاتِ رزروشده رو آزاد).
|
||||||
|
# این تابع قراردادِ دوتاییاش را نگه میدارد؛ متادیتای تولید فقط در
|
||||||
|
# مسیرِ اصلیِ AskAIView (که آن را ذخیره میکند) لازم است.
|
||||||
|
answer, follow_ups, _meta = generate_answer(user_question, context_chunks)
|
||||||
|
return answer, follow_ups
|
||||||
|
|
||||||
|
if not (settings.DEBUG or settings.RAG_FORCE_MOCK):
|
||||||
|
raise RuntimeError('RAG پیکربندی نشده (OPENAI_API_KEY/QDRANT_API_KEY)؛ stub فقط در DEBUG.')
|
||||||
|
|
||||||
|
context_note = f' (با context: {context})' if context else ''
|
||||||
answer = (
|
answer = (
|
||||||
f'این یک پاسخ نمونه (stub) برای سوال «{user_question}» است. '
|
f'این یک پاسخ نمونه (stub) برای سوال «{user_question}»{context_note} است. '
|
||||||
'برای اتصال به مدل واقعی هوش مصنوعی، این تابع را در ai_tutor/views.py '
|
'برای اتصال به مدل واقعی هوش مصنوعی، OPENAI_API_KEY و QDRANT_API_KEY '
|
||||||
'جایگزین کنید (مثلاً با فراخوانی Anthropic API).'
|
'را در .env تنظیم کنید (لایهی RAG در اپ rag).'
|
||||||
)
|
)
|
||||||
follow_ups = ['یک مثال دیگر بزن', 'بیشتر توضیح بده']
|
follow_ups = ['یک مثال دیگر بزن', 'بیشتر توضیح بده']
|
||||||
return answer, follow_ups
|
return answer, follow_ups
|
||||||
|
|
||||||
|
|
||||||
|
class AIBusy(APIException):
|
||||||
|
"""همهی جاهای همزمانِ دستیار در این worker پرند (ai_tutor/concurrency.py). سهمیه دست نخورده."""
|
||||||
|
status_code = 503
|
||||||
|
default_detail = 'دستیار الان خیلی شلوغه؛ چند ثانیهی دیگه دوباره بپرس.'
|
||||||
|
default_code = 'assistant_busy'
|
||||||
|
# هندلرِ DRF از `wait` هدرِ Retry-After میسازد.
|
||||||
|
wait = 10
|
||||||
|
|
||||||
|
|
||||||
class AskAIView(APIView):
|
class AskAIView(APIView):
|
||||||
"""POST /api/ai/ask — سوال از AI (استریم اختیاری؛ در این فاز استریم پیاده نشده)"""
|
"""POST /api/ai/ask — سوال از AI (استریم اختیاری؛ در این فاز استریم پیاده نشده)"""
|
||||||
|
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
# daily_quota فقط سقفِ روزانهست و جلوی burstِ کوتاهمدت رو نمیگیره؛
|
||||||
|
# این throttle_scope مجزا اون burst رو محدود میکنه.
|
||||||
|
throttle_scope = 'ai_ask'
|
||||||
|
throttle_classes = [ScopedRateThrottle]
|
||||||
|
|
||||||
@extend_schema(request=AskRequestSerializer, responses=AskResponseSerializer, tags=['AI Tutor (ai_tutor)'])
|
@extend_schema(
|
||||||
|
request=AskRequestSerializer,
|
||||||
|
responses=AskResponseSerializer,
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False,
|
||||||
|
description=(
|
||||||
|
'کلید اختیاری idempotency (مثل payment و content). اگه ست بشه، '
|
||||||
|
'رتری/دابلکلیکِ همون سوال با همون کلید، دوباره پیامِ کاربر/دستیارِ '
|
||||||
|
'تکراری نمیسازه و اسلاتِ کوتای روزانهی دیگری مصرف نمیکنه؛ بهجاش '
|
||||||
|
'نتیجهی همون درخواستِ قبلاً پردازششده برگردونده میشه.'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
tags=['AI Tutor (ai_tutor)'],
|
||||||
|
)
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
|
# پیش از هر کارِ دیگری، حتی رزروِ سهمیه: درخواستِ ردشده نباید از سهمیهی روزانه کم کند.
|
||||||
|
if not concurrency.try_acquire():
|
||||||
|
logger.warning('ai_busy user_id=%s in_flight=%s limit=%s',
|
||||||
|
request.user.id, concurrency.in_flight(), concurrency.limit())
|
||||||
|
raise AIBusy()
|
||||||
|
try:
|
||||||
|
response = self._ask(request)
|
||||||
|
except BaseException:
|
||||||
|
concurrency.release()
|
||||||
|
raise
|
||||||
|
if isinstance(response, StreamingHttpResponse):
|
||||||
|
# جا تا پایانِ استریم دستِ همین درخواست میماند (ReleaseWhenDone).
|
||||||
|
response.streaming_content = concurrency.ReleaseWhenDone(response.streaming_content)
|
||||||
|
else:
|
||||||
|
concurrency.release()
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _ask(self, request):
|
||||||
serializer = AskRequestSerializer(data=request.data)
|
serializer = AskRequestSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
v = serializer.validated_data
|
v = serializer.validated_data
|
||||||
user = request.user
|
user = request.user
|
||||||
|
idempotency_key = request.headers.get('Idempotency-Key') or None
|
||||||
|
|
||||||
|
# درخواست تکراری با همین کلید: نتیجهی قبلی برگردانده میشود، بدون مصرف دوبارهی کوتا.
|
||||||
|
if idempotency_key:
|
||||||
|
existing = AskIdempotency.objects.filter(
|
||||||
|
user_id=user.id, idempotency_key=idempotency_key,
|
||||||
|
).select_related('conversation', 'user_message', 'assistant_message').first()
|
||||||
|
if existing is not None:
|
||||||
|
return Response(self._build_ask_response(existing).data)
|
||||||
|
|
||||||
|
# سه فاز جدا: فاز ۱ کوتای روزانه را زیر قفل ردیف کاربر چک/رزرو میکند،
|
||||||
|
# فاز ۲ (فراخوانی AI) کاملاً بیرون از هر قفل/تراکنشی اجرا میشود تا
|
||||||
|
# تأخیر شبکه قفل کاربر را نگه ندارد، فاز ۳ پیام دستیار و idempotency
|
||||||
|
# را ثبت میکند. اگر فاز ۲/۳ شکست بخورد، رزرو فاز ۱ آزاد میشود.
|
||||||
|
# محافظِ دستیار، لایهی ۱ (rag/services/guardrails.py): ترفندِ آشکار برای عوض کردنِ
|
||||||
|
# قواعد، یا جملهای دربارهی آسیب به خود، اصلاً به مدل نمیرسد؛ پس هزینهای ندارد و از
|
||||||
|
# سهمیه هم کم نمیشود. سقفِ burst (throttle_scope=ai_ask) جلوی تکرارش را میگیرد.
|
||||||
|
from rag.services import guardrails
|
||||||
|
|
||||||
|
screened = guardrails.screen(v['userQuestion'])
|
||||||
|
|
||||||
# ⚠️ اصلاح: چک و مصرفِ کوتای روزانه قبلاً race condition داشت —
|
|
||||||
# دو درخواست همزمان از همون کاربر میتونستن هر دو شمارشِ «امروز»
|
|
||||||
# رو قبل از ساختن پیامِ خودشون بخونن و هر دو رد بشن، در نتیجه از
|
|
||||||
# سقف ۱۵ سوال روزانه عبور کنن. حالا با قفلکردن ردیف کاربر
|
|
||||||
# (select_for_update) در یک تراکنش اتمیک، چک و مصرفِ کوتا برای هر
|
|
||||||
# کاربر serialize میشه؛ درخواستهای همزمانِ همون کاربر منتظر آزاد
|
|
||||||
# شدن قفل میمونن و دیگه نمیتونن همزمان از کنار چک رد بشن.
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
AccountUser.objects.select_for_update().get(pk=user.id)
|
AccountUser.objects.select_for_update().get(pk=user.id)
|
||||||
|
|
||||||
|
# هر سوالی که به API میرسد پول است و از سقفِ روزانه کم میشود (رایگان: AI_DAILY_QUOTA_FREE،
|
||||||
|
# مشترک: Plan.ai_daily_quota). قفلِ ردیفِ کاربر جلوی دو سوالِ همزمان روی آخرین سهم را میگیرد.
|
||||||
today = timezone.localdate()
|
today = timezone.localdate()
|
||||||
used_today = Message.objects.filter(
|
used_today = Message.objects.filter(
|
||||||
conversation__user_id=user.id, role='user', created_at__date=today
|
conversation__user_id=user.id, role='user', created_at__date=today, charge='free',
|
||||||
).count()
|
).count()
|
||||||
if used_today >= DAILY_AI_QUOTA:
|
quota = daily_quota(user.id)
|
||||||
raise Throttled(detail='سقف روزانهی سوال از هوش مصنوعی به پایان رسیده است.')
|
if not screened:
|
||||||
|
_check_global_daily_cap()
|
||||||
|
if screened:
|
||||||
|
charge = 'none'
|
||||||
|
elif used_today < quota:
|
||||||
|
charge = 'free'
|
||||||
|
else:
|
||||||
|
from subscription.services import has_active_subscription
|
||||||
|
|
||||||
|
n = str(quota).translate(str.maketrans('0123456789', '۰۱۲۳۴۵۶۷۸۹'))
|
||||||
|
raise Throttled(detail=(
|
||||||
|
# مشترک تا فردا صبر میکند، بیاشتراک اشتراک میخرد (سوالِ جدا فروخته نمیشود).
|
||||||
|
f'سقفِ امروزِ سوال از دستیار ({n} سوال) پر شد. فردا دوباره میتونی بپرسی.'
|
||||||
|
if has_active_subscription(user.id) else
|
||||||
|
f'سوالهای رایگانِ امروز ({n} سوال) تمام شد. با خریدِ اشتراک هر روز سوالِ بیشتری بپرس.'
|
||||||
|
))
|
||||||
|
|
||||||
conversation_id = v.get('conversationId')
|
conversation_id = v.get('conversationId')
|
||||||
|
conversation_is_new = not conversation_id
|
||||||
if conversation_id:
|
if conversation_id:
|
||||||
conversation = get_object_or_404(Conversation, pk=conversation_id, user_id=user.id)
|
# ورودی غیرعددی هم باید 404 بدهد، نه خطای خام ORM.
|
||||||
|
try:
|
||||||
|
conversation = get_object_or_404(Conversation, pk=conversation_id, user_id=user.id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise Http404
|
||||||
else:
|
else:
|
||||||
|
# lessonId/chapterId روی Conversation فیلدهای IntegerField هستند؛
|
||||||
|
# مقدار غیرعددی نادیده گرفته میشود (None ذخیره میشود) اما مقدار
|
||||||
|
# خام همچنان بدون تغییر به generate_tutor_answer میرسد.
|
||||||
|
raw_lesson_id = v.get('lessonId')
|
||||||
|
raw_chapter_id = v.get('chapterId')
|
||||||
conversation = Conversation.objects.create(
|
conversation = Conversation.objects.create(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
lesson_id=v.get('lessonId') or None,
|
lesson_id=raw_lesson_id if _is_db_ref_id(raw_lesson_id) else None,
|
||||||
chapter_id=v.get('chapterId') or None,
|
chapter_id=raw_chapter_id if _is_db_ref_id(raw_chapter_id) else None,
|
||||||
grade_id=v.get('gradeId'),
|
grade_id=v.get('gradeId'),
|
||||||
|
subject=(v.get('subject') or '').strip(),
|
||||||
|
lesson_number=v.get('lessonNumber'),
|
||||||
)
|
)
|
||||||
|
|
||||||
Message.objects.create(
|
raw_question_id = v.get('questionId')
|
||||||
|
user_message = Message.objects.create(
|
||||||
conversation=conversation,
|
conversation=conversation,
|
||||||
role='user',
|
role='user',
|
||||||
text=v['userQuestion'],
|
text=v['userQuestion'],
|
||||||
question_id=v.get('questionId') or None,
|
question_id=raw_question_id if _is_db_ref_id(raw_question_id) else None,
|
||||||
|
charge=charge,
|
||||||
)
|
)
|
||||||
|
# قفل ردیف کاربر اینجا آزاد شده؛ فراخوانی AI بیرون از هر تراکنشی اجرا میشود.
|
||||||
|
# لایهی RAG (اپ rag) اینجا context را میسازد: پرسش → embedding →
|
||||||
|
# جستجوی Qdrant (محدود به subject+grade+lessonNumber) → چند خلاصه.
|
||||||
|
# اگر RAG پیکربندی نشده باشد context=None میماند و generate_tutor_answer
|
||||||
|
# همان پاسخِ stub را میدهد. خطای شبکه/provider در همین try به 503
|
||||||
|
# تبدیل و اسلاتِ رزروشده آزاد میشود.
|
||||||
|
# مقادیرِ ذخیرهشده روی مکالمه هم fallbackاند: در پیامِ دوم به بعد، اپ
|
||||||
|
# فقط conversationId میفرستد و دیگر lessonId/gradeId نمیآید.
|
||||||
|
grade_id = v.get('gradeId') or conversation.grade_id
|
||||||
|
lesson_id = v.get('lessonId') or conversation.lesson_id
|
||||||
|
# ⚠️ درسِ تازهی همین درخواست از کتابِ ذخیرهشدهی مکالمه جلو میافتد: از وقتی یک پایه چند
|
||||||
|
# کتاب دارد، مکالمهای که با درسِ هندسه شروع شده میتواند با درسِ حسابان ادامه پیدا کند و
|
||||||
|
# `conversation.subject`/`lesson_number`ِ قدیمی جواب را از کتابِ غلط میگرفت.
|
||||||
|
explicit_lesson = bool(v.get('lessonId'))
|
||||||
|
subject = (v.get('subject') or ('' if explicit_lesson else conversation.subject) or '').strip()
|
||||||
|
grade_title = (v.get('grade') or '').strip()
|
||||||
|
lesson_number = v.get('lessonNumber')
|
||||||
|
if lesson_number is None and not explicit_lesson:
|
||||||
|
lesson_number = conversation.lesson_number
|
||||||
|
|
||||||
answer_text, follow_ups = generate_tutor_answer(
|
# اگر اپ فقط lessonId (یا فقط conversationId) داده — حالتِ طبیعی، چون
|
||||||
v['userQuestion'], v.get('gradeId'), v.get('lessonId')
|
# میداند دانشآموز روی کدام درس است نه اینکه نامِ collectionِ Qdrant
|
||||||
)
|
# چیست — خودمان subject/پایه/فصل را از زنجیرهی
|
||||||
assistant_message = Message.objects.create(
|
# Lesson→Node→Unit→Grade درمیآوریم.
|
||||||
conversation=conversation,
|
#
|
||||||
role='assistant',
|
# ⚠️ شرط عمداً `grade_title` را هم میبیند: پیشتر فقط subject و
|
||||||
text=answer_text,
|
# lesson_number را چک میکرد، و چون Conversation فیلدی برای عنوانِ پایه
|
||||||
question_id=v.get('questionId') or None,
|
# ندارد، از پیامِ دومِ هر مکالمه grade_title خالی میماند و
|
||||||
)
|
# resolve_target هیچ collectionی نمیساخت — یعنی فقط جوابِ اول به کتاب
|
||||||
|
# مستند بود و بقیه بیسروصدا عمومی میشدند.
|
||||||
|
if lesson_id and not (subject and grade_title and lesson_number is not None):
|
||||||
|
from rag.services.lesson_context import resolve_from_lesson
|
||||||
|
|
||||||
used_today += 1 # همین سوالی که الان ثبت شد هم جزو مصرف امروزه
|
derived = resolve_from_lesson(lesson_id)
|
||||||
remaining_quota = max(DAILY_AI_QUOTA - used_today, 0)
|
subject = subject or derived.get('subject', '')
|
||||||
|
grade_title = grade_title or derived.get('gradeTitle', '')
|
||||||
|
if lesson_number is None:
|
||||||
|
lesson_number = derived.get('chapter')
|
||||||
|
# تا آخرِ همین مکالمه دوباره حساب نشود.
|
||||||
|
if subject and not conversation.subject:
|
||||||
|
conversation.subject = subject
|
||||||
|
conversation.lesson_number = lesson_number
|
||||||
|
conversation.save(update_fields=['subject', 'lesson_number'])
|
||||||
|
|
||||||
|
# کتاب صریح آمده (فرانت کتابِ انتخابیِ دانشآموز را میفرستد) ولی درس و عنوانِ پایه نه:
|
||||||
|
# عنوانِ پایه از پایهی خودِ کاربر درمیآید، وگرنه resolve_target هیچ collectionی نمیسازد
|
||||||
|
# و جواب بیصدا عمومی میشد.
|
||||||
|
if subject and not grade_title:
|
||||||
|
from rag.services.config import resolve_grade_title
|
||||||
|
|
||||||
|
grade_title = resolve_grade_title(grade_id or getattr(user, 'grade_id', None)) or ''
|
||||||
|
|
||||||
|
# نه درسی آمده نه subject — حالتِ عادیِ صفحهی «از پی بپرس» که از نوارِ
|
||||||
|
# پایین باز میشود. پایهی ذخیرهشدهی خودِ کاربر کتاب را معلوم میکند؛
|
||||||
|
# بدونِ این، جوابهای آن صفحه هیچوقت به کتاب مستند نمیشدند.
|
||||||
|
if not subject:
|
||||||
|
from rag.services.lesson_context import resolve_from_grade
|
||||||
|
|
||||||
|
grade_id = grade_id or getattr(user, 'grade_id', None)
|
||||||
|
derived = resolve_from_grade(grade_id)
|
||||||
|
subject = derived.get('subject', '')
|
||||||
|
grade_title = grade_title or derived.get('gradeTitle', '')
|
||||||
|
if subject and not conversation.subject:
|
||||||
|
conversation.subject = subject
|
||||||
|
conversation.grade_id = conversation.grade_id or grade_id
|
||||||
|
conversation.save(update_fields=['subject', 'grade_id'])
|
||||||
|
|
||||||
|
age = _learner_age(user)
|
||||||
|
|
||||||
|
# --- مسیرِ استریم (SSE) --- فقط وقتی stream=true و RAG پیکربندیشده.
|
||||||
|
# قراردادِ JSONِ غیراستریم دستنخورده میماند؛ Swagger که stream
|
||||||
|
# نمیفرسته همون مسیرِ قبلی رو میگیره.
|
||||||
|
if v.get('stream'):
|
||||||
|
from rag.services.config import rag_is_configured
|
||||||
|
|
||||||
|
if rag_is_configured():
|
||||||
|
return self._streaming_response(
|
||||||
|
request=request, v=v, user=user, conversation=conversation,
|
||||||
|
user_message=user_message, conversation_is_new=conversation_is_new,
|
||||||
|
idempotency_key=idempotency_key, used_today=used_today,
|
||||||
|
subject=subject, grade_id=grade_id, grade_title=grade_title,
|
||||||
|
lesson_number=lesson_number, screened=screened, quota=quota, age=age,
|
||||||
|
)
|
||||||
|
|
||||||
|
used_chunks: list[dict] = []
|
||||||
|
retrieval_meta: dict = {}
|
||||||
|
generation_meta: dict = {}
|
||||||
|
answer_from_cache = False
|
||||||
|
context = None
|
||||||
|
try:
|
||||||
|
from rag.services.config import rag_is_configured
|
||||||
|
|
||||||
|
if screened:
|
||||||
|
answer_text, follow_ups = screened[1], []
|
||||||
|
generation_meta = {'promptVersion': f'guardrail-{screened[0]}'}
|
||||||
|
elif rag_is_configured():
|
||||||
|
from rag.services import answer_cache
|
||||||
|
from rag.services.generation import generate_answer
|
||||||
|
from rag.services.retrieval import resolve_target, retrieve_context
|
||||||
|
|
||||||
|
history = _conversation_history(conversation, user_message)
|
||||||
|
focus = _question_focus(v, user)
|
||||||
|
cacheable = answer_cache.cacheable(history) and not focus
|
||||||
|
_col, _fk, _fv = resolve_target(subject, grade_title, grade_id, lesson_number)
|
||||||
|
cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'], _cache_grade(grade_title, age))
|
||||||
|
cached = answer_cache.get(cache_key) if cacheable else None
|
||||||
|
if cached is not None:
|
||||||
|
answer_text = cached['answer']
|
||||||
|
follow_ups = list(cached['followUps'])
|
||||||
|
used_chunks = cached['usedChunks']
|
||||||
|
retrieval_meta = cached.get('retrievalMeta') or {}
|
||||||
|
generation_meta = cached.get('generationMeta') or {}
|
||||||
|
answer_from_cache = True
|
||||||
|
_uncharge(user_message)
|
||||||
|
else:
|
||||||
|
# Qdrant و مدل چند ثانیه طول میکشند؛ اتصالِ دیتابیس در این مدت به pool برمیگردد.
|
||||||
|
release_db_connection()
|
||||||
|
used_chunks, retrieval_meta = retrieve_context(
|
||||||
|
_retrieval_query(v['userQuestion'], history, focus),
|
||||||
|
subject=subject, grade_id=grade_id,
|
||||||
|
grade_title=grade_title, lesson_number=lesson_number,
|
||||||
|
)
|
||||||
|
used_chunks = [
|
||||||
|
{'title': c.get('title', ''), 'summary': c.get('summary', '')}
|
||||||
|
for c in used_chunks
|
||||||
|
]
|
||||||
|
answer_text, follow_ups, generation_meta = generate_answer(
|
||||||
|
v['userQuestion'], used_chunks or None,
|
||||||
|
retrieval_meta.get('gradeTitle') or grade_title or '',
|
||||||
|
history=history, focus=focus, age=age,
|
||||||
|
)
|
||||||
|
provider_status.record_success()
|
||||||
|
# ردِ «غیرِ ریاضی» نه منبعِ کتاب نشان میدهد نه کش میشود.
|
||||||
|
if generation_meta.get('refused'):
|
||||||
|
used_chunks = []
|
||||||
|
elif cacheable:
|
||||||
|
answer_cache.set(cache_key, {
|
||||||
|
'answer': answer_text, 'followUps': follow_ups,
|
||||||
|
'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta,
|
||||||
|
'generationMeta': generation_meta,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
answer_text, follow_ups = generate_tutor_answer(
|
||||||
|
v['userQuestion'], grade_id, lesson_id, context
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception('generate_tutor_answer failed for user_id=%s', user.id)
|
||||||
|
provider_status.record_failure(exc)
|
||||||
|
self._release_reserved_slot(conversation, user_message, conversation_is_new)
|
||||||
|
raise AIProviderUnavailable()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
assistant_message = Message.objects.create(
|
||||||
|
conversation=conversation,
|
||||||
|
role='assistant',
|
||||||
|
text=answer_text,
|
||||||
|
question_id=_db_ref_or_none(v.get('questionId')),
|
||||||
|
**_annotation(retrieval_meta, generation_meta, from_cache=answer_from_cache),
|
||||||
|
)
|
||||||
|
|
||||||
|
if idempotency_key:
|
||||||
|
AskIdempotency.objects.create(
|
||||||
|
user_id=user.id,
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
conversation=conversation,
|
||||||
|
user_message=user_message,
|
||||||
|
assistant_message=assistant_message,
|
||||||
|
suggested_follow_ups=follow_ups,
|
||||||
|
)
|
||||||
|
except IntegrityError:
|
||||||
|
if not idempotency_key:
|
||||||
|
raise
|
||||||
|
existing = AskIdempotency.objects.filter(
|
||||||
|
user_id=user.id, idempotency_key=idempotency_key,
|
||||||
|
).select_related('conversation', 'user_message', 'assistant_message').first()
|
||||||
|
if existing is None:
|
||||||
|
raise
|
||||||
|
self._release_reserved_slot(conversation, user_message, conversation_is_new)
|
||||||
|
return Response(self._build_ask_response(existing).data)
|
||||||
|
|
||||||
|
state = quota_state(user.id)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'conversationId': str(conversation.id),
|
'conversationId': str(conversation.id),
|
||||||
'messageId': str(assistant_message.id),
|
'messageId': str(assistant_message.id),
|
||||||
'answer': answer_text,
|
'answer': answer_text,
|
||||||
'suggestedFollowUps': follow_ups,
|
'suggestedFollowUps': follow_ups,
|
||||||
'remainingQuota': remaining_quota,
|
'remainingQuota': state['remainingQuota'],
|
||||||
|
'diagram': assistant_message.diagram,
|
||||||
|
'usedChunks': [
|
||||||
|
{'title': c.get('title', ''), 'summary': c.get('summary', '')}
|
||||||
|
for c in used_chunks
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
# فقط برای بازبینیِ داخلیِ خودمان (نه کاربر): از کدام collection/فصل
|
||||||
|
# چانکها برداشته شد. فقط وقتی DEBUG روشن است در پاسخ میآید.
|
||||||
|
if settings.DEBUG and retrieval_meta:
|
||||||
|
data['retrievalDebug'] = {
|
||||||
|
'collection': retrieval_meta.get('collection'),
|
||||||
|
'filterKey': retrieval_meta.get('filterKey'),
|
||||||
|
'chapterOrLesson': retrieval_meta.get('filterValue'),
|
||||||
|
'gradeTitle': retrieval_meta.get('gradeTitle'),
|
||||||
|
'chunkCount': retrieval_meta.get('chunkCount'),
|
||||||
|
'chunkTitles': [c.get('title', '') for c in used_chunks],
|
||||||
|
}
|
||||||
return Response(AskResponseSerializer(data).data)
|
return Response(AskResponseSerializer(data).data)
|
||||||
|
|
||||||
|
def _streaming_response(self, *, request, v, user, conversation, user_message,
|
||||||
|
conversation_is_new, idempotency_key, used_today,
|
||||||
|
subject, grade_id, grade_title, lesson_number, screened=None, quota=None,
|
||||||
|
age=None):
|
||||||
|
"""
|
||||||
|
نسخهی SSE از post(): همون منطقِ retrieval + کوتا + idempotency، ولی
|
||||||
|
پاسخ توکنبهتوکن استریم میشه. رویدادها:
|
||||||
|
event: chunk data: {"delta": "..."}
|
||||||
|
event: done data: {conversationId, messageId, remainingQuota, diagram, usedChunks[, retrievalDebug]}
|
||||||
|
event: error data: {"detail": "..."}
|
||||||
|
فاز ۱ (رزرو کوتا + conversation + user_message) قبلاً در post() انجام شده.
|
||||||
|
"""
|
||||||
|
from rag.services import answer_cache, guardrails
|
||||||
|
from rag.services.generation import (
|
||||||
|
PROMPT_VERSION, chat_model_for, default_follow_ups, stream_answer,
|
||||||
|
)
|
||||||
|
from rag.services.retrieval import resolve_target, retrieve_context
|
||||||
|
|
||||||
|
def _sse(event: str, payload: dict) -> str:
|
||||||
|
return f'event: {event}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n'
|
||||||
|
|
||||||
|
def _gen():
|
||||||
|
nonlocal used_today
|
||||||
|
generation_meta: dict = {}
|
||||||
|
answer_from_cache = False
|
||||||
|
_col, _fk, _fv = resolve_target(subject, grade_title, grade_id, lesson_number)
|
||||||
|
cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'], _cache_grade(grade_title, age))
|
||||||
|
try:
|
||||||
|
history = _conversation_history(conversation, user_message)
|
||||||
|
focus = _question_focus(v, user)
|
||||||
|
cacheable = answer_cache.cacheable(history) and not focus
|
||||||
|
cached = answer_cache.get(cache_key) if cacheable and not screened else None
|
||||||
|
if screened:
|
||||||
|
answer_text, follow_ups = screened[1], []
|
||||||
|
used_chunks, retrieval_meta = [], {}
|
||||||
|
generation_meta = {'promptVersion': f'guardrail-{screened[0]}'}
|
||||||
|
yield _sse('chunk', {'delta': answer_text})
|
||||||
|
elif cached is not None:
|
||||||
|
answer_text = cached['answer']
|
||||||
|
follow_ups = list(cached['followUps'])
|
||||||
|
used_chunks = cached['usedChunks']
|
||||||
|
retrieval_meta = cached.get('retrievalMeta') or {}
|
||||||
|
generation_meta = cached.get('generationMeta') or {}
|
||||||
|
answer_from_cache = True
|
||||||
|
_uncharge(user_message)
|
||||||
|
yield _sse('chunk', {'delta': answer_text})
|
||||||
|
else:
|
||||||
|
# کلِ استریم (تا ۴۰ ثانیه) بیاتصالِ دیتابیس؛ ثبتِ پیامِ آخر یکی تازه قرض میگیرد.
|
||||||
|
release_db_connection()
|
||||||
|
used_chunks, retrieval_meta = retrieve_context(
|
||||||
|
_retrieval_query(v['userQuestion'], history, focus),
|
||||||
|
subject=subject, grade_id=grade_id,
|
||||||
|
grade_title=grade_title, lesson_number=lesson_number,
|
||||||
|
)
|
||||||
|
used_chunks = [
|
||||||
|
{'title': c.get('title', ''), 'summary': c.get('summary', '')}
|
||||||
|
for c in used_chunks
|
||||||
|
]
|
||||||
|
parts: list[str] = []
|
||||||
|
stream_result: dict = {}
|
||||||
|
started = time.monotonic()
|
||||||
|
effective_grade = retrieval_meta.get('gradeTitle') or grade_title or ''
|
||||||
|
try:
|
||||||
|
for delta in stream_answer(
|
||||||
|
v['userQuestion'], used_chunks or None, effective_grade,
|
||||||
|
history=history, focus=focus, result=stream_result, age=age,
|
||||||
|
):
|
||||||
|
parts.append(delta)
|
||||||
|
yield _sse('chunk', {'delta': delta})
|
||||||
|
except GeneratorExit:
|
||||||
|
# کاربر صفحه را وسطِ جواب بست. سوال خرج میماند (مدل هزینهاش را گرفته)، ولی
|
||||||
|
# همان بخشی که ساخته شد ثبت میشود تا گفتگو سوالِ بیجواب نداشته باشد.
|
||||||
|
self._save_partial_answer(conversation, user, v, ''.join(parts))
|
||||||
|
raise
|
||||||
|
provider_status.record_success()
|
||||||
|
answer_text = ''.join(parts).strip() or 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم.'
|
||||||
|
follow_ups = default_follow_ups()
|
||||||
|
# توکنها از آخرین تکهی استریم میآیند (stream_options.include_usage).
|
||||||
|
# تا ۲۰۲۶-۱۰-۰۱ اینجا صفر ثبت میشد، یعنی هزینهی واقعیِ اپ هیچجا
|
||||||
|
# اندازهگیری نمیشد؛ providerی که usage نفرستد هنوز صفر میدهد.
|
||||||
|
usage = stream_result.get('usage') or {}
|
||||||
|
generation_meta = {
|
||||||
|
'model': chat_model_for(effective_grade),
|
||||||
|
'promptVersion': PROMPT_VERSION,
|
||||||
|
'latencyMs': int((time.monotonic() - started) * 1000),
|
||||||
|
'hadContext': bool(used_chunks),
|
||||||
|
'diagram': stream_result.get('diagram'),
|
||||||
|
'promptTokens': usage.get('promptTokens', 0),
|
||||||
|
'completionTokens': usage.get('completionTokens', 0),
|
||||||
|
}
|
||||||
|
if stream_result.get('refused'):
|
||||||
|
used_chunks = []
|
||||||
|
answer_text = guardrails.REFUSAL
|
||||||
|
elif cacheable:
|
||||||
|
answer_cache.set(cache_key, {
|
||||||
|
'answer': answer_text, 'followUps': follow_ups,
|
||||||
|
'usedChunks': used_chunks, 'retrievalMeta': retrieval_meta,
|
||||||
|
'generationMeta': generation_meta,
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception('stream generate failed for user_id=%s', user.id)
|
||||||
|
provider_status.record_failure(exc)
|
||||||
|
self._release_reserved_slot(conversation, user_message, conversation_is_new)
|
||||||
|
yield _sse('error', {'detail': AIProviderUnavailable.default_detail})
|
||||||
|
return
|
||||||
|
|
||||||
|
# فاز ۳: ثبتِ پیامِ دستیار + idempotency (همون منطقِ post()).
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
assistant_message = Message.objects.create(
|
||||||
|
conversation=conversation, role='assistant', text=answer_text,
|
||||||
|
question_id=_db_ref_or_none(v.get('questionId')),
|
||||||
|
**_annotation(retrieval_meta, generation_meta, from_cache=answer_from_cache),
|
||||||
|
)
|
||||||
|
if idempotency_key:
|
||||||
|
AskIdempotency.objects.create(
|
||||||
|
user_id=user.id, idempotency_key=idempotency_key,
|
||||||
|
conversation=conversation, user_message=user_message,
|
||||||
|
assistant_message=assistant_message,
|
||||||
|
suggested_follow_ups=follow_ups,
|
||||||
|
)
|
||||||
|
except IntegrityError:
|
||||||
|
existing = AskIdempotency.objects.filter(
|
||||||
|
user_id=user.id, idempotency_key=idempotency_key,
|
||||||
|
).select_related('assistant_message').first() if idempotency_key else None
|
||||||
|
if existing is None:
|
||||||
|
self._release_reserved_slot(conversation, user_message, conversation_is_new)
|
||||||
|
yield _sse('error', {'detail': AIProviderUnavailable.default_detail})
|
||||||
|
return
|
||||||
|
self._release_reserved_slot(conversation, user_message, conversation_is_new)
|
||||||
|
yield _sse('done', {
|
||||||
|
'conversationId': str(existing.conversation_id),
|
||||||
|
'messageId': str(existing.assistant_message_id),
|
||||||
|
'suggestedFollowUps': existing.suggested_follow_ups,
|
||||||
|
**quota_state(user.id),
|
||||||
|
'diagram': existing.assistant_message.diagram,
|
||||||
|
'usedChunks': [],
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
done = {
|
||||||
|
'conversationId': str(conversation.id),
|
||||||
|
'messageId': str(assistant_message.id),
|
||||||
|
'suggestedFollowUps': follow_ups,
|
||||||
|
**quota_state(user.id),
|
||||||
|
'diagram': assistant_message.diagram,
|
||||||
|
'usedChunks': [
|
||||||
|
{'title': c.get('title', ''), 'summary': c.get('summary', '')}
|
||||||
|
for c in used_chunks
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if settings.DEBUG and retrieval_meta:
|
||||||
|
done['retrievalDebug'] = {
|
||||||
|
'collection': retrieval_meta.get('collection'),
|
||||||
|
'filterKey': retrieval_meta.get('filterKey'),
|
||||||
|
'chapterOrLesson': retrieval_meta.get('filterValue'),
|
||||||
|
'gradeTitle': retrieval_meta.get('gradeTitle'),
|
||||||
|
'chunkCount': retrieval_meta.get('chunkCount'),
|
||||||
|
'chunkTitles': [c.get('title', '') for c in used_chunks],
|
||||||
|
}
|
||||||
|
yield _sse('done', done)
|
||||||
|
|
||||||
|
response = StreamingHttpResponse(_gen(), content_type='text/event-stream')
|
||||||
|
response['Cache-Control'] = 'no-cache'
|
||||||
|
response['X-Accel-Buffering'] = 'no'
|
||||||
|
return response
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _save_partial_answer(conversation, user, v, text):
|
||||||
|
logger.info('stream closed by client mid-answer: user_id=%s conversation_id=%s chars=%s',
|
||||||
|
user.id, conversation.id, len(text))
|
||||||
|
if not text.strip():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
Message.objects.create(
|
||||||
|
conversation=conversation, role='assistant', text=text.strip() + ' …',
|
||||||
|
question_id=_db_ref_or_none(v.get('questionId')),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception('saving partial answer failed: conversation_id=%s', conversation.id)
|
||||||
|
|
||||||
|
def _release_reserved_slot(self, conversation, user_message, conversation_is_new):
|
||||||
|
"""پاککردن رزرو فاز ۱ وقتی فاز بعدی شکست میخورد، تا نه پیام یتیم بماند و نه کوتا مصرفشده حساب شود."""
|
||||||
|
with transaction.atomic():
|
||||||
|
if conversation_is_new:
|
||||||
|
conversation.delete()
|
||||||
|
else:
|
||||||
|
user_message.delete()
|
||||||
|
|
||||||
|
def _build_ask_response(self, existing: AskIdempotency) -> AskResponseSerializer:
|
||||||
|
"""
|
||||||
|
ساختِ همون پاسخِ AskResponseSerializer برای یک AskIdempotency
|
||||||
|
از قبل ثبتشده (مسیرِ idempotent replay)، بدونِ ساختِ پیامِ جدید یا
|
||||||
|
مصرفِ کوتا.
|
||||||
|
|
||||||
|
⚠️ اصلاح: قبلاً suggestedFollowUps ذخیره نمیشد و اینجا با
|
||||||
|
فراخوانیِ دوبارهی generate_tutor_answer بازسازی میشد. تا وقتی
|
||||||
|
generate_tutor_answer یک stub خالی از اثر جانبیه بیضرر بود، اما
|
||||||
|
وقتی به یک AI provider واقعی وصل بشه، هر replay یک فراخوانیِ
|
||||||
|
واقعیِ دیگه به provider میزد (هزینه/تأخیرِ تکراری، و
|
||||||
|
suggestedFollowUps متفاوت در هر retry — دقیقاً برخلافِ هدفِ
|
||||||
|
idempotency). حالا suggested_follow_ups مستقیماً از خودِ رکوردِ
|
||||||
|
AskIdempotency (ذخیرهشده در همون درخواستِ اولِ موفق) خونده میشه؛
|
||||||
|
هیچ فراخوانیِ دیگری به generate_tutor_answer در مسیرِ replay انجام
|
||||||
|
نمیشه. answer هم مثلِ قبل از Message.text (بدونِ تغییر) خونده
|
||||||
|
میشه؛ remainingQuota هم مثلِ قبل از روی مصرفِ *فعلیِ* امروز
|
||||||
|
محاسبه میشه، نه لحظهی درخواستِ اولیه، چون این فراخوانیِ
|
||||||
|
idempotent بهخودیخود چیزی مصرف نکرده.
|
||||||
|
"""
|
||||||
|
conversation = existing.conversation
|
||||||
|
state = quota_state(existing.user_id)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'conversationId': str(conversation.id),
|
||||||
|
'messageId': str(existing.assistant_message.id),
|
||||||
|
'answer': to_plain(existing.assistant_message.text),
|
||||||
|
'suggestedFollowUps': existing.suggested_follow_ups,
|
||||||
|
'remainingQuota': state['remainingQuota'],
|
||||||
|
'diagram': existing.assistant_message.diagram,
|
||||||
|
}
|
||||||
|
return AskResponseSerializer(data)
|
||||||
|
|
||||||
|
|
||||||
class ConversationMessagesView(APIView):
|
class ConversationMessagesView(APIView):
|
||||||
"""GET /api/ai/conversations/{id}/messages — تاریخچهی مکالمه"""
|
"""GET /api/ai/conversations/{id}/messages — تاریخچهی مکالمه"""
|
||||||
|
|
||||||
|
# سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `read` همان نرخِ مسیرهای همخانواده است.
|
||||||
|
throttle_scope = 'read'
|
||||||
|
throttle_classes = [ScopedRateThrottle]
|
||||||
|
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
@extend_schema(responses=ConversationMessagesResponseSerializer, tags=['AI Tutor (ai_tutor)'])
|
@extend_schema(responses=ConversationMessagesResponseSerializer, tags=['AI Tutor (ai_tutor)'])
|
||||||
def get(self, request, conversation_id):
|
def get(self, request, conversation_id):
|
||||||
conversation = get_object_or_404(Conversation, pk=conversation_id, user_id=request.user.id)
|
conversation = get_object_or_404(Conversation, pk=conversation_id, user_id=request.user.id)
|
||||||
messages = conversation.messages.all()
|
# آخرین ۲۰۰ پیام، به ترتیبِ زمانی (قدیمی→جدید). جلوی رشدِ نامحدودِ پاسخ
|
||||||
|
# برای یک مکالمهی خیلی طولانی رو میگیره.
|
||||||
|
messages = reversed(list(conversation.messages.order_by('-id')[:200]))
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'conversationId': str(conversation.id),
|
'conversationId': str(conversation.id),
|
||||||
'lessonId': str(conversation.lesson_id) if conversation.lesson_id else None,
|
'lessonId': str(conversation.lesson_id) if conversation.lesson_id else None,
|
||||||
'messages': [
|
'messages': [
|
||||||
{'messageId': str(m.id), 'role': m.role, 'text': m.text} for m in messages
|
{'messageId': str(m.id), 'role': m.role,
|
||||||
|
# تورِ ایمنی: پیامِ قدیمی با LaTeX/markdown هم هرگز خام نمایش داده نمیشود.
|
||||||
|
'text': to_plain(m.text) if m.role == 'assistant' else plain_convert(m.text), 'diagram': m.diagram}
|
||||||
|
for m in messages
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
return Response(ConversationMessagesResponseSerializer(data).data)
|
return Response(ConversationMessagesResponseSerializer(data).data)
|
||||||
@@ -128,6 +851,10 @@ class ConversationMessagesView(APIView):
|
|||||||
class MessageFeedbackView(APIView):
|
class MessageFeedbackView(APIView):
|
||||||
"""POST /api/ai/messages/{id}/feedback — فیدبک 👍/👎"""
|
"""POST /api/ai/messages/{id}/feedback — فیدبک 👍/👎"""
|
||||||
|
|
||||||
|
# سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `user_profile_write` همان نرخِ مسیرهای همخانواده است.
|
||||||
|
throttle_scope = 'user_profile_write'
|
||||||
|
throttle_classes = [ScopedRateThrottle]
|
||||||
|
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
@@ -140,8 +867,27 @@ class MessageFeedbackView(APIView):
|
|||||||
serializer = FeedbackRequestSerializer(data=request.data)
|
serializer = FeedbackRequestSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
MessageFeedback.objects.update_or_create(
|
# `reason` فقط وقتی نوشته میشود که در همین درخواست آمده باشد: کاربری
|
||||||
message=message,
|
# که رأیش را عوض میکند معمولاً دلیل نمینویسد، و پیشفرضِ '' متنِ
|
||||||
defaults={'is_helpful': serializer.validated_data['isHelpful']},
|
# قبلیاش را پاک میکرد — یعنی مفیدترین بخشِ فیدبک از دست میرفت.
|
||||||
)
|
defaults = {'is_helpful': serializer.validated_data['isHelpful']}
|
||||||
|
if 'reason' in serializer.validated_data:
|
||||||
|
defaults['reason'] = serializer.validated_data['reason']
|
||||||
|
MessageFeedback.objects.update_or_create(message=message, defaults=defaults)
|
||||||
return Response(FeedbackResponseSerializer({'success': True}).data)
|
return Response(FeedbackResponseSerializer({'success': True}).data)
|
||||||
|
|
||||||
|
|
||||||
|
class QuotaView(APIView):
|
||||||
|
"""
|
||||||
|
GET /api/ai/quota — «امروز X سوال». اپ پیش از اولین سوال این را نشان میدهد؛ بعد از هر جواب همین عدد
|
||||||
|
در خودِ پاسخ هم میآید (remainingQuota).
|
||||||
|
"""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
throttle_scope = 'read'
|
||||||
|
throttle_classes = [ScopedRateThrottle]
|
||||||
|
|
||||||
|
@extend_schema(responses=QuotaResponseSerializer, tags=['AI Tutor (ai_tutor)'])
|
||||||
|
def get(self, request):
|
||||||
|
data = {'freeDaily': daily_quota(request.user.id), **quota_state(request.user.id)}
|
||||||
|
return Response(QuotaResponseSerializer(data).data)
|
||||||
|
|||||||
0
campaigns/__init__.py
Normal file
0
campaigns/__init__.py
Normal file
56
campaigns/admin.py
Normal file
56
campaigns/admin.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""
|
||||||
|
ثبتِ MessageCampaign و CampaignDelivery در Django Admin.
|
||||||
|
|
||||||
|
این ماژول صرفاً برای مشاهده/مدیریتِ دستیِ کمپینها و بررسیِ وضعیتِ
|
||||||
|
تحویلهاست؛ هیچ منطقِ اجرای کمپین (execute_campaign) اینجا صدا زده
|
||||||
|
نمیشه — طبق MessageCampaign.__doc__ و CampaignDelivery.__doc__، اجرا
|
||||||
|
و پنلِ ادمینِ مربوط به execution فیچرِ بعدیه.
|
||||||
|
|
||||||
|
- MessageCampaignAdmin: امکانِ ساخت/ویرایش/حذفِ کمپین (فیلدهای معمولی،
|
||||||
|
بدون منطقِ اجرا) رو با ModelAdmin پیشفرضِ Django فراهم میکنه.
|
||||||
|
- CampaignDeliveryAdmin: صرفاً read-only. CampaignDelivery یک لاگِ
|
||||||
|
وضعیتِ تولیدشده توسطِ execute_campaign()ه (طبق docstring خودش)؛
|
||||||
|
دستکاریِ دستیِ status/error_message از ادمین میتونه این لاگ رو با
|
||||||
|
چیزی که واقعاً برای کاربر اتفاق افتاده (یا نیفتاده) ناسازگار کنه و
|
||||||
|
رفتارِ idempotency/unique-constraint که در campaigns.services روی
|
||||||
|
همین رکوردها تکیه میکنه رو دور بزنه. به همین دلیل add/change/delete
|
||||||
|
همه غیرفعالن؛ تنها راهِ دیدنِ رکوردها لیست/دیتیلِ read-onlyه.
|
||||||
|
"""
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import CampaignDelivery, MessageCampaign
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(MessageCampaign)
|
||||||
|
class MessageCampaignAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('id', 'title', 'channel', 'created_at')
|
||||||
|
list_filter = ('channel',)
|
||||||
|
search_fields = ('title', 'body')
|
||||||
|
readonly_fields = ('created_at',)
|
||||||
|
fields = ('title', 'body', 'channel', 'target_filter', 'created_at')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(CampaignDelivery)
|
||||||
|
class CampaignDeliveryAdmin(admin.ModelAdmin):
|
||||||
|
"""
|
||||||
|
نمایشِ read-only: لیست و صفحهی جزئیات فقط با اجازهی view_campaigndelivery
|
||||||
|
(رفتارِ پیشفرضِ جنگو)، و هیچ راهی برای
|
||||||
|
افزودن، ویرایش یا حذفِ رکورد از ادمین وجود نداره تا وضعیتِ تحویل
|
||||||
|
(که فقط باید توسطِ campaigns.services.execute_campaign آپدیت بشه)
|
||||||
|
از ادمین دستکاری نشه.
|
||||||
|
"""
|
||||||
|
|
||||||
|
list_display = ('id', 'campaign', 'user_id', 'channel', 'status', 'created_at')
|
||||||
|
list_filter = ('channel', 'status')
|
||||||
|
search_fields = ('user_id', 'error_message')
|
||||||
|
fields = ('campaign', 'user_id', 'channel', 'status', 'error_message', 'created_at')
|
||||||
|
readonly_fields = fields
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_change_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
6
campaigns/apps.py
Normal file
6
campaigns/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'campaigns'
|
||||||
28
campaigns/migrations/0001_initial.py
Normal file
28
campaigns/migrations/0001_initial.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 5.2.17 on 2026-08-24 16:52
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='MessageCampaign',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('title', models.CharField(max_length=150)),
|
||||||
|
('body', models.TextField()),
|
||||||
|
('channel', models.CharField(choices=[('sms', 'پیامک'), ('in_app', 'اعلان داخل\u200cاپ')], max_length=10)),
|
||||||
|
('target_filter', models.JSONField(blank=True, default=dict)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
29
campaigns/migrations/0002_campaigndelivery.py
Normal file
29
campaigns/migrations/0002_campaigndelivery.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 5.2.17 on 2026-08-24 00:00
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('campaigns', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CampaignDelivery',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('user_id', models.IntegerField(db_index=True)),
|
||||||
|
('channel', models.CharField(choices=[('sms', 'پیامک'), ('in_app', 'اعلان داخل\u200cاپ')], max_length=10)),
|
||||||
|
('status', models.CharField(choices=[('pending', 'در انتظار'), ('sent', 'ارسال شد'), ('failed', 'ناموفق')], default='pending', max_length=10)),
|
||||||
|
('error_message', models.CharField(blank=True, max_length=300)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('campaign', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deliveries', to='campaigns.messagecampaign')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated manually for the campaign-delivery concurrency fix.
|
||||||
|
#
|
||||||
|
# اضافهکردنِ یک partial/conditional unique index روی
|
||||||
|
# CampaignDelivery(campaign, user_id, channel) که فقط رکوردهایی با
|
||||||
|
# status در ('pending', 'sent') رو محدود میکنه. رکوردهای failed از
|
||||||
|
# این constraint مستثنا هستن تا رفتارِ retryِ فعلی (ساختنِ رکوردِ
|
||||||
|
# pendingِ جدید بعد از یک تلاشِ ناموفق) بدونِ تغییر بمونه.
|
||||||
|
#
|
||||||
|
# این migration فقط یک constraint اضافه میکنه؛ هیچ فیلد/مدلِ جدیدی
|
||||||
|
# نمیسازه و به دیتای موجود دست نمیزنه.
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('campaigns', '0002_campaigndelivery'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='campaigndelivery',
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(status__in=['pending', 'sent']),
|
||||||
|
fields=('campaign', 'user_id', 'channel'),
|
||||||
|
name='campaigns_one_active_delivery_per_user_channel',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
campaigns/migrations/__init__.py
Normal file
0
campaigns/migrations/__init__.py
Normal file
113
campaigns/models.py
Normal file
113
campaigns/models.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.db.models import Q, UniqueConstraint
|
||||||
|
|
||||||
|
|
||||||
|
class MessageCampaign(models.Model):
|
||||||
|
"""
|
||||||
|
ذخیرهسازیِ مینیمالِ یک «کمپینِ پیامی» برای فیچرهای ارتباطی آینده.
|
||||||
|
|
||||||
|
این مدل صرفاً یک محلِ ذخیرهست؛ هیچ منطقِ ارسال/زمانبندی/صف
|
||||||
|
پسزمینه/retry/پیگیریِ تحویل اینجا نیست. ارسالِ واقعی (که از
|
||||||
|
accounts.services.send_sms یا notifications.services.notify استفاده
|
||||||
|
خواهد کرد) و انتخابِ کاربرانِ هدف (که از هلپرهای accounts.services،
|
||||||
|
مثلِ get_users_by_grade/get_inactive_users/..., استفاده خواهد کرد) در
|
||||||
|
فیچرهای بعدی روی همین رکورد پیاده میشن.
|
||||||
|
|
||||||
|
- `channel`: کانالِ ارسال (فعلاً پیامک یا اعلانِ داخلاپ).
|
||||||
|
- `target_filter`: تعریفِ هدف/فیلترِ انتخابشده، به همون شکلی که در UI
|
||||||
|
ساخته میشه (مثلاً {"type": "grade", "grade_id": 3} یا
|
||||||
|
{"type": "inactive_since_days", "days": 14} یا {"type": "all"}).
|
||||||
|
عمداً JSON آزاد نگه داشته شده تا با هلپرهای targeting در
|
||||||
|
accounts.services (که هیچکدوم جدولِ جدیدی ندارن) هماهنگ بمونه، بدونِ
|
||||||
|
اینکه این مدل به یک ساختارِ فیلترِ خاص قفل بشه.
|
||||||
|
"""
|
||||||
|
|
||||||
|
CHANNEL_CHOICES = [
|
||||||
|
('sms', 'پیامک'),
|
||||||
|
('in_app', 'اعلان داخلاپ'),
|
||||||
|
]
|
||||||
|
|
||||||
|
title = models.CharField(max_length=150)
|
||||||
|
body = models.TextField()
|
||||||
|
channel = models.CharField(max_length=10, choices=CHANNEL_CHOICES)
|
||||||
|
target_filter = models.JSONField(default=dict, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"campaign-{self.pk}-{self.channel}-{self.title}"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Django `choices=` روی CharField صرفاً برای فرم/ادمینه و بهخودیخود
|
||||||
|
در save() اجرا نمیشه؛ بدونِ این چک، هر مقدارِ دلخواهی برای channel
|
||||||
|
(مثلاً یک typo) میتونه ذخیره بشه و فقط بعداً، وقتِ اجرای
|
||||||
|
execute_campaign()، شکست بخوره. این کمترین اعتبارسنجیِ لازمه تا
|
||||||
|
مقدارِ نامعتبر اصلاً ذخیره نشه — بدونِ full_clean() روی کلِ مدل
|
||||||
|
(که فیلدهای دیگهای مثل title/target_filter رو هم درگیر میکنه و
|
||||||
|
در این پروژه جایی برای مدلها استفاده نشده) و با همون الگوی
|
||||||
|
ValueError که در campaigns.services برای خطاهای مشابه بهکار
|
||||||
|
رفته (مثلِ UnsupportedTargetFilter).
|
||||||
|
"""
|
||||||
|
valid_channels = {choice[0] for choice in self.CHANNEL_CHOICES}
|
||||||
|
if self.channel not in valid_channels:
|
||||||
|
raise ValueError(f"Invalid MessageCampaign channel: {self.channel!r}")
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignDelivery(models.Model):
|
||||||
|
"""
|
||||||
|
رکوردِ مینیمالِ نتیجهی تحویلِ یک MessageCampaign به یک کاربرِ هدف؛
|
||||||
|
همون «پیگیریِ تحویل» که در MessageCampaign.__doc__ بهعنوانِ فیچرِ
|
||||||
|
بعدی اشاره شده بود. این مدل صرفاً یک لاگِ وضعیته — بدونِ retry، صفِ
|
||||||
|
پسزمینه، زمانبندی، API یا پنلِ ادمین. execute_campaign() به ازای هر
|
||||||
|
تلاشِ واقعیِ ارسال (نه هر کاربرِ resolveشده — کاربرانِ skipشده مثلِ
|
||||||
|
مهمانِ بدونِ شماره رکورد نمیگیرن) یک رکورد میسازه و بعد از تلاش،
|
||||||
|
وضعیتش رو بهروزرسانی میکنه.
|
||||||
|
|
||||||
|
- `user_id`: ارجاعِ سبک به accounts.User، همالگو با
|
||||||
|
notifications.models.Notification.user_id (بدونِ FK واقعی).
|
||||||
|
- `status`: هنگامِ ساختِ رکورد pendingه (قبل از تلاش)، بعد از تلاش
|
||||||
|
به sent یا failed آپدیت میشه.
|
||||||
|
- `error_message`: فقط در status=failed پر میشه.
|
||||||
|
"""
|
||||||
|
|
||||||
|
STATUS_PENDING = 'pending'
|
||||||
|
STATUS_SENT = 'sent'
|
||||||
|
STATUS_FAILED = 'failed'
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
(STATUS_PENDING, 'در انتظار'),
|
||||||
|
(STATUS_SENT, 'ارسال شد'),
|
||||||
|
(STATUS_FAILED, 'ناموفق'),
|
||||||
|
]
|
||||||
|
|
||||||
|
campaign = models.ForeignKey(
|
||||||
|
MessageCampaign, on_delete=models.CASCADE, related_name='deliveries',
|
||||||
|
)
|
||||||
|
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
|
||||||
|
channel = models.CharField(max_length=10, choices=MessageCampaign.CHANNEL_CHOICES)
|
||||||
|
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=STATUS_PENDING)
|
||||||
|
error_message = models.CharField(max_length=300, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
constraints = [
|
||||||
|
# جلوگیریِ race condition: اگه execute_campaign() برای همون
|
||||||
|
# campaign همزمان اجرا بشه، این constraint تضمین میکنه حداکثر
|
||||||
|
# یک رکوردِ «فعال یا موفق» (pending/sent) بهازای هر
|
||||||
|
# (campaign, user_id, channel) باشه؛ تلاشِ دومِ همزمان با
|
||||||
|
# IntegrityError شکست میخوره و بهعنوانِ claimشده skip میشه.
|
||||||
|
# status=failed عمداً مستثناست تا retry (ساختِ pendingِ جدید
|
||||||
|
# بعد از شکست) دستنخورده بمونه.
|
||||||
|
UniqueConstraint(
|
||||||
|
fields=['campaign', 'user_id', 'channel'],
|
||||||
|
condition=Q(status__in=['pending', 'sent']),
|
||||||
|
name='campaigns_one_active_delivery_per_user_channel',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"delivery-{self.pk}-campaign{self.campaign_id}-user{self.user_id}-{self.status}"
|
||||||
287
campaigns/services.py
Normal file
287
campaigns/services.py
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
"""
|
||||||
|
اجرای یک MessageCampaign: صرفاً «چسباندنِ» سه چیزِ از قبل موجود به هم —
|
||||||
|
هدفگیریِ کاربر (accounts.services)، ارسالِ پیامک (accounts.services.send_sms)
|
||||||
|
و ثبتِ اعلانِ داخلاپ (notifications.services.notify). این ماژول هیچ منطقِ
|
||||||
|
جدیدِ ارسال/اعلان نمیسازه، هیچ صف/جاب پسزمینهای نداره، و هیچ زمانبندی،
|
||||||
|
retry یا پیگیریِ تحویلی انجام نمیده — طبق MessageCampaign.__doc__، اونا
|
||||||
|
مسئولیتِ فیچرهای بعدی هستن.
|
||||||
|
|
||||||
|
فراخوانی همیشه sync و بلاکینگه (برای هر کاربر یک send_sms/notify)؛
|
||||||
|
مناسبِ لیستهای کوچیک یا اجرای دستی/تست، نه کمپینهای بزرگ در پروداکشن.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
from django.db import IntegrityError, OperationalError, transaction
|
||||||
|
|
||||||
|
from accounts import services as account_services
|
||||||
|
from accounts.services import SMSDeliveryError
|
||||||
|
from notifications.services import notify
|
||||||
|
|
||||||
|
from .models import CampaignDelivery, MessageCampaign
|
||||||
|
|
||||||
|
|
||||||
|
def _retry_on_sqlite_lock(fn, *, retries=8, base_delay=0.05, max_delay=0.4):
|
||||||
|
"""
|
||||||
|
روی SQLite وقتی دو اتصالِ همزمان میخوان همون جدول رو بنویسن، ممکنه
|
||||||
|
OperationalError('database table is locked') بگیریم. این SQLITE_LOCKEDه
|
||||||
|
نه SQLITE_BUSY، پس busy_timeoutِ اتصال کمکی نمیکنه و بلافاصله raise
|
||||||
|
میشه؛ خطای واقعیِ داده نیست، فقط تناقضِ لحظهایِ لاک. هر
|
||||||
|
OperationalError دیگه (یا شکستِ تلاشِ آخر) دستنخورده بالا میره.
|
||||||
|
|
||||||
|
⚠️ پنجرهی انتظار باید از خودِ قفل درازتر باشد، وگرنه محافظ فقط *گاهی*
|
||||||
|
کار میکند. نسخهی اول ۵ تلاش با backoffِ خطی داشت — رویِهم ۰٫۵ ثانیه —
|
||||||
|
و تستِ همزمانیِ campaigns را در حدودِ نیمی از اجراهای کاملِ سوئیت قرمز
|
||||||
|
میکرد، چون زیرِ بارِ کلِ تستها قفل بیشتر از آن طول میکشد. حالا
|
||||||
|
backoff نمایی است و تا ~۲ ثانیه صبر میکند؛ سقفِ max_delay هم هست تا
|
||||||
|
یک قفلِ طولانی، درخواستِ کاربر را بینهایت معلق نگه ندارد.
|
||||||
|
"""
|
||||||
|
for attempt in range(retries):
|
||||||
|
try:
|
||||||
|
return fn()
|
||||||
|
except OperationalError as exc:
|
||||||
|
if 'locked' not in str(exc).lower() or attempt == retries - 1:
|
||||||
|
raise
|
||||||
|
time.sleep(min(base_delay * (2 ** attempt), max_delay))
|
||||||
|
|
||||||
|
# عمداً به notifications.models.Notification.TYPE_CHOICES اضافه نشده: آن
|
||||||
|
# choices فقط رویدادهاییه که notifications بهعنوانِ listener گوش میده،
|
||||||
|
# اینجا notify() مستقیم صدا زده میشه؛ Notification.type یک CharField
|
||||||
|
# معمولیه، مقداردهیِ خارج از choices مجازه.
|
||||||
|
CAMPAIGN_NOTIFICATION_TYPE = 'message_campaign'
|
||||||
|
|
||||||
|
|
||||||
|
class UnsupportedTargetFilter(ValueError):
|
||||||
|
"""target_filter کمپین با هیچکدوم از حالتهای فعلاً پشتیبانیشده مطابقت نداره."""
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_target_users(target_filter: dict):
|
||||||
|
"""
|
||||||
|
target_filter رو به یک QuerySet از accounts.User تبدیل میکنه، فقط با
|
||||||
|
استفاده از هلپرهای موجودِ accounts.services (بدونِ کوئریِ جدید).
|
||||||
|
|
||||||
|
حالتهای فعلاً پشتیبانیشده:
|
||||||
|
{"type": "all"}
|
||||||
|
{"type": "grade", "grade_id": <int>}
|
||||||
|
{"type": "inactive_since_days", "days": <int>}
|
||||||
|
{"type": "active_subscription"}
|
||||||
|
"""
|
||||||
|
target_filter = target_filter or {}
|
||||||
|
filter_type = target_filter.get('type')
|
||||||
|
|
||||||
|
if filter_type == 'all':
|
||||||
|
return account_services.get_all_users()
|
||||||
|
|
||||||
|
if filter_type == 'grade':
|
||||||
|
return account_services.get_users_by_grade(target_filter['grade_id'])
|
||||||
|
|
||||||
|
if filter_type == 'inactive_since_days':
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
since = timezone.now() - timedelta(days=target_filter['days'])
|
||||||
|
return account_services.get_inactive_users(since)
|
||||||
|
|
||||||
|
if filter_type == 'active_subscription':
|
||||||
|
return account_services.get_users_with_active_subscription()
|
||||||
|
|
||||||
|
raise UnsupportedTargetFilter(
|
||||||
|
f"Unsupported target_filter type: {filter_type!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_delivery_result(delivery: CampaignDelivery, *, error: Exception | None) -> None:
|
||||||
|
"""رکوردِ pendingای که قبل از تلاشِ ارسال ساخته شده رو به sent/failed آپدیت میکنه."""
|
||||||
|
if error is None:
|
||||||
|
delivery.status = CampaignDelivery.STATUS_SENT
|
||||||
|
_retry_on_sqlite_lock(lambda: delivery.save(update_fields=['status']))
|
||||||
|
return
|
||||||
|
|
||||||
|
delivery.status = CampaignDelivery.STATUS_FAILED
|
||||||
|
delivery.error_message = str(error)[:300]
|
||||||
|
_retry_on_sqlite_lock(lambda: delivery.save(update_fields=['status', 'error_message']))
|
||||||
|
|
||||||
|
|
||||||
|
def _claim_delivery(campaign: MessageCampaign, user_id: int) -> CampaignDelivery | None:
|
||||||
|
"""
|
||||||
|
رکوردِ pendingِ یک CampaignDelivery رو برای این کاربر «claim» میکنه،
|
||||||
|
یا اگه یک اجرای همزمانِ دیگرِ همین campaign همین کاربر رو زودتر
|
||||||
|
claim کرده باشه، None برمیگردونه (بدونِ ساختنِ رکورد و بدونِ
|
||||||
|
فراخوانیِ send_sms/notify).
|
||||||
|
|
||||||
|
چکِ already_sent_user_ids قبل از شروعِ حلقه یکبار انجام میشه، پس
|
||||||
|
بینِ اون چک و رسیدنِ نوبتِ این کاربر در حلقه، ممکنه یک اجرای همزمانِ
|
||||||
|
دیگر (پراسسِ دیگر/دابلکلیکِ اجرا) دقیقاً همین کاربر رو claim کرده و
|
||||||
|
حتی به sent رسونده باشه (TOCTOU). این تابع همون کاریه که چکِ
|
||||||
|
اپلیکیشنلایه فرض میکرد ولی گارانتی نمیداد: با تکیه به
|
||||||
|
CampaignDelivery.Meta.constraints (یک رکوردِ فعال/موفق به ازای هر
|
||||||
|
campaign+user_id+channel)، اگه یک رکوردِ pending یا sent از قبل
|
||||||
|
برای همین کاربر وجود داشته باشه (چه توسطِ این اجرا چه توسطِ اجرای
|
||||||
|
همزمانِ دیگر)، create() با IntegrityError رد میشه و این تابع
|
||||||
|
None برمیگردونه — یعنی کاربر بهجای duplicate delivery، skip میشه.
|
||||||
|
"""
|
||||||
|
def _create():
|
||||||
|
with transaction.atomic():
|
||||||
|
return CampaignDelivery.objects.create(
|
||||||
|
campaign=campaign, user_id=user_id, channel=campaign.channel,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return _retry_on_sqlite_lock(_create)
|
||||||
|
except IntegrityError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _already_sent_user_ids(campaign: MessageCampaign) -> set[int]:
|
||||||
|
"""
|
||||||
|
user_idهایی که برای همین campaign+channel از قبل یک CampaignDelivery
|
||||||
|
با status=sent دارن — یعنی تحویلِ واقعی قبلاً با موفقیت انجام شده.
|
||||||
|
|
||||||
|
این استعلام دقیقاً همون کاریه که پرامپت بهعنوانِ جهتِ حل پیشنهاد
|
||||||
|
داده: «استفاده از رکوردهای CampaignDelivery موجود برای جلوگیری از
|
||||||
|
ارسالِ دوبارهی تحویلهایی که قبلاً کامل شدن». عمداً روی pending/failed
|
||||||
|
فیلتر نمیکنه: pending یعنی تلاشِ قبلی هیچوقت به نتیجه نرسیده (مثلاً
|
||||||
|
اجرا وسط کار قطع شده) و failed یعنی واقعاً تحویل داده نشده — در هر دو
|
||||||
|
حالت، تلاشِ دوباره «duplicate delivery» نیست، بلکه اولین تحویلِ واقعیِ
|
||||||
|
موفقه؛ پس این کاربرها باید دوباره امتحان بشن.
|
||||||
|
"""
|
||||||
|
return set(
|
||||||
|
CampaignDelivery.objects.filter(
|
||||||
|
campaign=campaign,
|
||||||
|
channel=campaign.channel,
|
||||||
|
status=CampaignDelivery.STATUS_SENT,
|
||||||
|
).values_list('user_id', flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def execute_campaign(campaign: MessageCampaign) -> None:
|
||||||
|
"""
|
||||||
|
یک MessageCampaign رو اجرا میکنه: کاربرانِ هدف رو resolve میکنه و به
|
||||||
|
ازای هر کدوم، بسته به campaign.channel، یا اعلانِ داخلاپ ثبت میکنه
|
||||||
|
(notify) یا پیامک میفرسته (send_sms). به ازای هر تلاشِ واقعیِ ارسال،
|
||||||
|
یک CampaignDelivery ساخته میشه (ابتدا pending) و بعد از تلاش به
|
||||||
|
sent/failed آپدیت میشه — بدونِ retry، صفِ پسزمینه یا زمانبندی.
|
||||||
|
|
||||||
|
Idempotency: اگه execute_campaign() دوباره برای همون campaign صدا زده
|
||||||
|
بشه (مثلاً بهخاطرِ اجرای دستیِ تکراری یا ریاستارتِ فرآیند وسطِ کار)،
|
||||||
|
کاربرهایی که از قبل یک CampaignDelivery با status=sent برای همین
|
||||||
|
campaign+channel دارن، دوباره skip میشن — نه CampaignDeliveryی
|
||||||
|
جدیدی براشون ساخته میشه و نه send_sms/notify دوباره صداشون میزنه.
|
||||||
|
این چک قبل از شروعِ حلقه، یکبار برای کلِ کمپین انجام میشه (نه به
|
||||||
|
ازای هر کاربر) تا کوئریِ اضافه نداشته باشیم. کاربرهایی که رکوردشون
|
||||||
|
pending یا failed مونده، عمداً دوباره امتحان میشن؛ چون براشون هنوز
|
||||||
|
هیچ تحویلِ موفقی ثبت نشده.
|
||||||
|
|
||||||
|
Concurrency: چکِ بالا خودش TOCTOUه (یکبار قبل از حلقه محاسبه
|
||||||
|
میشه)، پس اگه دو اجرای execute_campaign() برای همون campaign
|
||||||
|
همزمان راه بیفتن، ممکنه هر دو یک کاربر رو «هنوز sent نیست» ببینن.
|
||||||
|
محافظتِ واقعی در _claim_delivery() و در سطحِ دیتابیسه: هر کاربر قبل
|
||||||
|
از notify/send_sms باید یک رکوردِ CampaignDelivery رو claim کنه، و
|
||||||
|
یک unique constraint روی مدل (به ازای هر campaign+user_id+channel
|
||||||
|
حداکثر یک رکوردِ pending/sent) تضمین میکنه که فقط یکی از اون دو
|
||||||
|
اجرای همزمان میتونه claim موفق داشته باشه؛ اون یکی دیگه بدونِ
|
||||||
|
ساختنِ رکورد و بدونِ صدازدنِ notify/send_sms، آن کاربر رو skip
|
||||||
|
میکنه.
|
||||||
|
|
||||||
|
کاربرانِ مهمان بدونِ شمارهتلفن، برای کانالِ sms بهسادگی رد (skip)
|
||||||
|
میشن و هیچ CampaignDeliveryای براشون ساخته نمیشه — چون اینجا
|
||||||
|
اصلاً تلاشی برای ارسال انجام نشده؛ accounts.services.send_sms برای
|
||||||
|
شمارهی خالی معنی نداره و هیچ صف/ارسالِ جایگزینی هم براشون نیست.
|
||||||
|
|
||||||
|
شکستِ تحویلِ sms (SMSDeliveryError) دیگه کلِ اجرای کمپین رو متوقف
|
||||||
|
نمیکنه؛ فقط همون رکورد failed میشه و اجرا برای بقیهی کاربران ادامه
|
||||||
|
پیدا میکنه.
|
||||||
|
|
||||||
|
Consistency (مارککردنِ sent بعد از تلاشِ موفق): برای in_app، هم
|
||||||
|
notify() (یک Notification.objects.create ساده) و هم آپدیتِ نهاییِ
|
||||||
|
CampaignDelivery به sent، هر دو نوشتنِ دیتابیسیِ ساده روی همین
|
||||||
|
دیتابیسن — پس هر دو تویِ یک `transaction.atomic()` انجام میشن. اگه
|
||||||
|
notify() خودش شکست بخوره یا اگه بعد از notify() موفق، خودِ نوشتنِ
|
||||||
|
«sent» به هر دلیلی (مثلاً یک خطای دیتابیسِ گذرا) شکست بخوره، کل
|
||||||
|
تراکنش rollback میشه — یعنی Notificationای هم که notify() نوشته
|
||||||
|
بود هیچوقت persist نمیشه، و بعدش رکورد به failed آپدیت میشه (بیرونِ
|
||||||
|
همون تراکنشِ rollbackشده، با یک نوشتنِ مستقلِ جدید) و exception
|
||||||
|
دوباره بالا میره. نتیجه: هیچوقت به وضعیتِ ناسازگارِ «Notification
|
||||||
|
واقعاً وجود داره ولی CampaignDelivery رو pendingِ گیرکرده مونده»
|
||||||
|
نمیرسیم؛ رکورد یا واقعاً sent میشه (با Notificationِ واقعی) یا
|
||||||
|
failed میشه (بدونِ هیچ Notificationِ persistشدهای) — و چون failed
|
||||||
|
از unique constraint مستثناست، برای اجرای بعدی قابلِ retry میمونه.
|
||||||
|
|
||||||
|
برای sms این کار عمداً انجام نشده و نمیتونه انجام بشه: send_sms()
|
||||||
|
یک تماسِ خارجی با providerه، نه یک نوشتنِ دیتابیسی — یک
|
||||||
|
transaction.atomic() فقط نوشتنهای همین دیتابیس رو rollback میکنه؛
|
||||||
|
پیامکی که provider واقعاً فرستاده رو نمیشه با rollbackِ دیتابیس
|
||||||
|
«پس گرفت». اگه بعد از یک send_sms() موفق، خودِ نوشتنِ «sent» شکست
|
||||||
|
بخوره، exception (بدونِ catch/hide) بالا میره و رکورد pending
|
||||||
|
میمونه — دقیقاً همون رفتارِ قبلی. این pendingِ گیرکرده باعثِ
|
||||||
|
duplicate SMS در اجرای بعدی نمیشه (چون unique constraint یک
|
||||||
|
claimِ دوباره برای همون کاربر رو رد میکنه)، ولی اون یک رکوردِ
|
||||||
|
خاص هم دیگه هیچوقت خودکار به sent/failed نمیرسه — نیازمندِ
|
||||||
|
بررسیِ دستیه، که پیامدِ ذاتیِ غیرقابلاجتنابِ غیرترانزکشنالبودنِ
|
||||||
|
provider خارجیه، نه یک باگِ قابلرفعِ کوچیک.
|
||||||
|
"""
|
||||||
|
users = _resolve_target_users(campaign.target_filter)
|
||||||
|
already_sent_user_ids = _already_sent_user_ids(campaign)
|
||||||
|
|
||||||
|
if campaign.channel == 'in_app':
|
||||||
|
# ⚠️ عمداً list() و نه .iterator(): کرسرِ باز باید *پیش از* اولین
|
||||||
|
# نوشتن بسته شود. با .iterator() خواندنِ کاربرها در تمامِ طولِ حلقه
|
||||||
|
# باز میماند و روی SQLite همان، جدول را برای هر نویسندهی همزمان
|
||||||
|
# قفل میکند — قفلی که تا پایانِ حلقه رها نمیشود، پس هیچ مقدار
|
||||||
|
# retry هم نجاتش نمیدهد. این دقیقاً همان چیزی بود که تستِ
|
||||||
|
# همزمانیِ campaigns را در حدودِ نیمی از اجراها قرمز میکرد.
|
||||||
|
#
|
||||||
|
# حافظه: اینجا فقط idها نگه داشته میشوند و این ماژول طبقِ
|
||||||
|
# docstringِ بالا برای لیستهای کوچک است، نه کمپینِ انبوه.
|
||||||
|
for user_id in list(users.values_list('id', flat=True)):
|
||||||
|
if user_id in already_sent_user_ids:
|
||||||
|
continue
|
||||||
|
delivery = _claim_delivery(campaign, user_id)
|
||||||
|
if delivery is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
def _notify_and_mark_sent(user_id=user_id, delivery=delivery):
|
||||||
|
with transaction.atomic():
|
||||||
|
notify(
|
||||||
|
user_id=user_id,
|
||||||
|
type=CAMPAIGN_NOTIFICATION_TYPE,
|
||||||
|
title=campaign.title,
|
||||||
|
body=campaign.body,
|
||||||
|
related_id=campaign.id,
|
||||||
|
)
|
||||||
|
_mark_delivery_result(delivery, error=None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_retry_on_sqlite_lock(_notify_and_mark_sent)
|
||||||
|
except Exception as exc:
|
||||||
|
# چه notify() خودش شکست بخوره چه فقط نوشتنِ نهاییِ sent
|
||||||
|
# (که تویِ همون atomic block بالاست)، هر دو حالت اینجا
|
||||||
|
# میرسه: تراکنش rollback شده (پس هیچ Notificationِ
|
||||||
|
# persistشدهای نمونده)، و این save یک نوشتنِ جدید و
|
||||||
|
# مستقله که رکورد رو به failed میبره تا برای اجرای بعدی
|
||||||
|
# گیر نکنه. exception عمداً catch/hide نمیشه و بالا میره.
|
||||||
|
_mark_delivery_result(delivery, error=exc)
|
||||||
|
raise
|
||||||
|
return
|
||||||
|
|
||||||
|
if campaign.channel == 'sms':
|
||||||
|
# همان دلیلِ شاخهی in_app بالا، و اینجا حتی مهمتر: وسطِ این حلقه
|
||||||
|
# یک فراخوانیِ شبکهای به کاوهنگار انجام میشود، پس کرسرِ باز
|
||||||
|
# میتوانست بهاندازهی کلِ ارسالِ پیامکها جدول را قفل نگه دارد.
|
||||||
|
for user_id, phone in list(users.values_list('id', 'phone')):
|
||||||
|
if user_id in already_sent_user_ids:
|
||||||
|
continue
|
||||||
|
if not phone:
|
||||||
|
continue
|
||||||
|
delivery = _claim_delivery(campaign, user_id)
|
||||||
|
if delivery is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
account_services.send_sms(phone=phone, message=campaign.body)
|
||||||
|
except SMSDeliveryError as exc:
|
||||||
|
_mark_delivery_result(delivery, error=exc)
|
||||||
|
else:
|
||||||
|
_mark_delivery_result(delivery, error=None)
|
||||||
|
return
|
||||||
|
|
||||||
|
raise ValueError(f"Unsupported campaign channel: {campaign.channel!r}")
|
||||||
495
campaigns/tests.py
Normal file
495
campaigns/tests.py
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
import threading
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import IntegrityError, OperationalError, connection, transaction
|
||||||
|
from django.test import TestCase, TransactionTestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from accounts.models import User
|
||||||
|
from accounts.services import SMSDeliveryError
|
||||||
|
from notifications.models import Notification
|
||||||
|
|
||||||
|
from .admin import CampaignDeliveryAdmin, MessageCampaignAdmin
|
||||||
|
from .models import CampaignDelivery, MessageCampaign
|
||||||
|
from .services import execute_campaign
|
||||||
|
|
||||||
|
|
||||||
|
class MessageCampaignChannelValidationTests(TestCase):
|
||||||
|
"""
|
||||||
|
اطمینان از اینکه مقدارِ نامعتبر برای channel اصلاً ذخیره نمیشه (نه
|
||||||
|
اینکه فقط بعداً موقعِ execute_campaign() شکست بخوره)، و مقادیرِ
|
||||||
|
معتبرِ فعلی (sms, in_app) بدون تغییر همچنان کار میکنن.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_valid_channels_still_save(self):
|
||||||
|
for channel in ('sms', 'in_app'):
|
||||||
|
campaign = MessageCampaign.objects.create(
|
||||||
|
title=f'title-{channel}', body='body', channel=channel,
|
||||||
|
)
|
||||||
|
self.assertEqual(campaign.channel, channel)
|
||||||
|
|
||||||
|
def test_invalid_channel_rejected_on_create(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
MessageCampaign.objects.create(
|
||||||
|
title='bad', body='body', channel='email',
|
||||||
|
)
|
||||||
|
self.assertEqual(MessageCampaign.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_invalid_channel_rejected_on_save(self):
|
||||||
|
campaign = MessageCampaign(title='bad', body='body', channel='whatsapp')
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
campaign.save()
|
||||||
|
self.assertEqual(MessageCampaign.objects.count(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecuteCampaignIdempotencyTests(TestCase):
|
||||||
|
"""
|
||||||
|
اجرای دوبارهی execute_campaign() برای همون campaign نباید کاربرهایی
|
||||||
|
که قبلاً یک CampaignDelivery با status=sent دارن رو دوباره تحویل
|
||||||
|
بده (نه CampaignDelivery تکراری، نه notify/send_sms تکراری).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user1 = User.objects.create(name='u1', phone='0910000001')
|
||||||
|
self.user2 = User.objects.create(name='u2', phone='0910000002')
|
||||||
|
|
||||||
|
def test_in_app_second_run_skips_already_sent_users(self):
|
||||||
|
campaign = MessageCampaign.objects.create(
|
||||||
|
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
|
||||||
|
)
|
||||||
|
|
||||||
|
execute_campaign(campaign)
|
||||||
|
self.assertEqual(CampaignDelivery.objects.filter(campaign=campaign).count(), 2)
|
||||||
|
self.assertEqual(Notification.objects.count(), 2)
|
||||||
|
self.assertEqual(
|
||||||
|
set(CampaignDelivery.objects.filter(campaign=campaign).values_list('status', flat=True)),
|
||||||
|
{CampaignDelivery.STATUS_SENT},
|
||||||
|
)
|
||||||
|
|
||||||
|
# اجرای دوبارهی همون کمپین: نباید هیچ CampaignDelivery یا
|
||||||
|
# Notification جدیدی ساخته بشه، چون هر دو کاربر از قبل sent هستن.
|
||||||
|
execute_campaign(campaign)
|
||||||
|
self.assertEqual(CampaignDelivery.objects.filter(campaign=campaign).count(), 2)
|
||||||
|
self.assertEqual(Notification.objects.count(), 2)
|
||||||
|
|
||||||
|
@patch('campaigns.services.account_services.send_sms')
|
||||||
|
def test_sms_second_run_skips_already_sent_users(self, mock_send_sms):
|
||||||
|
campaign = MessageCampaign.objects.create(
|
||||||
|
title='t', body='b', channel='sms', target_filter={'type': 'all'},
|
||||||
|
)
|
||||||
|
|
||||||
|
execute_campaign(campaign)
|
||||||
|
self.assertEqual(mock_send_sms.call_count, 2)
|
||||||
|
self.assertEqual(CampaignDelivery.objects.filter(campaign=campaign).count(), 2)
|
||||||
|
|
||||||
|
execute_campaign(campaign)
|
||||||
|
# هیچ فراخوانیِ اضافهای برای send_sms نباید انجام بشه.
|
||||||
|
self.assertEqual(mock_send_sms.call_count, 2)
|
||||||
|
self.assertEqual(CampaignDelivery.objects.filter(campaign=campaign).count(), 2)
|
||||||
|
|
||||||
|
@patch('campaigns.services.account_services.send_sms')
|
||||||
|
def test_sms_failed_delivery_is_retried_not_treated_as_duplicate(self, mock_send_sms):
|
||||||
|
"""
|
||||||
|
رکوردهای failed نشونهی «تحویلِ قبلاً کاملشده» نیستن؛ پس اجرای
|
||||||
|
دوباره باید دوباره امتحانشون کنه، نه skip.
|
||||||
|
"""
|
||||||
|
mock_send_sms.side_effect = SMSDeliveryError('boom')
|
||||||
|
campaign = MessageCampaign.objects.create(
|
||||||
|
title='t', body='b', channel='sms', target_filter={'type': 'all'},
|
||||||
|
)
|
||||||
|
|
||||||
|
execute_campaign(campaign)
|
||||||
|
self.assertEqual(mock_send_sms.call_count, 2)
|
||||||
|
self.assertEqual(
|
||||||
|
set(CampaignDelivery.objects.filter(campaign=campaign).values_list('status', flat=True)),
|
||||||
|
{CampaignDelivery.STATUS_FAILED},
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_send_sms.side_effect = None
|
||||||
|
execute_campaign(campaign)
|
||||||
|
self.assertEqual(mock_send_sms.call_count, 4)
|
||||||
|
self.assertEqual(CampaignDelivery.objects.filter(campaign=campaign).count(), 4)
|
||||||
|
self.assertEqual(
|
||||||
|
CampaignDelivery.objects.filter(
|
||||||
|
campaign=campaign, status=CampaignDelivery.STATUS_SENT,
|
||||||
|
).count(),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch('campaigns.services.notify')
|
||||||
|
def test_in_app_unexpected_exception_marks_delivery_failed_and_reraises(self, mock_notify):
|
||||||
|
"""
|
||||||
|
اگه notify() یک استثنای غیرمنتظره بده، اون خطا نباید catch/hide
|
||||||
|
بشه (باید بالا بره، مثلِ قبل)، ولی رکوردِ pendingِ همون کاربر
|
||||||
|
باید قبل از re-raise به failed آپدیت بشه — وگرنه بهخاطرِ
|
||||||
|
unique constraintِ CampaignDelivery، این کاربر برای همیشه گیر
|
||||||
|
میکنه و هیچ اجرای بعدیای نمیتونه دوباره claim کنه.
|
||||||
|
"""
|
||||||
|
mock_notify.side_effect = RuntimeError('boom')
|
||||||
|
campaign = MessageCampaign.objects.create(
|
||||||
|
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
execute_campaign(campaign)
|
||||||
|
|
||||||
|
# فقط یک کاربر (اولین موردِ resolveشده) رکورد گرفته، چون خطا
|
||||||
|
# بلافاصله بعد از notify() اول بالا رفته و حلقه متوقف شده —
|
||||||
|
# دقیقاً همون رفتاری که قبل از این تغییر هم وجود داشت.
|
||||||
|
deliveries = CampaignDelivery.objects.filter(campaign=campaign)
|
||||||
|
self.assertEqual(deliveries.count(), 1)
|
||||||
|
self.assertEqual(deliveries.get().status, CampaignDelivery.STATUS_FAILED)
|
||||||
|
self.assertEqual(Notification.objects.count(), 0)
|
||||||
|
|
||||||
|
@patch('campaigns.services.notify')
|
||||||
|
def test_in_app_failed_delivery_after_exception_can_be_retried(self, mock_notify):
|
||||||
|
"""
|
||||||
|
بعد از اینکه یک اجرا با notify() شکستخورده رکوردِ failed ساخته،
|
||||||
|
اجرای بعدی نباید بهخاطرِ constraint گیر کنه — باید بتونه دوباره
|
||||||
|
برای همون کاربر claim کنه و اینبار اگه notify() موفق باشه، به
|
||||||
|
sent برسه.
|
||||||
|
"""
|
||||||
|
mock_notify.side_effect = RuntimeError('boom')
|
||||||
|
campaign = MessageCampaign.objects.create(
|
||||||
|
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
execute_campaign(campaign)
|
||||||
|
self.assertEqual(
|
||||||
|
CampaignDelivery.objects.filter(campaign=campaign).count(), 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_notify.side_effect = None
|
||||||
|
execute_campaign(campaign)
|
||||||
|
|
||||||
|
# هر دو کاربر باید حالا sent باشن: کاربرِ اول (که بارِ قبل failed
|
||||||
|
# شده بود) با یک رکوردِ جدید retry شده، و کاربرِ دوم هم برای
|
||||||
|
# اولینبار موفق بوده.
|
||||||
|
self.assertEqual(CampaignDelivery.objects.filter(campaign=campaign).count(), 3)
|
||||||
|
self.assertEqual(
|
||||||
|
CampaignDelivery.objects.filter(
|
||||||
|
campaign=campaign, status=CampaignDelivery.STATUS_SENT,
|
||||||
|
).count(),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignDeliveryConcurrencyTests(TestCase):
|
||||||
|
"""
|
||||||
|
محافظتِ سطحِ دیتابیس در برابرِ race condition بینِ دو اجرای همزمانِ
|
||||||
|
execute_campaign() برای همون campaign: هر (campaign, user_id,
|
||||||
|
channel) حداکثر یک رکوردِ «فعال» (pending یا sent) میتونه داشته
|
||||||
|
باشه. رکوردهای failed مستثنا هستن تا رفتارِ retryِ فعلی دستنخورده
|
||||||
|
بمونه (پوشش دادهشده در ExecuteCampaignIdempotencyTests بالا).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(name='u', phone='0910000003')
|
||||||
|
self.campaign = MessageCampaign.objects.create(
|
||||||
|
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_second_pending_delivery_for_same_user_is_rejected(self):
|
||||||
|
CampaignDelivery.objects.create(
|
||||||
|
campaign=self.campaign, user_id=self.user.id, channel='in_app',
|
||||||
|
)
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
with transaction.atomic():
|
||||||
|
CampaignDelivery.objects.create(
|
||||||
|
campaign=self.campaign, user_id=self.user.id, channel='in_app',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_second_sent_delivery_for_same_user_is_rejected(self):
|
||||||
|
first = CampaignDelivery.objects.create(
|
||||||
|
campaign=self.campaign, user_id=self.user.id, channel='in_app',
|
||||||
|
)
|
||||||
|
first.status = CampaignDelivery.STATUS_SENT
|
||||||
|
first.save(update_fields=['status'])
|
||||||
|
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
with transaction.atomic():
|
||||||
|
CampaignDelivery.objects.create(
|
||||||
|
campaign=self.campaign, user_id=self.user.id, channel='in_app',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_failed_delivery_does_not_block_retry(self):
|
||||||
|
first = CampaignDelivery.objects.create(
|
||||||
|
campaign=self.campaign, user_id=self.user.id, channel='in_app',
|
||||||
|
)
|
||||||
|
first.status = CampaignDelivery.STATUS_FAILED
|
||||||
|
first.save(update_fields=['status'])
|
||||||
|
|
||||||
|
# نباید IntegrityError بده: رکوردِ failed دیگه «فعال» حساب
|
||||||
|
# نمیشه، پس جای رکوردِ جدید رو برای retry باز میذاره.
|
||||||
|
second = CampaignDelivery.objects.create(
|
||||||
|
campaign=self.campaign, user_id=self.user.id, channel='in_app',
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(second.pk)
|
||||||
|
|
||||||
|
@patch('campaigns.services.notify')
|
||||||
|
def test_execute_campaign_skips_user_claimed_by_concurrent_run(self, mock_notify):
|
||||||
|
"""
|
||||||
|
شبیهسازیِ اجرای همزمان: یک رکوردِ pending از قبل برای این کاربر
|
||||||
|
وجود داره (انگار یک اجرای دیگرِ همین campaign همین الان داره
|
||||||
|
روش کار میکنه). execute_campaign نباید notify رو صدا بزنه یا
|
||||||
|
رکوردِ دیگهای برای این کاربر بسازه.
|
||||||
|
"""
|
||||||
|
CampaignDelivery.objects.create(
|
||||||
|
campaign=self.campaign, user_id=self.user.id, channel='in_app',
|
||||||
|
)
|
||||||
|
|
||||||
|
execute_campaign(self.campaign)
|
||||||
|
|
||||||
|
mock_notify.assert_not_called()
|
||||||
|
self.assertEqual(
|
||||||
|
CampaignDelivery.objects.filter(campaign=self.campaign).count(), 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecuteCampaignRealConcurrencyTests(TransactionTestCase):
|
||||||
|
"""
|
||||||
|
برخلافِ CampaignDeliveryConcurrencyTests (که وضعیتِ «قبلاً claimشده»
|
||||||
|
رو با ساختنِ دستیِ یک رکوردِ pending شبیهسازی میکنه)، این تست دو
|
||||||
|
execute_campaign() واقعی رو در دو تردِ جداگانه — هرکدوم با کانکشنِ
|
||||||
|
دیتابیسِ خودش — دقیقاً همزمان (با threading.Barrier) اجرا میکنه.
|
||||||
|
از TransactionTestCase استفاده شده (نه TestCase) چون TestCase کلِ
|
||||||
|
تست رو در یک تراکنشِ واحد rollback میکنه و بینِ کانکشنها ایزوله
|
||||||
|
نیست — یعنی race واقعیِ بینِ دو تراکنشِ مستقل رو نشون نمیده.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(name='u', phone='0910000004')
|
||||||
|
self.campaign = MessageCampaign.objects.create(
|
||||||
|
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_two_concurrent_runs_deliver_notification_exactly_once(self):
|
||||||
|
barrier = threading.Barrier(2)
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
def run():
|
||||||
|
try:
|
||||||
|
barrier.wait(timeout=5)
|
||||||
|
execute_campaign(self.campaign)
|
||||||
|
except Exception as exc: # pragma: no cover - surfaced via `errors`
|
||||||
|
errors.append(exc)
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=run) for _ in range(2)]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join(timeout=10)
|
||||||
|
|
||||||
|
self.assertEqual(errors, [])
|
||||||
|
self.assertEqual(
|
||||||
|
CampaignDelivery.objects.filter(
|
||||||
|
campaign=self.campaign, status=CampaignDelivery.STATUS_SENT,
|
||||||
|
).count(),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
self.assertEqual(Notification.objects.count(), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class InAppDeliveryConsistencyTests(TestCase):
|
||||||
|
"""
|
||||||
|
برای in_app، notify() و آپدیتِ نهاییِ CampaignDelivery به sent هر دو
|
||||||
|
تویِ یک transaction.atomic() هستن. این تست ثابت میکنه که اگه خودِ
|
||||||
|
نوشتنِ «sent» (نه notify()) به یک خطای دیتابیسی بخوره، کل تراکنش
|
||||||
|
rollback میشه — یعنی Notificationِ ساختهشده توسطِ notify() هم
|
||||||
|
persist نمیمونه — و رکورد بهجای اینکه برای همیشه pending گیر کنه،
|
||||||
|
failed میشه (و در نتیجه در اجرای بعدی قابلِ retryه).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(name='u', phone='0910000005')
|
||||||
|
self.campaign = MessageCampaign.objects.create(
|
||||||
|
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_failed_status_update_rolls_back_notification_and_marks_failed(self):
|
||||||
|
original_save = CampaignDelivery.save
|
||||||
|
|
||||||
|
def flaky_save(self_delivery, *args, **kwargs):
|
||||||
|
# فقط دقیقاً همون saveای که داره سعی میکنه رکورد رو به sent
|
||||||
|
# ببره شکست میخوره (نه saveی claim که status هنوز pendingه،
|
||||||
|
# و نه saveی مسیرِ failedِ بعدی که این تابع دیگه patch نشده).
|
||||||
|
if self_delivery.status == CampaignDelivery.STATUS_SENT:
|
||||||
|
raise OperationalError('simulated transient db failure')
|
||||||
|
return original_save(self_delivery, *args, **kwargs)
|
||||||
|
|
||||||
|
with patch.object(CampaignDelivery, 'save', flaky_save):
|
||||||
|
with self.assertRaises(OperationalError):
|
||||||
|
execute_campaign(self.campaign)
|
||||||
|
|
||||||
|
# notify() واقعاً صدا زده شده بود (نه mock)، ولی چون تویِ همون
|
||||||
|
# atomic-ی بود که بعداً rollback شد، هیچ Notificationِ persist
|
||||||
|
# شدهای نباید وجود داشته باشه.
|
||||||
|
self.assertEqual(Notification.objects.count(), 0)
|
||||||
|
|
||||||
|
delivery = CampaignDelivery.objects.get(campaign=self.campaign, user_id=self.user.id)
|
||||||
|
self.assertEqual(delivery.status, CampaignDelivery.STATUS_FAILED)
|
||||||
|
|
||||||
|
# حالا که رکورد failed شده (نه pendingِ گیرکرده)، اجرای بعدی
|
||||||
|
# باید بتونه دوباره claim کنه و اینبار (بدونِ patch) موفق بشه.
|
||||||
|
execute_campaign(self.campaign)
|
||||||
|
delivery.refresh_from_db()
|
||||||
|
self.assertEqual(delivery.status, CampaignDelivery.STATUS_FAILED)
|
||||||
|
self.assertEqual(
|
||||||
|
CampaignDelivery.objects.filter(
|
||||||
|
campaign=self.campaign, user_id=self.user.id,
|
||||||
|
status=CampaignDelivery.STATUS_SENT,
|
||||||
|
).count(),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
self.assertEqual(Notification.objects.count(), 1)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignsAdminRegistrationTests(TestCase):
|
||||||
|
"""
|
||||||
|
اطمینان از اینکه MessageCampaign و CampaignDelivery در Django Admin
|
||||||
|
ثبت شدن و یک ادمینِ staff میتونه changelist هرکدوم رو ببینه.
|
||||||
|
توجه: get_user_model() اینجا مدلِ auth.User جنگو (برای لاگین به
|
||||||
|
/admin/) هست، کاملاً جدا از accounts.models.User که مدلِ بیزنسیِ
|
||||||
|
این پروژهست و در CampaignDelivery.user_id بهصورت سبک ارجاع
|
||||||
|
میشه.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
AuthUser = get_user_model()
|
||||||
|
self.staff_user = AuthUser.objects.create_superuser(
|
||||||
|
username='admin', email='admin@example.com', password='pass12345',
|
||||||
|
)
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
|
||||||
|
def test_message_campaign_registered(self):
|
||||||
|
self.assertIn(MessageCampaign, admin.site._registry)
|
||||||
|
|
||||||
|
def test_campaign_delivery_registered(self):
|
||||||
|
self.assertIn(CampaignDelivery, admin.site._registry)
|
||||||
|
|
||||||
|
def test_message_campaign_changelist_accessible(self):
|
||||||
|
url = reverse('admin:campaigns_messagecampaign_changelist')
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_campaign_delivery_changelist_accessible(self):
|
||||||
|
url = reverse('admin:campaigns_campaigndelivery_changelist')
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
|
class MessageCampaignAdminManagementTests(TestCase):
|
||||||
|
"""
|
||||||
|
MessageCampaign باید از ادمین قابلِ ساخت/ویرایش/حذف باشه (بدونِ هیچ
|
||||||
|
منطقِ اجرا) — یعنی رفتارِ پیشفرضِ ModelAdmin دستنخورده مونده.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
AuthUser = get_user_model()
|
||||||
|
self.staff_user = AuthUser.objects.create_superuser(
|
||||||
|
username='admin2', email='admin2@example.com', password='pass12345',
|
||||||
|
)
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
|
||||||
|
def test_add_permission_allowed(self):
|
||||||
|
request = type('Req', (), {'user': self.staff_user})()
|
||||||
|
model_admin = MessageCampaignAdmin(MessageCampaign, admin.site)
|
||||||
|
self.assertTrue(model_admin.has_add_permission(request))
|
||||||
|
|
||||||
|
def test_can_create_campaign_via_admin(self):
|
||||||
|
url = reverse('admin:campaigns_messagecampaign_add')
|
||||||
|
response = self.client.post(url, {
|
||||||
|
'title': 'admin-created',
|
||||||
|
'body': 'body text',
|
||||||
|
'channel': 'sms',
|
||||||
|
'target_filter': '{}',
|
||||||
|
})
|
||||||
|
# ریدایرکتِ موفق بعدِ ساختِ رکورد
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertTrue(MessageCampaign.objects.filter(title='admin-created').exists())
|
||||||
|
|
||||||
|
def test_can_delete_campaign_via_admin(self):
|
||||||
|
campaign = MessageCampaign.objects.create(title='to-delete', body='b', channel='sms')
|
||||||
|
url = reverse('admin:campaigns_messagecampaign_delete', args=[campaign.pk])
|
||||||
|
response = self.client.post(url, {'post': 'yes'})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertFalse(MessageCampaign.objects.filter(pk=campaign.pk).exists())
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignDeliveryAdminReadOnlyTests(TestCase):
|
||||||
|
"""
|
||||||
|
CampaignDelivery نباید از ادمین قابلِ افزودن/ویرایش/حذفِ دستی باشه؛
|
||||||
|
فقط باید قابلِ مشاهده باشه. این دقیقاً همون چیزیه که
|
||||||
|
campaigns.services.execute_campaign برای idempotency/unique
|
||||||
|
constraint روش تکیه میکنه — دستکاریِ دستیِ status میتونه اون
|
||||||
|
تضمینها رو دور بزنه.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
AuthUser = get_user_model()
|
||||||
|
self.staff_user = AuthUser.objects.create_superuser(
|
||||||
|
username='admin3', email='admin3@example.com', password='pass12345',
|
||||||
|
)
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
self.user = User.objects.create(name='u', phone='0910000009')
|
||||||
|
self.campaign = MessageCampaign.objects.create(title='t', body='b', channel='sms')
|
||||||
|
self.delivery = CampaignDelivery.objects.create(
|
||||||
|
campaign=self.campaign, user_id=self.user.id, channel='sms',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_permission_methods_deny_add_change_delete_but_allow_view(self):
|
||||||
|
request = type('Req', (), {'user': self.staff_user})()
|
||||||
|
model_admin = CampaignDeliveryAdmin(CampaignDelivery, admin.site)
|
||||||
|
self.assertFalse(model_admin.has_add_permission(request))
|
||||||
|
self.assertFalse(model_admin.has_change_permission(request, self.delivery))
|
||||||
|
self.assertFalse(model_admin.has_delete_permission(request, self.delivery))
|
||||||
|
self.assertTrue(model_admin.has_view_permission(request, self.delivery))
|
||||||
|
|
||||||
|
def test_change_detail_view_is_viewable(self):
|
||||||
|
"""
|
||||||
|
صفحهی جزئیاتِ رکورد باید قابلِ مشاهده باشه (GET -> 200، بهصورتِ
|
||||||
|
read-only)، حتی اگه has_change_permission همیشه False باشه؛
|
||||||
|
دیدن نباید به fallbackِ codename پرمیشنِ پیشفرضِ Django وابسته
|
||||||
|
بمونه (رجوع به has_view_permission بالا).
|
||||||
|
"""
|
||||||
|
url = reverse('admin:campaigns_campaigndelivery_change', args=[self.delivery.pk])
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_all_fields_are_readonly(self):
|
||||||
|
model_admin = CampaignDeliveryAdmin(CampaignDelivery, admin.site)
|
||||||
|
self.assertEqual(set(model_admin.readonly_fields), set(model_admin.fields))
|
||||||
|
|
||||||
|
def test_add_view_forbidden(self):
|
||||||
|
url = reverse('admin:campaigns_campaigndelivery_add')
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_delete_view_forbidden(self):
|
||||||
|
url = reverse('admin:campaigns_campaigndelivery_delete', args=[self.delivery.pk])
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_change_via_post_does_not_modify_status(self):
|
||||||
|
"""
|
||||||
|
حتی اگه کسی مستقیماً POST بزنه به change URL، چون
|
||||||
|
has_change_permission همیشه False برمیگردونه، جنگو باید 403
|
||||||
|
بده و status رکورد نباید تغییر کنه.
|
||||||
|
"""
|
||||||
|
url = reverse('admin:campaigns_campaigndelivery_change', args=[self.delivery.pk])
|
||||||
|
response = self.client.post(url, {
|
||||||
|
'campaign': self.campaign.pk,
|
||||||
|
'user_id': self.user.id,
|
||||||
|
'channel': 'sms',
|
||||||
|
'status': CampaignDelivery.STATUS_SENT,
|
||||||
|
'error_message': '',
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
self.delivery.refresh_from_db()
|
||||||
|
self.assertEqual(self.delivery.status, CampaignDelivery.STATUS_PENDING)
|
||||||
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', ''))
|
||||||
51
config/exceptions.py
Normal file
51
config/exceptions.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""
|
||||||
|
هندلرِ سفارشیِ exception برای DRF: هندلرِ پیشفرض فقط APIException/Http404/
|
||||||
|
PermissionDenied رو میشناسه؛ برای بقیه None برمیگردونه و exception خام تا
|
||||||
|
مدیریتِ خطای جنگو بالا میره که (DEBUG=False) یه صفحهی HTML عمومی میده، نه
|
||||||
|
JSON. این هندلر اون حالت رو با traceback کامل لاگ میکنه و یه پاسخِ JSON
|
||||||
|
ژنریکِ ۵۰۰ برمیگردونه.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.core.exceptions import RequestDataTooBig, SuspiciousOperation
|
||||||
|
|
||||||
|
from config.db import SERVER_BUSY_BODY, SERVER_BUSY_RETRY_AFTER, is_pool_timeout, log_server_busy
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import exception_handler as drf_exception_handler
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def custom_exception_handler(exc, context):
|
||||||
|
response = drf_exception_handler(exc, context)
|
||||||
|
if response is not None:
|
||||||
|
return response
|
||||||
|
|
||||||
|
request = context.get('request')
|
||||||
|
path = getattr(request, 'path', '')
|
||||||
|
|
||||||
|
# بدنهی بزرگتر از DATA_UPLOAD_MAX_MEMORY_SIZE (≈ ۲٫۵ مگابایت): Django خودش این را ۴۰۰ میکند، ولی
|
||||||
|
# چون DRF آن را نمیشناسد پیشتر اینجا «۵۰۰ + ERRORِ لاگ با traceback» میشد و هر درخواستِ بزرگ لاگ را پر
|
||||||
|
# میکرد (۲۰۲۶-۱۰-۰۷ روی سرورِ زنده دیده شد). باقیِ SuspiciousOperationها هم همان ۴۰۰ِ Django را میگیرند.
|
||||||
|
# poolِ دیتابیس پر (config/db.py): ۵۰۳ با Retry-After. پیش از خواندنِ request.user پایینتر، که اگر
|
||||||
|
# احرازِ هویت خودش همین خطا را داده بود دوباره منتظرِ pool میماند.
|
||||||
|
if is_pool_timeout(exc):
|
||||||
|
log_server_busy(request)
|
||||||
|
return Response(SERVER_BUSY_BODY, status=503, headers={'Retry-After': str(SERVER_BUSY_RETRY_AFTER)})
|
||||||
|
if isinstance(exc, RequestDataTooBig):
|
||||||
|
logger.warning('request_too_large path=%s', path)
|
||||||
|
return Response({'detail': 'حجمِ درخواست بیش از حدِ مجاز است.'}, status=413)
|
||||||
|
if isinstance(exc, SuspiciousOperation):
|
||||||
|
logger.warning('suspicious_operation path=%s type=%s', path, type(exc).__name__)
|
||||||
|
return Response({'detail': 'درخواستِ نامعتبر.'}, status=400)
|
||||||
|
|
||||||
|
user = getattr(request, 'user', None)
|
||||||
|
user_id = user.id if getattr(user, 'is_authenticated', False) else None
|
||||||
|
logger.exception(
|
||||||
|
'unhandled_exception path=%s user_id=%s',
|
||||||
|
path, user_id,
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
{'detail': 'خطای غیرمنتظرهای رخ داد؛ لطفاً بعداً دوباره تلاش کنید.'},
|
||||||
|
status=500,
|
||||||
|
)
|
||||||
442
config/health.py
Normal file
442
config/health.py
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
"""
|
||||||
|
GET /api/health — «آیا این سرور واقعاً سالم است؟»
|
||||||
|
|
||||||
|
چرا لازم بود: تا امروز تنها راهِ فهمیدنِ اینکه دیپلوی درست نشسته یا نه، این
|
||||||
|
بود که کسی از بیرون اپ را دستی امتحان کند و اگر چیزی غلط بود به آدمی که به
|
||||||
|
سرور دسترسی دارد پیام بدهد. یعنی خرابی را همیشه آدم کشف میکرد، نه ابزار.
|
||||||
|
این اندپوینت همان بررسیها را از بیرون و بدونِ دسترسیِ SSH ممکن میکند.
|
||||||
|
|
||||||
|
مهمترین بررسی «مهاجرتهای اعمالنشده» است: شایعترین حالتِ خرابِ دیپلوی این
|
||||||
|
است که کد pull شده ولی migrate اجرا نشده — اپ بالا میآید و بعد زیرِ بارِ
|
||||||
|
واقعی با خطای ستونِ ناموجود میشکند.
|
||||||
|
|
||||||
|
عمداً هیچ سکرتی برنمیگرداند: فقط بله/خیرِ «تنظیم شده» و شمارشِ محتوا.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.db import connection
|
||||||
|
from django.db.migrations.executor import MigrationExecutor
|
||||||
|
from drf_spectacular.utils import extend_schema
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.throttling import ScopedRateThrottle
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_database() -> dict:
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute('SELECT 1')
|
||||||
|
cursor.fetchone()
|
||||||
|
return {'ok': True, 'engine': connection.vendor}
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return {'ok': False, 'error': type(exc).__name__}
|
||||||
|
|
||||||
|
|
||||||
|
def _check_migrations() -> dict:
|
||||||
|
"""مهاجرتِ اعمالنشده یعنی دیپلوی نیمهکاره است."""
|
||||||
|
try:
|
||||||
|
executor = MigrationExecutor(connection)
|
||||||
|
targets = executor.loader.graph.leaf_nodes()
|
||||||
|
pending = executor.migration_plan(targets)
|
||||||
|
# migration_plan یک فهرست از (migration, backwards) میدهد؛ خودِ
|
||||||
|
# Migration فیلدهای app_label و name دارد (نه key).
|
||||||
|
return {
|
||||||
|
'ok': not pending,
|
||||||
|
'pending': [f'{migration.app_label}.{migration.name}' for migration, _ in pending],
|
||||||
|
}
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return {'ok': False, 'error': type(exc).__name__}
|
||||||
|
|
||||||
|
|
||||||
|
def _check_cache() -> dict:
|
||||||
|
"""با DEBUG=False کشِ مشترک اجباری است؛ throttle بدونش بیاثر میشود."""
|
||||||
|
try:
|
||||||
|
cache.set('health:probe', 'ok', 10)
|
||||||
|
return {'ok': cache.get('health:probe') == 'ok', 'backend': settings.CACHES['default']['BACKEND'].rsplit('.', 1)[-1]}
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return {'ok': False, 'error': type(exc).__name__}
|
||||||
|
|
||||||
|
|
||||||
|
def _check_content() -> dict:
|
||||||
|
"""
|
||||||
|
محتوا روی این سرور هست یا نه — بدونِ این، اپ سالم به نظر میرسد ولی
|
||||||
|
کاربر صفحهی خالی میبیند.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from content.models import Grade, Lesson, Question
|
||||||
|
|
||||||
|
grades = Grade.objects.count()
|
||||||
|
lessons = Lesson.objects.count()
|
||||||
|
questions = Question.objects.count()
|
||||||
|
return {
|
||||||
|
'ok': grades > 0 and lessons > 0,
|
||||||
|
'grades': grades,
|
||||||
|
'lessons': lessons,
|
||||||
|
'questions': questions,
|
||||||
|
}
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return {'ok': False, 'error': type(exc).__name__}
|
||||||
|
|
||||||
|
|
||||||
|
def _traffic() -> dict:
|
||||||
|
"""
|
||||||
|
آیا کسی واقعاً به این سرور وصل میشود؟
|
||||||
|
|
||||||
|
تا امروز راهی نبود که از بیرون بفهمیم فرانت به سرور رسیده یا نه — باید
|
||||||
|
منتظر میماندیم کسی بگوید. این سه عدد همان سیگنالاند. فقط شمارش و زمان،
|
||||||
|
بدونِ هیچ اطلاعاتِ شخصی.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from accounts.models import User
|
||||||
|
|
||||||
|
since = timezone.now() - timedelta(hours=24)
|
||||||
|
last = User.objects.order_by('-last_activity_at').values_list(
|
||||||
|
'last_activity_at', flat=True,
|
||||||
|
).first()
|
||||||
|
return {
|
||||||
|
'users': User.objects.count(),
|
||||||
|
'activeLast24h': User.objects.filter(last_activity_at__gte=since).count(),
|
||||||
|
'lastActivityAt': last.isoformat() if last else None,
|
||||||
|
}
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return {'error': type(exc).__name__}
|
||||||
|
|
||||||
|
|
||||||
|
def _check_integrations() -> dict:
|
||||||
|
"""
|
||||||
|
فقط «تنظیم شده یا نه» — هیچ کلیدی برنمیگردد. `aiProvider` وضعیتِ واقعیِ
|
||||||
|
آخرین تماس است: کلیدِ تنظیمشده با اعتبارِ تمامشده هم `openai: true` بود.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'qdrant': bool(getattr(settings, 'QDRANT_API_KEY', '')),
|
||||||
|
'openai': bool(getattr(settings, 'OPENAI_API_KEY', '')),
|
||||||
|
'sms': bool(getattr(settings, 'KAVENEGAR_API_KEY', '')),
|
||||||
|
'bazaar': _bazaar_configured(),
|
||||||
|
'bazaarDynamicPrice': bool(getattr(settings, 'BAZAAR_DYNAMIC_PRICE_KEY', '')),
|
||||||
|
'aiProvider': _ai_provider_state(),
|
||||||
|
'chatModel': getattr(settings, 'OPENAI_CHAT_MODEL', ''),
|
||||||
|
'chatModelUpper': getattr(settings, 'OPENAI_CHAT_MODEL_UPPER', '') or None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _bazaar_configured() -> bool:
|
||||||
|
try:
|
||||||
|
from payment.bazaar import is_configured
|
||||||
|
return is_configured()
|
||||||
|
except Exception: # noqa: BLE001 — سلامت نباید با خطای خواندنِ اعتبارِ بازار ۵۰۰ بدهد؛ ولی دیده شود
|
||||||
|
logger.exception('health: بررسیِ تنظیمِ بازار شکست خورد')
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_provider_state() -> dict:
|
||||||
|
try:
|
||||||
|
from statuslog.models import StatusChangeLog
|
||||||
|
|
||||||
|
row = StatusChangeLog.objects.filter(label='ai_tutor.Provider').values(
|
||||||
|
'new_status', 'created_at').first()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return {'error': type(exc).__name__}
|
||||||
|
if row is None:
|
||||||
|
return {'state': 'ok', 'since': None}
|
||||||
|
return {'state': row['new_status'], 'since': row['created_at'].isoformat()}
|
||||||
|
|
||||||
|
|
||||||
|
def _offsite_backup() -> dict:
|
||||||
|
"""
|
||||||
|
کپیِ زندهی دیتابیس روی سرورِ دیگر (`ops/pgbackup/`، شرح در docs/operations.md «بکاپ»).
|
||||||
|
|
||||||
|
آن سرور با `pg_receivewal` از اسلاتِ تکثیرِ `OFFSITE_WAL_SLOT` هر تغییر را لحظهای میگیرد؛ اینجا از
|
||||||
|
سمتِ همین دیتابیس دیده میشود که وصل است و چقدر عقب است. ستنبودنِ اسلات روی Postgres یعنی کپیِ
|
||||||
|
بیرونی نداریم و هشدار میشود. base backupِ روزانهی آن سرور از اینجا دیده نمیشود (journalِ همانجا).
|
||||||
|
"""
|
||||||
|
if connection.vendor != 'postgresql':
|
||||||
|
return {'state': 'not_postgres'}
|
||||||
|
if settings.OFFSITE_SYNC_STATUS_FILE:
|
||||||
|
return _pushed_backup()
|
||||||
|
slot = settings.OFFSITE_WAL_SLOT
|
||||||
|
if not slot:
|
||||||
|
return {'state': 'not_configured'}
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
'SELECT active, wal_status, pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) '
|
||||||
|
'FROM pg_replication_slots WHERE slot_name = %s',
|
||||||
|
[slot],
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return {'state': 'error', 'error': type(exc).__name__}
|
||||||
|
return _slot_state(row)
|
||||||
|
|
||||||
|
|
||||||
|
def _slot_state(row) -> dict:
|
||||||
|
if row is None:
|
||||||
|
return {'state': 'slot_missing'}
|
||||||
|
active, wal_status, lag = row
|
||||||
|
lag = int(lag or 0)
|
||||||
|
if wal_status == 'lost':
|
||||||
|
# سقفِ max_slot_wal_keep_size خورده: دیسکِ این سرور امن ماند ولی زنجیرهی WAL آنجا بریده است.
|
||||||
|
state = 'lost'
|
||||||
|
elif not active:
|
||||||
|
state = 'disconnected'
|
||||||
|
elif lag > settings.OFFSITE_WAL_MAX_LAG_BYTES:
|
||||||
|
state = 'lagging'
|
||||||
|
else:
|
||||||
|
state = 'ok'
|
||||||
|
return {'state': state, 'lagBytes': lag}
|
||||||
|
|
||||||
|
|
||||||
|
def _pushed_backup() -> dict:
|
||||||
|
"""
|
||||||
|
بکاپِ رمزشدهی ۲۱۶: pgBackRest هر WAL را با archive_command آرشیو میکند (pg_stat_archiver) و
|
||||||
|
`tadrisino-offsite-sync` مخزن را هر دقیقه به ۲۴۷ میفرستد و زمانش را در OFFSITE_SYNC_STATUS_FILE مینویسد.
|
||||||
|
کهنگیِ آخرین آرشیو هشدار نیست: دیتابیسِ بیتغییر WALِ تازهای ندارد که آرشیو شود.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute('SELECT last_archived_time, last_failed_time FROM pg_stat_archiver')
|
||||||
|
last_archived, last_failed = cursor.fetchone()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return {'state': 'error', 'error': type(exc).__name__}
|
||||||
|
try:
|
||||||
|
with open(settings.OFFSITE_SYNC_STATUS_FILE) as f:
|
||||||
|
synced_at = int(f.read().strip())
|
||||||
|
except FileNotFoundError:
|
||||||
|
synced_at = None
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
return {'state': 'error', 'error': type(exc).__name__}
|
||||||
|
return _pushed_state(last_archived, last_failed, synced_at, time.time())
|
||||||
|
|
||||||
|
|
||||||
|
def _pushed_state(last_archived, last_failed, synced_at, now) -> dict:
|
||||||
|
age = None if synced_at is None else int(now - synced_at)
|
||||||
|
if last_failed is not None and (last_archived is None or last_failed > last_archived):
|
||||||
|
state = 'archive_failing'
|
||||||
|
elif age is None:
|
||||||
|
state = 'never_synced'
|
||||||
|
elif age > settings.OFFSITE_SYNC_MAX_AGE_SECONDS:
|
||||||
|
state = 'sync_stale'
|
||||||
|
else:
|
||||||
|
state = 'ok'
|
||||||
|
return {
|
||||||
|
'state': state,
|
||||||
|
'syncAgeSeconds': age,
|
||||||
|
'lastArchivedAt': last_archived.isoformat() if last_archived else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# اتصالهایی بیرون از poolِ workerها: pg_receivewal و pg_basebackupِ سرورِ بکاپ (۲)، pg_dump و
|
||||||
|
# migrateِ deploy.sh (۲)، فرمانِ مدیریتی یا psqlِ دستی (۲)، و workerی که gunicorn با max_requests
|
||||||
|
# عوض میکند و تا بسته شدنِ قبلی همزمان بالاست (یک pool).
|
||||||
|
DB_CONNECTION_RESERVE = 6
|
||||||
|
|
||||||
|
|
||||||
|
def _db_pool() -> dict:
|
||||||
|
"""
|
||||||
|
poolِ اتصالِ همین worker (config/settings.py) و بودجهی کلِ اتصالها در برابرِ max_connections.
|
||||||
|
|
||||||
|
آمارِ pool مالِ workerی است که این درخواست را جواب داد، نه همه؛ `requestsErrors` یعنی چند بار
|
||||||
|
درخواستی تا DB_POOL_TIMEOUT اتصال نگرفت و ۵۰۳ شد (از بالا آمدنِ این worker). بودجه از
|
||||||
|
GUNICORN_WORKERS (gunicorn.conf.py، post_fork) حساب میشود؛ بیرون از gunicorn (runserver) ۱ است.
|
||||||
|
"""
|
||||||
|
if connection.vendor != 'postgresql':
|
||||||
|
return {'state': 'not_postgres'}
|
||||||
|
pool = connection.pool
|
||||||
|
if pool is None:
|
||||||
|
return {'state': 'off'}
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT current_setting('max_connections')::int - current_setting('superuser_reserved_connections')::int, "
|
||||||
|
'(SELECT count(*) FROM pg_stat_activity WHERE datname = current_database())'
|
||||||
|
)
|
||||||
|
allowed, in_use = cursor.fetchone()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return {'state': 'error', 'error': type(exc).__name__}
|
||||||
|
stats = pool.get_stats()
|
||||||
|
workers = int(os.environ.get('GUNICORN_WORKERS', '1'))
|
||||||
|
budget = workers * pool.max_size + DB_CONNECTION_RESERVE
|
||||||
|
return {
|
||||||
|
'state': 'ok' if budget <= allowed else 'over_budget',
|
||||||
|
'workers': workers,
|
||||||
|
'poolMaxSize': pool.max_size,
|
||||||
|
'budget': budget,
|
||||||
|
'maxConnections': allowed,
|
||||||
|
'connectionsInUse': in_use,
|
||||||
|
'thisWorker': {
|
||||||
|
'size': stats.get('pool_size', 0),
|
||||||
|
'available': stats.get('pool_available', 0),
|
||||||
|
'waiting': stats.get('requests_waiting', 0),
|
||||||
|
'requests': stats.get('requests_num', 0),
|
||||||
|
'requestsQueued': stats.get('requests_queued', 0),
|
||||||
|
'requestsErrors': stats.get('requests_errors', 0),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_concurrency() -> dict:
|
||||||
|
from ai_tutor import concurrency
|
||||||
|
|
||||||
|
return {'maxPerWorker': concurrency.limit(), 'inFlightThisWorker': concurrency.in_flight()}
|
||||||
|
|
||||||
|
|
||||||
|
def _db_pool_warnings(pool: dict) -> list[str]:
|
||||||
|
if pool.get('state') == 'over_budget':
|
||||||
|
return [
|
||||||
|
f"بودجهی اتصالِ دیتابیس ({pool['workers']} worker × {pool['poolMaxSize']} + {DB_CONNECTION_RESERVE} رزرو "
|
||||||
|
f"= {pool['budget']}) از سقفِ Postgres ({pool['maxConnections']}) بیشتر است: زیرِ بار درخواستها ۵۰۰ میگیرند. "
|
||||||
|
'DB_POOL_MAX_SIZE یا تعدادِ workerها را کم کنید.'
|
||||||
|
]
|
||||||
|
if pool.get('state') == 'error':
|
||||||
|
return ['وضعیتِ poolِ دیتابیس خوانده نشد.']
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
_OFFSITE_MESSAGES = {
|
||||||
|
'not_configured': 'کپیِ زندهی دیتابیس بیرون از این سرور تنظیم نشده (OFFSITE_WAL_SLOT).',
|
||||||
|
'slot_missing': 'اسلاتِ تکثیرِ OFFSITE_WAL_SLOT در Postgres نیست؛ سرورِ بکاپ هیچوقت وصل نشده یا اسلات حذف شده.',
|
||||||
|
'disconnected': 'سرورِ بکاپ الان وصل نیست؛ تغییرات روی همین سرور جمع میشوند تا سقفِ max_slot_wal_keep_size.',
|
||||||
|
'lagging': 'سرورِ بکاپ از دیتابیس عقب است.',
|
||||||
|
'lost': 'سرورِ بکاپ آنقدر قطع بود که زنجیرهی WAL برید؛ روی آن سرور اسلات و base backupِ تازه لازم است.',
|
||||||
|
'error': 'وضعیتِ کپیِ زندهی دیتابیس خوانده نشد.',
|
||||||
|
'archive_failing': 'آرشیوِ WAL (pgBackRest) شکست میخورد و تغییراتِ تازه بکاپ نمیشوند: sudo -u postgres pgbackrest --stanza=zareian check',
|
||||||
|
'never_synced': 'بکاپِ رمزشده هنوز هیچوقت به سرورِ بیرونی (۲۴۷) نرسیده (فایلِ OFFSITE_SYNC_STATUS_FILE نیست).',
|
||||||
|
'sync_stale': 'ارسالِ بکاپِ رمزشده به ۲۴۷ عقب افتاده: journalctl -u tadrisino-offsite-sync',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _offsite_warnings(offsite: dict) -> list[str]:
|
||||||
|
msg = _OFFSITE_MESSAGES.get(offsite['state'])
|
||||||
|
return [msg] if msg else []
|
||||||
|
|
||||||
|
|
||||||
|
# بیش از این یعنی آرشیوِ شبانه (`ops/applogs/archive-app-log.sh`) چند روز است اجرا نشده؛
|
||||||
|
# با ترافیکِ ۶۰۰ کاربرِ فعال یک روز حدودِ ۳۰ مگ است (برآورد، نه سنجش).
|
||||||
|
APP_LOG_MAX_BYTES = 256 * 2**20
|
||||||
|
|
||||||
|
|
||||||
|
def _app_log_warnings() -> list[str]:
|
||||||
|
"""با چرخشِ بیرونی، برنامه app.log را پاک نمیکند؛ اگر آرشیوِ شبانه بخوابد فایل بیانتها بزرگ میشود."""
|
||||||
|
if not settings.LOG_DIR or settings.LOG_FILE_ROTATION != 'external':
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
size = os.path.getsize(os.path.join(settings.LOG_DIR, 'app.log'))
|
||||||
|
except FileNotFoundError:
|
||||||
|
return []
|
||||||
|
if size > APP_LOG_MAX_BYTES:
|
||||||
|
return [f'app.log {size // 2**20} مگ است: آرشیوِ شبانهی لاگ (zareian-applog-archive.timer) اجرا نشده.']
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _warnings() -> list[str]:
|
||||||
|
"""
|
||||||
|
چیزهایی که خرابی نیستند ولی روی یک سرورِ عمومی خطرناکاند.
|
||||||
|
|
||||||
|
اینجا هستند چون تنها راهِ دیدنشان تا امروز این بود که کسی تنظیماتِ سرور را
|
||||||
|
از نزدیک بخواند — و ما به سرور دسترسی نداریم.
|
||||||
|
"""
|
||||||
|
out = []
|
||||||
|
if settings.DEBUG:
|
||||||
|
out.append(
|
||||||
|
'DEBUG روشن است: صفحههای خطا کلِ تنظیمات و traceback را نشان میدهند، '
|
||||||
|
'و کدِ ورودِ آزمایشیِ 0000 برای هر شمارهای پذیرفته میشود. '
|
||||||
|
'روی سرورِ عمومی باید خاموش باشد.'
|
||||||
|
)
|
||||||
|
if getattr(settings, 'DEV_MASTER_OTP', None) is not None:
|
||||||
|
out.append('کدِ یکسانِ ورود (DEV_MASTER_OTP) فعال است — هر کسی میتواند بهجای هر کاربری وارد شود.')
|
||||||
|
test_phones = getattr(settings, 'TEST_LOGIN_PHONES', {})
|
||||||
|
if test_phones:
|
||||||
|
# نه خودِ شماره و نه کد برنمیگردد؛ فقط تعداد، تا کسی که تنظیماتِ سرور
|
||||||
|
# را ندیده هم بداند چند حسابِ تست با کدِ ثابت باز است و بتواند ببنددشان.
|
||||||
|
# تعداد عمداً گفته نمیشود: این صفحه عمومی است و «۷ حسابِ کدِ ثابت» برای
|
||||||
|
# مهاجم یعنی هدفِ مشخص برای حدس زدن.
|
||||||
|
out.append(
|
||||||
|
'شمارههای تستِ ورود با کدِ ثابت فعالاند (TEST_LOGIN_PHONES). روی سرور باید خالی باشد: '
|
||||||
|
'هر کس کد را دیده باشد وارد میشود. برای آزمون یک OTPCode در پنلِ ادمین بسازید.'
|
||||||
|
)
|
||||||
|
if not settings.DEBUG and settings.SECRET_KEY.startswith('django-insecure'):
|
||||||
|
# بزرگترین ریسکِ باز (docs/security-before-production.md): این کلید در گیت است و هر
|
||||||
|
# کس ریپو را ببیند برای هر کاربری توکن میسازد. فقط بله/خیر، نه خودِ کلید.
|
||||||
|
out.append(
|
||||||
|
'کلیدِ امضای توکن همان کلیدِ کامیتشده در ریپو است (django-insecure): هر کس ریپو را '
|
||||||
|
'ببیند میتواند بهجای هر کاربری وارد شود. راهِ رفع در docs/security-before-production.md.'
|
||||||
|
)
|
||||||
|
if _ai_provider_state().get('state') == 'quota_exhausted':
|
||||||
|
out.append(
|
||||||
|
'اعتبارِ سرویسِ هوش مصنوعی (GapGPT) تمام شده: دستیار برای همهی کاربرها '
|
||||||
|
'«در دسترس نیست» میدهد تا حساب شارژ شود.'
|
||||||
|
)
|
||||||
|
out.extend(_app_log_warnings())
|
||||||
|
backend = settings.CACHES['default']['BACKEND']
|
||||||
|
if not settings.DEBUG and 'LocMem' in backend:
|
||||||
|
out.append('کش محلیِ هر پروسه است؛ محدودیتِ نرخ بینِ workerها مشترک نیست.')
|
||||||
|
if not getattr(settings, 'CORS_ALLOWED_ORIGINS', None):
|
||||||
|
out.append('هیچ origin ای برای CORS مجاز نیست — مرورگر همهی درخواستهای فرانت را بلاک میکند.')
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
from payment.permissions import HasInternalServiceKey # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class HealthView(APIView):
|
||||||
|
"""
|
||||||
|
GET /api/health — وضعیتِ این نسخهی مستقرشده.
|
||||||
|
|
||||||
|
۲۰۰ یعنی سالم، ۵۰۳ یعنی چیزی واقعاً خراب است (دیتابیس، کش، یا مهاجرتِ
|
||||||
|
اعمالنشده). نبودِ محتوا خرابی حساب نمیشود ولی گزارش میشود.
|
||||||
|
"""
|
||||||
|
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
authentication_classes = []
|
||||||
|
throttle_classes = [ScopedRateThrottle]
|
||||||
|
throttle_scope = 'health'
|
||||||
|
|
||||||
|
@extend_schema(tags=['System'], responses={200: dict, 503: dict})
|
||||||
|
def get(self, request):
|
||||||
|
checks = {
|
||||||
|
'database': _check_database(),
|
||||||
|
'migrations': _check_migrations(),
|
||||||
|
'cache': _check_cache(),
|
||||||
|
'content': _check_content(),
|
||||||
|
}
|
||||||
|
critical_ok = all(checks[k]['ok'] for k in ('database', 'migrations', 'cache'))
|
||||||
|
|
||||||
|
status = 200 if critical_ok else 503
|
||||||
|
# عموم فقط ok/error میبینند؛ جزئیات (هشدارها، ترافیک، سرویسهای وصلشده، IP) پشتِ هدرِ
|
||||||
|
# X-Internal-Key. کد ۲۰۰/۵۰۳ عمومی میماند تا پایشِ بیرونی کار کند. اگر INTERNAL_SERVICE_KEY
|
||||||
|
# در .env نباشد، هیچکس جزئیات را نمیبیند (نه اینکه همه ببینند).
|
||||||
|
if not HasInternalServiceKey().has_permission(request, self):
|
||||||
|
return Response({'status': 'ok' if critical_ok else 'error'}, status=status)
|
||||||
|
|
||||||
|
offsite = _offsite_backup()
|
||||||
|
db_pool = _db_pool()
|
||||||
|
warnings = _warnings() + _offsite_warnings(offsite) + _db_pool_warnings(db_pool)
|
||||||
|
payload = {
|
||||||
|
'status': 'ok' if critical_ok else 'error',
|
||||||
|
'debug': bool(settings.DEBUG),
|
||||||
|
'warnings': warnings,
|
||||||
|
'checks': checks,
|
||||||
|
'integrations': _check_integrations(),
|
||||||
|
'traffic': _traffic(),
|
||||||
|
'offsiteBackup': offsite,
|
||||||
|
'dbPool': db_pool,
|
||||||
|
# جوابهای همزمانِ دستیار در workerی که این را جواب داد (ai_tutor/concurrency.py).
|
||||||
|
'aiConcurrency': _ai_concurrency(),
|
||||||
|
'allowedOrigins': list(getattr(settings, 'CORS_ALLOWED_ORIGINS', [])),
|
||||||
|
# IPای که throttle برای همین درخواست میبیند. باید IPِ عمومیِ خودِ
|
||||||
|
# صدازننده باشد؛ اگر IPِ ArvanCloud یا nginx بود، NUM_PROXIES غلط
|
||||||
|
# است و همهی مهمانها یک سهمِ مشترک دارند. فقط IPِ خودِ صدازننده
|
||||||
|
# را به خودش برمیگرداند، پس چیزی لو نمیدهد.
|
||||||
|
'clientIp': ScopedRateThrottle().get_ident(request),
|
||||||
|
}
|
||||||
|
return Response(payload, status=status)
|
||||||
36
config/log_formatters.py
Normal file
36
config/log_formatters.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""
|
||||||
|
فرمتکنندهی سبکِ JSON برای خروجیِ logging — آمادهسازیِ پروژه برای
|
||||||
|
جمعآوریِ متمرکزِ لاگ (مثلاً یک agentِ بیرونی مثلِ Promtail/Filebeat که
|
||||||
|
stdout رو میخونه و به Loki/ELK میفرسته) در آینده.
|
||||||
|
|
||||||
|
⚠️ توجه (محدودهی این تغییر):
|
||||||
|
- این فایل فقط *فرمتِ* خروجیِ همون لاگهای موجودِ اپلیکیشن رو عوض میکنه
|
||||||
|
(یک شیِ JSON در هر خط، بهجای متنِ سادهی قبلی)؛ هیچ eventِ لاگِ جدیدی
|
||||||
|
اضافه نمیکنه و هیچ loggerِ موجودی رو تغییر نمیده.
|
||||||
|
- فقط از فیلدهای استانداردِ خودِ LogRecord (زمان، سطح، نامِ logger، پیامِ
|
||||||
|
از قبل ساختهشده، و traceback در صورتِ وجود) استفاده میشه؛ یعنی همون
|
||||||
|
محافظتهای حساسیتیِ قبلی (بدون OTP/token/رمزعبور/بدنهی خامِ provider و
|
||||||
|
غیره در پیامها) دستنخورده میمونه — این فرمتکننده چیزی به پیام اضافه
|
||||||
|
نمیکنه، فقط قالببندیِ همون پیامِ از قبل امن رو تغییر میده.
|
||||||
|
- هیچ وابستگیِ جدید (Elasticsearch/Loki/Grafana client) یا appِ جدید یا
|
||||||
|
ذخیرهسازیِ دیتابیسی/APIِ لاگ اضافه نمیکنه؛ فقط stdlibِ logging + json.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
class JSONLogFormatter(logging.Formatter):
|
||||||
|
"""هر LogRecord رو به یک خطِ JSON (سازگار با collectorهای سبکِ Loki/ELK) تبدیل میکنه."""
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
payload = {
|
||||||
|
'timestamp': datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
|
||||||
|
'level': record.levelname,
|
||||||
|
'logger': record.name,
|
||||||
|
'message': record.getMessage(),
|
||||||
|
}
|
||||||
|
if record.exc_info:
|
||||||
|
payload['exception'] = self.formatException(record.exc_info)
|
||||||
|
return json.dumps(payload, ensure_ascii=False)
|
||||||
224
config/middleware.py
Normal file
224
config/middleware.py
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
"""
|
||||||
|
میانافزارِ سبکِ لاگِ درخواستهای API — سطحِ پروژه.
|
||||||
|
|
||||||
|
⚠️ افزوده شده: پروژه چند logger پراکندهی اپلیکیشنی داشت (accounts.services،
|
||||||
|
ai_tutor.views، ...) اما هیچ لاگِ یکپارچهای در سطحِ پروژه برای *همهی*
|
||||||
|
درخواستهای API وجود نداشت؛ یعنی درخواستهای عادی، ناموفق، یا الگوهای
|
||||||
|
مشکوک (مثلاً یک IP/کاربر که پشتِ سرِ هم درخواستِ ناموفق میفرسته) بهصورتِ
|
||||||
|
یکدست قابلِ ردیابی نبودن. این میانافزار فقط یک خط لاگِ خلاصه بهازای هر
|
||||||
|
درخواستِ زیرِ /api/ ثبت میکنه: متد، مسیر، کدِ وضعیتِ پاسخ، مدتزمان، و
|
||||||
|
شناسهی کاربرِ احرازهویتشده (اگه وجود داشته باشه).
|
||||||
|
|
||||||
|
نکاتِ امنیتی/حریمِ خصوصی:
|
||||||
|
- هیچوقت بدنهی درخواست/پاسخ، query string، هدرها (از جمله Authorization،
|
||||||
|
Idempotency-Key و غیره)، رمزعبور/OTP، access/refresh token، کلیدِ API، یا
|
||||||
|
سکرتِ webhook لاگ نمیشه — فقط request.path (بدونِ query string) و
|
||||||
|
متادیتای بیضررِ بالا.
|
||||||
|
- در صورتِ هر خطای غیرمنتظره در خودِ فرآیندِ لاگکردن (مثلاً یک مقدارِ
|
||||||
|
غیرمنتظره برای user)، این میانافزار هیچوقت نباید جریانِ درخواست/پاسخ
|
||||||
|
را بشکنه؛ به همین خاطر کل بخشِ لاگ داخلِ try/except قرار گرفته.
|
||||||
|
- ⚠️ اصلاح: قبلاً اگه self.get_response(request) خودش یک exceptionِ
|
||||||
|
دستنخورده (unhandled) بالا میآورد، اون exception قبل از رسیدن به بلوکِ
|
||||||
|
لاگ از __call__ خارج میشد؛ یعنی خطاهای ۵۰۰ اصلاً لاگ نمیشدن. حالا
|
||||||
|
self.get_response در try/except قرار گرفته: در صورتِ exception، یک خطِ
|
||||||
|
لاگِ سطحِ ERROR (با متد/مسیر/مدت/user_id، بدونِ جزئیاتِ حساس) ثبت میشه
|
||||||
|
و بعد همون exceptionِ اصلی بدونِ تغییر دوباره raise میشه (تا رفتارِ
|
||||||
|
مدیریتِ خطای Django/DRF دستنخورده بمونه).
|
||||||
|
- ⚠️ اصلاحِ این دور: پیامِ لاگِ مسیرِ exception از یک placeholderِ واقعیِ
|
||||||
|
status=%s استفاده میکنه (همسو با مسیرِ موفق)، و status=500 حالا از
|
||||||
|
طریقِ آرگومانِ status به _log_safely() صریحاً پاس داده میشه، نه بهصورتِ
|
||||||
|
متنِ ثابت داخلِ رشته. این هم پیام رو با مسیرِ موفق یکدست میکنه و هم از
|
||||||
|
ناهماهنگیِ تعدادِ آرگومانهای % formatting جلوگیری میکنه.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# فقط مسیرهای API لاگ میشن؛ درخواستهای استاتیک/غیرِ API (که این پروژه
|
||||||
|
# اصلاً مسیرِ URL جداگانهای براشون در config/urls.py تعریف نکرده) عمداً
|
||||||
|
# نادیده گرفته میشن تا لاگ شلوغ/بیفایده تولید نشه.
|
||||||
|
_LOGGED_PATH_PREFIX = '/api/'
|
||||||
|
|
||||||
|
|
||||||
|
def _user_id_for_log(request):
|
||||||
|
"""شناسهی کاربرِ احرازهویتشده برای لاگ، یا None (کاربرِ ناشناس/بدونِ user)."""
|
||||||
|
user = getattr(request, 'user', None)
|
||||||
|
return user.id if getattr(user, 'is_authenticated', False) else None
|
||||||
|
|
||||||
|
|
||||||
|
class APIRequestLoggingMiddleware:
|
||||||
|
"""میانافزارِ Django برای لاگِ خلاصهی هر درخواستِ API (متد/مسیر/وضعیت/مدت/کاربر)."""
|
||||||
|
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
if not request.path.startswith(_LOGGED_PATH_PREFIX):
|
||||||
|
return self.get_response(request)
|
||||||
|
|
||||||
|
start = time.monotonic()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.get_response(request)
|
||||||
|
except Exception:
|
||||||
|
self._log_safely(
|
||||||
|
logger.error,
|
||||||
|
'api_request method=%s path=%s status=%s duration_ms=%.1f user_id=%s (unhandled exception)',
|
||||||
|
request, start, status=500,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
self._log_safely(
|
||||||
|
logger.info,
|
||||||
|
'api_request method=%s path=%s status=%s duration_ms=%.1f user_id=%s',
|
||||||
|
request, start, response.status_code,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _log_safely(self, log_func, message, request, start, status=None):
|
||||||
|
# لاگکردن هیچوقت نباید خودش دلیلِ شکستِ یک درخواست بشه (چه در
|
||||||
|
# مسیرِ موفق، چه در مسیرِ exception)؛ به همین خاطر کل بخشِ لاگ
|
||||||
|
# داخلِ try/except قرار گرفته.
|
||||||
|
try:
|
||||||
|
duration_ms = (time.monotonic() - start) * 1000
|
||||||
|
if status is None:
|
||||||
|
log_func(message, request.method, request.path, duration_ms, _user_id_for_log(request))
|
||||||
|
else:
|
||||||
|
log_func(message, request.method, request.path, status, duration_ms, _user_id_for_log(request))
|
||||||
|
except Exception:
|
||||||
|
logger.exception('api_request logging failed')
|
||||||
|
|
||||||
|
|
||||||
|
class TrustedProxyMiddleware:
|
||||||
|
"""
|
||||||
|
X-Forwarded-For فقط وقتی پذیرفته میشود که درخواست واقعاً از ArvanCloud آمده باشد.
|
||||||
|
|
||||||
|
NUM_PROXIES=2 فرض میکند همیشه آروان و nginx جلوی ما هستند؛ ولی nginxِ این ماشین روی IPِ خودش
|
||||||
|
(پورت ۸۰ و ۴۴۳) هم جواب میدهد. کسی که مستقیم به IP بزند و در هر درخواست یک XFFِ تازه بفرستد،
|
||||||
|
برای DRF هر بار «کاربرِ تازه» بود و از همهی سقفهای IP-محور (درخواستِ کد، حدسِ کد، ورودِ ادمین)
|
||||||
|
رد میشد (۲۰۲۶-۱۰-۱۳ روی سرور بازتولید شد: ۶۵ از ۶۵ درخواستِ health با XFFِ متفاوت ۲۰۰).
|
||||||
|
|
||||||
|
X-Real-IP را nginx خودش از آدرسِ TCP میگذارد (`proxy_params`ِ سایتِ `mathapp`؛ مقدارِ کلاینت را
|
||||||
|
بازنویسی میکند)، پس جعلپذیر نیست. اگر آن آدرس در TRUSTED_PROXY_NETWORKS نیست، XFF دور ریخته و
|
||||||
|
خودِ آن آدرس IPِ کاربر میشود. بی X-Real-IP (runserverِ محلی، تستها) دست نمیزند.
|
||||||
|
سنجیده نشد: رنجهای IPv6ِ آروان (فهرستِ آروان در این تاریخ فقط IPv4 داشت).
|
||||||
|
"""
|
||||||
|
|
||||||
|
WARN_EVERY_SECONDS = 60 * 60
|
||||||
|
|
||||||
|
def __init__(self, get_response):
|
||||||
|
from django.conf import settings
|
||||||
|
self.get_response = get_response
|
||||||
|
self.networks = settings.TRUSTED_PROXY_NETWORKS
|
||||||
|
|
||||||
|
def _trusted(self, peer: str) -> bool:
|
||||||
|
import ipaddress
|
||||||
|
try:
|
||||||
|
address = ipaddress.ip_address(peer)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return any(address in network for network in self.networks)
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
peer = request.META.get('HTTP_X_REAL_IP', '').strip()
|
||||||
|
if peer and not self._trusted(peer):
|
||||||
|
forwarded = request.META.get('HTTP_X_FORWARDED_FOR', '')
|
||||||
|
# XFF با دو مقدار یعنی پراکسیِ دیگری جلوی nginx بوده: یا کسی XFF جعل کرده، یا آروان رنجِ
|
||||||
|
# تازهای گرفته که در فهرست نیست. دومی همهی کاربرانِ آن edge را یک IP میکند و سقفها برایشان
|
||||||
|
# زود پر میشود؛ پس هر peer ساعتی یک بار WARNING میگیرد تا در لاگ دیده شود.
|
||||||
|
if forwarded.count(',') >= 1:
|
||||||
|
from django.core.cache import cache
|
||||||
|
if cache.add(f'untrusted_proxy_warned:{peer}', 1, self.WARN_EVERY_SECONDS):
|
||||||
|
logger.warning('X-Forwarded-For from a peer outside TRUSTED_PROXY_NETWORKS ignored: '
|
||||||
|
'peer=%s (a new ArvanCloud range would also look like this)', peer)
|
||||||
|
request.META['HTTP_X_FORWARDED_FOR'] = peer
|
||||||
|
return self.get_response(request)
|
||||||
|
|
||||||
|
|
||||||
|
class APINoStoreMiddleware:
|
||||||
|
"""
|
||||||
|
هر پاسخِ `/api/` هدرِ `Cache-Control: no-store` میگیرد، مگر خودِ view
|
||||||
|
چیزِ دیگری گذاشته باشد (مثلاً استریمِ دستیار که `no-cache` دارد).
|
||||||
|
|
||||||
|
ArvanCloud جلوی سرور است و الان برای API کش نمیکند (`x-cache: BYPASS`)،
|
||||||
|
ولی این فقط به تنظیمِ پنلِ آروان بند است. پاسخهای API به کاربر وابستهاند
|
||||||
|
(توکنِ Bearer)؛ اگر روزی کسی کش را در پنل روشن کند، بدونِ این هدر یک
|
||||||
|
دانشآموز میتوانست `/api/home` یا پروفایلِ دیگری را ببیند.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
response = self.get_response(request)
|
||||||
|
if request.path.startswith(_LOGGED_PATH_PREFIX) and not response.has_header('Cache-Control'):
|
||||||
|
response['Cache-Control'] = 'private, no-store'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseBusyMiddleware:
|
||||||
|
"""
|
||||||
|
poolِ دیتابیسِ پر → ۵۰۳ با Retry-After بهجای ۵۰۰ (config/db.py) برای viewهای غیرِ DRF مثلِ /admin/؛
|
||||||
|
viewهای DRF را هندلرِ خطای DRF (config/exceptions.py) جواب میدهد، چون هر خطا را خودش ۵۰۰ میکند
|
||||||
|
و استثنا هیچوقت به اینجا نمیرسد. فقط خطای pool را میگیرد؛ هر OperationalErrorِ دیگری (Postgres
|
||||||
|
پایین، کوئریِ timeoutخورده) همان ۵۰۰ِ قبلی میماند.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
return self.get_response(request)
|
||||||
|
|
||||||
|
def process_exception(self, request, exception):
|
||||||
|
from django.http import JsonResponse
|
||||||
|
|
||||||
|
from config.db import SERVER_BUSY_BODY, SERVER_BUSY_RETRY_AFTER, is_pool_timeout, log_server_busy
|
||||||
|
|
||||||
|
if not is_pool_timeout(exception):
|
||||||
|
return None
|
||||||
|
log_server_busy(request)
|
||||||
|
response = JsonResponse(SERVER_BUSY_BODY, status=503)
|
||||||
|
response['Retry-After'] = str(SERVER_BUSY_RETRY_AFTER)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class AdminLoginThrottleMiddleware:
|
||||||
|
"""
|
||||||
|
سقفِ تلاشِ ورود به /admin/ برای هر IP (ADMIN_LOGIN_MAX_ATTEMPTS در ۱۵ دقیقه).
|
||||||
|
|
||||||
|
پنلِ ادمین روی دامنهی عمومی است و فرمِ ورودِ جنگو هیچ محدودیتی ندارد؛
|
||||||
|
یعنی رمزِ ادمین (که همهی محتوا و پرداختها را کنترل میکند) بیسقف قابلِ
|
||||||
|
حدس زدن بود. شمارش در کشِ پیشفرض است: تا CACHE_URL (Redis) تنظیم نشده،
|
||||||
|
برای هر worker جدا — یعنی سقفِ واقعی تا سه برابر. IP با همان منطقِ
|
||||||
|
NUM_PROXIESِ throttleهای DRF خوانده میشود.
|
||||||
|
"""
|
||||||
|
|
||||||
|
WINDOW_SECONDS = 15 * 60
|
||||||
|
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
if request.method == 'POST' and request.path.startswith('/admin/login'):
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from rest_framework.throttling import BaseThrottle
|
||||||
|
|
||||||
|
key = f'admin_login:{BaseThrottle().get_ident(request)}'
|
||||||
|
cache.add(key, 0, self.WINDOW_SECONDS)
|
||||||
|
try:
|
||||||
|
attempts = cache.incr(key)
|
||||||
|
except ValueError:
|
||||||
|
cache.set(key, 1, self.WINDOW_SECONDS)
|
||||||
|
attempts = 1
|
||||||
|
if attempts > getattr(settings, 'ADMIN_LOGIN_MAX_ATTEMPTS', 10):
|
||||||
|
logger.warning('admin login throttled: ident=%s attempts=%s', key, attempts)
|
||||||
|
return HttpResponse(
|
||||||
|
'تلاشِ ورود بیش از حد مجاز است؛ ۱۵ دقیقهی دیگر دوباره امتحان کنید.',
|
||||||
|
status=429, content_type='text/plain; charset=utf-8',
|
||||||
|
)
|
||||||
|
return self.get_response(request)
|
||||||
24
config/renderers.py
Normal file
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
|
||||||
File diff suppressed because it is too large
Load Diff
518
config/tests.py
Normal file
518
config/tests.py
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.test import RequestFactory, SimpleTestCase, TestCase, override_settings
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from accounts.authentication import issue_access_token
|
||||||
|
from accounts.models import User
|
||||||
|
from config.middleware import APIRequestLoggingMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
class SecretKeyFailClosedTests(SimpleTestCase):
|
||||||
|
"""
|
||||||
|
SECRET_KEY در پروداکشن هرگز دیفالتِ قابلحدسزدن ندارد — این هنوز برقرار است.
|
||||||
|
|
||||||
|
آنچه عوض شد: قبلاً نبودنِ DJANGO_SECRET_KEY یعنی اپ اصلاً بالا نمیآمد، و
|
||||||
|
نتیجهاش از بیرون فقط 502 بود بدونِ هیچ سرنخی؛ کسی که سرور را بالا میآورد
|
||||||
|
باید حدس میزد چه چیزی کم است. حالا اولین اجرا یک کلیدِ تصادفیِ واقعی
|
||||||
|
میسازد و در فایلی کنارِ پروژه نگه میدارد — همانقدر غیرقابلِ حدس، بدونِ
|
||||||
|
یک قدمِ دستیِ دیگر. چیزی که باید همچنان تضمین شود این است که آن کلید
|
||||||
|
تصادفی باشد، نه رشتهی هاردکدِ حالتِ توسعه.
|
||||||
|
|
||||||
|
مثل SecretDefaultsFailClosedTests در payment/tests.py، چون SECRET_KEY
|
||||||
|
فقط یکبار موقعِ import محاسبه میشه (نه چیزی که override_settings
|
||||||
|
بتونه toggle کنه)، ماژولِ config.settings رو با متغیرهای محیطیِ
|
||||||
|
کنترلشده reload میکنیم.
|
||||||
|
"""
|
||||||
|
|
||||||
|
allow_database_queries = True
|
||||||
|
|
||||||
|
def _reload_settings_with_env(self, env_overrides, env_removed=()):
|
||||||
|
from config import settings as settings_module
|
||||||
|
|
||||||
|
clean_env = {k: v for k, v in os.environ.items() if k not in env_removed}
|
||||||
|
clean_env.update(env_overrides)
|
||||||
|
with patch.dict(os.environ, clean_env, clear=True):
|
||||||
|
importlib.reload(settings_module)
|
||||||
|
return settings_module
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
# همیشه ماژول رو به حالتِ واقعیِ محیطِ تست برمیگردونیم تا روی
|
||||||
|
# بقیهی تستها اثر نذاره.
|
||||||
|
from config import settings as settings_module
|
||||||
|
importlib.reload(settings_module)
|
||||||
|
|
||||||
|
def test_production_without_the_env_var_generates_a_real_random_key(self):
|
||||||
|
settings_module = self._reload_settings_with_env(
|
||||||
|
env_overrides={'DJANGO_DEBUG': ''},
|
||||||
|
env_removed={'DJANGO_SECRET_KEY'},
|
||||||
|
)
|
||||||
|
key = settings_module.SECRET_KEY
|
||||||
|
self.addCleanup(
|
||||||
|
lambda: (settings_module.BASE_DIR / '.django_secret_key').unlink(missing_ok=True)
|
||||||
|
)
|
||||||
|
self.assertFalse(settings_module.DEBUG)
|
||||||
|
self.assertNotEqual(
|
||||||
|
key,
|
||||||
|
'django-insecure-jn!s*25lz^+bjm101tzmlz8jlj70i0qka++*h-(!0s^^vk-0ss',
|
||||||
|
'کلیدِ حالتِ توسعه هرگز نباید در پروداکشن استفاده شود',
|
||||||
|
)
|
||||||
|
self.assertGreaterEqual(len(key), 50)
|
||||||
|
|
||||||
|
def test_the_generated_key_survives_a_restart(self):
|
||||||
|
"""اگر هر بار کلیدِ تازه ساخته شود، همهی نشستها با هر ریاستارت باطل میشوند."""
|
||||||
|
first = self._reload_settings_with_env(
|
||||||
|
env_overrides={'DJANGO_DEBUG': ''}, env_removed={'DJANGO_SECRET_KEY'},
|
||||||
|
).SECRET_KEY
|
||||||
|
settings_module = self._reload_settings_with_env(
|
||||||
|
env_overrides={'DJANGO_DEBUG': ''}, env_removed={'DJANGO_SECRET_KEY'},
|
||||||
|
)
|
||||||
|
self.addCleanup(
|
||||||
|
lambda: (settings_module.BASE_DIR / '.django_secret_key').unlink(missing_ok=True)
|
||||||
|
)
|
||||||
|
self.assertEqual(first, settings_module.SECRET_KEY)
|
||||||
|
|
||||||
|
def test_production_configured_secret_key_used(self):
|
||||||
|
# CACHE_URL هم باید ست بشه، وگرنه با DEBUG=False همون
|
||||||
|
# ImproperlyConfiguredِ CACHE_URL رخ میده، نه رسیدن به assertionِ
|
||||||
|
# SECRET_KEY که این تست واقعاً میسنجه.
|
||||||
|
settings_module = self._reload_settings_with_env(
|
||||||
|
env_overrides={
|
||||||
|
'DJANGO_DEBUG': '',
|
||||||
|
'DJANGO_SECRET_KEY': 'prod-secret-xyz',
|
||||||
|
'CACHE_URL': 'redis://localhost:6379/0',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertFalse(settings_module.DEBUG)
|
||||||
|
self.assertEqual(settings_module.SECRET_KEY, 'prod-secret-xyz')
|
||||||
|
|
||||||
|
def test_debug_mode_missing_secret_key_uses_local_fallback(self):
|
||||||
|
settings_module = self._reload_settings_with_env(
|
||||||
|
env_overrides={'DJANGO_DEBUG': '1'},
|
||||||
|
env_removed={'DJANGO_SECRET_KEY'},
|
||||||
|
)
|
||||||
|
self.assertTrue(settings_module.DEBUG)
|
||||||
|
self.assertEqual(
|
||||||
|
settings_module.SECRET_KEY,
|
||||||
|
'django-insecure-jn!s*25lz^+bjm101tzmlz8jlj70i0qka++*h-(!0s^^vk-0ss',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CacheUrlFailClosedTests(SimpleTestCase):
|
||||||
|
"""
|
||||||
|
نبودنِ CACHE_URL در پروداکشن یک ضعفِ واقعیست: LocMemCache بینِ workerها
|
||||||
|
مشترک نیست و throttleها را کماثر میکند.
|
||||||
|
|
||||||
|
ولی دلیلِ خوبی برای بالا نیامدنِ کلِ سرویس نیست — قبلاً همینجا استثنا
|
||||||
|
پرتاب میشد و نتیجهاش از بیرون فقط 502 بود. حالا سرویس بالا میآید و
|
||||||
|
/api/health صریحاً هشدار میدهد. چیزی که باید تضمین شود این است که این
|
||||||
|
ضعف **بیصدا** نماند.
|
||||||
|
"""
|
||||||
|
|
||||||
|
allow_database_queries = True
|
||||||
|
|
||||||
|
def _reload_settings_with_env(self, env_overrides, env_removed=()):
|
||||||
|
from config import settings as settings_module
|
||||||
|
|
||||||
|
clean_env = {k: v for k, v in os.environ.items() if k not in env_removed}
|
||||||
|
clean_env.update(env_overrides)
|
||||||
|
with patch.dict(os.environ, clean_env, clear=True):
|
||||||
|
importlib.reload(settings_module)
|
||||||
|
return settings_module
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
from config import settings as settings_module
|
||||||
|
importlib.reload(settings_module)
|
||||||
|
|
||||||
|
def test_production_without_cache_url_boots_but_is_not_silent(self):
|
||||||
|
settings_module = self._reload_settings_with_env(
|
||||||
|
env_overrides={'DJANGO_DEBUG': '', 'DJANGO_SECRET_KEY': 'prod-secret-xyz'},
|
||||||
|
env_removed={'CACHE_URL'},
|
||||||
|
)
|
||||||
|
self.assertIn('LocMem', settings_module.CACHES['default']['BACKEND'])
|
||||||
|
|
||||||
|
# همان ضعف باید در /api/health دیده شود، وگرنه بیصدا میماند.
|
||||||
|
from config import health
|
||||||
|
|
||||||
|
with self.settings(DEBUG=False,
|
||||||
|
CACHES=settings_module.CACHES,
|
||||||
|
DEV_MASTER_OTP=None):
|
||||||
|
warnings = ' '.join(health._warnings())
|
||||||
|
self.assertIn('کش', warnings)
|
||||||
|
|
||||||
|
def test_debug_mode_missing_cache_url_uses_locmem_fallback(self):
|
||||||
|
settings_module = self._reload_settings_with_env(
|
||||||
|
env_overrides={'DJANGO_DEBUG': '1'},
|
||||||
|
env_removed={'CACHE_URL'},
|
||||||
|
)
|
||||||
|
self.assertTrue(settings_module.DEBUG)
|
||||||
|
self.assertEqual(
|
||||||
|
settings_module.CACHES['default']['BACKEND'],
|
||||||
|
'django.core.cache.backends.locmem.LocMemCache',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class APIRequestLoggingMiddlewareTests(TestCase):
|
||||||
|
"""
|
||||||
|
🔒 اثبات میانافزارِ جدیدِ لاگِ درخواستِ API (config.middleware.
|
||||||
|
APIRequestLoggingMiddleware): باید برای مسیرهای /api/ یک خطِ لاگِ خلاصه
|
||||||
|
(متد/مسیر/وضعیت/مدت/شناسهی کاربر) ثبت کنه، بدونِ اینکه هیچوقت بدنه/
|
||||||
|
هدر/سکرت رو لاگ کنه؛ و برای مسیرهای غیرِ /api/ (مثلِ /admin/) اصلاً
|
||||||
|
لاگ نکنه.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
|
||||||
|
def test_authenticated_api_request_is_logged_with_user_id_and_no_secrets(self):
|
||||||
|
user = User.objects.create(phone='09000000005', name='')
|
||||||
|
token = issue_access_token(user.id)
|
||||||
|
|
||||||
|
from content.models import Grade
|
||||||
|
Grade.objects.create(pk=10, title='g10', icon='', color='')
|
||||||
|
|
||||||
|
with self.assertLogs('config.middleware', level='INFO') as captured:
|
||||||
|
response = self.client.post(
|
||||||
|
'/api/user/grade',
|
||||||
|
{'gradeId': 10},
|
||||||
|
format='json',
|
||||||
|
HTTP_AUTHORIZATION=f'Bearer {token}',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(len(captured.records), 1)
|
||||||
|
message = captured.records[0].getMessage()
|
||||||
|
|
||||||
|
self.assertIn('method=POST', message)
|
||||||
|
self.assertIn('path=/api/user/grade', message)
|
||||||
|
self.assertIn('status=200', message)
|
||||||
|
self.assertIn(f'user_id={user.id}', message)
|
||||||
|
|
||||||
|
# هیچوقت نباید هیچ سکرتی در پیامِ لاگ باشه.
|
||||||
|
self.assertNotIn(token, message)
|
||||||
|
self.assertNotIn('Bearer', message)
|
||||||
|
self.assertNotIn('gradeId', message)
|
||||||
|
|
||||||
|
def test_anonymous_api_request_is_logged_with_null_user_id(self):
|
||||||
|
with self.assertLogs('config.middleware', level='INFO') as captured:
|
||||||
|
response = self.client.post(
|
||||||
|
'/api/auth/refresh',
|
||||||
|
{'refreshToken': 'not-a-real-token'},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
self.assertEqual(len(captured.records), 1)
|
||||||
|
message = captured.records[0].getMessage()
|
||||||
|
|
||||||
|
self.assertIn('method=POST', message)
|
||||||
|
self.assertIn('path=/api/auth/refresh', message)
|
||||||
|
self.assertIn('status=401', message)
|
||||||
|
self.assertIn('user_id=None', message)
|
||||||
|
|
||||||
|
def test_non_api_request_is_not_logged(self):
|
||||||
|
# /admin/ زیرِ /api/ نیست؛ میانافزار عمداً اینجور مسیرها رو لاگ
|
||||||
|
# نمیکنه تا لاگِ اضافه/بیفایده تولید نشه.
|
||||||
|
logger = logging.getLogger('config.middleware')
|
||||||
|
handler = logging.Handler()
|
||||||
|
records = []
|
||||||
|
handler.emit = records.append
|
||||||
|
logger.addHandler(handler)
|
||||||
|
try:
|
||||||
|
self.client.get('/admin/login/')
|
||||||
|
finally:
|
||||||
|
logger.removeHandler(handler)
|
||||||
|
|
||||||
|
self.assertEqual(records, [])
|
||||||
|
|
||||||
|
def test_unhandled_exception_from_view_is_logged_as_error_and_reraised(self):
|
||||||
|
# میانافزار باید یک خطِ ERROR (متد/مسیر/status=500/مدت/user_id، بدونِ
|
||||||
|
# جزئیاتِ حساس) ثبت کنه و بعد همون exception رو دوباره raise کنه.
|
||||||
|
# با logging.Formatter().format(record) دقیقاً همون مسیری که یک
|
||||||
|
# handlerِ واقعی طی میکنه رو صدا میزنیم (نه فقط getMessage()، که
|
||||||
|
# یه mismatchِ %%-args رو با exceptionِ خودش میپوشونه، نه fail واقعی).
|
||||||
|
request = RequestFactory().get('/api/some/failing/path')
|
||||||
|
|
||||||
|
def failing_get_response(inner_request):
|
||||||
|
raise ValueError('boom')
|
||||||
|
|
||||||
|
middleware = APIRequestLoggingMiddleware(failing_get_response)
|
||||||
|
|
||||||
|
with self.assertLogs('config.middleware', level='ERROR') as captured:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
middleware(request)
|
||||||
|
|
||||||
|
self.assertEqual(len(captured.records), 1)
|
||||||
|
record = captured.records[0]
|
||||||
|
self.assertEqual(record.levelname, 'ERROR')
|
||||||
|
|
||||||
|
formatted = logging.Formatter().format(record)
|
||||||
|
|
||||||
|
self.assertIn('method=GET', formatted)
|
||||||
|
self.assertIn('path=/api/some/failing/path', formatted)
|
||||||
|
self.assertIn('status=500', formatted)
|
||||||
|
self.assertIn('user_id=None', formatted)
|
||||||
|
|
||||||
|
|
||||||
|
class JSONLogFormatterTests(SimpleTestCase):
|
||||||
|
"""
|
||||||
|
🔒 اثبات فرمتکنندهی جدیدِ JSON (config.log_formatters.JSONLogFormatter)
|
||||||
|
که پروژه رو برای جمعآوریِ متمرکزِ لاگ (Loki/ELK-style) آماده میکنه:
|
||||||
|
خروجی باید یک JSON معتبر با فیلدهای timestamp/level/logger/message
|
||||||
|
باشه، و در صورتِ وجودِ exception، traceback هم بهعنوانِ فیلدِ
|
||||||
|
"exception" اضافه بشه.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _make_record(self, level=logging.INFO, message='hello world', exc_info=None):
|
||||||
|
return logging.LogRecord(
|
||||||
|
name='some.logger',
|
||||||
|
level=level,
|
||||||
|
pathname=__file__,
|
||||||
|
lineno=1,
|
||||||
|
msg=message,
|
||||||
|
args=(),
|
||||||
|
exc_info=exc_info,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_basic_record_is_formatted_as_valid_json_with_expected_fields(self):
|
||||||
|
from config.log_formatters import JSONLogFormatter
|
||||||
|
|
||||||
|
record = self._make_record(level=logging.INFO, message='api_request method=GET path=/api/x status=200')
|
||||||
|
formatted = JSONLogFormatter().format(record)
|
||||||
|
|
||||||
|
payload = json.loads(formatted)
|
||||||
|
self.assertEqual(payload['level'], 'INFO')
|
||||||
|
self.assertEqual(payload['logger'], 'some.logger')
|
||||||
|
self.assertEqual(payload['message'], 'api_request method=GET path=/api/x status=200')
|
||||||
|
self.assertIn('timestamp', payload)
|
||||||
|
self.assertNotIn('exception', payload)
|
||||||
|
|
||||||
|
def test_record_with_exception_includes_traceback_field(self):
|
||||||
|
from config.log_formatters import JSONLogFormatter
|
||||||
|
|
||||||
|
try:
|
||||||
|
raise ValueError('boom')
|
||||||
|
except ValueError:
|
||||||
|
import sys
|
||||||
|
exc_info = sys.exc_info()
|
||||||
|
|
||||||
|
record = self._make_record(level=logging.ERROR, message='something failed', exc_info=exc_info)
|
||||||
|
formatted = JSONLogFormatter().format(record)
|
||||||
|
|
||||||
|
payload = json.loads(formatted)
|
||||||
|
self.assertEqual(payload['level'], 'ERROR')
|
||||||
|
self.assertEqual(payload['message'], 'something failed')
|
||||||
|
self.assertIn('exception', payload)
|
||||||
|
self.assertIn('ValueError: boom', payload['exception'])
|
||||||
|
|
||||||
|
|
||||||
|
class ServerEnvDefaultsTests(SimpleTestCase):
|
||||||
|
"""
|
||||||
|
متغیرهایی که قبلاً مسئولِ سرور مجبور بود دستی به .env اضافه کند.
|
||||||
|
|
||||||
|
هر سهتا یک الگو داشتند: نبودشان یا سرویس را میخواباند یا یک مسیر را
|
||||||
|
بیسروصدا میشکست، و چون در .env.example هم نبودند، هر بار از نو کشف
|
||||||
|
میشدند. این تست نگهبانِ همان است — نه اینکه مقدارها «قشنگ»اند، بلکه
|
||||||
|
اینکه بدونِ هیچ متغیرِ محیطیای مقدارِ کارآمد دارند.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# دامنهی API از PUBLIC_BASE_URL میآید، نه از یک رشتهی ثابت در تست.
|
||||||
|
HOST = urlsplit(settings.PUBLIC_BASE_URL).hostname
|
||||||
|
|
||||||
|
def test_the_api_domain_is_allowed_without_any_env_var(self):
|
||||||
|
self.assertIn(self.HOST, settings.DEFAULT_ALLOWED_HOSTS)
|
||||||
|
|
||||||
|
def test_an_incomplete_env_value_does_not_drop_our_domains(self):
|
||||||
|
"""
|
||||||
|
.env سرور از قبل DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 دارد.
|
||||||
|
اگر مقدارِ موجود پیشفرض را کنار بزند، همان «Disallowed Host» قدیمی
|
||||||
|
برمیگردد و باز کسی باید دستی فایل را ویرایش کند.
|
||||||
|
"""
|
||||||
|
# settings فقط نامهای بزرگ را نشان میدهد؛ تابع از خودِ ماژول میآید.
|
||||||
|
from config.settings import _with_defaults
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {'DJANGO_ALLOWED_HOSTS': 'localhost,127.0.0.1'}):
|
||||||
|
merged = _with_defaults('DJANGO_ALLOWED_HOSTS', settings.DEFAULT_ALLOWED_HOSTS)
|
||||||
|
self.assertIn(self.HOST, merged)
|
||||||
|
self.assertIn('localhost', merged, 'مقدارِ موجود نباید حذف شود')
|
||||||
|
|
||||||
|
def test_a_present_but_empty_value_still_falls_back(self):
|
||||||
|
"""کلید در .env هست ولی خالی — os.environ.get پیشفرض نمیدهد."""
|
||||||
|
with patch.dict(os.environ, {'PUBLIC_BASE_URL': ''}):
|
||||||
|
value = (os.environ.get('PUBLIC_BASE_URL', '').strip()
|
||||||
|
or 'https://mathapi.tadrisino2.ir')
|
||||||
|
self.assertTrue(value.startswith('https://'))
|
||||||
|
|
||||||
|
def test_admin_login_origin_is_trusted_without_any_env_var(self):
|
||||||
|
self.assertIn(f'https://{self.HOST}', settings.CSRF_TRUSTED_ORIGINS)
|
||||||
|
|
||||||
|
def test_the_deployed_frontend_may_call_the_api(self):
|
||||||
|
self.assertIn('https://math.tadrisino2.ir', settings.CORS_ALLOWED_ORIGINS)
|
||||||
|
|
||||||
|
def test_payment_urls_are_absolute_without_any_env_var(self):
|
||||||
|
"""خالی بودنِ PUBLIC_BASE_URL مسیرِ پرداخت را بیصدا میشکست."""
|
||||||
|
self.assertTrue(settings.PUBLIC_BASE_URL.startswith('https://'))
|
||||||
|
self.assertTrue(settings.ZARINPAL_CALLBACK_URL.startswith('https://'))
|
||||||
|
self.assertIn('/api/payment/zarinpal/callback', settings.ZARINPAL_CALLBACK_URL)
|
||||||
|
|
||||||
|
|
||||||
|
class SecureCookieTests(SimpleTestCase):
|
||||||
|
"""
|
||||||
|
کوکیِ نشستِ ادمین جاییست که اعتبارنامهی واقعی وارد میشود؛ روی HTTP
|
||||||
|
نباید رد و بدل شود.
|
||||||
|
|
||||||
|
مثلِ بقیهی تستهای این فایل ماژول را دوباره بار میکنیم: این مقدارها در
|
||||||
|
زمانِ import محاسبه میشوند و اجرای تست خودش DEBUG را خاموش میکند، پس
|
||||||
|
خواندنِ settings در لحظه چیزِ گمراهکنندهای میدهد.
|
||||||
|
"""
|
||||||
|
|
||||||
|
allow_database_queries = True
|
||||||
|
|
||||||
|
def _reload(self, env_overrides, env_removed=()):
|
||||||
|
from config import settings as settings_module
|
||||||
|
|
||||||
|
clean_env = {k: v for k, v in os.environ.items() if k not in env_removed}
|
||||||
|
clean_env.update(env_overrides)
|
||||||
|
with patch.dict(os.environ, clean_env, clear=True):
|
||||||
|
importlib.reload(settings_module)
|
||||||
|
return settings_module
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
from config import settings as settings_module
|
||||||
|
importlib.reload(settings_module)
|
||||||
|
|
||||||
|
def test_cookies_are_secure_in_production(self):
|
||||||
|
module = self._reload(
|
||||||
|
{'DJANGO_DEBUG': '', 'DJANGO_SECRET_KEY': 'x' * 60,
|
||||||
|
'CACHE_URL': 'redis://localhost:6379/0'},
|
||||||
|
)
|
||||||
|
self.assertFalse(module.DEBUG)
|
||||||
|
self.assertTrue(module.SESSION_COOKIE_SECURE)
|
||||||
|
self.assertTrue(module.CSRF_COOKIE_SECURE)
|
||||||
|
self.assertTrue(module.SESSION_COOKIE_HTTPONLY)
|
||||||
|
|
||||||
|
def test_cookies_stay_usable_over_plain_http_in_development(self):
|
||||||
|
"""با DEBUG روشن، کوکیِ امن یعنی ورود به ادمینِ محلی کار نمیکند."""
|
||||||
|
module = self._reload({'DJANGO_DEBUG': '1'})
|
||||||
|
self.assertTrue(module.DEBUG)
|
||||||
|
self.assertFalse(module.SESSION_COOKIE_SECURE)
|
||||||
|
|
||||||
|
def test_django_trusts_the_proxy_for_https(self):
|
||||||
|
"""بدونِ این، کوکیِ امن پشتِ nginx هرگز ست نمیشود و ورود میشکند."""
|
||||||
|
self.assertEqual(settings.SECURE_PROXY_SSL_HEADER,
|
||||||
|
('HTTP_X_FORWARDED_PROTO', 'https'))
|
||||||
|
|
||||||
|
|
||||||
|
class CorsDevPortTests(TestCase):
|
||||||
|
"""
|
||||||
|
پورتِ سرورِ توسعه نباید در فهرستِ CORS شمرده شود.
|
||||||
|
|
||||||
|
فهرستِ ثابتِ قبلی (۳۰۰۰ و ۵۱۷۳) روزی شکست که Vite دید ۵۱۷۳ اشغال است و
|
||||||
|
بیسروصدا رفت روی ۵۱۷۴. فرانتکار خطای CORS گرفت، از سمتِ سرور هیچ ردی در
|
||||||
|
هیچ لاگی نبود، و چون همزمان با تغییراتِ بکاند شد به اشتباه گردنِ ما افتاد.
|
||||||
|
هزینهاش یک ویرایشِ .env و یک ریاستارتِ پروداکشن بود، برای چیزی که اصلاً
|
||||||
|
به پروداکشن ربطی نداشت.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _preflight(self, origin):
|
||||||
|
return self.client.options(
|
||||||
|
'/api/grades',
|
||||||
|
HTTP_ORIGIN=origin,
|
||||||
|
HTTP_ACCESS_CONTROL_REQUEST_METHOD='GET',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_any_localhost_port_is_allowed_so_a_new_dev_port_never_breaks_again(self):
|
||||||
|
for origin in ('http://localhost:5173', 'http://localhost:5174',
|
||||||
|
'http://localhost:3000', 'http://127.0.0.1:8080'):
|
||||||
|
with self.subTest(origin=origin):
|
||||||
|
resp = self._preflight(origin)
|
||||||
|
self.assertEqual(
|
||||||
|
resp.headers.get('access-control-allow-origin'), origin)
|
||||||
|
|
||||||
|
def test_the_deployed_frontend_origin_still_works(self):
|
||||||
|
resp = self._preflight('https://math.tadrisino2.ir')
|
||||||
|
self.assertEqual(resp.headers.get('access-control-allow-origin'),
|
||||||
|
'https://math.tadrisino2.ir')
|
||||||
|
|
||||||
|
def test_a_stranger_origin_is_still_refused(self):
|
||||||
|
# الگو فقط localhost است، نه هر دامنهای؛ و «localhost» بهعنوانِ
|
||||||
|
# زیررشته در دامنهی دیگری نباید رد شود.
|
||||||
|
for origin in ('https://evil.example.com',
|
||||||
|
'http://localhost.evil.com',
|
||||||
|
'https://math.tadrisino2.ir.evil.com'):
|
||||||
|
with self.subTest(origin=origin):
|
||||||
|
resp = self._preflight(origin)
|
||||||
|
self.assertIsNone(resp.headers.get('access-control-allow-origin'))
|
||||||
|
|
||||||
|
def test_credentials_stay_off(self):
|
||||||
|
# توکنِ ما در هدر است نه کوکی؛ روشن کردنِ این فقط سطحِ حمله اضافه میکند
|
||||||
|
# و پذیرشِ هر پورتِ localhost را واقعاً خطرناک میکرد.
|
||||||
|
from django.conf import settings
|
||||||
|
self.assertFalse(settings.CORS_ALLOW_CREDENTIALS)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiDocsRtlShellTests(TestCase):
|
||||||
|
"""
|
||||||
|
صفحههای مستنداتِ API باید پوستهی راستبهچپِ خودمان را داشته باشند.
|
||||||
|
|
||||||
|
بدونِ آن، Swagger متنِ فارسی را در یک بلوکِ چپبهراست میچیند و نتیجهاش
|
||||||
|
پریدنِ نقطهی جمله به ابتدای خط و برعکس شدنِ ستونهای جدول است. این تست
|
||||||
|
هست چون خرابیاش فقط با چشم دیده میشود: هیچ خطایی نمیدهد و هیچ تستِ
|
||||||
|
دیگری نمیگیردش.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
from django.contrib.auth.models import User as AuthUser
|
||||||
|
self.client.force_login(AuthUser.objects.create_superuser('docs-admin', 'd@x.ir', 'pw'))
|
||||||
|
|
||||||
|
def test_docs_are_closed_to_anonymous_visitors(self):
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
for path in ('/api/docs/', '/api/redoc/', '/api/schema/'):
|
||||||
|
self.assertIn(APIClient().get(path).status_code, (401, 403), path)
|
||||||
|
|
||||||
|
def test_swagger_ui_carries_the_rtl_shell(self):
|
||||||
|
html = self.client.get('/api/docs/').content.decode()
|
||||||
|
self.assertIn('direction: rtl', html)
|
||||||
|
# کد باید صریحاً چپبهراست بماند، وگرنه «Authorization: Bearer ...»
|
||||||
|
# در بلوکِ RTL جابهجا میشود.
|
||||||
|
self.assertIn('direction: ltr', html)
|
||||||
|
# قالبِ اصلیِ drf-spectacular باید دستنخورده زیرش باشد.
|
||||||
|
self.assertIn('swagger-ui-bundle', html)
|
||||||
|
|
||||||
|
def test_redoc_carries_the_rtl_shell(self):
|
||||||
|
html = self.client.get('/api/redoc/').content.decode()
|
||||||
|
self.assertIn('direction: rtl', html)
|
||||||
|
|
||||||
|
|
||||||
|
class InsecureSecretKeyWarningTests(TestCase):
|
||||||
|
"""کلیدِ کامیتشدهی django-insecure در پروداکشن باید در /api/health دیده شود (۲۰۲۶-۱۰-۰۳)."""
|
||||||
|
|
||||||
|
def test_committed_key_is_reported(self):
|
||||||
|
from config import health
|
||||||
|
with override_settings(DEBUG=False, SECRET_KEY='django-insecure-xyz'):
|
||||||
|
self.assertIn('django-insecure', ' '.join(health._warnings()))
|
||||||
|
with override_settings(DEBUG=False, SECRET_KEY='a-real-random-key'):
|
||||||
|
self.assertNotIn('django-insecure', ' '.join(health._warnings()))
|
||||||
|
|
||||||
|
|
||||||
|
class HealthIsMinimalForThePublicTests(TestCase):
|
||||||
|
"""/api/health بیکلید فقط ok/error میدهد؛ با کلیدِ درست جزئیات."""
|
||||||
|
|
||||||
|
@override_settings(INTERNAL_SERVICE_KEY='k')
|
||||||
|
def test_public_sees_only_status(self):
|
||||||
|
body = self.client.get('/api/health').json()
|
||||||
|
self.assertEqual(set(body), {'status'})
|
||||||
|
wrong = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='nope').json()
|
||||||
|
self.assertEqual(set(wrong), {'status'})
|
||||||
|
full = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').json()
|
||||||
|
self.assertIn('traffic', full)
|
||||||
|
|
||||||
|
@override_settings(INTERNAL_SERVICE_KEY='')
|
||||||
|
def test_without_a_configured_key_nobody_gets_details(self):
|
||||||
|
self.assertEqual(set(self.client.get('/api/health', HTTP_X_INTERNAL_KEY='').json()), {'status'})
|
||||||
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'})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user