Updated ImageService to use default_storage.url() instead of manually
constructing URLs. This ensures images displayed on the frontend correctly
include the tenant_id in the path, enabling proper file access within
multi-tenant environment.
Changes:
- ImageService.get_url() now delegates to default_storage.url()
- All image URLs now include /media/tenants/{tenant_id}/ path
- Ensures consistent behavior with TenantAwareFileSystemStorage
- Frontend photos now display correctly with tenant isolation
Result:
- Thumbnail URLs: /media/tenants/papa/products/4/28/thumb.webp
- Medium URLs: /media/tenants/papa/products/4/28/medium.webp
- Large URLs: /media/tenants/papa/products/4/28/large.webp
- Original URLs: /media/tenants/papa/products/4/28/original.jpg
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
11 KiB
Решение: Изоляция фотографий товаров между тенантами
Проблема
Фотографии товаров одного тенанта перезаписывали фотографии другого тенанта. Два разных тенанта с товарами ID=1 использовали одни и те же файлы:
media/products/1/1/original.jpg ← перезатиралось для каждого тенанта
Решение: Tenant-Aware FileSystemStorage
Реализована полная изоляция файлов между тенантами через custom Django storage backend.
Архитектура
На диске (физическое хранилище):
media/tenants/{tenant_id}/products/{entity_id}/{photo_id}/{size}.ext
media/tenants/{tenant_id}/kits/{entity_id}/{photo_id}/{size}.ext
media/tenants/{tenant_id}/categories/{entity_id}/{photo_id}/{size}.ext
В базе данных (для экономии и мобильности):
products/{entity_id}/{photo_id}/{size}.ext
kits/{entity_id}/{photo_id}/{size}.ext
categories/{entity_id}/{photo_id}/{size}.ext
Tenant ID добавляется/удаляется автоматически при работе с файлами.
Реализованные изменения
1. Создан Custom Storage Backend
Файл: products/utils/storage.py
Класс TenantAwareFileSystemStorage расширяет стандартный Django FileSystemStorage:
_get_tenant_id()- Получает ID текущего тенанта из контекста django-tenants_get_tenant_path()- Добавляет tenant_id в начало путиget_available_name()- Проверяет уникальность на диске, но возвращает путь БЕЗ tenant_id для БД_save()- Сохраняет файл с tenant_id на диск, но возвращает путь БЕЗ tenant_id для БД_open()- Открывает файл, добавляя tenant_id если необходимо (критично для Celery!)path()- Преобразует относительные пути в полные системные пути с tenant_iddelete()- Удаляет файлы с проверкой принадлежности тенанту (безопасность)exists()- Проверяет существование с валидацией тенантаurl()- Генерирует URL с проверкой безопасности
Безопасность: Storage предотвращает доступ к файлам других тенантов и выбрасывает исключение при попытке кросс-тенантного доступа.
2. Обновлена конфигурация Django
Файл: myproject/settings.py
DEFAULT_FILE_STORAGE = 'products.utils.storage.TenantAwareFileSystemStorage'
3. Обновлены модели фотографий
Файл: products/models/photos.py
- Заменены жесткие
upload_to='products/temp/'на callable функции - Функции генерируют пути БЕЗ tenant_id (добавляется автоматически storage)
- Добавлены комментарии о мультитенантности в docstring каждого класса
Функции upload_to:
get_product_photo_upload_path()→products/temp/{filename}get_kit_photo_upload_path()→kits/temp/{filename}get_category_photo_upload_path()→categories/temp/{filename}
4. Обновлены утилиты обработки фотографий
Файлы:
products/utils/image_processor.py- Добавлены комментарии о мультитенантностиproducts/utils/image_service.py- Добавлены комментарии о структуре путейproducts/tasks.py- Обновлены комментарии о мультитенантности в Celery задачах
Важно: Эти файлы работают как есть благодаря архитектуре storage!
5. Созданы комплексные тесты
Файл: products/tests/test_multi_tenant_photos.py
Тесты проверяют:
- ✅ Что пути в БД не содержат tenant_id (для мобильности)
- ✅ Что пути на диске содержат tenant_id (для изоляции)
- ✅ Что фотографии разных тенантов сохраняются в разные места
- ✅ Что storage отказывает в доступе к файлам других тенантов
- ✅ Что storage настроен в settings
- ✅ Что качество фото устанавливается корректно
# Запуск тестов
cd myproject
python manage.py test products.tests.test_multi_tenant_photos -v 2
Результат: Все 5 тестов проходят успешно ✅
Как это работает
Сценарий загрузки фото
-
Пользователь загружает фото в tenant1
- Django создает
ProductPhotoобъект - Пользователь указывает файл (temporary)
- Django создает
-
BasePhoto.save() срабатывает
- Проверяет контекст (connection.schema_name = 'tenant_1')
- Запускает Celery задачу для асинхронной обработки
-
ImageField сохраняет файл
- Вызывает
TenantAwareFileSystemStorage._save() - Storage:
- Добавляет tenant_id:
tenants/tenant_1/products/temp/image.jpg - Сохраняет на диск:
media/tenants/tenant_1/products/temp/image.jpg - Возвращает БД путь БЕЗ tenant_id:
products/temp/image.jpg
- Добавляет tenant_id:
- Django сохраняет в БД:
products/temp/image.jpg
- Вызывает
-
Celery обрабатывает фото в фоне
- Активирует schema:
connection.set_schema('tenant_1') - Читает фото из БД (путь
products/temp/image.jpg) - Storage автоматически добавляет tenant_id при чтении
- Обрабатывает и создает размеры
- Сохраняет обработанные файлы
- Обновляет БД с путем:
products/{entity_id}/{photo_id}/original.jpg
- Активирует schema:
-
Когда пользователь заходит в Tenant2
- Товар с ID=1 в tenant2 имеет разные фото
- Файлы хранятся в:
media/tenants/tenant_2/products/1/{photo_id}/ - Не пересекаются с tenant1!
Безопасность
Защита от кросс-тенантного доступа
Storage проверяет tenant_id при операциях чтения/удаления:
def delete(self, name):
tenant_id = self._get_tenant_id()
if not name.startswith(f"tenants/{tenant_id}/"):
raise RuntimeError(f"Cannot delete file - belongs to different tenant")
Если пользователь попытается обратиться к файлу другого тенанта - получит исключение.
Преимущества решения
✅ Полная изоляция - Файлы разных тенантов физически разделены ✅ Безопасность - Storage предотвращает кросс-тенантный доступ ✅ Чистота БД - Пути в БД не содержат tenant_id (более мобильно) ✅ Минимум изменений - ImageProcessor и ImageService работают без изменений ✅ Асинхронность - Celery полностью поддерживает мультитенантность ✅ Масштабируемость - Готово к переходу на S3 в будущем ✅ Протестировано - 5 комплексных тестов проходят успешно
Путь к облаку (S3)
В будущем очень легко перейти на S3 хранилище:
# Просто замените одну строку в settings.py:
# Текущая конфигурация
DEFAULT_FILE_STORAGE = 'products.utils.storage.TenantAwareFileSystemStorage'
# Облачное хранилище (S3)
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
# Структура путей остается той же!
# S3: s3://bucket/tenants/{tenant_id}/products/{entity_id}/{photo_id}/{size}.ext
Структура paths останется идентичной - нужны только зависимости и конфигурация подключения к S3.
Отладка
Логирование
Все операции логируются с префиксом [Storage] для удобства отладки:
[Storage] Extracted tenant_id=tenant_1 from schema=tenant_1
[Storage] get_available_name: products/temp/image.jpg → checking disk with: tenants/tenant_1/products/temp/image.jpg
[Storage] Stripped tenant prefix: tenants/tenant_1/products/temp/image_lKjH.jpg → products/temp/image_lKjH.jpg
[Storage] _save: products/temp/image.jpg → tenants/tenant_1/products/temp/image.jpg
Проверка структуры файлов
# На диске файлы организованы так:
media/
├── tenants/
│ ├── tenant_1/
│ │ └── products/
│ │ └── temp/
│ │ └── image.jpg
│ └── tenant_2/
│ └── products/
│ └── temp/
│ └── image.jpg
Миграция (если были старые фото)
Для проекта указано - начинаем с чистого листа, без миграции старых фото.
Если в будущем понадобится мигрировать старые данные:
- Напишите management команду для перемещения файлов
- Обновите пути в БД
- Используйте storage для добавления tenant_id в пути
Контрольный список
- ✅ Custom storage backend создан
- ✅ Settings обновлены
- ✅ Модели фотографий обновлены
- ✅ Комментарии добавлены во все утилиты
- ✅ Тесты написаны и проходят
- ✅ Безопасность валидирована
- ✅ Документация готова
Следующие шаги
Когда проект вырастет:
- S3 миграция - замените storage backend на S3
- CDN - настройте CloudFront для ускорения доставки
- Бэкапы - настройте S3 versioning и lifecycle policies
- Мониторинг - добавьте метрики для отслеживания использования storage
Дата: 2025-11-23 Статус: ✅ Готово к продакшену