سه کارِ بههمپیوسته که از یک کشف شروع شدند: راهحلهایی که vision از کتاب
درآورده گاهی غلطاند، و این خطا در بازیابی دیده نمیشود — فقط وقتی کسی به
`solution` اعتماد کند خودش را نشان میدهد.
۱) بازبینیِ راهحلها (rag_verify_solutions)
هر chunk دوباره حل میشود و با کتاب مقایسه. نتیجه سهحالته است، نه بله/خیر:
اجرای اول با بولی ۱۹٪ خطا گزارش کرد؛ خواندنِ دستیِ نمونهها نشان داد ۳۵٪ از
آنها مسئلههایی بودند که به شکلِ کتاب ارجاع میدادند و اصلاً قابلِ قضاوت
نبودند، و ۲۴٪ موردی که «۳/۱۰۴» را کسرِ ۳÷۱۰۴ خوانده بود نه عددِ ۳٫۱۰۴. با دو
حالت، «نمیدانم» ناچار به «غلط است» ترجمه میشد.
با سه حالت و پرامپتی که این دو تله را میشناسد: ۲٬۱۱۹ chunk بررسی شد،
۱٬۱۱۲ درست، ۱۲۳ غلط (۵٫۸٪)، ۸۸۱ غیرقابلِ قضاوت. آن ۸۸۱ رایگان برچسب خوردند
چون پیشفیلتر قبل از هر فراخوانی کنارشان گذاشت.
`solution` هرگز بازنویسی نمیشود — کتاب باید کتاب بماند. برچسب در payloadِ
Qdrant هم مینشیند، ولی با نامی که `retrieval._payload_text` نمیخواند: یک
نویسه هم به متنی که به مدل میرسد اضافه نشد.
نمونهی خطاهای واقعیِ کتاب که پیدا شد:
BMI = ۱۰۰/(۲٫۷۴)² کتاب: ۳۳٫۲ درست: ۱۳٫۳
۱۶ آبی + ۲۴ صورتی کتاب: «کل ۵۰» درست: ۴۰
بزرگترین عددِ ۴رقمیِ بیتکرار کتاب: ۸۵۳۲ درست: ۹۸۷۶
g(x) = x³ − ۲ کتاب: «نمایی» درست: چندجملهای
۲) سوالها فقط از راهحلهای تأییدشده
۱٬۱۹۹ سوال در content/seed/questions.json، همه از chunkهایی که برچسبِ
«درست» گرفتهاند. هر شش پایه برنامهی درسی دارند (۱٬۹۲۸ درس) — هشتم و دهم
تازه ساخته شدند.
۳) GET /api/nodes/{id}/quiz
واحدِ بازی در اپ «مبحث» است ولی سوالها به «درس» بستهاند، و درس اینجا
ریزترین دانه است: یک نوعِ مسئله، نه یک جلسه. نتیجهاش این بود که اپ برای یک
آزمونِ چهارسوالی باید دهها درخواست میزد. این اندپوینت از همهی درسهای مبحث
نمونه برمیدارد، حداکثر یکی از هر درس تا آزمون روی یک مهارت گیر نکند.
درهمریزی با بذرِ nodeId:userId پایدار است: رفرشِ صفحه همان آزمون را میدهد،
نه مجموعهی تازهای که پیشرفت را بیمعنا کند. `questionCount` جدا از
`requested` میآید تا مبحثِ کمسوال وانمود نکند کامل است.
──────────
اصلاحاتِ جانبی که سرِ راه لازم شدند:
• QDRANT_TIMEOUT_SECONDS: کلاینت بدونِ timeout ساخته میشد و پیشفرضِ ۵
ثانیهایِ کتابخانه ingestِ کتابِ هشتم را روی اولین فراخوانی کشت.
• پاکسازیِ نقاطِ نیمهنوشته: upsert با wait=True ممکن است نوشته باشد و فقط
جوابش تایماوت شده باشد؛ مسیرِ جبران فقط ردیفهای DB را پس میگرفت و هر
retry یک نسخهی تکراری در Qdrant جا میگذاشت (۸ مورد در پایه نهم، پاک شد).
• تشخیصِ سقفِ حساب: «insufficient user quota» با فاصله میآید نه زیرخط، و
محافظِ اول نمیگرفتش — یک اجرا ۷۷۰ فراخوانیِ محکومبهشکست زد.
• پرامپتِ vision حالا میخواهد محتوای شکل داخلِ متنِ مسئله نوشته شود. ۴۲٪ از
محتوای موجود به شکلی ارجاع میدهد که در متن نیست؛ مدل آن را دیده بود ولی
از او خواسته نشده بود بنویسدش. کتابهای موجود قبل از این قاعده وارد شدهاند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
658 lines
34 KiB
Python
658 lines
34 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
|
||
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', # فرانتِ مستقر
|
||
'http://localhost:3000', 'http://127.0.0.1:3000',
|
||
'http://localhost:5173', 'http://127.0.0.1:5173', # Vite/CRA محلی
|
||
]
|
||
CORS_ALLOWED_ORIGINS = _with_defaults('CORS_ALLOWED_ORIGINS', CORS_DEFAULT_ORIGINS)
|
||
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
|
||
|
||
# -----------------------------------------------------------------------
|
||
# 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ی که به کلاینت برمیگردد
|
||
# (PaymentRedirectView) و هم برای callback_url که به زرینپال میفرستیم لازم
|
||
# است.
|
||
#
|
||
# چرا پیشفرض دارد: خالی بودنش سرویس را نمیخواباند، فقط مسیرِ پرداخت را
|
||
# بیسروصدا میشکند — paymentUrl نسبی میشود و زرینپال callback خالی
|
||
# میگیرد. این دقیقاً همان دسته خطاست که فقط موقعِ اولین پرداختِ واقعی معلوم
|
||
# میشود. دامنهی خودمان از قبل معلوم است، پس حدس زدنی نیست.
|
||
# .strip() اول لازم است: کلید در .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')
|