Добавлена защита от удаления дефолтных Склада и Витрины
Проблема: при создании тенанта автоматически создаются дефолтные Склад и Витрина. Если пользователь удалит их, система может сломаться: 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:
@@ -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)
|
||||||
|
|||||||
@@ -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}". '
|
||||||
'Пожалуйста, выберите другое название.'
|
'Пожалуйста, выберите другое название.'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user