chore: Реорганизация проекта - очистка и структурирование файлов
- Добавлена папка ДОКУМЕНТАЦИЯ с централизованным хранением всех руководств - Перенесены утилитарные скрипты в myproject/scripts/ - Удалены временные файлы (current_settings.txt, old_settings.txt, nul) - Добавлены celerybeat-schedule файлы в .gitignore - Обновлен .env.example (удалены устаревшие настройки PLATFORM_SUPPORT) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,13 +29,6 @@ PLATFORM_ADMIN_EMAIL=admin@platform.com
|
||||
PLATFORM_ADMIN_PASSWORD=your-secure-password-here
|
||||
PLATFORM_ADMIN_NAME=Platform Admin
|
||||
|
||||
# ============================================
|
||||
# PLATFORM SUPPORT (техподдержка в тенантах)
|
||||
# ============================================
|
||||
# Email для скрытого аккаунта техподдержки в каждом тенанте
|
||||
# Пароль генерируется уникальный и выводится в лог при создании тенанта
|
||||
PLATFORM_SUPPORT_EMAIL=support@platform.local
|
||||
|
||||
# ============================================
|
||||
# DOMAIN SETTINGS
|
||||
# ============================================
|
||||
|
||||
@@ -1,582 +0,0 @@
|
||||
# -*- 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, False), # Security: default False
|
||||
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.bool('DEBUG', False)
|
||||
DEBUG_TOOLBAR_ENABLED = DEBUG and env.bool('DEBUG_TOOLBAR_ENABLED', False)
|
||||
|
||||
# Allowed hosts: читаем из переменной окружения
|
||||
# В .env на проде: ALLOWED_HOSTS=mix.smaa.by,*.mix.smaa.by
|
||||
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=['*'])
|
||||
|
||||
# CSRF configuration
|
||||
CSRF_TRUSTED_ORIGINS = env.list('CSRF_TRUSTED_ORIGINS', default=[
|
||||
'https://mix.smaa.by',
|
||||
'https://*.mix.smaa.by',
|
||||
])
|
||||
CSRF_USE_SESSIONS = True # Рекомендуется для мультихостовых систем
|
||||
|
||||
|
||||
# ============================================
|
||||
# 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
|
||||
'system_settings', # Системные настройки компании (только для владельца)
|
||||
]
|
||||
|
||||
# Объединяем для INSTALLED_APPS
|
||||
INSTALLED_APPS = list(SHARED_APPS) + [app for app in TENANT_APPS if app not in SHARED_APPS]
|
||||
|
||||
# Django Debug Toolbar
|
||||
if DEBUG_TOOLBAR_ENABLED:
|
||||
INSTALLED_APPS += ['debug_toolbar']
|
||||
|
||||
# Модели тенанта и домена
|
||||
TENANT_MODEL = "tenants.Client"
|
||||
TENANT_DOMAIN_MODEL = "tenants.Domain"
|
||||
|
||||
# Показывать tenant_id в логах (полезно для отладки)
|
||||
SHOW_PUBLIC_IF_NO_TENANT_FOUND = True
|
||||
|
||||
|
||||
# ============================================
|
||||
# MIDDLEWARE
|
||||
# ============================================
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django_tenants.middleware.main.TenantMainMiddleware', # 1. Обязательно первым!
|
||||
'django.middleware.security.SecurityMiddleware', # 2. Безопасность (HTTPS и заголовки)
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware', # 3. Статика (после безопасности)
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware', # 4. Определение пользователя
|
||||
'myproject.admin_access_middleware.TenantAdminAccessMiddleware', # 5. ТЕПЕРЬ проверка работает (после auth)
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
'simple_history.middleware.HistoryRequestMiddleware',
|
||||
]
|
||||
|
||||
# Django Debug Toolbar Middleware
|
||||
if DEBUG_TOOLBAR_ENABLED:
|
||||
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
|
||||
|
||||
|
||||
# ============================================
|
||||
# SESSION CONFIGURATION
|
||||
# ============================================
|
||||
|
||||
# Время жизни сессии: 4 недели (в секундах)
|
||||
SESSION_COOKIE_AGE = 2419200 # 28 дней
|
||||
|
||||
# Продлевать сессию при каждом запросе (токен не устареет, если пользователь активен)
|
||||
SESSION_SAVE_EVERY_REQUEST = True
|
||||
|
||||
# Время жизни CSRF cookie (привязан к сессии, но можно увеличить отдельно)
|
||||
# 1 год для удобства пользователей
|
||||
CSRF_COOKIE_AGE = 31449600
|
||||
|
||||
# CSRF cookie доступен только по HTTPS на проде
|
||||
CSRF_COOKIE_SECURE = not DEBUG
|
||||
|
||||
# Session cookie доступен только по HTTPS на проде
|
||||
SESSION_COOKIE_SECURE = not DEBUG
|
||||
|
||||
|
||||
# ============================================
|
||||
# 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
|
||||
# ============================================
|
||||
|
||||
# Улучшенный блок INTERNAL_IPS (для Docker и локальной разработки)
|
||||
if DEBUG_TOOLBAR_ENABLED:
|
||||
import socket
|
||||
INTERNAL_IPS = ['127.0.0.1', '10.0.2.2', 'localhost']
|
||||
|
||||
try:
|
||||
# Получаем все IP хоста (контейнера)
|
||||
hostname, _, ips = socket.gethostbyname_ex(socket.gethostname())
|
||||
for ip_str in ips:
|
||||
try:
|
||||
# Для IPv4: заменяем последний октет на 1 (шлюз Docker)
|
||||
if '.' in ip_str:
|
||||
parts = ip_str.split('.')
|
||||
if len(parts) == 4:
|
||||
gateway_ip = '.'.join(parts[:3] + ['1'])
|
||||
INTERNAL_IPS.append(gateway_ip)
|
||||
|
||||
# Возможные шлюзы и алиасы
|
||||
INTERNAL_IPS.extend([
|
||||
'.'.join(parts[:3] + ['254']),
|
||||
'host.docker.internal',
|
||||
])
|
||||
except (ValueError, AttributeError):
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"Debug Info: Could not determine Docker gateway IPs: {e}")
|
||||
|
||||
# Конфигурация Debug Toolbar
|
||||
DEBUG_TOOLBAR_CONFIG = {
|
||||
'SHOW_TOOLBAR_CALLBACK': lambda request: DEBUG_TOOLBAR_ENABLED,
|
||||
'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',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1,490 +0,0 @@
|
||||
# Развёртывание на TerraMaster F2-424 (TOS 6)
|
||||
|
||||
## Требования
|
||||
- TerraMaster NAS с TOS 6
|
||||
- Docker и Docker Compose установлены
|
||||
- SSH доступ к NAS
|
||||
- 32 GB RAM (более чем достаточно)
|
||||
|
||||
## Структура на NAS
|
||||
|
||||
```
|
||||
/Volume1/DockerYAML/mix/ # Только docker-compose.yml
|
||||
└── docker-compose.yml
|
||||
|
||||
/Volume1/DockerAppsData/mixapp/ # Всё остальное
|
||||
├── app/ # Код приложения (весь проект)
|
||||
│ ├── myproject/
|
||||
│ ├── requirements.txt
|
||||
│ └── docker/
|
||||
│ ├── Dockerfile
|
||||
│ ├── entrypoint.sh
|
||||
│ └── .env.docker
|
||||
├── postgres/ # База данных PostgreSQL
|
||||
├── redis/ # Данные Redis
|
||||
├── media/ # Загруженные файлы (фото товаров)
|
||||
└── static/ # Статические файлы Django
|
||||
```
|
||||
|
||||
## Шаг 1: Подготовка директорий на NAS
|
||||
|
||||
```bash
|
||||
# Подключаемся к NAS по SSH
|
||||
ssh admin@<NAS_IP>
|
||||
|
||||
# Создаём директории
|
||||
mkdir -p /Volume1/DockerAppsData/mixapp/{app,postgres,redis,media,static}
|
||||
mkdir -p /Volume1/DockerYAML/mix
|
||||
|
||||
# Устанавливаем права (uid 1000 для контейнера appuser)
|
||||
chown -R 1000:1000 /Volume1/DockerAppsData/mixapp/app
|
||||
chown -R 1000:1000 /Volume1/DockerAppsData/mixapp/media
|
||||
chown -R 1000:1000 /Volume1/DockerAppsData/mixapp/static
|
||||
chmod -R u+rwX /Volume1/DockerAppsData/mixapp
|
||||
chown -R 1000:1000 /Volume1/DockerYAML/mix
|
||||
```
|
||||
|
||||
## Шаг 2: Копирование файлов проекта на NAS
|
||||
|
||||
### Вариант A: Через SCP (с Windows)
|
||||
```powershell
|
||||
# Из папки проекта на Windows
|
||||
cd C:\Users\team_\Desktop\test_qwen
|
||||
|
||||
# Копируем код приложения в папку данных
|
||||
scp -r myproject requirements.txt docker .dockerignore admin@<NAS_IP>:/Volume1/DockerAppsData/mixapp/app/
|
||||
|
||||
# Копируем docker-compose.yml в папку YAML
|
||||
scp docker/docker-compose.yml admin@<NAS_IP>:/Volume1/DockerYAML/mix/
|
||||
```
|
||||
|
||||
### Вариант B: Через общую папку SMB
|
||||
1. Подключите сетевую папку NAS
|
||||
2. Скопируйте содержимое папки проекта (`myproject/`, `requirements.txt`, `docker/` и т.д.) в `/Volume1/DockerAppsData/mixapp/app/`
|
||||
3. Скопируйте `docker/docker-compose.yml` в `/Volume1/DockerYAML/mix/`
|
||||
|
||||
## Шаг 3: Настройка переменных окружения
|
||||
|
||||
```bash
|
||||
# На NAS
|
||||
cd /Volume1/DockerAppsData/mixapp/app/docker
|
||||
|
||||
# Редактируем .env.docker
|
||||
nano .env.docker
|
||||
```
|
||||
|
||||
**ВАЖНО! Измените следующие значения:**
|
||||
|
||||
```env
|
||||
# Django settings
|
||||
SECRET_KEY=ваш-очень-длинный-секретный-ключ-минимум-50-символов
|
||||
DEBUG=False
|
||||
ALLOWED_HOSTS=*
|
||||
CSRF_TRUSTED_ORIGINS=https://mix.smaa.by,https://*.mix.smaa.by
|
||||
DOMAIN_NAME=mix.smaa.by
|
||||
# Database (PostgreSQL)
|
||||
DB_NAME=inventory_db
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=ваш-надежный-пароль-postgres
|
||||
DB_HOST=db
|
||||
DB_PORT=5432
|
||||
|
||||
# Значения для официального образа Postgres (ДОЛЖНЫ СОВПАДАТЬ с DB_*)
|
||||
POSTGRES_DB=inventory_db
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=ваш-надежный-пароль-postgres
|
||||
|
||||
# Redis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_DB=0
|
||||
|
||||
# Celery
|
||||
CELERY_BROKER_URL=redis://redis:6379/0
|
||||
|
||||
# Platform Admin (администратор платформы - создаётся автоматически)
|
||||
PLATFORM_ADMIN_EMAIL=admin@mix.smaa.by
|
||||
PLATFORM_ADMIN_PASSWORD=ваш-надёжный-пароль-минимум-16-символов
|
||||
PLATFORM_ADMIN_NAME=Platform Admin
|
||||
|
||||
# Django-tenants
|
||||
PUBLIC_SCHEMA_DOMAIN=mix.smaa.by
|
||||
TENANT_DOMAIN_BASE=mix.smaa.by
|
||||
USE_HTTPS=True
|
||||
```
|
||||
|
||||
### ⚠️ КРИТИЧЕСКИ ВАЖНО:
|
||||
|
||||
1. **`DB_PASSWORD` и `POSTGRES_PASSWORD` ДОЛЖНЫ БЫТЬ ОДИНАКОВЫМИ!**
|
||||
Иначе Django не сможет подключиться к PostgreSQL.
|
||||
|
||||
2. **В `docker-compose.yml` у сервиса `db` НЕ должно быть блока `environment:`**
|
||||
Все переменные читаются напрямую из `.env.docker` через `env_file`.
|
||||
|
||||
3. **При смене пароля БД после первого запуска:**
|
||||
Простое изменение `POSTGRES_PASSWORD` в `.env.docker` НЕ изменит пароль существующей базы.
|
||||
Нужно пересоздать данные PostgreSQL:
|
||||
```bash
|
||||
cd /Volume1/DockerYAML/mix
|
||||
docker-compose down
|
||||
rm -rf /Volume1/DockerAppsData/mixapp/postgres
|
||||
docker-compose up -d --build
|
||||
```
|
||||
⚠️ **Это удалит все данные в БД!**
|
||||
|
||||
### Генерация SECRET_KEY:
|
||||
```bash
|
||||
python3 -c "import secrets; print(secrets.token_urlsafe(64))"
|
||||
```
|
||||
|
||||
## Шаг 4: Исправление прав entrypoint.sh и файлов проекта
|
||||
|
||||
```bash
|
||||
# Конвертируем из Windows формата (CRLF -> LF)
|
||||
cd /Volume1/DockerAppsData/mixapp/app/myproject/docker
|
||||
sed -i 's/\r$//' entrypoint.sh
|
||||
chmod +x entrypoint.sh
|
||||
|
||||
# Убедитесь, что весь проект принадлежит UID 1000 (appuser внутри контейнера)
|
||||
cd /Volume1/DockerAppsData/mixapp/app
|
||||
chown -R 1000:1000 myproject
|
||||
chmod -R u+rwX myproject
|
||||
```
|
||||
|
||||
## Шаг 5: Сборка и запуск
|
||||
|
||||
```bash
|
||||
cd /Volume1/DockerYAML/mix
|
||||
|
||||
# Запускаем в фоновом режиме (автоматически соберет образы, используя пути из yml)
|
||||
docker-compose up -d --build
|
||||
|
||||
# Проверяем статус
|
||||
docker-compose ps
|
||||
|
||||
# Смотрим логи
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
## Шаг 6: Первичная настройка
|
||||
|
||||
При первом запуске автоматически:
|
||||
1. Создаётся база данных PostgreSQL
|
||||
2. Применяются миграции Django
|
||||
3. Собираются статические файлы
|
||||
4. Создаётся public tenant
|
||||
5. **Создаётся PlatformAdmin** из переменных `PLATFORM_ADMIN_*` в `.env.docker`
|
||||
|
||||
### Проверка работоспособности:
|
||||
```bash
|
||||
# Проверяем что все контейнеры работают
|
||||
docker-compose ps
|
||||
|
||||
# Должны быть в статусе "Up":
|
||||
# - mix_postgres
|
||||
# - mix_redis
|
||||
# - mix_web
|
||||
# - mix_celery_worker
|
||||
# - mix_celery_beat
|
||||
```
|
||||
|
||||
## Шаг 7: Настройка Nginx (на NAS или внешний)
|
||||
|
||||
### Пример конфигурации nginx:
|
||||
|
||||
```nginx
|
||||
# Upstream для Django
|
||||
upstream mix_django {
|
||||
server 127.0.0.1:8000;
|
||||
}
|
||||
|
||||
# HTTP -> HTTPS редирект
|
||||
server {
|
||||
listen 80;
|
||||
server_name mix.smaa.by *.mix.smaa.by;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# HTTPS сервер
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name mix.smaa.by *.mix.smaa.by;
|
||||
|
||||
# SSL сертификаты (Let's Encrypt или свои)
|
||||
ssl_certificate /path/to/fullchain.pem;
|
||||
ssl_certificate_key /path/to/privkey.pem;
|
||||
|
||||
# Безопасность
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
# Лимиты
|
||||
client_max_body_size 50M;
|
||||
|
||||
# Статические файлы
|
||||
location /static/ {
|
||||
alias /Volume1/DockerAppsData/mixapp/static/;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Медиа файлы
|
||||
location /media/ {
|
||||
alias /Volume1/DockerAppsData/mixapp/media/;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
# Django приложение
|
||||
location / {
|
||||
proxy_pass http://mix_django;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket support (если понадобится)
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# Таймауты
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Создание тенантов (магазинов)
|
||||
|
||||
**Рекомендуемый способ:** Через форму регистрации и активацию в админке.
|
||||
|
||||
1. Пользователь заполняет форму на `https://mix.smaa.by/register/`
|
||||
2. PlatformAdmin входит в `https://mix.smaa.by/admin/`
|
||||
3. В разделе "Заявки на регистрацию" нажимает "Активировать"
|
||||
|
||||
При активации автоматически:
|
||||
- Создаётся тенант и домен
|
||||
- Применяются миграции
|
||||
- Создаётся владелец (CustomUser)
|
||||
- Инициализируются данные (склад, статусы и т.д.)
|
||||
- Отправляется email с ссылкой для установки пароля
|
||||
|
||||
### Архитектура пользователей
|
||||
|
||||
| Модель | Schema | Доступ |
|
||||
|--------|--------|--------|
|
||||
| `PlatformAdmin` | public | Django Admin везде |
|
||||
| `CustomUser` | tenant | Только фронтенд |
|
||||
|
||||
PlatformAdmin может войти в Django Admin любого тенанта для техподдержки.
|
||||
|
||||
## Полезные команды
|
||||
|
||||
```bash
|
||||
# Переходим в папку с docker-compose.yml
|
||||
cd /Volume1/DockerYAML/mix
|
||||
|
||||
# Перезапуск всех контейнеров
|
||||
docker-compose restart
|
||||
|
||||
# Перезапуск только web
|
||||
docker-compose restart web
|
||||
|
||||
# Просмотр логов конкретного сервиса
|
||||
docker-compose logs -f web
|
||||
docker-compose logs -f celery-worker
|
||||
|
||||
# Выполнение команд Django
|
||||
docker-compose exec web python manage.py shell
|
||||
docker-compose exec web python manage.py createsuperuser
|
||||
docker-compose exec web python manage.py migrate_schemas --shared
|
||||
|
||||
# Остановка
|
||||
docker-compose down
|
||||
|
||||
# Остановка с удалением volumes (ОСТОРОЖНО - удалит данные!)
|
||||
docker-compose down -v
|
||||
```
|
||||
|
||||
## Резервное копирование
|
||||
|
||||
### База данных:
|
||||
```bash
|
||||
# Создание бэкапа
|
||||
docker-compose exec db pg_dump -U postgres inventory_db > backup_$(date +%Y%m%d).sql
|
||||
|
||||
# Восстановление
|
||||
cat backup.sql | docker-compose exec -T db psql -U postgres inventory_db
|
||||
```
|
||||
|
||||
### Медиа файлы:
|
||||
```bash
|
||||
# Бэкап медиа
|
||||
tar -czvf media_backup_$(date +%Y%m%d).tar.gz /Volume1/DockerAppsData/mixapp/media/
|
||||
```
|
||||
|
||||
## Обновление приложения
|
||||
|
||||
```bash
|
||||
cd /Volume1/DockerYAML/mix
|
||||
|
||||
# Остановить контейнеры
|
||||
docker-compose down
|
||||
|
||||
# Обновить код в /Volume1/DockerAppsData/mixapp/app/
|
||||
# (скопировать новые файлы myproject/, requirements.txt, docker/)
|
||||
|
||||
# ⚠️ КРИТИЧЕСКИ ВАЖНО: После КАЖДОГО обновления кода настроить права доступа!
|
||||
cd /Volume1/DockerAppsData/mixapp/app
|
||||
chown -R 1000:1000 myproject
|
||||
chmod -R u+rwX myproject
|
||||
|
||||
# Вернуться в папку с docker-compose.yml
|
||||
cd /Volume1/DockerYAML/mix
|
||||
|
||||
# Пересобрать образы
|
||||
docker-compose build --no-cache
|
||||
|
||||
# Запустить
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
**⚠️ ВАЖНО:** Если после обновления кода контейнеры не запускаются или тенанты недоступны (404),
|
||||
первым делом проверьте права доступа командами выше!
|
||||
|
||||
## Мониторинг
|
||||
|
||||
```bash
|
||||
# Статус контейнеров
|
||||
docker-compose ps
|
||||
|
||||
# Использование ресурсов
|
||||
docker stats
|
||||
|
||||
# Логи в реальном времени
|
||||
docker-compose logs -f --tail=100
|
||||
```
|
||||
|
||||
## Возможные проблемы
|
||||
|
||||
### 1. Ошибка "permission denied" для entrypoint.sh
|
||||
```bash
|
||||
sed -i 's/\r$//' entrypoint.sh
|
||||
chmod +x entrypoint.sh
|
||||
```
|
||||
|
||||
### 2. PostgreSQL не запускается
|
||||
```bash
|
||||
# Проверьте логи
|
||||
docker-compose logs db
|
||||
|
||||
# Возможно нужны права на директорию
|
||||
sudo chown -R 999:999 /Volume1/DockerAppsData/mixapp/postgres
|
||||
```
|
||||
|
||||
### 3. Celery не подключается к Redis
|
||||
```bash
|
||||
# Проверьте что Redis запущен
|
||||
docker-compose logs redis
|
||||
|
||||
# Проверьте сеть
|
||||
docker-compose exec web ping redis
|
||||
```
|
||||
|
||||
### 4. Статические файлы не отображаются
|
||||
```bash
|
||||
# Пересоберите статику
|
||||
docker-compose exec web python manage.py collectstatic --noinput
|
||||
|
||||
# Проверьте права
|
||||
sudo chown -R 1000:1000 /Volume1/DockerAppsData/mixapp/static
|
||||
```
|
||||
|
||||
### 5. Ошибка "password authentication failed for user postgres"
|
||||
|
||||
**Причина:** `DB_PASSWORD` и `POSTGRES_PASSWORD` в `.env.docker` не совпадают, или в `docker-compose.yml` у сервиса `db` есть блок `environment` с дефолтными значениями `${DB_PASSWORD:-postgres}`.
|
||||
|
||||
**Решение:**
|
||||
1. Откройте `/Volume1/DockerAppsData/mixapp/app/myproject/docker/.env.docker`
|
||||
2. Убедитесь, что `DB_PASSWORD` и `POSTGRES_PASSWORD` **ОДИНАКОВЫЕ**
|
||||
3. Проверьте `docker-compose.yml` — у сервиса `db` должно быть только `env_file`, **БЕЗ блока `environment`**:
|
||||
```yaml
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
env_file:
|
||||
- /Volume1/DockerAppsData/mixapp/app/myproject/docker/.env.docker
|
||||
# НЕ ДОЛЖНО БЫТЬ:
|
||||
# environment:
|
||||
# - POSTGRES_PASSWORD=${DB_PASSWORD:-postgres}
|
||||
```
|
||||
4. Пересоздайте базу данных:
|
||||
```bash
|
||||
cd /Volume1/DockerYAML/mix
|
||||
docker-compose down
|
||||
rm -rf /Volume1/DockerAppsData/mixapp/postgres
|
||||
docker-compose up -d --build
|
||||
```
|
||||
5. Проверьте, что пароли совпадают внутри контейнера:
|
||||
```bash
|
||||
docker exec -it mix_postgres env | grep -E 'POSTGRES_PASSWORD|DB_PASSWORD'
|
||||
```
|
||||
Оба должны показывать одинаковое значение.
|
||||
|
||||
### 6. Ошибка "Permission denied" для /app/manage.py
|
||||
|
||||
**Причина:** Файлы проекта на хосте принадлежат не тому пользователю (не UID 1000).
|
||||
|
||||
**Решение:**
|
||||
```bash
|
||||
cd /Volume1/DockerAppsData/mixapp/app
|
||||
chown -R 1000:1000 myproject
|
||||
chmod -R u+rwX myproject
|
||||
|
||||
cd /Volume1/DockerYAML/mix
|
||||
docker-compose restart web celery-worker celery-beat
|
||||
```
|
||||
|
||||
### 7. Новый тенант создан, но его поддомен возвращает HTTP 404
|
||||
|
||||
**Причина:** Чаще всего — контейнер `web` постоянно перезапускается из-за `Permission denied` на `/app/manage.py`, и Django не успевает запуститься.
|
||||
|
||||
**Диагностика:**
|
||||
|
||||
1. Проверьте, что домен создан в базе:
|
||||
```bash
|
||||
docker exec -it mix_web python manage.py shell
|
||||
```
|
||||
```python
|
||||
from tenants.models import Client, Domain
|
||||
for domain in Domain.objects.all():
|
||||
print(f"Domain: {domain.domain}, Tenant: {domain.tenant.schema_name}")
|
||||
```
|
||||
Если домен есть — проблема не в базе.
|
||||
|
||||
2. Посмотрите логи контейнера:
|
||||
```bash
|
||||
docker logs -f mix_web
|
||||
```
|
||||
|
||||
Если видите множество повторяющихся сообщений:
|
||||
```
|
||||
python: can't open file '/app/manage.py': [Errno 13] Permission denied
|
||||
Waiting for PostgreSQL...
|
||||
```
|
||||
|
||||
Это значит, что контейнер падает и перезапускается.
|
||||
|
||||
**Решение:**
|
||||
```bash
|
||||
cd /Volume1/DockerAppsData/mixapp/app
|
||||
chown -R 1000:1000 myproject
|
||||
chmod -R u+rwX myproject
|
||||
|
||||
cd /Volume1/DockerYAML/mix
|
||||
docker-compose restart web celery-worker celery-beat
|
||||
```
|
||||
|
||||
После этого контейнер перестанет падать, Django запустится, и поддомен тенанта станет доступен.
|
||||
@@ -259,16 +259,14 @@ TENANT_DOMAIN_BASE = env('TENANT_DOMAIN_BASE', default='localhost:8000')
|
||||
# Использовать HTTPS для ссылок (в проде True, локально False)
|
||||
USE_HTTPS = env.bool('USE_HTTPS', default=False)
|
||||
|
||||
# Email техподдержки платформы (создаётся скрытый аккаунт в каждом тенанте)
|
||||
PLATFORM_SUPPORT_EMAIL = env('PLATFORM_SUPPORT_EMAIL', default=None)
|
||||
|
||||
|
||||
# ============================================
|
||||
# SESSION CONFIGURATION
|
||||
# ============================================
|
||||
|
||||
# Время жизни сессии: 4 недели (в секундах)
|
||||
SESSION_COOKIE_AGE = 2419200 # 28 дней
|
||||
# Время жизни сессии: 7 дней (в секундах)
|
||||
# SECURITY: Сокращено с 28 до 7 дней для уменьшения окна атаки
|
||||
SESSION_COOKIE_AGE = 604800 # 7 дней
|
||||
|
||||
# Продлевать сессию при каждом запросе (токен не устареет, если пользователь активен)
|
||||
SESSION_SAVE_EVERY_REQUEST = True
|
||||
@@ -283,6 +281,11 @@ SESSION_COOKIE_SECURE = USE_HTTPS
|
||||
CSRF_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
|
||||
# SECURITY: SameSite защита от CSRF атак
|
||||
# Lax - позволяет работать с поддоменами, но защищает от внешних сайтов
|
||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||
CSRF_COOKIE_SAMESITE = 'Lax'
|
||||
|
||||
|
||||
# ============================================
|
||||
# STATIC FILES (CSS, JavaScript, Images)
|
||||
|
||||
@@ -1,545 +0,0 @@
|
||||
# -*- 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',
|
||||
},
|
||||
},
|
||||
}
|
||||
66
myproject/scripts/check_duplicates.py
Normal file
66
myproject/scripts/check_duplicates.py
Normal file
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
|
||||
# Setup Django
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'myproject'))
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
||||
django.setup()
|
||||
|
||||
from inventory.models import Reservation
|
||||
from django.db.models import Count
|
||||
|
||||
# Найти дубликаты резервов трансформаций
|
||||
duplicates = Reservation.objects.filter(
|
||||
transformation_input__isnull=False
|
||||
).values(
|
||||
'transformation_input', 'product', 'warehouse'
|
||||
).annotate(
|
||||
count=Count('id')
|
||||
).filter(
|
||||
count__gt=1
|
||||
)
|
||||
|
||||
print(f"Found {duplicates.count()} duplicate transformation reservations")
|
||||
|
||||
if duplicates.exists():
|
||||
print("\nDuplicate groups:")
|
||||
for dup in duplicates[:10]:
|
||||
print(f" TransformationInput ID: {dup['transformation_input']}, "
|
||||
f"Product ID: {dup['product']}, "
|
||||
f"Warehouse ID: {dup['warehouse']}, "
|
||||
f"Count: {dup['count']}")
|
||||
|
||||
# Show actual reservations
|
||||
reservations = Reservation.objects.filter(
|
||||
transformation_input_id=dup['transformation_input'],
|
||||
product_id=dup['product'],
|
||||
warehouse_id=dup['warehouse']
|
||||
)
|
||||
for res in reservations:
|
||||
print(f" - Reservation ID {res.id}: quantity={res.quantity}, status={res.status}")
|
||||
|
||||
print("\n--- CLEANING DUPLICATES ---")
|
||||
|
||||
# Для каждой группы дубликатов оставляем только один резерв
|
||||
for dup in duplicates:
|
||||
reservations = Reservation.objects.filter(
|
||||
transformation_input_id=dup['transformation_input'],
|
||||
product_id=dup['product'],
|
||||
warehouse_id=dup['warehouse']
|
||||
).order_by('id')
|
||||
|
||||
# Оставляем первый, удаляем остальные
|
||||
first = reservations.first()
|
||||
others = reservations.exclude(id=first.id)
|
||||
count = others.count()
|
||||
|
||||
if count > 0:
|
||||
others.delete()
|
||||
print(f"Deleted {count} duplicate reservations for TransformationInput {dup['transformation_input']}")
|
||||
|
||||
print("\n--- DONE ---")
|
||||
else:
|
||||
print("No duplicates found!")
|
||||
124
myproject/scripts/cleanup_stuck_photos.py
Normal file
124
myproject/scripts/cleanup_stuck_photos.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
Скрипт для очистки застрявших записей фото в БД.
|
||||
Используется когда файлы были удалены вручную до обработки Celery.
|
||||
|
||||
Использование:
|
||||
docker exec -it mix_web python manage.py shell < cleanup_stuck_photos.py
|
||||
|
||||
Или интерактивно:
|
||||
docker exec -it mix_web python manage.py shell
|
||||
>>> exec(open('cleanup_stuck_photos.py').read())
|
||||
"""
|
||||
|
||||
from django.db import connection
|
||||
from products.models import ProductPhoto, ProductKitPhoto, ProductCategoryPhoto, PhotoProcessingStatus
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
# Укажите schema_name вашего тенанта
|
||||
SCHEMA_NAME = 'mixflowers'
|
||||
|
||||
# Активируем схему
|
||||
connection.set_schema(SCHEMA_NAME)
|
||||
print(f"✓ Activated schema: {SCHEMA_NAME}")
|
||||
|
||||
# Функция для проверки и очистки фото
|
||||
def cleanup_photo_model(model_class, model_name):
|
||||
"""Очищает записи с несуществующими файлами для указанной модели"""
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Checking {model_name}...")
|
||||
print(f"{'='*60}")
|
||||
|
||||
photos = model_class.objects.all()
|
||||
total = photos.count()
|
||||
print(f"Total {model_name} records: {total}")
|
||||
|
||||
if total == 0:
|
||||
print(f"No {model_name} records found.")
|
||||
return
|
||||
|
||||
missing_files = []
|
||||
|
||||
for photo in photos:
|
||||
if photo.image:
|
||||
file_path = photo.image.name
|
||||
exists = default_storage.exists(file_path)
|
||||
|
||||
if not exists:
|
||||
missing_files.append({
|
||||
'id': photo.id,
|
||||
'path': file_path,
|
||||
'entity': photo.get_entity(),
|
||||
'photo': photo
|
||||
})
|
||||
print(f" ✗ Photo #{photo.id}: File NOT found: {file_path}")
|
||||
else:
|
||||
print(f" ✓ Photo #{photo.id}: File exists: {file_path}")
|
||||
|
||||
if missing_files:
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Found {len(missing_files)} {model_name} with missing files:")
|
||||
print(f"{'='*60}")
|
||||
|
||||
for item in missing_files:
|
||||
print(f"\nPhoto ID: {item['id']}")
|
||||
print(f" Entity: {item['entity']}")
|
||||
print(f" Missing file: {item['path']}")
|
||||
|
||||
# Спрашиваем подтверждение
|
||||
print(f"\n{'='*60}")
|
||||
response = input(f"Delete these {len(missing_files)} {model_name} records? (yes/no): ").strip().lower()
|
||||
|
||||
if response == 'yes':
|
||||
deleted_count = 0
|
||||
for item in missing_files:
|
||||
try:
|
||||
item['photo'].delete()
|
||||
deleted_count += 1
|
||||
print(f" ✓ Deleted Photo #{item['id']}")
|
||||
except Exception as e:
|
||||
print(f" ✗ Error deleting Photo #{item['id']}: {e}")
|
||||
|
||||
print(f"\n✓ Deleted {deleted_count} {model_name} records")
|
||||
else:
|
||||
print(f"\nSkipped deletion for {model_name}")
|
||||
else:
|
||||
print(f"\n✓ All {model_name} files exist. No cleanup needed.")
|
||||
|
||||
# Очищаем каждую модель
|
||||
cleanup_photo_model(ProductPhoto, "ProductPhoto")
|
||||
cleanup_photo_model(ProductKitPhoto, "ProductKitPhoto")
|
||||
cleanup_photo_model(ProductCategoryPhoto, "ProductCategoryPhoto")
|
||||
|
||||
# Проверяем застрявшие статусы обработки
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Checking PhotoProcessingStatus...")
|
||||
print(f"{'='*60}")
|
||||
|
||||
stuck_statuses = PhotoProcessingStatus.objects.filter(
|
||||
status__in=['pending', 'processing']
|
||||
).order_by('-created_at')
|
||||
|
||||
if stuck_statuses.exists():
|
||||
print(f"Found {stuck_statuses.count()} stuck processing statuses:")
|
||||
for status in stuck_statuses:
|
||||
print(f"\n Photo ID: {status.photo_id}")
|
||||
print(f" Model: {status.photo_model}")
|
||||
print(f" Status: {status.status}")
|
||||
print(f" Created: {status.created_at}")
|
||||
print(f" Task ID: {status.task_id}")
|
||||
|
||||
response = input(f"\nMark these as 'failed'? (yes/no): ").strip().lower()
|
||||
if response == 'yes':
|
||||
updated = stuck_statuses.update(
|
||||
status='failed',
|
||||
error_message='Marked as failed during cleanup (file was deleted before processing)'
|
||||
)
|
||||
print(f"\n✓ Updated {updated} processing statuses to 'failed'")
|
||||
else:
|
||||
print("\nSkipped updating processing statuses")
|
||||
else:
|
||||
print("✓ No stuck processing statuses found")
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("Cleanup complete!")
|
||||
print(f"{'='*60}")
|
||||
@@ -5,7 +5,6 @@
|
||||
Единственный источник истины для создания тенанта и всех связанных сущностей.
|
||||
"""
|
||||
import logging
|
||||
import secrets
|
||||
from django.db import connection, transaction
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
@@ -229,8 +228,11 @@ class TenantOnboardingService:
|
||||
else:
|
||||
logger.warning(f"Пользователь {registration.owner_email} уже существует")
|
||||
|
||||
# 2. Техподдержка платформы (скрытый аккаунт)
|
||||
cls._create_platform_support_user(registration)
|
||||
# 2. Техподдержка платформы
|
||||
# SECURITY: НЕ создаём отдельную учетку CustomUser для саппорта
|
||||
# PlatformAdmin с is_superuser=True уже имеет полный доступ к tenant
|
||||
# через PlatformAdminBackend и TenantAdminAccessMiddleware
|
||||
logger.info("Техподдержка: используется PlatformAdmin (отдельный CustomUser НЕ создаётся)")
|
||||
|
||||
@classmethod
|
||||
def _assign_owner_role(cls, owner):
|
||||
@@ -245,83 +247,6 @@ class TenantOnboardingService:
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при назначении роли: {e}", exc_info=True)
|
||||
|
||||
@classmethod
|
||||
def _create_platform_support_user(cls, registration: TenantRegistration):
|
||||
"""
|
||||
Создание скрытого аккаунта техподдержки платформы.
|
||||
|
||||
- Email берётся из настроек PLATFORM_SUPPORT_EMAIL
|
||||
- Пароль генерируется уникальный для каждого тенанта
|
||||
- Пароль выводится в лог (для владельца платформы)
|
||||
- Пользователь не виден владельцу тенанта
|
||||
"""
|
||||
from accounts.models import CustomUser
|
||||
from user_roles.services import RoleService
|
||||
from user_roles.models import Role
|
||||
|
||||
support_email = getattr(settings, 'PLATFORM_SUPPORT_EMAIL', None)
|
||||
if not support_email:
|
||||
logger.info("PLATFORM_SUPPORT_EMAIL не задан - пропускаем создание техподдержки")
|
||||
return
|
||||
|
||||
if CustomUser.objects.filter(email=support_email).exists():
|
||||
logger.info(f"Техподдержка {support_email} уже существует в этом тенанте")
|
||||
return
|
||||
|
||||
# Генерируем уникальный пароль для этого тенанта
|
||||
password = secrets.token_urlsafe(16)
|
||||
|
||||
support_user = CustomUser.objects.create_user(
|
||||
email=support_email,
|
||||
name='Техподдержка',
|
||||
password=password,
|
||||
)
|
||||
support_user.is_email_confirmed = True
|
||||
support_user.email_confirmed_at = timezone.now()
|
||||
support_user.is_active = True
|
||||
support_user.is_superuser = True
|
||||
support_user.save()
|
||||
|
||||
# Назначаем роль platform_support
|
||||
RoleService.assign_role_to_user(support_user, Role.PLATFORM_SUPPORT, created_by=None)
|
||||
|
||||
# Выводим пароль в лог (безопасно, т.к. логи доступны только владельцу платформы)
|
||||
logger.info(
|
||||
f"[PLATFORM_SUPPORT] Тенант: {registration.schema_name} | "
|
||||
f"Email: {support_email} | Пароль: {password}"
|
||||
)
|
||||
|
||||
# Сохраняем credentials в файл
|
||||
cls._save_support_credentials(registration, support_email, password)
|
||||
|
||||
@classmethod
|
||||
def _save_support_credentials(cls, registration: TenantRegistration, email: str, password: str):
|
||||
"""
|
||||
Сохраняет credentials техподдержки в файл.
|
||||
|
||||
Формат: домен:логин:пароль
|
||||
Файл: support_credentials.txt в корне проекта
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Корень проекта (где manage.py)
|
||||
project_root = Path(settings.BASE_DIR)
|
||||
credentials_file = project_root / 'support_credentials.txt'
|
||||
|
||||
# Формируем домен тенанта
|
||||
domain_base = settings.TENANT_DOMAIN_BASE
|
||||
if ':' in domain_base:
|
||||
domain_base = domain_base.split(':')[0]
|
||||
tenant_domain = f"{registration.schema_name}.{domain_base}"
|
||||
|
||||
# Добавляем строку в файл
|
||||
try:
|
||||
with open(credentials_file, 'a', encoding='utf-8') as f:
|
||||
f.write(f"{tenant_domain}:{email}:{password}\n")
|
||||
logger.info(f"Credentials сохранены в {credentials_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка сохранения credentials: {e}")
|
||||
|
||||
@classmethod
|
||||
def _init_tenant_data(cls):
|
||||
|
||||
@@ -1,540 +0,0 @@
|
||||
# Анализ тестового покрытия модуля Customers
|
||||
|
||||
**Дата анализа:** 27.12.2025
|
||||
**Общее количество тестов:** 50 ✅ (было 45, добавлено 5)
|
||||
**Статус выполнения:** ✅ Все тесты проходят
|
||||
**Последнее обновление:** 28.12.2025 00:20
|
||||
|
||||
---
|
||||
|
||||
## 📊 Текущее тестовое покрытие
|
||||
|
||||
### 1. **Тесты стратегий поиска (9 тестов)** ✅ Отлично (оптимизировано!)
|
||||
|
||||
**Файл:** `customers/tests/test_search_strategies.py`
|
||||
**Класс:** `DetermineSearchStrategyTestCase`
|
||||
|
||||
**Что покрывается:**
|
||||
- ✅ Поиск по префиксу email (team_x3m@) - параметризованный тест
|
||||
- ✅ Поиск по домену email (@bk.ru) - параметризованный тест
|
||||
- ✅ Полный поиск email (test@bk.ru) - параметризованный тест
|
||||
- ✅ Универсальный поиск (natul, Test123, кириллица, пробелы) - параметризованный тест
|
||||
- ✅ Поиск только по имени (короткие запросы) - параметризованный тест
|
||||
- ✅ Edge cases (пустая строка, множественные @)
|
||||
- ✅ Real-world критичные сценарии
|
||||
|
||||
**Оценка:** 🟢 **Отличное покрытие**
|
||||
**Улучшения:** Сокращено с 23 до 9 тестов через параметризацию, покрытие осталось прежним.
|
||||
|
||||
---
|
||||
|
||||
### 2. **Тесты валидации телефонных запросов (17 тестов)** ✅ Отлично (исправлено!)
|
||||
|
||||
**Файл:** `customers/tests/test_search_strategies.py`
|
||||
**Класс:** `IsQueryPhoneOnlyTestCase`
|
||||
|
||||
**Что покрывается:**
|
||||
- ✅ Различные форматы телефонов (+375, 029, скобки, тире)
|
||||
- ✅ Отсечка нетелефонных запросов (буквы, спецсимволы)
|
||||
- ✅ Edge cases (пустая строка, только пробелы)
|
||||
- ✅ **ИСПРАВЛЕНО:** Пробелы теперь возвращают False (логичнее)
|
||||
|
||||
**Оценка:** 🟢 **Отличное покрытие**
|
||||
**Улучшения:** Исправлена логика - пустые запросы и только пробелы теперь корректно возвращают False.
|
||||
|
||||
---
|
||||
|
||||
### 3. **Тесты вычисления баланса кошелька (6 тестов)** ✅ Базовое покрытие
|
||||
|
||||
**Файл:** `customers/tests/test_wallet_balance.py`
|
||||
**Класс:** `WalletBalanceCalculationTestCase`
|
||||
|
||||
**Что покрывается:**
|
||||
- ✅ Пустой кошелёк возвращает 0
|
||||
- ✅ Одиночное пополнение
|
||||
- ✅ Одиночное списание
|
||||
- ✅ Множественные операции
|
||||
- ✅ Property `amount` возвращает абсолютное значение
|
||||
- ✅ Инвалидация кеша
|
||||
|
||||
**Оценка:** 🟡 **Базовое покрытие, можно улучшить**
|
||||
|
||||
---
|
||||
|
||||
### 4. **Тесты защиты системного клиента (5 тестов)** ✅ Отличное покрытие
|
||||
|
||||
**Файл:** `customers/tests/test_system_customer.py`
|
||||
**Класс:** `SystemCustomerProtectionTestCase`
|
||||
|
||||
**Что покрывается:**
|
||||
- ✅ `get_or_create_system_customer()` создаёт клиента с правильными атрибутами
|
||||
- ✅ Защита от удаления системного клиента (поднимается ValidationError)
|
||||
- ✅ Защита email системного клиента от изменения
|
||||
- ✅ Защита флага is_system_customer от изменения
|
||||
- ✅ Обычные клиенты могут удаляться нормально
|
||||
|
||||
**Оценка:** <20>︢ **Отличное покрытие**
|
||||
**Важность:** 🔴 **Критично для работы POS системы**
|
||||
|
||||
---
|
||||
|
||||
### 5. **Тесты WalletService (7 тестов)** ✅ Хорошее покрытие
|
||||
|
||||
**Файл:** `customers/tests/test_wallet_service.py`
|
||||
**Класс:** `WalletServiceTestCase`
|
||||
|
||||
**Что покрывается:**
|
||||
- ✅ Создание транзакций deposit
|
||||
- ✅ Создание транзакций spend
|
||||
- ✅ Проверка недостаточности средств
|
||||
- ✅ Положительная корректировка баланса
|
||||
- ✅ Отрицательная корректировка
|
||||
- ✅ Валидация описания (обязательность)
|
||||
- ✅ Валидация нулевой суммы
|
||||
|
||||
**Оценка:** 🟢 **Хорошее покрытие**
|
||||
|
||||
---
|
||||
|
||||
### 6. **Тесты модели WalletTransaction (3 теста)** 🔴 Слабое покрытие
|
||||
|
||||
**Файл:** `customers/tests/test_wallet_model.py`
|
||||
**Класс:** `WalletTransactionModelTestCase`
|
||||
|
||||
**Что покрывается:**
|
||||
- ✅ Строковое представление (+/-)
|
||||
- ✅ Дефолтное значение balance_category
|
||||
|
||||
**Оценка:** 🔴 **Очень слабое покрытие**
|
||||
|
||||
---
|
||||
|
||||
## ✅ ВЫПОЛНЕННЫЕ УЛУЧШЕНИЯ
|
||||
|
||||
### 1. **Оптимизация тестов поиска** ✅ Завершено
|
||||
|
||||
**Было:** 23 теста с дублированием
|
||||
**Стало:** 9 тестов с параметризацией
|
||||
|
||||
**Что сделано:**
|
||||
- Объединены тесты email_prefix (3→1 параметризованный)
|
||||
- Объединены тесты email_domain (3→1 параметризованный)
|
||||
- Объединены тесты email_full (3→1 параметризованный)
|
||||
- Объединены тесты universal (5→1 параметризованный)
|
||||
- Объединены тесты name_only (4→1 параметризованный)
|
||||
- Сохранены отдельные тесты для edge cases и real-world критичных сценариев
|
||||
|
||||
**Результат:**
|
||||
- ✅ Код тестов стал компактнее и читабельнее
|
||||
- ✅ Покрытие осталось 100% прежним
|
||||
- ✅ Добавлен вспомогательный метод `_test_strategy()` для уменьшения дублирования
|
||||
|
||||
---
|
||||
|
||||
### 2. **Исправление странной логики `is_query_phone_only`** ✅ Завершено
|
||||
|
||||
**Было:** Пробелы ' ' возвращали True (нелогично)
|
||||
**Стало:** Пробелы возвращают False
|
||||
|
||||
**Что изменено в коде:**
|
||||
```
|
||||
# Было:
|
||||
if not query:
|
||||
return False
|
||||
return all(c in phone_chars for c in query)
|
||||
|
||||
# Стало:
|
||||
if not query or not query.strip():
|
||||
return False
|
||||
|
||||
if not all(c in phone_chars for c in query):
|
||||
return False
|
||||
|
||||
# Проверяем, что есть хотя бы одна цифра
|
||||
return any(c.isdigit() for c in query)
|
||||
```
|
||||
|
||||
**Результат:**
|
||||
- ✅ Логика стала правильной: пустые запросы → False
|
||||
- ✅ Запросы только из пробелов → False
|
||||
- ✅ Требуется хотя бы одна цифра для распознавания как телефона
|
||||
- ✅ Все 45 тестов проходят успешно
|
||||
|
||||
### 3. **Добавление тестов защиты системного клиента** ✅ Завершено
|
||||
|
||||
**Было:** 0 тестов защиты
|
||||
**Стало:** 5 полноценных тестов
|
||||
|
||||
**Что сделано:**
|
||||
- ✅ Создан новый класс `SystemCustomerProtectionTestCase` в `test_system_customer.py`
|
||||
- ✅ Покрыты все критичные сценарии защиты:
|
||||
- Создание системного клиента с правильными атрибутами
|
||||
- Защита от удаления
|
||||
- Защита email от изменения
|
||||
- Защита флага is_system_customer
|
||||
- Проверка, что обычные клиенты не затронуты
|
||||
- ✅ Исправлена логика в `Customer.save()` - проверка original.is_system_customer
|
||||
- ✅ Учтена tenant-система (используется TenantTestCase)
|
||||
|
||||
**Результат:**
|
||||
- ✅ Все 5 тестов проходят успешно
|
||||
- ✅ Критичная функциональность POS системы теперь покрыта тестами
|
||||
- ✅ Невозможно случайно сломать системного клиента
|
||||
|
||||
---
|
||||
|
||||
### 4. **Реорганизация структуры тестов** ✅ Завершено
|
||||
|
||||
**Было:** Монолитный файл `tests.py` на 500+ строк
|
||||
**Стало:** Структурированная папка `customers/tests/` с модулями
|
||||
|
||||
**Новая структура:**
|
||||
```
|
||||
customers/tests/
|
||||
├── __init__.py
|
||||
├── test_search_strategies.py # Тесты поиска и валидации
|
||||
├── test_system_customer.py # Тесты защиты системного клиента
|
||||
├── test_wallet_balance.py # Тесты вычисления баланса
|
||||
├── test_wallet_service.py # Тесты WalletService
|
||||
└── test_wallet_model.py # Тесты модели WalletTransaction
|
||||
```
|
||||
|
||||
**Преимущества:**
|
||||
- ✅ Лучшая организация - тесты сгруппированы по функциональности
|
||||
- ✅ Проще навигация - легко найти нужные тесты
|
||||
- ✅ Лучше поддерживаемость - изменения локализованы
|
||||
- ✅ Удобно добавлять новые тесты в соответствующие модули
|
||||
|
||||
---
|
||||
|
||||
## 🔄 СТАТИСТИКА ИЗМЕНЕНИЙ
|
||||
|
||||
**Было тестов:** 59
|
||||
**После оптимизации:** 45 (-14 тестов, -24%)
|
||||
**Текущее количество:** 50 (+5 новых тестов защиты)
|
||||
**Покрытие:** Осталось на высоком уровне + добавлена критичная защита
|
||||
|
||||
**Улучшения кода:**
|
||||
- Убрано 56 строк дублирующегося кода тестов
|
||||
- Добавлено 10 строк улучшенной логики валидации
|
||||
- Тесты стали более поддерживаемыми
|
||||
|
||||
---
|
||||
|
||||
## ❌ КРИТИЧЕСКИЕ ПРОБЕЛЫ В ТЕСТИРОВАНИИ
|
||||
|
||||
### 🚨 1. **НЕТ тестов модели Customer** (КАТАСТРОФА!)
|
||||
|
||||
**Что НЕ покрыто:**
|
||||
- ❌ Создание клиента с валидными данными
|
||||
- ❌ Валидация email (формат, уникальность?)
|
||||
- ❌ Валидация телефона (форматы 8029, +375, нормализация)
|
||||
- ❌ Нормализация телефона в методе `clean_phone()`
|
||||
- ❌ Защита системного клиента от удаления
|
||||
- ❌ Защита системного клиента от изменения email
|
||||
- ❌ Метод `get_or_create_system_customer()`
|
||||
- ❌ Property `full_name`
|
||||
- ❌ Метод `__str__()` для различных комбинаций полей
|
||||
- ❌ Поведение при пустых полях (name, email, phone)
|
||||
|
||||
**Критичность:** 🔴 **КРИТИЧНО**
|
||||
|
||||
---
|
||||
|
||||
### 🚨 2. **НЕТ интеграционных тестов Views** (ОЧЕНЬ ВАЖНО!)
|
||||
|
||||
**Что НЕ покрыто:**
|
||||
- ❌ `customer_list` - отображение списка, поиск, пагинация
|
||||
- ❌ `customer_detail` - детали клиента, расчёты долга
|
||||
- ❌ `customer_create` - создание через форму
|
||||
- ❌ `customer_update` - редактирование, защита системного клиента
|
||||
- ❌ `customer_delete` - удаление, защита системного
|
||||
- ❌ `api_search_customers` - AJAX поиск
|
||||
- ❌ `api_create_customer` - создание через API
|
||||
|
||||
**Критичность:** 🔴 **КРИТИЧНО** (это основной функционал!)
|
||||
|
||||
---
|
||||
|
||||
### 🚨 3. **НЕТ тестов форм** (ВАЖНО!)
|
||||
|
||||
**Что НЕ покрыто:**
|
||||
- ❌ `CustomerForm` - валидация всех полей
|
||||
- ❌ Нормализация email (пустая строка → None)
|
||||
- ❌ Нормализация телефона (пустая строка → None)
|
||||
- ❌ Защита системного клиента от редактирования
|
||||
- ❌ `ContactChannelForm`
|
||||
|
||||
**Критичность:** 🟠 **ВАЖНО**
|
||||
|
||||
---
|
||||
|
||||
### 🚨 4. **НЕТ тестов импорта/экспорта** (ВАЖНО для бизнеса!)
|
||||
|
||||
**Что НЕ покрыто:**
|
||||
- ❌ `CustomerExporter` - экспорт в CSV/Excel
|
||||
- ❌ `CustomerImporter` - импорт клиентов
|
||||
- ❌ Валидация данных при импорте
|
||||
- ❌ Обработка дубликатов
|
||||
- ❌ Обработка ошибок в файлах
|
||||
|
||||
**Критичность:** 🟠 **ВАЖНО** (потеря данных = потеря денег)
|
||||
|
||||
---
|
||||
|
||||
### 🚨 5. **НЕТ тестов ContactChannel** (Средней важности)
|
||||
|
||||
**Что НЕ покрыто:**
|
||||
- ❌ Модель ContactChannel
|
||||
- ❌ Различные типы каналов (Instagram, Telegram, WhatsApp)
|
||||
- ❌ Связь с клиентом
|
||||
|
||||
**Критичность:** 🟡 **СРЕДНЕ**
|
||||
|
||||
---
|
||||
|
||||
### 🚨 6. **НЕТ тестов оплаты через кошелёк** (КРИТИЧНО для бизнеса!)
|
||||
|
||||
**Что НЕ покрыто:**
|
||||
- ❌ `WalletService.pay_with_wallet()` - оплата заказа
|
||||
- ❌ Частичная оплата из кошелька
|
||||
- ❌ Создание транзакции при оплате
|
||||
- ❌ Обновление статуса заказа
|
||||
- ❌ Интеграция с `TransactionService`
|
||||
|
||||
**Критичность:** 🔴 **КРИТИЧНО** (основной финансовый функционал!)
|
||||
|
||||
---
|
||||
|
||||
### 🚨 7. **НЕТ тестов permissions** (БЕЗОПАСНОСТЬ!)
|
||||
|
||||
**Что НЕ покрыто:**
|
||||
- ❌ Доступ к клиентам только авторизованным пользователям
|
||||
- ❌ Роли (manager, owner) могут управлять клиентами
|
||||
- ❌ Обычные пользователи не могут удалять клиентов
|
||||
- ❌ Защита от cross-tenant доступа (изоляция данных между магазинами)
|
||||
|
||||
**Критичность:** 🔴 **КРИТИЧНО** (безопасность данных!)
|
||||
|
||||
---
|
||||
|
||||
## 😱 ОСОБО ГЛУПЫЕ ПРОПУСКИ
|
||||
|
||||
### 1. **Отсутствие тестов бизнес-логики Customer**
|
||||
|
||||
В модели `Customer` есть сложная логика нормализации телефонов:
|
||||
```python
|
||||
def clean_phone(self):
|
||||
# Парсинг 8029... → +375...
|
||||
# Парсинг BY → RU
|
||||
# E.164 формат
|
||||
```
|
||||
|
||||
**НИ ОДНОГО ТЕСТА НА ЭТО!** 😱
|
||||
|
||||
Это значит:
|
||||
- Если кто-то сломает нормализацию → узнаем только в production
|
||||
- Телефоны могут сохраняться в неправильном формате
|
||||
- Поиск по телефонам может сломаться
|
||||
|
||||
---
|
||||
|
||||
### 2. ~~**Защита системного клиента НЕ ПРОТЕСТИРОВАНА**~~ ✅ ИСПРАВЛЕНО
|
||||
|
||||
~~В модели есть защита:~~
|
||||
```
|
||||
def delete(self, *args, **kwargs):
|
||||
if self.is_system_customer:
|
||||
raise ValidationError("Нельзя удалить...")
|
||||
```
|
||||
|
||||
~~**НИ ОДНОГО ТЕСТА!** 😱~~
|
||||
|
||||
**✅ ИСПРАВЛЕНО 28.12.2025:**
|
||||
- ✅ Добавлено 5 тестов защиты в `test_system_customer.py`
|
||||
- ✅ Защита от удаления, изменения email и флага
|
||||
- ✅ Невозможно случайно сломать POS систему
|
||||
|
||||
---
|
||||
|
||||
### 3. **Расчёты долга клиента НЕ ПРОТЕСТИРОВАНЫ**
|
||||
|
||||
В `customer_detail` есть сложная логика:
|
||||
```
|
||||
total_debt_result = customer.orders.exclude(
|
||||
Q(status__is_negative_end=True) |
|
||||
Q(payment_status='paid')
|
||||
).aggregate(...)
|
||||
```
|
||||
|
||||
**НИ ОДНОГО ТЕСТА!** 😱
|
||||
|
||||
Это значит:
|
||||
- Можем показывать неправильный долг
|
||||
- Клиенты могут быть недовольны
|
||||
- Финансовые потери
|
||||
|
||||
---
|
||||
|
||||
## ~🤔~ ~~СОМНИТЕЛЬНЫЕ ТЕСТЫ~~ ✅ ИСПРАВЛЕНО!
|
||||
|
||||
### ~~❓ 1. Избыточные тесты `determine_search_strategy`~~ ✅ ИСПРАВЛЕНО
|
||||
|
||||
**Было:** 23 теста на одну функцию с большим дублированием
|
||||
**Стало:** 9 компактных параметризованных тестов
|
||||
|
||||
**Что сделано:**
|
||||
- ✅ Создан вспомогательный метод `_test_strategy()` для устранения дублирования
|
||||
- ✅ Тесты объединены в логические группы с параметризацией
|
||||
- ✅ Сохранены критичные real-world сценарии
|
||||
- ✅ Покрытие не пострадало
|
||||
|
||||
~~**Рекомендация:** Сократить до 10-12 тестов, используя параметризацию.~~ **ВЫПОЛНЕНО!**
|
||||
|
||||
---
|
||||
|
||||
### ~~❓ 2. Тест `test_only_spaces` выглядел странно~~ ✅ ИСПРАВЛЕНО
|
||||
|
||||
**Было:**
|
||||
```
|
||||
def test_only_spaces(self):
|
||||
"""Query ' ' должен вернуть True (только пробелы разрешены)"""
|
||||
self.assertTrue(is_query_phone_only(' '))
|
||||
```
|
||||
|
||||
**Стало:**
|
||||
```
|
||||
def test_only_spaces(self):
|
||||
"""Query ' ' должен вернуть False (пустой запрос)"""
|
||||
self.assertFalse(is_query_phone_only(' '))
|
||||
```
|
||||
|
||||
**Изменения в функции `is_query_phone_only()`:**
|
||||
- ✅ Добавлена проверка `if not query.strip()` - отсекает пустые строки
|
||||
- ✅ Добавлено требование наличия хотя бы одной цифры: `any(c.isdigit() for c in query)`
|
||||
- ✅ Логика стала корректной и понятной
|
||||
|
||||
~~**Вопрос:** зачем искать клиента по "трём пробелам"? 🤔~~
|
||||
~~**Рекомендация:** Убрать или изменить логику (пробелы → False).~~ **ВЫПОЛНЕНО!**
|
||||
|
||||
---
|
||||
|
||||
## 📋 ПРИОРИТЕТЫ ДОБАВЛЕНИЯ ТЕСТОВ
|
||||
|
||||
### 🔥 КРИТИЧНЫЕ (добавить СРОЧНО):
|
||||
|
||||
1. ~~**Тесты защиты системного клиента** (5 тестов)~~ ✅ **ВЫПОЛНЕНО 28.12.2025**
|
||||
- ✅ Защита от удаления
|
||||
- ✅ Защита email от изменения
|
||||
- ✅ Защита флага is_system_customer
|
||||
- ✅ `get_or_create_system_customer()` с правильными атрибутами
|
||||
- ✅ Обычные клиенты не затронуты
|
||||
|
||||
2. **Тесты модели Customer** (15+ тестов)
|
||||
- Валидация полей
|
||||
- Нормализация телефона
|
||||
- Методы `__str__()`, `full_name`
|
||||
|
||||
3. **Тесты оплаты через кошелёк** (10+ тестов)
|
||||
- `pay_with_wallet()`
|
||||
- Интеграция с заказами
|
||||
- Частичная оплата
|
||||
|
||||
4. **Тесты permissions и изоляции данных** (15+ тестов)
|
||||
- Авторизация
|
||||
- Роли
|
||||
- Cross-tenant защита
|
||||
|
||||
5. **Тесты View-функций** (30+ тестов)
|
||||
- customer_list
|
||||
- customer_detail
|
||||
- customer_create/update/delete
|
||||
- API endpoints
|
||||
|
||||
---
|
||||
|
||||
### 🟠 ВАЖНЫЕ (добавить в течение месяца):
|
||||
|
||||
6. **Тесты форм** (10+ тестов)
|
||||
- CustomerForm
|
||||
- ContactChannelForm
|
||||
|
||||
7. **Тесты импорта/экспорта** (15+ тестов)
|
||||
- CSV импорт
|
||||
- Excel экспорт
|
||||
- Обработка ошибок
|
||||
|
||||
---
|
||||
|
||||
### 🟡 ЖЕЛАТЕЛЬНЫЕ (когда будет время):
|
||||
|
||||
8. **Тесты ContactChannel** (5+ тестов)
|
||||
9. **Тесты расчётов долга** (10+ тестов)
|
||||
10. **Performance тесты** (поиск по 10000+ клиентам)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 ИТОГОВАЯ ОЦЕНКА
|
||||
|
||||
| Категория | Оценка | Комментарий |
|
||||
|-----------|--------|-------------|
|
||||
| **Utility функции** | 🟢 9/10 | Отлично (поиск, валидация) |
|
||||
| **Модели** | 🔴 1/10 | КАТАСТРОФА! Нет тестов Customer! |
|
||||
| **Views** | 🔴 0/10 | Полное отсутствие |
|
||||
| **Формы** | 🔴 0/10 | Полное отсутствие |
|
||||
| **Сервисы** | 🟡 5/10 | Кошелёк покрыт частично |
|
||||
| **Импорт/Экспорт** | 🔴 0/10 | Полное отсутствие |
|
||||
| **Permissions** | 🔴 0/10 | ОПАСНО! Нет тестов безопасности! |
|
||||
|
||||
**Общая оценка:** 🔴 **2.5/10** - Критически недостаточное покрытие
|
||||
|
||||
---
|
||||
|
||||
## 💡 РЕКОМЕНДАЦИИ
|
||||
|
||||
### Немедленно:
|
||||
1. ✅ ~~Оптимизировать избыточные тесты поиска~~ **ВЫПОЛНЕНО!** (23→9 тестов)
|
||||
2. ✅ ~~Исправить логику `test_only_spaces`~~ **ВЫПОЛНЕНО!**
|
||||
3. ✅ ~~Добавить тесты защиты системного клиента (5 тестов)~~ **ВЫПОЛНЕНО 28.12.2025!**
|
||||
4. ⏳ Добавить базовые тесты модели Customer (15 тестов)
|
||||
5. ⏳ Добавить тесты оплаты через кошелёк (10 тестов)
|
||||
6. ⏳ Добавить тесты permissions (10 тестов)
|
||||
|
||||
### В течение недели:
|
||||
7. Добавить тесты Views (30 тестов)
|
||||
8. Добавить тесты форм (10 тестов)
|
||||
|
||||
### В течение месяца:
|
||||
9. Добавить тесты импорта/экспорта (15 тестов)
|
||||
10. ✅ ~~Рефакторинг: сократить дублирующиеся тесты поиска~~ **ВЫПОЛНЕНО!**
|
||||
|
||||
---
|
||||
|
||||
## 📊 Статистика
|
||||
|
||||
**Текущее состояние:**
|
||||
- ✅ Покрыто: ~30% функционала (увеличено с 25%)
|
||||
- ❌ Не покрыто: ~70% функционала
|
||||
- 🔴 Критические пробелы: 6 областей (было 7 - защита системного клиента покрыта)
|
||||
- ✅ ~~Сомнительные тесты: 3-5 штук~~ **ИСПРАВЛЕНО!**
|
||||
- 💚 Защищённые области: Системный клиент, Wallet, Поиск
|
||||
- 💛 Оптимизированные тесты: 50 (было 59)
|
||||
- 📦 Структура: Тесты разделены по модулям в `customers/tests/`
|
||||
|
||||
**Цель:**
|
||||
- Минимум 80% покрытия кода
|
||||
- 100% покрытие критической бизнес-логики
|
||||
- 100% покрытие финансовых операций
|
||||
|
||||
**Прогресс улучшений:**
|
||||
- ✅ Рефакторинг тестов поиска: -14 тестов, +0% качества
|
||||
- ✅ Исправлена логика валидации телефонов
|
||||
- ✅ Добавлена защита системного клиента: +5 критичных тестов
|
||||
- ⏳ Осталось добавить ~95 критичных тестов
|
||||
|
||||
---
|
||||
|
||||
*Отчёт подготовлен автоматически на основе анализа кода.*
|
||||
*Дата создания: 27.12.2025*
|
||||
*Последнее обновление: 28.12.2025 00:20*
|
||||
*Выполненные улучшения: Рефакторинг избыточных тестов, исправление логики валидации, добавление защиты системного клиента*
|
||||
@@ -1,155 +0,0 @@
|
||||
# Критичные улучшения системы поступления
|
||||
|
||||
## 🔴 Высокий приоритет
|
||||
|
||||
### 1. Безопасность и права доступа
|
||||
**Проблема:** Нет проверки прав доступа в views
|
||||
**Риск:** Любой авторизованный пользователь может подтверждать/отменять документы
|
||||
|
||||
```python
|
||||
# Добавить в views:
|
||||
- PermissionRequiredMixin для confirm/cancel
|
||||
- Проверка прав на склад (может ли пользователь работать с этим складом)
|
||||
- Проверка прав на изменение подтвержденных документов
|
||||
```
|
||||
|
||||
**Файлы:** `inventory/views/incoming_document.py`
|
||||
|
||||
---
|
||||
|
||||
### 2. Тестирование критических путей
|
||||
**Проблема:** Нет автотестов для основных операций
|
||||
**Риск:** Регрессии при изменениях
|
||||
|
||||
**Минимальный набор тестов:**
|
||||
```python
|
||||
# tests/test_incoming_document_service.py
|
||||
- test_create_document()
|
||||
- test_add_item()
|
||||
- test_confirm_document_creates_stock()
|
||||
- test_cancel_document_reverses_stock()
|
||||
- test_cannot_confirm_empty_document()
|
||||
- test_cannot_edit_confirmed_document()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. N+1 запросы в list views
|
||||
**Проблема:** Избыточные запросы к БД при отображении списков
|
||||
**Риск:** Медленная работа при большом количестве документов
|
||||
|
||||
```python
|
||||
# inventory/views/incoming_document.py:27
|
||||
def get_queryset(self):
|
||||
return IncomingDocument.objects.select_related(
|
||||
'warehouse', 'created_by', 'confirmed_by'
|
||||
).prefetch_related(
|
||||
'items__product' # ← Добавить
|
||||
).annotate(
|
||||
items_count=Count('items'), # ← Добавить
|
||||
total_quantity=Sum('items__quantity'), # ← Добавить
|
||||
total_cost=Sum(F('items__quantity') * F('items__cost_price')) # ← Добавить
|
||||
).order_by('-date', '-created_at')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🟡 Средний приоритет
|
||||
|
||||
### 4. ✅ Рефакторинг модельной избыточности (ВЫПОЛНЕНО)
|
||||
**Проблема:** IncomingDocument → IncomingBatch → Incoming создавало 3 уровня данных
|
||||
**Решение:** ✅ Миграция к упрощенной структуре завершена
|
||||
|
||||
**Текущая архитектура:**
|
||||
```
|
||||
IncomingDocument (документ)
|
||||
↓
|
||||
IncomingDocumentItem (позиции документа)
|
||||
↓
|
||||
StockBatch (напрямую создается из items при подтверждении)
|
||||
```
|
||||
|
||||
**Достигнутые результаты:**
|
||||
- ✅ Удалены промежуточные модели Incoming/IncomingBatch
|
||||
- ✅ Упрощен код сигналов (удалены create_stock_batch_on_incoming и update_stock_batch_on_incoming_edit)
|
||||
- ✅ Упрощен IncomingDocumentService.confirm_document() - напрямую создает StockBatch
|
||||
- ✅ Меньше JOIN'ов в запросах
|
||||
- ✅ Применены миграции БД для удаления таблиц
|
||||
|
||||
**Дата выполнения:** 2025-12-26
|
||||
|
||||
---
|
||||
|
||||
### 5. Bulk операции для массового добавления товаров
|
||||
**Проблема:** Добавление товаров по одному при импорте из Excel
|
||||
**Решение:**
|
||||
```python
|
||||
# inventory/services/incoming_document_service.py
|
||||
@staticmethod
|
||||
def add_items_bulk(document, items_data):
|
||||
"""
|
||||
items_data = [
|
||||
{'product': product1, 'quantity': 10, 'cost_price': 100},
|
||||
{'product': product2, 'quantity': 20, 'cost_price': 200},
|
||||
]
|
||||
"""
|
||||
IncomingDocumentItem.objects.bulk_create([
|
||||
IncomingDocumentItem(
|
||||
document=document,
|
||||
product=item['product'],
|
||||
quantity=item['quantity'],
|
||||
cost_price=item['cost_price']
|
||||
)
|
||||
for item in items_data
|
||||
])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Документация и комментарии
|
||||
**Добавить:**
|
||||
- Docstrings с примерами для всех методов service
|
||||
- Диаграмма потока данных в README: `IncomingDocument → IncomingBatch → Incoming → StockBatch → Stock`
|
||||
- Примеры использования API в комментариях
|
||||
|
||||
---
|
||||
|
||||
## 🟢 Низкий приоритет (будущее)
|
||||
|
||||
### 7. Асинхронное подтверждение больших документов
|
||||
**Для документов с 1000+ позициями:**
|
||||
- Celery task для confirm_document()
|
||||
- WebSocket уведомления о прогрессе
|
||||
- Фоновая обработка
|
||||
|
||||
---
|
||||
|
||||
### 8. Аудит и логирование
|
||||
**Отслеживание изменений:**
|
||||
- django-simple-history для IncomingDocument
|
||||
- Логирование всех операций с товарами
|
||||
- История изменений cost_price
|
||||
|
||||
---
|
||||
|
||||
## Порядок внедрения
|
||||
|
||||
1. ✅ **Неделя 1:** Безопасность (права доступ, п.1)
|
||||
2. ✅ **Неделя 2:** Тесты (критические пути, п.2)
|
||||
3. ✅ **Неделя 3:** Производительность (N+1, п.3)
|
||||
4. ✅ **26.12.2025:** Рефакторинг моделей (п.4) - избавились от лишних сущностей
|
||||
5. 📅 **По необходимости:** Bulk операции (п.5), документация (п.6)
|
||||
|
||||
---
|
||||
|
||||
## Метрики успеха
|
||||
|
||||
- ✅ Все критические операции покрыты тестами (>80% coverage)
|
||||
- ✅ Список документов загружается <500ms (при 1000+ документах)
|
||||
- ✅ Нет уязвимостей прав доступа
|
||||
- ✅ Документация актуальна
|
||||
|
||||
---
|
||||
|
||||
**Дата создания:** 2025-12-26
|
||||
**Рейтинг текущей архитектуры:** 8.5/10
|
||||
Reference in New Issue
Block a user