""" 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:products-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 elif key.endswith('-sales_unit') and post_data[key]: value = post_data[key] if '_' in value: # Извлекаем числовой ID из "sales_unit_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, ProductSalesUnit selected_products = {} selected_variants = {} selected_sales_units = {} 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 if '-sales_unit' in key and value: try: sales_unit = ProductSalesUnit.objects.select_related('product').get(id=value) text = f"{sales_unit.name} ({sales_unit.product.name})" # Получаем actual_price: приоритет sale_price > price actual_price = sales_unit.sale_price if sales_unit.sale_price else sales_unit.price selected_sales_units[key] = { 'id': sales_unit.id, 'text': text, 'price': str(sales_unit.price) if sales_unit.price else None, 'actual_price': str(actual_price) if actual_price else '0' } except ProductSalesUnit.DoesNotExist: pass context['selected_products'] = selected_products context['selected_variants'] = selected_variants context['selected_sales_units'] = selected_sales_units 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 elif key.endswith('-sales_unit') and post_data[key]: value = post_data[key] if '_' in value: # Извлекаем числовой ID из "sales_unit_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 from ..models import Product, ProductVariantGroup, ProductSalesUnit selected_products = {} selected_variants = {} selected_sales_units = {} 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 if '-sales_unit' in key and value: try: # Очищаем ID от префикса если есть numeric_value = value.split('_')[1] if '_' in value else value sales_unit = ProductSalesUnit.objects.select_related('product').get(id=numeric_value) text = f"{sales_unit.name} ({sales_unit.product.name})" # Получаем actual_price: приоритет sale_price > price actual_price = sales_unit.sale_price if sales_unit.sale_price else sales_unit.price selected_sales_units[key] = { 'id': sales_unit.id, 'text': text, 'price': str(sales_unit.price) if sales_unit.price else None, 'actual_price': str(actual_price) if actual_price else '0' } except ProductSalesUnit.DoesNotExist: pass context['selected_products'] = selected_products context['selected_variants'] = selected_variants context['selected_sales_units'] = selected_sales_units else: context['kititem_formset'] = KitItemFormSetUpdate(instance=self.object, prefix='kititem') # Подготавливаем данные для предзагрузки в Select2 from ..models import Product, ProductVariantGroup, ProductSalesUnit selected_products = {} selected_variants = {} selected_sales_units = {} 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.sales_unit: sales_unit = item.sales_unit text = f"{sales_unit.name} ({sales_unit.product.name})" actual_price = sales_unit.sale_price if sales_unit.sale_price else sales_unit.price selected_sales_units[f"{form_prefix}-sales_unit"] = { 'id': sales_unit.id, 'text': text, 'price': str(sales_unit.price) if sales_unit.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['selected_sales_units'] = selected_sales_units 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:products-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})