The API moved to mathapi.tadrisino2.ir on 2026-09-26 (nginx server_name changed at 23:22) and api.math.tadrisino2.ir no longer resolves. The code default for PUBLIC_BASE_URL and the docs' curl examples still named the dead host, so every paymentUrl, the Zarinpal callback and the Bazaar redirect the backend builds pointed nowhere. Historical status notes in CLAUDE.md keep the old name on purpose. config/tests.py now reads the API host from PUBLIC_BASE_URL instead of a literal. 624 tests green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
910 lines
54 KiB
Python
910 lines
54 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 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
|
||
|
||
_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
|
||
|
||
|
||
# --- دامنهها ----------------------------------------------------------------
|
||
# دو آدرسِ عمومی؛ تنها جای کد که دامنه در آن نوشته شده. 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',
|
||
'family',
|
||
|
||
# لاگِ عمومیِ تغییرِ وضعیت، مستقل از بقیهی اپها
|
||
'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',
|
||
# ⚠️ افزوده شده: لاگِ سبکِ هر درخواستِ 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
|
||
|
||
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': {
|
||
'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 واقعی اشتباه خونده میشه.
|
||
#
|
||
# پروداکشن دو لایه دارد: 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 است (پایینتر و
|
||
# در دیتابیس)؛ اینها فقط جلوی یک ماشینِ تنها را میگیرند.
|
||
'otp_request': os.environ.get('OTP_REQUEST_IP_RATE', '20/hour'),
|
||
'otp_login': os.environ.get('OTP_LOGIN_IP_RATE', '30/hour'),
|
||
'guest_session': os.environ.get('GUEST_SESSION_IP_RATE', '60/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',
|
||
'token_refresh': '30/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',
|
||
'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',
|
||
# این متن قراردادِ بینِ ما و تیمِ فرانت است و تنها جاییست که هر دو طرف
|
||
# میبینند. پس بهجای فهرستِ امکانات، به سه سوالِ اولِ هر مصرفکننده جواب
|
||
# میدهد: چطور توکن بگیرم، خطا چه شکلی برمیگردد، و کِی محدود میشوم.
|
||
# Swagger UI و Redoc هر دو Markdown را رندر میکنند.
|
||
'DESCRIPTION': (
|
||
'بکاندِ اپلیکیشنِ آموزشیِ تدریسینو: برنامهی درسی، آزمون، پیشرفتِ '
|
||
'کاربر، دستیارِ هوشِ مصنوعی، اشتراک و پرداخت.\n'
|
||
'\n'
|
||
'## احرازِ هویت\n'
|
||
'\n'
|
||
'همهی درخواستهای احرازشده با یک توکنِ Bearer انجام میشوند:\n'
|
||
'\n'
|
||
'```\n'
|
||
'Authorization: Bearer <token>\n'
|
||
'```\n'
|
||
'\n'
|
||
'دو راه برای گرفتنِ توکن هست:\n'
|
||
'\n'
|
||
'| مسیر | کاربرد |\n'
|
||
'|---|---|\n'
|
||
'| `POST /api/auth/session` | ورودِ مهمان با `deviceId` — بدونِ شماره، بلافاصله |\n'
|
||
'| `POST /api/auth/otp/request` + `POST /api/auth/login` | ورودِ کامل با شمارهی موبایل |\n'
|
||
'\n'
|
||
'کاربرِ مهمان میتواند بعداً شمارهاش را وصل کند و همان حساب را نگه '
|
||
'دارد. توکنِ دسترسی یک ساعت اعتبار دارد؛ با `POST /api/auth/refresh` '
|
||
'تازهاش کنید.\n'
|
||
'\n'
|
||
'`deviceId` هر رشتهای میتواند باشد و هر مقدارِ تازه یک حسابِ جدا '
|
||
'میسازد، پس برای تست لازم نیست منتظرِ چیزی بمانید. چند `deviceId` '
|
||
'از پیشآماده هم برای دیدنِ صفحههای دادهدار وجود دارد — از تیمِ '
|
||
'بکاند بخواهید.\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` است (`deviceId`, `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': 'Family (family)', 'description': 'کنترلِ والدین و گزارشِ هفتگی'},
|
||
{'name': 'Support (support)', 'description': 'تیکت و گزارشِ اشکال'},
|
||
{'name': 'Affiliate (affiliate)', 'description': 'برنامهی معرفی و پورسانت'},
|
||
{'name': 'System', 'description': 'سلامتِ سرویس'},
|
||
],
|
||
'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
|
||
|
||
# عمرِ هر کدِ ورود. سقفهای دیتابیسیِ پایین زمانِ صدور را از 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` صفحهی «قلبها تمام شد» و زمانِ
|
||
# nextRefillAt را نشان دهد؛ روشن کردنش پیش از آن، دانشآموزِ بیقلب را به
|
||
# صفحهی خطای عمومی میبرد. تا آن موقع «قلبِ نامحدودِ» اشتراک فقط در اپ است.
|
||
HEARTS_ENFORCED = os.environ.get('HEARTS_ENFORCED', '0') == '1'
|
||
|
||
# تلاشِ ورودِ ادمین در ۱۵ دقیقه برای هر IP.
|
||
ADMIN_LOGIN_MAX_ATTEMPTS = 10
|
||
|
||
# -----------------------------------------------------------------------
|
||
# Payment security
|
||
# -----------------------------------------------------------------------
|
||
# POST /api/payment/orders باید فقط سرور-به-سرور (از subscription) صدا زده شود؛
|
||
# کلاینت باید هدر X-Internal-Key برابر همین مقدار بفرستد. بدون env در
|
||
# پروداکشن مقدار خالی میماند و HasInternalServiceKey صراحتاً رد میکند.
|
||
INTERNAL_SERVICE_KEY = os.environ.get(
|
||
'INTERNAL_SERVICE_KEY',
|
||
'dev-internal-service-key-change-me' if DEBUG else '',
|
||
)
|
||
|
||
|
||
# -----------------------------------------------------------------------
|
||
# 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')
|
||
# اختیاری: مدلِ جدا فقط برای پایههای دهم تا دوازدهم (حسابان/مثلثات). خالی یعنی
|
||
# همهی پایهها 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).
|
||
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')
|