feat: Добавить систему мультитенантности с регистрацией магазинов

Реализована полноценная система мультитенантности на базе django-tenants.
Каждый магазин получает изолированную схему БД и поддомен.

Основные компоненты:

Django-tenants интеграция:
- Модели Client (тенант) и Domain в приложении tenants/
- Разделение на SHARED_APPS и TENANT_APPS
- Public schema для общей админки
- Tenant schemas для изолированных данных магазинов

Система регистрации магазинов:
- Публичная форма регистрации на /register/
- Модель TenantRegistration для заявок со статусами (pending/approved/rejected)
- Валидация schema_name (латиница, 3-63 символа, уникальность)
- Проверка на зарезервированные имена (admin, api, www и т.д.)
- Админ-панель для модерации заявок с кнопками активации/отклонения

Система подписок:
- Модель Subscription с планами (триал 90 дней, месяц, квартал, год)
- Автоматическое создание триальной подписки при активации
- Методы is_expired() и days_left() для проверки статуса
- Цветовая индикация в админке (зеленый/оранжевый/красный)

Приложения:
- tenants/ - управление тенантами, регистрация, подписки
- shops/ - точки магазинов/самовывоза (tenant app)
- Обновлены миграции для всех приложений

Утилиты:
- switch_to_tenant.py - переключение между схемами тенантов
- Обновлены image_processor и image_service

Конфигурация:
- urls_public.py - роуты для public schema (админка + регистрация)
- urls.py - роуты для tenant schemas (магазины)
- requirements.txt - добавлены django-tenants, django-environ, phonenumber-field

Документация:
- DJANGO_TENANTS_SETUP.md - настройка мультитенантности
- TENANT_REGISTRATION_GUIDE.md - руководство по регистрации
- QUICK_START.md - быстрый старт
- START_HERE.md - общая документация

Использование:
1. Пользователь: http://localhost:8000/register/ → заполняет форму
2. Админ: http://localhost:8000/admin/ → активирует заявку
3. Результат: http://{schema_name}.localhost:8000/ - готовый магазин

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-27 19:13:10 +03:00
parent 4b44624f86
commit 097d4ea304
43 changed files with 3186 additions and 553 deletions

View File

@@ -13,22 +13,18 @@ class ImageService:
Динамически строит URL на основе пути к оригинальному файлу.
"""
# Константы для маппинга форматов и расширений файлов
FORMAT_EXTENSIONS = {
'JPEG': 'jpg',
'WEBP': 'webp',
'PNG': 'png',
}
@staticmethod
def _get_config():
"""Получить конфигурацию из settings"""
return getattr(settings, 'IMAGE_PROCESSING_CONFIG', {})
@staticmethod
def _get_size_folders():
"""Получить папки для разных размеров из конфигурации"""
config = ImageService._get_config()
return config.get('folders', {
'thumbnail': 'thumbnails',
'medium': 'medium',
'large': 'large',
'original': 'originals',
})
@staticmethod
def _get_format_config(size_key):
"""Получить конфигурацию формата для заданного типа изображения"""
@@ -37,32 +33,25 @@ class ImageService:
return formats.get(size_key, {'format': 'JPEG', 'quality': 90})
@staticmethod
def _get_file_extension(size_key):
"""Получить расширение файла для заданного типа изображения"""
format_config = ImageService._get_format_config(size_key)
image_format = format_config.get('format', 'JPEG')
def _get_file_extension(image_format):
"""Получить расширение файла для заданного формата"""
return ImageService.FORMAT_EXTENSIONS.get(image_format, 'jpg')
ext_map = {
'JPEG': 'jpg',
'WEBP': 'webp',
'PNG': 'png',
}
return ext_map.get(image_format, 'jpg')
@staticmethod
def _normalize_size_name(size_key):
"""Преобразовать 'thumbnail' в 'thumb' для имени файла"""
return 'thumb' if size_key == 'thumbnail' else size_key
@staticmethod
def get_url(original_image_path, size='medium'):
"""
Получает URL изображения нужного размера.
Работает с новой структурой:
- products/<entity_id>/<photo_id>/original.jpg
- products/<entity_id>/<photo_id>/large.webp
- products/<entity_id>/<photo_id>/medium.webp
- products/<entity_id>/<photo_id>/thumb.webp
Структура хранения: base_path/entity_id/photo_id/size.ext
Пример: products/123/456/medium.webp
Args:
original_image_path: Путь к оригинальному файлу (из models.image)
Обычно это путь к файлу 'original'
Пример: products/123/456/original.jpg
size: Размер ('original', 'large', 'medium', 'thumbnail')
По умолчанию 'medium'
@@ -74,115 +63,29 @@ class ImageService:
return ''
try:
# Работаем с новой структурой: products/<entity_id>/<photo_id>/original.jpg
path_str = str(original_image_path)
parts = path_str.split('/')
if len(parts) >= 3:
# Извлекаем base_path, entity_id, photo_id из пути
base_path = parts[0] # products, kits, categories
entity_id = parts[1] # ID сущности
photo_id = parts[2] # ID фото
# Определяем размер в имени файла
filename = parts[-1] if parts else os.path.basename(path_str)
# Проверяем, является ли это новой структурой
if filename in ['original.jpg', 'large.webp', 'medium.webp', 'thumb.webp']:
# Это новая структура, заменяем только размер
ext_map = {
'original': 'jpg',
'large': 'webp',
'medium': 'webp',
'thumbnail': 'webp',
}
target_ext = ext_map.get(size, 'jpg')
# Переименовываем thumbnail в thumb
final_size_name = 'thumb' if size == 'thumbnail' else size
# Создаем путь в новой структуре
new_path = f"{base_path}/{entity_id}/{photo_id}/{final_size_name}.{target_ext}"
# Проверяем существование файла
if default_storage.exists(new_path):
return f"{settings.MEDIA_URL}{new_path}"
# Если файл не найден, пробуем с другим расширением
# Определяем расширение из конфигурации
format_config = ImageService._get_format_config(size)
image_format = format_config.get('format', 'JPEG')
ext_map_config = {
'JPEG': 'jpg',
'WEBP': 'webp',
'PNG': 'png',
}
target_ext = ext_map_config.get(image_format, 'jpg')
final_size_name = 'thumb' if size == 'thumbnail' else size
fallback_path = f"{base_path}/{entity_id}/{photo_id}/{final_size_name}.{target_ext}"
if default_storage.exists(fallback_path):
return f"{settings.MEDIA_URL}{fallback_path}"
return f"{settings.MEDIA_URL}{path_str}"
# Старая структура для совместимости
filename = os.path.basename(path_str)
# Определяем базовый путь (products, kits, categories)
if len(parts) > 0:
base_path = parts[0]
else:
base_path = 'products'
if len(parts) < 3:
return ''
# Проверяем старый формат имени файла с расширением
# Поддерживаем jpg, webp, png расширения
if filename.endswith(('.jpg', '.webp', '.png')):
# Определяем расширение файла
file_ext = os.path.splitext(filename)[1] # .jpg, .webp и т.д.
filename_without_ext = filename[:-(len(file_ext))] # Имя без расширения
# Извлекаем base_path, entity_id, photo_id из пути
base_path = parts[0] # products, kits, categories
entity_id = parts[1] # ID сущности
photo_id = parts[2] # ID фото
# Разделяем по последнему _ для получения base_filename и size_key
parts_of_name = filename_without_ext.rsplit('_', 1)
# Определяем расширение из конфигурации
format_config = ImageService._get_format_config(size)
image_format = format_config.get('format', 'JPEG')
extension = ImageService._get_file_extension(image_format)
if len(parts_of_name) == 2:
base_filename, file_size_key = parts_of_name
# Это старый формат с явным указанием размера в имени
# Преобразуем thumbnail в thumb
final_size_name = ImageService._normalize_size_name(size)
# Получаем расширение для целевого размера
target_ext = ImageService._get_file_extension(size)
# Создаем путь и возвращаем URL
file_path = f"{base_path}/{entity_id}/{photo_id}/{final_size_name}.{extension}"
return f"{settings.MEDIA_URL}{file_path}"
# Строим папку
size_folders = ImageService._get_size_folders()
folder = size_folders.get(size, 'medium')
# Сначала пытаемся с правильным расширением из конфигурации
filename_new = f"{base_filename}_{size}.{target_ext}"
new_path_primary = f"{base_path}/{folder}/{filename_new}"
# Если файл существует - возвращаем его
if default_storage.exists(new_path_primary):
return f"{settings.MEDIA_URL}{new_path_primary}"
# Иначе пробуем старый формат (все .jpg) для совместимости
filename_fallback = f"{base_filename}_{size}.jpg"
new_path_fallback = f"{base_path}/{folder}/{filename_fallback}"
if default_storage.exists(new_path_fallback):
return f"{settings.MEDIA_URL}{new_path_fallback}"
# Если ничего не найдено, возвращаем путь с новым расширением (браузер покажет ошибку)
return f"{settings.MEDIA_URL}{new_path_primary}"
# Строим новый путь (для старых файлов без новой структуры)
size_folders = ImageService._get_size_folders()
folder = size_folders.get(size, 'medium')
new_path = f"{base_path}/{folder}/{filename}"
# Возвращаем URL
return f"{settings.MEDIA_URL}{new_path}"
except Exception:
return ''