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