Files
octopus/myproject/products/models/photos.py
Andrey Smakotin 0791ebb13b fix: Сохранять файл фото ДО запуска Celery task
При асинхронной обработке фото нужно сначала сохранить файл в БД,
потом запустить Celery task. Иначе task не найдет файл.

Изменения:
- BasePhoto.save() теперь сохраняет файл перед запуском task
- Исправлена проблема 'Photo has no image file' в Celery worker

🤖 Generated with Claude Code
2025-11-15 11:11:08 +03:00

473 lines
19 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):
"""
При загрузке нового изображения запускает асинхронную обработку через 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
)
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
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)
super().save(update_fields=['image', 'quality_level', 'quality_warning'])
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'
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'