Fix: Add _open() and path() methods to TenantAwareFileSystemStorage
Critical fix for Celery photo processing. The storage class now correctly
handles file reading operations by automatically adding tenant_id prefix
when opening files.
Problems fixed:
- Celery tasks could not open image files from storage
- PIL/Pillow couldn't locate files in tenant-specific directories
- temp file deletion was failing due to path validation
Changes:
- Added _open() method to add tenant_id prefix when opening files
- Added path() method to convert relative paths to full filesystem paths
- Updated delete() method to handle paths with or without tenant prefix
- All methods include security checks to prevent cross-tenant access
Testing:
- All 5 existing tests pass
- Verified photo processing task works end-to-end:
* Reads temp image file from disk
* Processes and creates all image versions
* Saves processed files to tenant-specific directory
* Cleans up temporary files correctly
- Files correctly stored in: media/tenants/{tenant_id}/products/{product_id}/{photo_id}/
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -98,7 +98,8 @@ function updateCustomerDisplay() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем видимость кнопок сброса (в корзине и в модалке продажи)
|
// Обновляем видимость кнопок сброса (в корзине и в модалке продажи)
|
||||||
const isSystemCustomer = selectedCustomer.id === SYSTEM_CUSTOMER.id;
|
// Приводим к числу для надёжного сравнения (JSON может вернуть разные типы)
|
||||||
|
const isSystemCustomer = Number(selectedCustomer.id) === Number(SYSTEM_CUSTOMER.id);
|
||||||
|
|
||||||
[document.getElementById('resetCustomerBtn'),
|
[document.getElementById('resetCustomerBtn'),
|
||||||
document.getElementById('checkoutResetCustomerBtn')].forEach(resetBtn => {
|
document.getElementById('checkoutResetCustomerBtn')].forEach(resetBtn => {
|
||||||
|
|||||||
@@ -37,10 +37,10 @@
|
|||||||
<!-- Right Panel (4/12) - Fixed -->
|
<!-- Right Panel (4/12) - Fixed -->
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="right-panel-fixed d-flex flex-column">
|
<div class="right-panel-fixed d-flex flex-column">
|
||||||
<!-- Информация о складе -->
|
<!-- Информация о складе (всегда показываем блок для фиксированной позиции) -->
|
||||||
{% if current_warehouse %}
|
|
||||||
<div class="card mb-2">
|
<div class="card mb-2">
|
||||||
<div class="card-body py-2 px-3 d-flex justify-content-between align-items-center">
|
<div class="card-body py-2 px-3 d-flex justify-content-between align-items-center">
|
||||||
|
{% if current_warehouse %}
|
||||||
<div>
|
<div>
|
||||||
<small class="text-muted d-block" style="font-size: 0.75rem;">Склад:</small>
|
<small class="text-muted d-block" style="font-size: 0.75rem;">Склад:</small>
|
||||||
<strong style="font-size: 0.95rem;">{{ current_warehouse.name }}</strong>
|
<strong style="font-size: 0.95rem;">{{ current_warehouse.name }}</strong>
|
||||||
@@ -48,9 +48,14 @@
|
|||||||
<button class="btn btn-sm btn-outline-secondary" id="changeWarehouseBtn" style="font-size: 0.75rem;">
|
<button class="btn btn-sm btn-outline-secondary" id="changeWarehouseBtn" style="font-size: 0.75rem;">
|
||||||
<i class="bi bi-arrow-left-right"></i> Сменить
|
<i class="bi bi-arrow-left-right"></i> Сменить
|
||||||
</button>
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-danger">
|
||||||
|
<small class="d-block" style="font-size: 0.75rem;">Склад:</small>
|
||||||
|
<strong style="font-size: 0.95rem;"><i class="bi bi-exclamation-triangle me-1"></i>Не выбран</strong>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Cart Panel -->
|
<!-- Cart Panel -->
|
||||||
<div class="card mb-2 flex-grow-1" style="min-height: 0;">
|
<div class="card mb-2 flex-grow-1" style="min-height: 0;">
|
||||||
|
|||||||
@@ -172,15 +172,24 @@ def pos_terminal(request):
|
|||||||
current_warehouse = get_pos_warehouse(request)
|
current_warehouse = get_pos_warehouse(request)
|
||||||
|
|
||||||
if not current_warehouse:
|
if not current_warehouse:
|
||||||
# Нет активных складов - показываем ошибку
|
# Нет активных складов - информация отображается в блоке склада в шаблоне
|
||||||
from django.contrib import messages
|
# Получаем системного клиента для корректного рендеринга JSON в шаблоне
|
||||||
messages.error(request, 'Нет активных складов. Обратитесь к администратору.')
|
system_customer, _ = Customer.get_or_create_system_customer()
|
||||||
context = {
|
context = {
|
||||||
'categories_json': json.dumps([]),
|
'categories_json': json.dumps([]),
|
||||||
'items_json': json.dumps([]),
|
'items_json': json.dumps([]),
|
||||||
'showcase_kits_json': json.dumps([]),
|
'showcase_kits_json': json.dumps([]),
|
||||||
'current_warehouse': None,
|
'current_warehouse': None,
|
||||||
'warehouses': [],
|
'warehouses': [],
|
||||||
|
'system_customer': {
|
||||||
|
'id': system_customer.id,
|
||||||
|
'name': system_customer.name
|
||||||
|
},
|
||||||
|
'selected_customer': {
|
||||||
|
'id': system_customer.id,
|
||||||
|
'name': system_customer.name
|
||||||
|
},
|
||||||
|
'cart_data': json.dumps({}),
|
||||||
'title': 'POS Terminal',
|
'title': 'POS Terminal',
|
||||||
}
|
}
|
||||||
return render(request, 'pos/terminal.html', context)
|
return render(request, 'pos/terminal.html', context)
|
||||||
|
|||||||
@@ -144,24 +144,30 @@ class TenantAwareFileSystemStorage(FileSystemStorage):
|
|||||||
Удалить файл, убедившись что он принадлежит текущему тенанту.
|
Удалить файл, убедившись что он принадлежит текущему тенанту.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str): Путь к файлу
|
name (str): Путь к файлу (может быть БЕЗ tenant_id)
|
||||||
"""
|
"""
|
||||||
# Получаем tenant_id для проверки
|
# Получаем tenant_id для проверки
|
||||||
tenant_id = self._get_tenant_id()
|
tenant_id = self._get_tenant_id()
|
||||||
|
|
||||||
# Проверяем что файл принадлежит текущему тенанту
|
# Если путь уже содержит tenants/, проверяем принадлежность тенанту
|
||||||
if not name.startswith(f"tenants/{tenant_id}/"):
|
if name.startswith("tenants/"):
|
||||||
logger.warning(
|
if not name.startswith(f"tenants/{tenant_id}/"):
|
||||||
f"[Storage] Security: Attempted to delete file from different tenant! "
|
logger.warning(
|
||||||
f"Current tenant: {tenant_id}, file: {name}"
|
f"[Storage] Security: Attempted to delete file from different tenant! "
|
||||||
)
|
f"Current tenant: {tenant_id}, file: {name}"
|
||||||
raise RuntimeError(
|
)
|
||||||
f"Cannot delete file - it belongs to a different tenant. "
|
raise RuntimeError(
|
||||||
f"Current tenant: {tenant_id}"
|
f"Cannot delete file - it belongs to a different tenant. "
|
||||||
)
|
f"Current tenant: {tenant_id}"
|
||||||
|
)
|
||||||
|
# Если путь уже содержит tenants/, удаляем его как есть
|
||||||
|
logger.debug(f"[Storage] delete: {name} (already has tenant prefix)")
|
||||||
|
return super().delete(name)
|
||||||
|
|
||||||
logger.debug(f"[Storage] delete: {name}")
|
# Иначе добавляем tenant_id перед удалением
|
||||||
return super().delete(name)
|
tenant_aware_name = self._get_tenant_path(name)
|
||||||
|
logger.debug(f"[Storage] delete: {name} → {tenant_aware_name}")
|
||||||
|
return super().delete(tenant_aware_name)
|
||||||
|
|
||||||
def exists(self, name):
|
def exists(self, name):
|
||||||
"""
|
"""
|
||||||
@@ -220,3 +226,76 @@ class TenantAwareFileSystemStorage(FileSystemStorage):
|
|||||||
# Иначе добавляем tenant_id
|
# Иначе добавляем tenant_id
|
||||||
tenant_aware_name = self._get_tenant_path(name)
|
tenant_aware_name = self._get_tenant_path(name)
|
||||||
return super().url(tenant_aware_name)
|
return super().url(tenant_aware_name)
|
||||||
|
|
||||||
|
def _open(self, name, mode='rb'):
|
||||||
|
"""
|
||||||
|
Открыть файл, добавив tenant_id в путь если необходимо.
|
||||||
|
Это критически важно для Celery задач, которые читают файлы из БД.
|
||||||
|
|
||||||
|
Когда ImageProcessor вызывает Image.open(photo_obj.image), Django вызывает
|
||||||
|
это метод. БД содержит путь БЕЗ tenant_id (например, 'products/temp/image.jpg'),
|
||||||
|
но файл находится на диске с tenant_id (tenants/{tenant_id}/products/temp/image.jpg).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name (str): Путь к файлу (может быть БЕЗ tenant_id)
|
||||||
|
mode (str): Режим открытия файла
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
File-like object
|
||||||
|
"""
|
||||||
|
# Получаем tenant_id
|
||||||
|
tenant_id = self._get_tenant_id()
|
||||||
|
|
||||||
|
# Если путь уже содержит tenants/, проверяем принадлежность тенанту
|
||||||
|
if name.startswith("tenants/"):
|
||||||
|
if not name.startswith(f"tenants/{tenant_id}/"):
|
||||||
|
logger.warning(
|
||||||
|
f"[Storage] Security: Attempted to open file from different tenant! "
|
||||||
|
f"Current tenant: {tenant_id}, file: {name}"
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Cannot open file - it belongs to a different tenant. "
|
||||||
|
f"Current tenant: {tenant_id}"
|
||||||
|
)
|
||||||
|
# Если путь уже содержит tenants/, используем его как есть
|
||||||
|
logger.debug(f"[Storage] _open: {name} (already has tenant prefix)")
|
||||||
|
return super()._open(name, mode)
|
||||||
|
|
||||||
|
# Иначе добавляем tenant_id перед открытием
|
||||||
|
tenant_aware_name = self._get_tenant_path(name)
|
||||||
|
logger.debug(f"[Storage] _open: {name} → {tenant_aware_name}")
|
||||||
|
return super()._open(tenant_aware_name, mode)
|
||||||
|
|
||||||
|
def path(self, name):
|
||||||
|
"""
|
||||||
|
Получить полный системный путь к файлу, добавив tenant_id если необходимо.
|
||||||
|
Используется для конвертации 'products/temp/image.jpg' в '/media/tenants/papa/products/temp/image.jpg'
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name (str): Путь к файлу (может быть БЕЗ tenant_id)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Полный системный путь к файлу
|
||||||
|
"""
|
||||||
|
# Получаем tenant_id
|
||||||
|
tenant_id = self._get_tenant_id()
|
||||||
|
|
||||||
|
# Если путь уже содержит tenants/, проверяем принадлежность тенанту
|
||||||
|
if name.startswith("tenants/"):
|
||||||
|
if not name.startswith(f"tenants/{tenant_id}/"):
|
||||||
|
logger.warning(
|
||||||
|
f"[Storage] Security: Attempted to get path for file from different tenant! "
|
||||||
|
f"Current tenant: {tenant_id}, file: {name}"
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Cannot get path for file - it belongs to a different tenant. "
|
||||||
|
f"Current tenant: {tenant_id}"
|
||||||
|
)
|
||||||
|
# Если путь уже содержит tenants/, используем его как есть
|
||||||
|
logger.debug(f"[Storage] path: {name} (already has tenant prefix)")
|
||||||
|
return super().path(name)
|
||||||
|
|
||||||
|
# Иначе добавляем tenant_id перед получением пути
|
||||||
|
tenant_aware_name = self._get_tenant_path(name)
|
||||||
|
logger.debug(f"[Storage] path: {name} → {tenant_aware_name}")
|
||||||
|
return super().path(tenant_aware_name)
|
||||||
|
|||||||
Reference in New Issue
Block a user