Рефакторинг системы вариативных товаров и справочник атрибутов
Основные изменения: - Переименование 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:
@@ -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 %}
|
||||
196
myproject/products/templates/products/attribute_detail.html
Normal file
196
myproject/products/templates/products/attribute_detail.html
Normal 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 %}
|
||||
180
myproject/products/templates/products/attribute_form.html
Normal file
180
myproject/products/templates/products/attribute_form.html
Normal 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 %}
|
||||
110
myproject/products/templates/products/attribute_list.html
Normal file
110
myproject/products/templates/products/attribute_list.html
Normal 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 %}">
|
||||
«
|
||||
</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 %}">
|
||||
»
|
||||
</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 %}
|
||||
Reference in New Issue
Block a user