Files
octopus/myproject/products/models/photos.py
Andrey Smakotin 622e17a775 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>
2025-11-02 14:39:33 +03:00

354 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Модели для работы с фотографиями продуктов, комплектов и категорий.
Использует паттерн 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'