Рефакторинг системы вариативных товаров и справочник атрибутов

Основные изменения:
- Переименование ConfigurableKitProduct → ConfigurableProduct
- Добавлена поддержка Product как варианта (не только ProductKit)
- Создан справочник атрибутов (ProductAttribute, ProductAttributeValue)
- CRUD для управления атрибутами с inline редактированием значений
- Пересозданы миграции с нуля для всех приложений
- Добавлена ссылка на атрибуты в навигацию

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 01:44:34 +03:00
parent 277a514a82
commit 79ff523adb
36 changed files with 1597 additions and 951 deletions

View File

@@ -1,5 +1,5 @@
"""
CRUD представления для вариативных товаров (ConfigurableKitProduct).
CRUD представления для вариативных товаров (ConfigurableProduct).
"""
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
@@ -13,18 +13,18 @@ from django.contrib.auth.decorators import login_required
from django.db import transaction
from user_roles.mixins import ManagerOwnerRequiredMixin
from ..models import ConfigurableKitProduct, ConfigurableKitOption, ProductKit, ConfigurableKitProductAttribute
from ..models import ConfigurableProduct, ConfigurableProductOption, ProductKit, ConfigurableProductAttribute
from ..forms import (
ConfigurableKitProductForm,
ConfigurableKitOptionFormSetCreate,
ConfigurableKitOptionFormSetUpdate,
ConfigurableKitProductAttributeFormSetCreate,
ConfigurableKitProductAttributeFormSetUpdate
ConfigurableProductForm,
ConfigurableProductOptionFormSetCreate,
ConfigurableProductOptionFormSetUpdate,
ConfigurableProductAttributeFormSetCreate,
ConfigurableProductAttributeFormSetUpdate
)
class ConfigurableKitProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, ListView):
model = ConfigurableKitProduct
class ConfigurableProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, ListView):
model = ConfigurableProduct
template_name = 'products/configurablekit_list.html'
context_object_name = 'configurable_kits'
paginate_by = 20
@@ -33,7 +33,7 @@ class ConfigurableKitProductListView(LoginRequiredMixin, ManagerOwnerRequiredMix
queryset = super().get_queryset().prefetch_related(
Prefetch(
'options',
queryset=ConfigurableKitOption.objects.select_related('kit')
queryset=ConfigurableProductOption.objects.select_related('kit')
)
)
@@ -80,8 +80,8 @@ class ConfigurableKitProductListView(LoginRequiredMixin, ManagerOwnerRequiredMix
return context
class ConfigurableKitProductDetailView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DetailView):
model = ConfigurableKitProduct
class ConfigurableProductDetailView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DetailView):
model = ConfigurableProduct
template_name = 'products/configurablekit_detail.html'
context_object_name = 'configurable_kit'
@@ -89,7 +89,7 @@ class ConfigurableKitProductDetailView(LoginRequiredMixin, ManagerOwnerRequiredM
return super().get_queryset().prefetch_related(
Prefetch(
'options',
queryset=ConfigurableKitOption.objects.select_related('kit').order_by('id')
queryset=ConfigurableProductOption.objects.select_related('kit').order_by('id')
),
'parent_attributes'
)
@@ -104,9 +104,9 @@ class ConfigurableKitProductDetailView(LoginRequiredMixin, ManagerOwnerRequiredM
return context
class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, CreateView):
model = ConfigurableKitProduct
form_class = ConfigurableKitProductForm
class ConfigurableProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, CreateView):
model = ConfigurableProduct
form_class = ConfigurableProductForm
template_name = 'products/configurablekit_form.html'
def get_context_data(self, **kwargs):
@@ -116,12 +116,12 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM
if 'option_formset' in kwargs:
context['option_formset'] = kwargs['option_formset']
elif self.request.POST:
context['option_formset'] = ConfigurableKitOptionFormSetCreate(
context['option_formset'] = ConfigurableProductOptionFormSetCreate(
self.request.POST,
prefix='options'
)
else:
context['option_formset'] = ConfigurableKitOptionFormSetCreate(
context['option_formset'] = ConfigurableProductOptionFormSetCreate(
prefix='options'
)
@@ -129,12 +129,12 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM
if 'attribute_formset' in kwargs:
context['attribute_formset'] = kwargs['attribute_formset']
elif self.request.POST:
context['attribute_formset'] = ConfigurableKitProductAttributeFormSetCreate(
context['attribute_formset'] = ConfigurableProductAttributeFormSetCreate(
self.request.POST,
prefix='attributes'
)
else:
context['attribute_formset'] = ConfigurableKitProductAttributeFormSetCreate(
context['attribute_formset'] = ConfigurableProductAttributeFormSetCreate(
prefix='attributes'
)
@@ -147,14 +147,14 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM
return context
def form_valid(self, form):
from products.models.kits import ConfigurableKitOptionAttribute
from products.models.kits import ConfigurableProductOptionAttribute
# Пересоздаём formsets с POST данными
option_formset = ConfigurableKitOptionFormSetCreate(
option_formset = ConfigurableProductOptionFormSetCreate(
self.request.POST,
prefix='options'
)
attribute_formset = ConfigurableKitProductAttributeFormSetCreate(
attribute_formset = ConfigurableProductAttributeFormSetCreate(
self.request.POST,
prefix='attributes'
)
@@ -212,7 +212,7 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM
# Сохраняем выбранные атрибуты для этого варианта
for field_name, field_value in option_form.cleaned_data.items():
if field_name.startswith('attribute_') and field_value:
ConfigurableKitOptionAttribute.objects.create(
ConfigurableProductOptionAttribute.objects.create(
option=option,
attribute=field_value
)
@@ -250,7 +250,7 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM
from products.models.kits import ProductKit
# Сначала удаляем все старые атрибуты
ConfigurableKitProductAttribute.objects.filter(parent=self.object).delete()
ConfigurableProductAttribute.objects.filter(parent=self.object).delete()
# Получаем количество карточек параметров
total_forms_str = self.request.POST.get('attributes-TOTAL_FORMS', '0')
@@ -293,7 +293,7 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM
except (json.JSONDecodeError, TypeError):
kit_ids = []
# Создаём ConfigurableKitProductAttribute для каждого значения
# Создаём ConfigurableProductAttribute для каждого значения
for value_idx, value in enumerate(values):
if value and value.strip():
# Получаем соответствующий ID комплекта
@@ -317,7 +317,7 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM
# Комплект не найден - создаём без привязки
pass
ConfigurableKitProductAttribute.objects.create(**create_kwargs)
ConfigurableProductAttribute.objects.create(**create_kwargs)
def _validate_variant_kits(self, option_formset):
"""
@@ -376,9 +376,9 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM
return reverse_lazy('products:configurablekit-detail', kwargs={'pk': self.object.pk})
class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, UpdateView):
model = ConfigurableKitProduct
form_class = ConfigurableKitProductForm
class ConfigurableProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, UpdateView):
model = ConfigurableProduct
form_class = ConfigurableProductForm
template_name = 'products/configurablekit_form.html'
def get_context_data(self, **kwargs):
@@ -388,13 +388,13 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM
if 'option_formset' in kwargs:
context['option_formset'] = kwargs['option_formset']
elif self.request.POST:
context['option_formset'] = ConfigurableKitOptionFormSetUpdate(
context['option_formset'] = ConfigurableProductOptionFormSetUpdate(
self.request.POST,
instance=self.object,
prefix='options'
)
else:
context['option_formset'] = ConfigurableKitOptionFormSetUpdate(
context['option_formset'] = ConfigurableProductOptionFormSetUpdate(
instance=self.object,
prefix='options'
)
@@ -403,13 +403,13 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM
if 'attribute_formset' in kwargs:
context['attribute_formset'] = kwargs['attribute_formset']
elif self.request.POST:
context['attribute_formset'] = ConfigurableKitProductAttributeFormSetUpdate(
context['attribute_formset'] = ConfigurableProductAttributeFormSetUpdate(
self.request.POST,
instance=self.object,
prefix='attributes'
)
else:
context['attribute_formset'] = ConfigurableKitProductAttributeFormSetUpdate(
context['attribute_formset'] = ConfigurableProductAttributeFormSetUpdate(
instance=self.object,
prefix='attributes'
)
@@ -423,15 +423,15 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM
return context
def form_valid(self, form):
from products.models.kits import ConfigurableKitOptionAttribute
from products.models.kits import ConfigurableProductOptionAttribute
# Пересоздаём formsets с POST данными
option_formset = ConfigurableKitOptionFormSetUpdate(
option_formset = ConfigurableProductOptionFormSetUpdate(
self.request.POST,
instance=self.object,
prefix='options'
)
attribute_formset = ConfigurableKitProductAttributeFormSetUpdate(
attribute_formset = ConfigurableProductAttributeFormSetUpdate(
self.request.POST,
instance=self.object,
prefix='attributes'
@@ -489,7 +489,7 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM
# Сохраняем выбранные атрибуты для этого варианта
for field_name, field_value in option_form.cleaned_data.items():
if field_name.startswith('attribute_') and field_value:
ConfigurableKitOptionAttribute.objects.create(
ConfigurableProductOptionAttribute.objects.create(
option=option,
attribute=field_value
)
@@ -527,7 +527,7 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM
from products.models.kits import ProductKit
# Сначала удаляем все старые атрибуты
ConfigurableKitProductAttribute.objects.filter(parent=self.object).delete()
ConfigurableProductAttribute.objects.filter(parent=self.object).delete()
# Получаем количество карточек параметров
total_forms_str = self.request.POST.get('attributes-TOTAL_FORMS', '0')
@@ -570,7 +570,7 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM
except (json.JSONDecodeError, TypeError):
kit_ids = []
# Создаём ConfigurableKitProductAttribute для каждого значения
# Создаём ConfigurableProductAttribute для каждого значения
for value_idx, value in enumerate(values):
if value and value.strip():
# Получаем соответствующий ID комплекта
@@ -594,7 +594,7 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM
# Комплект не найден - создаём без привязки
pass
ConfigurableKitProductAttribute.objects.create(**create_kwargs)
ConfigurableProductAttribute.objects.create(**create_kwargs)
def _validate_variant_kits(self, option_formset):
"""
@@ -653,8 +653,8 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM
return reverse_lazy('products:configurablekit-detail', kwargs={'pk': self.object.pk})
class ConfigurableKitProductDeleteView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DeleteView):
model = ConfigurableKitProduct
class ConfigurableProductDeleteView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DeleteView):
model = ConfigurableProduct
template_name = 'products/configurablekit_confirm_delete.html'
success_url = reverse_lazy('products:configurablekit-list')
@@ -671,7 +671,7 @@ def add_option_to_configurable(request, pk):
"""
Добавить вариант (комплект) к вариативному товару.
"""
configurable = get_object_or_404(ConfigurableKitProduct, pk=pk)
configurable = get_object_or_404(ConfigurableProduct, pk=pk)
kit_id = request.POST.get('kit_id')
attributes = request.POST.get('attributes', '')
is_default = request.POST.get('is_default') == 'true'
@@ -685,15 +685,15 @@ def add_option_to_configurable(request, pk):
return JsonResponse({'success': False, 'error': 'Комплект не найден'}, status=404)
# Проверяем, не добавлен ли уже этот комплект
if ConfigurableKitOption.objects.filter(parent=configurable, kit=kit).exists():
if ConfigurableProductOption.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)
ConfigurableProductOption.objects.filter(parent=configurable, is_default=True).update(is_default=False)
# Создаём вариант
option = ConfigurableKitOption.objects.create(
option = ConfigurableProductOption.objects.create(
parent=configurable,
kit=kit,
attributes=attributes,
@@ -720,8 +720,8 @@ 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)
configurable = get_object_or_404(ConfigurableProduct, pk=pk)
option = get_object_or_404(ConfigurableProductOption, pk=option_id, parent=configurable)
option.delete()
@@ -734,11 +734,11 @@ 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)
configurable = get_object_or_404(ConfigurableProduct, pk=pk)
option = get_object_or_404(ConfigurableProductOption, pk=option_id, parent=configurable)
# Снимаем флаг со всех других
ConfigurableKitOption.objects.filter(parent=configurable).update(is_default=False)
ConfigurableProductOption.objects.filter(parent=configurable).update(is_default=False)
# Устанавливаем текущий
option.is_default = True