Files
octopus/myproject/inventory/views/showcase.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

279 lines
12 KiB
Python
Raw 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.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib import messages
from django.shortcuts import render, redirect, get_object_or_404
from django.db.models import Count, Q
from django.views.generic import ListView, CreateView, UpdateView, DeleteView, View
from django.urls import reverse_lazy
from django.utils.decorators import method_decorator
from django.http import JsonResponse
from inventory.models import Showcase, Reservation
from inventory.forms_showcase import ShowcaseForm
@method_decorator(login_required, name='dispatch')
class ShowcaseListView(ListView):
"""
Список всех витрин с группировкой по складам.
Отображает информацию о количестве активных резервов на каждой витрине.
"""
model = Showcase
template_name = 'inventory/showcase/list.html'
context_object_name = 'showcases'
def get_queryset(self):
"""
Получаем витрины с аннотацией количества активных резервов.
Сортируем по складу и названию.
По умолчанию показываем только активные витрины.
"""
# По умолчанию показываем только активные витрины
queryset = Showcase.objects.filter(is_active=True).select_related('warehouse').annotate(
active_reservations_count=Count(
'reservations',
filter=Q(reservations__status='reserved')
)
).order_by('warehouse__name', 'name')
# Фильтрация по складу, если указан GET-параметр
warehouse_id = self.request.GET.get('warehouse')
if warehouse_id:
queryset = queryset.filter(warehouse_id=warehouse_id)
# Фильтрация по статусу активности (переопределяет дефолтный фильтр)
is_active = self.request.GET.get('is_active')
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
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['title'] = 'Витрины'
# Добавляем информацию для фильтров
from inventory.models import Warehouse
context['warehouses'] = Warehouse.objects.filter(is_active=True).order_by('name')
return context
@method_decorator(login_required, name='dispatch')
class ShowcaseCreateView(CreateView):
"""
Создание новой витрины.
"""
model = Showcase
form_class = ShowcaseForm
template_name = 'inventory/showcase/form.html'
success_url = reverse_lazy('inventory:showcase-list')
def form_valid(self, form):
"""Сохраняем витрину и показываем сообщение об успехе"""
response = super().form_valid(form)
messages.success(
self.request,
f'Витрина "{self.object.name}" успешно создана на складе "{self.object.warehouse.name}"'
)
return response
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['title'] = 'Создание витрины'
context['form_title'] = 'Новая витрина'
context['submit_text'] = 'Создать витрину'
return context
@method_decorator(login_required, name='dispatch')
class ShowcaseUpdateView(UpdateView):
"""
Редактирование существующей витрины.
"""
model = Showcase
form_class = ShowcaseForm
template_name = 'inventory/showcase/form.html'
success_url = reverse_lazy('inventory:showcase-list')
def form_valid(self, form):
"""Сохраняем изменения и показываем сообщение об успехе"""
response = super().form_valid(form)
messages.success(
self.request,
f'Витрина "{self.object.name}" успешно обновлена'
)
return response
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['title'] = f'Редактирование витрины: {self.object.name}'
context['form_title'] = f'Редактирование: {self.object.name}'
context['submit_text'] = 'Сохранить изменения'
# Добавляем информацию о резервах на витрине
context['active_reservations_count'] = Reservation.objects.filter(
showcase=self.object,
status='reserved'
).count()
return context
@method_decorator(login_required, name='dispatch')
class ShowcaseDeleteView(DeleteView):
"""
Деактивация витрины (мягкое удаление) с подтверждением.
Проверяет наличие активных резервов и физических экземпляров перед деактивацией.
"""
model = Showcase
template_name = 'inventory/showcase/delete.html'
success_url = reverse_lazy('inventory:showcase-list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['title'] = f'Деактивация витрины: {self.object.name}'
# Проверяем наличие активных резервов
active_reservations = Reservation.objects.filter(
showcase=self.object,
status='reserved'
).select_related('product')
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 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'на ней есть {active_reservations_count} активных резервов. '
'Сначала освободите или продайте зарезервированные товары.'
)
return redirect('inventory:showcase-delete', pk=showcase.pk)
# 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}" успешно деактивирована и скрыта из списка. '
'Все связанные данные сохранены.'
)
return HttpResponseRedirect(self.get_success_url())
@method_decorator(login_required, name='dispatch')
class SetDefaultShowcaseView(LoginRequiredMixin, View):
"""
Установка витрины по умолчанию для её склада.
Обрабатывает POST запрос от AJAX и возвращает JSON ответ.
"""
def post(self, request, pk):
"""
Установить витрину с заданным pk как витрину по умолчанию для её склада
"""
try:
showcase = get_object_or_404(Showcase, pk=pk, is_active=True)
# Установить эту витрину как по умолчанию
# (метод save() в модели автоматически снимет флаг с других витрин этого склада)
showcase.is_default = True
showcase.save()
return JsonResponse({
'status': 'success',
'message': f'Витрина "{showcase.name}" на складе "{showcase.warehouse.name}" установлена по умолчанию',
'showcase_id': showcase.id,
'showcase_name': showcase.name,
'warehouse_id': showcase.warehouse.id,
'warehouse_name': showcase.warehouse.name
})
except Showcase.DoesNotExist:
return JsonResponse({
'status': 'error',
'message': 'Витрина не найдена'
}, status=404)
except Exception as e:
return JsonResponse({
'status': 'error',
'message': f'Ошибка при установке витрины по умолчанию: {str(e)}'
}, status=500)