fix: Сохранять файл фото ДО запуска Celery task
При асинхронной обработке фото нужно сначала сохранить файл в БД,
потом запустить Celery task. Иначе task не найдет файл.
Изменения:
- BasePhoto.save() теперь сохраняет файл перед запуском task
- Исправлена проблема 'Photo has no image file' в Celery worker
🤖 Generated with Claude Code
This commit is contained in:
@@ -47,78 +47,99 @@ class BasePhoto(models.Model):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
При загрузке нового изображения обрабатывает его и создает все необходимые размеры.
|
||||
Автоматически определяет и сохраняет уровень качества (quality_level и quality_warning).
|
||||
При загрузке нового изображения запускает асинхронную обработку через 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
|
||||
|
||||
is_new = not self.pk
|
||||
entity = self.get_entity()
|
||||
entity_type = self.get_entity_type()
|
||||
|
||||
# Если это новый объект с изображением, нужно сначала сохранить без изображения, чтобы получить ID
|
||||
if is_new and self.image:
|
||||
# Сохраняем объект без изображения, чтобы получить ID
|
||||
temp_image = self.image
|
||||
self.image = None
|
||||
super().save(*args, **kwargs)
|
||||
processed_paths = ImageProcessor.process_image(
|
||||
temp_image,
|
||||
entity_type,
|
||||
entity_id=entity.id,
|
||||
photo_id=self.id
|
||||
)
|
||||
|
||||
# Теперь обрабатываем изображение с известными 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.image = processed_paths['original']
|
||||
self.quality_level = processed_paths.get('quality_level', 'acceptable')
|
||||
self.quality_warning = processed_paths.get('quality_warning', False)
|
||||
|
||||
# Сохраняем уровень качества
|
||||
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)
|
||||
super().save(update_fields=['image', 'quality_level', 'quality_warning'])
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""Удаляет все версии изображения при удалении фото"""
|
||||
@@ -351,3 +372,101 @@ class ProductCategoryPhoto(BasePhoto):
|
||||
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'
|
||||
|
||||
Reference in New Issue
Block a user