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

Основные изменения:
- Переименование 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

@@ -80,18 +80,30 @@ from .tag_views import (
ProductTagDeleteView,
)
# CRUD представления для ConfigurableKitProduct
# CRUD представления для ConfigurableProduct
from .configurablekit_views import (
ConfigurableKitProductListView,
ConfigurableKitProductCreateView,
ConfigurableKitProductDetailView,
ConfigurableKitProductUpdateView,
ConfigurableKitProductDeleteView,
ConfigurableProductListView,
ConfigurableProductCreateView,
ConfigurableProductDetailView,
ConfigurableProductUpdateView,
ConfigurableProductDeleteView,
add_option_to_configurable,
remove_option_from_configurable,
set_option_as_default,
)
# CRUD представления для ProductAttribute (справочник атрибутов)
from .attribute_views import (
ProductAttributeListView,
ProductAttributeCreateView,
ProductAttributeDetailView,
ProductAttributeUpdateView,
ProductAttributeDeleteView,
create_attribute_api,
add_attribute_value_api,
delete_attribute_value_api,
)
# API представления
from .api_views import search_products_and_variants, validate_kit_cost, create_temporary_kit_api, create_tag_api
@@ -162,16 +174,26 @@ __all__ = [
'ProductTagUpdateView',
'ProductTagDeleteView',
# ConfigurableKitProduct CRUD
'ConfigurableKitProductListView',
'ConfigurableKitProductCreateView',
'ConfigurableKitProductDetailView',
'ConfigurableKitProductUpdateView',
'ConfigurableKitProductDeleteView',
# ConfigurableProduct CRUD
'ConfigurableProductListView',
'ConfigurableProductCreateView',
'ConfigurableProductDetailView',
'ConfigurableProductUpdateView',
'ConfigurableProductDeleteView',
'add_option_to_configurable',
'remove_option_from_configurable',
'set_option_as_default',
# ProductAttribute CRUD
'ProductAttributeListView',
'ProductAttributeCreateView',
'ProductAttributeDetailView',
'ProductAttributeUpdateView',
'ProductAttributeDeleteView',
'create_attribute_api',
'add_attribute_value_api',
'delete_attribute_value_api',
# API
'search_products_and_variants',
'validate_kit_cost',

View File

@@ -0,0 +1,247 @@
"""
CRUD представления для справочника атрибутов товаров (ProductAttribute, ProductAttributeValue).
"""
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import ListView, CreateView, DetailView, UpdateView, DeleteView
from django.urls import reverse_lazy
from django.db.models import Q, Count
from django.db import IntegrityError
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from django.contrib.auth.decorators import login_required
import json
from ..models import ProductAttribute, ProductAttributeValue
from ..forms import ProductAttributeForm, ProductAttributeValueFormSet
class ProductAttributeListView(LoginRequiredMixin, ListView):
"""Список всех атрибутов с поиском"""
model = ProductAttribute
template_name = 'products/attribute_list.html'
context_object_name = 'attributes'
paginate_by = 20
def get_queryset(self):
queryset = super().get_queryset()
# Аннотируем количество значений для каждого атрибута
queryset = queryset.annotate(
num_values=Count('values', distinct=True)
)
# Поиск по названию и slug
search_query = self.request.GET.get('search')
if search_query:
queryset = queryset.filter(
Q(name__icontains=search_query) |
Q(slug__icontains=search_query)
)
return queryset.order_by('position', 'name')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['search_query'] = self.request.GET.get('search', '')
return context
class ProductAttributeDetailView(LoginRequiredMixin, DetailView):
"""Детальная информация об атрибуте с его значениями"""
model = ProductAttribute
template_name = 'products/attribute_detail.html'
context_object_name = 'attribute'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
attribute = self.get_object()
# Получаем все значения атрибута
context['values'] = attribute.values.all().order_by('position', 'value')
return context
class ProductAttributeCreateView(LoginRequiredMixin, CreateView):
"""Создание нового атрибута с inline значениями"""
model = ProductAttribute
form_class = ProductAttributeForm
template_name = 'products/attribute_form.html'
success_url = reverse_lazy('products:attribute-list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if self.request.POST:
context['value_formset'] = ProductAttributeValueFormSet(self.request.POST, instance=self.object)
else:
context['value_formset'] = ProductAttributeValueFormSet(instance=self.object)
return context
def form_valid(self, form):
context = self.get_context_data()
value_formset = context['value_formset']
try:
self.object = form.save()
if value_formset.is_valid():
value_formset.instance = self.object
value_formset.save()
else:
return self.form_invalid(form)
messages.success(self.request, f'Атрибут "{self.object.name}" успешно создан.')
return super().form_valid(form)
except IntegrityError as e:
error_msg = str(e).lower()
if 'unique' in error_msg:
messages.error(
self.request,
f'Ошибка: атрибут с таким названием уже существует.'
)
else:
messages.error(
self.request,
'Ошибка при сохранении атрибута. Пожалуйста, проверьте введённые данные.'
)
return self.form_invalid(form)
class ProductAttributeUpdateView(LoginRequiredMixin, UpdateView):
"""Редактирование существующего атрибута с inline значениями"""
model = ProductAttribute
form_class = ProductAttributeForm
template_name = 'products/attribute_form.html'
success_url = reverse_lazy('products:attribute-list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if self.request.POST:
context['value_formset'] = ProductAttributeValueFormSet(self.request.POST, instance=self.object)
else:
context['value_formset'] = ProductAttributeValueFormSet(instance=self.object)
return context
def form_valid(self, form):
context = self.get_context_data()
value_formset = context['value_formset']
try:
self.object = form.save()
if value_formset.is_valid():
value_formset.save()
else:
return self.form_invalid(form)
messages.success(self.request, f'Атрибут "{self.object.name}" успешно обновлен.')
return super().form_valid(form)
except IntegrityError as e:
error_msg = str(e).lower()
if 'unique' in error_msg:
messages.error(
self.request,
f'Ошибка: атрибут с таким названием уже существует.'
)
else:
messages.error(
self.request,
'Ошибка при сохранении атрибута. Пожалуйста, проверьте введённые данные.'
)
return self.form_invalid(form)
class ProductAttributeDeleteView(LoginRequiredMixin, DeleteView):
"""Удаление атрибута с подтверждением"""
model = ProductAttribute
template_name = 'products/attribute_confirm_delete.html'
success_url = reverse_lazy('products:attribute-list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
attribute = self.get_object()
# Количество значений
context['values_count'] = attribute.values.count()
return context
def delete(self, request, *args, **kwargs):
attribute = self.get_object()
attribute_name = attribute.name
response = super().delete(request, *args, **kwargs)
messages.success(request, f'Атрибут "{attribute_name}" успешно удален.')
return response
# API endpoints
@login_required
@require_POST
def create_attribute_api(request):
"""API для быстрого создания атрибута"""
try:
data = json.loads(request.body)
name = data.get('name', '').strip()
if not name:
return JsonResponse({'success': False, 'error': 'Название обязательно'})
attribute = ProductAttribute.objects.create(name=name)
return JsonResponse({
'success': True,
'id': attribute.pk,
'name': attribute.name,
'slug': attribute.slug
})
except IntegrityError:
return JsonResponse({'success': False, 'error': 'Атрибут с таким названием уже существует'})
except Exception as e:
return JsonResponse({'success': False, 'error': str(e)})
@login_required
@require_POST
def add_attribute_value_api(request, pk):
"""API для добавления значения к атрибуту"""
try:
data = json.loads(request.body)
value = data.get('value', '').strip()
if not value:
return JsonResponse({'success': False, 'error': 'Значение обязательно'})
attribute = ProductAttribute.objects.get(pk=pk)
attr_value = ProductAttributeValue.objects.create(
attribute=attribute,
value=value
)
return JsonResponse({
'success': True,
'id': attr_value.pk,
'value': attr_value.value,
'slug': attr_value.slug
})
except ProductAttribute.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Атрибут не найден'})
except IntegrityError:
return JsonResponse({'success': False, 'error': 'Такое значение уже существует'})
except Exception as e:
return JsonResponse({'success': False, 'error': str(e)})
@login_required
@require_POST
def delete_attribute_value_api(request, pk, value_id):
"""API для удаления значения атрибута"""
try:
value = ProductAttributeValue.objects.get(pk=value_id, attribute_id=pk)
value.delete()
return JsonResponse({'success': True})
except ProductAttributeValue.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Значение не найдено'})
except Exception as e:
return JsonResponse({'success': False, 'error': str(e)})

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