Files
octopus/myproject/inventory/views/warehouse.py
Andrey Smakotin 0f09702094 Добавлена защита от удаления дефолтных Склада и Витрины
Проблема: при создании тенанта автоматически создаются дефолтные
Склад и Витрина. Если пользователь удалит их, система может сломаться:
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>
2026-01-03 19:52:01 +03:00

171 lines
7.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
from django.shortcuts import render, get_object_or_404
from django.views.generic import ListView, CreateView, UpdateView, DeleteView, View
from django.urls import reverse_lazy
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib import messages
from django.http import JsonResponse, HttpResponseRedirect
from django.views.decorators.http import require_http_methods
from django.utils.decorators import method_decorator
from ..models import Warehouse
from ..forms import WarehouseForm
class WarehouseListView(LoginRequiredMixin, ListView):
"""
Список всех складов тенанта
Сортирует по is_default (по умолчанию первым), потом по названию
"""
model = Warehouse
template_name = 'inventory/warehouse/warehouse_list.html'
context_object_name = 'warehouses'
paginate_by = 20
def get_queryset(self):
# Сортируем: сначала is_default DESC (по умолчанию первый), потом по названию
return Warehouse.objects.filter(is_active=True).order_by('-is_default', 'name')
class WarehouseCreateView(LoginRequiredMixin, CreateView):
"""
Создание нового склада
"""
model = Warehouse
form_class = WarehouseForm
template_name = 'inventory/warehouse/warehouse_form.html'
success_url = reverse_lazy('inventory:warehouse-list')
def form_valid(self, form):
messages.success(self.request, f'Склад "{form.instance.name}" успешно создан.')
return super().form_valid(form)
class WarehouseUpdateView(LoginRequiredMixin, UpdateView):
"""
Редактирование склада
"""
model = Warehouse
form_class = WarehouseForm
template_name = 'inventory/warehouse/warehouse_form.html'
success_url = reverse_lazy('inventory:warehouse-list')
def form_valid(self, form):
messages.success(self.request, f'Склад "{form.instance.name}" успешно обновлён.')
return super().form_valid(form)
class WarehouseDeleteView(LoginRequiredMixin, DeleteView):
"""
Удаление склада (мягкое удаление - деактивация).
Вместо физического удаления из БД, устанавливаем is_active=False
"""
model = Warehouse
template_name = 'inventory/warehouse/warehouse_confirm_delete.html'
success_url = reverse_lazy('inventory:warehouse-list')
def post(self, request, *args, **kwargs):
"""
Переопределяем POST метод чтобы использовать мягкое удаление
вместо стандартного физического удаления 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}" успешно архивирован и скрыт из списка. '
'Все связанные данные сохранены.'
)
return HttpResponseRedirect(self.get_success_url())
@method_decorator(require_http_methods(["POST"]), name="dispatch")
class SetDefaultWarehouseView(LoginRequiredMixin, View):
"""
Установка склада по умолчанию
Обрабатывает POST запрос от AJAX и возвращает JSON ответ
"""
def post(self, request, pk):
"""
Установить склад с заданным pk как склад по умолчанию
"""
try:
warehouse = get_object_or_404(Warehouse, pk=pk, is_active=True)
# Установить этот склад как по умолчанию
# (метод save() в модели автоматически снимет флаг с других)
warehouse.is_default = True
warehouse.save()
return JsonResponse({
'status': 'success',
'message': f'Склад "{warehouse.name}" установлен по умолчанию',
'warehouse_id': warehouse.id,
'warehouse_name': warehouse.name
})
except Warehouse.DoesNotExist:
return JsonResponse({
'status': 'error',
'message': 'Склад не найден'
}, status=404)
except Exception as e:
return JsonResponse({
'status': 'error',
'message': str(e)
}, status=500)