- 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>
529 lines
24 KiB
Python
529 lines
24 KiB
Python
"""
|
||
CRUD представления для комплектов товаров (ProductKit).
|
||
"""
|
||
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.shortcuts import redirect
|
||
from django.db import transaction, IntegrityError
|
||
|
||
from user_roles.mixins import ManagerOwnerRequiredMixin
|
||
from ..models import ProductKit, ProductCategory, ProductTag, ProductKitPhoto, Product, ProductVariantGroup
|
||
from ..forms import ProductKitForm, KitItemFormSetCreate, KitItemFormSetUpdate
|
||
from .utils import handle_photos
|
||
|
||
|
||
class ProductKitListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, ListView):
|
||
model = ProductKit
|
||
template_name = 'products/productkit_list.html'
|
||
context_object_name = 'kits'
|
||
paginate_by = 10
|
||
|
||
def get_queryset(self):
|
||
queryset = super().get_queryset()
|
||
queryset = queryset.prefetch_related('categories', 'photos', 'kit_items', 'tags')
|
||
|
||
# Скрываем временные комплекты из общего каталога
|
||
queryset = queryset.filter(is_temporary=False)
|
||
|
||
# Поиск по названию
|
||
search_query = self.request.GET.get('search')
|
||
if search_query:
|
||
queryset = queryset.filter(name__icontains=search_query)
|
||
|
||
# Фильтр по категории
|
||
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 = self.request.GET.get('is_active')
|
||
if is_active == '1':
|
||
queryset = queryset.filter(status='active')
|
||
elif is_active == '0':
|
||
queryset = queryset.filter(status__in=['archived', 'discontinued'])
|
||
|
||
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', ''),
|
||
'is_active': self.request.GET.get('is_active', ''),
|
||
'tags': self.request.GET.getlist('tags'),
|
||
}
|
||
}
|
||
|
||
# Кнопки действий
|
||
action_buttons = []
|
||
|
||
if self.request.user.has_perm('products.add_productkit'):
|
||
action_buttons.append({
|
||
'url': reverse_lazy('products:productkit-create'),
|
||
'text': 'Создать комплект',
|
||
'class': 'btn-primary',
|
||
'icon': 'plus-circle'
|
||
})
|
||
|
||
action_buttons.append({
|
||
'url': reverse_lazy('products:product-list'),
|
||
'text': 'К товарам',
|
||
'class': 'btn-outline-primary',
|
||
'icon': 'box'
|
||
})
|
||
|
||
context['action_buttons'] = action_buttons
|
||
|
||
return context
|
||
|
||
|
||
class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, CreateView):
|
||
"""
|
||
View для создания нового комплекта с добавлением компонентов на одной странице.
|
||
"""
|
||
model = ProductKit
|
||
form_class = ProductKitForm
|
||
template_name = 'products/productkit_create.html'
|
||
|
||
def post(self, request, *args, **kwargs):
|
||
"""
|
||
Обрабатываем POST данные и очищаем ID товаров/комплектов от префиксов.
|
||
API возвращает ID в формате "product_123" или "kit_456", но Django ожидает числа.
|
||
"""
|
||
# Создаем изменяемую копию POST данных
|
||
post_data = request.POST.copy()
|
||
|
||
# Очищаем product ID от префиксов (product_123 -> 123)
|
||
for key in post_data.keys():
|
||
if key.endswith('-product') and post_data[key]:
|
||
value = post_data[key]
|
||
if '_' in value:
|
||
# Извлекаем числовой ID из "product_123"
|
||
numeric_id = value.split('_')[1]
|
||
post_data[key] = numeric_id
|
||
|
||
# Заменяем request.POST на очищенные данные
|
||
request.POST = post_data
|
||
|
||
return super().post(request, *args, **kwargs)
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
|
||
if self.request.POST:
|
||
context['kititem_formset'] = KitItemFormSetCreate(self.request.POST, prefix='kititem')
|
||
|
||
# При ошибке валидации: извлекаем выбранные товары для предзагрузки в Select2
|
||
from ..models import Product, ProductVariantGroup
|
||
selected_products = {}
|
||
selected_variants = {}
|
||
|
||
for key, value in self.request.POST.items():
|
||
if '-product' in key and value:
|
||
try:
|
||
product = Product.objects.get(id=value)
|
||
text = product.name
|
||
if product.sku:
|
||
text += f" ({product.sku})"
|
||
# Получаем actual_price: приоритет sale_price > price
|
||
actual_price = product.sale_price if product.sale_price else product.price
|
||
selected_products[key] = {
|
||
'id': product.id,
|
||
'text': text,
|
||
'price': str(product.price) if product.price else None,
|
||
'actual_price': str(actual_price) if actual_price else '0'
|
||
}
|
||
except Product.DoesNotExist:
|
||
pass
|
||
|
||
if '-variant_group' in key and value:
|
||
try:
|
||
variant_group = ProductVariantGroup.objects.prefetch_related(
|
||
'items__product'
|
||
).get(id=value)
|
||
|
||
variant_price = variant_group.price or 0
|
||
count = variant_group.items.count()
|
||
|
||
selected_variants[key] = {
|
||
'id': variant_group.id,
|
||
'text': f"{variant_group.name} ({count} вариантов)",
|
||
'price': str(variant_price),
|
||
'actual_price': str(variant_price),
|
||
'type': 'variant',
|
||
'count': count
|
||
}
|
||
except ProductVariantGroup.DoesNotExist:
|
||
pass
|
||
|
||
context['selected_products'] = selected_products
|
||
context['selected_variants'] = selected_variants
|
||
else:
|
||
context['kititem_formset'] = KitItemFormSetCreate(prefix='kititem')
|
||
|
||
return context
|
||
|
||
def form_valid(self, form):
|
||
# Получаем формсет из POST с правильным префиксом
|
||
kititem_formset = KitItemFormSetCreate(self.request.POST, prefix='kititem')
|
||
|
||
# Проверяем валидность основной формы
|
||
if not form.is_valid():
|
||
messages.error(self.request, 'Пожалуйста, исправьте ошибки в основной форме комплекта.')
|
||
return self.form_invalid(form)
|
||
|
||
if not kititem_formset.is_valid():
|
||
# Если формсет невалиден, показываем форму с ошибками
|
||
messages.error(self.request, 'Пожалуйста, исправьте ошибки в компонентах комплекта.')
|
||
return self.form_invalid(form)
|
||
|
||
try:
|
||
with transaction.atomic():
|
||
# Сохраняем основную форму (комплект)
|
||
self.object = form.save(commit=True)
|
||
|
||
# Убеждаемся что объект в БД
|
||
if not self.object.pk:
|
||
raise Exception("Не удалось сохранить комплект в базу данных")
|
||
|
||
# Сохраняем компоненты
|
||
kititem_formset.instance = self.object
|
||
saved_items = kititem_formset.save()
|
||
|
||
# Пересчитываем базовую цену после сохранения всех компонентов
|
||
self.object.recalculate_base_price()
|
||
|
||
# Обработка фотографий
|
||
handle_photos(self.request, self.object, ProductKitPhoto, 'kit')
|
||
|
||
messages.success(
|
||
self.request,
|
||
f'Комплект "{self.object.name}" успешно создан!'
|
||
)
|
||
|
||
return redirect('products:products-list')
|
||
except IntegrityError as e:
|
||
# Обработка нарушения уникальности в БД
|
||
error_msg = str(e).lower()
|
||
if 'unique_active_kit_name' in error_msg or ('name' in error_msg and 'duplicate' in error_msg):
|
||
messages.error(
|
||
self.request,
|
||
f'Ошибка: комплект с названием "{form.instance.name}" уже существует. '
|
||
'Пожалуйста, используйте другое название.'
|
||
)
|
||
elif 'slug' in error_msg or 'unique' in error_msg:
|
||
messages.error(
|
||
self.request,
|
||
'Ошибка: комплект с таким названием уже существует. '
|
||
'Пожалуйста, используйте другое название.'
|
||
)
|
||
else:
|
||
messages.error(
|
||
self.request,
|
||
'Ошибка при сохранении комплекта. Пожалуйста, проверьте введённые данные.'
|
||
)
|
||
return self.form_invalid(form)
|
||
except Exception as e:
|
||
messages.error(self.request, f'Ошибка при сохранении: {str(e)}')
|
||
import traceback
|
||
traceback.print_exc()
|
||
return self.form_invalid(form)
|
||
|
||
def form_invalid(self, form):
|
||
# Получаем формсет для отображения ошибок
|
||
context = self.get_context_data(form=form)
|
||
return self.render_to_response(context)
|
||
|
||
|
||
class ProductKitUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, UpdateView):
|
||
"""
|
||
View для редактирования существующего комплекта и добавления товаров.
|
||
"""
|
||
model = ProductKit
|
||
form_class = ProductKitForm
|
||
template_name = 'products/productkit_edit.html'
|
||
|
||
def post(self, request, *args, **kwargs):
|
||
"""
|
||
Обрабатываем POST данные и очищаем ID товаров/комплектов от префиксов.
|
||
API возвращает ID в формате "product_123" или "kit_456", но Django ожидает числа.
|
||
"""
|
||
# Создаем изменяемую копию POST данных
|
||
post_data = request.POST.copy()
|
||
|
||
# Очищаем product ID от префиксов (product_123 -> 123)
|
||
for key in post_data.keys():
|
||
if key.endswith('-product') and post_data[key]:
|
||
value = post_data[key]
|
||
if '_' in value:
|
||
# Извлекаем числовой ID из "product_123"
|
||
numeric_id = value.split('_')[1]
|
||
post_data[key] = numeric_id
|
||
|
||
# Заменяем request.POST на очищенные данные
|
||
request.POST = post_data
|
||
|
||
return super().post(request, *args, **kwargs)
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
|
||
if self.request.POST:
|
||
context['kititem_formset'] = KitItemFormSetUpdate(self.request.POST, instance=self.object, prefix='kititem')
|
||
|
||
# При ошибке валидации - подготавливаем данные для Select2
|
||
selected_products = {}
|
||
selected_variants = {}
|
||
|
||
for key, value in self.request.POST.items():
|
||
if '-product' in key and value:
|
||
try:
|
||
# Очищаем ID от префикса если есть
|
||
numeric_value = value.split('_')[1] if '_' in value else value
|
||
product = Product.objects.get(id=numeric_value)
|
||
|
||
text = product.name
|
||
if product.sku:
|
||
text += f" ({product.sku})"
|
||
|
||
actual_price = product.sale_price if product.sale_price else product.price
|
||
selected_products[key] = {
|
||
'id': product.id,
|
||
'text': text,
|
||
'price': str(product.price) if product.price else None,
|
||
'actual_price': str(actual_price) if actual_price else '0'
|
||
}
|
||
except Product.DoesNotExist:
|
||
pass
|
||
|
||
if '-variant_group' in key and value:
|
||
try:
|
||
variant_group = ProductVariantGroup.objects.prefetch_related(
|
||
'items__product'
|
||
).get(id=value)
|
||
|
||
variant_price = variant_group.price or 0
|
||
count = variant_group.items.count()
|
||
|
||
selected_variants[key] = {
|
||
'id': variant_group.id,
|
||
'text': f"{variant_group.name} ({count} вариантов)",
|
||
'price': str(variant_price),
|
||
'actual_price': str(variant_price),
|
||
'type': 'variant',
|
||
'count': count
|
||
}
|
||
except ProductVariantGroup.DoesNotExist:
|
||
pass
|
||
|
||
context['selected_products'] = selected_products
|
||
context['selected_variants'] = selected_variants
|
||
else:
|
||
context['kititem_formset'] = KitItemFormSetUpdate(instance=self.object, prefix='kititem')
|
||
|
||
# Подготавливаем данные для предзагрузки в Select2
|
||
selected_products = {}
|
||
selected_variants = {}
|
||
|
||
for item in self.object.kit_items.all():
|
||
form_prefix = f"kititem-{item.id}"
|
||
|
||
if item.product:
|
||
product = item.product
|
||
text = product.name
|
||
if product.sku:
|
||
text += f" ({product.sku})"
|
||
|
||
actual_price = product.sale_price if product.sale_price else product.price
|
||
selected_products[f"{form_prefix}-product"] = {
|
||
'id': product.id,
|
||
'text': text,
|
||
'price': str(product.price) if product.price else None,
|
||
'actual_price': str(actual_price) if actual_price else '0'
|
||
}
|
||
|
||
if item.variant_group:
|
||
variant_group = ProductVariantGroup.objects.prefetch_related(
|
||
'items__product'
|
||
).get(id=item.variant_group.id)
|
||
|
||
variant_price = variant_group.price or 0
|
||
count = variant_group.items.count()
|
||
|
||
selected_variants[f"{form_prefix}-variant_group"] = {
|
||
'id': variant_group.id,
|
||
'text': f"{variant_group.name} ({count} вариантов)",
|
||
'price': str(variant_price),
|
||
'actual_price': str(variant_price),
|
||
'type': 'variant',
|
||
'count': count
|
||
}
|
||
|
||
context['selected_products'] = selected_products
|
||
context['selected_variants'] = selected_variants
|
||
|
||
context['productkit_photos'] = self.object.photos.all().order_by('order', 'created_at')
|
||
context['photos_count'] = self.object.photos.count()
|
||
|
||
return context
|
||
|
||
def form_valid(self, form):
|
||
# Получаем формсет из POST с правильным префиксом
|
||
kititem_formset = KitItemFormSetUpdate(self.request.POST, instance=self.object, prefix='kititem')
|
||
|
||
# Проверяем валидность основной формы и формсета
|
||
if not form.is_valid():
|
||
messages.error(self.request, 'Пожалуйста, исправьте ошибки в основной форме комплекта.')
|
||
return self.form_invalid(form)
|
||
|
||
if not kititem_formset.is_valid():
|
||
# Если формсет невалиден, показываем форму с ошибками
|
||
messages.error(self.request, 'Пожалуйста, исправьте ошибки в компонентах комплекта.')
|
||
return self.form_invalid(form)
|
||
|
||
try:
|
||
with transaction.atomic():
|
||
# Сохраняем основную форму
|
||
self.object = form.save(commit=True)
|
||
|
||
# Сохраняем компоненты
|
||
kititem_formset.instance = self.object
|
||
kititem_formset.save()
|
||
|
||
# Пересчитываем базовую цену после сохранения всех компонентов
|
||
self.object.recalculate_base_price()
|
||
|
||
# Обработка фотографий
|
||
handle_photos(self.request, self.object, ProductKitPhoto, 'kit')
|
||
|
||
messages.success(self.request, f'Комплект "{self.object.name}" успешно обновлен!')
|
||
|
||
# Проверяем, какую кнопку нажали
|
||
if self.request.POST.get('action') == 'continue':
|
||
return redirect('products:productkit-update', pk=self.object.pk)
|
||
else:
|
||
return redirect('products:products-list')
|
||
except IntegrityError as e:
|
||
# Обработка нарушения уникальности в БД
|
||
error_msg = str(e).lower()
|
||
if 'unique_active_kit_name' in error_msg or ('name' in error_msg and 'duplicate' in error_msg):
|
||
messages.error(
|
||
self.request,
|
||
f'Ошибка: комплект с названием "{form.instance.name}" уже существует. '
|
||
'Пожалуйста, используйте другое название.'
|
||
)
|
||
elif 'slug' in error_msg or 'unique' in error_msg:
|
||
messages.error(
|
||
self.request,
|
||
'Ошибка: комплект с таким названием уже существует. '
|
||
'Пожалуйста, используйте другое название.'
|
||
)
|
||
else:
|
||
messages.error(
|
||
self.request,
|
||
'Ошибка при сохранении комплекта. Пожалуйста, проверьте введённые данные.'
|
||
)
|
||
return self.form_invalid(form)
|
||
except Exception as e:
|
||
messages.error(self.request, f'Ошибка при сохранении: {str(e)}')
|
||
import traceback
|
||
traceback.print_exc()
|
||
return self.form_invalid(form)
|
||
|
||
def form_invalid(self, form):
|
||
# Получаем формсет для отображения ошибок
|
||
context = self.get_context_data(form=form)
|
||
return self.render_to_response(context)
|
||
|
||
def get_success_url(self):
|
||
return reverse_lazy('products:products-list')
|
||
|
||
|
||
class ProductKitDetailView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DetailView):
|
||
"""
|
||
View для просмотра деталей комплекта.
|
||
Показывает все компоненты, цены, фотографии.
|
||
"""
|
||
model = ProductKit
|
||
template_name = 'products/productkit_detail.html'
|
||
context_object_name = 'kit'
|
||
|
||
def get_queryset(self):
|
||
# Prefetch для оптимизации запросов
|
||
return super().get_queryset().prefetch_related(
|
||
'photos',
|
||
'kit_items__product',
|
||
'kit_items__variant_group',
|
||
'tags'
|
||
)
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
# Добавляем фотографии комплекта в контекст
|
||
context['productkit_photos'] = self.object.photos.all().order_by('order', 'created_at')
|
||
context['photos_count'] = self.object.photos.count()
|
||
# Добавляем компоненты
|
||
context['kit_items'] = self.object.kit_items.all().select_related('product', 'variant_group')
|
||
return context
|
||
|
||
|
||
class ProductKitDeleteView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DeleteView):
|
||
"""
|
||
View для удаления комплекта.
|
||
"""
|
||
model = ProductKit
|
||
template_name = 'products/productkit_confirm_delete.html'
|
||
context_object_name = 'kit'
|
||
|
||
def get_success_url(self):
|
||
messages.success(self.request, f'Комплект "{self.object.name}" успешно удален!')
|
||
return reverse_lazy('products:productkit-list')
|
||
|
||
|
||
class ProductKitMakePermanentView(LoginRequiredMixin, ManagerOwnerRequiredMixin, UpdateView):
|
||
"""
|
||
View для преобразования временного комплекта в постоянный.
|
||
Позволяет отредактировать название, добавить категории, теги перед сохранением.
|
||
"""
|
||
model = ProductKit
|
||
template_name = 'products/productkit_make_permanent.html'
|
||
context_object_name = 'kit'
|
||
fields = ['name', 'description', 'categories', 'tags', 'sale_price']
|
||
|
||
def get_queryset(self):
|
||
# Только временные комплекты можно преобразовать
|
||
return super().get_queryset().filter(is_temporary=True)
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
context['kit_items'] = self.object.kit_items.all().select_related('product', 'variant_group')
|
||
context['productkit_photos'] = self.object.photos.all().order_by('order', 'created_at')
|
||
return context
|
||
|
||
def form_valid(self, form):
|
||
# Преобразуем в постоянный
|
||
if self.object.make_permanent():
|
||
messages.success(
|
||
self.request,
|
||
f'Комплект "{self.object.name}" преобразован в постоянный и теперь доступен в каталоге!'
|
||
)
|
||
else:
|
||
messages.warning(self.request, f'Комплект "{self.object.name}" уже является постоянным.')
|
||
|
||
return super().form_valid(form)
|
||
|
||
def get_success_url(self):
|
||
return reverse_lazy('products:productkit-detail', kwargs={'pk': self.object.pk})
|