Admin login (and any POST form) fails with "Origin checking failed" behind a proxy that terminates TLS, since Django had no trusted-origins list configured at all. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
516 lines
24 KiB
Python
516 lines
24 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'
|
||
|
||
# SECURITY WARNING: keep the secret key used in production secret!
|
||
# No hardcoded fallback when DEBUG=False — fails closed at startup instead.
|
||
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:
|
||
raise ImproperlyConfigured(
|
||
'DJANGO_SECRET_KEY environment variable is not set. '
|
||
'A predictable fallback secret key cannot be used when DEBUG=False.'
|
||
)
|
||
|
||
ALLOWED_HOSTS = [h for h in os.environ.get('DJANGO_ALLOWED_HOSTS', '').split(',') if h]
|
||
|
||
CSRF_TRUSTED_ORIGINS = [o for o in os.environ.get('CSRF_TRUSTED_ORIGINS', '').split(',') if o]
|
||
|
||
|
||
# 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',
|
||
|
||
# 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',
|
||
'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:
|
||
raise ImproperlyConfigured(
|
||
'CACHE_URL environment variable is not set. '
|
||
'A shared cache is required when DEBUG=False, since rate limits '
|
||
'(OTP throttles and the phone-based login limit) rely on a cache '
|
||
'shared across all workers/instances; LocMemCache cannot be used in production.'
|
||
)
|
||
|
||
|
||
# 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/'
|
||
|
||
# -----------------------------------------------------------------------
|
||
# 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',
|
||
'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',
|
||
},
|
||
}
|
||
|
||
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 که به زرینپال میفرستیم لازم
|
||
# است. بدونش این دو URL شکسته میمانند — سیگنالِ واضحی که هنوز ست نشده.
|
||
PUBLIC_BASE_URL = os.environ.get('PUBLIC_BASE_URL', '').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_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')
|