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