With DEBUG=0 and Kavenegar unconfigured, POST /api/auth/otp/request returns a hard 503, so the only way to sign in was for someone with admin access to hand- create an OTPCode row. That made every frontend developer's login depend on one person being available. TEST_LOGIN_PHONES is the pattern real apps use for exactly this (Firebase Auth's fictional numbers, Apple's App Store demo account): a short allowlist of phone:code pairs that skip SMS delivery entirely. Unlike DEV_MASTER_OTP it is not "any phone with one code" and it is not tied to DEBUG -- each number carries its own code, and everything else takes the normal OTP path untouched. The code never appears in an API response, so knowing the number is not enough to log in, and the existing per-phone login attempt limit still applies, so the code cannot be brute-forced. Malformed configuration raises ImproperlyConfigured at startup rather than being skipped silently, and /api/health reports how many test numbers are open (count only) so they cannot be forgotten on a server that has real users. The code is capped at six digits because LoginRequestSerializer caps otp at max_length=6; a longer code would have been rejected before reaching the check and looked like the feature was broken. The guest path the frontend uses today is untouched, and leaving the variable empty reproduces the previous behaviour exactly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
722 lines
38 KiB
Python
722 lines
38 KiB
Python
"""
|
||
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 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
|
||
|
||
_key_file = BASE_DIR / '.django_secret_key'
|
||
try:
|
||
SECRET_KEY = _key_file.read_text().strip()
|
||
except OSError:
|
||
SECRET_KEY = ''
|
||
if not SECRET_KEY:
|
||
SECRET_KEY = secrets.token_urlsafe(64)
|
||
try:
|
||
_key_file.write_text(SECRET_KEY)
|
||
_key_file.chmod(0o600)
|
||
except OSError as exc:
|
||
raise ImproperlyConfigured(
|
||
f'DJANGO_SECRET_KEY ست نیست و نوشتنِ {_key_file} هم ممکن نشد ({exc}). '
|
||
'یا متغیر را ست کنید یا اجازهی نوشتن در پوشهی پروژه بدهید.'
|
||
) from exc
|
||
|
||
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
|
||
|
||
|
||
# دامنههای شناختهشدهی خودمان همیشه مجازند تا یک .env قدیمی یا ناقص باعثِ
|
||
# DisallowedHost نشود؛ هر چه در محیط باشد به اینها اضافه میشود.
|
||
DEFAULT_ALLOWED_HOSTS = [
|
||
'api.math.tadrisino2.ir',
|
||
'math.tadrisino2.ir',
|
||
'localhost', '127.0.0.1',
|
||
]
|
||
ALLOWED_HOSTS = _with_defaults('DJANGO_ALLOWED_HOSTS', DEFAULT_ALLOWED_HOSTS)
|
||
|
||
# لازم برای ورود به پنلِ ادمین از پشتِ پراکسیِ HTTPS؛ بدونش فرمِ ورود
|
||
# «CSRF verification failed» میدهد و کسی نمیفهمد چرا.
|
||
CSRF_TRUSTED_ORIGINS = _with_defaults(
|
||
'CSRF_TRUSTED_ORIGINS',
|
||
['https://api.math.tadrisino2.ir', 'https://math.tadrisino2.ir'],
|
||
)
|
||
|
||
# --- امنیتِ پشتِ پراکسی ---------------------------------------------------
|
||
# 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 = [
|
||
'https://math.tadrisino2.ir', # فرانتِ مستقر
|
||
]
|
||
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',
|
||
|
||
# Tadrisino apps (فاز اول - ۴ از ۱۱ اپ)
|
||
'accounts',
|
||
'content',
|
||
'progress',
|
||
'ai_tutor',
|
||
|
||
# Tadrisino apps (فاز دوم - بخش ۱ از ۳: subscription, payment, discount)
|
||
'subscription',
|
||
'payment',
|
||
'discount',
|
||
|
||
# Tadrisino apps (فاز دوم - بخش ۲: notifications)
|
||
'notifications',
|
||
|
||
# Tadrisino apps (فاز دوم - بخش ۳: affiliate)
|
||
'affiliate',
|
||
|
||
# Tadrisino apps (فاز دوم - بخش ۴: support)
|
||
'support',
|
||
|
||
# Tadrisino apps (فاز دوم - بخش ۵: family)
|
||
'family',
|
||
|
||
# Tadrisino apps (سیستم پرداخت بازار + آفر/تخفیف + لاگ عمومی تغییر Status)
|
||
'statuslog',
|
||
|
||
# Tadrisino apps (مدلِ مینیمالِ ذخیرهسازیِ کمپینهای پیامی، بدونِ منطقِ ارسال)
|
||
'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',
|
||
'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 (متد/مسیر/وضعیت/مدت/کاربر)؛
|
||
# عمداً آخرِ لیست قرار گرفته تا نزدیکترین میانافزار به خودِ view باشه
|
||
# (مدتزمانِ دقیقتر، و request.user که توسطِ DRF authentication در
|
||
# حینِ پردازشِ view ست میشه، در دسترس باشه).
|
||
'config.middleware.APIRequestLoggingMiddleware',
|
||
]
|
||
|
||
ROOT_URLCONF = 'config.urls'
|
||
|
||
TEMPLATES = [
|
||
{
|
||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||
'DIRS': [],
|
||
'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
|
||
|
||
DATABASES = {
|
||
'default': {
|
||
'ENGINE': 'django.db.backends.sqlite3',
|
||
'NAME': BASE_DIR / 'db.sqlite3',
|
||
# timeout بالاتر از پیشفرض: وقتی دو کانکشنِ همزمان (مثلِ تستِ
|
||
# concurrency واقعیِ campaigns) به قفلِ نوشتنِ SQLite برخورد
|
||
# میکنن، این باعث میشه کانکشنِ منتظر تا ۲۰ ثانیه صبر کنه بهجای
|
||
# اینکه فوراً OperationalError('database table is locked') بده.
|
||
'OPTIONS': {
|
||
'timeout': 20,
|
||
},
|
||
# کانکشنِ DB بهازای هر request باز/بسته نشه؛ تا این تعداد ثانیه
|
||
# persistent بمونه. با SQLite بیاثرِ عملی ولی بیضرره؛ بعد از
|
||
# مهاجرت به Postgres جلوی هزینهی TCP+auth در هر request رو میگیره.
|
||
'CONN_MAX_AGE': int(os.environ.get('DB_CONN_MAX_AGE', '60')),
|
||
}
|
||
}
|
||
|
||
# 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': {
|
||
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
|
||
'LOCATION': CACHE_URL,
|
||
}
|
||
}
|
||
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
|
||
# تازه از GuestAware*Throttleها بگیره (bypassِ چرخشِ حساب مهمان). این
|
||
# مقدار باید دقیقاً با تعدادِ لایهی پراکسی/LB جلوی اپ یکی باشه (مثلاً
|
||
# Cloudflare+Nginx=۲)، وگرنه IP واقعی اشتباه خونده میشه.
|
||
'NUM_PROXIES': int(os.environ.get('NUM_PROXIES', '1')),
|
||
'DEFAULT_THROTTLE_RATES': {
|
||
'otp_request': '5/hour',
|
||
'otp_login': '10/hour',
|
||
'guest_session': '20/hour',
|
||
'bazaar_verify': '10/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',
|
||
'token_refresh': '30/hour',
|
||
'question_answer': '60/min',
|
||
'question_report': '20/hour',
|
||
'lesson_complete': '10/hour',
|
||
'support_create': '30/hour',
|
||
'offer_redeem': '10/hour',
|
||
'discount_validate': '20/hour',
|
||
'discount_redeem': '10/hour',
|
||
'family_invite_accept': '10/hour',
|
||
'family_invite_create': '5/hour',
|
||
'user_profile_write': '60/hour',
|
||
'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',
|
||
'DESCRIPTION': (
|
||
'بکاند اپلیکیشن آموزشی Tadrisino — احراز هویت با OTP، محتوای درسی '
|
||
'و پیشرفت کاربر، دستیار هوش مصنوعی، اشتراک و پرداخت، تخفیف، '
|
||
'اعلانها، همکاری در فروش، پشتیبانی و حساب خانواده.'
|
||
),
|
||
'VERSION': '1.0.0',
|
||
'SERVE_INCLUDE_SCHEMA': False,
|
||
'COMPONENT_SPLIT_REQUEST': True,
|
||
'SWAGGER_UI_SETTINGS': {
|
||
'deepLinking': True,
|
||
'displayRequestDuration': True,
|
||
'defaultModelsExpandDepth': -1,
|
||
'docExpansion': 'list',
|
||
'filter': True,
|
||
'persistAuthorization': True,
|
||
},
|
||
}
|
||
|
||
# اعتبار توکن دسترسی (ثانیه) و رفرشتوکن (روز)
|
||
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
|
||
|
||
# در محیط dev، این کد OTP همیشه معتبر است تا بدون SMS واقعی تست شود.
|
||
# فقط وقتی DEBUG=True مقدار دارد؛ در پروداکشن None است.
|
||
DEV_MASTER_OTP = '0000' if DEBUG else None
|
||
|
||
# -----------------------------------------------------------------------
|
||
# شمارههای تستِ ورود (TEST_LOGIN_PHONES)
|
||
# -----------------------------------------------------------------------
|
||
# مشکلی که این حل میکند: با DEBUG=0 و بدونِ کاوهنگار، هیچ توسعهدهندهای
|
||
# نمیتواند وارد API شود مگر اینکه کسی با دسترسیِ ادمین دستی یک ردیفِ OTPCode
|
||
# بسازد. یعنی ورودِ هر عضوِ تیم به یک آدمِ خاص وابسته بود.
|
||
#
|
||
# این همان الگویی است که اپهای واقعی برای همین کار دارند (Firebase Auth
|
||
# «fictional phone numbers»، Apple «demo account» برای بررسیِ App Store):
|
||
# چند شمارهی مشخص با کدِ ثابت، که پیامک برایشان ارسال نمیشود. برخلافِ
|
||
# DEV_MASTER_OTP این «هر شمارهای» نیست — فقط همین چند شماره، و هر کدام
|
||
# کدِ خودش را دارد.
|
||
#
|
||
# TEST_LOGIN_PHONES=09120000001:834512,09120000002:110947
|
||
#
|
||
# قواعدی که عمداً رعایت میشوند:
|
||
# • کد هیچوقت در پاسخِ API برنمیگردد — هر کس شماره را بداند هم نمیتواند
|
||
# کد را از سرور بیرون بکشد.
|
||
# • سقفِ تلاشِ ورودِ هر شماره (OTP_LOGIN_PHONE_MAX_ATTEMPTS) روی اینها هم
|
||
# اعمال میشود، پس حدسزدنِ کد با brute-force بسته است.
|
||
# • ورودیِ خراب باعثِ بالا نیامدنِ سرویس میشود، نه نادیده گرفته شدنِ بیصدا.
|
||
# • /api/health وجودشان را هشدار میدهد تا روی سرور فراموش نشوند.
|
||
#
|
||
# ⚠️ شماره باید *دقیقاً* همان رشتهای باشد که کلاینت میفرستد. این کدبیس شماره
|
||
# را normalize نمیکند (accounts/serializers.py فقط CharField است)، پس
|
||
# «09121111111» و «+989121111111» دو کاربرِ جدا هستند و تطبیق رشتهای است.
|
||
#
|
||
# ⚠️ این حسابها حسابِ تستاند: کدِ واقعی و تصادفی بگذارید (نه 1234)، و هیچ
|
||
# دادهی واقعیِ کاربری رویشان نگذارید.
|
||
def _parse_test_login_phones(raw: str) -> dict[str, str]:
|
||
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'
|
||
)
|
||
pairs[phone] = 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', '')
|
||
OTP_SMS_TEMPLATE = os.environ.get('OTP_SMS_TEMPLATE', 'کد ورود شما به تدریسینو: {code}')
|
||
|
||
# -----------------------------------------------------------------------
|
||
# Content progress (NodeProgress status)
|
||
# -----------------------------------------------------------------------
|
||
# بعد از چند روز بدون فعالیت روی یک Node که status اون in_progress هست،
|
||
# management command مربوطه (content/management/commands/
|
||
# mark_abandoned_node_progress.py) اونو abandoned علامت میزنه. ۱۴ روز
|
||
# (نه ۳-۷ روزِ رایج در اپهای مشابه) عمداً انتخاب شده چون قطعی/محدودیت
|
||
# اینترنت و دیتای موبایل در ایران باعث وقفههای چندروزهی غیرارادی
|
||
# میشه که نباید فوراً بهعنوان «رهاکردن درس» حساب بشه.
|
||
NODE_PROGRESS_ABANDON_DAYS = 14
|
||
|
||
# -----------------------------------------------------------------------
|
||
# Payment security
|
||
# -----------------------------------------------------------------------
|
||
# POST /api/payment/orders باید فقط سرور-به-سرور (از subscription) صدا زده شود؛
|
||
# کلاینت باید هدر X-Internal-Key برابر همین مقدار بفرستد. بدون env در
|
||
# پروداکشن مقدار خالی میماند و HasInternalServiceKey صراحتاً رد میکند.
|
||
INTERNAL_SERVICE_KEY = os.environ.get(
|
||
'INTERNAL_SERVICE_KEY',
|
||
'dev-internal-service-key-change-me' if DEBUG else '',
|
||
)
|
||
|
||
# POST /api/payment/webhook باید از درگاه پرداخت بیاید، نه یک کلاینت دلخواه.
|
||
# فعلاً یک shared-secret ساده (X-Webhook-Secret) استفاده میشود؛ در اتصال
|
||
# واقعی باید با بررسی امضای HMAC درگاه جایگزین شود.
|
||
PAYMENT_WEBHOOK_SECRET = os.environ.get(
|
||
'PAYMENT_WEBHOOK_SECRET',
|
||
'dev-webhook-secret-change-me' if DEBUG else '',
|
||
)
|
||
|
||
# -----------------------------------------------------------------------
|
||
# Bazaar In-App Billing (پرداخت درونبرنامهای کافهبازار — REST API v2)
|
||
# -----------------------------------------------------------------------
|
||
# صحتسنجی 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', '')
|
||
BAZAAR_API_BASE = os.environ.get('BAZAAR_API_BASE', 'https://pardakht.cafebazaar.ir/devapi/v2')
|
||
|
||
# -----------------------------------------------------------------------
|
||
# Zarinpal (درگاهِ اصلیِ پرداختِ ما — Payment API v4)
|
||
# -----------------------------------------------------------------------
|
||
# آدرسِ عمومیِ بکاند: هم paymentUrlی که به کلاینت برمیگردد از آن ساخته
|
||
# میشود، هم callback_urlی که به زرینپال میرود.
|
||
#
|
||
# پیشفرض دارد چون خالی بودنش سرویس را نمیخواباند، فقط مسیرِ پرداخت را
|
||
# بیصدا میشکند و تا اولین پرداختِ واقعی معلوم نمیشود. `.strip() or` هم
|
||
# لازم است: کلید در .env سرور هست ولی خالی، و os.environ.get فقط وقتی کلید
|
||
# *نباشد* پیشفرض میدهد.
|
||
PUBLIC_BASE_URL = (
|
||
os.environ.get('PUBLIC_BASE_URL', '').strip() or 'https://api.math.tadrisino2.ir'
|
||
).rstrip('/')
|
||
|
||
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',
|
||
)
|
||
ZARINPAL_CALLBACK_URL = os.environ.get(
|
||
'ZARINPAL_CALLBACK_URL',
|
||
f'{PUBLIC_BASE_URL}/api/payment/zarinpal/callback' if PUBLIC_BASE_URL else '',
|
||
)
|
||
# اختیاری: بعدِ verify، مرورگر/کلاینت را به این URL (مثلاً یک deep link اپ)
|
||
# با ?orderId=...&status=... ریدایرکت میکنیم. اگه خالی باشه، همونجا یک
|
||
# پاسخِ JSON ساده برمیگرده.
|
||
ZARINPAL_REDIRECT_URL = os.environ.get('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')
|
||
OPENAI_CHAT_MODEL = os.environ.get('OPENAI_CHAT_MODEL', 'gpt-4o-mini')
|
||
OPENAI_VISION_MODEL = os.environ.get('OPENAI_VISION_MODEL', 'gpt-4o')
|
||
# نباید تغییر کنه مگر اینکه 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).
|
||
import sys as _sys
|
||
|
||
if 'test' in _sys.argv:
|
||
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', '')
|
||
|
||
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')
|