""" 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 import re from pathlib import Path from urllib.parse import urlsplit 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' # کلیدِ امضا. ترتیبِ اولویت: متغیرِ محیطی، بعد فایلِ تولیدشده‌ی کنارِ پروژه. # # چرا فایل و نه خطای راه‌اندازی: قبلاً با DEBUG خاموش و بدونِ این متغیر، اپ # اصلاً بالا نمی‌آمد و از بیرون فقط 502 دیده می‌شد. کسی که سرور را بالا می‌آورد # باید حدس می‌زد چه چیزی کم است. حالا اولین اجرا خودش یک کلیدِ تصادفیِ واقعی # می‌سازد و در .django_secret_key (خارج از گیت) نگه می‌دارد — همان‌قدر امن، # بدونِ یک قدمِ دستیِ دیگر. متغیرِ محیطی همچنان بر آن اولویت دارد. 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: import secrets _key_file = BASE_DIR / '.django_secret_key' try: SECRET_KEY = _key_file.read_text().strip() except OSError: SECRET_KEY = '' if not SECRET_KEY: SECRET_KEY = secrets.token_urlsafe(64) try: _key_file.write_text(SECRET_KEY) _key_file.chmod(0o600) except OSError as exc: raise ImproperlyConfigured( f'DJANGO_SECRET_KEY ست نیست و نوشتنِ {_key_file} هم ممکن نشد ({exc}). ' 'یا متغیر را ست کنید یا اجازه‌ی نوشتن در پوشه‌ی پروژه بدهید.' ) from exc def _env_list(name: str) -> list[str]: return [v.strip() for v in os.environ.get(name, '').split(',') if v.strip()] def _with_defaults(name: str, defaults: list[str]) -> list[str]: """ مقدارِ محیطی + پیش‌فرض‌های خودمان، بدونِ تکرار و با حفظِ ترتیب. چرا اضافه و نه جایگزین: فایلِ .env روی سرور از قبل وجود دارد و مقدارهای ناقص دارد (مثلاً DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1). اگر مقدارِ موجود پیش‌فرض را کنار می‌زد، همان خطای قدیمی برمی‌گشت و باز کسی باید دستی فایل را ویرایش می‌کرد. دامنه‌های خودمان همیشه باید مجاز باشند. """ seen, out = set(), [] for value in _env_list(name) + defaults: if value not in seen: seen.add(value) out.append(value) return out # --- دامنه‌ها ---------------------------------------------------------------- # دو آدرسِ عمومی؛ تنها جای کد که دامنه در آن نوشته شده. ALLOWED_HOSTS، CSRF، # CORS، callbackِ زرین‌پال، برگشت از پرداخت و redirectِ بازار همه از همین دو # ساخته می‌شوند. برای جابه‌جایی دامنه فقط این دو را در .env عوض کنید؛ جزئیات # و ترتیبِ کار در docs/operations.md («عوض کردنِ دامنه»). # # پیش‌فرض دارند چون خالی بودنشان سرویس را نمی‌خواباند، فقط مسیرِ پرداخت و # فرانت را بی‌صدا می‌شکند. `.strip() or` لازم است: کلید ممکن است در .env # باشد ولی خالی، و os.environ.get فقط وقتی کلید *نباشد* پیش‌فرض می‌دهد. PUBLIC_BASE_URL = ( os.environ.get('PUBLIC_BASE_URL', '').strip() or 'https://mathapi.tadrisino2.ir' ).rstrip('/') # آدرسِ خودِ اپ (فرانت)؛ برگشت از درگاه و CORS از آن ساخته می‌شوند. FRONTEND_URL = ( os.environ.get('FRONTEND_URL', '').strip() or 'https://math.tadrisino2.ir' ).rstrip('/') def _origin(url): parts = urlsplit(url) return f'{parts.scheme}://{parts.netloc}' if parts.scheme and parts.netloc else '' def _hostname(url): return urlsplit(url).hostname or '' # دامنه‌های خودمان (از دو آدرسِ بالا) همیشه مجازند تا یک .env قدیمی یا ناقص # باعثِ DisallowedHost نشود؛ هر چه در محیط باشد به این‌ها اضافه می‌شود. در دورانِ # جابه‌جاییِ دامنه، دامنه‌ی قبلی را در DJANGO_ALLOWED_HOSTS نگه دارید تا تا # عوض شدنِ DNS هر دو جواب بدهند. DEFAULT_ALLOWED_HOSTS = [ h for h in (_hostname(PUBLIC_BASE_URL), _hostname(FRONTEND_URL)) if h ] + ['localhost', '127.0.0.1'] ALLOWED_HOSTS = _with_defaults('DJANGO_ALLOWED_HOSTS', DEFAULT_ALLOWED_HOSTS) # لازم برای ورود به پنلِ ادمین از پشتِ پراکسیِ HTTPS؛ بدونش فرمِ ورود # «CSRF verification failed» می‌دهد و کسی نمی‌فهمد چرا. CSRF_TRUSTED_ORIGINS = _with_defaults( 'CSRF_TRUSTED_ORIGINS', [o for o in (_origin(PUBLIC_BASE_URL), _origin(FRONTEND_URL)) if o], ) # --- امنیتِ پشتِ پراکسی --------------------------------------------------- # TLS روی nginx/ArvanCloud تمام می‌شود و جنگو درخواست را HTTP می‌بیند؛ بدونِ # این هدر، کوکی‌های امن هرگز ست نمی‌شوند و ورود به پنلِ ادمین می‌شکند. SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') # فقط در پروداکشن: کوکیِ نشستِ ادمین و توکنِ CSRF نباید روی HTTP رد و بدل شوند. # (احرازِ هویتِ API ما کوکی نیست — این‌ها برای پنلِ ادمین‌اند، یعنی همان‌جایی # که اعتبارنامه‌ی واقعی وارد می‌شود.) SESSION_COOKIE_SECURE = not DEBUG CSRF_COOKIE_SECURE = not DEBUG SESSION_COOKIE_HTTPONLY = True # SECURE_SSL_REDIRECT و HSTS عمداً روشن نشده‌اند: اولی پشتِ پراکسیِ بدتنظیم # حلقه‌ی ریدایرکت می‌سازد و کلِ سایت را می‌خواباند، دومی هم در مرورگرِ کاربر # کش می‌شود و عملاً برگشت‌ناپذیر است. هر دو تصمیمِ زیرساختی‌اند، نه کد. # --- CORS ------------------------------------------------------------------- # فرانت‌اند (Vite/React) از یک origin دیگر صدا می‌زند، پس بدونِ این هدرها مرورگر # هر درخواستِ /api/ را قبل از رسیدن به Django رد می‌کند. # # allowlist صریح است، نه CORS_ALLOW_ALL_ORIGINS: احرازِ هویتِ ما توکنِ Bearer در # هدر است، پس origin ناشناس نباید بتواند از مرورگرِ کاربر به API ما بزند. # CORS_ALLOW_CREDENTIALS هم عمداً خاموش می‌ماند — کوکی‌ای در کار نیست و روشن # کردنش فقط سطحِ حمله اضافه می‌کند. # origin های شناخته‌شده‌ی فرانت. عمداً اینجا و نه فقط در متغیرِ محیطی: اگر # تنها راهش env بود، یک دیپلویِ فراموش‌شده یعنی بک‌اندِ سالم که مرورگر همه‌ی # درخواست‌هایش را بلاک می‌کند — خطایی که در سمتِ سرور هیچ ردی نمی‌گذارد و فقط # در کنسولِ مرورگر دیده می‌شود. CORS_DEFAULT_ORIGINS = [o for o in (_origin(FRONTEND_URL),) if o] # فرانتِ مستقر CORS_ALLOWED_ORIGINS = _with_defaults('CORS_ALLOWED_ORIGINS', CORS_DEFAULT_ORIGINS) # ⚠️ پورتِ سرورِ توسعه را نباید فهرست کرد. فهرستِ ثابتِ قبلی (۳۰۰۰ و ۵۱۷۳) روزی # شکست که Vite دید ۵۱۷۳ اشغال است و بی‌سروصدا رفت روی ۵۱۷۴ — رفتارِ پیش‌فرضِ # خودش. آن‌وقت فرانت‌کار خطای CORS گرفت، از سمتِ سرور هیچ ردی در هیچ لاگی # نبود، و چون هم‌زمان با تغییراتِ ما شد، به اشتباه گردنِ بک‌اند افتاد. # هر بار که این تکرار شود یعنی یک ویرایشِ .env و یک ری‌استارتِ پروداکشن برای # چیزی که اصلاً به پروداکشن ربطی ندارد. # # پس به‌جای شمردنِ پورت‌ها، الگوی «هر پورتی روی خودِ ماشینِ توسعه‌دهنده» را # می‌پذیریم. این سطحِ حمله اضافه نمی‌کند: احرازِ هویتِ ما توکنِ Bearer در هدر # است و `CORS_ALLOW_CREDENTIALS` خاموش، پس صفحه‌ای که روی localhostِ یک مهاجم # باز است هیچ اعتبارنامه‌ای از مرورگرِ قربانی به دست نمی‌آورد؛ CORS هم اصلاً # سدِ سرور-به-سرور نیست. CORS_ALLOWED_ORIGIN_REGEXES = [ r'^http://localhost:\d+$', r'^http://127\.0\.0\.1:\d+$', ] 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', # اپ‌های خودمان. هر کدام یک مسئولیت و مدل‌های خودش را دارد؛ ارجاعِ بینِ # اپ‌ها با user_id/object_id انجام می‌شود نه ForeignKey، تا هر کدام بعداً # قابلِ جدا شدن بماند (docs/architecture.md). # مسیرِ اصلیِ کاربر: ورود، محتوا، پیشرفت، دستیار 'accounts', 'content', 'progress', 'ai_tutor', # کسبِ درآمد 'subscription', 'payment', 'discount', # ارتباط با کاربر و پشتیبانی 'notifications', 'affiliate', 'support', # لاگِ عمومیِ تغییرِ وضعیت، مستقل از بقیه‌ی اپ‌ها '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', # سقفِ حدسِ رمزِ /admin/ برای هر IP (config/middleware.py). 'config.middleware.AdminLoginThrottleMiddleware', '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 هرگز در کشِ ArvanCloud/مرورگر نماند (config/middleware.py). 'config.middleware.APINoStoreMiddleware', # ⚠️ افزوده شده: لاگِ سبکِ هر درخواستِ API (متد/مسیر/وضعیت/مدت/کاربر)؛ # عمداً آخرِ لیست قرار گرفته تا نزدیک‌ترین میان‌افزار به خودِ view باشه # (مدت‌زمانِ دقیق‌تر، و request.user که توسطِ DRF authentication در # حینِ پردازشِ view ست می‌شه، در دسترس باشه). 'config.middleware.APIRequestLoggingMiddleware', ] ROOT_URLCONF = 'config.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', # قالب‌های سطحِ پروژه (که به هیچ اپی تعلق ندارند) — فعلاً فقط # پوسته‌ی راست‌به‌چپِ صفحه‌های مستنداتِ API. 'DIRS': [BASE_DIR / 'templates'], '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, # هر atomic() قفلِ نوشتن را از اول می‌گیرد (BEGIN IMMEDIATE). # SQLite از SELECT ... FOR UPDATE پشتیبانی نمی‌کند و جنگو # select_for_update را رویش بی‌صدا نادیده می‌گیرد؛ پس محافظ‌های # «قفلِ ردیف» در پرداخت، تخفیف، قلب و سهمیه‌ی دستیار بدونِ این # عملاً قفلی نداشتند. با IMMEDIATE کلِ تراکنش سریالی می‌شود (قفلِ # کلِ دیتابیس، نه ردیف) — روی SQLite درشت‌تر ولی درست. هزینه: # تراکنش‌ها پشتِ هم صف می‌کشند؛ فراخوانی‌های شبکه‌ای (AI، زرین‌پال) # عمداً بیرون از atomic() هستند. بعد از Postgres این را بردارید. 'transaction_mode': 'IMMEDIATE', }, # کانکشنِ DB به‌ازای هر request باز/بسته نشه؛ تا این تعداد ثانیه # persistent بمونه. با SQLite بی‌اثرِ عملی ولی بی‌ضرره؛ بعد از # مهاجرت به Postgres جلوی هزینه‌ی TCP+auth در هر request رو می‌گیره. 'CONN_MAX_AGE': int(os.environ.get('DB_CONN_MAX_AGE', '60')), # تست‌ها روی فایل، مثلِ پروداکشن. دیتابیسِ پیش‌فرضِ تست در حافظه با # shared-cache است که قفلش جدول‌به‌جدول و بی‌انتظار است («table is # locked») — رفتاری که روی فایلِ واقعی پیش نمی‌آید؛ تستِ هم‌زمانیِ # campaigns با BEGIN IMMEDIATE فقط همین‌جا می‌افتاد. 'TEST': {'NAME': str(BASE_DIR / '.test-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: # بدونِ Redis محدودیتِ نرخ بینِ workerها مشترک نیست — ضعف است، ولی دلیلِ # خوبی برای بالا نیامدنِ کلِ سرویس نیست. قبلاً اینجا استثنا پرتاب می‌شد و # نتیجه‌اش از بیرون فقط 502 بود، بدونِ هیچ سرنخی. حالا بالا می‌آید و # /api/health صریحاً هشدار می‌دهد. CACHES = { 'default': { 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', } } # 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 واقعی اشتباه خونده می‌شه. # # پروداکشن دو لایه دارد: ArvanCloud (TLS، `server: ArvanCloud`) → nginx روی # همین ماشین → gunicorn. XFF در جنگو «IPِ کاربر, IPِ آروان» است؛ با ۱ # آخرین مقدار، یعنی IPِ آروان، IPِ کاربر حساب می‌شد و همه‌ی مهمان‌ها و همه‌ی # درخواست‌های OTP پشتِ یک edgeِ آروان یک سهمِ مشترک داشتند (ورودِ مهمان # ۲۰ در ساعت برای *همه*). برای اجرای محلی بی‌پراکسی هم بی‌خطر است: وقتی # XFF نیست یا کوتاه‌تر است، DRF همان REMOTE_ADDR/اولین مقدار را می‌گیرد. # `/api/health` در `clientIp` نشان می‌دهد سرور چه IPای می‌بیند. 'NUM_PROXIES': int(os.environ.get('NUM_PROXIES', '2')), 'DEFAULT_THROTTLE_RATES': { # سقف‌های per-IP عمداً گشاده‌اند: پشتِ CGNATِ اپراتورهای موبایل، وای‌فایِ # مدرسه یا شبکه‌ی یک دفتر، ده‌ها نفر یک IP دارند و با ۵ در ساعت فقط ۵ نفر # می‌توانستند وارد شوند. حفاظِ اصلی per-phone و per-code است (پایین‌تر و # در دیتابیس)؛ این‌ها فقط جلوی یک ماشینِ تنها را می‌گیرند. 'otp_request': os.environ.get('OTP_REQUEST_IP_RATE', '20/hour'), 'otp_login': os.environ.get('OTP_LOGIN_IP_RATE', '30/hour'), 'guest_session': os.environ.get('GUEST_SESSION_IP_RATE', '60/hour'), 'bazaar_verify': '10/hour', 'bazaar_oauth': '20/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', 'question_report': '20/hour', # هر درس اینجا یک نوع مسئله است با یکی-دو سوال، نه یک جلسه‌ی درسی؛ # ۱۰ در ساعت را دانش‌آموزِ عادی می‌زد و پیشرفتش ۴۲۹ می‌گرفت. 'lesson_complete': '120/hour', 'node_complete': '60/hour', 'support_create': '30/hour', 'offer_redeem': '10/hour', 'discount_validate': '20/hour', 'discount_redeem': '10/hour', 'user_profile_write': '60/hour', # حذفِ حساب با کدِ پیامکی؛ سقفِ حدسِ کد جدا در verify_phone_otp است. 'account_delete': '5/hour', 'affiliate_track': '10/hour', 'payment_webhook': '60/hour', 'payment_pay': '30/hour', # /api/health برای پایشِ بیرونی است؛ باید مکرر قابلِ صدا زدن باشد # ولی نه آن‌قدر که خودش به ابزارِ فشار تبدیل شود. 'health': os.environ.get('HEALTH_THROTTLE_RATE', '60/min'), }, } SPECTACULAR_SETTINGS = { 'TITLE': 'Tadrisino API', # این متن قراردادِ بینِ ما و تیمِ فرانت است و تنها جایی‌ست که هر دو طرف # می‌بینند. پس به‌جای فهرستِ امکانات، به سه سوالِ اولِ هر مصرف‌کننده جواب # می‌دهد: چطور توکن بگیرم، خطا چه شکلی برمی‌گردد، و کِی محدود می‌شوم. # Swagger UI و Redoc هر دو Markdown را رندر می‌کنند. 'DESCRIPTION': ( 'بک‌اندِ اپلیکیشنِ آموزشیِ تدریسینو: برنامه‌ی درسی، آزمون، پیشرفتِ ' 'کاربر، دستیارِ هوشِ مصنوعی، اشتراک و پرداخت.\n' '\n' '## احرازِ هویت\n' '\n' 'همه‌ی درخواست‌های احرازشده با یک توکنِ Bearer انجام می‌شوند:\n' '\n' '```\n' 'Authorization: Bearer \n' '```\n' '\n' 'دو راه برای گرفتنِ توکن هست:\n' '\n' '| مسیر | کاربرد |\n' '|---|---|\n' '| `POST /api/auth/session` | ورودِ مهمان با `deviceId` — بدونِ شماره، بلافاصله |\n' '| `POST /api/auth/otp/request` + `POST /api/auth/login` | ورودِ کامل با شماره‌ی موبایل |\n' '\n' 'کاربرِ مهمان می‌تواند بعداً شماره‌اش را وصل کند و همان حساب را نگه ' 'دارد. توکنِ دسترسی یک ساعت اعتبار دارد؛ با `POST /api/auth/refresh` ' 'تازه‌اش کنید.\n' '\n' '`deviceId` هر رشته‌ای می‌تواند باشد و هر مقدارِ تازه یک حسابِ جدا ' 'می‌سازد، پس برای تست لازم نیست منتظرِ چیزی بمانید. چند `deviceId` ' 'از پیش‌آماده هم برای دیدنِ صفحه‌های داده‌دار وجود دارد — از تیمِ ' 'بک‌اند بخواهید.\n' '\n' '**در همین صفحه:** روی **Authorize** بزنید و *فقط خودِ توکن* را وارد ' 'کنید — کلمه‌ی `Bearer` را Swagger خودش اضافه می‌کند.\n' '\n' 'اندپوینتی که توکن نگیرد، درخواست را ناشناس می‌بیند؛ اینکه ورود لازم ' 'باشد یا نه، به خودِ آن اندپوینت بستگی دارد.\n' '\n' '## خطاها\n' '\n' 'هر خطا — حتی خطای پیش‌بینی‌نشده — یک پاسخِ JSON است، نه صفحه‌ی HTML. ' 'پیامِ `detail` فارسی و قابلِ نمایش به کاربرِ نهایی است.\n' '\n' '```json\n' '{"detail": "کد OTP نامعتبر یا منقضی‌شده است."}\n' '```\n' '\n' '| کد | معنی |\n' '|---|---|\n' '| `400` | ورودی نامعتبر است |\n' '| `401` | توکن نیست، نامعتبر است یا منقضی شده |\n' '| `403` | توکن معتبر است ولی اجازه‌ی این کار را ندارد |\n' '| `404` | چیزی با این شناسه وجود ندارد |\n' '| `429` | از محدودیتِ نرخ گذشتید — بخشِ بعد |\n' '| `503` | سرویسِ بیرونی (پیامک، پرداخت) در دسترس نیست |\n' '\n' '`503` عمدی‌ست: وقتی سرویسِ بیرونی تنظیم یا در دسترس نباشد، پاسخِ ' 'صریح می‌گیرید، نه موفقیتِ ساختگی.\n' '\n' '## محدودیتِ نرخ\n' '\n' 'اندپوینت‌های حساس (ورود، درخواستِ کد، پرسش از دستیار) سقفِ مستقل ' 'دارند. عبور از سقف یعنی `429`، و هر جا که معنی داشته باشد هدرِ ' '`Retry-After` هم می‌آید. کاربرِ مهمان بر اساسِ IP شمرده می‌شود.\n' '\n' 'مسیرِ ورود سقفِ جداگانه‌ای بر اساسِ *شماره* هم دارد، مستقل از IP، تا ' 'پخش کردنِ تلاش بینِ چند IP فایده‌ای نداشته باشد.\n' '\n' '## قراردادها\n' '\n' '- نامِ فیلدها `camelCase` است (`deviceId`, `refreshToken`, `nodeId`).\n' '- زمان‌ها ISO 8601 با منطقه‌ی زمانی‌اند.\n' '- متنِ رو به کاربر فارسی است؛ شناسه‌ها و نامِ فیلدها انگلیسی.\n' '- این اسکیما **مرجعِ قرارداد** است. کلاینت را از روی حافظه ننویسید — ' 'از `/api/schema/` تولیدش کنید.\n' ), 'VERSION': '1.0.0', 'CONTACT': {'name': 'تیمِ بک‌اندِ تدریسینو'}, 'EXTERNAL_DOCS': { 'description': 'راهنمای اتصالِ فرانت‌اند', 'url': 'https://gitea.tadrisino2.ir/Tadrisino/MathApp/src/branch/main/docs/frontend/README.md', }, # ترتیبِ تگ‌ها در Swagger به همین ترتیب است، نه الفبایی — یعنی مسیرِ # واقعیِ یک کاربر: اول وارد شو، بعد محتوا بگیر، بعد پیشرفت و بقیه. 'TAGS': [ {'name': 'Identity & Profile (accounts)', 'description': 'ورود، session، پروفایل'}, {'name': 'Curriculum (content)', 'description': 'پایه، فصل، نود، درس، سوال، آزمون'}, {'name': 'Progress & Gamification (progress)', 'description': 'آمار، streak، لیدربرد، شخصیت‌ها'}, {'name': 'AI Tutor (ai_tutor)', 'description': 'پرسش از دستیار و بازخورد روی پاسخ'}, {'name': 'RAG (rag)', 'description': 'بازیابی از محتوای کتاب — داخلی'}, {'name': 'Subscription (subscription)', 'description': 'پلن‌ها و اشتراکِ کاربر'}, {'name': 'Payment (payment)', 'description': 'سفارش و تراکنش (زرین‌پال و کافه‌بازار)'}, {'name': 'Discount (discount)', 'description': 'کدِ تخفیف و آفرها'}, {'name': 'Notifications (notifications)', 'description': 'اعلان‌های کاربر'}, {'name': 'Support (support)', 'description': 'تیکت و گزارشِ اشکال'}, {'name': 'Affiliate (affiliate)', 'description': 'برنامه‌ی معرفی و پورسانت'}, {'name': 'System', 'description': 'سلامتِ سرویس'}, ], 'SERVE_INCLUDE_SCHEMA': False, 'COMPONENT_SPLIT_REQUEST': True, # دو فیلدِ «kind» با دو مجموعه‌ی مقدار: نوعِ درس و نوعِ پلن (بسته/اشتراک، از ۲۰۲۶-۱۰-۰۲). # بدونِ نامِ صریح، drf-spectacular یکی را «KindEd8Enum» می‌نامید و هشدار می‌داد. 'ENUM_NAME_OVERRIDES': { 'LessonKindEnum': [('quiz', 'quiz'), ('video', 'video')], 'PlanKindEnum': [('pack', 'pack'), ('subscription', 'subscription')], }, '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 # عمرِ هر کدِ ورود. سقف‌های دیتابیسیِ پایین زمانِ صدور را از expires_at منهای # همین مقدار می‌خوانند، پس عوض کردنش آن سقف‌ها را هم (برای کدهای قدیمی) جابه‌جا می‌کند. OTP_TTL_SECONDS = 300 # سقف‌هایی که در دیتابیس شمرده می‌شوند، نه در کش. کشِ پیش‌فرض LocMem و مالِ # هر worker است، پس cooldown و سقفِ ساعتیِ بالا روی سرور (۳ worker) عملاً سه # برابرند؛ این دو بینِ همه‌ی workerها مشترک‌اند و بدونِ Redis هم درست کار می‌کنند. # - روزانه‌ی هر شماره: جلوی بمبارانِ پیامکیِ یک گوشی در طولِ روز. # - روزانه‌ی کلِ سامانه: سقفِ خرجِ اعتبارِ کاوه‌نگار اگر کسی با شماره‌های تصادفی # و IPهای زیاد پیامک بکشد (SMS pumping). رسیدن به آن ۵۰۳ و لاگِ ERROR می‌دهد؛ # با رشدِ کاربرِ واقعی از .env بالا ببرید. OTP_REQUEST_PHONE_DAILY_MAX = int(os.environ.get('OTP_REQUEST_PHONE_DAILY_MAX', '10')) OTP_SMS_GLOBAL_DAILY_MAX = int(os.environ.get('OTP_SMS_GLOBAL_DAILY_MAX', '300')) # هر کدِ ورود بعد از این تعداد حدسِ غلط باطل می‌شود (شمارش در دیتابیس). # با کدِ ۶رقمی یعنی شانسِ حدس زدنِ هر کد ۵ در یک میلیون. OTP_MAX_FAILED_ATTEMPTS = 5 # در محیط dev، این کد OTP همیشه معتبر است تا بدون SMS واقعی تست شود. # فقط وقتی DEBUG=True مقدار دارد؛ در پروداکشن None است. DEV_MASTER_OTP = '0000' if DEBUG else None # شماره‌های تستِ ورود: «شماره:کد» با کامای جداکننده. برای این شماره‌ها پیامک # ارسال نمی‌شود و همان کدِ ثابت معتبر است — تا ورودِ تیمِ توسعه به تنظیم بودنِ # کاوه‌نگار یا به یک آدمِ خاص وابسته نباشد. برخلافِ DEV_MASTER_OTP «هر # شماره‌ای» نیست و به DEBUG گره نخورده. روالِ استفاده در docs/operations.md. # # ⚠️ شماره باید *دقیقاً* همان رشته‌ای باشد که کلاینت می‌فرستد: این کدبیس شماره # را normalize نمی‌کند (accounts/serializers.py فقط CharField است)، پس # «09121111111» و «+989121111111» دو کاربرِ جدا هستند. def _parse_test_login_phones(raw: str) -> dict[str, str]: from accounts.phone import normalize_phone pairs = {} for item in raw.split(','): item = item.strip() if not item: continue phone, sep, code = item.partition(':') phone, code = phone.strip(), code.strip() # سقفِ ۶ رقمِ کد دلبخواه نیست: LoginRequestSerializer فیلدِ otp را # max_length=6 گرفته، پس کدِ بلندتر پیش از رسیدن به اینجا ۴۰۰ می‌شد و # به‌نظر می‌آمد کدِ تست کار نمی‌کند. if not sep or not re.fullmatch(r'\+?\d{10,15}', phone) or not re.fullmatch(r'\d{4,6}', code): raise ImproperlyConfigured( f'TEST_LOGIN_PHONES مقدارِ نامعتبر دارد: «{item}». ' 'قالبِ درست «شماره:کد» است؛ شماره ۱۰ تا ۱۵ رقم (با + اختیاری) ' 'و کد ۴ تا ۶ رقم. مثال: TEST_LOGIN_PHONES=09120000001:834512' ) # همان نرمال‌سازیِ ورودیِ API (accounts/phone.py)، وگرنه شماره‌ای که # اینجا ‎+98…‎ نوشته شده با ورودیِ نرمال‌شده‌ی ‎09…‎ هرگز جور نمی‌شد. normalized = normalize_phone(phone) if normalized is None: raise ImproperlyConfigured( f'TEST_LOGIN_PHONES شماره‌ی موبایلِ ایران نیست: «{item}». مثال: 09120000001:834512' ) pairs[normalized] = code return pairs TEST_LOGIN_PHONES = _parse_test_login_phones(os.environ.get('TEST_LOGIN_PHONES', '')) # ----------------------------------------------------------------------- # 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', '') # نامِ الگوی تأییدشده در پنلِ کاوه‌نگار برای کدِ ورود (verify/lookup، با %token%). # وقتی تنظیم باشد OTP_SMS_TEMPLATE پایین استفاده نمی‌شود و متن را خودِ الگو می‌سازد. # خطِ پیش‌فرضِ حسابِ فعلی پیامکِ متنِ آزاد را لغو می‌کند، پس روی سرور لازم است. KAVENEGAR_OTP_TEMPLATE = os.environ.get('KAVENEGAR_OTP_TEMPLATE', '').strip() # جمله‌ی دوم هشدارِ مهندسیِ اجتماعی است (رایج در پیامک‌های بانکی)؛ کلِ متن زیرِ # ۷۰ نویسه می‌ماند تا یک صفحه پیامکِ فارسی حساب شود، نه دو صفحه. OTP_SMS_TEMPLATE = os.environ.get( 'OTP_SMS_TEMPLATE', 'کد ورود شما به تدریسینو: {code}\nاین کد را به هیچ‌کس ندهید.', ) # ----------------------------------------------------------------------- # Content progress (NodeProgress status) # ----------------------------------------------------------------------- # بعد از چند روز بدون فعالیت روی یک Node که status اون in_progress هست، # management command مربوطه (content/management/commands/ # mark_abandoned_node_progress.py) اونو abandoned علامت می‌زنه. ۱۴ روز # (نه ۳-۷ روزِ رایج در اپ‌های مشابه) عمداً انتخاب شده چون قطعی/محدودیت # اینترنت و دیتای موبایل در ایران باعث وقفه‌های چندروزه‌ی غیرارادی # می‌شه که نباید فوراً به‌عنوان «رهاکردن درس» حساب بشه. NODE_PROGRESS_ABANDON_DAYS = 14 # قلبِ صفر جلوی جواب دادن را در سرور بگیرد؟ (content.views.QuestionAnswerView) # از ۲۰۲۶-۱۰-۰۲ روشن است (خواستِ صاحبِ پروژه): فرانت ۴۰۳ِ `no_hearts` را با صفحه‌ی «قلب‌هایت # تمام شد» و زمانِ پر شدن نشان می‌دهد، و قلبِ نامحدودِ هدیه‌ی بسته‌ها بدونِ این اثری نداشت. # HEARTS_ENFORCED=0 در .env آن را خاموش می‌کند. HEARTS_ENFORCED = os.environ.get('HEARTS_ENFORCED', '1') == '1' # تلاشِ ورودِ ادمین در ۱۵ دقیقه برای هر IP. ADMIN_LOGIN_MAX_ATTEMPTS = 10 # ----------------------------------------------------------------------- # 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 '', ) # ----------------------------------------------------------------------- # 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', '') # روشِ جدیدِ بازار: توکنِ ثابتِ پیشخان (API پیشخان بازار ← دریافت توکن جدید). # اگر ست باشد بر OAuthِ بالا (CLIENT_ID/SECRET/REFRESH_TOKEN) مقدم است. BAZAAR_PISHKHAN_API_SECRET = os.environ.get('BAZAAR_PISHKHAN_API_SECRET', '').strip() BAZAAR_API_BASE = os.environ.get('BAZAAR_API_BASE', 'https://pardakht.cafebazaar.ir/devapi/v2') # Redirect URI که در پنل بازار ثبت شده؛ باید با آن *عیناً* یکی باشد. به # خواستِ مدیرِ حسابِ بازار (۲۰۲۶-۰۹-۲۶) همان آدرسِ verify است: GETِ آن codeِ # بازار را می‌گیرد و POSTِ آن خریدِ اپ را. با عوض شدنِ PUBLIC_BASE_URL این هم # عوض می‌شود، و آن‌وقت در پنلِ بازار هم باید عوض شود. BAZAAR_REDIRECT_URI = ( os.environ.get('BAZAAR_REDIRECT_URI', '').strip() or f'{PUBLIC_BASE_URL}/api/payment/bazaar/verify' ) # چند ساعت لینکِ «اجازه»ی بازار باز می‌ماند؛ بعد از اتصالِ موفق خودش بسته می‌شود. BAZAAR_AUTHORIZE_WINDOW_HOURS = int(os.environ.get('BAZAAR_AUTHORIZE_WINDOW_HOURS', '72')) # ----------------------------------------------------------------------- # Zarinpal (درگاهِ اصلیِ پرداختِ ما — Payment API v4) # ----------------------------------------------------------------------- # paymentUrlی که به کلاینت برمی‌گردد و callback_urlی که به زرین‌پال می‌رود # هر دو از PUBLIC_BASE_URL (بخشِ «دامنه‌ها» در بالای همین فایل) ساخته می‌شوند. 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', ) # `.strip() or` مثلِ بقیه: کلیدِ خالی در .env نباید callback را خالی کند (آن‌وقت # هر پرداختی بی‌صدا ۵۰۳ می‌گرفت). ZARINPAL_CALLBACK_URL = ( os.environ.get('ZARINPAL_CALLBACK_URL', '').strip() or f'{PUBLIC_BASE_URL}/api/payment/zarinpal/callback' ) # بعدِ verify، مرورگر را به این URL با ?orderId=...&status=... برمی‌گردانیم # (برای اپِ موبایل می‌تواند یک deep link باشد). پیش‌فرض خودِ اپِ وب است؛ قبلاً # خالی بود و دانش‌آموز بعد از پرداختِ واقعی روی یک صفحه‌ی JSONِ خام روی دامنه‌ی # API می‌ماند. مقدارِ صریحِ `-` یعنی همان پاسخِ JSON (برای تست/ابزار). ZARINPAL_REDIRECT_URL = os.environ.get('ZARINPAL_REDIRECT_URL', '').strip() or f'{FRONTEND_URL}/' if ZARINPAL_REDIRECT_URL == '-': 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') # gpt-4.1-mini از ۲۰۲۶-۰۹-۲۶ (تصمیمِ صاحبِ پروژه): در سنجشِ ۱۰۲ مسئله ۹۶ درست در برابرِ # ۸۵–۹۰ِ gpt-4o-mini، با ۲٫۹ برابر هزینه (۰٫۱۲ سنت برای هر پاسخ). شرح: گزارش ۲۳ و # docs/architecture.md. پیش‌فرض همین‌جاست تا دیپلوی به ویرایشِ دستیِ .env نیاز نداشته باشد. OPENAI_CHAT_MODEL = os.environ.get('OPENAI_CHAT_MODEL', 'gpt-4.1-mini') # سقفِ سوالِ روزانه از دستیار برای کاربرِ بدونِ اشتراک (مشترکان: Plan.ai_daily_quota). # ۳ پیشنهادِ docs/ai-cost-and-guardrails.md است؛ تا ۲۰۲۶-۱۰-۰۱ برای همه ۱۵ بود. AI_DAILY_QUOTA_FREE = int(os.environ.get('AI_DAILY_QUOTA_FREE', '3')) # اختیاری: مدلِ جدا فقط برای پایه‌های دهم تا دوازدهم (حسابان/مثلثات). خالی یعنی # همه‌ی پایه‌ها OPENAI_CHAT_MODEL. انتخابش با `rag_model_benchmark` سنجیده می‌شود. OPENAI_CHAT_MODEL_UPPER = os.environ.get('OPENAI_CHAT_MODEL_UPPER', '') # فقط برای مدل‌های reasoning (o-series/gpt-5): low | medium | high؛ خالی = پیش‌فرضِ مدل. OPENAI_CHAT_REASONING_EFFORT = os.environ.get('OPENAI_CHAT_REASONING_EFFORT', 'low') # gpt-4.1-mini در سنجشِ ۲۰۲۶-۰۹-۲۰ هم ارزان‌تر و هم دقیق‌تر از gpt-4o بود # (docs/extraction-playbook.md)؛ پیش‌فرض همان است تا به .env وابسته نباشد. OPENAI_VISION_MODEL = os.environ.get('OPENAI_VISION_MODEL', 'gpt-4.1-mini') # نباید تغییر کنه مگر اینکه 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-client پنج ثانیه است و برای این سرور کم است: ingestِ # کتابِ هشتم روی همان اولین فراخوانی (ساختِ collection) افتاد و صفر صفحه رفت. # سی ثانیه در مسیرِ ingest که دقیقه‌ها طول می‌کشد هزینه‌ای ندارد، و جلوی # مردنِ یک اجرای طولانی بابتِ یک کندیِ لحظه‌ای را می‌گیرد. QDRANT_TIMEOUT_SECONDS = int(os.environ.get('QDRANT_TIMEOUT_SECONDS', '30')) 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')