Files
MathApp/config/settings.py

441 lines
21 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
# 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]
# 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',
]
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,
},
}
}
# 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',
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
'rest_framework.renderers.BrowsableAPIRenderer',
],
'DEFAULT_THROTTLE_CLASSES': [
'rest_framework.throttling.ScopedRateThrottle',
],
# ⚠️ اضافه شده (امنیتی): بدونِ NUM_PROXIES، DRF (SimpleRateThrottle.get_ident)
# هدرِ X-Forwarded-For رو - اگه در درخواست وجود داشته باشه - عیناً و
# بدون‌چک به‌عنوانِ IP کلاینت قبول می‌کنه؛ یعنی هر کلاینتِ ناشناس می‌تونست
# با فرستادنِ یک X-Forwarded-For دلخواه/تصادفی در هر درخواست، برای
# هر GuestAware*Throttle (content/ai_tutor/subscription/payment/discount/
# support/family/affiliate) یک IP-bucket تازه بگیره - دقیقاً همون
# bypassِ چرخشِ حساب مهمان که این throttle-classها قرار بود ببندن، فقط
# از طریقِ هدر به‌جای deviceId جدید.
# با NUM_PROXIES=1 (پیش‌فرض؛ برای دیپلویِ پشتِ یک لایه‌ی پراکسی/LB که
# خودش X-Forwarded-For رو ست/rewrite می‌کنه، نه پاس‌دادنِ مقدارِ
# کلاینت)، DRF از انتهای زنجیره‌ی XFF دقیقاً همون تعداد hop رو
# می‌خونه. اگه دیپلویِ پروداکشن تعداد لایه‌ی پراکسیِ دیگه‌ای داره
# (مثلاً Cloudflare + Nginx = ۲ لایه)، این مقدار باید با env
# NUM_PROXIES مطابقِ همون تعداد ست بشه، وگرنه یا 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',
'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',
# ⚠️ اضافه شده: چهار endpointی که در ممیزیِ throttleِ امروز بدونِ
# throttle_scope پیدا شدن (نگاه کنید به توضیحاتِ throttle_scope
# مربوطه در accounts/views.py، affiliate/views.py، payment/views.py).
'user_profile_write': '60/hour',
'affiliate_track': '10/hour',
'payment_webhook': '60/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
# سقفِ تعدادِ گیرنده برای اکشنِ ادمینِ send_sms_to_selected (هم برای
# هدف‌گیریِ 'selected' و هم 'inactive'). این اکشن هنوز کاملاً بلادرنگ و
# synchronous توی همون request اجرا می‌شه (بدون صفِ پس‌زمینه)، پس این سقف
# صرفاً جلویِ یک ارسالِ ناخواسته‌ی خیلی بزرگ (که می‌تونه request رو تایم‌اوت
# کنه یا پروایدرِ پیامک رو overload کنه) رو می‌گیره؛ اگر تعدادِ گیرنده‌ها
# بیشتر از این باشه، اکشن هیچ پیامکی ارسال نمی‌کنه و از ادمین می‌خواد
# بازه/انتخاب رو محدودتر کنه.
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 رو نمی‌گیره (مثلاً هر ۵ تلاشِ مجاز در عرضِ کمتر از یک ثانیه). این
# cooldown دقیقاً همون burst رو هدف می‌گیره؛ نه جایگزینِ سقفِ ساعتیه، نه
# باهاش redundant — هر کدوم یک بُعدِ متفاوتِ سوءاستفاده رو می‌بندن.
# ⚠️ تصمیمِ محصول/امنیت: کاربر واقعاً باید هر ۲ دقیقه بتونه دوباره OTP
# بگیره؛ enforcement سمتِ سرور هم باید دقیقاً همین مقدار باشه — 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)
# -----------------------------------------------------------------------
# طبق داکیومنت رسمی (developers.cafebazaar.ir/fa/guidelines/in-app-billing)،
# صحت‌سنجی پرداخت بازار یک OAuth2 سرور-به-سرور‌ه: یک بار با CLIENT_ID/
# CLIENT_SECRET و کد بازگشتی از پنل devapi، REFRESH_TOKEN گرفته می‌شه (یک‌بار،
# دستی)؛ از اون به بعد بک‌اند با همین REFRESH_TOKEN، ACCESS_TOKEN تازه
# می‌گیره و با اون به endpoint وضعیت خرید/اشتراک درخواست می‌زنه.
# ⚠️ کافه‌بازار اواخر ۱۴۰۳ روش صحت‌سنجی رو تغییر داد؛ قبل از اتصال واقعی،
# حتماً آخرین مسیر endpointها رو از همون صفحه‌ی رسمی/پنل devapi خودتون
# تایید کنید (BAZAAR_API_BASE پایینی رو بر همون اساس ست کنید).
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')
# 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 (آماده‌سازیِ لاگ‌های موجود برای جمع‌آوریِ متمرکز — Loki/ELK-style)
# -----------------------------------------------------------------------
# ⚠️ افزوده شده: پروژه از قبل چندین logger (config.middleware،
# accounts.services، ai_tutor.views، ...) با logging.getLogger(__name__)
# داشت، اما بدونِ یک LOGGING صریح در settings.py، اینا فقط به rootِ
# پیش‌فرضِ کتابخانه‌ی logging می‌رسیدن (که برای سطحِ INFO عملاً هیچ خروجی‌ای
# تولید نمی‌کنه). این تنظیم، بدونِ اضافه/حذفِ هیچ loggerِ اپلیکیشنی یا
# eventِ لاگی، همون پیام‌های موجود رو روی stdout و به‌صورتِ یک JSON در هر
# خط (فرمتِ سازگار با agentِ جمع‌آوریِ بیرونی مثلِ Promtail/Filebeat که
# بعداً می‌تونه به Loki/ELK بفرسته) چاپ می‌کنه. هیچ appِ جدید، ذخیره‌سازیِ
# دیتابیسی، API/UIِ لاگ، یا وابستگیِ Elasticsearch/Loki/Grafana به خودِ
# کدِ Django اضافه نشده.
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',
},
}
# ⚠️ افزوده شده (یکپارچه‌سازیِ Docker + Promtail/Loki/Grafana): وقتی
# env var LOG_DIR ست شده باشه (فقط داخلِ docker-compose.yml، روی یک
# ولومِ مشترک با کانتینرِ promtail)، همون خطوطِ JSON که از قبل روی
# stdout چاپ می‌شدن، *علاوه* بر stdout در یک فایل هم نوشته می‌شن؛
# Promtail همون فایل رو tail می‌کنه و به Loki می‌فرسته.
# این فقط یک هندلرِ خروجیِ اضافه با همون formatter (json) هست — نه
# پیام‌های لاگ، نه محافظت‌های حساسیتیِ موجود (بدونِ OTP/token/رمزعبور و
# غیره در پیام‌ها)، و نه هیچ loggerِ اپلیکیشنی تغییر نمی‌کنه.
# وقتی LOG_DIR ست نباشه (یعنی اجرای عادیِ خارج از Docker، طبق README)،
# رفتار دقیقاً همون چیزیه که از قبل بود: فقط 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')