Resolves critical bug where photos of products with the same ID in different tenants were overwriting each other. Implemented complete isolation of media files between tenants using custom Django storage backend. ## Changes ### New Files - products/utils/storage.py: TenantAwareFileSystemStorage backend * Automatically adds tenant_id to file paths on disk * Prevents cross-tenant file access with security checks * Stores clean paths in DB for portability - products/tests/test_multi_tenant_photos.py: Comprehensive tests * 5 tests covering isolation, security, and configuration * All tests passing ✅ - MULTITENANT_PHOTO_FIX.md: Complete documentation ### Modified Files - settings.py: Configured DEFAULT_FILE_STORAGE to use TenantAwareFileSystemStorage - products/models/photos.py: * Converted upload_to from strings to callable functions * Updated ProductPhoto, ProductKitPhoto, ProductCategoryPhoto * Added tenant isolation documentation - products/tasks.py: Added documentation about file structure - products/utils/image_processor.py: Added documentation - products/utils/image_service.py: Added documentation ## Architecture **On disk:** media/tenants/{tenant_id}/products/{entity_id}/{photo_id}/{size}.ext **In DB:** products/{entity_id}/{photo_id}/{size}.ext Tenant ID is automatically added/removed during file operations. ## Security - Storage rejects cross-tenant file access - Proper tenant context validation - Integration with django-tenants schema system ## Testing - All 5 multi-tenant photo tests pass - Verified photo paths are isolated per tenant - Verified storage rejects cross-tenant access - Verified configuration is correct ## Future-proof - Ready for S3 migration (just change storage backend) - No breaking changes to existing code - Clean separation of concerns Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
571 lines
24 KiB
Python
571 lines
24 KiB
Python
"""
|
||
Модели для работы с фотографиями продуктов, комплектов и категорий.
|
||
Использует паттерн 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'
|