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:
2026-01-09 05:50:42 +03:00
parent 0bddbc08c4
commit 256606f2a0
12 changed files with 208 additions and 2534 deletions

View File

@@ -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
# ============================================

View File

@@ -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',
},
},
}

View File

@@ -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 запустится, и поддомен тенанта станет доступен.

View File

@@ -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)

View File

@@ -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',
},
},
}

View 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!")

View 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}")

View File

@@ -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):

View File

@@ -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*
*Выполненные улучшения: Рефакторинг избыточных тестов, исправление логики валидации, добавление защиты системного клиента*

View File

@@ -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