- profile publicId ("User482917", accounts/public_id.py); admin shows and searches it
- plans API reports aiDailyQuota per plan and freeAiDailyQuota; migration 0007 sets the
temporary prices (monthly 99,000 / yearly 990,000 toman, 5 tutor questions a day) unless
an admin already edited the yearly plan
- tutor tone follows the learner's age (18+ gets an adult tone at any grade); notation
still follows the grade's book; answer cache keyed by tone (prompt tutor-v12-age-tone)
- POST /api/user/delete: phone users confirm with a fresh SMS code (same checks as login,
now shared in verify_phone_otp), guests type "حذف"; payment records are kept, demo
accounts refused
- delete_users --skip-paid
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
911 lines
54 KiB
Python
911 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',
|
||
|
||
# لاگِ عمومیِ تغییرِ وضعیت، مستقل از بقیهی اپها
|
||
'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',
|
||
'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'
|
||
'دو راه برای گرفتنِ توکن هست:\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': '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')
|
||
# سقفِ سوالِ روزانه از دستیار برای کاربرِ بدونِ اشتراک (مشترکان: Plan.ai_daily_quota).
|
||
# ۳ پیشنهادِ docs/ai-cost-and-guardrails.md است؛ تا ۲۰۲۶-۱۰-۰۱ برای همه ۱۵ بود.
|
||
AI_DAILY_QUOTA_FREE = int(os.environ.get('AI_DAILY_QUOTA_FREE', '3'))
|
||
# اختیاری: مدلِ جدا فقط برای پایههای دهم تا دوازدهم (حسابان/مثلثات). خالی یعنی
|
||
# همهی پایهها 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')
|