Рефакторинг системы вариативных товаров и справочник атрибутов

Основные изменения:
- Переименование ConfigurableKitProduct → ConfigurableProduct
- Добавлена поддержка Product как варианта (не только ProductKit)
- Создан справочник атрибутов (ProductAttribute, ProductAttributeValue)
- CRUD для управления атрибутами с inline редактированием значений
- Пересозданы миграции с нуля для всех приложений
- Добавлена ссылка на атрибуты в навигацию

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 01:44:34 +03:00
parent 277a514a82
commit 79ff523adb
36 changed files with 1597 additions and 951 deletions

View File

@@ -0,0 +1,42 @@
{% extends 'base.html' %}
{% block title %}Удалить атрибут{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-sm-6 col-md-5 col-lg-4">
<div class="card shadow-sm border-0">
<div class="card-body p-4 text-center">
<div class="text-danger mb-3">
<i class="bi bi-exclamation-triangle fs-1"></i>
</div>
<h5 class="mb-3">Удалить атрибут?</h5>
<p class="mb-2">
<strong>{{ object.name }}</strong>
</p>
{% if values_count > 0 %}
<div class="alert alert-warning py-2 small">
<i class="bi bi-exclamation-circle"></i>
Будет удалено <strong>{{ values_count }}</strong> значени{{ values_count|pluralize:"е,я,й" }}
</div>
{% endif %}
<form method="post">
{% csrf_token %}
<div class="d-grid gap-2">
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash"></i> Удалить
</button>
<a href="{% url 'products:attribute-list' %}" class="btn btn-light btn-sm">Отмена</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,196 @@
{% extends 'base.html' %}
{% block title %}Атрибут: {{ attribute.name }}{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-lg-8">
<!-- Заголовок -->
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<a href="{% url 'products:attribute-list' %}" class="text-decoration-none text-muted small">
<i class="bi bi-arrow-left"></i> Все атрибуты
</a>
<h4 class="mb-0 mt-1">
<i class="bi bi-sliders text-primary"></i> {{ attribute.name }}
</h4>
<small class="text-muted">{{ attribute.slug }}</small>
</div>
<div class="btn-group">
<a href="{% url 'products:attribute-update' attribute.pk %}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-pencil"></i> Изменить
</a>
<a href="{% url 'products:attribute-delete' attribute.pk %}" class="btn btn-outline-danger btn-sm">
<i class="bi bi-trash"></i>
</a>
</div>
</div>
{% if attribute.description %}
<p class="text-muted mb-4">{{ attribute.description }}</p>
{% endif %}
<!-- Значения атрибута -->
<div class="card shadow-sm border-0">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span><i class="bi bi-list-ul"></i> Значения ({{ values|length }})</span>
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addValueModal">
<i class="bi bi-plus"></i> Добавить
</button>
</div>
<div class="card-body p-0">
{% if values %}
<ul class="list-group list-group-flush">
{% for value in values %}
<li class="list-group-item d-flex justify-content-between align-items-center py-2">
<div>
<span class="fw-medium">{{ value.value }}</span>
<small class="text-muted ms-2">{{ value.slug }}</small>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-light text-dark" title="Порядок сортировки"><i class="bi bi-arrows-vertical"></i> {{ value.position }}</span>
<button type="button" class="btn btn-outline-danger btn-sm py-0 px-1 delete-value-btn"
data-value-id="{{ value.pk }}" title="Удалить">
<i class="bi bi-trash"></i>
</button>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="text-center text-muted py-4">
<i class="bi bi-list-ul fs-2 opacity-25"></i>
<p class="mb-0 mt-2">Значений пока нет</p>
</div>
{% endif %}
</div>
</div>
<!-- Метаданные -->
<div class="text-muted small mt-3 text-center">
<i class="bi bi-clock-history"></i>
Создан: {{ attribute.created_at|date:"d.m.Y H:i" }} |
Обновлен: {{ attribute.updated_at|date:"d.m.Y H:i" }}
</div>
</div>
</div>
</div>
<!-- Модальное окно добавления значения -->
<div class="modal fade" id="addValueModal" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Добавить значение</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Значение</label>
<input type="text" id="new-value-input" class="form-control" placeholder="Например: 50">
</div>
<div id="add-value-message"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary btn-sm" id="add-value-btn">
<i class="bi bi-plus"></i> Добавить
</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const addBtn = document.getElementById('add-value-btn');
const valueInput = document.getElementById('new-value-input');
const messageDiv = document.getElementById('add-value-message');
// Добавление значения
addBtn.addEventListener('click', async function() {
const value = valueInput.value.trim();
if (!value) {
showMessage('Введите значение', 'danger');
return;
}
addBtn.disabled = true;
addBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
try {
const response = await fetch('{% url "products:attribute-add-value" attribute.pk %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
},
body: JSON.stringify({value: value})
});
const data = await response.json();
if (data.success) {
showMessage('Значение добавлено', 'success');
setTimeout(() => window.location.reload(), 500);
} else {
showMessage(data.error, 'danger');
resetBtn();
}
} catch (error) {
showMessage('Ошибка сети', 'danger');
resetBtn();
}
});
// Удаление значения
document.querySelectorAll('.delete-value-btn').forEach(btn => {
btn.addEventListener('click', async function() {
if (!confirm('Удалить это значение?')) return;
const valueId = this.dataset.valueId;
this.disabled = true;
try {
const response = await fetch(`{% url "products:attribute-delete-value" attribute.pk 0 %}`.replace('/0/', `/${valueId}/`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
}
});
const data = await response.json();
if (data.success) {
this.closest('li').remove();
} else {
alert(data.error);
this.disabled = false;
}
} catch (error) {
alert('Ошибка сети');
this.disabled = false;
}
});
});
function showMessage(text, type) {
messageDiv.innerHTML = `<div class="alert alert-${type} py-1 px-2 small mb-0">${text}</div>`;
}
function resetBtn() {
addBtn.disabled = false;
addBtn.innerHTML = '<i class="bi bi-plus"></i> Добавить';
valueInput.focus();
}
// Enter для добавления
valueInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
addBtn.click();
}
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,180 @@
{% extends 'base.html' %}
{% block title %}{% if object %}Редактировать атрибут{% else %}Создать атрибут{% endif %}{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card shadow-sm border-0">
<div class="card-body p-4">
<h5 class="text-center mb-4">
<i class="bi bi-sliders{% if not object %}-fill{% endif %} text-primary"></i>
{% if object %}Редактировать атрибут{% else %}Новый атрибут{% endif %}
</h5>
<form method="post">
{% csrf_token %}
<!-- Основные поля атрибута -->
<div class="row mb-3">
<div class="col-md-8">
<label class="form-label">Название <span class="text-danger">*</span></label>
{{ form.name }}
{% if form.name.errors %}
<small class="text-danger">{{ form.name.errors.0 }}</small>
{% endif %}
</div>
<div class="col-md-4">
<label class="form-label"><i class="bi bi-arrows-vertical"></i> Позиция</label>
{{ form.position }}
<small class="text-muted">Порядок сортировки</small>
</div>
</div>
<div class="mb-3">
<label class="form-label">Slug (URL)</label>
{{ form.slug }}
<small class="text-muted">{{ form.slug.help_text }}</small>
{% if form.slug.errors %}
<small class="text-danger d-block">{{ form.slug.errors.0 }}</small>
{% endif %}
</div>
<div class="mb-4">
<label class="form-label">Описание</label>
{{ form.description }}
</div>
<!-- Значения атрибута (inline formset) -->
<hr>
<h6 class="mb-3">
<i class="bi bi-list-ul"></i> Значения атрибута
</h6>
{{ value_formset.management_form }}
<!-- Заголовки столбцов -->
<div class="row align-items-center g-2 mb-2 small text-muted fw-medium">
<div class="col">Значение</div>
<div class="col-auto" style="width: 120px;">Slug</div>
<div class="col-auto" style="width: 80px;" title="Порядок сортировки"><i class="bi bi-arrows-vertical"></i> Поз.</div>
<div class="col-auto" style="width: 40px;"></div>
</div>
<div id="value-formset">
{% for value_form in value_formset %}
<div class="value-row mb-2 {% if value_form.instance.pk %}{% else %}empty-form{% endif %}">
<div class="row align-items-center g-2">
<div class="col">
{{ value_form.value }}
{% if value_form.value.errors %}
<small class="text-danger">{{ value_form.value.errors.0 }}</small>
{% endif %}
</div>
<div class="col-auto" style="width: 120px;">
{{ value_form.slug }}
</div>
<div class="col-auto" style="width: 80px;">
{{ value_form.position }}
</div>
<div class="col-auto">
{% if value_form.instance.pk %}
<div class="form-check">
{{ value_form.DELETE }}
<label class="form-check-label text-danger small" for="{{ value_form.DELETE.id_for_label }}">
<i class="bi bi-trash"></i>
</label>
</div>
{% else %}
<button type="button" class="btn btn-outline-danger btn-sm remove-value-row">
<i class="bi bi-x"></i>
</button>
{% endif %}
</div>
{{ value_form.id }}
</div>
</div>
{% endfor %}
</div>
<button type="button" id="add-value-btn" class="btn btn-outline-secondary btn-sm mt-2">
<i class="bi bi-plus"></i> Добавить значение
</button>
<hr class="my-4">
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check2"></i>
{% if object %}Сохранить{% else %}Создать{% endif %}
</button>
<a href="{% url 'products:attribute-list' %}" class="btn btn-light btn-sm">Отмена</a>
</div>
</form>
{% if object %}
<hr class="my-3">
<small class="text-muted d-block text-center">
<i class="bi bi-clock-history"></i> Обновлено: {{ object.updated_at|date:"d.m.Y H:i" }}
</small>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const formset = document.getElementById('value-formset');
const addBtn = document.getElementById('add-value-btn');
const totalForms = document.querySelector('[name="values-TOTAL_FORMS"]');
// Шаблон для новой строки
function createValueRow(index) {
const row = document.createElement('div');
row.className = 'value-row mb-2';
row.innerHTML = `
<div class="row align-items-center g-2">
<div class="col">
<input type="text" name="values-${index}-value" class="form-control form-control-sm" placeholder="Например: 50">
</div>
<div class="col-auto" style="width: 120px;">
<input type="text" name="values-${index}-slug" class="form-control form-control-sm" placeholder="Авто">
</div>
<div class="col-auto" style="width: 80px;">
<input type="number" name="values-${index}-position" value="0" class="form-control form-control-sm" style="width: 70px;">
</div>
<div class="col-auto">
<button type="button" class="btn btn-outline-danger btn-sm remove-value-row">
<i class="bi bi-x"></i>
</button>
</div>
<input type="hidden" name="values-${index}-id" value="">
</div>
`;
return row;
}
// Добавление новой строки
addBtn.addEventListener('click', function() {
const currentTotal = parseInt(totalForms.value);
const newRow = createValueRow(currentTotal);
formset.appendChild(newRow);
totalForms.value = currentTotal + 1;
// Фокус на новое поле
newRow.querySelector('input[type="text"]').focus();
});
// Удаление строки (делегирование событий)
formset.addEventListener('click', function(e) {
if (e.target.closest('.remove-value-row')) {
const row = e.target.closest('.value-row');
row.remove();
}
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,110 @@
{% extends 'base.html' %}
{% block title %}Атрибуты товаров{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-lg-8">
<!-- Заголовок и кнопка создания -->
<div class="d-flex align-items-center justify-content-between mb-3">
<h5 class="mb-0"><i class="bi bi-sliders text-primary"></i> Атрибуты товаров</h5>
<a href="{% url 'products:attribute-create' %}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg"></i> Новый атрибут
</a>
</div>
<!-- Поиск -->
<form method="get" class="d-flex gap-2 mb-3">
<input type="text" class="form-control form-control-sm" name="search"
value="{{ search_query }}" placeholder="Поиск по названию...">
<button type="submit" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-search"></i>
</button>
{% if search_query %}
<a href="{% url 'products:attribute-list' %}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-x"></i>
</a>
{% endif %}
</form>
<!-- Список атрибутов -->
{% if attributes %}
<!-- Заголовки столбцов -->
<div class="d-flex align-items-center py-2 px-3 bg-light border rounded-top small fw-medium text-muted">
<div class="flex-grow-1">Название</div>
<div class="d-flex align-items-center gap-2">
<span style="min-width: 70px; text-align: center;">Значения</span>
<span style="min-width: 60px; text-align: center;">Действия</span>
</div>
</div>
<div class="list-group list-group-flush border border-top-0 rounded-bottom">
{% for attr in attributes %}
<div class="list-group-item d-flex align-items-center py-2 px-3">
<div class="flex-grow-1">
<a href="{% url 'products:attribute-detail' attr.pk %}" class="text-decoration-none fw-medium">
{{ attr.name }}
</a>
<small class="text-muted ms-2">{{ attr.slug }}</small>
{% if attr.description %}
<small class="text-muted d-block">{{ attr.description|truncatewords:10 }}</small>
{% endif %}
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-secondary" title="Количество значений">
{{ attr.num_values }} знач.
</span>
<div class="btn-group btn-group-sm">
<a href="{% url 'products:attribute-update' attr.pk %}"
class="btn btn-outline-secondary btn-sm py-0 px-1" title="Изменить">
<i class="bi bi-pencil"></i>
</a>
<a href="{% url 'products:attribute-delete' attr.pk %}"
class="btn btn-outline-danger btn-sm py-0 px-1" title="Удалить">
<i class="bi bi-trash"></i>
</a>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Пагинация -->
{% if is_paginated %}
<nav class="mt-3">
<ul class="pagination pagination-sm justify-content-center mb-0">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if search_query %}&search={{ search_query }}{% endif %}">
&laquo;
</a>
</li>
{% endif %}
<li class="page-item active">
<span class="page-link">{{ page_obj.number }}/{{ page_obj.paginator.num_pages }}</span>
</li>
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if search_query %}&search={{ search_query }}{% endif %}">
&raquo;
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-sliders fs-1 opacity-25"></i>
<p class="mb-0 mt-2">Атрибутов пока нет</p>
<p class="small">Создайте первый атрибут, например "Длина стебля"</p>
<a href="{% url 'products:attribute-create' %}" class="btn btn-primary btn-sm mt-2">
<i class="bi bi-plus-lg"></i> Создать атрибут
</a>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}