- Добавлен retry на 5 сек при DoesNotExist для ожидания коммита транзакции - temp_path сохраняется в PhotoProcessingStatus.result_data при постановке задачи - При окончательной неудаче not_found удаляется осиротевший temp файл - Предотвращает накопление temp файлов при гонке создания фото
495 lines
21 KiB
Python
495 lines
21 KiB
Python
"""
|
||
Модели для работы с фотографиями продуктов, комплектов и категорий.
|
||
Использует паттерн 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):
|
||
"""
|
||
При загрузке нового изображения запускает асинхронную обработку через Celery.
|
||
|
||
ВАЖНО: Асинхронная обработка!
|
||
1. Сохраняем объект БЕЗ обработки изображения (быстро)
|
||
2. Запускаем Celery task для обработки (в фоне)
|
||
3. Пользователь видит "Обрабатывается..." + прогресс-бар
|
||
4. Когда обработка завершится, фото обновляется
|
||
|
||
Преимущества:
|
||
- HTTP request не блокируется (не зависает UI)
|
||
- Другие тенанты работают нормально
|
||
- Можно обрабатывать много фото параллельно
|
||
"""
|
||
import logging
|
||
from django.db import connection
|
||
from ..utils.image_processor import ImageProcessor
|
||
|
||
logger = logging.getLogger(__name__)
|
||
is_new = not self.pk
|
||
use_async = kwargs.pop('use_async', True) # Можно отключить для тестов/админки
|
||
|
||
# Если это новый объект с изображением
|
||
if is_new and self.image:
|
||
temp_image = self.image
|
||
# КРИТИЧНО: Сохраняем объект С ФАЙЛОМ сначала!
|
||
# (потом Celery сможет прочитать файл)
|
||
super().save(*args, **kwargs)
|
||
|
||
if use_async:
|
||
# АСИНХРОННАЯ ОБРАБОТКА через Celery
|
||
try:
|
||
from ..tasks import process_product_photo_async
|
||
|
||
# Получаем текущую схему тенанта (для мультитенантности)
|
||
schema_name = connection.schema_name
|
||
logger.info(f"[BasePhoto.save] Photo {self.pk} submitted to Celery "
|
||
f"(schema: {schema_name})")
|
||
|
||
# Формируем полный путь к модели
|
||
photo_model_class = f"{self._meta.app_label}.{self.__class__.__name__}"
|
||
|
||
# Запускаем асинхронную задачу
|
||
task_result = process_product_photo_async.delay(
|
||
self.pk,
|
||
photo_model_class,
|
||
schema_name
|
||
)
|
||
|
||
logger.info(f"[BasePhoto.save] Task ID: {task_result.id}")
|
||
|
||
# Создаем запись о статусе обработки для фронтенда
|
||
PhotoProcessingStatus.objects.create(
|
||
photo_id=self.pk,
|
||
photo_model=photo_model_class,
|
||
status='pending',
|
||
task_id=task_result.id,
|
||
result_data={'temp_path': getattr(temp_image, 'name', None)}
|
||
)
|
||
|
||
except ImportError:
|
||
logger.error("Celery task import failed, falling back to sync processing")
|
||
# Fallback на синхронную обработку если Celery недоступен
|
||
self._process_image_sync(temp_image, use_sync=True)
|
||
|
||
else:
|
||
# СИНХРОННАЯ ОБРАБОТКА (для совместимости и тестов)
|
||
self._process_image_sync(temp_image)
|
||
|
||
else:
|
||
# Обновление существующего объекта (без изменения изображения)
|
||
super().save(*args, **kwargs)
|
||
|
||
def _process_image_sync(self, temp_image, use_sync=False):
|
||
"""
|
||
Синхронная обработка изображения (fallback метод).
|
||
Используется только если Celery недоступен.
|
||
"""
|
||
from ..utils.image_processor import ImageProcessor
|
||
from django.core.files.storage import default_storage
|
||
|
||
entity = self.get_entity()
|
||
entity_type = self.get_entity_type()
|
||
|
||
# Сохраняем путь к временному файлу до перезаписи поля image
|
||
temp_path = getattr(temp_image, 'name', None)
|
||
|
||
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)
|
||
|
||
super().save(update_fields=['image', 'quality_level', 'quality_warning'])
|
||
|
||
# Удаляем временный файл из temp после успешной обработки
|
||
try:
|
||
if temp_path and default_storage.exists(temp_path):
|
||
default_storage.delete(temp_path)
|
||
except Exception:
|
||
pass
|
||
|
||
def delete(self, *args, **kwargs):
|
||
"""Удаляет все версии изображения при удалении фото"""
|
||
import logging
|
||
from ..utils.image_processor import ImageProcessor
|
||
from django.core.files.storage import default_storage
|
||
|
||
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
|
||
)
|
||
|
||
# Если фото так и осталось во временном пути (обработка не завершилась) — удаляем temp файл
|
||
try:
|
||
if '/temp/' in self.image.name and default_storage.exists(self.image.name):
|
||
default_storage.delete(self.image.name)
|
||
logger.info(f"[{self.__class__.__name__}.delete] Deleted temp file: {self.image.name}")
|
||
except Exception as del_exc:
|
||
logger.warning(f"[{self.__class__.__name__}.delete] Could not delete temp file {self.image.name}: {del_exc}")
|
||
|
||
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'
|
||
|
||
|
||
class PhotoProcessingStatus(models.Model):
|
||
"""
|
||
Модель для отслеживания статуса асинхронной обработки фото через Celery.
|
||
Используется для показа прогресса пользователю во время загрузки.
|
||
|
||
Каждая загрузка фото создает запись с информацией о статусе обработки.
|
||
Фронтенд опрашивает этот статус через API.
|
||
"""
|
||
STATUS_CHOICES = [
|
||
('pending', 'В очереди'),
|
||
('processing', 'Обрабатывается'),
|
||
('completed', 'Завершено'),
|
||
('failed', 'Ошибка'),
|
||
]
|
||
|
||
photo_id = models.IntegerField(
|
||
verbose_name="ID фото",
|
||
help_text='ID объекта ProductPhoto/ProductKitPhoto/ProductCategoryPhoto'
|
||
)
|
||
photo_model = models.CharField(
|
||
max_length=100,
|
||
verbose_name="Модель фото",
|
||
help_text='Полный путь модели (e.g., products.ProductPhoto)'
|
||
)
|
||
status = models.CharField(
|
||
max_length=20,
|
||
choices=STATUS_CHOICES,
|
||
default='pending',
|
||
db_index=True,
|
||
verbose_name="Статус обработки"
|
||
)
|
||
task_id = models.CharField(
|
||
max_length=255,
|
||
blank=True,
|
||
verbose_name="ID задачи Celery",
|
||
help_text='Уникальный ID задачи для отслеживания',
|
||
db_index=True
|
||
)
|
||
error_message = models.TextField(
|
||
blank=True,
|
||
verbose_name="Сообщение об ошибке",
|
||
help_text='Детальное описание ошибки при обработке'
|
||
)
|
||
result_data = models.JSONField(
|
||
default=dict,
|
||
blank=True,
|
||
verbose_name="Результаты обработки",
|
||
help_text='JSON с информацией о качестве, путях и метаданных'
|
||
)
|
||
created_at = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name="Дата создания"
|
||
)
|
||
updated_at = models.DateTimeField(
|
||
auto_now=True,
|
||
verbose_name="Дата обновления"
|
||
)
|
||
started_at = models.DateTimeField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Время начала обработки"
|
||
)
|
||
completed_at = models.DateTimeField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Время завершения обработки"
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Статус обработки фото"
|
||
verbose_name_plural = "Статусы обработки фото"
|
||
ordering = ['-created_at']
|
||
indexes = [
|
||
models.Index(fields=['photo_id', 'photo_model']),
|
||
models.Index(fields=['task_id']),
|
||
models.Index(fields=['status']),
|
||
models.Index(fields=['status', 'created_at']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.photo_model}#{self.photo_id} - {self.get_status_display()}"
|
||
|
||
@property
|
||
def is_processing(self):
|
||
"""Проверяет находится ли фото в обработке"""
|
||
return self.status in ['pending', 'processing']
|
||
|
||
@property
|
||
def is_completed(self):
|
||
"""Проверяет завершена ли обработка успешно"""
|
||
return self.status == 'completed'
|
||
|
||
@property
|
||
def is_failed(self):
|
||
"""Проверяет произошла ли ошибка"""
|
||
return self.status == 'failed'
|