Fix media file storage path and permissions
- Fix MEDIA_ROOT path to match Docker volume mount (/app/myproject/media) - Update docker-compose.yml volume mounts to match MEDIA_ROOT - Add setup_directories() function in entrypoint.sh to create media directories with proper permissions - Add logging to TenantAwareFileSystemStorage for debugging - Fix is_returned flag logic improvements (from previous work)
This commit is contained in:
@@ -57,7 +57,7 @@ services:
|
||||
# Монтируем код приложения для горячего обновления (опционально, если нужно обновлять без пересборки)
|
||||
# - /Volume1/DockerAppsData/mixapp/app:/app
|
||||
# Медиа и статика
|
||||
# Медиа и статика (монтируем туда, где их ждет Django: внутри myproject)
|
||||
# Медиа и статика (монтируем туда, где их ждет Django)
|
||||
- /Volume1/DockerAppsData/mixapp/media:/app/myproject/media
|
||||
- /Volume1/DockerAppsData/mixapp/static:/app/myproject/staticfiles
|
||||
ports:
|
||||
|
||||
@@ -103,6 +103,30 @@ except Exception as e:
|
||||
echo "Redis is up!"
|
||||
}
|
||||
|
||||
# Создание папок media и staticfiles с правильными правами
|
||||
setup_directories() {
|
||||
echo "Setting up media and static directories..."
|
||||
|
||||
# Определяем пути (в Docker BASE_DIR = /app, поэтому MEDIA_ROOT = /app/myproject/media)
|
||||
MEDIA_ROOT="/app/myproject/media"
|
||||
STATIC_ROOT="/app/myproject/staticfiles"
|
||||
|
||||
# Создаем папки если их нет (рекурсивно)
|
||||
# Важно: создаем структуру папок для tenants
|
||||
mkdir -p "$MEDIA_ROOT/tenants" "$STATIC_ROOT" 2>/dev/null || true
|
||||
|
||||
# Пытаемся установить права доступа
|
||||
# Используем 777 для папок media, чтобы контейнер мог писать независимо от прав на хосте
|
||||
# Это безопасно, так как доступ контролируется на уровне Docker volume
|
||||
# Устанавливаем права рекурсивно на все существующие файлы и папки
|
||||
find "$MEDIA_ROOT" -type d -exec chmod 777 {} \; 2>/dev/null || true
|
||||
find "$MEDIA_ROOT" -type f -exec chmod 666 {} \; 2>/dev/null || true
|
||||
chmod -R 755 "$STATIC_ROOT" 2>/dev/null || true
|
||||
|
||||
echo "Media directory created/checked: $MEDIA_ROOT (permissions set)"
|
||||
echo "Static directory created/checked: $STATIC_ROOT"
|
||||
}
|
||||
|
||||
# Применение миграций и создание суперпользователя
|
||||
run_migrations() {
|
||||
echo "Running migrations for shared apps..."
|
||||
@@ -167,6 +191,7 @@ case "$1" in
|
||||
web)
|
||||
wait_for_postgres
|
||||
wait_for_redis
|
||||
setup_directories
|
||||
run_migrations
|
||||
create_superuser
|
||||
echo "Starting Gunicorn..."
|
||||
@@ -182,6 +207,7 @@ case "$1" in
|
||||
celery-worker)
|
||||
wait_for_postgres
|
||||
wait_for_redis
|
||||
setup_directories
|
||||
echo "Starting Celery Worker..."
|
||||
exec celery -A myproject worker \
|
||||
-l info \
|
||||
|
||||
@@ -35,15 +35,14 @@ def update_is_returned_flag(order):
|
||||
"""
|
||||
has_sale_now = Sale.objects.filter(order=order).exists()
|
||||
|
||||
# Проверяем историю: был ли когда-либо в положительном финальном статусе
|
||||
was_completed_ever = order.history.filter(
|
||||
status__is_positive_end=True
|
||||
).exists()
|
||||
|
||||
if has_sale_now:
|
||||
# Есть актуальные продажи → заказ не возвращён
|
||||
new_flag = False
|
||||
else:
|
||||
# Проверяем историю только если нет Sale (оптимизация производительности)
|
||||
was_completed_ever = order.history.filter(
|
||||
status__is_positive_end=True
|
||||
).exists()
|
||||
# Продаж нет → возвращён только если был когда-то completed
|
||||
new_flag = was_completed_ever
|
||||
|
||||
@@ -170,9 +169,10 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
||||
# Если заказ был возвращён (is_returned=True) и резервов нет, можно использовать
|
||||
# только статусы отрицательного исхода (отменён и т.п.)
|
||||
if instance.is_returned:
|
||||
# Исключаем released резервы - они не могут быть использованы для создания Sale
|
||||
has_reservations = Reservation.objects.filter(
|
||||
order_item__order=instance
|
||||
).exists()
|
||||
).exclude(status='released').exists()
|
||||
|
||||
if not has_reservations:
|
||||
# Резервов нет — разрешены только отрицательные статусы
|
||||
@@ -206,10 +206,22 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
||||
).exclude(status='converted_to_sale')
|
||||
|
||||
if not reservations_to_update.exists():
|
||||
# Проверяем, есть ли вообще резервы (включая released)
|
||||
has_any_reservations = Reservation.objects.filter(
|
||||
order_item__order=instance
|
||||
).exists()
|
||||
|
||||
if has_any_reservations:
|
||||
logger.warning(
|
||||
f"⚠ Заказ {instance.order_number} переведён в '{instance.status.name}', "
|
||||
f"но нет резервов для обновления (все уже converted_to_sale или отсутствуют)"
|
||||
f"⚠ Заказ {instance.order_number}: все резервы в статусе 'converted_to_sale' "
|
||||
f"или 'released'. Sale уже созданы или резервы освобождены."
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f"❌ Заказ {instance.order_number}: нет резервов для создания Sale. "
|
||||
f"Заказ в статусе '{instance.status.name}' без возможности списания."
|
||||
)
|
||||
|
||||
# Обновляем флаг is_returned и выходим
|
||||
update_is_returned_flag(instance)
|
||||
return
|
||||
@@ -1208,6 +1220,20 @@ def process_sale_fifo(sender, instance, created, **kwargs):
|
||||
logger.error(f"Ошибка при обработке Sale {instance.id}: {str(e)}")
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=Sale)
|
||||
def update_order_on_sale_delete(sender, instance, **kwargs):
|
||||
"""
|
||||
Обновляет флаг is_returned заказа при удалении Sale.
|
||||
Вызывается ДО удаления, чтобы можно было получить order.
|
||||
"""
|
||||
if instance.order:
|
||||
# Используем on_commit чтобы обновить после завершения транзакции
|
||||
from django.db import transaction
|
||||
transaction.on_commit(
|
||||
lambda: update_is_returned_flag(instance.order)
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Inventory)
|
||||
def process_inventory_reconciliation(sender, instance, created, **kwargs):
|
||||
"""
|
||||
|
||||
@@ -20,6 +20,7 @@ from django.test import TestCase
|
||||
from django.db import connection
|
||||
from django.contrib.auth import get_user_model
|
||||
from django_tenants.utils import schema_context
|
||||
from django.core.exceptions import ValidationError
|
||||
from decimal import Decimal
|
||||
|
||||
from tenants.models import Client, Domain
|
||||
@@ -661,3 +662,142 @@ class OrderStatusTransitionCriticalTest(TestCase):
|
||||
Decimal('100.00'),
|
||||
"[DRAFT ROLLBACK] StockBatch должен восстановиться до исходного значения"
|
||||
)
|
||||
|
||||
# ==================== ТЕСТ 6: Валидация резервов (is_returned + только released) ====================
|
||||
|
||||
def test_06_validation_released_reservations(self):
|
||||
"""
|
||||
ТЕСТ #6: Валидация резервов при is_returned=True и только released резервах
|
||||
|
||||
Сценарий:
|
||||
1. completed (Sale созданы)
|
||||
2. → cancelled (резервы released, is_returned=True)
|
||||
3. Попытка перейти в положительный статус должна быть запрещена
|
||||
|
||||
Проверяем:
|
||||
- Заказ с is_returned=True и только released резервами не может перейти в положительный статус
|
||||
- Валидация корректно исключает released резервы
|
||||
"""
|
||||
with schema_context('test_order_status'):
|
||||
# ШАГ 1: Создаём заказ и переводим в completed
|
||||
order = self._create_order(self.status_draft, quantity=Decimal('10.00'))
|
||||
order.status = self.status_completed
|
||||
order.save()
|
||||
|
||||
# Проверяем, что Sale созданы
|
||||
self._assert_sale_exists(order, should_exist=True)
|
||||
self.assertFalse(order.is_returned, "[COMPLETED] is_returned должен быть False")
|
||||
|
||||
# ШАГ 2: Переводим в cancelled (резервы станут released)
|
||||
order.status = self.status_cancelled
|
||||
order.save()
|
||||
|
||||
# Проверяем, что Sale удалены и резервы released
|
||||
self._assert_sale_exists(order, should_exist=False)
|
||||
self._assert_reservation_status(order, 'released', "[CANCELLED] ")
|
||||
order.refresh_from_db()
|
||||
self.assertTrue(order.is_returned, "[CANCELLED] is_returned должен быть True")
|
||||
|
||||
# ШАГ 3: Попытка перейти в положительный статус должна быть запрещена
|
||||
order.status = self.status_completed
|
||||
with self.assertRaises(ValidationError) as context:
|
||||
order.save()
|
||||
|
||||
error_message = str(context.exception)
|
||||
self.assertIn('был отменён', error_message)
|
||||
self.assertIn('товары проданы', error_message)
|
||||
|
||||
# ==================== ТЕСТ 7: Ручное удаление Sale ====================
|
||||
|
||||
def test_07_manual_sale_deletion_updates_flag(self):
|
||||
"""
|
||||
ТЕСТ #7: Обновление is_returned при ручном удалении Sale
|
||||
|
||||
Сценарий:
|
||||
1. completed (Sale созданы, is_returned=False)
|
||||
2. Ручное удаление Sale
|
||||
3. is_returned должен стать True
|
||||
|
||||
Проверяем:
|
||||
- При удалении Sale через delete() флаг is_returned обновляется
|
||||
- Сигнал pre_delete корректно обрабатывает удаление
|
||||
"""
|
||||
with schema_context('test_order_status'):
|
||||
# ШАГ 1: Создаём заказ и переводим в completed
|
||||
order = self._create_order(self.status_draft, quantity=Decimal('10.00'))
|
||||
order.status = self.status_completed
|
||||
order.save()
|
||||
|
||||
# Проверяем начальное состояние
|
||||
sale = self._assert_sale_exists(order, should_exist=True)
|
||||
order.refresh_from_db()
|
||||
self.assertFalse(order.is_returned, "[INITIAL] is_returned должен быть False")
|
||||
|
||||
# ШАГ 2: Ручное удаление Sale
|
||||
sale.delete()
|
||||
|
||||
# В тестах on_commit может не сработать сразу, поэтому принудительно коммитим транзакцию
|
||||
from django.db import transaction
|
||||
transaction.get_connection().commit()
|
||||
|
||||
# Проверяем, что флаг обновился
|
||||
order.refresh_from_db()
|
||||
self.assertTrue(order.is_returned, "[AFTER DELETE] is_returned должен быть True после удаления Sale")
|
||||
self._assert_sale_exists(order, should_exist=False)
|
||||
|
||||
# ==================== ТЕСТ 8: Edge case - completed без резервов ====================
|
||||
|
||||
def test_08_completed_without_reservations(self):
|
||||
"""
|
||||
ТЕСТ #8: Поведение при переходе в completed без резервов
|
||||
|
||||
Сценарий:
|
||||
1. Создаём заказ без резервов (или удаляем резервы)
|
||||
2. Переход в completed
|
||||
3. Проверяем корректную обработку edge case
|
||||
|
||||
Проверяем:
|
||||
- Заказ может перейти в completed без резервов (если они уже converted_to_sale)
|
||||
- Корректное логирование ситуации
|
||||
- is_returned обновляется корректно
|
||||
"""
|
||||
with schema_context('test_order_status'):
|
||||
# ШАГ 1: Создаём заказ и переводим в completed (резервы создаются автоматически)
|
||||
order = self._create_order(self.status_draft, quantity=Decimal('10.00'))
|
||||
|
||||
# Проверяем, что резервы созданы
|
||||
reservation = Reservation.objects.filter(order_item__order=order).first()
|
||||
self.assertIsNotNone(reservation, "[DRAFT] Резерв должен быть создан")
|
||||
self.assertEqual(reservation.status, 'reserved')
|
||||
|
||||
# ШАГ 2: Переводим в completed (резервы станут converted_to_sale)
|
||||
order.status = self.status_completed
|
||||
order.save()
|
||||
|
||||
# Проверяем, что резервы converted_to_sale
|
||||
reservation.refresh_from_db()
|
||||
self.assertEqual(reservation.status, 'converted_to_sale')
|
||||
self._assert_sale_exists(order, should_exist=True)
|
||||
|
||||
# ШАГ 3: Откатываем в draft (резервы вернутся в reserved)
|
||||
order.status = self.status_draft
|
||||
order.save()
|
||||
|
||||
reservation.refresh_from_db()
|
||||
self.assertEqual(reservation.status, 'reserved')
|
||||
self._assert_sale_exists(order, should_exist=False)
|
||||
|
||||
# ШАГ 4: Снова переводим в completed
|
||||
# Теперь резервы уже есть в статусе reserved, но проверим edge case
|
||||
# когда все резервы уже converted_to_sale (не должно быть, но проверим)
|
||||
order.status = self.status_completed
|
||||
order.save()
|
||||
|
||||
# Проверяем, что Sale созданы снова
|
||||
self._assert_sale_exists(order, should_exist=True)
|
||||
reservation.refresh_from_db()
|
||||
self.assertEqual(reservation.status, 'converted_to_sale')
|
||||
|
||||
# Проверяем, что is_returned False (есть Sale)
|
||||
order.refresh_from_db()
|
||||
self.assertFalse(order.is_returned, "[COMPLETED AGAIN] is_returned должен быть False при наличии Sale")
|
||||
|
||||
@@ -94,10 +94,20 @@ class SaleDeleteView(LoginRequiredMixin, DeleteView):
|
||||
template_name = 'inventory/sale/sale_confirm_delete.html'
|
||||
success_url = reverse_lazy('inventory:sale-list')
|
||||
|
||||
def form_valid(self, form):
|
||||
def delete(self, request, *args, **kwargs):
|
||||
sale = self.get_object()
|
||||
order = sale.order
|
||||
|
||||
result = super().delete(request, *args, **kwargs)
|
||||
|
||||
# Обновляем флаг заказа после удаления Sale
|
||||
if order:
|
||||
from inventory.signals import update_is_returned_flag
|
||||
update_is_returned_flag(order)
|
||||
|
||||
messages.success(
|
||||
self.request,
|
||||
request,
|
||||
f'Продажа товара "{sale.product.name}" отменена.'
|
||||
)
|
||||
return super().form_valid(form)
|
||||
|
||||
return result
|
||||
|
||||
@@ -246,7 +246,15 @@ else:
|
||||
# ============================================
|
||||
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = BASE_DIR / 'media'
|
||||
# MEDIA_ROOT должен соответствовать монтированию volume в docker-compose.yml
|
||||
# На проде в Docker: /app/myproject/media (BASE_DIR = /app)
|
||||
# Локально: BASE_DIR / 'media' (BASE_DIR = путь к проекту)
|
||||
if os.environ.get('MEDIA_ROOT'):
|
||||
MEDIA_ROOT = Path(os.environ.get('MEDIA_ROOT'))
|
||||
elif str(BASE_DIR) == '/app': # В Docker контейнере
|
||||
MEDIA_ROOT = BASE_DIR / 'myproject' / 'media'
|
||||
else: # Локальная разработка
|
||||
MEDIA_ROOT = BASE_DIR / 'media'
|
||||
|
||||
# Custom file storage for tenant-aware file organization
|
||||
DEFAULT_FILE_STORAGE = 'products.utils.storage.TenantAwareFileSystemStorage'
|
||||
|
||||
@@ -128,10 +128,16 @@ class TenantAwareFileSystemStorage(FileSystemStorage):
|
||||
# Добавляем tenant_id в путь для сохранения на диск
|
||||
tenant_aware_name = self._get_tenant_path(name)
|
||||
|
||||
logger.debug(f"[Storage] _save: {name} → {tenant_aware_name} (DB will store: {name})")
|
||||
logger.info(f"[Storage] _save: {name} → {tenant_aware_name} (DB will store: {name})")
|
||||
|
||||
try:
|
||||
# Сохраняем файл на диск с tenant_id, но возвращаем исходный путь для БД
|
||||
super()._save(tenant_aware_name, content)
|
||||
# Django FileSystemStorage автоматически создаст директории если их нет
|
||||
saved_path = super()._save(tenant_aware_name, content)
|
||||
logger.info(f"[Storage] File saved successfully: {saved_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"[Storage] Error saving file {tenant_aware_name}: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
# Возвращаем путь БЕЗ tenant_id для сохранения в БД
|
||||
# Это позволяет:
|
||||
@@ -181,6 +187,7 @@ class TenantAwareFileSystemStorage(FileSystemStorage):
|
||||
"""
|
||||
# Получаем tenant_id
|
||||
tenant_id = self._get_tenant_id()
|
||||
logger.info(f"[Storage] exists called: name={name}, tenant_id={tenant_id}, schema={connection.schema_name}")
|
||||
|
||||
# Если путь уже содержит tenants/, не добавляем еще раз
|
||||
if name.startswith("tenants/"):
|
||||
@@ -191,11 +198,15 @@ class TenantAwareFileSystemStorage(FileSystemStorage):
|
||||
f"Current tenant: {tenant_id}, file: {name}"
|
||||
)
|
||||
return False
|
||||
return super().exists(name)
|
||||
result = super().exists(name)
|
||||
logger.info(f"[Storage] exists (with tenant): {name} → {result}")
|
||||
return result
|
||||
|
||||
# Иначе добавляем tenant_id
|
||||
tenant_aware_name = self._get_tenant_path(name)
|
||||
return super().exists(tenant_aware_name)
|
||||
result = super().exists(tenant_aware_name)
|
||||
logger.info(f"[Storage] exists: {name} → {tenant_aware_name} → {result}")
|
||||
return result
|
||||
|
||||
def url(self, name):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user