Content ------- Every node in grades 8, 9, 10, 11 and 12 now holds a full four-question quiz — 121 more questions, filling the two حسابان دوازدهم nodes in فصل ۵: کاربردهای مشتق that were empty and the two ریاضی هشتم nodes that were short. The repo's own seed guard was already failing on those two empty nodes; it passes now. Grades 3, 6 and 7 are the other team's books and were left alone. 39 of their 145 nodes still hold fewer than four questions, but none is empty, so every node on the map still plays. An audit over all 1,593 questions in grades 8–12 found no structural fault: no question with the wrong number of options, no question with zero or two correct answers, no empty or duplicated option, no leftover LaTeX, and no two identical questions inside one lesson. Contract -------- Three endpoints were missing or malformed in the OpenAPI schema, which matters more than it looks: that schema is the contract the frontend generates its client from, so a view missing there is a view the other repo cannot call. - NotificationMarkReadView was dropped from the schema entirely, because spectacular could not guess a request body for its PATCH and gives up on the whole view rather than the one field. It declares request=None. - PaymentRedirectView was dropped for the same reason; it answers with a 302 and no JSON body, which it now says. - /api/health and /api/health/ collided on operationId and were emitted as health_retrieve and health_retrieve_2. The slash variant exists only for a monitor that calls it that way, so it is excluded from the schema. `spectacular` now runs clean — no errors, no warnings. Tidying ------- Condensed the two longest comment blocks (18 and 14 lines) in rag_verify_solutions and settings, keeping the measurement and the rule and dropping the retelling. README gains the bulk question-authoring workflow; the playbook's subagent section now points at the real tool instead of describing the steps by hand. 458 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
669 lines
34 KiB
Python
669 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', # فرانتِ مستقر
|
||
]
|
||
CORS_ALLOWED_ORIGINS = _with_defaults('CORS_ALLOWED_ORIGINS', CORS_DEFAULT_ORIGINS)
|
||
|
||
# ⚠️ پورتِ سرورِ توسعه را نباید فهرست کرد. فهرستِ ثابتِ قبلی (۳۰۰۰ و ۵۱۷۳) روزی
|
||
# شکست که Vite دید ۵۱۷۳ اشغال است و بیسروصدا رفت روی ۵۱۷۴ — رفتارِ پیشفرضِ
|
||
# خودش. آنوقت فرانتکار خطای CORS گرفت، از سمتِ سرور هیچ ردی در هیچ لاگی
|
||
# نبود، و چون همزمان با تغییراتِ ما شد، به اشتباه گردنِ بکاند افتاد.
|
||
# هر بار که این تکرار شود یعنی یک ویرایشِ .env و یک ریاستارتِ پروداکشن برای
|
||
# چیزی که اصلاً به پروداکشن ربطی ندارد.
|
||
#
|
||
# پس بهجای شمردنِ پورتها، الگوی «هر پورتی روی خودِ ماشینِ توسعهدهنده» را
|
||
# میپذیریم. این سطحِ حمله اضافه نمیکند: احرازِ هویتِ ما توکنِ Bearer در هدر
|
||
# است و `CORS_ALLOW_CREDENTIALS` خاموش، پس صفحهای که روی localhostِ یک مهاجم
|
||
# باز است هیچ اعتبارنامهای از مرورگرِ قربانی به دست نمیآورد؛ CORS هم اصلاً
|
||
# سدِ سرور-به-سرور نیست.
|
||
CORS_ALLOWED_ORIGIN_REGEXES = [
|
||
r'^http://localhost:\d+$',
|
||
r'^http://127\.0\.0\.1:\d+$',
|
||
]
|
||
CORS_ALLOW_CREDENTIALS = False
|
||
# فقط مسیرهای API؛ /admin/ و فایلهای استاتیک لازم نیست از origin دیگر خوانده شوند.
|
||
CORS_URLS_REGEX = r'^/api/.*$'
|
||
# Idempotency-Key و X-Internal-Key هدرهای خودماناند و در فهرستِ پیشفرض نیستند.
|
||
CORS_ALLOW_HEADERS = (
|
||
'accept', 'accept-encoding', 'authorization', 'content-type', 'dnt',
|
||
'origin', 'user-agent', 'x-csrftoken', 'x-requested-with',
|
||
'idempotency-key', 'x-internal-key',
|
||
)
|
||
|
||
|
||
# Application definition
|
||
|
||
INSTALLED_APPS = [
|
||
'django.contrib.admin',
|
||
'django.contrib.auth',
|
||
'django.contrib.contenttypes',
|
||
'django.contrib.sessions',
|
||
'django.contrib.messages',
|
||
'django.contrib.staticfiles',
|
||
|
||
# 3rd party
|
||
'rest_framework',
|
||
'drf_spectacular',
|
||
'corsheaders',
|
||
|
||
# Tadrisino apps (فاز اول - ۴ از ۱۱ اپ)
|
||
'accounts',
|
||
'content',
|
||
'progress',
|
||
'ai_tutor',
|
||
|
||
# Tadrisino apps (فاز دوم - بخش ۱ از ۳: subscription, payment, discount)
|
||
'subscription',
|
||
'payment',
|
||
'discount',
|
||
|
||
# Tadrisino apps (فاز دوم - بخش ۲: notifications)
|
||
'notifications',
|
||
|
||
# Tadrisino apps (فاز دوم - بخش ۳: affiliate)
|
||
'affiliate',
|
||
|
||
# Tadrisino apps (فاز دوم - بخش ۴: support)
|
||
'support',
|
||
|
||
# Tadrisino apps (فاز دوم - بخش ۵: family)
|
||
'family',
|
||
|
||
# Tadrisino apps (سیستم پرداخت بازار + آفر/تخفیف + لاگ عمومی تغییر Status)
|
||
'statuslog',
|
||
|
||
# Tadrisino apps (مدلِ مینیمالِ ذخیرهسازیِ کمپینهای پیامی، بدونِ منطقِ ارسال)
|
||
'campaigns',
|
||
|
||
# Tadrisino apps (لایهی RAG: ذخیرهی chunkهای درسی + بازیابی از Qdrant،
|
||
# مصرفکنندهش ai_tutor.generate_tutor_answer است)
|
||
'rag',
|
||
]
|
||
|
||
MIDDLEWARE = [
|
||
'django.middleware.security.SecurityMiddleware',
|
||
# ⚠️ CorsMiddleware باید تا حدِ ممکن بالا و حتماً قبل از CommonMiddleware
|
||
# باشد، وگرنه پاسخهایی که زودتر برمیگردند (ریدایرکتِ APPEND_SLASH، خطای
|
||
# ۴۰۰) هدرِ CORS نمیگیرند و در مرورگر بهشکلِ خطای مبهمِ شبکه دیده میشوند.
|
||
'corsheaders.middleware.CorsMiddleware',
|
||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||
'django.middleware.common.CommonMiddleware',
|
||
'django.middleware.csrf.CsrfViewMiddleware',
|
||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||
'django.contrib.messages.middleware.MessageMiddleware',
|
||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||
# ⚠️ افزوده شده: لاگِ سبکِ هر درخواستِ API (متد/مسیر/وضعیت/مدت/کاربر)؛
|
||
# عمداً آخرِ لیست قرار گرفته تا نزدیکترین میانافزار به خودِ view باشه
|
||
# (مدتزمانِ دقیقتر، و request.user که توسطِ DRF authentication در
|
||
# حینِ پردازشِ view ست میشه، در دسترس باشه).
|
||
'config.middleware.APIRequestLoggingMiddleware',
|
||
]
|
||
|
||
ROOT_URLCONF = 'config.urls'
|
||
|
||
TEMPLATES = [
|
||
{
|
||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||
'DIRS': [],
|
||
'APP_DIRS': True,
|
||
'OPTIONS': {
|
||
'context_processors': [
|
||
'django.template.context_processors.request',
|
||
'django.contrib.auth.context_processors.auth',
|
||
'django.contrib.messages.context_processors.messages',
|
||
],
|
||
},
|
||
},
|
||
]
|
||
|
||
WSGI_APPLICATION = 'config.wsgi.application'
|
||
|
||
|
||
# Database
|
||
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
||
|
||
DATABASES = {
|
||
'default': {
|
||
'ENGINE': 'django.db.backends.sqlite3',
|
||
'NAME': BASE_DIR / 'db.sqlite3',
|
||
# timeout بالاتر از پیشفرض: وقتی دو کانکشنِ همزمان (مثلِ تستِ
|
||
# concurrency واقعیِ campaigns) به قفلِ نوشتنِ SQLite برخورد
|
||
# میکنن، این باعث میشه کانکشنِ منتظر تا ۲۰ ثانیه صبر کنه بهجای
|
||
# اینکه فوراً OperationalError('database table is locked') بده.
|
||
'OPTIONS': {
|
||
'timeout': 20,
|
||
},
|
||
# کانکشنِ DB بهازای هر request باز/بسته نشه؛ تا این تعداد ثانیه
|
||
# persistent بمونه. با SQLite بیاثرِ عملی ولی بیضرره؛ بعد از
|
||
# مهاجرت به Postgres جلوی هزینهی TCP+auth در هر request رو میگیره.
|
||
'CONN_MAX_AGE': int(os.environ.get('DB_CONN_MAX_AGE', '60')),
|
||
}
|
||
}
|
||
|
||
# In production a shared cache is required for rate limiting to work across
|
||
# workers/instances; falls back to LocMemCache only when DEBUG=True.
|
||
CACHE_URL = os.environ.get('CACHE_URL', '')
|
||
|
||
if CACHE_URL:
|
||
CACHES = {
|
||
'default': {
|
||
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
|
||
'LOCATION': CACHE_URL,
|
||
}
|
||
}
|
||
elif DEBUG:
|
||
CACHES = {
|
||
'default': {
|
||
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
|
||
}
|
||
}
|
||
else:
|
||
# بدونِ Redis محدودیتِ نرخ بینِ workerها مشترک نیست — ضعف است، ولی دلیلِ
|
||
# خوبی برای بالا نیامدنِ کلِ سرویس نیست. قبلاً اینجا استثنا پرتاب میشد و
|
||
# نتیجهاش از بیرون فقط 502 بود، بدونِ هیچ سرنخی. حالا بالا میآید و
|
||
# /api/health صریحاً هشدار میدهد.
|
||
CACHES = {
|
||
'default': {
|
||
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
|
||
}
|
||
}
|
||
|
||
|
||
# Password validation
|
||
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
|
||
|
||
AUTH_PASSWORD_VALIDATORS = [
|
||
{
|
||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||
},
|
||
{
|
||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||
},
|
||
{
|
||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||
},
|
||
{
|
||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||
},
|
||
]
|
||
|
||
|
||
# Internationalization
|
||
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
||
|
||
LANGUAGE_CODE = 'fa-ir'
|
||
|
||
TIME_ZONE = 'Asia/Tehran'
|
||
|
||
USE_I18N = True
|
||
|
||
USE_TZ = True
|
||
|
||
|
||
# Static files (CSS, JavaScript, Images)
|
||
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
||
|
||
STATIC_URL = 'static/'
|
||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||
|
||
STORAGES = {
|
||
'default': {
|
||
'BACKEND': 'django.core.files.storage.FileSystemStorage',
|
||
},
|
||
'staticfiles': {
|
||
# عمداً نسخهی Manifest نیست: اون نسخه اگه staticfiles.json نباشه
|
||
# (یعنی هر وقت collectstatic اجرا نشده) موقعِ رندرِ هر صفحهی ادمین
|
||
# ValueError پرت میکنه. این یعنی تستها میشکستن — جنگو در تست همیشه
|
||
# DEBUG=False میکنه، پس WHITENOISE_USE_FINDERS هم خاموشه — و مهمتر،
|
||
# روزی که روی سرور DEBUG=0 بشه و collectstatic فراموش بشه، پنلِ ادمین
|
||
# کاملاً میخوابید. این نسخه فقط فشردهسازی میکنه و هیچوقت fail نمیشه؛
|
||
# بهاش از دست دادنِ hash در نامِ فایله که برای پنلِ ادمین ناچیزه.
|
||
'BACKEND': 'whitenoise.storage.CompressedStaticFilesStorage',
|
||
},
|
||
}
|
||
# با DEBUG=1، از finders مستقیم سرو میکنه؛ نیازی به collectstatic نیست.
|
||
WHITENOISE_USE_FINDERS = DEBUG
|
||
|
||
# -----------------------------------------------------------------------
|
||
# Tadrisino — تنظیمات پروژه
|
||
# -----------------------------------------------------------------------
|
||
|
||
# این پروژه از مدل User جنگو (auth) استفاده نمیکنه؛ طبق طراحی سینیورت
|
||
# accounts.User یک مدل مستقل (business model) هست، نه AUTH_USER_MODEL.
|
||
# احراز هویت با یک TokenAuthentication سفارشی (accounts/authentication.py)
|
||
# روی همین مدل accounts.User انجام میشه؛ نه سشن/کوکی جنگو.
|
||
|
||
REST_FRAMEWORK = {
|
||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||
'accounts.authentication.TadrisinoTokenAuthentication',
|
||
],
|
||
'DEFAULT_PERMISSION_CLASSES': [
|
||
'rest_framework.permissions.AllowAny',
|
||
],
|
||
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
||
# ⚠️ افزوده شده: بدونِ این، exceptionِ ناشناخته (غیر از APIException/
|
||
# Http404/PermissionDenied) بهجای پاسخِ JSON، صفحهی HTML عمومیِ ۵۰۰
|
||
# جنگو رو برمیگردونه (نگاه کنید به config/exceptions.py).
|
||
'EXCEPTION_HANDLER': 'config.exceptions.custom_exception_handler',
|
||
# BrowsableAPIRenderer فقط در dev؛ در پروداکشن هم overhead داره هم یک
|
||
# مرورگرِ HTMLِ API رو در معرض دید میذاره.
|
||
'DEFAULT_RENDERER_CLASSES': [
|
||
'rest_framework.renderers.JSONRenderer',
|
||
*(['rest_framework.renderers.BrowsableAPIRenderer'] if DEBUG else []),
|
||
],
|
||
'DEFAULT_THROTTLE_CLASSES': [
|
||
'rest_framework.throttling.ScopedRateThrottle',
|
||
],
|
||
# بدونِ NUM_PROXIES، DRF هدرِ X-Forwarded-For رو بدونچک بهعنوانِ IP
|
||
# کلاینت قبول میکنه؛ یعنی هر کلاینت میتونست با XFF دلخواه، bucket
|
||
# تازه از GuestAware*Throttleها بگیره (bypassِ چرخشِ حساب مهمان). این
|
||
# مقدار باید دقیقاً با تعدادِ لایهی پراکسی/LB جلوی اپ یکی باشه (مثلاً
|
||
# Cloudflare+Nginx=۲)، وگرنه IP واقعی اشتباه خونده میشه.
|
||
'NUM_PROXIES': int(os.environ.get('NUM_PROXIES', '1')),
|
||
'DEFAULT_THROTTLE_RATES': {
|
||
'otp_request': '5/hour',
|
||
'otp_login': '10/hour',
|
||
'guest_session': '20/hour',
|
||
'bazaar_verify': '10/hour',
|
||
'ai_ask': '20/hour',
|
||
'rag_ingest': '30/hour',
|
||
# سقفِ ارزانِ خواندن روی endpointهای پرترافیکِ GET (home/leaderboard/
|
||
# summary/notifications). فقط جلوی کلاینتِ خراب/اسکرپر رو میگیره؛
|
||
# به UX عادی دست نمیزنه.
|
||
'read': os.environ.get('READ_THROTTLE_RATE', '120/min'),
|
||
'purchase': '10/hour',
|
||
'token_refresh': '30/hour',
|
||
'question_answer': '60/min',
|
||
'question_report': '20/hour',
|
||
'lesson_complete': '10/hour',
|
||
'support_create': '30/hour',
|
||
'offer_redeem': '10/hour',
|
||
'discount_validate': '20/hour',
|
||
'discount_redeem': '10/hour',
|
||
'family_invite_accept': '10/hour',
|
||
'family_invite_create': '5/hour',
|
||
'user_profile_write': '60/hour',
|
||
'affiliate_track': '10/hour',
|
||
'payment_webhook': '60/hour',
|
||
'payment_pay': '30/hour',
|
||
# /api/health برای پایشِ بیرونی است؛ باید مکرر قابلِ صدا زدن باشد
|
||
# ولی نه آنقدر که خودش به ابزارِ فشار تبدیل شود.
|
||
'health': os.environ.get('HEALTH_THROTTLE_RATE', '60/min'),
|
||
},
|
||
}
|
||
|
||
SPECTACULAR_SETTINGS = {
|
||
'TITLE': 'Tadrisino API',
|
||
'DESCRIPTION': (
|
||
'بکاند اپلیکیشن آموزشی Tadrisino — احراز هویت با OTP، محتوای درسی '
|
||
'و پیشرفت کاربر، دستیار هوش مصنوعی، اشتراک و پرداخت، تخفیف، '
|
||
'اعلانها، همکاری در فروش، پشتیبانی و حساب خانواده.'
|
||
),
|
||
'VERSION': '1.0.0',
|
||
'SERVE_INCLUDE_SCHEMA': False,
|
||
'COMPONENT_SPLIT_REQUEST': True,
|
||
'SWAGGER_UI_SETTINGS': {
|
||
'deepLinking': True,
|
||
'displayRequestDuration': True,
|
||
'defaultModelsExpandDepth': -1,
|
||
'docExpansion': 'list',
|
||
'filter': True,
|
||
'persistAuthorization': True,
|
||
},
|
||
}
|
||
|
||
# اعتبار توکن دسترسی (ثانیه) و رفرشتوکن (روز)
|
||
ACCESS_TOKEN_TTL_SECONDS = 3600
|
||
REFRESH_TOKEN_TTL_DAYS = 30
|
||
|
||
# کشِ کوتاهِ ردیفِ کاربر در TadrisinoTokenAuthentication تا هر request
|
||
# authenticated یک SELECT روی accounts_user نزنه. ۰ = خاموش (رفتارِ فعلی، و
|
||
# پیشفرض تا وقتی Redis در پروداکشن هست). توصیهی پروداکشن: ۳۰. با هر save/
|
||
# delete روی User، کلیدِ کاربر باطل میشه (accounts/receivers.py)، پس تنها
|
||
# پنجرهی کهنگی برای تغییری که از مسیرِ دیگری (مثلاً shell/DB) اعمال شده،
|
||
# همین چند ثانیهست.
|
||
AUTH_USER_CACHE_TTL = int(os.environ.get('AUTH_USER_CACHE_TTL', '0'))
|
||
|
||
# سقفِ گیرندهی اکشنِ ادمینِ send_sms_to_selected؛ این اکشن synchronous توی
|
||
# همون requestه (بدون صفِ پسزمینه)، پس این سقف جلویِ تایماوت/overloadِ
|
||
# provider رو میگیره — بیشتر از سقف یعنی هیچ پیامکی ارسال نمیشه.
|
||
ADMIN_SMS_MAX_RECIPIENTS = int(os.environ.get('ADMIN_SMS_MAX_RECIPIENTS', 200))
|
||
|
||
# محدودیت تلاش ورود بر اساس شماره تلفن، مستقل از throttle IP-محورِ otp_login.
|
||
OTP_LOGIN_PHONE_MAX_ATTEMPTS = 10
|
||
OTP_LOGIN_PHONE_WINDOW_SECONDS = 3600
|
||
|
||
# محدودیتِ صدورِ/ارسالِ دوبارهِ OTP بر اساسِ شمارهتلفن، مستقل از throttle
|
||
# IP-محورِ otp_request؛ جلوی flood کردنِ پیامک به یک شماره از طریق چند IP
|
||
# مختلف رو میگیره (فقط تعدادِ درخواست رو محدود میکنه، نه شمارهی IP رو).
|
||
OTP_REQUEST_PHONE_MAX_ATTEMPTS = 5
|
||
OTP_REQUEST_PHONE_WINDOW_SECONDS = 3600
|
||
|
||
# حداقل فاصله بین دو درخواستِ OTP برای همون شماره؛ مستقل از سقفِ ساعتیِ بالا
|
||
# (که جلویِ burst رو نمیگیره، مثلاً ۵ تلاش در کمتر از یک ثانیه). این مقدار
|
||
# باید با countdownِ فرانت یکی باشه — countdown صرفاً UX است، نه مرجعِ امنیتی.
|
||
OTP_REQUEST_PHONE_COOLDOWN_SECONDS = 120
|
||
|
||
# در محیط dev، این کد OTP همیشه معتبر است تا بدون SMS واقعی تست شود.
|
||
# فقط وقتی DEBUG=True مقدار دارد؛ در پروداکشن None است.
|
||
DEV_MASTER_OTP = '0000' if DEBUG else None
|
||
|
||
# -----------------------------------------------------------------------
|
||
# OTP SMS delivery (accounts/services.py: send_otp_sms)
|
||
# -----------------------------------------------------------------------
|
||
# در پروداکشن send_otp_sms از طریق کاوهنگار پیامک واقعی میفرستد.
|
||
# بدون تنظیم این env، ارسال باید صراحتاً خطا بدهد، نه silently موفق باشد.
|
||
KAVENEGAR_API_KEY = os.environ.get('KAVENEGAR_API_KEY', '')
|
||
KAVENEGAR_SENDER = os.environ.get('KAVENEGAR_SENDER', '')
|
||
OTP_SMS_TEMPLATE = os.environ.get('OTP_SMS_TEMPLATE', 'کد ورود شما به تدریسینو: {code}')
|
||
|
||
# -----------------------------------------------------------------------
|
||
# Content progress (NodeProgress status)
|
||
# -----------------------------------------------------------------------
|
||
# بعد از چند روز بدون فعالیت روی یک Node که status اون in_progress هست،
|
||
# management command مربوطه (content/management/commands/
|
||
# mark_abandoned_node_progress.py) اونو abandoned علامت میزنه. ۱۴ روز
|
||
# (نه ۳-۷ روزِ رایج در اپهای مشابه) عمداً انتخاب شده چون قطعی/محدودیت
|
||
# اینترنت و دیتای موبایل در ایران باعث وقفههای چندروزهی غیرارادی
|
||
# میشه که نباید فوراً بهعنوان «رهاکردن درس» حساب بشه.
|
||
NODE_PROGRESS_ABANDON_DAYS = 14
|
||
|
||
# -----------------------------------------------------------------------
|
||
# Payment security
|
||
# -----------------------------------------------------------------------
|
||
# POST /api/payment/orders باید فقط سرور-به-سرور (از subscription) صدا زده شود؛
|
||
# کلاینت باید هدر X-Internal-Key برابر همین مقدار بفرستد. بدون env در
|
||
# پروداکشن مقدار خالی میماند و HasInternalServiceKey صراحتاً رد میکند.
|
||
INTERNAL_SERVICE_KEY = os.environ.get(
|
||
'INTERNAL_SERVICE_KEY',
|
||
'dev-internal-service-key-change-me' if DEBUG else '',
|
||
)
|
||
|
||
# POST /api/payment/webhook باید از درگاه پرداخت بیاید، نه یک کلاینت دلخواه.
|
||
# فعلاً یک shared-secret ساده (X-Webhook-Secret) استفاده میشود؛ در اتصال
|
||
# واقعی باید با بررسی امضای HMAC درگاه جایگزین شود.
|
||
PAYMENT_WEBHOOK_SECRET = os.environ.get(
|
||
'PAYMENT_WEBHOOK_SECRET',
|
||
'dev-webhook-secret-change-me' if DEBUG else '',
|
||
)
|
||
|
||
# -----------------------------------------------------------------------
|
||
# Bazaar In-App Billing (پرداخت درونبرنامهای کافهبازار — REST API v2)
|
||
# -----------------------------------------------------------------------
|
||
# صحتسنجی OAuth2 سرور-به-سروره: REFRESH_TOKEN یکبار دستی از پنل devapi
|
||
# گرفته میشه، بعد بکاند با همون REFRESH_TOKEN، ACCESS_TOKEN تازه میگیره.
|
||
# قبل از اتصال واقعی، مسیرِ endpointها رو از پنل devapi تایید کنید — بازار
|
||
# این روش رو قبلاً تغییر داده.
|
||
BAZAAR_PACKAGE_NAME = os.environ.get('BAZAAR_PACKAGE_NAME', 'ir.tadrisino.app')
|
||
BAZAAR_CLIENT_ID = os.environ.get('BAZAAR_CLIENT_ID', '')
|
||
BAZAAR_CLIENT_SECRET = os.environ.get('BAZAAR_CLIENT_SECRET', '')
|
||
BAZAAR_REFRESH_TOKEN = os.environ.get('BAZAAR_REFRESH_TOKEN', '')
|
||
BAZAAR_API_BASE = os.environ.get('BAZAAR_API_BASE', 'https://pardakht.cafebazaar.ir/devapi/v2')
|
||
|
||
# -----------------------------------------------------------------------
|
||
# Zarinpal (درگاهِ اصلیِ پرداختِ ما — Payment API v4)
|
||
# -----------------------------------------------------------------------
|
||
# آدرسِ عمومیِ بکاند: هم paymentUrlی که به کلاینت برمیگردد از آن ساخته
|
||
# میشود، هم callback_urlی که به زرینپال میرود.
|
||
#
|
||
# پیشفرض دارد چون خالی بودنش سرویس را نمیخواباند، فقط مسیرِ پرداخت را
|
||
# بیصدا میشکند و تا اولین پرداختِ واقعی معلوم نمیشود. `.strip() or` هم
|
||
# لازم است: کلید در .env سرور هست ولی خالی، و os.environ.get فقط وقتی کلید
|
||
# *نباشد* پیشفرض میدهد.
|
||
PUBLIC_BASE_URL = (
|
||
os.environ.get('PUBLIC_BASE_URL', '').strip() or 'https://api.math.tadrisino2.ir'
|
||
).rstrip('/')
|
||
|
||
ZARINPAL_MERCHANT_ID = os.environ.get('ZARINPAL_MERCHANT_ID', '')
|
||
ZARINPAL_SANDBOX = os.environ.get('ZARINPAL_SANDBOX') == '1'
|
||
ZARINPAL_API_BASE = os.environ.get(
|
||
'ZARINPAL_API_BASE',
|
||
'https://sandbox.zarinpal.com/pg/v4/payment' if ZARINPAL_SANDBOX
|
||
else 'https://api.zarinpal.com/pg/v4/payment',
|
||
)
|
||
ZARINPAL_STARTPAY_BASE = os.environ.get(
|
||
'ZARINPAL_STARTPAY_BASE',
|
||
'https://sandbox.zarinpal.com/pg/StartPay' if ZARINPAL_SANDBOX
|
||
else 'https://www.zarinpal.com/pg/StartPay',
|
||
)
|
||
ZARINPAL_CALLBACK_URL = os.environ.get(
|
||
'ZARINPAL_CALLBACK_URL',
|
||
f'{PUBLIC_BASE_URL}/api/payment/zarinpal/callback' if PUBLIC_BASE_URL else '',
|
||
)
|
||
# اختیاری: بعدِ verify، مرورگر/کلاینت را به این URL (مثلاً یک deep link اپ)
|
||
# با ?orderId=...&status=... ریدایرکت میکنیم. اگه خالی باشه، همونجا یک
|
||
# پاسخِ JSON ساده برمیگرده.
|
||
ZARINPAL_REDIRECT_URL = os.environ.get('ZARINPAL_REDIRECT_URL', '')
|
||
|
||
# -----------------------------------------------------------------------
|
||
# RAG / AI provider (اپ rag + ai_tutor.generate_tutor_answer)
|
||
# -----------------------------------------------------------------------
|
||
# provider همون OpenAI است ولی از طریقِ پروکسیِ ایرانیِ GapGPT (base_url).
|
||
# بدونِ OPENAI_API_KEY و QDRANT_API_KEY، rag_is_configured() برابرِ False
|
||
# میشه و generate_tutor_answer به همون پاسخِ stubِ قبلی برمیگرده (پس
|
||
# تستهای موجود و اجرای بدونِ کلید دستنخورده کار میکنن).
|
||
OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY', '')
|
||
OPENAI_BASE_URL = os.environ.get('OPENAI_BASE_URL', 'https://api.gapgpt.app/v1')
|
||
OPENAI_CHAT_MODEL = os.environ.get('OPENAI_CHAT_MODEL', 'gpt-4o-mini')
|
||
OPENAI_VISION_MODEL = os.environ.get('OPENAI_VISION_MODEL', 'gpt-4o')
|
||
# نباید تغییر کنه مگر اینکه collectionهای Qdrant هم با همون مدلِ جدید بازساخته بشن.
|
||
OPENAI_EMBEDDING_MODEL = os.environ.get('OPENAI_EMBEDDING_MODEL', 'text-embedding-3-small')
|
||
OPENAI_EMBEDDING_DIM = int(os.environ.get('OPENAI_EMBEDDING_DIM', '1536'))
|
||
OPENAI_TIMEOUT_SECONDS = int(os.environ.get('OPENAI_TIMEOUT_SECONDS', '60'))
|
||
|
||
QDRANT_URL = os.environ.get('QDRANT_URL', 'https://qdrant.tadrisino2.ir:443')
|
||
# پیشفرضِ خودِ qdrant-client پنج ثانیه است و برای این سرور کم است: ingestِ
|
||
# کتابِ هشتم روی همان اولین فراخوانی (ساختِ collection) افتاد و صفر صفحه رفت.
|
||
# سی ثانیه در مسیرِ ingest که دقیقهها طول میکشد هزینهای ندارد، و جلوی
|
||
# مردنِ یک اجرای طولانی بابتِ یک کندیِ لحظهای را میگیرد.
|
||
QDRANT_TIMEOUT_SECONDS = int(os.environ.get('QDRANT_TIMEOUT_SECONDS', '30'))
|
||
QDRANT_API_KEY = os.environ.get('QDRANT_API_KEY', '')
|
||
|
||
# نامِ collection در دادهٔ تیمِ دیگه = f"{subject} {grade}".lower() . کلیدِ
|
||
# فیلترِ درس خودکار انتخاب میشه (rag/services/known_collections.py): ریاضی →
|
||
# `chapter` ، بقیه → `lesson_number`.
|
||
RAG_COLLECTION_NAME_TEMPLATE = os.environ.get('RAG_COLLECTION_NAME_TEMPLATE', '{subject} {grade}')
|
||
# نگاشتِ اختیاریِ gradeId → عنوانِ دقیقِ گرید همانطور که در نامِ collection
|
||
# تیمِ دیگه اومده (مثلاً {6: 'ششم دبستان'})؛ اگه خالی باشه از content.Grade.title
|
||
# استفاده میشه. کاربر قبلاً با نامِ نادرستِ گرید به مشکل خورده بود.
|
||
RAG_GRADE_TITLE_OVERRIDES = {}
|
||
RAG_RETRIEVAL_TOP_K = int(os.environ.get('RAG_RETRIEVAL_TOP_K', '4'))
|
||
RAG_FORCE_MOCK = os.environ.get('RAG_FORCE_MOCK', '') == '1'
|
||
|
||
# کشِ پاسخِ نهایی برای سوالِ عیناً تکراری روی همون (collection + فصل + متنِ
|
||
# سوال). فقط embedding/جستجو/تولید رو رد میکنه؛ کوتا/idempotency/ثبتِ پیام
|
||
# مثلِ همیشه اجرا میشن. ۰ یعنی خاموش. TTL کوتاه چون دادهٔ Qdrant ممکنه
|
||
# بهروز بشه.
|
||
RAG_ANSWER_CACHE_TTL = int(os.environ.get('RAG_ANSWER_CACHE_TTL', '1800'))
|
||
|
||
# در اجرای تستها همیشه RAG خاموش است تا هیچ تستی به OpenAI/Qdrant واقعی
|
||
# نزند (سرعت + پایداری). تستهایی که مسیرِ RAG را میآزمایند خودشان با
|
||
# override_settings + mockِ کلاینتها این را برمیگردانند (rag/tests.py).
|
||
import sys as _sys
|
||
|
||
if 'test' in _sys.argv:
|
||
RAG_FORCE_MOCK = True
|
||
RAG_ANSWER_CACHE_TTL = 0
|
||
|
||
|
||
# Default primary key field type
|
||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||
|
||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||
|
||
# -----------------------------------------------------------------------
|
||
# Logging — بدونِ یک LOGGING صریح، loggerهای موجودِ اپ (config.middleware،
|
||
# accounts.services، ...) فقط به rootِ پیشفرض میرسیدن که برای INFO عملاً
|
||
# خروجیای نداره. خروجی: یک JSON در هر خط روی stdout، سازگار با
|
||
# Promtail/Filebeat برای جمعآوریِ بعدی توسطِ Loki/ELK.
|
||
# -----------------------------------------------------------------------
|
||
LOGGING = {
|
||
'version': 1,
|
||
'disable_existing_loggers': False,
|
||
'formatters': {
|
||
'json': {
|
||
'()': 'config.log_formatters.JSONLogFormatter',
|
||
},
|
||
},
|
||
'handlers': {
|
||
'console': {
|
||
'class': 'logging.StreamHandler',
|
||
'formatter': 'json',
|
||
},
|
||
},
|
||
'root': {
|
||
'handlers': ['console'],
|
||
'level': 'INFO',
|
||
},
|
||
}
|
||
|
||
# وقتی LOG_DIR ست باشه (فقط داخلِ docker-compose.yml، روی ولومِ مشترک با
|
||
# promtail)، همون خطوطِ JSON علاوه بر stdout در یک فایل هم نوشته میشن تا
|
||
# Promtail بتونه tail کنه. بدونش (اجرای عادی خارج از Docker)، فقط stdout.
|
||
LOG_DIR = os.environ.get('LOG_DIR', '')
|
||
|
||
if LOG_DIR:
|
||
os.makedirs(LOG_DIR, exist_ok=True)
|
||
LOGGING['handlers']['file'] = {
|
||
'class': 'logging.handlers.RotatingFileHandler',
|
||
'filename': os.path.join(LOG_DIR, 'app.log'),
|
||
'maxBytes': 10 * 1024 * 1024, # 10MB در هر فایل، قبل از rotate
|
||
'backupCount': 5,
|
||
'formatter': 'json',
|
||
}
|
||
LOGGING['root']['handlers'].append('file')
|