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:
@@ -94,74 +94,6 @@ input[name*="DELETE"] {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Варианты (комплекты) -->
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="mb-0">Варианты (комплекты)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{ option_formset.management_form }}
|
||||
|
||||
{% if option_formset.non_form_errors %}
|
||||
<div class="alert alert-danger">
|
||||
{{ option_formset.non_form_errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="optionFormsetContainer">
|
||||
{% for form in option_formset %}
|
||||
<div class="option-form border rounded p-3 mb-3" style="background: #f8f9fa;">
|
||||
{{ form.id }}
|
||||
{% if form.instance.pk %}
|
||||
<input type="hidden" name="options-{{ forloop.counter0 }}-id" value="{{ form.instance.pk }}">
|
||||
{% endif %}
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">{{ form.kit.label }}</label>
|
||||
{{ form.kit }}
|
||||
{% if form.kit.errors %}
|
||||
<div class="text-danger small">{{ form.kit.errors.0 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">{{ form.attributes.label }}</label>
|
||||
{{ form.attributes }}
|
||||
{% if form.attributes.errors %}
|
||||
<div class="text-danger small">{{ form.attributes.errors.0 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small d-block">{{ form.is_default.label }}</label>
|
||||
<div class="form-check form-switch">
|
||||
{{ form.is_default }}
|
||||
<label class="form-check-label" for="{{ form.is_default.id_for_label }}">
|
||||
<span class="default-switch-label">{% if form.instance.is_default %}Да{% else %}Нет{% endif %}</span>
|
||||
</label>
|
||||
</div>
|
||||
{% if form.is_default.errors %}
|
||||
<div class="text-danger small">{{ form.is_default.errors.0 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
{% if option_formset.can_delete %}
|
||||
<label class="form-label small d-block"> </label>
|
||||
{{ form.DELETE }}
|
||||
<label for="{{ form.DELETE.id_for_label }}" class="btn btn-sm btn-outline-danger d-block">
|
||||
<i class="bi bi-trash"></i> Удалить
|
||||
</label>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="addOptionBtn">
|
||||
<i class="bi bi-plus-circle me-1"></i>Добавить вариант
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Атрибуты родительского товара -->
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header bg-white">
|
||||
@@ -171,9 +103,9 @@ input[name*="DELETE"] {
|
||||
<p class="small text-muted mb-3">
|
||||
Определите схему атрибутов для вариативного товара. Например: Цвет=Красный, Размер=M, Длина=60см.
|
||||
</p>
|
||||
|
||||
|
||||
{{ attribute_formset.management_form }}
|
||||
|
||||
|
||||
{% if attribute_formset.non_form_errors %}
|
||||
<div class="alert alert-danger">
|
||||
{{ attribute_formset.non_form_errors }}
|
||||
@@ -241,6 +173,92 @@ input[name*="DELETE"] {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Варианты (комплекты) -->
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="mb-0">Варианты (комплекты)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{ option_formset.management_form }}
|
||||
|
||||
{% if option_formset.non_form_errors %}
|
||||
<div class="alert alert-danger">
|
||||
{{ option_formset.non_form_errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="optionFormsetContainer">
|
||||
{% for form in option_formset %}
|
||||
<div class="option-form border rounded p-3 mb-3" style="background: #f8f9fa;">
|
||||
{{ form.id }}
|
||||
{% if form.instance.pk %}
|
||||
<input type="hidden" name="options-{{ forloop.counter0 }}-id" value="{{ form.instance.pk }}">
|
||||
{% endif %}
|
||||
<div class="row g-2">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">{{ form.kit.label }}</label>
|
||||
{{ form.kit }}
|
||||
{% if form.kit.errors %}
|
||||
<div class="text-danger small">{{ form.kit.errors.0 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Динамически генерируемые поля для атрибутов варианта -->
|
||||
{% for field in form %}
|
||||
{% if "attribute_" in field.name %}
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">{{ field.label }}</label>
|
||||
{{ field }}
|
||||
{% if field.errors %}
|
||||
<div class="text-danger small">{{ field.errors.0 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small d-block">{{ form.is_default.label }}</label>
|
||||
<div class="form-check form-switch">
|
||||
{{ form.is_default }}
|
||||
<label class="form-check-label" for="{{ form.is_default.id_for_label }}">
|
||||
<span class="default-switch-label">{% if form.instance.is_default %}Да{% else %}Нет{% endif %}</span>
|
||||
</label>
|
||||
</div>
|
||||
{% if form.is_default.errors %}
|
||||
<div class="text-danger small">{{ form.is_default.errors.0 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
{% if option_formset.can_delete %}
|
||||
<label class="form-label small d-block"> </label>
|
||||
{{ form.DELETE }}
|
||||
<label for="{{ form.DELETE.id_for_label }}" class="btn btn-sm btn-outline-danger d-block">
|
||||
<i class="bi bi-trash"></i> Удалить
|
||||
</label>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Скрытый контейнер с информацией об атрибутах для JavaScript -->
|
||||
<div id="attributesMetadata" style="display: none;">
|
||||
{% for attr in attribute_formset %}
|
||||
{% if attr.cleaned_data or attr.instance.pk %}
|
||||
<div data-attr-name="{{ attr.cleaned_data.name|default:attr.instance.name }}"
|
||||
data-attr-id="{{ attr.instance.id }}">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="addOptionBtn">
|
||||
<i class="bi bi-plus-circle me-1"></i>Добавить вариант
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-circle me-1"></i>Сохранить
|
||||
@@ -278,12 +296,38 @@ document.getElementById('addOptionBtn').addEventListener('click', function() {
|
||||
const container = document.getElementById('optionFormsetContainer');
|
||||
const totalForms = document.querySelector('[name="options-TOTAL_FORMS"]');
|
||||
const formIdx = parseInt(totalForms.value);
|
||||
|
||||
|
||||
// Получаем первую существующую форму чтобы узнать какие атрибуты нужны
|
||||
const firstForm = container.querySelector('.option-form');
|
||||
let attributesHtml = '';
|
||||
|
||||
if (firstForm) {
|
||||
// Ищем поля атрибутов в первой форме
|
||||
const attributeFields = firstForm.querySelectorAll('select[data-attribute-name]');
|
||||
attributeFields.forEach(field => {
|
||||
const attrName = field.getAttribute('data-attribute-name');
|
||||
const options = field.innerHTML;
|
||||
const colWidth = attributeFields.length > 2 ? 'col-md-1.5' : 'col-md-2';
|
||||
attributesHtml += `
|
||||
<div class="${colWidth}">
|
||||
<label class="form-label small">${attrName}</label>
|
||||
<select name="options-${formIdx}-attribute_${attrName}"
|
||||
id="id_options-${formIdx}-attribute_${attrName}"
|
||||
class="form-select"
|
||||
data-attribute-name="${attrName}">
|
||||
<option value="">---------</option>
|
||||
${options}
|
||||
</select>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
// Создаём новую форму HTML
|
||||
const newFormHtml = `
|
||||
<div class="option-form border rounded p-3 mb-3" style="background: #f8f9fa;">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Комплект</label>
|
||||
<select name="options-${formIdx}-kit" id="id_options-${formIdx}-kit" class="form-select">
|
||||
<option value="">---------</option>
|
||||
@@ -292,28 +336,22 @@ document.getElementById('addOptionBtn').addEventListener('click', function() {
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Атрибуты варианта</label>
|
||||
<input type="text" name="options-${formIdx}-attributes"
|
||||
id="id_options-${formIdx}-attributes"
|
||||
class="form-control"
|
||||
placeholder="Например: Количество:15;Длина:60см">
|
||||
</div>
|
||||
${attributesHtml}
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small d-block">По умолчанию</label>
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" name="options-${formIdx}-is_default"
|
||||
id="id_options-${formIdx}-is_default"
|
||||
<input type="checkbox" name="options-${formIdx}-is_default"
|
||||
id="id_options-${formIdx}-is_default"
|
||||
class="form-check-input is-default-switch" role="switch">
|
||||
<label class="form-check-label" for="id_options-${formIdx}-is_default">
|
||||
<span class="default-switch-label">Нет</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-1.5">
|
||||
<label class="form-label small d-block"> </label>
|
||||
<input type="checkbox" name="options-${formIdx}-DELETE"
|
||||
id="id_options-${formIdx}-DELETE"
|
||||
<input type="checkbox" name="options-${formIdx}-DELETE"
|
||||
id="id_options-${formIdx}-DELETE"
|
||||
style="display:none;">
|
||||
<label for="id_options-${formIdx}-DELETE" class="btn btn-sm btn-outline-danger d-block">
|
||||
<i class="bi bi-trash"></i> Удалить
|
||||
@@ -322,10 +360,10 @@ document.getElementById('addOptionBtn').addEventListener('click', function() {
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
container.insertAdjacentHTML('beforeend', newFormHtml);
|
||||
totalForms.value = formIdx + 1;
|
||||
|
||||
|
||||
// Переинициализируем логику switch после добавления новой формы
|
||||
initDefaultSwitches();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user