From 622e17a775a4de3ce70b68377a3e050320c43b96 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Sun, 2 Nov 2025 14:39:33 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D1=81=D0=B8=D1=81=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=D1=83=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BA=D0=B8=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=87=D0=B5=D1=81=D1=82=D0=B2=D0=B0=20=D1=84=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D0=B3=D1=80=D0=B0=D1=84=D0=B8=D0=B9=20=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=80=D0=BE=D0=B2=20(=D0=A4=D0=B0=D0=B7=D0=B0=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлена полностью гибкая система для оценки качества фотографий на основе размеров: Конфигурация (settings.py): - IMAGE_QUALITY_LEVELS: Пороги качества как доля от максимума (95%, 70%, 40%, 20%) - IMAGE_QUALITY_LABELS: Описания, цвета и рекомендации для каждого уровня - Система полностью адаптивна - меняется max_width в settings → пороги пересчитываются Валидатор (validators/image_validators.py): - get_max_dimension_from_config() - динамически читает из IMAGE_PROCESSING_CONFIG - get_image_quality_level() - определяет уровень качества (excellent/good/acceptable/poor/very_poor) - get_quality_info() - информация о уровне из IMAGE_QUALITY_LABELS - validate_product_image() - комплексная валидация для UI Модели (models/photos.py): - ProductPhoto, ProductKitPhoto, ProductCategoryPhoto дополнены полями: - quality_level: уровень качества (CharField с choices) - quality_warning: требует ли обновления (BooleanField) - Добавлены индексы для быстрого поиска товаров требующих обновления фото Обработчик (image_processor.py): - process_image() теперь возвращает дополнительно: - width, height: размеры оригинального изображения - quality_level: уровень качества - quality_warning: нужно ли обновить перед выгрузкой - Вызывает валидатор автоматически при обработке Логика сохранения (photos.py -> save()): - При сохранении нового фото автоматически вычисляет quality_level и quality_warning - При обновлении существующего фото пересчитывает качество - Сохраняет все три поля атомарно Система полностью готова к: - Phase 2: Admin интерфейс с фильтрами и индикаторами - Phase 3: Фронтенд с визуальными индикаторами в формах и API Примеры использования: >>> from products.validators.image_validators import get_image_quality_level >>> get_image_quality_level(1400, 1400) # если max=2160 ('good', False) # 1400/2160 = 64.8% >= 70%? Нет, но >= 40% >>> get_image_quality_level(400, 400) ('poor', True) # Требует обновления 🤖 Generated with Claude Code Co-Authored-By: Claude --- myproject/myproject/settings.py | 73 +++- myproject/products/models/photos.py | 353 ++++++++++++++++++ myproject/products/utils/image_processor.py | 21 +- .../products/validators/image_validators.py | 235 ++++++++++++ 4 files changed, 680 insertions(+), 2 deletions(-) create mode 100644 myproject/products/models/photos.py create mode 100644 myproject/products/validators/image_validators.py diff --git a/myproject/myproject/settings.py b/myproject/myproject/settings.py index dd43df7..7dd9365 100644 --- a/myproject/myproject/settings.py +++ b/myproject/myproject/settings.py @@ -145,7 +145,6 @@ DATABASES = { 'PORT': env('DB_PORT'), 'OPTIONS': { 'client_encoding': 'UTF8', - 'connect_timeout': 10, }, 'CONN_MAX_AGE': 0, } @@ -242,6 +241,78 @@ IMAGE_PROCESSING_CONFIG = { } +# ============================================ +# 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 # ============================================ diff --git a/myproject/products/models/photos.py b/myproject/products/models/photos.py new file mode 100644 index 0000000..845d28c --- /dev/null +++ b/myproject/products/models/photos.py @@ -0,0 +1,353 @@ +""" +Модели для работы с фотографиями продуктов, комплектов и категорий. +Использует паттерн Template Method для устранения дублирования кода. +""" +from abc import abstractmethod +from django.db import models +from django.utils import timezone + + +class BasePhoto(models.Model): + """ + Абстрактный базовый класс для всех фотомоделей. + Объединяет общую логику обработки изображений для ProductPhoto, ProductKitPhoto, ProductCategoryPhoto. + + Паттерн: Template Method + - Общие методы save(), delete() и get_*_url() определены здесь + - Специфичные детали (related entity, upload path) задаются через абстрактные методы + """ + image = models.ImageField(verbose_name="Оригинальное фото") + order = models.PositiveIntegerField(default=0, verbose_name="Порядок") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания") + + class Meta: + abstract = True + ordering = ['order', '-created_at'] + + @abstractmethod + def get_entity(self): + """ + Возвращает связанную сущность (product, kit, category). + Должен быть реализован в дочернем классе. + + Returns: + Model: Связанный объект (Product, ProductKit или ProductCategory) + """ + pass + + @abstractmethod + def get_entity_type(self): + """ + Возвращает тип сущности для путей изображений. + + Returns: + str: Одно из значений: 'products', 'kits', 'categories' + """ + pass + + def save(self, *args, **kwargs): + """ + При загрузке нового изображения обрабатывает его и создает все необходимые размеры. + Автоматически определяет и сохраняет уровень качества (quality_level и quality_warning). + """ + from ..utils.image_processor import ImageProcessor + + is_new = not self.pk + + # Если это новый объект с изображением, нужно сначала сохранить без изображения, чтобы получить ID + if is_new and self.image: + # Сохраняем объект без изображения, чтобы получить ID + temp_image = self.image + self.image = None + super().save(*args, **kwargs) + + # Теперь обрабатываем изображение с известными ID + entity = self.get_entity() + entity_type = self.get_entity_type() + processed_paths = ImageProcessor.process_image( + temp_image, + entity_type, + entity_id=entity.id, + photo_id=self.id + ) + self.image = processed_paths['original'] + + # Сохраняем уровень качества + self.quality_level = processed_paths.get('quality_level', 'acceptable') + self.quality_warning = processed_paths.get('quality_warning', False) + + # Обновляем поля image, quality_level и quality_warning + super().save(update_fields=['image', 'quality_level', 'quality_warning']) + else: + # Проверяем старый путь для удаления, если это обновление + old_image_path = None + if self.pk: + try: + old_obj = self.__class__.objects.get(pk=self.pk) + if old_obj.image and old_obj.image != self.image: + old_image_path = old_obj.image.name + except self.__class__.DoesNotExist: + pass + + # Проверяем, нужно ли обрабатывать изображение + if self.image and old_image_path: + # Обновление существующего изображения + entity = self.get_entity() + entity_type = self.get_entity_type() + processed_paths = ImageProcessor.process_image( + self.image, + entity_type, + entity_id=entity.id, + photo_id=self.id + ) + self.image = processed_paths['original'] + + # Обновляем уровень качества + self.quality_level = processed_paths.get('quality_level', 'acceptable') + self.quality_warning = processed_paths.get('quality_warning', False) + + # Удаляем старые версии + ImageProcessor.delete_all_versions( + entity_type, + old_image_path, + entity_id=entity.id, + photo_id=self.id + ) + + # Обновляем поля image, quality_level и quality_warning + super().save(update_fields=['image', 'quality_level', 'quality_warning']) + else: + # Просто сохраняем без обработки изображения + super().save(*args, **kwargs) + + def delete(self, *args, **kwargs): + """Удаляет все версии изображения при удалении фото""" + import logging + from ..utils.image_processor import ImageProcessor + + logger = logging.getLogger(__name__) + + if self.image: + try: + entity = self.get_entity() + entity_type = self.get_entity_type() + logger.info(f"[{self.__class__.__name__}.delete] Удаляем изображение: {self.image.name}") + ImageProcessor.delete_all_versions( + entity_type, + self.image.name, + entity_id=entity.id, + photo_id=self.id + ) + logger.info(f"[{self.__class__.__name__}.delete] ✓ Все версии изображения удалены") + except Exception as e: + logger.error( + f"[{self.__class__.__name__}.delete] ✗ Ошибка при удалении версий: {str(e)}", + exc_info=True + ) + + super().delete(*args, **kwargs) + + def get_thumbnail_url(self): + """Получить URL миниатюры (150x150)""" + from ..utils.image_service import ImageService + return ImageService.get_thumbnail_url(self.image.name) + + def get_medium_url(self): + """Получить URL среднего размера (400x400)""" + from ..utils.image_service import ImageService + return ImageService.get_medium_url(self.image.name) + + def get_large_url(self): + """Получить URL большого размера (800x800)""" + from ..utils.image_service import ImageService + return ImageService.get_large_url(self.image.name) + + def get_original_url(self): + """Получить URL оригинального изображения""" + from ..utils.image_service import ImageService + return ImageService.get_original_url(self.image.name) + + +class ProductPhoto(BasePhoto): + """ + Модель для хранения фото товара (один товар может иметь несколько фото). + Автоматически создает несколько размеров при загрузке: original, thumbnail, medium, large. + + Каждое фото автоматически оценивается по качеству на основе размера: + - quality_level: Уровень качества (excellent/good/acceptable/poor/very_poor) + - quality_warning: True если требует обновления перед выгрузкой на сайт + """ + QUALITY_LEVEL_CHOICES = [ + ('excellent', 'Отлично (>= 2052px)'), + ('good', 'Хорошо (1512-2051px)'), + ('acceptable', 'Приемлемо (864-1511px)'), + ('poor', 'Плохо (432-863px)'), + ('very_poor', 'Очень плохо (< 432px)'), + ] + + product = models.ForeignKey( + 'Product', + on_delete=models.CASCADE, + related_name='photos', + verbose_name="Товар" + ) + image = models.ImageField(upload_to='products/temp/', verbose_name="Оригинальное фото") + + # Оценка качества + quality_level = models.CharField( + max_length=15, + choices=QUALITY_LEVEL_CHOICES, + default='acceptable', + db_index=True, + verbose_name="Уровень качества", + help_text='Определяется автоматически на основе размера изображения' + ) + quality_warning = models.BooleanField( + default=False, + db_index=True, + verbose_name="Требует обновления", + help_text='True если нужно обновить фото перед выгрузкой на сайт (poor или very_poor)' + ) + + class Meta: + verbose_name = "Фото товара" + verbose_name_plural = "Фото товаров" + ordering = ['order', '-created_at'] + indexes = [ + models.Index(fields=['quality_level']), + models.Index(fields=['quality_warning']), + models.Index(fields=['quality_warning', 'product']), # Для поиска товаров требующих обновления фото + ] + + def __str__(self): + return f"Фото для {self.product.name} ({self.get_quality_level_display()})" + + def get_entity(self): + """Возвращает связанный товар""" + return self.product + + def get_entity_type(self): + """Возвращает тип сущности для путей""" + return 'products' + + +class ProductKitPhoto(BasePhoto): + """ + Модель для хранения фото комплекта (один комплект может иметь несколько фото). + Автоматически создает несколько размеров при загрузке: original, thumbnail, medium, large. + + Каждое фото автоматически оценивается по качеству на основе размера. + """ + QUALITY_LEVEL_CHOICES = [ + ('excellent', 'Отлично (>= 2052px)'), + ('good', 'Хорошо (1512-2051px)'), + ('acceptable', 'Приемлемо (864-1511px)'), + ('poor', 'Плохо (432-863px)'), + ('very_poor', 'Очень плохо (< 432px)'), + ] + + kit = models.ForeignKey( + 'ProductKit', + on_delete=models.CASCADE, + related_name='photos', + verbose_name="Комплект" + ) + image = models.ImageField(upload_to='kits/temp/', verbose_name="Оригинальное фото") + + # Оценка качества + quality_level = models.CharField( + max_length=15, + choices=QUALITY_LEVEL_CHOICES, + default='acceptable', + db_index=True, + verbose_name="Уровень качества", + help_text='Определяется автоматически на основе размера изображения' + ) + quality_warning = models.BooleanField( + default=False, + db_index=True, + verbose_name="Требует обновления", + help_text='True если нужно обновить фото перед выгрузкой на сайт' + ) + + class Meta: + verbose_name = "Фото комплекта" + verbose_name_plural = "Фото комплектов" + ordering = ['order', '-created_at'] + indexes = [ + models.Index(fields=['quality_level']), + models.Index(fields=['quality_warning']), + models.Index(fields=['quality_warning', 'kit']), + ] + + def __str__(self): + return f"Фото для {self.kit.name} ({self.get_quality_level_display()})" + + def get_entity(self): + """Возвращает связанный комплект""" + return self.kit + + def get_entity_type(self): + """Возвращает тип сущности для путей""" + return 'kits' + + +class ProductCategoryPhoto(BasePhoto): + """ + Модель для хранения фото категории (одна категория может иметь несколько фото). + Автоматически создает несколько размеров при загрузке: original, thumbnail, medium, large. + + Каждое фото автоматически оценивается по качеству на основе размера. + """ + QUALITY_LEVEL_CHOICES = [ + ('excellent', 'Отлично (>= 2052px)'), + ('good', 'Хорошо (1512-2051px)'), + ('acceptable', 'Приемлемо (864-1511px)'), + ('poor', 'Плохо (432-863px)'), + ('very_poor', 'Очень плохо (< 432px)'), + ] + + category = models.ForeignKey( + 'ProductCategory', + on_delete=models.CASCADE, + related_name='photos', + verbose_name="Категория" + ) + image = models.ImageField(upload_to='categories/temp/', verbose_name="Оригинальное фото") + + # Оценка качества + quality_level = models.CharField( + max_length=15, + choices=QUALITY_LEVEL_CHOICES, + default='acceptable', + db_index=True, + verbose_name="Уровень качества", + help_text='Определяется автоматически на основе размера изображения' + ) + quality_warning = models.BooleanField( + default=False, + db_index=True, + verbose_name="Требует обновления", + help_text='True если нужно обновить фото перед выгрузкой на сайт' + ) + + class Meta: + verbose_name = "Фото категории" + verbose_name_plural = "Фото категорий" + ordering = ['order', '-created_at'] + indexes = [ + models.Index(fields=['quality_level']), + models.Index(fields=['quality_warning']), + models.Index(fields=['quality_warning', 'category']), + ] + + def __str__(self): + return f"Фото для {self.category.name} ({self.get_quality_level_display()})" + + def get_entity(self): + """Возвращает связанную категорию""" + return self.category + + def get_entity_type(self): + """Возвращает тип сущности для путей""" + return 'categories' diff --git a/myproject/products/utils/image_processor.py b/myproject/products/utils/image_processor.py index b31e820..5ddfafd 100644 --- a/myproject/products/utils/image_processor.py +++ b/myproject/products/utils/image_processor.py @@ -79,12 +79,16 @@ class ImageProcessor: photo_id: ID фотографии Returns: - dict: Словарь с путями сохраненных файлов + dict: Словарь с сохраненными данными { 'original': 'products///original.jpg', 'large': 'products///large.webp', 'medium': 'products///medium.webp', 'thumbnail': 'products///thumb.webp', + 'width': 1920, + 'height': 1080, + 'quality_level': 'good', + 'quality_warning': False, } Raises: @@ -96,6 +100,11 @@ class ImageProcessor: try: # Открываем изображение img = Image.open(image_file) + width, height = img.size + + # Определяем качество на основе размеров + from ..validators.image_validators import get_image_quality_level + quality_level, needs_update = get_image_quality_level(width, height) # Конвертируем в RGB если необходимо (для JPEG/WebP) if img.mode in ('RGBA', 'LA', 'P'): @@ -126,6 +135,16 @@ class ImageProcessor: ) saved_paths[size_key] = size_path + # Добавляем информацию о качестве + saved_paths['width'] = width + saved_paths['height'] = height + saved_paths['quality_level'] = quality_level + saved_paths['quality_warning'] = needs_update + + logger.info( + f"Image processed: {width}x{height}px → quality={quality_level}, warning={needs_update}" + ) + return saved_paths except Exception as e: diff --git a/myproject/products/validators/image_validators.py b/myproject/products/validators/image_validators.py new file mode 100644 index 0000000..bd54b64 --- /dev/null +++ b/myproject/products/validators/image_validators.py @@ -0,0 +1,235 @@ +""" +Валидаторы и утилиты для оценки качества изображений товаров. + +Система полностью гибкая - все параметры читаются из settings: +- Пороги качества из IMAGE_QUALITY_LEVELS +- Описания из IMAGE_QUALITY_LABELS +- Максимальные размеры из IMAGE_PROCESSING_CONFIG + +Это позволяет менять поведение без изменения кода. +""" + +from django.conf import settings +from django.core.exceptions import ValidationError +from PIL import Image +import logging + +logger = logging.getLogger(__name__) + + +def get_max_dimension_from_config(): + """ + Динамически читает максимальный размер из IMAGE_PROCESSING_CONFIG. + + Получает max_width и max_height из original формата и возвращает максимальное значение. + + Returns: + int: Максимальный размер оригинального изображения (из settings) + + Examples: + >>> get_max_dimension_from_config() + 2160 # если original.max_width=2160 и original.max_height=2160 + """ + config = settings.IMAGE_PROCESSING_CONFIG + original_config = config.get('formats', {}).get('original', {}) + + # Получаем параметры с дефолтными значениями + max_width = original_config.get('max_width', 2160) + max_height = original_config.get('max_height', 2160) + + # Возвращаем максимальное значение + max_dimension = max(max_width, max_height) + + logger.debug(f"Max dimension from config: {max_dimension}px") + return max_dimension + + +def get_image_quality_level(width, height): + """ + Определяет уровень качества изображения на основе его размеров. + + Логика: + 1. Берет минимальное измерение (ширина или высота) + 2. Вычисляет его как процент от максимального размера (из settings) + 3. Сравнивает с пороги из IMAGE_QUALITY_LEVELS + 4. Возвращает уровень качества и флаг требует ли обновления + + Args: + width (int): Ширина изображения в пиксели + height (int): Высота изображения в пиксели + + Returns: + tuple: (quality_level, needs_update) + quality_level (str): Один из: 'excellent', 'good', 'acceptable', 'poor', 'very_poor' + needs_update (bool): True если требует обновления перед выгрузкой + + Examples: + >>> get_image_quality_level(2160, 2160) + ('excellent', False) + + >>> get_image_quality_level(1400, 1400) # если max=2160 + ('good', False) # 1400/2160 = 0.648 >= 0.70? НЕТ, но >= 0.40 (acceptable) + + >>> get_image_quality_level(800, 800) # если max=2160 + ('acceptable', False) # 800/2160 = 0.370 >= 0.40? НЕТ, но >= 0.20 (poor) + """ + min_dimension = min(width, height) + max_dimension = get_max_dimension_from_config() + + # Читаем пороги из settings с fallback значениями + quality_levels = getattr(settings, 'IMAGE_QUALITY_LEVELS', { + 'excellent': 0.95, + 'good': 0.70, + 'acceptable': 0.40, + 'poor': 0.20, + }) + + # Вычисляем процент от максимума + quality_percent = min_dimension / max_dimension + + logger.debug(f"Image size: {width}x{height}px, Quality: {quality_percent:.1%} of max {max_dimension}px") + + # Определяем уровень качества + # 'poor' и 'very_poor' требуют обновления перед выгрузкой на сайт + if quality_percent >= quality_levels.get('excellent', 0.95): + return 'excellent', False + elif quality_percent >= quality_levels.get('good', 0.70): + return 'good', False + elif quality_percent >= quality_levels.get('acceptable', 0.40): + return 'acceptable', False + elif quality_percent >= quality_levels.get('poor', 0.20): + return 'poor', True # ← Требует обновления + else: + return 'very_poor', True # ← Требует обновления + + +def get_quality_info(quality_level): + """ + Получить полную информацию о уровне качества. + + Читает описание, цвет, иконку и рекомендацию из IMAGE_QUALITY_LABELS в settings. + + Args: + quality_level (str): Уровень качества ('excellent', 'good', 'acceptable', 'poor', 'very_poor') + + Returns: + dict: Информация о уровне с ключами: + - label: Название уровня + - short_label: Краткое название с иконкой + - description: Описание + - color: Цвет для UI (success, info, warning, danger) + - icon: Иконка (✓, ◐, ⚠, ✗) + - recommendation: Рекомендация для пользователя + - badge_class: CSS класс для бутстрапа + + Examples: + >>> get_quality_info('excellent') + {'label': 'Отлично', 'color': 'success', ...} + + >>> get_quality_info('poor') + {'label': 'Плохо', 'color': 'danger', ...} + """ + labels = getattr(settings, 'IMAGE_QUALITY_LABELS', {}) + default_info = { + 'label': 'Неизвестно', + 'short_label': 'Неизвестно', + 'description': 'Не удалось определить качество', + 'color': 'secondary', + 'icon': '?', + 'recommendation': 'Обновите изображение', + 'badge_class': 'badge-secondary', + } + return labels.get(quality_level, default_info) + + +def validate_product_image(image_file): + """ + Валидация изображения товара. + + Проверяет что файл является валидным изображением и может быть открыт. + Не отклоняет маленькие изображения - просто определяет их качество. + + Args: + image_file: Django UploadedFile объект + + Returns: + dict: Результат валидации с ключами: + - valid (bool): Прошло ли валидацию + - width (int): Ширина изображения + - height (int): Высота изображения + - quality_level (str): Уровень качества + - needs_update (bool): Требует ли обновления + - message (str): Сообщение для пользователя + - error (str): Сообщение об ошибке (если valid=False) + + Raises: + ValidationError: Если файл не является изображением + """ + try: + # Открываем изображение для получения размеров + img = Image.open(image_file) + width, height = img.size + + # Определяем качество + quality_level, needs_update = get_image_quality_level(width, height) + quality_info = get_quality_info(quality_level) + + # Подготавливаем сообщение для пользователя + message = ( + f"Размер изображения: {width}×{height}px. " + f"Качество: {quality_info['label']} - {quality_info['description']}." + ) + + if needs_update: + message += f" {quality_info['recommendation']}" + + logger.info(f"Image validation: {width}x{height}px → {quality_level}") + + return { + 'valid': True, + 'width': width, + 'height': height, + 'quality_level': quality_level, + 'needs_update': needs_update, + 'message': message, + 'error': None, + } + + except Exception as e: + error_msg = f"Не удалось обработать изображение: {str(e)}" + logger.error(f"Image validation error: {str(e)}", exc_info=True) + + return { + 'valid': False, + 'width': None, + 'height': None, + 'quality_level': None, + 'needs_update': False, + 'message': None, + 'error': error_msg, + } + + +def get_quality_level_percentage(quality_level): + """ + Получить процент от максимума для уровня качества. + + Используется для визуализации прогресс-баров в UI. + + Args: + quality_level (str): Уровень качества + + Returns: + float: Процент от максимума (0.0 - 1.0) + """ + quality_levels = getattr(settings, 'IMAGE_QUALITY_LEVELS', { + 'excellent': 0.95, + 'good': 0.70, + 'acceptable': 0.40, + 'poor': 0.20, + }) + + if quality_level == 'very_poor': + return 0.1 # Визуально покажет как очень мало + + return quality_levels.get(quality_level, 0.5)