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:
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'
|
||||
Reference in New Issue
Block a user