diff --git a/myproject/inventory/admin.py b/myproject/inventory/admin.py index 96a6fe6..03f916f 100644 --- a/myproject/inventory/admin.py +++ b/myproject/inventory/admin.py @@ -37,6 +37,63 @@ class ShowcaseAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): ) 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 ===== @admin.register(Warehouse) @@ -61,6 +118,50 @@ class WarehouseAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): return '-' 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 ===== @admin.register(StockBatch) diff --git a/myproject/inventory/forms_showcase.py b/myproject/inventory/forms_showcase.py index a96b5b1..a41cce8 100644 --- a/myproject/inventory/forms_showcase.py +++ b/myproject/inventory/forms_showcase.py @@ -55,13 +55,13 @@ class ShowcaseForm(forms.ModelForm): self.initial['warehouse'] = default_warehouse.id def clean_name(self): - """Проверка уникальности названия витрины в рамках склада""" + """Проверка уникальности названия витрины в рамках склада (среди активных)""" name = self.cleaned_data.get('name') warehouse = self.cleaned_data.get('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: @@ -69,7 +69,7 @@ class ShowcaseForm(forms.ModelForm): if queryset.exists(): raise ValidationError( - f'Витрина с названием "{name}" уже существует на складе "{warehouse.name}". ' + f'Активная витрина с названием "{name}" уже существует на складе "{warehouse.name}". ' 'Пожалуйста, выберите другое название.' ) diff --git a/myproject/inventory/views/showcase.py b/myproject/inventory/views/showcase.py index 2213f13..17bdacc 100644 --- a/myproject/inventory/views/showcase.py +++ b/myproject/inventory/views/showcase.py @@ -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') diff --git a/myproject/inventory/views/warehouse.py b/myproject/inventory/views/warehouse.py index 1f2659f..7267029 100644 --- a/myproject/inventory/views/warehouse.py +++ b/myproject/inventory/views/warehouse.py @@ -66,16 +66,70 @@ class WarehouseDeleteView(LoginRequiredMixin, DeleteView): def post(self, request, *args, **kwargs): """ Переопределяем POST метод чтобы использовать мягкое удаление - вместо стандартного физического удаления Django + вместо стандартного физического удаления Django. + Включает строгую валидацию для защиты от случайного удаления. """ self.object = self.get_object() 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.save() - messages.success(request, f'Склад "{warehouse_name}" архивирован и скрыт из списка.') + messages.success( + request, + f'Склад "{warehouse_name}" успешно архивирован и скрыт из списка. ' + 'Все связанные данные сохранены.' + ) return HttpResponseRedirect(self.get_success_url())