""" 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')