Добавлена модель атрибутов для вариативных товаров (ConfigurableKitProductAttribute)

- Создана модель ConfigurableKitProductAttribute с полями name, option, position, visible
- Добавлены формы и formsets для управления атрибутами родительского товара
- Обновлены CRUD представления для работы с атрибутами (создание/редактирование)
- Добавлен блок атрибутов в шаблоны создания/редактирования
- Обновлена страница детального просмотра с отображением атрибутов товара
- Добавлен JavaScript для динамического добавления форм атрибутов
- Реализована валидация дубликатов атрибутов в formset
- Атрибуты сохраняются в transaction.atomic() вместе с вариантами

Теперь можно определять схему атрибутов для экспорта на WooCommerce без использования JSON или ID, только name и option.
This commit is contained in:
2025-11-18 09:24:49 +03:00
parent bdea6b5398
commit c4260f6b1c
15 changed files with 2017 additions and 2 deletions

View File

@@ -0,0 +1,367 @@
"""
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):
# Пересоздаём 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
option_formset.save()
# Сохраняем атрибуты родителя
attribute_formset.instance = self.object
attribute_formset.save()
messages.success(self.request, f'Вариативный товар "{self.object.name}" успешно создан!')
return super().form_valid(form)
except Exception as e:
messages.error(self.request, f'Ошибка при сохранении: {str(e)}')
return self.form_invalid(form)
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):
# Пересоздаём 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()
# Сохраняем варианты
option_formset.save()
# Сохраняем атрибуты родителя
attribute_formset.save()
messages.success(self.request, f'Вариативный товар "{self.object.name}" успешно обновлён!')
return super().form_valid(form)
except Exception as e:
messages.error(self.request, f'Ошибка при сохранении: {str(e)}')
return self.form_invalid(form)
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': 'Вариант установлен как по умолчанию'})