Files
octopus/myproject/products/models/photos.py

604 lines
26 KiB
Python
Raw Permalink 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.db.models import Q
from django.utils import timezone
# ============================================
# Функции для upload_to с поддержкой мультитенантности
# ============================================
def get_product_photo_upload_path(instance, filename):
"""
Генерирует путь для загрузки фото товара.
Путь: products/temp/{filename}
Примечание: Tenant ID добавляется автоматически TenantAwareFileSystemStorage.
Финальный путь: tenants/{tenant_id}/products/temp/{filename}
Args:
instance: Объект ProductPhoto
filename: Исходное имя файла
Returns:
str: Путь для временного сохранения
"""
return f'products/temp/{filename}'
def get_kit_photo_upload_path(instance, filename):
"""
Генерирует путь для загрузки фото комплекта.
Путь: kits/temp/{filename}
Примечание: Tenant ID добавляется автоматически TenantAwareFileSystemStorage.
Финальный путь: tenants/{tenant_id}/kits/temp/{filename}
Args:
instance: Объект ProductKitPhoto
filename: Исходное имя файла
Returns:
str: Путь для временного сохранения
"""
return f'kits/temp/{filename}'
def get_category_photo_upload_path(instance, filename):
"""
Генерирует путь для загрузки фото категории.
Путь: categories/temp/{filename}
Примечание: Tenant ID добавляется автоматически TenantAwareFileSystemStorage.
Финальный путь: tenants/{tenant_id}/categories/temp/{filename}
Args:
instance: Объект ProductCategoryPhoto
filename: Исходное имя файла
Returns:
str: Путь для временного сохранения
"""
return f'categories/temp/{filename}'
class BasePhoto(models.Model):
"""
Абстрактный базовый класс для всех фотомоделей.
Объединяет общую логику обработки изображений для ProductPhoto, ProductKitPhoto, ProductCategoryPhoto.
Паттерн: Template Method
- Общие методы save(), delete() и get_*_url() определены здесь
- Специфичные детали (related entity, upload path) задаются через абстрактные методы
Главное фото:
- is_main=True определяет главное фото (используется в карточках, каталоге, превью)
- Constraint уникальности (только одно is_main=True на сущность) реализован в дочерних классах
- order используется для сортировки остальных фото
"""
image = models.ImageField(verbose_name="Оригинальное фото")
is_main = models.BooleanField(
default=False,
db_index=True,
verbose_name="Главное фото",
help_text="Главное фото отображается в карточках, каталоге и превью. Может быть только одно."
)
order = models.PositiveIntegerField(default=0, verbose_name="Порядок")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
class Meta:
abstract = True
ordering = ['-is_main', '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 если требует обновления перед выгрузкой на сайт
МУЛЬТИТЕНАНТНОСТЬ:
Файлы сохраняются с автоматическим добавлением tenant_id в путь.
Структура на диске: media/tenants/{tenant_id}/products/{entity_id}/{photo_id}/{size}.ext
В БД сохраняется (для экономии места): products/{entity_id}/{photo_id}/{size}.ext
TenantAwareFileSystemStorage добавляет tenant_id автоматически при сохранении/удалении файлов.
"""
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=get_product_photo_upload_path, 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 = ['-is_main', 'order', '-created_at']
indexes = [
models.Index(fields=['quality_level']),
models.Index(fields=['quality_warning']),
models.Index(fields=['quality_warning', 'product']), # Для поиска товаров требующих обновления фото
]
constraints = [
models.UniqueConstraint(
fields=['product'],
condition=Q(is_main=True),
name='unique_main_photo_per_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.
Каждое фото автоматически оценивается по качеству на основе размера.
МУЛЬТИТЕНАНТНОСТЬ:
Файлы сохраняются с автоматическим добавлением tenant_id в путь.
Структура на диске: media/tenants/{tenant_id}/kits/{entity_id}/{photo_id}/{size}.ext
В БД сохраняется (для экономии места): kits/{entity_id}/{photo_id}/{size}.ext
TenantAwareFileSystemStorage добавляет tenant_id автоматически при сохранении/удалении файлов.
"""
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=get_kit_photo_upload_path, 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 = ['-is_main', 'order', '-created_at']
indexes = [
models.Index(fields=['quality_level']),
models.Index(fields=['quality_warning']),
models.Index(fields=['quality_warning', 'kit']),
]
constraints = [
models.UniqueConstraint(
fields=['kit'],
condition=Q(is_main=True),
name='unique_main_photo_per_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.
Каждое фото автоматически оценивается по качеству на основе размера.
МУЛЬТИТЕНАНТНОСТЬ:
Файлы сохраняются с автоматическим добавлением tenant_id в путь.
Структура на диске: media/tenants/{tenant_id}/categories/{entity_id}/{photo_id}/{size}.ext
В БД сохраняется (для экономии места): categories/{entity_id}/{photo_id}/{size}.ext
TenantAwareFileSystemStorage добавляет tenant_id автоматически при сохранении/удалении файлов.
"""
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=get_category_photo_upload_path, 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 = ['-is_main', 'order', '-created_at']
indexes = [
models.Index(fields=['quality_level']),
models.Index(fields=['quality_warning']),
models.Index(fields=['quality_warning', 'category']),
]
constraints = [
models.UniqueConstraint(
fields=['category'],
condition=Q(is_main=True),
name='unique_main_photo_per_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'