Добавлена защита от удаления дефолтных Склада и Витрины

Проблема: при создании тенанта автоматически создаются дефолтные
Склад и Витрина. Если пользователь удалит их, система может сломаться:
POS, создание заказов и резервирование перестанут работать.

Решение: реализована строгая валидация + мягкое удаление для витрин.

Изменения в inventory/views/warehouse.py:
- Добавлена валидация перед деактивацией склада:
  * Блокировка деактивации последнего активного склада
  * Проверка ненулевых остатков товаров
  * Проверка активных резервов
  * Предупреждение при деактивации дефолтного склада

Изменения в inventory/views/showcase.py:
- ShowcaseListView: по умолчанию показывает только активные витрины
- ShowcaseDeleteView: изменена логика с жесткого на мягкое удаление
- Добавлена валидация перед деактивацией витрины:
  * Блокировка деактивации последней активной витрины склада
  * Проверка активных резервов
  * Проверка физических экземпляров комплектов (ShowcaseItem)
  * Предупреждение при деактивации дефолтной витрины

Изменения в inventory/forms_showcase.py:
- Проверка уникальности названия витрины учитывает только активные

Изменения в inventory/admin.py:
- ShowcaseAdmin: добавлены методы delete_model() и delete_queryset()
  для блокировки удаления последней витрины через админку
- WarehouseAdmin: добавлены методы delete_model() и delete_queryset()
  для блокировки удаления последнего склада через админку

Преимущества:
 Система не сломается - всегда есть хотя бы один активный склад/витрина
 Данные в безопасности - мягкое удаление для обеих сущностей
 Понятные сообщения об ошибках для пользователя
 Защита работает как в UI, так и в Django Admin

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-03 19:52:01 +03:00
parent e6fb30aa02
commit 0f09702094
4 changed files with 244 additions and 27 deletions

View File

@@ -27,8 +27,10 @@ class ShowcaseListView(ListView):
"""
Получаем витрины с аннотацией количества активных резервов.
Сортируем по складу и названию.
По умолчанию показываем только активные витрины.
"""
queryset = Showcase.objects.select_related('warehouse').annotate(
# По умолчанию показываем только активные витрины
queryset = Showcase.objects.filter(is_active=True).select_related('warehouse').annotate(
active_reservations_count=Count(
'reservations',
filter=Q(reservations__status='reserved')
@@ -40,12 +42,24 @@ class ShowcaseListView(ListView):
if warehouse_id:
queryset = queryset.filter(warehouse_id=warehouse_id)
# Фильтрация по статусу активности
# Фильтрация по статусу активности (переопределяет дефолтный фильтр)
is_active = self.request.GET.get('is_active')
if is_active == '1':
queryset = queryset.filter(is_active=True)
elif is_active == '0':
queryset = queryset.filter(is_active=False)
if is_active == '0':
# Показать только неактивные
queryset = Showcase.objects.filter(is_active=False).select_related('warehouse').annotate(
active_reservations_count=Count(
'reservations',
filter=Q(reservations__status='reserved')
)
).order_by('warehouse__name', 'name')
elif is_active == 'all':
# Показать все (и активные, и неактивные)
queryset = Showcase.objects.select_related('warehouse').annotate(
active_reservations_count=Count(
'reservations',
filter=Q(reservations__status='reserved')
)
).order_by('warehouse__name', 'name')
return queryset
@@ -124,8 +138,8 @@ class ShowcaseUpdateView(UpdateView):
@method_decorator(login_required, name='dispatch')
class ShowcaseDeleteView(DeleteView):
"""
Удаление витрины с подтверждением.
Проверяет наличие активных резервов перед удалением.
Деактивация витрины (мягкое удаление) с подтверждением.
Проверяет наличие активных резервов и физических экземпляров перед деактивацией.
"""
model = Showcase
template_name = 'inventory/showcase/delete.html'
@@ -133,7 +147,7 @@ class ShowcaseDeleteView(DeleteView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['title'] = f'Удаление витрины: {self.object.name}'
context['title'] = f'Деактивация витрины: {self.object.name}'
# Проверяем наличие активных резервов
active_reservations = Reservation.objects.filter(
@@ -144,37 +158,85 @@ class ShowcaseDeleteView(DeleteView):
context['active_reservations'] = active_reservations
context['has_active_reservations'] = active_reservations.exists()
# Проверяем наличие физических экземпляров
from inventory.models import ShowcaseItem
showcase_items = ShowcaseItem.objects.filter(showcase=self.object)
context['showcase_items'] = showcase_items
context['has_showcase_items'] = showcase_items.exists()
return context
def delete(self, request, *args, **kwargs):
"""Проверяем наличие активных резервов перед удалением"""
showcase = self.get_object()
def post(self, request, *args, **kwargs):
"""
Переопределяем POST метод для мягкого удаления витрины.
Включает строгую валидацию для защиты от случайной деактивации.
"""
self.object = self.get_object()
showcase = self.object
showcase_name = showcase.name
# Проверка активных резервов
# 1. Проверка: это последняя активная витрина склада?
active_showcases_count = Showcase.objects.filter(
warehouse=showcase.warehouse,
is_active=True
).count()
if active_showcases_count <= 1:
messages.error(
request,
f'Невозможно деактивировать последнюю активную витрину склада "{showcase.warehouse.name}". '
'Система требует наличия хотя бы одной активной витрины на каждом складе. '
'Создайте новую витрину перед деактивацией этой.'
)
return redirect('inventory:showcase-delete', pk=showcase.pk)
# 2. Проверка: есть ли активные резервы?
active_reservations_count = Reservation.objects.filter(
showcase=showcase,
status='reserved'
).count()
if active_reservations_count > 0:
messages.error(
request,
f'Невозможно удалить витрину "{showcase.name}": '
f'Невозможно деактивировать витрину "{showcase_name}": '
f'на ней есть {active_reservations_count} активных резервов. '
'Сначала освободите или продайте зарезервированные товары.'
)
return redirect('inventory:showcase-delete', pk=showcase.pk)
# Удаляем витрину
showcase_name = showcase.name
response = super().delete(request, *args, **kwargs)
# 3. Проверка: есть ли физические экземпляры комплектов?
from inventory.models import ShowcaseItem
showcase_items_count = ShowcaseItem.objects.filter(
showcase=showcase,
status__in=['available', 'in_cart']
).count()
if showcase_items_count > 0:
messages.error(
request,
f'Невозможно деактивировать витрину "{showcase_name}": '
f'на ней есть {showcase_items_count} физических экземпляров комплектов. '
'Продайте или переместите все комплекты перед деактивацией витрины.'
)
return redirect('inventory:showcase-delete', pk=showcase.pk)
# 4. Предупреждение: если это дефолтная витрина
if showcase.is_default:
messages.warning(
request,
f'Внимание: вы деактивируете витрину по умолчанию для склада "{showcase.warehouse.name}". '
'Рекомендуется сначала назначить другую витрину по умолчанию.'
)
# Мягкое удаление - деактивируем витрину
showcase.is_active = False
showcase.save()
messages.success(
request,
f'Витрина "{showcase_name}" успешно удалена'
f'Витрина "{showcase_name}" успешно деактивирована и скрыта из списка. '
'Все связанные данные сохранены.'
)
return response
return HttpResponseRedirect(self.get_success_url())
@method_decorator(login_required, name='dispatch')