Implement M2M architecture for ConfigurableKitProduct variants with dynamic attribute selection

This commit introduces a complete refactoring of the variable product system:

1. **New Model**: ConfigurableKitOptionAttribute - M2M relationship between variants and attribute values
   - Replaces TextField-based attribute storage with proper database relationships
   - Ensures one value per attribute per variant through unique_together constraint
   - Includes indexes on both option and attribute fields for query performance

2. **Form Refactoring**:
   - Removed static 'attributes' field from ConfigurableKitOptionForm
   - Added dynamic field generation in __init__ based on parent attributes
   - Creates ModelChoiceField for each attribute (e.g., attribute_Длина, attribute_Упаковка)
   - Enhanced BaseConfigurableKitOptionFormSet validation to check all attributes are filled

3. **View Updates**:
   - Modified ConfigurableKitProductCreateView.form_valid() to save M2M relationships
   - Modified ConfigurableKitProductUpdateView.form_valid() with same logic
   - Uses transaction.atomic() for data consistency

4. **Template & JS Enhancements**:
   - Reordered form so attributes section appears before variants
   - Fixed template syntax: changed from field.name.startswith to "attribute_" in field.name
   - Updated JavaScript to dynamically generate attribute select fields when adding variants
   - Properly handles formset naming convention (options-{idx}-attribute_{name})

5. **Database Migrations**:
   - Created migration 0005 to alter ConfigurableKitOption.attributes to JSONField (for future use)
   - Created migration 0006 to add ConfigurableKitOptionAttribute model

6. **Tests**:
   - Added test_configurable_simple.py for model/form verification
   - Added test_workflow.py for complete end-to-end testing
   - All tests passing successfully

Features:
✓ All attributes required for each variant if defined on parent
✓ One value per attribute per variant (unique_together constraint)
✓ One default variant per product (formset validation)
✓ Dynamic form field generation based on parent attributes
✓ Atomic transactions for multi-part operations
✓ Proper error messages per variant number

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-18 20:04:22 +03:00
parent c4260f6b1c
commit 48938db04f
7 changed files with 530 additions and 107 deletions

View File

@@ -140,6 +140,8 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, CreateView):
return context
def form_valid(self, form):
from products.models.kits import ConfigurableKitOptionAttribute
# Пересоздаём formsets с POST данными
option_formset = ConfigurableKitOptionFormSetCreate(
self.request.POST,
@@ -157,7 +159,7 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, CreateView):
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))
@@ -167,10 +169,30 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, CreateView):
# Сохраняем основную форму
self.object = form.save()
# Сохраняем варианты
# Сохраняем варианты И их атрибуты
option_formset.instance = self.object
option_formset.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()
# Сохраняем атрибуты родителя
attribute_formset.instance = self.object
attribute_formset.save()
@@ -180,8 +202,15 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, CreateView):
except Exception as e:
messages.error(self.request, f'Ошибка при сохранении: {str(e)}')
import traceback
traceback.print_exc()
return self.form_invalid(form)
@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})
@@ -227,6 +256,8 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, UpdateView):
return context
def form_valid(self, form):
from products.models.kits import ConfigurableKitOptionAttribute
# Пересоздаём formsets с POST данными
option_formset = ConfigurableKitOptionFormSetUpdate(
self.request.POST,
@@ -246,7 +277,7 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, UpdateView):
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))
@@ -256,9 +287,29 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, UpdateView):
# Сохраняем основную форму
self.object = form.save()
# Сохраняем варианты
option_formset.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()
# Сохраняем атрибуты родителя
attribute_formset.save()
@@ -267,8 +318,15 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, UpdateView):
except Exception as e:
messages.error(self.request, f'Ошибка при сохранении: {str(e)}')
import traceback
traceback.print_exc()
return self.form_invalid(form)
@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})