Files
MathApp/config/settings.py
Zareian 98bf05fcbd OTP code requests per IP 20 -> 100/hour so a class on one school Wi-Fi can all log in
SMS cost stays bounded by the per-phone (10/day) and system-wide daily caps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:11:08 +03:30

1043 lines
66 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Django settings for config project.
Generated by 'django-admin startproject' using Django 5.2.17.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.2/ref/settings/
"""
import os
import re
from pathlib import Path
from urllib.parse import urlsplit
from django.core.exceptions import ImproperlyConfigured
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# مقادیرِ .env رو داخلِ os.environ می‌ذاره تا os.environ.get های پایین کار کنن.
# override=False: env واقعیِ سیستم و مقادیرِ docker-compose همچنان اولویت دارن،
# پس رفتارِ پروداکشن/داکر عوض نمی‌شه؛ این فقط برای اجرای محلی (بدون export) و
# کلیدهای RAG (accounts/... , OPENAI_API_KEY, QDRANT_API_KEY, ...) هست.
try:
from dotenv import load_dotenv
load_dotenv(BASE_DIR / '.env', override=False)
except ModuleNotFoundError:
pass
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
# SECURITY WARNING: don't run with debug turned on in production!
# Defaults to False; must be explicitly enabled via env for local dev.
DEBUG = os.environ.get('DJANGO_DEBUG', '') == '1'
# کلیدِ امضا. ترتیبِ اولویت: متغیرِ محیطی، بعد فایلِ تولیدشده‌ی کنارِ پروژه.
#
# چرا فایل و نه خطای راه‌اندازی: قبلاً با DEBUG خاموش و بدونِ این متغیر، اپ
# اصلاً بالا نمی‌آمد و از بیرون فقط 502 دیده می‌شد. کسی که سرور را بالا می‌آورد
# باید حدس می‌زد چه چیزی کم است. حالا اولین اجرا خودش یک کلیدِ تصادفیِ واقعی
# می‌سازد و در .django_secret_key (خارج از گیت) نگه می‌دارد — همان‌قدر امن،
# بدونِ یک قدمِ دستیِ دیگر. متغیرِ محیطی همچنان بر آن اولویت دارد.
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY')
if not SECRET_KEY:
if DEBUG:
SECRET_KEY = 'django-insecure-jn!s*25lz^+bjm101tzmlz8jlj70i0qka++*h-(!0s^^vk-0ss'
else:
import secrets
import time
# سه workerِ gunicorn هم‌زمان به این‌جا می‌رسند. فایل فقط یک بار و اتمی ساخته می‌شود
# (O_EXCL، و نوشتن در فایلِ موقت + rename)، و بقیه همان را می‌خوانند؛ در غیرِ این صورت هر
# worker کلیدِ خودش را می‌ساخت و توکن فقط روی یکی از آن‌ها معتبر بود. سنجیده با سه
# پروسه‌ی هم‌زمان، نه با gunicornِ واقعی.
_key_file = BASE_DIR / '.django_secret_key'
_lock_file = BASE_DIR / '.django_secret_key.lock'
SECRET_KEY = ''
for _attempt in range(50):
try:
SECRET_KEY = _key_file.read_text().strip()
except FileNotFoundError:
SECRET_KEY = ''
except OSError as exc:
raise ImproperlyConfigured(f'خواندنِ {_key_file} ممکن نشد ({exc}).') from exc
if SECRET_KEY:
break
try:
_fd = os.open(_lock_file, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
except FileExistsError:
time.sleep(0.1) # پروسه‌ی دیگری در حالِ ساختنِ کلید است
continue
except OSError as exc:
raise ImproperlyConfigured(
f'DJANGO_SECRET_KEY ست نیست و ساختنِ {_key_file} هم ممکن نشد ({exc}). '
'یا متغیر را ست کنید یا اجازه‌ی نوشتن در پوشه‌ی پروژه بدهید.'
) from exc
try:
os.close(_fd)
if not _key_file.exists():
_tmp = BASE_DIR / '.django_secret_key.tmp'
_tmp_fd = os.open(_tmp, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
with os.fdopen(_tmp_fd, 'w') as fh:
fh.write(secrets.token_urlsafe(64))
os.replace(_tmp, _key_file)
finally:
_lock_file.unlink(missing_ok=True)
if not SECRET_KEY:
raise ImproperlyConfigured(
f'کلیدِ امضا ساخته نشد: {_lock_file} مانده است. اگر هیچ پروسه‌ای در حالِ اجرا نیست، '
'آن را پاک کنید و دوباره راه‌اندازی کنید.'
)
def _env_list(name: str) -> list[str]:
return [v.strip() for v in os.environ.get(name, '').split(',') if v.strip()]
def _with_defaults(name: str, defaults: list[str]) -> list[str]:
"""
مقدارِ محیطی + پیش‌فرض‌های خودمان، بدونِ تکرار و با حفظِ ترتیب.
چرا اضافه و نه جایگزین: فایلِ .env روی سرور از قبل وجود دارد و مقدارهای
ناقص دارد (مثلاً DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1). اگر مقدارِ
موجود پیش‌فرض را کنار می‌زد، همان خطای قدیمی برمی‌گشت و باز کسی باید
دستی فایل را ویرایش می‌کرد. دامنه‌های خودمان همیشه باید مجاز باشند.
"""
seen, out = set(), []
for value in _env_list(name) + defaults:
if value not in seen:
seen.add(value)
out.append(value)
return out
# --- دامنه‌ها ----------------------------------------------------------------
# دو آدرسِ عمومی؛ تنها جای کد که دامنه در آن نوشته شده. ALLOWED_HOSTS، CSRF،
# CORS، callbackِ زرین‌پال، برگشت از پرداخت و redirectِ بازار همه از همین دو
# ساخته می‌شوند. برای جابه‌جایی دامنه فقط این دو را در .env عوض کنید؛ جزئیات
# و ترتیبِ کار در docs/operations.md («عوض کردنِ دامنه»).
#
# پیش‌فرض دارند چون خالی بودنشان سرویس را نمی‌خواباند، فقط مسیرِ پرداخت و
# فرانت را بی‌صدا می‌شکند. `.strip() or` لازم است: کلید ممکن است در .env
# باشد ولی خالی، و os.environ.get فقط وقتی کلید *نباشد* پیش‌فرض می‌دهد.
PUBLIC_BASE_URL = (
os.environ.get('PUBLIC_BASE_URL', '').strip() or 'https://mathapi.tadrisino2.ir'
).rstrip('/')
# آدرسِ خودِ اپ (فرانت)؛ برگشت از درگاه و CORS از آن ساخته می‌شوند.
FRONTEND_URL = (
os.environ.get('FRONTEND_URL', '').strip() or 'https://math.tadrisino2.ir'
).rstrip('/')
def _origin(url):
parts = urlsplit(url)
return f'{parts.scheme}://{parts.netloc}' if parts.scheme and parts.netloc else ''
def _hostname(url):
return urlsplit(url).hostname or ''
# دامنه‌های خودمان (از دو آدرسِ بالا) همیشه مجازند تا یک .env قدیمی یا ناقص
# باعثِ DisallowedHost نشود؛ هر چه در محیط باشد به این‌ها اضافه می‌شود. در دورانِ
# جابه‌جاییِ دامنه، دامنه‌ی قبلی را در DJANGO_ALLOWED_HOSTS نگه دارید تا تا
# عوض شدنِ DNS هر دو جواب بدهند.
DEFAULT_ALLOWED_HOSTS = [
h for h in (_hostname(PUBLIC_BASE_URL), _hostname(FRONTEND_URL)) if h
] + ['localhost', '127.0.0.1']
ALLOWED_HOSTS = _with_defaults('DJANGO_ALLOWED_HOSTS', DEFAULT_ALLOWED_HOSTS)
# لازم برای ورود به پنلِ ادمین از پشتِ پراکسیِ HTTPS؛ بدونش فرمِ ورود
# «CSRF verification failed» می‌دهد و کسی نمی‌فهمد چرا.
CSRF_TRUSTED_ORIGINS = _with_defaults(
'CSRF_TRUSTED_ORIGINS',
[o for o in (_origin(PUBLIC_BASE_URL), _origin(FRONTEND_URL)) if o],
)
# --- امنیتِ پشتِ پراکسی ---------------------------------------------------
# TLS روی nginx/ArvanCloud تمام می‌شود و جنگو درخواست را HTTP می‌بیند؛ بدونِ
# این هدر، کوکی‌های امن هرگز ست نمی‌شوند و ورود به پنلِ ادمین می‌شکند.
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# فقط در پروداکشن: کوکیِ نشستِ ادمین و توکنِ CSRF نباید روی HTTP رد و بدل شوند.
# (احرازِ هویتِ API ما کوکی نیست — این‌ها برای پنلِ ادمین‌اند، یعنی همان‌جایی
# که اعتبارنامه‌ی واقعی وارد می‌شود.)
SESSION_COOKIE_SECURE = not DEBUG
CSRF_COOKIE_SECURE = not DEBUG
SESSION_COOKIE_HTTPONLY = True
# SECURE_SSL_REDIRECT و HSTS عمداً روشن نشده‌اند: اولی پشتِ پراکسیِ بدتنظیم
# حلقه‌ی ریدایرکت می‌سازد و کلِ سایت را می‌خواباند، دومی هم در مرورگرِ کاربر
# کش می‌شود و عملاً برگشت‌ناپذیر است. هر دو تصمیمِ زیرساختی‌اند، نه کد.
# --- CORS -------------------------------------------------------------------
# فرانت‌اند (Vite/React) از یک origin دیگر صدا می‌زند، پس بدونِ این هدرها مرورگر
# هر درخواستِ /api/ را قبل از رسیدن به Django رد می‌کند.
#
# allowlist صریح است، نه CORS_ALLOW_ALL_ORIGINS: احرازِ هویتِ ما توکنِ Bearer در
# هدر است، پس origin ناشناس نباید بتواند از مرورگرِ کاربر به API ما بزند.
# CORS_ALLOW_CREDENTIALS هم عمداً خاموش می‌ماند — کوکی‌ای در کار نیست و روشن
# کردنش فقط سطحِ حمله اضافه می‌کند.
# origin های شناخته‌شده‌ی فرانت. عمداً اینجا و نه فقط در متغیرِ محیطی: اگر
# تنها راهش env بود، یک دیپلویِ فراموش‌شده یعنی بک‌اندِ سالم که مرورگر همه‌ی
# درخواست‌هایش را بلاک می‌کند — خطایی که در سمتِ سرور هیچ ردی نمی‌گذارد و فقط
# در کنسولِ مرورگر دیده می‌شود.
CORS_DEFAULT_ORIGINS = [o for o in (_origin(FRONTEND_URL),) if o] # فرانتِ مستقر
CORS_ALLOWED_ORIGINS = _with_defaults('CORS_ALLOWED_ORIGINS', CORS_DEFAULT_ORIGINS)
# ⚠️ پورتِ سرورِ توسعه را نباید فهرست کرد. فهرستِ ثابتِ قبلی (۳۰۰۰ و ۵۱۷۳) روزی
# شکست که Vite دید ۵۱۷۳ اشغال است و بی‌سروصدا رفت روی ۵۱۷۴ — رفتارِ پیش‌فرضِ
# خودش. آن‌وقت فرانت‌کار خطای CORS گرفت، از سمتِ سرور هیچ ردی در هیچ لاگی
# نبود، و چون هم‌زمان با تغییراتِ ما شد، به اشتباه گردنِ بک‌اند افتاد.
# هر بار که این تکرار شود یعنی یک ویرایشِ .env و یک ری‌استارتِ پروداکشن برای
# چیزی که اصلاً به پروداکشن ربطی ندارد.
#
# پس به‌جای شمردنِ پورت‌ها، الگوی «هر پورتی روی خودِ ماشینِ توسعه‌دهنده» را
# می‌پذیریم. این سطحِ حمله اضافه نمی‌کند: احرازِ هویتِ ما توکنِ Bearer در هدر
# است و `CORS_ALLOW_CREDENTIALS` خاموش، پس صفحه‌ای که روی localhostِ یک مهاجم
# باز است هیچ اعتبارنامه‌ای از مرورگرِ قربانی به دست نمی‌آورد؛ CORS هم اصلاً
# سدِ سرور-به-سرور نیست.
CORS_ALLOWED_ORIGIN_REGEXES = [
r'^http://localhost:\d+$',
r'^http://127\.0\.0\.1:\d+$',
]
CORS_ALLOW_CREDENTIALS = False
# فقط مسیرهای API؛ /admin/ و فایل‌های استاتیک لازم نیست از origin دیگر خوانده شوند.
CORS_URLS_REGEX = r'^/api/.*$'
# Idempotency-Key و X-Internal-Key هدرهای خودمان‌اند و در فهرستِ پیش‌فرض نیستند.
CORS_ALLOW_HEADERS = (
'accept', 'accept-encoding', 'authorization', 'content-type', 'dnt',
'origin', 'user-agent', 'x-csrftoken', 'x-requested-with',
'idempotency-key', 'x-internal-key',
)
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# 3rd party
'rest_framework',
'drf_spectacular',
'corsheaders',
# اپ‌های خودمان. هر کدام یک مسئولیت و مدل‌های خودش را دارد؛ ارجاعِ بینِ
# اپ‌ها با user_id/object_id انجام می‌شود نه ForeignKey، تا هر کدام بعداً
# قابلِ جدا شدن بماند (docs/architecture.md).
# مسیرِ اصلیِ کاربر: ورود، محتوا، پیشرفت، دستیار
'accounts',
'content',
'progress',
'ai_tutor',
# کسبِ درآمد
'subscription',
'payment',
'discount',
# ارتباط با کاربر و پشتیبانی
'notifications',
'affiliate',
'support',
# لاگِ عمومیِ تغییرِ وضعیت، مستقل از بقیه‌ی اپ‌ها
'statuslog',
# Tadrisino apps (مدلِ مینیمالِ ذخیره‌سازیِ کمپین‌های پیامی، بدونِ منطقِ ارسال)
'campaigns',
# Tadrisino apps (لایه‌ی RAG: ذخیره‌ی chunkهای درسی + بازیابی از Qdrant،
# مصرف‌کننده‌ش ai_tutor.generate_tutor_answer است)
'rag',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
# ⚠️ CorsMiddleware باید تا حدِ ممکن بالا و حتماً قبل از CommonMiddleware
# باشد، وگرنه پاسخ‌هایی که زودتر برمی‌گردند (ریدایرکتِ APPEND_SLASH، خطای
# ۴۰۰) هدرِ CORS نمی‌گیرند و در مرورگر به‌شکلِ خطای مبهمِ شبکه دیده می‌شوند.
'corsheaders.middleware.CorsMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
# سقفِ حدسِ رمزِ /admin/ برای هر IP (config/middleware.py).
'config.middleware.AdminLoginThrottleMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
# پاسخِ API هرگز در کشِ ArvanCloud/مرورگر نماند (config/middleware.py).
'config.middleware.APINoStoreMiddleware',
# poolِ دیتابیسِ پر → ۵۰۳ با Retry-After به‌جای ۵۰۰ برای viewهای غیرِ DRF (config/db.py).
'config.middleware.DatabaseBusyMiddleware',
# ⚠️ افزوده شده: لاگِ سبکِ هر درخواستِ API (متد/مسیر/وضعیت/مدت/کاربر)؛
# عمداً آخرِ لیست قرار گرفته تا نزدیک‌ترین میان‌افزار به خودِ view باشه
# (مدت‌زمانِ دقیق‌تر، و request.user که توسطِ DRF authentication در
# حینِ پردازشِ view ست می‌شه، در دسترس باشه).
'config.middleware.APIRequestLoggingMiddleware',
]
ROOT_URLCONF = 'config.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
# قالب‌های سطحِ پروژه (که به هیچ اپی تعلق ندارند) — فعلاً فقط
# پوسته‌ی راست‌به‌چپِ صفحه‌های مستنداتِ API.
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'config.wsgi.application'
# Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
# Postgres وقتی DB_NAME ست باشد؛ وگرنه SQLite (وضعِ فعلیِ سرور تا روزِ انتقال).
# ست‌بودنِ DB_NAME با ناقص‌بودنِ بقیه (کاربر/رمز) عمداً خطا می‌دهد، نه اتصالِ بی‌رمز.
DB_NAME = os.environ.get('DB_NAME', '')
if DB_NAME:
_missing = [k for k in ('DB_USER', 'DB_PASSWORD') if not os.environ.get(k)]
if _missing:
raise RuntimeError('DB_NAME ست شده ولی %s خالی است.' % ', '.join(_missing))
if 'DB_CONN_MAX_AGE' in os.environ:
# pool جای اتصالِ دائمی را گرفته و جنگو این دو را با هم نمی‌پذیرد؛ مقدارِ قدیمی بی‌صدا
# نادیده گرفته نمی‌شود تا کسی خیال نکند هنوز اثر دارد.
raise RuntimeError('DB_CONN_MAX_AGE دیگر معنی ندارد (اتصال‌ها از pool می‌آیند)؛ از .env برش دارید.')
# ── pool اتصال‌ها ──
# تا ۲۰۲۶-۱۰-۱۱ هر نخِ gunicorn (۳ worker × ۳۲ نخ = ۹۶) با CONN_MAX_AGE یک اتصالِ دائمیِ جدا
# نگه می‌داشت، ولی Postgresِ ما max_connections=60 دارد (۵۷ تا برای غیرِ ابرکاربر). روی
# Postgresِ محلی با همان سقف، ۱۵۰ درخواستِ هم‌زمان روی /api/home یک‌سومِ درخواست‌ها را ۵۰۰
# کرد («remaining connection slots are reserved»). حالا هر worker یک pool دارد و نخ‌ها اتصال
# را فقط تا پایانِ درخواست قرض می‌گیرند؛ زیرِ ازدحام نخ‌ها در صفِ pool منتظر می‌مانند و پس از
# DB_POOL_TIMEOUT ثانیه ۵۰۳ با Retry-After می‌گیرند (config/db.py)،
# نه ۵۰۰. بودجه: workers × DB_POOL_MAX_SIZE باید زیرِ max_connections بماند و جا برای
# pg_receivewal، pg_dump و migrateِ دیپلو، و psqlِ دستی بگذارد؛ /api/health آن را می‌سنجد.
# ۸ عدد: هر کوئری این‌جا چند میلی‌ثانیه است، پس ۸ اتصال صدها درخواست در ثانیه را برای یک
# worker (که با GIL حداکثر یک هسته است) می‌کشد؛ در لودتستِ محلی از ۸ به ۱۶ گذرشی بیشتر نشد.
DB_POOL_MIN_SIZE = int(os.environ.get('DB_POOL_MIN_SIZE', '2'))
DB_POOL_MAX_SIZE = int(os.environ.get('DB_POOL_MAX_SIZE', '8'))
DB_POOL_TIMEOUT = float(os.environ.get('DB_POOL_TIMEOUT', '10'))
if not 1 <= DB_POOL_MIN_SIZE <= DB_POOL_MAX_SIZE:
# psycopg_pool این را تازه سرِ اولین کوئری می‌فهمد و آن‌وقت همه‌ی درخواست‌ها ۵۰۰ می‌شوند.
raise RuntimeError(f'DB_POOL_MIN_SIZE ({DB_POOL_MIN_SIZE}) باید بینِ ۱ و DB_POOL_MAX_SIZE ({DB_POOL_MAX_SIZE}) باشد.')
# کوئریِ بیش از ۳۰ ثانیه در درخواستِ وب یعنی باگ یا قفلِ گیرکرده؛ بدونِ سقف اتصالِ pool و نخ را
# تا timeoutِ gunicorn (۱۲۰ ثانیه) گرو می‌گرفت. تراکنشِ بازِ بیکار (کدی که atomic را رها نکرده)
# بعد از ۶۰ ثانیه بسته می‌شود تا قفل‌هایش بقیه را نخواباند. برای یک فرمانِ مدیریتیِ طولانیِ دستی:
# DB_STATEMENT_TIMEOUT_MS=0 python manage.py ...
DB_STATEMENT_TIMEOUT_MS = int(os.environ.get('DB_STATEMENT_TIMEOUT_MS', '30000'))
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': DB_NAME,
'USER': os.environ['DB_USER'],
'PASSWORD': os.environ['DB_PASSWORD'],
'HOST': os.environ.get('DB_HOST', '127.0.0.1'),
'PORT': os.environ.get('DB_PORT', '5432'),
'CONN_MAX_AGE': 0,
# اتصالِ مرده (مثلاً بعد از ری‌استارتِ Postgres) پیش از تحویل از pool کنار گذاشته می‌شود،
# نه این‌که اولین درخواستِ بعدی رویش ۵۰۰ بگیرد. هزینه: یک رفت‌وبرگشتِ محلی برای هر قرض.
'CONN_HEALTH_CHECKS': True,
'OPTIONS': {
'pool': {
'min_size': DB_POOL_MIN_SIZE,
'max_size': DB_POOL_MAX_SIZE,
'timeout': DB_POOL_TIMEOUT,
'max_idle': 300,
'max_lifetime': 1800,
},
'connect_timeout': 5,
'options': (f'-c statement_timeout={DB_STATEMENT_TIMEOUT_MS} '
'-c idle_in_transaction_session_timeout=60000'),
},
# قفلِ ردیف روی Postgres واقعی است (select_for_update)؛ transaction_mode
# فقط برای SQLite بود. با تست‌های هم‌زمانی روی Postgres سنجیده می‌شود.
}
}
else:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
# timeout بالاتر از پیش‌فرض: وقتی دو کانکشنِ هم‌زمان (مثلِ تستِ
# concurrency واقعیِ campaigns) به قفلِ نوشتنِ SQLite برخورد
# می‌کنن، این باعث می‌شه کانکشنِ منتظر تا ۲۰ ثانیه صبر کنه به‌جای
# اینکه فوراً OperationalError('database table is locked') بده.
'OPTIONS': {
'timeout': 20,
# هر atomic() قفلِ نوشتن را از اول می‌گیرد (BEGIN IMMEDIATE).
# SQLite از SELECT ... FOR UPDATE پشتیبانی نمی‌کند و جنگو
# select_for_update را رویش بی‌صدا نادیده می‌گیرد؛ پس محافظ‌های
# «قفلِ ردیف» در پرداخت، تخفیف، قلب و سهمیه‌ی دستیار بدونِ این
# عملاً قفلی نداشتند. با IMMEDIATE کلِ تراکنش سریالی می‌شود (قفلِ
# کلِ دیتابیس، نه ردیف) — روی SQLite درشت‌تر ولی درست. هزینه:
# تراکنش‌ها پشتِ هم صف می‌کشند؛ فراخوانی‌های شبکه‌ای (AI، زرین‌پال)
# عمداً بیرون از atomic() هستند. بعد از Postgres این را بردارید.
'transaction_mode': 'IMMEDIATE',
},
# کانکشنِ DB به‌ازای هر request باز/بسته نشه؛ تا این تعداد ثانیه
# persistent بمونه. با SQLite بی‌اثرِ عملی ولی بی‌ضرره؛ بعد از
# مهاجرت به Postgres جلوی هزینه‌ی TCP+auth در هر request رو می‌گیره.
'CONN_MAX_AGE': int(os.environ.get('DB_CONN_MAX_AGE', '60')),
# تست‌ها روی فایل، مثلِ پروداکشن. دیتابیسِ پیش‌فرضِ تست در حافظه با
# shared-cache است که قفلش جدول‌به‌جدول و بی‌انتظار است («table is
# locked») — رفتاری که روی فایلِ واقعی پیش نمی‌آید؛ تستِ هم‌زمانیِ
# campaigns با BEGIN IMMEDIATE فقط همین‌جا می‌افتاد.
'TEST': {'NAME': str(BASE_DIR / '.test-db.sqlite3')},
}
}
# In production a shared cache is required for rate limiting to work across
# workers/instances; falls back to LocMemCache only when DEBUG=True.
CACHE_URL = os.environ.get('CACHE_URL', '')
if CACHE_URL:
CACHES = {
'default': {
# ResilientRedisCache: قطعیِ Redis API را ۵۰۰ نمی‌کند (config/cache.py شرح می‌دهد چه چیزی در
# عوضش اعمال نمی‌شود). روی سرور فعلاً CACHE_URL خالی است: Redisِ روی آن ماشین مالِ تیم‌های
# دیگر است و عمداً به آن وصل نمی‌شویم؛ این مسیر برای Redisِ اختصاصیِ خودمان آماده است.
'BACKEND': 'config.cache.ResilientRedisCache',
'LOCATION': CACHE_URL,
# بدونِ timeout، Redisِ گیرکرده هر درخواست را تا پایانِ timeoutِ gunicorn (۱۲۰ ثانیه) نگه می‌دارد.
'OPTIONS': {'socket_connect_timeout': 1, 'socket_timeout': 1},
}
}
elif DEBUG:
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
}
}
else:
# بدونِ Redis محدودیتِ نرخ بینِ workerها مشترک نیست — ضعف است، ولی دلیلِ
# خوبی برای بالا نیامدنِ کلِ سرویس نیست. قبلاً اینجا استثنا پرتاب می‌شد و
# نتیجه‌اش از بیرون فقط 502 بود، بدونِ هیچ سرنخی. حالا بالا می‌آید و
# /api/health صریحاً هشدار می‌دهد.
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
}
}
# Password validation
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/
LANGUAGE_CODE = 'fa-ir'
TIME_ZONE = 'Asia/Tehran'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/
STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
STORAGES = {
'default': {
'BACKEND': 'django.core.files.storage.FileSystemStorage',
},
'staticfiles': {
# عمداً نسخه‌ی Manifest نیست: اون نسخه اگه staticfiles.json نباشه
# (یعنی هر وقت collectstatic اجرا نشده) موقعِ رندرِ هر صفحه‌ی ادمین
# ValueError پرت می‌کنه. این یعنی تست‌ها می‌شکستن — جنگو در تست همیشه
# DEBUG=False می‌کنه، پس WHITENOISE_USE_FINDERS هم خاموشه — و مهم‌تر،
# روزی که روی سرور DEBUG=0 بشه و collectstatic فراموش بشه، پنلِ ادمین
# کاملاً می‌خوابید. این نسخه فقط فشرده‌سازی می‌کنه و هیچ‌وقت fail نمی‌شه؛
# بهاش از دست دادنِ hash در نامِ فایله که برای پنلِ ادمین ناچیزه.
'BACKEND': 'whitenoise.storage.CompressedStaticFilesStorage',
},
}
# با DEBUG=1، از finders مستقیم سرو می‌کنه؛ نیازی به collectstatic نیست.
WHITENOISE_USE_FINDERS = DEBUG
# -----------------------------------------------------------------------
# Tadrisino — تنظیمات پروژه
# -----------------------------------------------------------------------
# این پروژه از مدل User جنگو (auth) استفاده نمی‌کنه؛ طبق طراحی سینیورت
# accounts.User یک مدل مستقل (business model) هست، نه AUTH_USER_MODEL.
# احراز هویت با یک TokenAuthentication سفارشی (accounts/authentication.py)
# روی همین مدل accounts.User انجام می‌شه؛ نه سشن/کوکی جنگو.
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'accounts.authentication.TadrisinoTokenAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.AllowAny',
],
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
# ⚠️ افزوده شده: بدونِ این، exceptionِ ناشناخته (غیر از APIException/
# Http404/PermissionDenied) به‌جای پاسخِ JSON، صفحه‌ی HTML عمومیِ ۵۰۰
# جنگو رو برمی‌گردونه (نگاه کنید به config/exceptions.py).
'EXCEPTION_HANDLER': 'config.exceptions.custom_exception_handler',
# BrowsableAPIRenderer فقط در dev؛ در پروداکشن هم overhead داره هم یک
# مرورگرِ HTMLِ API رو در معرض دید می‌ذاره.
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
*(['rest_framework.renderers.BrowsableAPIRenderer'] if DEBUG else []),
],
'DEFAULT_THROTTLE_CLASSES': [
'rest_framework.throttling.ScopedRateThrottle',
],
# بدونِ NUM_PROXIES، DRF هدرِ X-Forwarded-For رو بدون‌چک به‌عنوانِ IP
# کلاینت قبول می‌کنه؛ یعنی هر کلاینت می‌تونست با XFF دلخواه، bucket
# تازه از throttleهای IP-محور بگیره. این
# مقدار باید دقیقاً با تعدادِ لایه‌ی پراکسی/LB جلوی اپ یکی باشه (مثلاً
# Cloudflare+Nginx=۲)، وگرنه IP واقعی اشتباه خونده می‌شه.
#
# پروداکشن دو لایه دارد: ArvanCloud (TLS، `server: ArvanCloud`) → nginx روی
# همین ماشین → gunicorn. XFF در جنگو «IPِ کاربر, IPِ آروان» است؛ با ۱
# آخرین مقدار، یعنی IPِ آروان، IPِ کاربر حساب می‌شد و همه‌ی مهمان‌ها و همه‌ی
# درخواست‌های OTP پشتِ یک edgeِ آروان یک سهمِ مشترک داشتند (ورودِ مهمان
# ۲۰ در ساعت برای *همه*). برای اجرای محلی بی‌پراکسی هم بی‌خطر است: وقتی
# XFF نیست یا کوتاه‌تر است، DRF همان REMOTE_ADDR/اولین مقدار را می‌گیرد.
# `/api/health` در `clientIp` نشان می‌دهد سرور چه IPای می‌بیند.
'NUM_PROXIES': int(os.environ.get('NUM_PROXIES', '2')),
'DEFAULT_THROTTLE_RATES': {
# سقف‌های per-IP عمداً گشاده‌اند: پشتِ CGNATِ اپراتورهای موبایل، وای‌فایِ
# مدرسه یا شبکه‌ی یک دفتر، ده‌ها نفر یک IP دارند و با ۵ در ساعت فقط ۵ نفر
# می‌توانستند وارد شوند. حفاظِ اصلی per-phone و per-code است (پایین‌تر و
# در دیتابیس)؛ این‌ها فقط جلوی یک ماشینِ تنها را می‌گیرند.
# ۲۰ تا ۲۰۲۶-۱۰-۱۱ (تصمیمِ مالک): کلاسی که روی یک وای‌فای با هم وارد می‌شود سقف را پر می‌کرد. ۱۰۰ در
# ساعت از یک IP هنوز جلوی یک ماشینِ تنها را می‌گیرد؛ هزینه‌ی پیامک را سقفِ هر شماره (۱۰ در روز) و سقفِ
# روزانه‌ی کلِ سامانه (OTP_SMS_GLOBAL_DAILY_MAX) می‌بندند.
'otp_request': os.environ.get('OTP_REQUEST_IP_RATE', '100/hour'),
# ۳۰ تا ۲۰۲۶-۱۰-۱۱: یک کلاسِ سی‌نفره روی وای‌فایِ مدرسه با چند غلطِ تایپی سقف را پر می‌کرد. حدسِ کد
# را سقفِ هر کد (OTP_MAX_FAILED_ATTEMPTS=۵، در دیتابیس) و هر شماره می‌بندد، نه این.
'otp_login': os.environ.get('OTP_LOGIN_IP_RATE', '300/hour'),
'bazaar_verify': '10/hour',
'bazaar_oauth': '20/hour',
'ai_ask': '20/hour',
'rag_ingest': '30/hour',
# سقفِ ارزانِ خواندن روی endpointهای پرترافیکِ GET (home/leaderboard/
# summary/notifications). فقط جلوی کلاینتِ خراب/اسکرپر رو می‌گیره؛
# به UX عادی دست نمی‌زنه.
'read': os.environ.get('READ_THROTTLE_RATE', '120/min'),
'purchase': '10/hour',
# بی‌توکن صدا زده می‌شود، پس سهمِ همه‌ی کاربرانِ یک IP مشترک است و هر کاربرِ فعال
# ساعتی یک بار تمدید می‌کند (توکنِ دسترسی یک‌ساعته). تا ۲۰۲۶-۱۰-۰۳ سی بود: سی‌ویکمین
# دانش‌آموزِ یک وای‌فایِ مدرسه یا یک CGNATِ موبایل ۴۲۹ می‌گرفت (بازتولید شد) و فرانت
# بیرونش می‌انداخت. refresh token یک‌بارمصرف و ۶۴ نویسه‌ی تصادفی است، پس حدس زدنش با
# سقفِ بالاتر هم ممکن نیست؛ این سقف فقط جلوی یک ماشینِ تنها را می‌گیرد.
'token_refresh': os.environ.get('TOKEN_REFRESH_IP_RATE', '600/hour'),
'question_answer': '60/min',
'question_report': '20/hour',
# هر درس اینجا یک نوع مسئله است با یکی-دو سوال، نه یک جلسه‌ی درسی؛
# ۱۰ در ساعت را دانش‌آموزِ عادی می‌زد و پیشرفتش ۴۲۹ می‌گرفت.
'lesson_complete': '120/hour',
'node_complete': '60/hour',
'support_create': '30/hour',
'offer_redeem': '10/hour',
'discount_validate': '20/hour',
'discount_redeem': '10/hour',
'user_profile_write': '60/hour',
# حذفِ حساب با کدِ پیامکی؛ سقفِ حدسِ کد جدا در verify_phone_otp است.
'account_delete': '5/hour',
'affiliate_track': '10/hour',
'payment_webhook': '60/hour',
'payment_pay': '30/hour',
# /api/health برای پایشِ بیرونی است؛ باید مکرر قابلِ صدا زدن باشد
# ولی نه آن‌قدر که خودش به ابزارِ فشار تبدیل شود.
'health': os.environ.get('HEALTH_THROTTLE_RATE', '60/min'),
},
}
SPECTACULAR_SETTINGS = {
'TITLE': 'Tadrisino API',
# این متن قراردادِ بینِ ما و تیمِ فرانت است و تنها جایی‌ست که هر دو طرف
# می‌بینند. پس به‌جای فهرستِ امکانات، به سه سوالِ اولِ هر مصرف‌کننده جواب
# می‌دهد: چطور توکن بگیرم، خطا چه شکلی برمی‌گردد، و کِی محدود می‌شوم.
# Swagger UI و Redoc هر دو Markdown را رندر می‌کنند.
'DESCRIPTION': (
'بک‌اندِ اپلیکیشنِ آموزشیِ تدریسینو: برنامه‌ی درسی، آزمون، پیشرفتِ '
'کاربر، دستیارِ هوشِ مصنوعی، اشتراک و پرداخت.\n'
'\n'
'## احرازِ هویت\n'
'\n'
'همه‌ی درخواست‌های احرازشده با یک توکنِ Bearer انجام می‌شوند:\n'
'\n'
'```\n'
'Authorization: Bearer <token>\n'
'```\n'
'\n'
'توکن را فقط با شماره‌ی موبایل می‌گیرید: `POST /api/auth/otp/request` '
'و بعد `POST /api/auth/login`. ورودِ مهمان نداریم؛ هر کاربر حداقل ثبت‌نامِ '
'اولیه با شماره دارد. توکنِ دسترسی یک ساعت اعتبار دارد؛ با '
'`POST /api/auth/refresh` تازه‌اش کنید.\n'
'\n'
'**در همین صفحه:** روی **Authorize** بزنید و *فقط خودِ توکن* را وارد '
'کنید — کلمه‌ی `Bearer` را Swagger خودش اضافه می‌کند.\n'
'\n'
'اندپوینتی که توکن نگیرد، درخواست را ناشناس می‌بیند؛ اینکه ورود لازم '
'باشد یا نه، به خودِ آن اندپوینت بستگی دارد.\n'
'\n'
'## خطاها\n'
'\n'
'هر خطا — حتی خطای پیش‌بینی‌نشده — یک پاسخِ JSON است، نه صفحه‌ی HTML. '
'پیامِ `detail` فارسی و قابلِ نمایش به کاربرِ نهایی است.\n'
'\n'
'```json\n'
'{"detail": "کد OTP نامعتبر یا منقضی‌شده است."}\n'
'```\n'
'\n'
'| کد | معنی |\n'
'|---|---|\n'
'| `400` | ورودی نامعتبر است |\n'
'| `401` | توکن نیست، نامعتبر است یا منقضی شده |\n'
'| `403` | توکن معتبر است ولی اجازه‌ی این کار را ندارد |\n'
'| `404` | چیزی با این شناسه وجود ندارد |\n'
'| `429` | از محدودیتِ نرخ گذشتید — بخشِ بعد |\n'
'| `503` | سرویسِ بیرونی (پیامک، پرداخت) در دسترس نیست |\n'
'\n'
'`503` عمدی‌ست: وقتی سرویسِ بیرونی تنظیم یا در دسترس نباشد، پاسخِ '
'صریح می‌گیرید، نه موفقیتِ ساختگی.\n'
'\n'
'## محدودیتِ نرخ\n'
'\n'
'اندپوینت‌های حساس (ورود، درخواستِ کد، پرسش از دستیار) سقفِ مستقل '
'دارند. عبور از سقف یعنی `429`، و هر جا که معنی داشته باشد هدرِ '
'`Retry-After` هم می‌آید. کاربرِ مهمان بر اساسِ IP شمرده می‌شود.\n'
'\n'
'مسیرِ ورود سقفِ جداگانه‌ای بر اساسِ *شماره* هم دارد، مستقل از IP، تا '
'پخش کردنِ تلاش بینِ چند IP فایده‌ای نداشته باشد.\n'
'\n'
'## قراردادها\n'
'\n'
'- نامِ فیلدها `camelCase` است (`refreshToken`, `nodeId`).\n'
'- زمان‌ها ISO 8601 با منطقه‌ی زمانی‌اند.\n'
'- متنِ رو به کاربر فارسی است؛ شناسه‌ها و نامِ فیلدها انگلیسی.\n'
'- این اسکیما **مرجعِ قرارداد** است. کلاینت را از روی حافظه ننویسید — '
'از `/api/schema/` تولیدش کنید.\n'
),
'VERSION': '1.0.0',
'CONTACT': {'name': 'تیمِ بک‌اندِ تدریسینو'},
'EXTERNAL_DOCS': {
'description': 'راهنمای اتصالِ فرانت‌اند',
'url': 'https://gitea.tadrisino2.ir/Tadrisino/MathApp/src/branch/main/docs/frontend/README.md',
},
# ترتیبِ تگ‌ها در Swagger به همین ترتیب است، نه الفبایی — یعنی مسیرِ
# واقعیِ یک کاربر: اول وارد شو، بعد محتوا بگیر، بعد پیشرفت و بقیه.
'TAGS': [
{'name': 'Identity & Profile (accounts)', 'description': 'ورود، session، پروفایل'},
{'name': 'Curriculum (content)', 'description': 'پایه، فصل، نود، درس، سوال، آزمون'},
{'name': 'Progress & Gamification (progress)', 'description': 'آمار، streak، لیدربرد، شخصیت‌ها'},
{'name': 'AI Tutor (ai_tutor)', 'description': 'پرسش از دستیار و بازخورد روی پاسخ'},
{'name': 'RAG (rag)', 'description': 'بازیابی از محتوای کتاب — داخلی'},
{'name': 'Subscription (subscription)', 'description': 'پلن‌ها و اشتراکِ کاربر'},
{'name': 'Payment (payment)', 'description': 'سفارش و تراکنش (زرین‌پال و کافه‌بازار)'},
{'name': 'Discount (discount)', 'description': 'کدِ تخفیف و آفرها'},
{'name': 'Notifications (notifications)', 'description': 'اعلان‌های کاربر'},
{'name': 'Support (support)', 'description': 'تیکت و گزارشِ اشکال'},
{'name': 'Affiliate (affiliate)', 'description': 'برنامه‌ی معرفی و پورسانت'},
{'name': 'System', 'description': 'سلامتِ سرویس'},
],
'SERVE_INCLUDE_SCHEMA': False,
# مستندات فقط برای ادمینِ واردشده به /admin/. IsAdminUser به‌تنهایی کافی نیست: احرازِ پیش‌فرضِ DRF
# فقط Bearer است و ادمینِ پنل ۴۰۱ می‌گرفت؛ SessionAuthentication کوکیِ پنل را می‌شناسد.
# فایلِ schema برای فرانت: `python manage.py spectacular --file schema.yaml` (بی‌نیاز از HTTP).
'SERVE_PERMISSIONS': ['rest_framework.permissions.IsAdminUser'],
'SERVE_AUTHENTICATION': ['rest_framework.authentication.SessionAuthentication'],
'COMPONENT_SPLIT_REQUEST': True,
# دو فیلدِ «kind» با دو مجموعه‌ی مقدار: نوعِ درس و نوعِ پلن (بسته/اشتراک، از ۲۰۲۶-۱۰-۰۲).
# بدونِ نامِ صریح، drf-spectacular یکی را «KindEd8Enum» می‌نامید و هشدار می‌داد.
'ENUM_NAME_OVERRIDES': {
'LessonKindEnum': [('quiz', 'quiz'), ('video', 'video')],
'PlanKindEnum': [('pack', 'pack'), ('subscription', 'subscription')],
},
'SWAGGER_UI_SETTINGS': {
'deepLinking': True,
'displayRequestDuration': True,
'defaultModelsExpandDepth': -1,
'docExpansion': 'list',
'filter': True,
'persistAuthorization': True,
},
# نسخه‌ی ثابت، نه @latest: این صفحه‌ها روی همان دامنه‌ی /admin/ اجرا می‌شوند، و نسخه‌ی منتشرشده
# در npm عوض‌شدنی نیست؛ با @latest هر نسخه‌ی آلوده‌ی تازه‌ی این بسته‌ها در مرورگرِ کارمند اجرا می‌شد.
# SRI ندارد. ارتقا یعنی عوض کردنِ همین دو عدد.
'SWAGGER_UI_DIST': 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1',
'SWAGGER_UI_FAVICON_HREF': 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/favicon-32x32.png',
'REDOC_DIST': 'https://cdn.jsdelivr.net/npm/redoc@2.5.4',
}
# اعتبار توکن دسترسی (ثانیه) و رفرش‌توکن (روز)
ACCESS_TOKEN_TTL_SECONDS = 3600
REFRESH_TOKEN_TTL_DAYS = 30
# کشِ کوتاهِ ردیفِ کاربر در TadrisinoTokenAuthentication تا هر request
# authenticated یک SELECT روی accounts_user نزنه. ۰ = خاموش (رفتارِ فعلی، و
# پیش‌فرض تا وقتی Redis در پروداکشن هست). توصیه‌ی پروداکشن: ۳۰. با هر save/
# delete روی User، کلیدِ کاربر باطل می‌شه (accounts/receivers.py)، پس تنها
# پنجره‌ی کهنگی برای تغییری که از مسیرِ دیگری (مثلاً shell/DB) اعمال شده،
# همین چند ثانیه‌ست.
AUTH_USER_CACHE_TTL = int(os.environ.get('AUTH_USER_CACHE_TTL', '0'))
# سقفِ گیرنده‌ی اکشنِ ادمینِ send_sms_to_selected؛ این اکشن synchronous توی
# همون requestه (بدون صفِ پس‌زمینه)، پس این سقف جلویِ تایم‌اوت/overloadِ
# provider رو می‌گیره — بیشتر از سقف یعنی هیچ پیامکی ارسال نمی‌شه.
ADMIN_SMS_MAX_RECIPIENTS = int(os.environ.get('ADMIN_SMS_MAX_RECIPIENTS', 200))
# محدودیت تلاش ورود بر اساس شماره تلفن، مستقل از throttle IP-محورِ otp_login.
OTP_LOGIN_PHONE_MAX_ATTEMPTS = 10
OTP_LOGIN_PHONE_WINDOW_SECONDS = 3600
# محدودیتِ صدورِ/ارسالِ دوبارهِ OTP بر اساسِ شماره‌تلفن، مستقل از throttle
# IP-محورِ otp_request؛ جلوی flood کردنِ پیامک به یک شماره از طریق چند IP
# مختلف رو می‌گیره (فقط تعدادِ درخواست رو محدود می‌کنه، نه شماره‌ی IP رو).
OTP_REQUEST_PHONE_MAX_ATTEMPTS = 5
OTP_REQUEST_PHONE_WINDOW_SECONDS = 3600
# حداقل فاصله بین دو درخواستِ OTP برای همون شماره؛ مستقل از سقفِ ساعتیِ بالا
# (که جلویِ burst رو نمی‌گیره، مثلاً ۵ تلاش در کمتر از یک ثانیه). این مقدار
# باید با countdownِ فرانت یکی باشه — countdown صرفاً UX است، نه مرجعِ امنیتی.
OTP_REQUEST_PHONE_COOLDOWN_SECONDS = 120
# عمرِ هر کدِ ورود. سقف‌های دیتابیسیِ پایین زمانِ صدور را از expires_at منهای
# همین مقدار می‌خوانند، پس عوض کردنش آن سقف‌ها را هم (برای کدهای قدیمی) جابه‌جا می‌کند.
OTP_TTL_SECONDS = 300
# سقف‌هایی که در دیتابیس شمرده می‌شوند، نه در کش. کشِ پیش‌فرض LocMem و مالِ
# هر worker است، پس cooldown و سقفِ ساعتیِ بالا روی سرور (۳ worker) عملاً سه
# برابرند؛ این دو بینِ همه‌ی workerها مشترک‌اند و بدونِ Redis هم درست کار می‌کنند.
# - روزانه‌ی هر شماره: جلوی بمبارانِ پیامکیِ یک گوشی در طولِ روز.
# - روزانه‌ی کلِ سامانه: سقفِ خرجِ اعتبارِ کاوه‌نگار اگر کسی با شماره‌های تصادفی
# و IPهای زیاد پیامک بکشد (SMS pumping). رسیدن به آن ۵۰۳ و لاگِ ERROR می‌دهد؛
# با رشدِ کاربرِ واقعی از .env بالا ببرید.
OTP_REQUEST_PHONE_DAILY_MAX = int(os.environ.get('OTP_REQUEST_PHONE_DAILY_MAX', '10'))
OTP_SMS_GLOBAL_DAILY_MAX = int(os.environ.get('OTP_SMS_GLOBAL_DAILY_MAX', '300'))
# هر کدِ ورود بعد از این تعداد حدسِ غلط باطل می‌شود (شمارش در دیتابیس).
# با کدِ ۶رقمی یعنی شانسِ حدس زدنِ هر کد ۵ در یک میلیون.
OTP_MAX_FAILED_ATTEMPTS = 5
# در محیط dev، این کد OTP همیشه معتبر است تا بدون SMS واقعی تست شود.
# فقط وقتی DEBUG=True مقدار دارد؛ در پروداکشن None است.
DEV_MASTER_OTP = '0000' if DEBUG else None
# شماره‌های تستِ ورود: «شماره:کد» با کامای جداکننده. برای این شماره‌ها پیامک
# ارسال نمی‌شود و همان کدِ ثابت معتبر است — تا ورودِ تیمِ توسعه به تنظیم بودنِ
# کاوه‌نگار یا به یک آدمِ خاص وابسته نباشد. برخلافِ DEV_MASTER_OTP «هر
# شماره‌ای» نیست و به DEBUG گره نخورده. روالِ استفاده در docs/operations.md.
#
# ⚠️ شماره باید *دقیقاً* همان رشته‌ای باشد که کلاینت می‌فرستد: این کدبیس شماره
# را normalize نمی‌کند (accounts/serializers.py فقط CharField است)، پس
# «09121111111» و «+989121111111» دو کاربرِ جدا هستند.
def _parse_test_login_phones(raw: str) -> dict[str, str]:
from accounts.phone import normalize_phone
pairs = {}
for item in raw.split(','):
item = item.strip()
if not item:
continue
phone, sep, code = item.partition(':')
phone, code = phone.strip(), code.strip()
# سقفِ ۶ رقمِ کد دلبخواه نیست: LoginRequestSerializer فیلدِ otp را
# max_length=6 گرفته، پس کدِ بلندتر پیش از رسیدن به اینجا ۴۰۰ می‌شد و
# به‌نظر می‌آمد کدِ تست کار نمی‌کند.
if not sep or not re.fullmatch(r'\+?\d{10,15}', phone) or not re.fullmatch(r'\d{4,6}', code):
raise ImproperlyConfigured(
f'TEST_LOGIN_PHONES مقدارِ نامعتبر دارد: «{item}». '
'قالبِ درست «شماره:کد» است؛ شماره ۱۰ تا ۱۵ رقم (با + اختیاری) '
'و کد ۴ تا ۶ رقم. مثال: TEST_LOGIN_PHONES=09120000001:834512'
)
# همان نرمال‌سازیِ ورودیِ API (accounts/phone.py)، وگرنه شماره‌ای که
# اینجا ‎+98…‎ نوشته شده با ورودیِ نرمال‌شده‌ی ‎09…‎ هرگز جور نمی‌شد.
normalized = normalize_phone(phone)
if normalized is None:
raise ImproperlyConfigured(
f'TEST_LOGIN_PHONES شماره‌ی موبایلِ ایران نیست: «{item}». مثال: 09120000001:834512'
)
pairs[normalized] = code
return pairs
TEST_LOGIN_PHONES = _parse_test_login_phones(os.environ.get('TEST_LOGIN_PHONES', ''))
# -----------------------------------------------------------------------
# OTP SMS delivery (accounts/services.py: send_otp_sms)
# -----------------------------------------------------------------------
# در پروداکشن send_otp_sms از طریق کاوه‌نگار پیامک واقعی می‌فرستد.
# بدون تنظیم این env، ارسال باید صراحتاً خطا بدهد، نه silently موفق باشد.
KAVENEGAR_API_KEY = os.environ.get('KAVENEGAR_API_KEY', '')
KAVENEGAR_SENDER = os.environ.get('KAVENEGAR_SENDER', '')
# نامِ الگوی تأییدشده در پنلِ کاوه‌نگار برای کدِ ورود (verify/lookup، با %token%).
# وقتی تنظیم باشد OTP_SMS_TEMPLATE پایین استفاده نمی‌شود و متن را خودِ الگو می‌سازد.
# خطِ پیش‌فرضِ حسابِ فعلی پیامکِ متنِ آزاد را لغو می‌کند، پس روی سرور لازم است.
KAVENEGAR_OTP_TEMPLATE = os.environ.get('KAVENEGAR_OTP_TEMPLATE', '').strip()
# جمله‌ی دوم هشدارِ مهندسیِ اجتماعی است (رایج در پیامک‌های بانکی)؛ کلِ متن زیرِ
# ۷۰ نویسه می‌ماند تا یک صفحه پیامکِ فارسی حساب شود، نه دو صفحه.
OTP_SMS_TEMPLATE = os.environ.get(
'OTP_SMS_TEMPLATE', 'کد ورود شما به تدریسینو: {code}\nاین کد را به هیچ‌کس ندهید.',
)
# -----------------------------------------------------------------------
# Content progress (NodeProgress status)
# -----------------------------------------------------------------------
# بعد از چند روز بدون فعالیت روی یک Node که status اون in_progress هست،
# management command مربوطه (content/management/commands/
# mark_abandoned_node_progress.py) اونو abandoned علامت می‌زنه. ۱۴ روز
# (نه ۳-۷ روزِ رایج در اپ‌های مشابه) عمداً انتخاب شده چون قطعی/محدودیت
# اینترنت و دیتای موبایل در ایران باعث وقفه‌های چندروزه‌ی غیرارادی
# می‌شه که نباید فوراً به‌عنوان «رهاکردن درس» حساب بشه.
NODE_PROGRESS_ABANDON_DAYS = 14
# قلبِ صفر جلوی جواب دادن را در سرور بگیرد؟ (content.views.QuestionAnswerView)
# از ۲۰۲۶-۱۰-۰۲ روشن است (خواستِ صاحبِ پروژه): فرانت ۴۰۳ِ `no_hearts` را با صفحه‌ی «قلب‌هایت
# تمام شد» و زمانِ پر شدن نشان می‌دهد، و قلبِ نامحدودِ هدیه‌ی بسته‌ها بدونِ این اثری نداشت.
# HEARTS_ENFORCED=0 در .env آن را خاموش می‌کند.
HEARTS_ENFORCED = os.environ.get('HEARTS_ENFORCED', '1') == '1'
# تلاشِ ورودِ ادمین در ۱۵ دقیقه برای هر IP.
ADMIN_LOGIN_MAX_ATTEMPTS = 10
# -----------------------------------------------------------------------
# Payment security
# -----------------------------------------------------------------------
# POST /api/payment/orders باید فقط سرور-به-سرور (از subscription) صدا زده شود؛
# کلاینت باید هدر X-Internal-Key برابر همین مقدار بفرستد. بدون env در
# پروداکشن مقدار خالی می‌ماند و HasInternalServiceKey صراحتاً رد می‌کند.
# هیچ پیش‌فرضی، حتی در DEBUG: رشته‌ی پیش‌فرضِ داخلِ ریپو هر لپ‌تاپِ توسعه‌ای را که روی شبکه
# باز است به ingest در Qdrant راه می‌داد.
INTERNAL_SERVICE_KEY = os.environ.get('INTERNAL_SERVICE_KEY', '')
# -----------------------------------------------------------------------
# Bazaar In-App Billing (پرداخت درون‌برنامه‌ای کافه‌بازار — REST API v2)
# -----------------------------------------------------------------------
# صحت‌سنجی OAuth2 سرور-به-سروره: REFRESH_TOKEN یک‌بار دستی از پنل devapi
# گرفته می‌شه، بعد بک‌اند با همون REFRESH_TOKEN، ACCESS_TOKEN تازه می‌گیره.
# قبل از اتصال واقعی، مسیرِ endpointها رو از پنل devapi تایید کنید — بازار
# این روش رو قبلاً تغییر داده.
BAZAAR_PACKAGE_NAME = os.environ.get('BAZAAR_PACKAGE_NAME', 'ir.tadrisino.app')
BAZAAR_CLIENT_ID = os.environ.get('BAZAAR_CLIENT_ID', '')
BAZAAR_CLIENT_SECRET = os.environ.get('BAZAAR_CLIENT_SECRET', '')
BAZAAR_REFRESH_TOKEN = os.environ.get('BAZAAR_REFRESH_TOKEN', '')
# روشِ جدیدِ بازار: توکنِ ثابتِ پیشخان (API پیشخان بازار ← دریافت توکن جدید).
# اگر ست باشد بر OAuthِ بالا (CLIENT_ID/SECRET/REFRESH_TOKEN) مقدم است.
BAZAAR_PISHKHAN_API_SECRET = os.environ.get('BAZAAR_PISHKHAN_API_SECRET', '').strip()
BAZAAR_API_BASE = os.environ.get('BAZAAR_API_BASE', 'https://pardakht.cafebazaar.ir/devapi/v2')
# Redirect URI که در پنل بازار ثبت شده؛ باید با آن *عیناً* یکی باشد. به
# خواستِ مدیرِ حسابِ بازار (۲۰۲۶-۰۹-۲۶) همان آدرسِ verify است: GETِ آن codeِ
# بازار را می‌گیرد و POSTِ آن خریدِ اپ را. با عوض شدنِ PUBLIC_BASE_URL این هم
# عوض می‌شود، و آن‌وقت در پنلِ بازار هم باید عوض شود.
BAZAAR_REDIRECT_URI = (
os.environ.get('BAZAAR_REDIRECT_URI', '').strip()
or f'{PUBLIC_BASE_URL}/api/payment/bazaar/verify'
)
# چند ساعت لینکِ «اجازه»ی بازار باز می‌ماند؛ بعد از اتصالِ موفق خودش بسته می‌شود.
BAZAAR_AUTHORIZE_WINDOW_HOURS = int(os.environ.get('BAZAAR_AUTHORIZE_WINDOW_HOURS', '72'))
# -----------------------------------------------------------------------
# Zarinpal (درگاهِ اصلیِ پرداختِ ما — Payment API v4)
# -----------------------------------------------------------------------
# paymentUrlی که به کلاینت برمی‌گردد و callback_urlی که به زرین‌پال می‌رود
# هر دو از PUBLIC_BASE_URL (بخشِ «دامنه‌ها» در بالای همین فایل) ساخته می‌شوند.
ZARINPAL_MERCHANT_ID = os.environ.get('ZARINPAL_MERCHANT_ID', '')
ZARINPAL_SANDBOX = os.environ.get('ZARINPAL_SANDBOX') == '1'
ZARINPAL_API_BASE = os.environ.get(
'ZARINPAL_API_BASE',
'https://sandbox.zarinpal.com/pg/v4/payment' if ZARINPAL_SANDBOX
else 'https://api.zarinpal.com/pg/v4/payment',
)
ZARINPAL_STARTPAY_BASE = os.environ.get(
'ZARINPAL_STARTPAY_BASE',
'https://sandbox.zarinpal.com/pg/StartPay' if ZARINPAL_SANDBOX
else 'https://www.zarinpal.com/pg/StartPay',
)
# `.strip() or` مثلِ بقیه: کلیدِ خالی در .env نباید callback را خالی کند (آن‌وقت
# هر پرداختی بی‌صدا ۵۰۳ می‌گرفت).
ZARINPAL_CALLBACK_URL = (
os.environ.get('ZARINPAL_CALLBACK_URL', '').strip()
or f'{PUBLIC_BASE_URL}/api/payment/zarinpal/callback'
)
# بعدِ verify، مرورگر را به این URL با ?orderId=...&status=... برمی‌گردانیم
# (برای اپِ موبایل می‌تواند یک deep link باشد). پیش‌فرض خودِ اپِ وب است؛ قبلاً
# خالی بود و دانش‌آموز بعد از پرداختِ واقعی روی یک صفحه‌ی JSONِ خام روی دامنه‌ی
# API می‌ماند. مقدارِ صریحِ `-` یعنی همان پاسخِ JSON (برای تست/ابزار).
ZARINPAL_REDIRECT_URL = os.environ.get('ZARINPAL_REDIRECT_URL', '').strip() or f'{FRONTEND_URL}/'
if ZARINPAL_REDIRECT_URL == '-':
ZARINPAL_REDIRECT_URL = ''
# -----------------------------------------------------------------------
# RAG / AI provider (اپ rag + ai_tutor.generate_tutor_answer)
# -----------------------------------------------------------------------
# provider همون OpenAI است ولی از طریقِ پروکسیِ ایرانیِ GapGPT (base_url).
# بدونِ OPENAI_API_KEY و QDRANT_API_KEY، rag_is_configured() برابرِ False
# می‌شه و generate_tutor_answer به همون پاسخِ stubِ قبلی برمی‌گرده (پس
# تست‌های موجود و اجرای بدونِ کلید دست‌نخورده کار می‌کنن).
OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY', '')
OPENAI_BASE_URL = os.environ.get('OPENAI_BASE_URL', 'https://api.gapgpt.app/v1')
# gpt-4.1-mini از ۲۰۲۶-۰۹-۲۶ (تصمیمِ صاحبِ پروژه): در سنجشِ ۱۰۲ مسئله ۹۶ درست در برابرِ
# ۸۵–۹۰ِ gpt-4o-mini، با ۲٫۹ برابر هزینه (۰٫۱۲ سنت برای هر پاسخ). شرح: گزارش ۲۳ و
# docs/architecture.md. پیش‌فرض همین‌جاست تا دیپلوی به ویرایشِ دستیِ .env نیاز نداشته باشد.
OPENAI_CHAT_MODEL = os.environ.get('OPENAI_CHAT_MODEL', 'gpt-4.1-mini')
# سقفِ سوالِ روزانه از دستیار برای کاربرِ بدونِ اشتراک (مشترکان: Plan.ai_daily_quota).
# ۲ از ۲۰۲۶-۱۰-۱۰ (تصمیمِ مالک): دستیار جدا از اشتراک فروخته می‌شود (بسته‌ی سوال)، پس سهمِ رایگان فقط
# برای آشنایی است. پیش از آن ۳، و تا ۲۰۲۶-۱۰-۰۱ برای همه ۱۵ بود.
AI_DAILY_QUOTA_FREE = int(os.environ.get('AI_DAILY_QUOTA_FREE', '2'))
# سقفِ روزانه‌ی *کلِ سامانه* برای سوال‌هایی که به مدل می‌رسند (رایگان یا با اعتبار)، همان الگوی
# OTP_SMS_GLOBAL_DAILY_MAX. سقف‌های بالا برای هر کاربر و هر IP‌اند؛ کسی با چند IP و مهمان‌های
# تازه می‌توانست خرجِ GapGPT را بی‌سقف بالا ببرد. ۳۰۰۰ با ~۰٫۲ سنت برای هر سوال یعنی حدودِ ۶
# دلار در روز؛ رسیدن به آن ۵۰۳ و لاگِ ERROR می‌دهد. با رشدِ کاربرِ واقعی از .env بالا ببرید.
AI_GLOBAL_DAILY_MAX = int(os.environ.get('AI_GLOBAL_DAILY_MAX', '3000'))
# حداکثر جوابِ هم‌زمانِ دستیار در هر worker (ai_tutor/concurrency.py). هر جواب یک نخ را ۳ تا ۴۰ ثانیه
# نگه می‌دارد؛ بی‌سقف، رگبارِ سوال هر ۳۲ نخِ worker را می‌گرفت و نقشه و آزمون پشتش صف می‌کشیدند. با ۱۲،
# دست‌کم ۲۰ نخ برای بقیه‌ی اپ می‌ماند؛ ۴ worker × ۱۲ = ۴۸ جوابِ هم‌زمان. بیشتر از آن ۵۰۳ «شلوغ است»
# می‌گیرد، پیش از رزروِ سهمیه. سقفِ هم‌زمانیِ خودِ GapGPT را نمی‌دانیم و نسنجیده‌ایم.
AI_MAX_CONCURRENT_PER_WORKER = int(os.environ.get('AI_MAX_CONCURRENT_PER_WORKER', '12'))
# اختیاری: مدلِ جدا فقط برای پایه‌های دهم تا دوازدهم (حسابان/مثلثات). خالی یعنی
# همه‌ی پایه‌ها OPENAI_CHAT_MODEL. انتخابش با `rag_model_benchmark` سنجیده می‌شود.
OPENAI_CHAT_MODEL_UPPER = os.environ.get('OPENAI_CHAT_MODEL_UPPER', '')
# فقط برای مدل‌های reasoning (o-series/gpt-5): low | medium | high؛ خالی = پیش‌فرضِ مدل.
OPENAI_CHAT_REASONING_EFFORT = os.environ.get('OPENAI_CHAT_REASONING_EFFORT', 'low')
# gpt-4.1-mini در سنجشِ ۲۰۲۶-۰۹-۲۰ هم ارزان‌تر و هم دقیق‌تر از gpt-4o بود
# (docs/extraction-playbook.md)؛ پیش‌فرض همان است تا به .env وابسته نباشد.
OPENAI_VISION_MODEL = os.environ.get('OPENAI_VISION_MODEL', 'gpt-4.1-mini')
# نباید تغییر کنه مگر اینکه collectionهای Qdrant هم با همون مدلِ جدید بازساخته بشن.
OPENAI_EMBEDDING_MODEL = os.environ.get('OPENAI_EMBEDDING_MODEL', 'text-embedding-3-small')
OPENAI_EMBEDDING_DIM = int(os.environ.get('OPENAI_EMBEDDING_DIM', '1536'))
OPENAI_TIMEOUT_SECONDS = int(os.environ.get('OPENAI_TIMEOUT_SECONDS', '60'))
QDRANT_URL = os.environ.get('QDRANT_URL', 'https://qdrant.tadrisino2.ir:443')
# پیش‌فرضِ خودِ qdrant-client پنج ثانیه است و برای این سرور کم است: ingestِ
# کتابِ هشتم روی همان اولین فراخوانی (ساختِ collection) افتاد و صفر صفحه رفت.
# سی ثانیه در مسیرِ ingest که دقیقه‌ها طول می‌کشد هزینه‌ای ندارد، و جلوی
# مردنِ یک اجرای طولانی بابتِ یک کندیِ لحظه‌ای را می‌گیرد.
QDRANT_TIMEOUT_SECONDS = int(os.environ.get('QDRANT_TIMEOUT_SECONDS', '30'))
QDRANT_API_KEY = os.environ.get('QDRANT_API_KEY', '')
# نامِ collection در دادهٔ تیمِ دیگه = f"{subject} {grade}".lower() . کلیدِ
# فیلترِ درس خودکار انتخاب می‌شه (rag/services/known_collections.py): ریاضی →
# `chapter` ، بقیه → `lesson_number`.
RAG_COLLECTION_NAME_TEMPLATE = os.environ.get('RAG_COLLECTION_NAME_TEMPLATE', '{subject} {grade}')
# نگاشتِ اختیاریِ gradeId → عنوانِ دقیقِ گرید همان‌طور که در نامِ collection
# تیمِ دیگه اومده (مثلاً {6: 'ششم دبستان'})؛ اگه خالی باشه از content.Grade.title
# استفاده می‌شه. کاربر قبلاً با نامِ نادرستِ گرید به مشکل خورده بود.
RAG_GRADE_TITLE_OVERRIDES = {}
RAG_RETRIEVAL_TOP_K = int(os.environ.get('RAG_RETRIEVAL_TOP_K', '4'))
RAG_FORCE_MOCK = os.environ.get('RAG_FORCE_MOCK', '') == '1'
# کشِ پاسخِ نهایی برای سوالِ عیناً تکراری روی همون (collection + فصل + متنِ
# سوال). فقط embedding/جستجو/تولید رو رد می‌کنه؛ کوتا/idempotency/ثبتِ پیام
# مثلِ همیشه اجرا می‌شن. ۰ یعنی خاموش. TTL کوتاه چون دادهٔ Qdrant ممکنه
# به‌روز بشه.
RAG_ANSWER_CACHE_TTL = int(os.environ.get('RAG_ANSWER_CACHE_TTL', '1800'))
# در اجرای تست‌ها همیشه RAG خاموش است تا هیچ تستی به OpenAI/Qdrant واقعی
# نزند (سرعت + پایداری). تست‌هایی که مسیرِ RAG را می‌آزمایند خودشان با
# override_settings + mockِ کلاینت‌ها این را برمی‌گردانند (rag/tests.py).
# فقط خودِ دستورِ `manage.py test`؛ هر آرگومانی به اسمِ «test» (مثلاً نامِ یک collection) نه.
import sys as _sys
if _sys.argv[1:2] == ['test']:
RAG_FORCE_MOCK = True
RAG_ANSWER_CACHE_TTL = 0
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# -----------------------------------------------------------------------
# Logging — بدونِ یک LOGGING صریح، loggerهای موجودِ اپ (config.middleware،
# accounts.services، ...) فقط به rootِ پیش‌فرض می‌رسیدن که برای INFO عملاً
# خروجی‌ای نداره. خروجی: یک JSON در هر خط روی stdout، سازگار با
# Promtail/Filebeat برای جمع‌آوریِ بعدی توسطِ Loki/ELK.
# -----------------------------------------------------------------------
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'json': {
'()': 'config.log_formatters.JSONLogFormatter',
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'json',
},
},
'root': {
'handlers': ['console'],
'level': 'INFO',
},
}
# وقتی LOG_DIR ست باشه (فقط داخلِ docker-compose.yml، روی ولومِ مشترک با
# promtail)، همون خطوطِ JSON علاوه بر stdout در یک فایل هم نوشته می‌شن تا
# Promtail بتونه tail کنه. بدونش (اجرای عادی خارج از Docker)، فقط stdout.
LOG_DIR = os.environ.get('LOG_DIR', '')
# کپیِ زنده‌ی دیتابیس روی سرورِ دیگر (ops/pgbackup/): نامِ اسلاتِ تکثیری که آن سرور از آن می‌خواند.
# خالی روی Postgres یعنی کپیِ بیرونی نداریم و /api/health هشدار می‌دهد.
OFFSITE_WAL_SLOT = os.environ.get('OFFSITE_WAL_SLOT', '')
# بیش از این عقب‌ماندن هشدار است. ۶۴ مگ ≈ چهار فایلِ WAL؛ در ترافیکِ عادی عقب‌ماندگی نزدیکِ صفر است.
OFFSITE_WAL_MAX_LAG_BYTES = 64 * 2**20
if LOG_DIR:
os.makedirs(LOG_DIR, exist_ok=True)
LOGGING['handlers']['file'] = {
'class': 'logging.handlers.RotatingFileHandler',
'filename': os.path.join(LOG_DIR, 'app.log'),
'maxBytes': 10 * 1024 * 1024, # 10MB در هر فایل، قبل از rotate
'backupCount': 5,
'formatter': 'json',
}
LOGGING['root']['handlers'].append('file')