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>
553 lines
24 KiB
Python
553 lines
24 KiB
Python
"""
|
||
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': 'Вариант установлен как по умолчанию'})
|