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:
@@ -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})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user