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:
@@ -1,87 +0,0 @@
|
||||
"""
|
||||
Management команда для обработки существующих изображений товаров, комплектов и категорий.
|
||||
Создает все необходимые размеры (thumbnail, medium, large) для уже загруженных изображений.
|
||||
|
||||
Использование:
|
||||
python manage.py process_images
|
||||
python manage.py process_images --model ProductPhoto
|
||||
python manage.py process_images --model ProductKitPhoto
|
||||
python manage.py process_images --model ProductCategoryPhoto
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from products.models import ProductPhoto, ProductKitPhoto, ProductCategoryPhoto
|
||||
from products.utils.image_processor import ImageProcessor
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Обработка существующих изображений и создание всех необходимых размеров"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--model',
|
||||
type=str,
|
||||
default=None,
|
||||
help='Какую модель обрабатывать (ProductPhoto, ProductKitPhoto, ProductCategoryPhoto)',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
model_name = options.get('model')
|
||||
|
||||
models_to_process = []
|
||||
|
||||
if not model_name:
|
||||
# Обрабатываем все модели
|
||||
models_to_process = [
|
||||
('ProductPhoto', ProductPhoto, 'products'),
|
||||
('ProductKitPhoto', ProductKitPhoto, 'kits'),
|
||||
('ProductCategoryPhoto', ProductCategoryPhoto, 'categories'),
|
||||
]
|
||||
else:
|
||||
# Обрабатываем конкретную модель
|
||||
if model_name == 'ProductPhoto':
|
||||
models_to_process = [('ProductPhoto', ProductPhoto, 'products')]
|
||||
elif model_name == 'ProductKitPhoto':
|
||||
models_to_process = [('ProductKitPhoto', ProductKitPhoto, 'kits')]
|
||||
elif model_name == 'ProductCategoryPhoto':
|
||||
models_to_process = [('ProductCategoryPhoto', ProductCategoryPhoto, 'categories')]
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f'Неизвестная модель: {model_name}')
|
||||
)
|
||||
return
|
||||
|
||||
total_processed = 0
|
||||
total_errors = 0
|
||||
|
||||
for model_display_name, model_class, base_path in models_to_process:
|
||||
self.stdout.write(f'\nОбработка {model_display_name}...')
|
||||
self.stdout.write('-' * 50)
|
||||
|
||||
photos = model_class.objects.filter(image__isnull=False).exclude(image='')
|
||||
|
||||
if not photos.exists():
|
||||
self.stdout.write(self.style.WARNING(f'Нет изображений для обработки в {model_display_name}'))
|
||||
continue
|
||||
|
||||
count = photos.count()
|
||||
self.stdout.write(f'Найдено изображений: {count}')
|
||||
|
||||
for i, photo in enumerate(photos, 1):
|
||||
try:
|
||||
# Сохраняем фото - это вызовет обработку в методе save()
|
||||
photo.save()
|
||||
total_processed += 1
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'✓ [{i}/{count}] {photo} - OK')
|
||||
)
|
||||
except Exception as e:
|
||||
total_errors += 1
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f'✗ [{i}/{count}] {photo} - ОШИБКА: {str(e)}')
|
||||
)
|
||||
|
||||
self.stdout.write('\n' + '=' * 50)
|
||||
self.stdout.write(self.style.SUCCESS(f'Обработано: {total_processed}'))
|
||||
if total_errors:
|
||||
self.stdout.write(self.style.ERROR(f'Ошибок: {total_errors}'))
|
||||
self.stdout.write('=' * 50)
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-25 13:44
|
||||
# Generated by Django 5.1.4 on 2025-10-26 22:44
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
@@ -91,7 +91,7 @@ class Migration(migrations.Migration):
|
||||
name='ProductCategoryPhoto',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('image', models.ImageField(upload_to='categories/originals/', verbose_name='Оригинальное фото')),
|
||||
('image', models.ImageField(upload_to='categories/temp/', verbose_name='Оригинальное фото')),
|
||||
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='products.productcategory', verbose_name='Категория')),
|
||||
@@ -131,7 +131,7 @@ class Migration(migrations.Migration):
|
||||
name='ProductKitPhoto',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('image', models.ImageField(upload_to='kits/originals/', verbose_name='Оригинальное фото')),
|
||||
('image', models.ImageField(upload_to='kits/temp/', verbose_name='Оригинальное фото')),
|
||||
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('kit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='products.productkit', verbose_name='Комплект')),
|
||||
@@ -146,7 +146,7 @@ class Migration(migrations.Migration):
|
||||
name='ProductPhoto',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('image', models.ImageField(upload_to='products/originals/', verbose_name='Оригинальное фото')),
|
||||
('image', models.ImageField(upload_to='products/temp/', verbose_name='Оригинальное фото')),
|
||||
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='products.product', verbose_name='Товар')),
|
||||
|
||||
@@ -21,6 +21,16 @@ class ImageProcessor:
|
||||
Использует конфигурацию из settings.IMAGE_PROCESSING_CONFIG
|
||||
"""
|
||||
|
||||
# Константы для маппинга форматов и расширений файлов
|
||||
FORMAT_EXTENSIONS = {
|
||||
'JPEG': 'jpg',
|
||||
'WEBP': 'webp',
|
||||
'PNG': 'png',
|
||||
}
|
||||
|
||||
# Размеры для обработки
|
||||
SIZE_KEYS = ['original', 'large', 'medium', 'thumbnail']
|
||||
|
||||
@staticmethod
|
||||
def _get_config():
|
||||
"""Получить конфигурацию из settings"""
|
||||
@@ -47,11 +57,15 @@ class ImageProcessor:
|
||||
return formats.get(size_key, {'format': 'JPEG', 'quality': 90})
|
||||
|
||||
@staticmethod
|
||||
def _get_folder(size_key):
|
||||
"""Получить папку для сохранения заданного типа изображения"""
|
||||
config = ImageProcessor._get_config()
|
||||
folders = config.get('folders', {})
|
||||
return folders.get(size_key, size_key)
|
||||
def _get_file_extension(image_format):
|
||||
"""Получить расширение файла для заданного формата"""
|
||||
return ImageProcessor.FORMAT_EXTENSIONS.get(image_format, 'jpg')
|
||||
|
||||
@staticmethod
|
||||
def _normalize_size_name(size_key):
|
||||
"""Преобразовать 'thumbnail' в 'thumb' для имени файла"""
|
||||
return 'thumb' if size_key == 'thumbnail' else size_key
|
||||
|
||||
|
||||
@staticmethod
|
||||
def process_image(image_file, base_path, entity_id=None, photo_id=None):
|
||||
@@ -74,8 +88,11 @@ class ImageProcessor:
|
||||
}
|
||||
|
||||
Raises:
|
||||
ValueError: Если файл не является изображением
|
||||
ValueError: Если файл не является изображением или отсутствуют обязательные параметры
|
||||
"""
|
||||
if entity_id is None or photo_id is None:
|
||||
raise ValueError("entity_id and photo_id are required parameters")
|
||||
|
||||
try:
|
||||
# Открываем изображение
|
||||
img = Image.open(image_file)
|
||||
@@ -103,12 +120,9 @@ class ImageProcessor:
|
||||
for size_key in ['large', 'medium', 'thumbnail']:
|
||||
size_dims = ImageProcessor._get_size_dimensions(size_key)
|
||||
resized_img = ImageProcessor._resize_image(img, size_dims)
|
||||
|
||||
# Переименовываем thumbnail в thumb для конечного пользователя
|
||||
final_size_key = 'thumb' if size_key == 'thumbnail' else size_key
|
||||
|
||||
|
||||
size_path = ImageProcessor._save_image_version(
|
||||
resized_img, base_path, entity_id, photo_id, size_key, final_size_key
|
||||
resized_img, base_path, entity_id, photo_id, size_key
|
||||
)
|
||||
saved_paths[size_key] = size_path
|
||||
|
||||
@@ -163,9 +177,39 @@ class ImageProcessor:
|
||||
return new_img
|
||||
|
||||
@staticmethod
|
||||
def _save_image_version(img, base_path, entity_id, photo_id, size_key, final_size_name=None):
|
||||
def _make_square_image(img, max_size):
|
||||
"""
|
||||
Сохраняет версию изображения в новой структуре с фиксированными именами.
|
||||
Делает изображение квадратным с белым фоном.
|
||||
|
||||
Args:
|
||||
img: PIL Image object
|
||||
max_size: Максимальный размер стороны квадрата
|
||||
|
||||
Returns:
|
||||
PIL Image object - квадратное изображение
|
||||
"""
|
||||
# Если изображение больше max_size, масштабируем
|
||||
if img.width > max_size or img.height > max_size:
|
||||
scale_factor = min(max_size / img.width, max_size / img.height)
|
||||
new_width = int(img.width * scale_factor)
|
||||
new_height = int(img.height * scale_factor)
|
||||
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||
|
||||
# Создаем квадратное изображение
|
||||
size_obj = min(max(img.width, img.height), max_size)
|
||||
square_img = Image.new('RGB', (size_obj, size_obj), (255, 255, 255))
|
||||
|
||||
# Центрируем изображение
|
||||
offset_x = (size_obj - img.width) // 2
|
||||
offset_y = (size_obj - img.height) // 2
|
||||
square_img.paste(img, (offset_x, offset_y))
|
||||
|
||||
return square_img
|
||||
|
||||
@staticmethod
|
||||
def _save_image_version(img, base_path, entity_id, photo_id, size_key):
|
||||
"""
|
||||
Сохраняет версию изображения с фиксированными именами.
|
||||
Использует формат и качество из конфигурации для каждого размера.
|
||||
|
||||
Args:
|
||||
@@ -174,7 +218,6 @@ class ImageProcessor:
|
||||
entity_id: ID сущности (product_id, category_id, kit_id)
|
||||
photo_id: ID фотографии
|
||||
size_key: Ключ размера ('original', 'large', 'medium', 'thumbnail')
|
||||
final_size_name: Имя размера в файле ('original', 'large', 'medium', 'thumb')
|
||||
|
||||
Returns:
|
||||
str: Путь сохраненного файла относительно MEDIA_ROOT
|
||||
@@ -184,55 +227,23 @@ class ImageProcessor:
|
||||
image_format = format_config.get('format', 'JPEG')
|
||||
quality = format_config.get('quality', 90)
|
||||
|
||||
# Определяем расширение файла в зависимости от формата
|
||||
ext_map = {
|
||||
'JPEG': 'jpg',
|
||||
'WEBP': 'webp',
|
||||
'PNG': 'png',
|
||||
}
|
||||
extension = ext_map.get(image_format, 'jpg')
|
||||
|
||||
# Определяем имя размера для файла ('thumb' вместо 'thumbnail')
|
||||
if final_size_name is None:
|
||||
final_size_name = 'thumb' if size_key == 'thumbnail' else size_key
|
||||
|
||||
# Создаем имя файла с фиксированным именем и расширением
|
||||
# Определяем расширение и имя файла
|
||||
extension = ImageProcessor._get_file_extension(image_format)
|
||||
final_size_name = ImageProcessor._normalize_size_name(size_key)
|
||||
filename = f"{final_size_name}.{extension}"
|
||||
|
||||
# Создаем путь в новой структуре: base_path/entity_id/photo_id/filename
|
||||
# Создаем путь: base_path/entity_id/photo_id/filename
|
||||
file_path = f"{base_path}/{entity_id}/{photo_id}/{filename}"
|
||||
|
||||
# Обрабатываем оригинал - делаем квадратным
|
||||
if size_key == 'original':
|
||||
max_size = ImageProcessor._get_size_dimensions('original')[0]
|
||||
img = ImageProcessor._make_square_image(img, max_size)
|
||||
|
||||
# Сохраняем в памяти
|
||||
img_io = BytesIO()
|
||||
|
||||
# Масштабируем оригинал если необходимо (для original размера)
|
||||
if size_key == 'original':
|
||||
max_size = ImageProcessor._get_size_dimensions('original')[0] # квадратный размер
|
||||
|
||||
# Если оригинал больше максимального размера, масштабируем
|
||||
if img.width > max_size or img.height > max_size:
|
||||
# Вычисляем новый размер с сохранением пропорций
|
||||
scale_factor = min(max_size / img.width, max_size / img.height)
|
||||
new_width = int(img.width * scale_factor)
|
||||
new_height = int(img.height * scale_factor)
|
||||
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||
|
||||
# Делаем изображение квадратным: добавляем белый фон
|
||||
size_obj = max(img.width, img.height)
|
||||
# Но не больше max_size
|
||||
if size_obj > max_size:
|
||||
size_obj = max_size
|
||||
|
||||
square_img = Image.new('RGB', (size_obj, size_obj), (255, 255, 255))
|
||||
offset_x = (size_obj - img.width) // 2
|
||||
offset_y = (size_obj - img.height) // 2
|
||||
square_img.paste(img, (offset_x, offset_y))
|
||||
img = square_img
|
||||
|
||||
# Сохраняем с указанным форматом и качеством
|
||||
save_kwargs = {'format': image_format, 'optimize': True}
|
||||
|
||||
# Качество поддерживается только для JPEG и WebP
|
||||
if image_format in ('JPEG', 'WEBP'):
|
||||
save_kwargs['quality'] = quality
|
||||
|
||||
@@ -248,7 +259,7 @@ class ImageProcessor:
|
||||
@staticmethod
|
||||
def delete_all_versions(base_path, original_image_path, entity_id=None, photo_id=None):
|
||||
"""
|
||||
Удаляет все версии изображения (original, large, medium, thumb) из новой структуры.
|
||||
Удаляет все версии изображения (original, large, medium, thumb).
|
||||
|
||||
Args:
|
||||
base_path: Базовый путь (например, 'products')
|
||||
@@ -259,90 +270,28 @@ class ImageProcessor:
|
||||
if not original_image_path:
|
||||
return
|
||||
|
||||
# Если переданы entity_id и photo_id, используем новую структуру
|
||||
if entity_id is not None and photo_id is not None:
|
||||
# Удаляем файлы в новой структуре
|
||||
for size_key in ['original', 'large', 'medium', 'thumbnail']:
|
||||
format_config = ImageProcessor._get_format_config(size_key)
|
||||
image_format = format_config.get('format', 'JPEG')
|
||||
if entity_id is None or photo_id is None:
|
||||
logger.error("entity_id and photo_id are required for deleting images")
|
||||
return
|
||||
|
||||
# Определяем расширение
|
||||
ext_map = {
|
||||
'JPEG': 'jpg',
|
||||
'WEBP': 'webp',
|
||||
'PNG': 'png',
|
||||
}
|
||||
extension = ext_map.get(image_format, 'jpg')
|
||||
# Удаляем все размеры
|
||||
for size_key in ImageProcessor.SIZE_KEYS:
|
||||
format_config = ImageProcessor._get_format_config(size_key)
|
||||
image_format = format_config.get('format', 'JPEG')
|
||||
|
||||
# Определяем имя размера для файла ('thumb' вместо 'thumbnail')
|
||||
final_size_name = 'thumb' if size_key == 'thumbnail' else size_key
|
||||
# Определяем расширение и имя файла
|
||||
extension = ImageProcessor._get_file_extension(image_format)
|
||||
final_size_name = ImageProcessor._normalize_size_name(size_key)
|
||||
size_filename = f"{final_size_name}.{extension}"
|
||||
|
||||
# Создаем имя файла для этого размера
|
||||
size_filename = f"{final_size_name}.{extension}"
|
||||
|
||||
# Создаем путь в новой структуре
|
||||
file_path = f"{base_path}/{entity_id}/{photo_id}/{size_filename}"
|
||||
# Создаем путь
|
||||
file_path = f"{base_path}/{entity_id}/{photo_id}/{size_filename}"
|
||||
|
||||
try:
|
||||
if default_storage.exists(file_path):
|
||||
default_storage.delete(file_path)
|
||||
logger.info(f"Deleted file: {file_path}")
|
||||
else:
|
||||
logger.warning(f"File not found: {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting {file_path}: {str(e)}", exc_info=True)
|
||||
else:
|
||||
# Для совместимости с предыдущей структурой
|
||||
# Извлекаем имя файла из пути
|
||||
filename = os.path.basename(str(original_image_path))
|
||||
|
||||
# Удаляем расширение и последний размер для получения base_filename
|
||||
# Из 'robot-50cm_1729611234567_original.jpg' получаем 'robot-50cm_1729611234567'
|
||||
# Важно: используем максимум 2 разделителя, т.к. в base_filename может быть _
|
||||
parts = filename.rsplit('_', 1)
|
||||
if len(parts) == 2:
|
||||
base_filename = parts[0]
|
||||
else:
|
||||
# Если формат не совпадает, используем полное имя без расширения
|
||||
base_filename = os.path.splitext(filename)[0]
|
||||
|
||||
config = ImageProcessor._get_config()
|
||||
|
||||
# Удаляем все версии в старой структуре
|
||||
for size_key in ['original', 'large', 'medium', 'thumbnail']:
|
||||
format_config = ImageProcessor._get_format_config(size_key)
|
||||
image_format = format_config.get('format', 'JPEG')
|
||||
|
||||
# Определяем расширение
|
||||
ext_map = {
|
||||
'JPEG': 'jpg',
|
||||
'WEBP': 'webp',
|
||||
'PNG': 'png',
|
||||
}
|
||||
extension = ext_map.get(image_format, 'jpg')
|
||||
|
||||
# Создаем имя файла для этого размера
|
||||
size_filename = f"{base_filename}_{size_key}.{extension}"
|
||||
folder = ImageProcessor._get_folder(size_key)
|
||||
file_path = f"{base_path}/{folder}/{size_filename}"
|
||||
|
||||
try:
|
||||
if default_storage.exists(file_path):
|
||||
default_storage.delete(file_path)
|
||||
logger.info(f"Deleted file: {file_path}")
|
||||
else:
|
||||
logger.warning(f"File not found: {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting {file_path}: {str(e)}", exc_info=True)
|
||||
|
||||
@staticmethod
|
||||
def _generate_unique_id():
|
||||
"""
|
||||
Генерирует уникальный ID для имени файла.
|
||||
|
||||
Returns:
|
||||
str: Уникальный ID (timestamp + random)
|
||||
"""
|
||||
import time
|
||||
import random
|
||||
return f"{int(time.time()*1000)}{random.randint(1000, 9999)}"
|
||||
try:
|
||||
if default_storage.exists(file_path):
|
||||
default_storage.delete(file_path)
|
||||
logger.info(f"Deleted file: {file_path}")
|
||||
else:
|
||||
logger.warning(f"File not found: {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting {file_path}: {str(e)}", exc_info=True)
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
|
||||
Reference in New Issue
Block a user