""" CRUD представления для вариативных товаров (ConfigurableKitProduct). """ from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.views.generic import ListView, CreateView, DetailView, UpdateView, DeleteView from django.urls import reverse_lazy from django.db.models import Q, Prefetch from django.http import JsonResponse from django.shortcuts import get_object_or_404 from django.views.decorators.http import require_POST from django.contrib.auth.decorators import login_required from django.db import transaction from ..models import ConfigurableKitProduct, ConfigurableKitOption, ProductKit, ConfigurableKitProductAttribute from ..forms import ( ConfigurableKitProductForm, ConfigurableKitOptionFormSetCreate, ConfigurableKitOptionFormSetUpdate, ConfigurableKitProductAttributeFormSetCreate, ConfigurableKitProductAttributeFormSetUpdate ) class ConfigurableKitProductListView(LoginRequiredMixin, ListView): model = ConfigurableKitProduct template_name = 'products/configurablekit_list.html' context_object_name = 'configurable_kits' paginate_by = 20 def get_queryset(self): queryset = super().get_queryset().prefetch_related( Prefetch( 'options', queryset=ConfigurableKitOption.objects.select_related('kit') ) ) # Поиск 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) ) # Фильтр по статусу status_filter = self.request.GET.get('status') if status_filter: queryset = queryset.filter(status=status_filter) return queryset.order_by('-created_at') def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) # Данные для фильтров context['filters'] = { 'current': { 'search': self.request.GET.get('search', ''), 'status': self.request.GET.get('status', ''), } } # Кнопки действий action_buttons = [] if self.request.user.has_perm('products.add_configurablekitproduct'): action_buttons.append({ 'url': reverse_lazy('products:configurablekit-create'), 'text': 'Создать вариативный товар', 'class': 'btn-primary', 'icon': 'plus-circle' }) context['action_buttons'] = action_buttons return context class ConfigurableKitProductDetailView(LoginRequiredMixin, DetailView): model = ConfigurableKitProduct template_name = 'products/configurablekit_detail.html' context_object_name = 'configurable_kit' def get_queryset(self): return super().get_queryset().prefetch_related( Prefetch( 'options', queryset=ConfigurableKitOption.objects.select_related('kit').order_by('id') ), 'parent_attributes' ) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) # Добавляем доступные комплекты для выбора (активные, не временные) context['available_kits'] = ProductKit.objects.filter( status='active', is_temporary=False ).order_by('name') return context class ConfigurableKitProductCreateView(LoginRequiredMixin, CreateView): model = ConfigurableKitProduct form_class = ConfigurableKitProductForm template_name = 'products/configurablekit_form.html' def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) # Formset для вариантов if 'option_formset' in kwargs: context['option_formset'] = kwargs['option_formset'] elif self.request.POST: context['option_formset'] = ConfigurableKitOptionFormSetCreate( self.request.POST, prefix='options' ) else: context['option_formset'] = ConfigurableKitOptionFormSetCreate( prefix='options' ) # Formset для атрибутов родителя if 'attribute_formset' in kwargs: context['attribute_formset'] = kwargs['attribute_formset'] elif self.request.POST: context['attribute_formset'] = ConfigurableKitProductAttributeFormSetCreate( self.request.POST, prefix='attributes' ) else: context['attribute_formset'] = ConfigurableKitProductAttributeFormSetCreate( prefix='attributes' ) return context def form_valid(self, form): from products.models.kits import ConfigurableKitOptionAttribute # Пересоздаём formsets с POST данными option_formset = ConfigurableKitOptionFormSetCreate( self.request.POST, prefix='options' ) attribute_formset = ConfigurableKitProductAttributeFormSetCreate( self.request.POST, prefix='attributes' ) if not form.is_valid(): messages.error(self.request, 'Пожалуйста, исправьте ошибки в основной форме.') return self.form_invalid(form) if not option_formset.is_valid(): messages.error(self.request, 'Пожалуйста, исправьте ошибки в вариантах.') return self.render_to_response(self.get_context_data(form=form, option_formset=option_formset, attribute_formset=attribute_formset)) if not attribute_formset.is_valid(): messages.error(self.request, 'Пожалуйста, исправьте ошибки в атрибутах.') return self.render_to_response(self.get_context_data(form=form, option_formset=option_formset, attribute_formset=attribute_formset)) try: with transaction.atomic(): # Сохраняем основную форму self.object = form.save() # Сохраняем варианты И их атрибуты option_formset.instance = self.object for option_form in option_formset: if option_form.cleaned_data and not self._should_delete_form(option_form, option_formset): # Сохраняем сам вариант option = option_form.save(commit=False) option.parent = self.object option.save() # Очищаем старые атрибуты варианта (если редактируем) option.attributes_set.all().delete() # Сохраняем выбранные атрибуты для этого варианта for field_name, field_value in option_form.cleaned_data.items(): if field_name.startswith('attribute_') and field_value: ConfigurableKitOptionAttribute.objects.create( option=option, attribute=field_value ) elif self._should_delete_form(option_form, option_formset): # Удаляем вариант если помечен для удаления if option_form.instance.pk: option_form.instance.delete() # Сохраняем атрибуты родителя - новый интерфейс # Карточный интерфейс: значения приходят как инлайн input'ы self._save_attributes_from_cards() messages.success(self.request, f'Вариативный товар "{self.object.name}" успешно создан!') return super().form_valid(form) except Exception as e: messages.error(self.request, f'Ошибка при сохранении: {str(e)}') import traceback traceback.print_exc() return self.form_invalid(form) def _save_attributes_from_cards(self): """ Сохранить атрибуты из карточного интерфейса. Каждая карточка содержит: - attributes-X-name: название параметра - attributes-X-position: позиция - attributes-X-visible: видимость - attributes-X-DELETE: помечен ли для удаления Значения приходят как инлайн input'ы внутри параметра: - Читаем из POST все 'parameter-value-input' инпуты """ # Сначала удаляем все старые атрибуты ConfigurableKitProductAttribute.objects.filter(parent=self.object).delete() # Получаем количество карточек параметров total_forms_str = self.request.POST.get('attributes-TOTAL_FORMS', '0') try: total_forms = int(total_forms_str) except (ValueError, TypeError): total_forms = 0 # Обрабатываем каждую карточку параметра for idx in range(total_forms): # Пропускаем если карточка помечена для удаления delete_key = f'attributes-{idx}-DELETE' if delete_key in self.request.POST and self.request.POST.get(delete_key): continue # Получаем название параметра name = self.request.POST.get(f'attributes-{idx}-name', '').strip() if not name: continue position = self.request.POST.get(f'attributes-{idx}-position', idx) try: position = int(position) except (ValueError, TypeError): position = idx visible = self.request.POST.get(f'attributes-{idx}-visible') == 'on' # Получаем все значения параметра из POST # Они приходят как data в JSON при отправке формы # Нужно их извлечь из скрытых input'ов или динамически созданных # Способ 1: Получаем все значения из POST которые относятся к этому параметру # Шаблон: 'attr_{idx}_value_{value_idx}' или просто читаем из скрытого JSON поля # Пока используем упрощённый подход: # JavaScript должен будет отправить значения в скрытом поле JSON # Формат: attributes-X-values = ["value1", "value2", "value3"] values_json = self.request.POST.get(f'attributes-{idx}-values', '[]') import json try: values = json.loads(values_json) except (json.JSONDecodeError, TypeError): values = [] # Создаём ConfigurableKitProductAttribute для каждого значения for value_idx, value in enumerate(values): if value and value.strip(): ConfigurableKitProductAttribute.objects.create( parent=self.object, name=name, option=value.strip(), position=position, visible=visible ) @staticmethod def _should_delete_form(form, formset): """Проверить должна ли форма быть удалена""" return formset.can_delete and form.cleaned_data.get(formset.deletion_field.name, False) def get_success_url(self): return reverse_lazy('products:configurablekit-detail', kwargs={'pk': self.object.pk}) class ConfigurableKitProductUpdateView(LoginRequiredMixin, UpdateView): model = ConfigurableKitProduct form_class = ConfigurableKitProductForm template_name = 'products/configurablekit_form.html' def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) # Formset для вариантов if 'option_formset' in kwargs: context['option_formset'] = kwargs['option_formset'] elif self.request.POST: context['option_formset'] = ConfigurableKitOptionFormSetUpdate( self.request.POST, instance=self.object, prefix='options' ) else: context['option_formset'] = ConfigurableKitOptionFormSetUpdate( instance=self.object, prefix='options' ) # Formset для атрибутов родителя if 'attribute_formset' in kwargs: context['attribute_formset'] = kwargs['attribute_formset'] elif self.request.POST: context['attribute_formset'] = ConfigurableKitProductAttributeFormSetUpdate( self.request.POST, instance=self.object, prefix='attributes' ) else: context['attribute_formset'] = ConfigurableKitProductAttributeFormSetUpdate( instance=self.object, prefix='attributes' ) return context def form_valid(self, form): from products.models.kits import ConfigurableKitOptionAttribute # Пересоздаём formsets с POST данными option_formset = ConfigurableKitOptionFormSetUpdate( self.request.POST, instance=self.object, prefix='options' ) attribute_formset = ConfigurableKitProductAttributeFormSetUpdate( self.request.POST, instance=self.object, prefix='attributes' ) if not form.is_valid(): messages.error(self.request, 'Пожалуйста, исправьте ошибки в основной форме.') return self.form_invalid(form) if not option_formset.is_valid(): messages.error(self.request, 'Пожалуйста, исправьте ошибки в вариантах.') return self.render_to_response(self.get_context_data(form=form, option_formset=option_formset, attribute_formset=attribute_formset)) if not attribute_formset.is_valid(): messages.error(self.request, 'Пожалуйста, исправьте ошибки в атрибутах.') return self.render_to_response(self.get_context_data(form=form, option_formset=option_formset, attribute_formset=attribute_formset)) try: with transaction.atomic(): # Сохраняем основную форму self.object = form.save() # Сохраняем варианты И их атрибуты for option_form in option_formset: if option_form.cleaned_data and not self._should_delete_form(option_form, option_formset): # Сохраняем сам вариант option = option_form.save(commit=False) option.parent = self.object option.save() # Очищаем старые атрибуты варианта option.attributes_set.all().delete() # Сохраняем выбранные атрибуты для этого варианта for field_name, field_value in option_form.cleaned_data.items(): if field_name.startswith('attribute_') and field_value: ConfigurableKitOptionAttribute.objects.create( option=option, attribute=field_value ) elif self._should_delete_form(option_form, option_formset): # Удаляем вариант если помечен для удаления if option_form.instance.pk: option_form.instance.delete() # Сохраняем атрибуты родителя - новый интерфейс # Карточный интерфейс: значения приходят как инлайн input'ы self._save_attributes_from_cards() messages.success(self.request, f'Вариативный товар "{self.object.name}" успешно обновлён!') return super().form_valid(form) except Exception as e: messages.error(self.request, f'Ошибка при сохранении: {str(e)}') import traceback traceback.print_exc() return self.form_invalid(form) def _save_attributes_from_cards(self): """ Сохранить атрибуты из карточного интерфейса. См. копию этого метода в ConfigurableKitProductCreateView для подробностей. """ # Сначала удаляем все старые атрибуты ConfigurableKitProductAttribute.objects.filter(parent=self.object).delete() # Получаем количество карточек параметров total_forms_str = self.request.POST.get('attributes-TOTAL_FORMS', '0') try: total_forms = int(total_forms_str) except (ValueError, TypeError): total_forms = 0 # Обрабатываем каждую карточку параметра for idx in range(total_forms): # Пропускаем если карточка помечена для удаления delete_key = f'attributes-{idx}-DELETE' if delete_key in self.request.POST and self.request.POST.get(delete_key): continue # Получаем название параметра name = self.request.POST.get(f'attributes-{idx}-name', '').strip() if not name: continue position = self.request.POST.get(f'attributes-{idx}-position', idx) try: position = int(position) except (ValueError, TypeError): position = idx visible = self.request.POST.get(f'attributes-{idx}-visible') == 'on' # Получаем все значения параметра из POST values_json = self.request.POST.get(f'attributes-{idx}-values', '[]') import json try: values = json.loads(values_json) except (json.JSONDecodeError, TypeError): values = [] # Создаём ConfigurableKitProductAttribute для каждого значения for value_idx, value in enumerate(values): if value and value.strip(): ConfigurableKitProductAttribute.objects.create( parent=self.object, name=name, option=value.strip(), position=position, visible=visible ) @staticmethod def _should_delete_form(form, formset): """Проверить должна ли форма быть удалена""" return formset.can_delete and form.cleaned_data.get(formset.deletion_field.name, False) def get_success_url(self): return reverse_lazy('products:configurablekit-detail', kwargs={'pk': self.object.pk}) class ConfigurableKitProductDeleteView(LoginRequiredMixin, DeleteView): model = ConfigurableKitProduct template_name = 'products/configurablekit_confirm_delete.html' success_url = reverse_lazy('products:configurablekit-list') def form_valid(self, form): messages.success(self.request, f'Вариативный товар "{self.object.name}" успешно удалён!') return super().form_valid(form) # API для управления вариантами @login_required @require_POST def add_option_to_configurable(request, pk): """ Добавить вариант (комплект) к вариативному товару. """ configurable = get_object_or_404(ConfigurableKitProduct, pk=pk) kit_id = request.POST.get('kit_id') attributes = request.POST.get('attributes', '') is_default = request.POST.get('is_default') == 'true' if not kit_id: return JsonResponse({'success': False, 'error': 'Не указан комплект'}, status=400) try: kit = ProductKit.objects.get(pk=kit_id) except ProductKit.DoesNotExist: return JsonResponse({'success': False, 'error': 'Комплект не найден'}, status=404) # Проверяем, не добавлен ли уже этот комплект if ConfigurableKitOption.objects.filter(parent=configurable, kit=kit).exists(): return JsonResponse({'success': False, 'error': 'Этот комплект уже добавлен как вариант'}, status=400) # Если is_default=True, снимаем флаг с других if is_default: ConfigurableKitOption.objects.filter(parent=configurable, is_default=True).update(is_default=False) # Создаём вариант option = ConfigurableKitOption.objects.create( parent=configurable, kit=kit, attributes=attributes, is_default=is_default ) return JsonResponse({ 'success': True, 'option': { 'id': option.id, 'kit_id': kit.id, 'kit_name': kit.name, 'kit_sku': kit.sku or '—', 'kit_price': str(kit.actual_price), 'attributes': option.attributes or '—', 'is_default': option.is_default, } }) @login_required @require_POST def remove_option_from_configurable(request, pk, option_id): """ Удалить вариант из вариативного товара. """ configurable = get_object_or_404(ConfigurableKitProduct, pk=pk) option = get_object_or_404(ConfigurableKitOption, pk=option_id, parent=configurable) option.delete() return JsonResponse({'success': True, 'message': 'Вариант удалён'}) @login_required @require_POST def set_option_as_default(request, pk, option_id): """ Установить вариант как по умолчанию. """ configurable = get_object_or_404(ConfigurableKitProduct, pk=pk) option = get_object_or_404(ConfigurableKitOption, pk=option_id, parent=configurable) # Снимаем флаг со всех других ConfigurableKitOption.objects.filter(parent=configurable).update(is_default=False) # Устанавливаем текущий option.is_default = True option.save(update_fields=['is_default']) return JsonResponse({'success': True, 'message': 'Вариант установлен как по умолчанию'})