""" Модели для работы с фотографиями продуктов, комплектов и категорий. Использует паттерн Template Method для устранения дублирования кода. """ from abc import abstractmethod from django.db import models 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) задаются через абстрактные методы """ 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 если требует обновления перед выгрузкой на сайт МУЛЬТИТЕНАНТНОСТЬ: Файлы сохраняются с автоматическим добавлением 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 = ['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. Каждое фото автоматически оценивается по качеству на основе размера. МУЛЬТИТЕНАНТНОСТЬ: Файлы сохраняются с автоматическим добавлением 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 = ['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. Каждое фото автоматически оценивается по качеству на основе размера. МУЛЬТИТЕНАНТНОСТЬ: Файлы сохраняются с автоматическим добавлением 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 = ['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'