Fix: Implement tenant-aware file storage for photo isolation

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>
This commit is contained in:
2025-11-23 20:05:20 +03:00
parent 122ea807d2
commit ff40a9c1f0
8 changed files with 740 additions and 3 deletions

View File

@@ -7,6 +7,64 @@ 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):
"""
Абстрактный базовый класс для всех фотомоделей.
@@ -219,6 +277,12 @@ class ProductPhoto(BasePhoto):
Каждое фото автоматически оценивается по качеству на основе размера:
- 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)'),
@@ -234,7 +298,7 @@ class ProductPhoto(BasePhoto):
related_name='photos',
verbose_name="Товар"
)
image = models.ImageField(upload_to='products/temp/', verbose_name="Оригинальное фото")
image = models.ImageField(upload_to=get_product_photo_upload_path, verbose_name="Оригинальное фото")
# Оценка качества
quality_level = models.CharField(
@@ -280,6 +344,12 @@ 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)'),
@@ -295,7 +365,7 @@ class ProductKitPhoto(BasePhoto):
related_name='photos',
verbose_name="Комплект"
)
image = models.ImageField(upload_to='kits/temp/', verbose_name="Оригинальное фото")
image = models.ImageField(upload_to=get_kit_photo_upload_path, verbose_name="Оригинальное фото")
# Оценка качества
quality_level = models.CharField(
@@ -341,6 +411,12 @@ 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)'),
@@ -356,7 +432,7 @@ class ProductCategoryPhoto(BasePhoto):
related_name='photos',
verbose_name="Категория"
)
image = models.ImageField(upload_to='categories/temp/', verbose_name="Оригинальное фото")
image = models.ImageField(upload_to=get_category_photo_upload_path, verbose_name="Оригинальное фото")
# Оценка качества
quality_level = models.CharField(