diff --git a/myproject/products/models.py b/myproject/products/models.py index c2ccd3b..8fcd8a2 100644 --- a/myproject/products/models.py +++ b/myproject/products/models.py @@ -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 - - # Если было загружено новое изображение - 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 + if is_new and self.image: + # Сохраняем объект без изображения, чтобы получить ID + temp_image = self.image + self.image = None + super().save(*args, **kwargs) + + # Теперь обрабатываем изображение с известными ID + processed_paths = ImageProcessor.process_image(temp_image, 'products', entity_id=self.product.id, photo_id=self.id) self.image = processed_paths['original'] + + # Обновляем только поле 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 + + # Проверяем, нужно ли обрабатывать изображение + 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'] - # Удаляем старые версии если это обновление - if old_image_path: - ImageProcessor.delete_all_versions('products', old_image_path) - - super().save(*args, **kwargs) + # Удаляем старые версии + 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 - - # Если было загружено новое изображение - 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 + if is_new and self.image: + # Сохраняем объект без изображения, чтобы получить ID + temp_image = self.image + self.image = None + super().save(*args, **kwargs) + + # Теперь обрабатываем изображение с известными ID + processed_paths = ImageProcessor.process_image(temp_image, 'kits', entity_id=self.kit.id, photo_id=self.id) self.image = processed_paths['original'] + + # Обновляем только поле 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 + + # Проверяем, нужно ли обрабатывать изображение + 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'] - # Удаляем старые версии если это обновление - if old_image_path: - ImageProcessor.delete_all_versions('kits', old_image_path) - - super().save(*args, **kwargs) + # Удаляем старые версии + 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 - - # Если было загружено новое изображение - 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 + if is_new and self.image: + # Сохраняем объект без изображения, чтобы получить ID + temp_image = self.image + self.image = None + super().save(*args, **kwargs) + + # Теперь обрабатываем изображение с известными ID + processed_paths = ImageProcessor.process_image(temp_image, 'categories', entity_id=self.category.id, photo_id=self.id) self.image = processed_paths['original'] + + # Обновляем только поле 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 + + # Проверяем, нужно ли обрабатывать изображение + 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'] - # Удаляем старые версии если это обновление - if old_image_path: - ImageProcessor.delete_all_versions('categories', old_image_path) - - super().save(*args, **kwargs) + # Удаляем старые версии + 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) diff --git a/myproject/products/utils/image_processor.py b/myproject/products/utils/image_processor.py index 15cdb23..c165ce3 100644 --- a/myproject/products/utils/image_processor.py +++ b/myproject/products/utils/image_processor.py @@ -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///original.jpg', + 'large': 'products///large.webp', + 'medium': 'products///medium.webp', + 'thumbnail': 'products///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(): diff --git a/myproject/products/utils/image_service.py b/myproject/products/utils/image_service.py index a63a142..02d80b1 100644 --- a/myproject/products/utils/image_service.py +++ b/myproject/products/utils/image_service.py @@ -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///original.jpg + - products///large.webp + - products///medium.webp + - products///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///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()