- Add view mixins (RoleRequiredMixin, OwnerRequiredMixin, ManagerOwnerRequiredMixin) to user_roles/mixins.py - Replace PermissionRequiredMixin with ManagerOwnerRequiredMixin in all product views - Remove permission_required attributes from view classes - Owner and Manager roles now grant access without Django model permissions This allows owners to access all product functionality through their custom role, without needing to be superusers or have explicit Django permissions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
390 lines
17 KiB
Python
390 lines
17 KiB
Python
"""
|
||
CRUD представления для товаров (Product).
|
||
"""
|
||
from django.contrib import messages
|
||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||
from django.views.generic import ListView, CreateView, DetailView, UpdateView, DeleteView
|
||
from django.urls import reverse_lazy
|
||
from django.db.models import Q, Sum, Value, DecimalField
|
||
from django.db.models.functions import Coalesce
|
||
from itertools import chain
|
||
|
||
from ..models import Product, ProductCategory, ProductTag, ProductKit
|
||
from ..forms import ProductForm
|
||
from .utils import handle_photos
|
||
from ..models import ProductPhoto
|
||
from user_roles.mixins import ManagerOwnerRequiredMixin
|
||
|
||
|
||
class ProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, ListView):
|
||
model = Product
|
||
template_name = 'products/product_list.html'
|
||
context_object_name = 'products'
|
||
paginate_by = 10
|
||
|
||
def get_queryset(self):
|
||
queryset = super().get_queryset()
|
||
# Добавляем prefetch_related для оптимизации запросов к категориям
|
||
queryset = queryset.prefetch_related('categories', 'photos', 'tags')
|
||
|
||
# Улучшенный поиск по нескольким полям
|
||
search_query = self.request.GET.get('search')
|
||
if search_query:
|
||
# Ищем по названию, артикулу, описанию, категориям и ключевым словам
|
||
queryset = queryset.filter(
|
||
Q(name__icontains=search_query) |
|
||
Q(sku__icontains=search_query) |
|
||
Q(description__icontains=search_query) |
|
||
Q(categories__name__icontains=search_query) |
|
||
Q(search_keywords__icontains=search_query)
|
||
).distinct()
|
||
|
||
# Фильтр по категории
|
||
category_id = self.request.GET.get('category')
|
||
if category_id:
|
||
queryset = queryset.filter(categories__id=category_id)
|
||
|
||
# Фильтр по статусу (новая система)
|
||
status_filter = self.request.GET.get('status')
|
||
if status_filter:
|
||
queryset = queryset.filter(status=status_filter)
|
||
else:
|
||
# Фильтр по is_active для обратной совместимости (старая система)
|
||
is_active_filter = self.request.GET.get('is_active')
|
||
if is_active_filter == '1':
|
||
queryset = queryset.filter(status='active')
|
||
elif is_active_filter == '0':
|
||
queryset = queryset.filter(status__in=['archived', 'discontinued'])
|
||
|
||
# Фильтр по тегам
|
||
tags = self.request.GET.getlist('tags')
|
||
if tags:
|
||
queryset = queryset.filter(tags__id__in=tags).distinct()
|
||
|
||
return queryset.order_by('-created_at')
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
|
||
# Данные для фильтров
|
||
context['filters'] = {
|
||
'categories': ProductCategory.objects.filter(is_active=True),
|
||
'tags': ProductTag.objects.filter(is_active=True),
|
||
'current': {
|
||
'search': self.request.GET.get('search', ''),
|
||
'category': self.request.GET.get('category', ''),
|
||
'status': self.request.GET.get('status', ''),
|
||
'tags': self.request.GET.getlist('tags'),
|
||
}
|
||
}
|
||
|
||
# Кнопки действий
|
||
action_buttons = []
|
||
|
||
if self.request.user.has_perm('products.add_product'):
|
||
action_buttons.append({
|
||
'url': reverse_lazy('products:product-create'),
|
||
'text': 'Создать товар',
|
||
'class': 'btn-primary',
|
||
'icon': 'plus-circle'
|
||
})
|
||
|
||
if self.request.user.has_perm('products.add_productkit'):
|
||
action_buttons.append({
|
||
'url': reverse_lazy('products:productkit-create'),
|
||
'text': 'Создать комплект',
|
||
'class': 'btn-outline-primary',
|
||
'icon': 'box-seam'
|
||
})
|
||
|
||
action_buttons.append({
|
||
'url': reverse_lazy('products:productkit-list'),
|
||
'text': 'К списку комплектов',
|
||
'class': 'btn-outline-secondary',
|
||
'icon': 'list'
|
||
})
|
||
|
||
context['action_buttons'] = action_buttons
|
||
|
||
return context
|
||
|
||
|
||
class ProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, CreateView):
|
||
model = Product
|
||
form_class = ProductForm
|
||
template_name = 'products/product_form.html'
|
||
|
||
def get_success_url(self):
|
||
return reverse_lazy('products:products-list')
|
||
|
||
def form_valid(self, form):
|
||
from django.db import IntegrityError
|
||
|
||
try:
|
||
response = super().form_valid(form)
|
||
|
||
# Обработка загрузки фотографий
|
||
photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product')
|
||
if photo_errors:
|
||
for error in photo_errors:
|
||
# Если это предупреждение о лимите фото - warning, иначе - error
|
||
if 'Загружено' in error and 'обработано только' in error:
|
||
messages.warning(self.request, error)
|
||
else:
|
||
messages.error(self.request, error)
|
||
|
||
messages.success(self.request, f'Товар "{form.instance.name}" успешно создан!')
|
||
return response
|
||
|
||
except IntegrityError as e:
|
||
# Обработка ошибки дублирования slug'а или других unique constraints
|
||
error_msg = str(e).lower()
|
||
if 'slug' in error_msg or 'duplicate key' in error_msg:
|
||
messages.error(
|
||
self.request,
|
||
f'Ошибка: товар с названием "{form.instance.name}" уже существует. '
|
||
'Пожалуйста, используйте другое название.'
|
||
)
|
||
else:
|
||
messages.error(
|
||
self.request,
|
||
'Ошибка при сохранении товара. Пожалуйста, проверьте введённые данные.'
|
||
)
|
||
|
||
# Перенаправляем обратно на форму с сохранённой информацией
|
||
return self.form_invalid(form)
|
||
|
||
|
||
class ProductDetailView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DetailView):
|
||
model = Product
|
||
template_name = 'products/product_detail.html'
|
||
context_object_name = 'product'
|
||
|
||
def get_queryset(self):
|
||
# Предзагрузка фотографий и аннотация остатков
|
||
total_available = Coalesce(Sum('stocks__quantity_available'), Value(0), output_field=DecimalField())
|
||
total_reserved = Coalesce(Sum('stocks__quantity_reserved'), Value(0), output_field=DecimalField())
|
||
return super().get_queryset().prefetch_related('photos').annotate(
|
||
total_available=total_available,
|
||
total_reserved=total_reserved,
|
||
total_free=total_available - total_reserved,
|
||
)
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
# Добавляем фотографии товара в контекст
|
||
context['product_photos'] = self.object.photos.all().order_by('order', 'created_at')
|
||
context['photos_count'] = self.object.photos.count()
|
||
return context
|
||
|
||
|
||
class ProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, UpdateView):
|
||
model = Product
|
||
form_class = ProductForm
|
||
template_name = 'products/product_form.html'
|
||
|
||
def get_success_url(self):
|
||
return reverse_lazy('products:products-list')
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
# Добавляем фотографии товара в контекст
|
||
context['product_photos'] = self.object.photos.all().order_by('order', 'created_at')
|
||
context['photos_count'] = self.object.photos.count()
|
||
return context
|
||
|
||
def form_valid(self, form):
|
||
from django.db import IntegrityError
|
||
|
||
try:
|
||
response = super().form_valid(form)
|
||
|
||
# Обработка загрузки фотографий
|
||
photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product')
|
||
if photo_errors:
|
||
for error in photo_errors:
|
||
# Если это предупреждение о лимите фото - warning, иначе - error
|
||
if 'Загружено' in error and 'обработано только' in error:
|
||
messages.warning(self.request, error)
|
||
else:
|
||
messages.error(self.request, error)
|
||
|
||
messages.success(self.request, f'Товар "{form.instance.name}" успешно обновлен!')
|
||
return response
|
||
|
||
except IntegrityError as e:
|
||
# Обработка ошибки дублирования slug'а или других unique constraints
|
||
error_msg = str(e).lower()
|
||
if 'slug' in error_msg or 'duplicate key' in error_msg:
|
||
messages.error(
|
||
self.request,
|
||
f'Ошибка: товар с названием "{form.instance.name}" уже существует. '
|
||
'Пожалуйста, используйте другое название.'
|
||
)
|
||
else:
|
||
messages.error(
|
||
self.request,
|
||
'Ошибка при сохранении товара. Пожалуйста, проверьте введённые данные.'
|
||
)
|
||
|
||
# Перенаправляем обратно на форму с сохранённой информацией
|
||
return self.form_invalid(form)
|
||
|
||
|
||
class ProductDeleteView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DeleteView):
|
||
model = Product
|
||
template_name = 'products/product_confirm_delete.html'
|
||
context_object_name = 'product'
|
||
|
||
def get_success_url(self):
|
||
messages.success(self.request, f'Товар "{self.object.name}" успешно удален!')
|
||
return reverse_lazy('products:products-list')
|
||
|
||
|
||
class CombinedProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, ListView):
|
||
"""
|
||
Объединенное представление для товаров и комплектов.
|
||
Показывает оба типа продуктов в одном списке с возможностью фильтрации по типу.
|
||
"""
|
||
template_name = 'products/products_list.html'
|
||
context_object_name = 'items'
|
||
paginate_by = 20
|
||
|
||
def get_queryset(self):
|
||
# Получаем фильтр по типу
|
||
type_filter = self.request.GET.get('type', 'all')
|
||
|
||
# Получаем товары и комплекты (только постоянные комплекты)
|
||
# Аннотируем товары данными об остатках из агрегированной таблицы Stock
|
||
total_available = Coalesce(Sum('stocks__quantity_available'), Value(0), output_field=DecimalField())
|
||
total_reserved = Coalesce(Sum('stocks__quantity_reserved'), Value(0), output_field=DecimalField())
|
||
products = Product.objects.prefetch_related('categories', 'photos', 'tags').annotate(
|
||
total_available=total_available,
|
||
total_reserved=total_reserved,
|
||
total_free=total_available - total_reserved,
|
||
)
|
||
kits = ProductKit.objects.filter(is_temporary=False).prefetch_related('categories', 'photos', 'tags')
|
||
|
||
# Применяем фильтры
|
||
search_query = self.request.GET.get('search')
|
||
category_id = self.request.GET.get('category')
|
||
status_filter = self.request.GET.get('status')
|
||
is_active_filter = self.request.GET.get('is_active')
|
||
in_stock_filter = self.request.GET.get('in_stock')
|
||
tags = self.request.GET.getlist('tags')
|
||
|
||
# Фильтрация по поиску
|
||
if search_query:
|
||
products = products.filter(
|
||
Q(name__icontains=search_query) |
|
||
Q(sku__icontains=search_query) |
|
||
Q(description__icontains=search_query) |
|
||
Q(categories__name__icontains=search_query) |
|
||
Q(search_keywords__icontains=search_query)
|
||
).distinct()
|
||
|
||
kits = kits.filter(
|
||
Q(name__icontains=search_query) |
|
||
Q(sku__icontains=search_query) |
|
||
Q(description__icontains=search_query) |
|
||
Q(categories__name__icontains=search_query)
|
||
).distinct()
|
||
|
||
# Фильтрация по категории
|
||
if category_id:
|
||
products = products.filter(categories__id=category_id)
|
||
kits = kits.filter(categories__id=category_id)
|
||
|
||
# Фильтрация по статусу (новая система)
|
||
if status_filter:
|
||
products = products.filter(status=status_filter)
|
||
kits = kits.filter(status=status_filter)
|
||
else:
|
||
# Фильтрация по is_active для обратной совместимости (старая система)
|
||
if is_active_filter == '1':
|
||
products = products.filter(status='active')
|
||
kits = kits.filter(status='active')
|
||
elif is_active_filter == '0':
|
||
products = products.filter(status__in=['archived', 'discontinued'])
|
||
kits = kits.filter(status__in=['archived', 'discontinued'])
|
||
|
||
# Фильтрация по наличию (только для товаров)
|
||
if in_stock_filter == '1':
|
||
products = products.filter(in_stock=True)
|
||
elif in_stock_filter == '0':
|
||
products = products.filter(in_stock=False)
|
||
|
||
# Фильтрация по тегам
|
||
if tags:
|
||
products = products.filter(tags__id__in=tags).distinct()
|
||
kits = kits.filter(tags__id__in=tags).distinct()
|
||
|
||
# Применяем фильтр по типу
|
||
products_list = []
|
||
kits_list = []
|
||
|
||
if type_filter in ['all', 'products']:
|
||
products_list = list(products.order_by('-created_at'))
|
||
for p in products_list:
|
||
p.item_type = 'product'
|
||
|
||
if type_filter in ['all', 'kits']:
|
||
kits_list = list(kits.order_by('-created_at'))
|
||
for k in kits_list:
|
||
k.item_type = 'kit'
|
||
|
||
# Объединяем и сортируем по дате создания
|
||
combined = sorted(
|
||
chain(products_list, kits_list),
|
||
key=lambda x: x.created_at,
|
||
reverse=True
|
||
)
|
||
|
||
return combined
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
|
||
# Получаем список статусов из модели
|
||
from ..models.base import BaseProductEntity
|
||
item_statuses = BaseProductEntity.STATUS_CHOICES
|
||
|
||
# Данные для фильтров
|
||
context['filters'] = {
|
||
'categories': ProductCategory.objects.filter(is_active=True),
|
||
'tags': ProductTag.objects.filter(is_active=True),
|
||
'current': {
|
||
'search': self.request.GET.get('search', ''),
|
||
'category': self.request.GET.get('category', ''),
|
||
'status': self.request.GET.get('status', ''),
|
||
'type': self.request.GET.get('type', 'all'),
|
||
'in_stock': self.request.GET.get('in_stock', ''),
|
||
'tags': [int(tag) for tag in self.request.GET.getlist('tags') if tag.isdigit()],
|
||
}
|
||
}
|
||
|
||
context['item_statuses'] = item_statuses
|
||
|
||
# Кнопки действий
|
||
action_buttons = []
|
||
|
||
if self.request.user.has_perm('products.add_product'):
|
||
action_buttons.append({
|
||
'url': reverse_lazy('products:product-create'),
|
||
'text': 'Создать товар',
|
||
'class': 'btn-primary',
|
||
'icon': 'plus-circle'
|
||
})
|
||
|
||
if self.request.user.has_perm('products.add_productkit'):
|
||
action_buttons.append({
|
||
'url': reverse_lazy('products:productkit-create'),
|
||
'text': 'Создать комплект',
|
||
'class': 'btn-outline-primary',
|
||
'icon': 'box-seam'
|
||
})
|
||
|
||
context['action_buttons'] = action_buttons
|
||
|
||
return context
|