Изменение структуры хранения изображений и исправление ошибки дублирования ID

This commit is contained in:
2025-10-25 19:21:32 +03:00
parent 2f557f3f9b
commit a55d0405ed
3 changed files with 274 additions and 153 deletions

View File

@@ -909,7 +909,7 @@ class ProductPhoto(models.Model):
"""
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='photos',
verbose_name="Товар")
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="Дата создания")
@@ -928,31 +928,45 @@ class ProductPhoto(models.Model):
from .utils.image_processor import ImageProcessor
is_new = not self.pk
old_image_path = None
# Если это обновление существующего объекта, сохраняем старый путь для удаления
if not is_new:
try:
old_obj = ProductPhoto.objects.get(pk=self.pk)
if old_obj.image and old_obj.image != self.image:
old_image_path = old_obj.image.name
except ProductPhoto.DoesNotExist:
pass
# Если это новый объект с изображением, нужно сначала сохранить без изображения, чтобы получить ID
if is_new and self.image:
# Сохраняем объект без изображения, чтобы получить ID
temp_image = self.image
self.image = None
super().save(*args, **kwargs)
# Если было загружено новое изображение
if self.image and (is_new or old_image_path):
# Обрабатываем изображение с использованием slug товара как идентификатора
# slug гарантирует уникальность и читаемость имени файла
identifier = self.product.slug
processed_paths = ImageProcessor.process_image(self.image, 'products', identifier=identifier)
# Сохраняем только путь к оригиналу в поле image
# Теперь обрабатываем изображение с известными ID
processed_paths = ImageProcessor.process_image(temp_image, 'products', entity_id=self.product.id, photo_id=self.id)
self.image = processed_paths['original']
# Удаляем старые версии если это обновление
if old_image_path:
ImageProcessor.delete_all_versions('products', old_image_path)
# Обновляем только поле image, чтобы избежать рекурсии и дублирования ID
super().save(update_fields=['image'])
else:
# Проверяем старый путь для удаления, если это обновление
old_image_path = None
if self.pk:
try:
old_obj = ProductPhoto.objects.get(pk=self.pk)
if old_obj.image and old_obj.image != self.image:
old_image_path = old_obj.image.name
except ProductPhoto.DoesNotExist:
pass
super().save(*args, **kwargs)
# Проверяем, нужно ли обрабатывать изображение
if self.image and old_image_path:
# Обновление существующего изображения
processed_paths = ImageProcessor.process_image(self.image, 'products', entity_id=self.product.id, photo_id=self.id)
self.image = processed_paths['original']
# Удаляем старые версии
ImageProcessor.delete_all_versions('products', old_image_path, entity_id=self.product.id, photo_id=self.id)
# Обновляем только поле image, чтобы избежать рекурсии
super().save(update_fields=['image'])
else:
# Просто сохраняем без обработки изображения
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
"""Удаляет все версии изображения при удалении фото"""
@@ -964,7 +978,7 @@ class ProductPhoto(models.Model):
if self.image:
try:
logger.info(f"[ProductPhoto.delete] Удаляем изображение: {self.image.name}")
ImageProcessor.delete_all_versions('products', self.image.name)
ImageProcessor.delete_all_versions('products', self.image.name, entity_id=self.product.id, photo_id=self.id)
logger.info(f"[ProductPhoto.delete] ✓ Все версии изображения удалены")
except Exception as e:
logger.error(f"[ProductPhoto.delete] ✗ Ошибка при удалении версий: {str(e)}", exc_info=True)
@@ -999,7 +1013,7 @@ class ProductKitPhoto(models.Model):
"""
kit = models.ForeignKey(ProductKit, on_delete=models.CASCADE, related_name='photos',
verbose_name="Комплект")
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="Дата создания")
@@ -1018,31 +1032,45 @@ class ProductKitPhoto(models.Model):
from .utils.image_processor import ImageProcessor
is_new = not self.pk
old_image_path = None
# Если это обновление существующего объекта, сохраняем старый путь для удаления
if not is_new:
try:
old_obj = ProductKitPhoto.objects.get(pk=self.pk)
if old_obj.image and old_obj.image != self.image:
old_image_path = old_obj.image.name
except ProductKitPhoto.DoesNotExist:
pass
# Если это новый объект с изображением, нужно сначала сохранить без изображения, чтобы получить ID
if is_new and self.image:
# Сохраняем объект без изображения, чтобы получить ID
temp_image = self.image
self.image = None
super().save(*args, **kwargs)
# Если было загружено новое изображение
if self.image and (is_new or old_image_path):
# Обрабатываем изображение с использованием slug комплекта как идентификатора
# slug гарантирует уникальность и читаемость имени файла
identifier = self.kit.slug
processed_paths = ImageProcessor.process_image(self.image, 'kits', identifier=identifier)
# Сохраняем только путь к оригиналу в поле image
# Теперь обрабатываем изображение с известными ID
processed_paths = ImageProcessor.process_image(temp_image, 'kits', entity_id=self.kit.id, photo_id=self.id)
self.image = processed_paths['original']
# Удаляем старые версии если это обновление
if old_image_path:
ImageProcessor.delete_all_versions('kits', old_image_path)
# Обновляем только поле image, чтобы избежать рекурсии и дублирования ID
super().save(update_fields=['image'])
else:
# Проверяем старый путь для удаления, если это обновление
old_image_path = None
if self.pk:
try:
old_obj = ProductKitPhoto.objects.get(pk=self.pk)
if old_obj.image and old_obj.image != self.image:
old_image_path = old_obj.image.name
except ProductKitPhoto.DoesNotExist:
pass
super().save(*args, **kwargs)
# Проверяем, нужно ли обрабатывать изображение
if self.image and old_image_path:
# Обновление существующего изображения
processed_paths = ImageProcessor.process_image(self.image, 'kits', entity_id=self.kit.id, photo_id=self.id)
self.image = processed_paths['original']
# Удаляем старые версии
ImageProcessor.delete_all_versions('kits', old_image_path, entity_id=self.kit.id, photo_id=self.id)
# Обновляем только поле image, чтобы избежать рекурсии
super().save(update_fields=['image'])
else:
# Просто сохраняем без обработки изображения
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
"""Удаляет все версии изображения при удалении фото"""
@@ -1054,7 +1082,7 @@ class ProductKitPhoto(models.Model):
if self.image:
try:
logger.info(f"[ProductKitPhoto.delete] Удаляем изображение: {self.image.name}")
ImageProcessor.delete_all_versions('kits', self.image.name)
ImageProcessor.delete_all_versions('kits', self.image.name, entity_id=self.kit.id, photo_id=self.id)
logger.info(f"[ProductKitPhoto.delete] ✓ Все версии изображения удалены")
except Exception as e:
logger.error(f"[ProductKitPhoto.delete] ✗ Ошибка при удалении версий: {str(e)}", exc_info=True)
@@ -1089,7 +1117,7 @@ class ProductCategoryPhoto(models.Model):
"""
category = models.ForeignKey(ProductCategory, on_delete=models.CASCADE, related_name='photos',
verbose_name="Категория")
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="Дата создания")
@@ -1108,31 +1136,45 @@ class ProductCategoryPhoto(models.Model):
from .utils.image_processor import ImageProcessor
is_new = not self.pk
old_image_path = None
# Если это обновление существующего объекта, сохраняем старый путь для удаления
if not is_new:
try:
old_obj = ProductCategoryPhoto.objects.get(pk=self.pk)
if old_obj.image and old_obj.image != self.image:
old_image_path = old_obj.image.name
except ProductCategoryPhoto.DoesNotExist:
pass
# Если это новый объект с изображением, нужно сначала сохранить без изображения, чтобы получить ID
if is_new and self.image:
# Сохраняем объект без изображения, чтобы получить ID
temp_image = self.image
self.image = None
super().save(*args, **kwargs)
# Если было загружено новое изображение
if self.image and (is_new or old_image_path):
# Обрабатываем изображение с использованием slug категории как идентификатора
# slug гарантирует уникальность и читаемость имени файла
identifier = self.category.slug
processed_paths = ImageProcessor.process_image(self.image, 'categories', identifier=identifier)
# Сохраняем только путь к оригиналу в поле image
# Теперь обрабатываем изображение с известными ID
processed_paths = ImageProcessor.process_image(temp_image, 'categories', entity_id=self.category.id, photo_id=self.id)
self.image = processed_paths['original']
# Удаляем старые версии если это обновление
if old_image_path:
ImageProcessor.delete_all_versions('categories', old_image_path)
# Обновляем только поле image, чтобы избежать рекурсии и дублирования ID
super().save(update_fields=['image'])
else:
# Проверяем старый путь для удаления, если это обновление
old_image_path = None
if self.pk:
try:
old_obj = ProductCategoryPhoto.objects.get(pk=self.pk)
if old_obj.image and old_obj.image != self.image:
old_image_path = old_obj.image.name
except ProductCategoryPhoto.DoesNotExist:
pass
super().save(*args, **kwargs)
# Проверяем, нужно ли обрабатывать изображение
if self.image and old_image_path:
# Обновление существующего изображения
processed_paths = ImageProcessor.process_image(self.image, 'categories', entity_id=self.category.id, photo_id=self.id)
self.image = processed_paths['original']
# Удаляем старые версии
ImageProcessor.delete_all_versions('categories', old_image_path, entity_id=self.category.id, photo_id=self.id)
# Обновляем только поле image, чтобы избежать рекурсии
super().save(update_fields=['image'])
else:
# Просто сохраняем без обработки изображения
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
"""Удаляет все версии изображения при удалении фото"""
@@ -1144,7 +1186,7 @@ class ProductCategoryPhoto(models.Model):
if self.image:
try:
logger.info(f"[ProductCategoryPhoto.delete] Удаляем изображение: {self.image.name}")
ImageProcessor.delete_all_versions('categories', self.image.name)
ImageProcessor.delete_all_versions('categories', self.image.name, entity_id=self.category.id, photo_id=self.id)
logger.info(f"[ProductCategoryPhoto.delete] ✓ Все версии изображения удалены")
except Exception as e:
logger.error(f"[ProductCategoryPhoto.delete] ✗ Ошибка при удалении версий: {str(e)}", exc_info=True)

View File

@@ -54,24 +54,23 @@ class ImageProcessor:
return folders.get(size_key, size_key)
@staticmethod
def process_image(image_file, base_path, identifier=None):
def process_image(image_file, base_path, entity_id=None, photo_id=None):
"""
Обрабатывает загруженное изображение и создает несколько версий.
Args:
image_file: Загруженный файл изображения (InMemoryUploadedFile)
base_path: Базовый путь для сохранения (например, 'products', 'kits', 'categories')
identifier: (Optional) Идентификатор товара/категории (slug, SKU, имя)
для более понятного имени файла.
Пример: 'robot-50cm', 'bouquet-red', 'category-flowers'
entity_id: ID сущности (product_id, category_id, kit_id)
photo_id: ID фотографии
Returns:
dict: Словарь с путями сохраненных файлов
{
'original': 'products/originals/robot-50cm_1729611234567_original.jpg',
'large': 'products/large/robot-50cm_1729611234567_large.webp',
'medium': 'products/medium/robot-50cm_1729611234567_medium.webp',
'thumbnail': 'products/thumbnails/robot-50cm_1729611234567_thumbnail.webp',
'original': 'products/<entity_id>/<photo_id>/original.jpg',
'large': 'products/<entity_id>/<photo_id>/large.webp',
'medium': 'products/<entity_id>/<photo_id>/medium.webp',
'thumbnail': 'products/<entity_id>/<photo_id>/thumb.webp',
}
Raises:
@@ -92,20 +91,11 @@ class ImageProcessor:
elif img.mode != 'RGB':
img = img.convert('RGB')
# Генерируем уникальное имя файла
if identifier:
# Используем переданный идентификатор (slug) + timestamp для уникальности
base_filename = f"{identifier}_{ImageProcessor._generate_unique_id()}"
else:
# Если идентификатор не передан, используем исходное имя файла
original_name = image_file.name.split('.')[0]
base_filename = f"{original_name}_{ImageProcessor._generate_unique_id()}"
saved_paths = {}
# Сохраняем оригинал (масштабируем если больше max_width/max_height)
original_path = ImageProcessor._save_image_version(
img, base_path, base_filename, 'original'
img, base_path, entity_id, photo_id, 'original'
)
saved_paths['original'] = original_path
@@ -113,8 +103,12 @@ 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, base_filename, size_key
resized_img, base_path, entity_id, photo_id, size_key, final_size_key
)
saved_paths[size_key] = size_path
@@ -169,17 +163,18 @@ class ImageProcessor:
return new_img
@staticmethod
def _save_image_version(img, base_path, base_filename, size_key):
def _save_image_version(img, base_path, entity_id, photo_id, size_key, final_size_name=None):
"""
Сохраняет версию изображения с информацией о размере в имени файла.
Сохраняет версию изображения в новой структуре с фиксированными именами.
Использует формат и качество из конфигурации для каждого размера.
Args:
img: PIL Image object
base_path: Базовый путь (например, 'products')
base_filename: Базовое имя файла без расширения и размера
(например, 'robot-50cm_1729611234567')
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
@@ -197,12 +192,15 @@ class ImageProcessor:
}
extension = ext_map.get(image_format, 'jpg')
# Создаем имя файла с указанием размера и расширением
filename = f"{base_filename}_{size_key}.{extension}"
# Определяем имя размера для файла ('thumb' вместо 'thumbnail')
if final_size_name is None:
final_size_name = 'thumb' if size_key == 'thumbnail' else size_key
# Создаем путь в правильной папке
folder = ImageProcessor._get_folder(size_key)
file_path = f"{base_path}/{folder}/{filename}"
# Создаем имя файла с фиксированным именем и расширением
filename = f"{final_size_name}.{extension}"
# Создаем путь в новой структуре: base_path/entity_id/photo_id/filename
file_path = f"{base_path}/{entity_id}/{photo_id}/{filename}"
# Сохраняем в памяти
img_io = BytesIO()
@@ -248,64 +246,94 @@ class ImageProcessor:
return saved_path
@staticmethod
def delete_all_versions(base_path, original_image_path):
def delete_all_versions(base_path, original_image_path, entity_id=None, photo_id=None):
"""
Удаляет все версии изображения (original, large, medium, thumbnail).
Работает с форматом имен файлов:
- robot-50cm_1729611234567_original.jpg
- robot-50cm_1729611234567_large.webp
- robot-50cm_1729611234567_medium.webp
- robot-50cm_1729611234567_thumbnail.webp
Удаляет все версии изображения (original, large, medium, thumb) из новой структуры.
Args:
base_path: Базовый путь (например, 'products')
original_image_path: Путь к оригинальному файлу (из БД)
entity_id: ID сущности (product_id, category_id, kit_id)
photo_id: ID фотографии
"""
if not original_image_path:
return
# Извлекаем имя файла из пути
filename = os.path.basename(str(original_image_path))
# Если переданы 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')
# Удаляем расширение и последний размер для получения 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]
# Определяем расширение
ext_map = {
'JPEG': 'jpg',
'WEBP': 'webp',
'PNG': 'png',
}
extension = ext_map.get(image_format, 'jpg')
# Определяем имя размера для файла ('thumb' вместо 'thumbnail')
final_size_name = 'thumb' if size_key == 'thumbnail' else size_key
# Создаем имя файла для этого размера
size_filename = f"{final_size_name}.{extension}"
# Создаем путь в новой структуре
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:
# Если формат не совпадает, используем полное имя без расширения
base_filename = os.path.splitext(filename)[0]
# Для совместимости с предыдущей структурой
# Извлекаем имя файла из пути
filename = os.path.basename(str(original_image_path))
config = ImageProcessor._get_config()
# Удаляем расширение и последний размер для получения 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]
# Удаляем все версии
for size_key in ['original', 'large', 'medium', 'thumbnail']:
format_config = ImageProcessor._get_format_config(size_key)
image_format = format_config.get('format', 'JPEG')
config = ImageProcessor._get_config()
# Определяем расширение
ext_map = {
'JPEG': 'jpg',
'WEBP': 'webp',
'PNG': 'png',
}
extension = ext_map.get(image_format, 'jpg')
# Удаляем все версии в старой структуре
for size_key in ['original', 'large', 'medium', 'thumbnail']:
format_config = ImageProcessor._get_format_config(size_key)
image_format = format_config.get('format', 'JPEG')
# Создаем имя файла для этого размера
size_filename = f"{base_filename}_{size_key}.{extension}"
folder = ImageProcessor._get_folder(size_key)
file_path = f"{base_path}/{folder}/{size_filename}"
# Определяем расширение
ext_map = {
'JPEG': 'jpg',
'WEBP': 'webp',
'PNG': 'png',
}
extension = ext_map.get(image_format, 'jpg')
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)
# Создаем имя файла для этого размера
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():

View File

@@ -54,16 +54,16 @@ class ImageService:
"""
Получает URL изображения нужного размера.
Работает с новым форматом имён файлов с поддержкой разных расширений:
- robot-50cm_1729611234567_original.jpg (JPEG, оригинал)
- robot-50cm_1729611234567_large.webp (WebP)
- robot-50cm_1729611234567_medium.webp (WebP)
- robot-50cm_1729611234567_thumbnail.webp (WebP)
Работает с новой структурой:
- 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
Args:
original_image_path: Путь к оригинальному файлу (из models.image)
Обычно это путь к файлу 'original'
Пример: products/originals/robot-50cm_1729611234567_original.jpg
Пример: products/123/456/original.jpg
size: Размер ('original', 'large', 'medium', 'thumbnail')
По умолчанию 'medium'
@@ -74,18 +74,70 @@ 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)
parts = path_str.split('/')
if len(parts) > 0:
base_path = parts[0]
else:
base_path = 'products'
# Проверяем новый формат имени файла с расширением
# Проверяем старый формат имени файла с расширением
# Поддерживаем jpg, webp, png расширения
if filename.endswith(('.jpg', '.webp', '.png')):
# Определяем расширение файла
@@ -97,7 +149,7 @@ class ImageService:
if len(parts_of_name) == 2:
base_filename, file_size_key = parts_of_name
# Это новый формат с явным указанием размера в имени
# Это старый формат с явным указанием размера в имени
# Получаем расширение для целевого размера
target_ext = ImageService._get_file_extension(size)
@@ -123,7 +175,6 @@ class ImageService:
# Если ничего не найдено, возвращаем путь с новым расширением (браузер покажет ошибку)
return f"{settings.MEDIA_URL}{new_path_primary}"
# Иначе оставляем как есть
# Строим новый путь (для старых файлов без новой структуры)
size_folders = ImageService._get_size_folders()