Files
MathApp/config/settings.py
Zareian ac1b1af398 open the API up to the frontend
سینیور خواست زودتر به فرانت‌اند وصل شویم تا از آنجا هم تست شود. این‌ها
سمتِ ما لازم بودند:

CORS با allowlist صریح (نه ALLOW_ALL): احرازِ هویتِ ما توکنِ Bearer در هدر
است، پس origin ناشناس نباید از مرورگرِ کاربر به API ما بزند.
CORS_ALLOW_CREDENTIALS عمداً خاموش می‌ماند — کوکی‌ای در کار نیست و روشن
کردنش فقط سطحِ حمله اضافه می‌کند. CorsMiddleware بالاتر از CommonMiddleware
گذاشته شد وگرنه ریدایرکتِ APPEND_SLASH و خطای ۴۰۰ هدرِ CORS نمی‌گیرند و در
مرورگر به‌شکلِ خطای مبهمِ شبکه دیده می‌شوند.

PATCH /api/user/profile اضافه شد؛ تا پیش از این هیچ راهی برای ثبتِ نامِ
کاربر نبود. GET /api/home بدونِ پارامتر حالا پایه‌ی ذخیره‌شده‌ی کاربر را
برمی‌گرداند و POST /api/ai/ask فقط با lessonId کار می‌کند — هر دو برای
اینکه فرانت لازم نباشد پایه را خودش نگه دارد. questionCount به
lessons/{id}/intro اضافه شد.

docs/frontend/api.ts و README.md کلاینتِ تایپ‌دار و راهنمای اتصال‌اند، از
روی OpenAPI واقعیِ همین سرویس تولید شده‌اند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:01:06 +03:30

568 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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]
# --- CORS -------------------------------------------------------------------
# فرانت‌اند (Vite/React) از یک origin دیگر صدا می‌زند، پس بدونِ این هدرها مرورگر
# هر درخواستِ /api/ را قبل از رسیدن به Django رد می‌کند.
#
# allowlist صریح است، نه CORS_ALLOW_ALL_ORIGINS: احرازِ هویتِ ما توکنِ Bearer در
# هدر است، پس origin ناشناس نباید بتواند از مرورگرِ کاربر به API ما بزند.
# CORS_ALLOW_CREDENTIALS هم عمداً خاموش می‌ماند — کوکی‌ای در کار نیست و روشن
# کردنش فقط سطحِ حمله اضافه می‌کند.
CORS_ALLOWED_ORIGINS = [
o for o in os.environ.get('CORS_ALLOWED_ORIGINS', '').split(',') if o
]
if DEBUG and not CORS_ALLOWED_ORIGINS:
# پورت‌های پیش‌فرضِ Vite/CRA برای توسعه‌ی محلی.
CORS_ALLOWED_ORIGINS = [
'http://localhost:3000', 'http://127.0.0.1:3000',
'http://localhost:5173', 'http://127.0.0.1:5173',
]
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:
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/'
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',
'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')