422 lines
19 KiB
Python
422 lines
19 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
|
||
|
||
|
||
# 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',
|
||
]
|
||
|
||
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',
|
||
}
|
||
}
|
||
|
||
# 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
|
||
|
||
# محدودیت تلاش ورود بر اساس شماره تلفن، مستقل از 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')
|