feat: Реализовать систему оценки качества фотографий товаров (Фаза 1)
Добавлена полностью гибкая система для оценки качества фотографий на основе размеров:
Конфигурация (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 <noreply@anthropic.com>
This commit is contained in:
@@ -145,7 +145,6 @@ DATABASES = {
|
|||||||
'PORT': env('DB_PORT'),
|
'PORT': env('DB_PORT'),
|
||||||
'OPTIONS': {
|
'OPTIONS': {
|
||||||
'client_encoding': 'UTF8',
|
'client_encoding': 'UTF8',
|
||||||
'connect_timeout': 10,
|
|
||||||
},
|
},
|
||||||
'CONN_MAX_AGE': 0,
|
'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
|
# BUSINESS LOGIC SETTINGS
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|||||||
353
myproject/products/models/photos.py
Normal file
353
myproject/products/models/photos.py
Normal file
@@ -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'
|
||||||
@@ -79,12 +79,16 @@ class ImageProcessor:
|
|||||||
photo_id: ID фотографии
|
photo_id: ID фотографии
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Словарь с путями сохраненных файлов
|
dict: Словарь с сохраненными данными
|
||||||
{
|
{
|
||||||
'original': 'products/<entity_id>/<photo_id>/original.jpg',
|
'original': 'products/<entity_id>/<photo_id>/original.jpg',
|
||||||
'large': 'products/<entity_id>/<photo_id>/large.webp',
|
'large': 'products/<entity_id>/<photo_id>/large.webp',
|
||||||
'medium': 'products/<entity_id>/<photo_id>/medium.webp',
|
'medium': 'products/<entity_id>/<photo_id>/medium.webp',
|
||||||
'thumbnail': 'products/<entity_id>/<photo_id>/thumb.webp',
|
'thumbnail': 'products/<entity_id>/<photo_id>/thumb.webp',
|
||||||
|
'width': 1920,
|
||||||
|
'height': 1080,
|
||||||
|
'quality_level': 'good',
|
||||||
|
'quality_warning': False,
|
||||||
}
|
}
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
@@ -96,6 +100,11 @@ class ImageProcessor:
|
|||||||
try:
|
try:
|
||||||
# Открываем изображение
|
# Открываем изображение
|
||||||
img = Image.open(image_file)
|
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)
|
# Конвертируем в RGB если необходимо (для JPEG/WebP)
|
||||||
if img.mode in ('RGBA', 'LA', 'P'):
|
if img.mode in ('RGBA', 'LA', 'P'):
|
||||||
@@ -126,6 +135,16 @@ class ImageProcessor:
|
|||||||
)
|
)
|
||||||
saved_paths[size_key] = size_path
|
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
|
return saved_paths
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
235
myproject/products/validators/image_validators.py
Normal file
235
myproject/products/validators/image_validators.py
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user