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

Проблема: при создании тенанта автоматически создаются дефолтные
Склад и Витрина. Если пользователь удалит их, система может сломаться:
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

@@ -37,6 +37,63 @@ class ShowcaseAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
) )
readonly_fields = ('created_at', 'updated_at') readonly_fields = ('created_at', 'updated_at')
def delete_model(self, request, obj):
"""
Защита от удаления последней активной витрины склада через админку.
Рекомендуется использовать деактивацию (is_active=False) вместо удаления.
"""
from django.contrib import messages
# Проверка: это последняя активная витрина склада?
active_showcases_count = Showcase.objects.filter(
warehouse=obj.warehouse,
is_active=True
).count()
if active_showcases_count <= 1 and obj.is_active:
messages.error(
request,
f'Невозможно удалить последнюю активную витрину склада "{obj.warehouse.name}" через админку. '
'Используйте деактивацию (is_active=False) вместо удаления.'
)
return
super().delete_model(request, obj)
def delete_queryset(self, request, queryset):
"""
Защита от массового удаления всех витрин склада через админку.
"""
from django.contrib import messages
# Группируем витрины по складам
warehouses = {}
for showcase in queryset:
if showcase.warehouse_id not in warehouses:
warehouses[showcase.warehouse_id] = {
'warehouse': showcase.warehouse,
'deleting_active': 0,
'total_active': Showcase.objects.filter(
warehouse=showcase.warehouse,
is_active=True
).count()
}
if showcase.is_active:
warehouses[showcase.warehouse_id]['deleting_active'] += 1
# Проверяем, не удаляем ли мы все витрины какого-либо склада
for wh_data in warehouses.values():
remaining = wh_data['total_active'] - wh_data['deleting_active']
if remaining < 1:
messages.error(
request,
f'Невозможно удалить все витрины склада "{wh_data["warehouse"].name}". '
'Хотя бы одна активная витрина должна остаться на каждом складе.'
)
return
super().delete_queryset(request, queryset)
# ===== WAREHOUSE ===== # ===== WAREHOUSE =====
@admin.register(Warehouse) @admin.register(Warehouse)
@@ -61,6 +118,50 @@ class WarehouseAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
return '-' return '-'
is_default_display.short_description = 'По умолчанию' is_default_display.short_description = 'По умолчанию'
def delete_model(self, request, obj):
"""
Защита от удаления последнего активного склада через админку.
Рекомендуется использовать деактивацию (is_active=False) вместо удаления.
"""
from django.contrib import messages
# Проверка: это последний активный склад?
active_warehouses_count = Warehouse.objects.filter(is_active=True).count()
if active_warehouses_count <= 1 and obj.is_active:
messages.error(
request,
'Невозможно удалить последний активный склад через админку. '
'Система требует наличия хотя бы одного активного склада для работы. '
'Используйте деактивацию (is_active=False) вместо удаления.'
)
return
super().delete_model(request, obj)
def delete_queryset(self, request, queryset):
"""
Защита от массового удаления всех складов через админку.
"""
from django.contrib import messages
# Подсчитываем текущее количество активных складов
active_count = Warehouse.objects.filter(is_active=True).count()
# Подсчитываем, сколько активных складов хотим удалить
deleting_active_count = queryset.filter(is_active=True).count()
# Проверяем, останется ли хотя бы один активный склад
if active_count - deleting_active_count < 1:
messages.error(
request,
'Невозможно удалить все активные склады. '
'Система требует наличия хотя бы одного активного склада для работы. '
'Используйте деактивацию (is_active=False) вместо удаления.'
)
return
super().delete_queryset(request, queryset)
# ===== STOCK BATCH ===== # ===== STOCK BATCH =====
@admin.register(StockBatch) @admin.register(StockBatch)

View File

@@ -55,13 +55,13 @@ class ShowcaseForm(forms.ModelForm):
self.initial['warehouse'] = default_warehouse.id self.initial['warehouse'] = default_warehouse.id
def clean_name(self): def clean_name(self):
"""Проверка уникальности названия витрины в рамках склада""" """Проверка уникальности названия витрины в рамках склада (среди активных)"""
name = self.cleaned_data.get('name') name = self.cleaned_data.get('name')
warehouse = self.cleaned_data.get('warehouse') warehouse = self.cleaned_data.get('warehouse')
if name and warehouse: if name and warehouse:
# Проверяем уникальность названия в рамках склада # Проверяем уникальность названия в рамках склада (только среди активных)
queryset = Showcase.objects.filter(name=name, warehouse=warehouse) queryset = Showcase.objects.filter(name=name, warehouse=warehouse, is_active=True)
# При редактировании исключаем текущий экземпляр # При редактировании исключаем текущий экземпляр
if self.instance and self.instance.pk: if self.instance and self.instance.pk:
@@ -69,7 +69,7 @@ class ShowcaseForm(forms.ModelForm):
if queryset.exists(): if queryset.exists():
raise ValidationError( raise ValidationError(
f'Витрина с названием "{name}" уже существует на складе "{warehouse.name}". ' f'Активная витрина с названием "{name}" уже существует на складе "{warehouse.name}". '
'Пожалуйста, выберите другое название.' 'Пожалуйста, выберите другое название.'
) )

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

View File

@@ -66,16 +66,70 @@ class WarehouseDeleteView(LoginRequiredMixin, DeleteView):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
""" """
Переопределяем POST метод чтобы использовать мягкое удаление Переопределяем POST метод чтобы использовать мягкое удаление
вместо стандартного физического удаления Django вместо стандартного физического удаления Django.
Включает строгую валидацию для защиты от случайного удаления.
""" """
self.object = self.get_object() self.object = self.get_object()
warehouse_name = self.object.name warehouse_name = self.object.name
# Мягкое удаление - просто деактивируем склад # 1. Проверка: это последний активный склад?
active_warehouses_count = Warehouse.objects.filter(is_active=True).count()
if active_warehouses_count <= 1:
messages.error(
request,
'Невозможно деактивировать последний активный склад. '
'Система требует наличия хотя бы одного активного склада для работы. '
'Создайте новый склад перед деактивацией этого.'
)
return HttpResponseRedirect(reverse_lazy('inventory:warehouse-list'))
# 2. Проверка: есть ли ненулевые остатки товаров?
from inventory.models import Stock
has_stock = Stock.objects.filter(
warehouse=self.object,
available__gt=0
).exists()
if has_stock:
messages.error(
request,
f'Невозможно деактивировать склад "{warehouse_name}": '
'на складе есть товары с ненулевыми остатками. '
'Переместите все товары на другой склад перед деактивацией.'
)
return HttpResponseRedirect(reverse_lazy('inventory:warehouse-list'))
# 3. Проверка: есть ли активные резервы?
from inventory.models import Reservation
active_reservations_count = Reservation.objects.filter(
warehouse=self.object,
status='reserved'
).count()
if active_reservations_count > 0:
messages.error(
request,
f'Невозможно деактивировать склад "{warehouse_name}": '
f'на складе есть {active_reservations_count} активных резервирований. '
'Завершите все заказы с резервами перед деактивацией склада.'
)
return HttpResponseRedirect(reverse_lazy('inventory:warehouse-list'))
# 4. Предупреждение: если это дефолтный склад
if self.object.is_default:
messages.warning(
request,
f'Внимание: вы деактивируете склад по умолчанию "{warehouse_name}". '
'Рекомендуется сначала назначить другой склад по умолчанию.'
)
# Мягкое удаление - деактивируем склад
self.object.is_active = False self.object.is_active = False
self.object.save() self.object.save()
messages.success(request, f'Склад "{warehouse_name}" архивирован и скрыт из списка.') messages.success(
request,
f'Склад "{warehouse_name}" успешно архивирован и скрыт из списка. '
'Все связанные данные сохранены.'
)
return HttpResponseRedirect(self.get_success_url()) return HttpResponseRedirect(self.get_success_url())