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

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

@@ -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)})