Files
octopus/myproject/products/utils/image_service.py
Andrey Smakotin ff40a9c1f0 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>
2025-11-23 20:05:20 +03:00

132 lines
5.6 KiB
Python
Raw 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.
"""
Сервис для получения URL изображений разных размеров.
Используется в шаблонах и представлениях для удобного доступа к разным версиям.
"""
import os
from django.conf import settings
from django.core.files.storage import default_storage
class ImageService:
"""
Сервис для работы с изображениями разных размеров.
Динамически строит URL на основе пути к оригинальному файлу.
МУЛЬТИТЕНАНТНОСТЬ:
На диске файлы хранятся как: tenants/{tenant_id}/products/{entity_id}/{photo_id}/{size}.ext
В БД сохраняется путь БЕЗ tenant_id: products/{entity_id}/{photo_id}/{size}.ext
TenantAwareFileSystemStorage автоматически добавляет/удаляет tenant_id при работе с файлами.
ImageService работает с путями из БД и генерирует URL, при необходимости системе видны файлы только своего тенанта.
"""
# Константы для маппинга форматов и расширений файлов
FORMAT_EXTENSIONS = {
'JPEG': 'jpg',
'WEBP': 'webp',
'PNG': 'png',
}
@staticmethod
def _get_config():
"""Получить конфигурацию из settings"""
return getattr(settings, 'IMAGE_PROCESSING_CONFIG', {})
@staticmethod
def _get_format_config(size_key):
"""Получить конфигурацию формата для заданного типа изображения"""
config = ImageService._get_config()
formats = config.get('formats', {})
return formats.get(size_key, {'format': 'JPEG', 'quality': 90})
@staticmethod
def _get_file_extension(image_format):
"""Получить расширение файла для заданного формата"""
return ImageService.FORMAT_EXTENSIONS.get(image_format, 'jpg')
@staticmethod
def _normalize_size_name(size_key):
"""Преобразовать 'thumbnail' в 'thumb' для имени файла"""
return 'thumb' if size_key == 'thumbnail' else size_key
@staticmethod
def get_url(original_image_path, size='medium'):
"""
Получает URL изображения нужного размера.
Структура хранения: base_path/entity_id/photo_id/size.ext
Пример: products/123/456/medium.webp
Args:
original_image_path: Путь к оригинальному файлу (из models.image)
Пример: products/123/456/original.jpg
size: Размер ('original', 'large', 'medium', 'thumbnail')
По умолчанию 'medium'
Returns:
str: URL изображения или пустая строка если нет файла
"""
if not original_image_path:
return ''
try:
path_str = str(original_image_path)
parts = path_str.split('/')
if len(parts) < 3:
return ''
# Извлекаем base_path, entity_id, photo_id из пути
base_path = parts[0] # products, kits, categories
entity_id = parts[1] # ID сущности
photo_id = parts[2] # ID фото
# Определяем расширение из конфигурации
format_config = ImageService._get_format_config(size)
image_format = format_config.get('format', 'JPEG')
extension = ImageService._get_file_extension(image_format)
# Преобразуем thumbnail в thumb
final_size_name = ImageService._normalize_size_name(size)
# Создаем путь и возвращаем URL
file_path = f"{base_path}/{entity_id}/{photo_id}/{final_size_name}.{extension}"
return f"{settings.MEDIA_URL}{file_path}"
except Exception:
return ''
@staticmethod
def get_thumbnail_url(original_image_path):
"""Получить URL миниатюры (150x150)"""
return ImageService.get_url(original_image_path, 'thumbnail')
@staticmethod
def get_medium_url(original_image_path):
"""Получить URL среднего размера (400x400)"""
return ImageService.get_url(original_image_path, 'medium')
@staticmethod
def get_large_url(original_image_path):
"""Получить URL большого размера (800x800)"""
return ImageService.get_url(original_image_path, 'large')
@staticmethod
def get_original_url(original_image_path):
"""Получить URL оригинального изображения"""
return ImageService.get_url(original_image_path, 'original')
@staticmethod
def get_all_urls(original_image_path):
"""
Получить все версии изображения.
Returns:
dict: {'original': url, 'thumbnail': url, 'medium': url, 'large': url}
"""
return {
'original': ImageService.get_original_url(original_image_path),
'thumbnail': ImageService.get_thumbnail_url(original_image_path),
'medium': ImageService.get_medium_url(original_image_path),
'large': ImageService.get_large_url(original_image_path),
}