Files
octopus/myproject/products/views/configurablekit_views.py
Andrey Smakotin def795f0ad Implement card-based interface for ConfigurableKitProduct attributes
This commit introduces a new user-friendly interface for managing product attributes:

1. **Form Changes** (products/forms.py):
   - Removed 'option' field from ConfigurableKitOptionForm (values now inline)
   - Updated ConfigurableKitProductAttributeFormSetCreate to only include name, position, visible
   - Updated BaseConfigurableKitProductAttributeFormSet validation for new structure

2. **Template Updates** (products/templates/products/configurablekit_form.html):
   - Replaced row-based attribute interface with card-based design
   - Each card contains:
     - Parameter name field
     - Position field
     - Visibility toggle
     - Inline value inputs with add/remove buttons
   - "Add parameter" button creates new cards
   - "Add value" button adds inline value inputs

3. **JavaScript Enhancements**:
   - addValueField(): Creates new value input with delete button
   - initAddValueBtn(): Initializes add value button for each card
   - addParameterBtn: Dynamically generates new parameter cards
   - serializeAttributeValues(): Converts inline values to JSON for POST submission
   - Form submission intercept to serialize data before sending

4. **View Updates** (products/views/configurablekit_views.py):
   - Both Create and Update views now have _save_attributes_from_cards() method
   - Reads attributes-X-values JSON from POST data
   - Creates ConfigurableKitProductAttribute for each parameter+value combination
   - Handles parameter deletion and visibility toggling

**Key Features**:
✓ One-time parameter name entry with multiple inline values
✓ Add/remove values without reloading page
✓ Add/remove entire parameters with one click
✓ No database changes required
✓ Better UX: card layout more intuitive than rows
✓ Proper JSON serialization for value transmission

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 20:54:14 +03:00

553 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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': 'Вариант установлен как по умолчанию'})