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

@@ -625,6 +625,7 @@ class ConfigurableKitProductForm(forms.ModelForm):
class ConfigurableKitOptionForm(forms.ModelForm):
"""
Форма для добавления варианта (комплекта) к вариативному товару.
Атрибуты варианта выбираются динамически на основе parent_attributes.
"""
kit = forms.ModelChoiceField(
queryset=ProductKit.objects.filter(status='active', is_temporary=False).order_by('name'),
@@ -635,37 +636,72 @@ class ConfigurableKitOptionForm(forms.ModelForm):
class Meta:
model = ConfigurableKitOption
fields = ['kit', 'attributes', 'is_default']
# Убрали 'attributes' - он будет заполняться через ConfigurableKitOptionAttribute
fields = ['kit', 'is_default']
labels = {
'kit': 'Комплект',
'attributes': 'Атрибуты варианта',
'is_default': 'По умолчанию'
}
widgets = {
'attributes': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Например: Количество:15;Длина:60см'
}),
'is_default': forms.CheckboxInput(attrs={
'class': 'form-check-input is-default-switch',
'role': 'switch'
})
}
def __init__(self, *args, **kwargs):
"""
Динамически генерируем поля для выбора атрибутов на основе parent_attributes.
"""
super().__init__(*args, **kwargs)
# Получаем instance (ConfigurableKitOption)
if self.instance and self.instance.parent_id:
parent = self.instance.parent
# Получаем все уникальные названия атрибутов родителя
attributes = parent.parent_attributes.all().order_by('position', 'name').distinct('name')
# Создаем поле для каждого названия атрибута
for attr in attributes:
attr_name = attr.name
field_name = f'attribute_{attr_name}'
# Получаем все возможные значения для этого атрибута
options = parent.parent_attributes.filter(name=attr_name).values_list('id', 'option')
# Создаем ChoiceField для выбора значения
self.fields[field_name] = forms.ModelChoiceField(
queryset=parent.parent_attributes.filter(name=attr_name),
required=True,
label=attr_name,
widget=forms.Select(attrs={'class': 'form-select', 'data-attribute-name': attr_name}),
to_field_name='id'
)
# Если редактируем существующий вариант - устанавливаем текущее значение
if self.instance.pk:
current_attr = self.instance.attributes_set.filter(attribute__name=attr_name).first()
if current_attr:
self.fields[field_name].initial = current_attr.attribute
class BaseConfigurableKitOptionFormSet(forms.BaseInlineFormSet):
def clean(self):
"""Проверка на дубликаты комплектов в вариативном товаре"""
"""Проверка на дубликаты комплектов и что все атрибуты заполнены"""
if any(self.errors):
return
kits = []
default_count = 0
for form in self.forms:
for idx, form in enumerate(self.forms):
if self.can_delete and self._should_delete_form(form):
continue
# Пропускаем пустые формы (extra формы при создании)
if not form.cleaned_data or not form.cleaned_data.get('kit'):
continue
kit = form.cleaned_data.get('kit')
is_default = form.cleaned_data.get('is_default')
@@ -682,6 +718,31 @@ class BaseConfigurableKitOptionFormSet(forms.BaseInlineFormSet):
if is_default:
default_count += 1
# Проверяем что все атрибуты заполнены для варианта с kit
parent = self.instance
if parent and parent.pk:
# Получаем все уникальные названия атрибутов родителя
attribute_names = (
parent.parent_attributes
.all()
.order_by('position', 'name')
.distinct('name')
.values_list('name', flat=True)
)
# Проверяем что каждый атрибут выбран
missing_attributes = []
for attr_name in attribute_names:
field_name = f'attribute_{attr_name}'
if field_name not in form.cleaned_data or not form.cleaned_data[field_name]:
missing_attributes.append(attr_name)
if missing_attributes:
attrs_str = ', '.join(f'"{attr}"' for attr in missing_attributes)
raise forms.ValidationError(
f'Вариант {idx + 1}: необходимо заполнить атрибут(ы) {attrs_str}.'
)
# Проверяем, что не более одного "is_default"
if default_count > 1:
raise forms.ValidationError(
@@ -695,7 +756,7 @@ ConfigurableKitOptionFormSetCreate = inlineformset_factory(
ConfigurableKitOption,
form=ConfigurableKitOptionForm,
formset=BaseConfigurableKitOptionFormSet,
fields=['kit', 'attributes', 'is_default'],
fields=['kit', 'is_default'], # Убрали 'attributes' - заполняется через ConfigurableKitOptionAttribute
extra=1, # Показать 1 пустую форму
can_delete=True,
min_num=0,
@@ -709,7 +770,7 @@ ConfigurableKitOptionFormSetUpdate = inlineformset_factory(
ConfigurableKitOption,
form=ConfigurableKitOptionForm,
formset=BaseConfigurableKitOptionFormSet,
fields=['kit', 'attributes', 'is_default'],
fields=['kit', 'is_default'], # Убрали 'attributes' - заполняется через ConfigurableKitOptionAttribute
extra=0, # НЕ показывать пустые формы
can_delete=True,
min_num=0,