# -*- coding: utf-8 -*- """ Django settings for myproject project with django-tenants support. This is a multi-tenant SaaS application where each shop owner gets their own subdomain and isolated database schema. Example: shop1.inventory.by, shop2.inventory.by """ from pathlib import Path import os import environ # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent # Initialize environment variables env = environ.Env( # Set casting and default values DEBUG=(bool, True), SECRET_KEY=(str, 'django-insecure-default-key-change-in-production'), ) # Read .env file (if exists - for local development) # In Docker, environment variables are set via docker-compose env_file env_file = BASE_DIR / '.env' if env_file.exists(): environ.Env.read_env(env_file) # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = env('SECRET_KEY') # SECURITY WARNING: don't run with debug turned on in production! DEBUG = env('DEBUG') # Allowed hosts: читаем из переменной окружения или используем wildcard для разработки ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=['*']) # CSRF trusted origins для работы за nginx proxy CSRF_TRUSTED_ORIGINS = env.list('CSRF_TRUSTED_ORIGINS', default=[ 'https://mix.smaa.by', 'https://*.mix.smaa.by', ]) # ============================================ # DJANGO-TENANTS CONFIGURATION # ============================================ # Shared apps: доступны в public схеме (общие для всей системы) SHARED_APPS = [ 'django_tenants', # ОБЯЗАТЕЛЬНО ПЕРВЫМ! 'tenants', # Приложение с моделями тенантов # Django встроенные приложения 'django.contrib.contenttypes', 'django.contrib.auth', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.admin', 'django.contrib.staticfiles', # Accounts должен быть в shared для CustomUser (используется в админке) 'accounts', # Celery results (для сохранения статуса асинхронных задач) 'django_celery_results', ] # Tenant apps: создаются в отдельной схеме для каждого тенанта (изолированные данные) TENANT_APPS = [ 'django.contrib.contenttypes', # Дублируем для tenant схем 'django.contrib.auth', # Дублируем для tenant схем # Приложения с бизнес-логикой (изолированные для каждого магазина) 'simple_history', # История изменений для каждого тенанта 'nested_admin', 'django_filters', # Фильтрация данных 'user_roles', # Роли пользователей 'customers', # Клиенты магазина 'products', # Товары и категории 'orders', # Заказы 'inventory', # Складской учет 'pos', # POS Terminal ] # Объединяем для INSTALLED_APPS INSTALLED_APPS = list(SHARED_APPS) + [app for app in TENANT_APPS if app not in SHARED_APPS] # Django Debug Toolbar (только в DEBUG режиме) if DEBUG: INSTALLED_APPS += ['debug_toolbar'] # Модели тенанта и домена TENANT_MODEL = "tenants.Client" TENANT_DOMAIN_MODEL = "tenants.Domain" # Показывать tenant_id в логах (полезно для отладки) SHOW_PUBLIC_IF_NO_TENANT_FOUND = True # ============================================ # MIDDLEWARE # ============================================ MIDDLEWARE = [ 'whitenoise.middleware.WhiteNoiseMiddleware', # Static files first (no DB access needed) 'django_tenants.middleware.main.TenantMainMiddleware', # ОБЯЗАТЕЛЬНО ПЕРВЫМ! 'myproject.admin_access_middleware.TenantAdminAccessMiddleware', # SECURITY: Ограничение доступа к админке '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', 'simple_history.middleware.HistoryRequestMiddleware', # История изменений ] # Django Debug Toolbar Middleware (только в DEBUG режиме) # ВАЖНО: добавляем ПОСЛЕ TenantMainMiddleware, чтобы toolbar видел корректный tenant if DEBUG: MIDDLEWARE.append('debug_toolbar.middleware.DebugToolbarMiddleware') # ============================================ # AUTHENTICATION BACKENDS # ============================================ # Кастомный backend для связи ролей с Django permissions API # ВАЖНО: Этот backend работает с ролями из tenant schema, НЕ трогая public schema! AUTHENTICATION_BACKENDS = [ 'user_roles.auth_backend.RoleBasedPermissionBackend', # Наш кастомный backend для ролей 'django.contrib.auth.backends.ModelBackend', # Стандартный backend (для superuser и т.д.) ] # ============================================ # URL CONFIGURATION # ============================================ ROOT_URLCONF = 'myproject.urls' # URL-конфигурация для public схемы (главный домен inventory.by) PUBLIC_SCHEMA_URLCONF = 'myproject.urls_public' # ============================================ # TEMPLATES # ============================================ TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', '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 = 'myproject.wsgi.application' # ============================================ # DATABASE CONFIGURATION # ============================================ DATABASES = { 'default': { 'ENGINE': 'django_tenants.postgresql_backend', # ВАЖНО: используем backend от django-tenants 'NAME': env('DB_NAME'), 'USER': env('DB_USER'), 'PASSWORD': env('DB_PASSWORD'), 'HOST': env('DB_HOST'), 'PORT': env('DB_PORT'), 'OPTIONS': { 'client_encoding': 'UTF8', }, 'CONN_MAX_AGE': 0, } } # Database router для django-tenants DATABASE_ROUTERS = [ 'django_tenants.routers.TenantSyncRouter', ] # ============================================ # PASSWORD VALIDATION # ============================================ 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 # ============================================ LANGUAGE_CODE = 'ru-ru' TIME_ZONE = 'Europe/Minsk' USE_I18N = True USE_TZ = True # ============================================ # STATIC FILES (CSS, JavaScript, Images) # ============================================ STATIC_URL = '/static/' STATICFILES_DIRS = [BASE_DIR / 'static'] # В production используем внешнюю директорию для nginx # В Docker контейнере BASE_DIR = /app, но структура проекта: /app/myproject/ # Поэтому STATIC_ROOT должен быть /app/myproject/staticfiles if str(BASE_DIR) == '/app': # В Docker контейнере STATIC_ROOT = BASE_DIR / 'myproject' / 'staticfiles' else: # Локальная разработка STATIC_ROOT = BASE_DIR / 'staticfiles' # Whitenoise storage STORAGES = { "default": { "BACKEND": "products.utils.storage.TenantAwareFileSystemStorage", }, "staticfiles": { "BACKEND": "whitenoise.storage.CompressedStaticFilesStorage", }, } # ============================================ # MEDIA FILES (User uploads) # ============================================ MEDIA_URL = '/media/' # MEDIA_ROOT должен соответствовать монтированию volume в docker-compose.yml # На проде в Docker: /app/myproject/media (BASE_DIR = /app) # Локально: BASE_DIR / 'media' (BASE_DIR = путь к проекту) if os.environ.get('MEDIA_ROOT'): MEDIA_ROOT = Path(os.environ.get('MEDIA_ROOT')) elif str(BASE_DIR) == '/app': # В Docker контейнере MEDIA_ROOT = BASE_DIR / 'myproject' / 'media' else: # Локальная разработка MEDIA_ROOT = BASE_DIR / 'media' # Время жизни временных файлов фото (TTL) до авто-удаления, в часах TEMP_MEDIA_TTL_HOURS = 24 # ============================================ # IMAGE PROCESSING SETTINGS # ============================================ IMAGE_PROCESSING_CONFIG = { 'formats': { 'original': { 'format': 'JPEG', 'quality': 100, 'max_width': 2160, 'max_height': 2160, 'description': 'Original image (4K max, JPEG format)' }, 'large': { 'format': 'WEBP', 'quality': 90, 'width': 1200, 'height': 1200, 'description': 'Large image (1200x1200, WebP format)' }, 'medium': { 'format': 'WEBP', 'quality': 85, 'width': 600, 'height': 600, 'description': 'Medium image (600x600, WebP format)' }, 'thumbnail': { 'format': 'WEBP', 'quality': 80, 'width': 200, 'height': 200, 'description': 'Thumbnail (200x200, WebP format)' }, } } # ============================================ # IMAGE QUALITY ASSESSMENT SETTINGS # ============================================ # Пороги качества как доля от максимального размера оригинала (0.0 - 1.0) # Вычисляется динамически: if image_size >= threshold * max_original_size → quality_level # # Пример: если max_width=2160, то: # - excellent: >= 2052px (95% * 2160) # - good: >= 1512px (70% * 2160) # - acceptable: >= 864px (40% * 2160) # - poor: >= 432px (20% * 2160) # - very_poor: < 432px IMAGE_QUALITY_LEVELS = { 'excellent': 0.95, # 95% от максимума 'good': 0.70, # 70% от максимума 'acceptable': 0.40, # 40% от максимума 'poor': 0.20, # 20% от максимума # < 20% = very_poor } # Описания и рекомендации для каждого уровня качества # Используется в админке, формах и API IMAGE_QUALITY_LABELS = { 'excellent': { 'label': 'Отлично', 'short_label': 'Отлично ✓', 'description': 'Идеальное качество изображения', 'color': 'success', 'icon': '✓', 'recommendation': 'Готово для выгрузки на сайт', 'badge_class': 'badge-success', }, 'good': { 'label': 'Хорошо', 'short_label': 'Хорошо ◐', 'description': 'Хорошее качество изображения', 'color': 'info', 'icon': '◐', 'recommendation': 'Можно выгружать на сайт', 'badge_class': 'badge-info', }, 'acceptable': { 'label': 'Приемлемо', 'short_label': 'Приемлемо ⚠', 'description': 'Приемлемое качество, но рекомендуется обновить', 'color': 'warning', 'icon': '⚠', 'recommendation': 'Лучше обновить перед выгрузкой на сайт', 'badge_class': 'badge-warning', }, 'poor': { 'label': 'Плохо', 'short_label': 'Плохо ✗', 'description': 'Низкое качество изображения', 'color': 'danger', 'icon': '✗', 'recommendation': 'Требует обновления перед выгрузкой на сайт', 'badge_class': 'badge-danger', }, 'very_poor': { 'label': 'Очень плохо', 'short_label': 'Очень плохо ✗✗', 'description': 'Очень низкое качество изображения', 'color': 'danger', 'icon': '✗✗', 'recommendation': 'Обязательно обновить перед любой выгрузкой', 'badge_class': 'badge-danger', }, } # ============================================ # BUSINESS LOGIC SETTINGS # ============================================ # Максимальная глубина вложенности категорий товаров MAX_CATEGORY_DEPTH = 10 # Настройки телефонных номеров PHONENUMBER_DEFAULT_REGION = 'BY' # ============================================ # DOMAIN SETTINGS (for multi-tenant URLs) # ============================================ # Главный домен приложения (без схемы http/https) # Локально: localhost:8000, в проде: mix.smaa.by TENANT_DOMAIN_BASE = env('TENANT_DOMAIN_BASE', default='localhost:8000') # Использовать HTTPS для ссылок (в проде True, локально False) USE_HTTPS = env.bool('USE_HTTPS', default=False) # ============================================ # DJANGO DEBUG TOOLBAR SETTINGS # ============================================ if DEBUG: # IP адреса, с которых разрешен доступ к Debug Toolbar INTERNAL_IPS = [ '127.0.0.1', 'localhost', ] # Если запускаете в Docker, добавьте IP хоста # Например: INTERNAL_IPS += ['172.17.0.1', '192.168.65.1'] # Docker Desktop # Конфигурация Debug Toolbar DEBUG_TOOLBAR_CONFIG = { # Показывать toolbar всегда при DEBUG=True и INTERNAL_IPS 'SHOW_TOOLBAR_CALLBACK': lambda request: DEBUG, # Или можно фильтровать по tenant (например, только для определенных поддоменов): # 'SHOW_TOOLBAR_CALLBACK': lambda request: DEBUG and getattr(request, 'tenant', None) and request.tenant.schema_name != 'public', # Отключить для тестов 'IS_RUNNING_TESTS': False, } # ============================================ # EMAIL SETTINGS # ============================================ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' DEFAULT_FROM_EMAIL = 'noreply@inventory.by' # ============================================ # AUTHENTICATION # ============================================ AUTH_USER_MODEL = 'accounts.CustomUser' # ============================================ # TENANT ADMIN AUTO-CREATION # ============================================ # При создании нового тенанта автоматически создается суперпользователь # с указанными credentials для доступа к админке тенанта TENANT_ADMIN_EMAIL = env('TENANT_ADMIN_EMAIL') TENANT_ADMIN_PASSWORD = env('TENANT_ADMIN_PASSWORD') TENANT_ADMIN_NAME = env('TENANT_ADMIN_NAME') # ============================================ # DEFAULT SETTINGS # ============================================ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' # ============================================ # CACHE CONFIGURATION (Redis) # ============================================ CACHES = { 'default': { 'BACKEND': 'django.core.cache.backends.redis.RedisCache', 'LOCATION': f'redis://{env("REDIS_HOST", default="localhost")}:{env("REDIS_PORT", default="6379")}/{env("REDIS_DB", default="0")}', 'KEY_PREFIX': 'myproject', # Префикс для всех ключей 'TIMEOUT': 300, # Таймаут по умолчанию (5 минут) } } # ============================================ # CELERY CONFIGURATION # ============================================ # Redis broker и backend для хранения результатов CELERY_BROKER_URL = f'redis://{env("REDIS_HOST", default="localhost")}:{env("REDIS_PORT", default="6379")}/{env("REDIS_DB", default="0")}' CELERY_RESULT_BACKEND = 'django-db' # Сохраняем результаты в БД (совместимо с мультитенантностью) # Сериализация CELERY_ACCEPT_CONTENT = ['json'] CELERY_TASK_SERIALIZER = 'json' CELERY_RESULT_SERIALIZER = 'json' CELERY_TIMEZONE = TIME_ZONE # Task routing для разделения нагрузки CELERY_TASK_ROUTES = { 'products.tasks.process_product_photo_async': {'queue': 'photo_processing'}, 'products.tasks.process_multiple_photos_async': {'queue': 'photo_processing'}, 'products.tasks.cleanup_temp_media_for_schema': {'queue': 'photo_processing'}, 'products.tasks.cleanup_temp_media_all': {'queue': 'photo_processing'}, } # Worker настройки для обработки длительных задач CELERY_WORKER_PREFETCH_MULTIPLIER = 1 # Worker берет по одной задаче за раз CELERY_TASK_ACKS_LATE = True # Подтверждаем задачу только после успешного выполнения CELERY_WORKER_MAX_TASKS_PER_CHILD = 50 # Перезапускаем worker после 50 задач (защита от утечек памяти PIL) # Timeouts CELERY_TASK_TIME_LIMIT = 300 # 5 минут максимум на одну задачу CELERY_TASK_SOFT_TIME_LIMIT = 240 # 4 минуты - мягкий лимит перед жестким # Результаты и события CELERY_RESULT_EXPIRES = 3600 # Результаты хранятся 1 час (достаточно для отслеживания прогресса) CELERY_WORKER_SEND_TASK_EVENTS = True # Отправляем события для мониторинга CELERY_TASK_SEND_SENT_EVENT = True # Retry настройки CELERY_TASK_DEFAULT_MAX_RETRIES = 3 CELERY_TASK_DEFAULT_RETRY_DELAY = 60 # Повторить через 60 секунд при ошибке # Celery Beat Schedule (периодические задачи) # Store schedule in /tmp to avoid PermissionError in Docker volumes CELERY_BEAT_SCHEDULE_FILENAME = os.path.join('/tmp', 'celerybeat-schedule') from celery.schedules import crontab CELERY_BEAT_SCHEDULE = { # Очистка истекших блокировок корзины каждые 5 минут 'cleanup-expired-cart-locks': { 'task': 'inventory.tasks.cleanup_expired_cart_locks', 'schedule': crontab(minute='*/5'), # Каждые 5 минут 'options': { 'expires': 240, # Задача устаревает через 4 минуты (меньше интервала) }, }, # Очистка временных файлов фото каждые 6 часов 'cleanup-temp-media-all-tenants': { 'task': 'products.tasks.cleanup_temp_media_all', 'schedule': crontab(minute=0, hour='*/6'), # Каждые 6 часов 'options': { 'queue': 'photo_processing', }, }, }