Добавлено API для получения списка атрибутов и их значений; обновлены формы для работы с атрибутами через JavaScript

This commit is contained in:
2025-12-30 02:41:30 +03:00
parent f39ee5f15d
commit a3f2185714
6 changed files with 498 additions and 46 deletions

View File

@@ -858,7 +858,7 @@ ConfigurableProductAttributeFormSetCreate = inlineformset_factory(
formset=BaseConfigurableProductAttributeFormSet,
# Убрали 'option' - значения будут добавляться через JavaScript в карточку
fields=['name', 'position', 'visible'],
extra=1,
extra=0, # Пользователь добавляет параметры через кнопку "Добавить параметр"
can_delete=True,
min_num=0,
validate_min=False,

View File

@@ -106,13 +106,35 @@ input[name*="DELETE"] {
{{ attribute_formset.management_form }}
<!-- Список доступных комплектов для JavaScript -->
<!-- Данные для JavaScript -->
<script>
window.AVAILABLE_KITS = [
{% for kit in available_kits %}
{ id: {{ kit.id }}, name: "{{ kit.name }}" }{% if not forloop.last %},{% endif %}
{ id: {{ kit.id }}, name: "{{ kit.name|escapejs }}" }{% if not forloop.last %},{% endif %}
{% endfor %}
];
// Справочник атрибутов с их значениями
window.PRODUCT_ATTRIBUTES = [
{% for attr in product_attributes %}
{
id: {{ attr.id }},
name: "{{ attr.name|escapejs }}",
slug: "{{ attr.slug|escapejs }}",
values: [
{% for val in attr.values.all %}
{ id: {{ val.id }}, value: "{{ val.value|escapejs }}", slug: "{{ val.slug|escapejs }}" }{% if not forloop.last %},{% endif %}
{% endfor %}
]
}{% if not forloop.last %},{% endif %}
{% endfor %}
];
// URL для API
window.API_URLS = {
createAttribute: "{% url 'products:api-attribute-create' %}",
addValue: function(attrId) { return `/products/api/attributes/${attrId}/values/add/`; }
};
</script>
{% if attribute_formset.non_form_errors %}
@@ -127,10 +149,26 @@ input[name*="DELETE"] {
{{ form.id }}
<div class="row align-items-end g-3 mb-3">
<!-- Название параметра -->
<div class="col-md-3">
<!-- Название параметра с выбором из справочника -->
<div class="col-md-4">
<label class="form-label fw-semibold">{{ form.name.label }}</label>
{{ form.name }}
<div class="input-group">
<select class="form-select param-name-select"
data-name-input="id_attributes-{{ forloop.counter0 }}-name">
<option value="">-- Выберите атрибут --</option>
{% for attr in product_attributes %}
<option value="{{ attr.name }}" {% if form.name.value == attr.name %}selected{% endif %}>
{{ attr.name }} ({{ attr.values.count }} знач.)
</option>
{% endfor %}
<option value="__new__">+ Создать новый...</option>
</select>
<input type="hidden"
name="attributes-{{ forloop.counter0 }}-name"
id="id_attributes-{{ forloop.counter0 }}-name"
class="param-name-input"
value="{{ form.name.value|default:'' }}">
</div>
{% if form.name.errors %}
<div class="text-danger small">{{ form.name.errors.0 }}</div>
{% endif %}
@@ -169,9 +207,14 @@ input[name*="DELETE"] {
</div>
</div>
<!-- Значения параметра (добавляются инлайн через JavaScript) -->
<!-- Значения параметра -->
<div class="parameter-values-container mt-3 p-3 bg-white rounded border">
<label class="form-label small fw-semibold d-block mb-2">Значения параметра:</label>
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label small fw-semibold mb-0">Значения параметра:</label>
<button type="button" class="btn btn-sm btn-outline-primary load-values-btn" title="Загрузить значения из справочника">
<i class="bi bi-arrow-down-circle me-1"></i> Загрузить из справочника
</button>
</div>
<div class="value-fields-wrapper" data-param-index="{{ forloop.counter0 }}">
<!-- Значения будут добавлены через JavaScript -->
</div>
@@ -231,14 +274,14 @@ function addValueField(container, valueText = '', kitId = '') {
const index = container.querySelectorAll('.value-field-group').length;
const fieldId = `value-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Получаем список доступных комплектов из скрытого элемента
// Получаем список доступных комплектов
const kitOptionsHtml = getKitOptionsHtml(kitId);
const html = `
<div class="value-field-group d-flex gap-2 mb-2 align-items-start">
<input type="text" class="form-control form-control-sm parameter-value-input"
placeholder="Введите значение"
value="${valueText}"
value="${escapeHtml(valueText)}"
data-field-id="${fieldId}"
style="min-width: 100px;">
<select class="form-select form-select-sm parameter-kit-select"
@@ -258,17 +301,27 @@ function addValueField(container, valueText = '', kitId = '') {
// Установка выбранного комплекта если был передан
if (kitId) {
const kitSelect = container.querySelector('.parameter-kit-select:last-child');
if (kitSelect) {
kitSelect.value = kitId;
const lastSelect = container.querySelector('.value-field-group:last-child .parameter-kit-select');
if (lastSelect) {
lastSelect.value = kitId;
}
}
// Обработчик удаления значения
container.querySelector('.remove-value-btn:last-child').addEventListener('click', function(e) {
e.preventDefault();
this.closest('.value-field-group').remove();
});
const lastRemoveBtn = container.querySelector('.value-field-group:last-child .remove-value-btn');
if (lastRemoveBtn) {
lastRemoveBtn.addEventListener('click', function(e) {
e.preventDefault();
this.closest('.value-field-group').remove();
});
}
}
// Экранирование HTML
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Получить HTML с опциями комплектов
@@ -276,23 +329,89 @@ function getKitOptionsHtml(selectedKitId = '') {
const kitsData = window.AVAILABLE_KITS || [];
return kitsData.map(kit => {
const selected = kit.id == selectedKitId ? 'selected' : '';
return `<option value="${kit.id}" ${selected}>${kit.name}</option>`;
return `<option value="${kit.id}" ${selected}>${escapeHtml(kit.name)}</option>`;
}).join('');
}
// Найти атрибут в справочнике по имени
function findAttributeByName(name) {
const attributes = window.PRODUCT_ATTRIBUTES || [];
return attributes.find(attr => attr.name.toLowerCase() === name.toLowerCase());
}
// Инициализация существующих параметров с их значениями из БД
function initializeParameterCards() {
document.querySelectorAll('.attribute-card').forEach(card => {
// Если это существующий параметр с ID, загрузим его значения
// Это будет обработано при первой загрузке в view
initAddValueBtn(card);
initCardHandlers(card);
});
}
// Инициализация кнопки добавления значения для карточки
// Инициализация всех обработчиков для карточки
function initCardHandlers(card) {
initAddValueBtn(card);
initLoadValuesBtn(card);
initAttributeSelect(card);
initParamDeleteToggle(card);
}
// Получить HTML опций для select атрибутов
function getAttributeOptionsHtml(selectedName = '') {
const attributes = window.PRODUCT_ATTRIBUTES || [];
let html = '<option value="">-- Выберите атрибут --</option>';
attributes.forEach(attr => {
const selected = attr.name === selectedName ? 'selected' : '';
html += `<option value="${escapeHtml(attr.name)}" ${selected}>${escapeHtml(attr.name)} (${attr.values.length} знач.)</option>`;
});
html += '<option value="__new__">+ Создать новый...</option>';
return html;
}
// Инициализация select выбора атрибута
function initAttributeSelect(card) {
const select = card.querySelector('.param-name-select');
if (select && !select.dataset.initialized) {
select.dataset.initialized = 'true';
select.addEventListener('change', function() {
const hiddenInput = card.querySelector('.param-name-input');
const value = this.value;
if (value === '__new__') {
// Открываем модальное окно для создания нового атрибута
openCreateAttributeModal('', card);
// Сбрасываем select
this.value = '';
} else {
// Устанавливаем значение в скрытое поле
if (hiddenInput) {
hiddenInput.value = value;
}
// Если выбран атрибут из справочника - предлагаем загрузить значения
if (value) {
const attribute = findAttributeByName(value);
if (attribute && attribute.values.length > 0) {
const container = card.querySelector('.value-fields-wrapper');
const existingValues = container.querySelectorAll('.value-field-group');
if (existingValues.length === 0) {
// Автоматически загружаем значения если их ещё нет
attribute.values.forEach(val => {
addValueField(container, val.value, '');
});
showToast(`Загружено ${attribute.values.length} значений для "${value}"`, 'success');
}
}
}
}
});
}
}
// Инициализация кнопки добавления значения
function initAddValueBtn(card) {
const addBtn = card.querySelector('.add-value-btn');
if (addBtn) {
if (addBtn && !addBtn.dataset.initialized) {
addBtn.dataset.initialized = 'true';
addBtn.addEventListener('click', function(e) {
e.preventDefault();
const container = this.closest('.parameter-values-container').querySelector('.value-fields-wrapper');
@@ -301,6 +420,263 @@ function initAddValueBtn(card) {
}
}
// Инициализация кнопки загрузки значений из справочника
function initLoadValuesBtn(card) {
const loadBtn = card.querySelector('.load-values-btn');
if (loadBtn && !loadBtn.dataset.initialized) {
loadBtn.dataset.initialized = 'true';
loadBtn.addEventListener('click', function(e) {
e.preventDefault();
const nameInput = card.querySelector('.param-name-input');
const attrName = nameInput ? nameInput.value.trim() : '';
if (!attrName) {
showToast('Сначала введите или выберите название параметра', 'warning');
return;
}
const attribute = findAttributeByName(attrName);
if (!attribute) {
showToast(`Атрибут "${attrName}" не найден в справочнике. Создайте его или добавьте значения вручную.`, 'warning');
return;
}
if (attribute.values.length === 0) {
showToast(`У атрибута "${attrName}" нет значений в справочнике`, 'info');
return;
}
// Загружаем значения из справочника
const container = card.querySelector('.value-fields-wrapper');
// Спрашиваем пользователя, если уже есть значения
const existingValues = container.querySelectorAll('.value-field-group');
if (existingValues.length > 0) {
if (!confirm('Уже есть добавленные значения. Добавить значения из справочника дополнительно?')) {
return;
}
}
// Добавляем значения
attribute.values.forEach(val => {
addValueField(container, val.value, '');
});
showToast(`Загружено ${attribute.values.length} значений из справочника`, 'success');
});
}
}
// Открыть модальное окно создания атрибута
function openCreateAttributeModal(attrName, card) {
// Получаем или создаем модальное окно
let modal = document.getElementById('createAttributeModal');
if (!modal) {
createAttributeModal();
modal = document.getElementById('createAttributeModal');
}
// Заполняем название
const nameInput = modal.querySelector('#newAttributeName');
nameInput.value = attrName;
// Очищаем значения
const valuesContainer = modal.querySelector('#newAttributeValues');
valuesContainer.innerHTML = '';
addNewAttributeValueField(valuesContainer);
// Сохраняем ссылку на карточку
modal.dataset.targetCardIndex = card.dataset.formsetIndex;
// Показываем модальное окно
const bsModal = new bootstrap.Modal(modal);
bsModal.show();
}
// Создать модальное окно для создания атрибута
function createAttributeModal() {
const modalHtml = `
<div class="modal fade" id="createAttributeModal" tabindex="-1">
<div class="modal-dialog">
<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" class="form-control" id="newAttributeName" placeholder="Например: Длина стебля">
</div>
<div class="mb-3">
<label class="form-label">Значения атрибута <small class="text-muted">(опционально)</small></label>
<div id="newAttributeValues"></div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" id="addNewAttributeValueBtn">
<i class="bi bi-plus-circle me-1"></i> Добавить значение
</button>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary" id="saveNewAttributeBtn">
<i class="bi bi-check-circle me-1"></i> Создать
</button>
</div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
// Инициализируем обработчики
const modal = document.getElementById('createAttributeModal');
document.getElementById('addNewAttributeValueBtn').addEventListener('click', function() {
const container = document.getElementById('newAttributeValues');
addNewAttributeValueField(container);
});
document.getElementById('saveNewAttributeBtn').addEventListener('click', async function() {
await saveNewAttribute();
});
}
// Добавить поле значения в модальном окне
function addNewAttributeValueField(container) {
const html = `
<div class="input-group mb-2 new-value-field">
<input type="text" class="form-control" placeholder="Значение">
<button type="button" class="btn btn-outline-danger remove-new-value-btn">
<i class="bi bi-trash"></i>
</button>
</div>
`;
container.insertAdjacentHTML('beforeend', html);
const lastRemoveBtn = container.querySelector('.new-value-field:last-child .remove-new-value-btn');
lastRemoveBtn.addEventListener('click', function() {
this.closest('.new-value-field').remove();
});
}
// Сохранить новый атрибут через API
async function saveNewAttribute() {
const modal = document.getElementById('createAttributeModal');
const name = document.getElementById('newAttributeName').value.trim();
if (!name) {
showToast('Введите название атрибута', 'warning');
return;
}
const saveBtn = document.getElementById('saveNewAttributeBtn');
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Сохранение...';
try {
// Создаем атрибут
const csrfToken = document.querySelector('[name="csrfmiddlewaretoken"]').value;
const response = await fetch(window.API_URLS.createAttribute, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ name: name })
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Ошибка создания атрибута');
}
const newAttr = {
id: data.id,
name: data.name,
slug: data.slug,
values: []
};
// Добавляем значения если они есть
const valueInputs = document.querySelectorAll('#newAttributeValues .new-value-field input');
for (const input of valueInputs) {
const value = input.value.trim();
if (value) {
const valResponse = await fetch(window.API_URLS.addValue(data.id), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ value: value })
});
const valData = await valResponse.json();
if (valData.success) {
newAttr.values.push({
id: valData.id,
value: valData.value,
slug: valData.slug
});
}
}
}
// Добавляем в локальный справочник
window.PRODUCT_ATTRIBUTES.push(newAttr);
// Обновляем все select'ы атрибутов
updateAttributeSelects();
// Закрываем модальное окно
const bsModal = bootstrap.Modal.getInstance(modal);
bsModal.hide();
showToast(`Атрибут "${name}" успешно создан!`, 'success');
// Если есть целевая карточка, обновляем её
const cardIndex = modal.dataset.targetCardIndex;
if (cardIndex !== undefined) {
const card = document.querySelector(`[data-formset-index="${cardIndex}"]`);
if (card) {
// Устанавливаем новый атрибут в select
const select = card.querySelector('.param-name-select');
const hiddenInput = card.querySelector('.param-name-input');
if (select) {
select.value = newAttr.name;
}
if (hiddenInput) {
hiddenInput.value = newAttr.name;
}
// Загружаем значения если есть
if (newAttr.values.length > 0) {
const container = card.querySelector('.value-fields-wrapper');
newAttr.values.forEach(val => {
addValueField(container, val.value, '');
});
}
}
}
} catch (error) {
showToast(error.message, 'danger');
} finally {
saveBtn.disabled = false;
saveBtn.innerHTML = '<i class="bi bi-check-circle me-1"></i> Создать';
}
}
// Обновить все select'ы атрибутов после добавления нового
function updateAttributeSelects() {
document.querySelectorAll('.param-name-select').forEach(select => {
const currentValue = select.value;
select.innerHTML = getAttributeOptionsHtml(currentValue);
});
}
// Добавление нового параметра
document.getElementById('addParameterBtn')?.addEventListener('click', function() {
const container = document.getElementById('attributeFormsetContainer');
@@ -312,12 +688,19 @@ document.getElementById('addParameterBtn')?.addEventListener('click', function()
<input type="hidden" name="attributes-${formIdx}-id">
<div class="row align-items-end g-3 mb-3">
<div class="col-md-3">
<div class="col-md-4">
<label class="form-label fw-semibold">Название параметра</label>
<input type="text" name="attributes-${formIdx}-name"
id="id_attributes-${formIdx}-name"
class="form-control param-name-input"
placeholder="Например: Длина, Цвет, Размер">
<div class="input-group">
<select class="form-select param-name-select"
data-name-input="id_attributes-${formIdx}-name">
${getAttributeOptionsHtml()}
</select>
<input type="hidden"
name="attributes-${formIdx}-name"
id="id_attributes-${formIdx}-name"
class="param-name-input"
value="">
</div>
</div>
<div class="col-md-2">
<label class="form-label small">Порядок</label>
@@ -348,7 +731,12 @@ document.getElementById('addParameterBtn')?.addEventListener('click', function()
</div>
<div class="parameter-values-container mt-3 p-3 bg-white rounded border">
<label class="form-label small fw-semibold d-block mb-2">Значения параметра:</label>
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label small fw-semibold mb-0">Значения параметра:</label>
<button type="button" class="btn btn-sm btn-outline-primary load-values-btn" title="Загрузить значения из справочника">
<i class="bi bi-arrow-down-circle me-1"></i> Загрузить из справочника
</button>
</div>
<div class="value-fields-wrapper" data-param-index="${formIdx}">
<!-- Значения добавляются сюда -->
</div>
@@ -364,16 +752,17 @@ document.getElementById('addParameterBtn')?.addEventListener('click', function()
// Инициализируем новую карточку
const newCard = container.querySelector(`[data-formset-index="${formIdx}"]`);
initAddValueBtn(newCard);
initCardHandlers(newCard);
// Инициализируем удаление параметра
initParamDeleteToggle(newCard);
// Фокус на select
newCard.querySelector('.param-name-select')?.focus();
});
// Функция для скрытия удаленного параметра
function initParamDeleteToggle(card) {
const deleteCheckbox = card.querySelector('input[type="checkbox"][name$="-DELETE"]');
if (deleteCheckbox) {
if (deleteCheckbox && !deleteCheckbox.dataset.initialized) {
deleteCheckbox.dataset.initialized = 'true';
deleteCheckbox.addEventListener('change', function() {
if (this.checked) {
card.style.opacity = '0.5';
@@ -388,12 +777,7 @@ function initParamDeleteToggle(card) {
// Функция для сериализации значений параметров и их комплектов перед отправкой формы
function serializeAttributeValues() {
/**
* Перед отправкой формы нужно сериализовать все значения параметров
* и их связанные комплекты из инлайн input'ов в скрытые JSON поля
*/
document.querySelectorAll('.attribute-card').forEach((card, idx) => {
// Получаем все инпуты с значениями и их комплектами внутри этой карточки
const valueGroups = card.querySelectorAll('.value-field-group');
const values = [];
const kits = [];
@@ -406,7 +790,7 @@ function serializeAttributeValues() {
const value = valueInput.value.trim();
const kitId = kitSelect ? kitSelect.value : '';
if (value && kitId) { // Требуем чтобы оба поля были заполнены
if (value && kitId) {
values.push(value);
kits.push(parseInt(kitId));
}
@@ -414,7 +798,6 @@ function serializeAttributeValues() {
});
// Создаем или обновляем скрытые поля JSON
// поле values: ["50", "60", "70"]
const valuesFieldName = `attributes-${idx}-values`;
let valuesField = document.querySelector(`input[name="${valuesFieldName}"]`);
if (!valuesField) {
@@ -425,7 +808,6 @@ function serializeAttributeValues() {
}
valuesField.value = JSON.stringify(values);
// поле kits: [1, 2, 3] (id ProductKit)
const kitsFieldName = `attributes-${idx}-kits`;
let kitsField = document.querySelector(`input[name="${kitsFieldName}"]`);
if (!kitsField) {
@@ -438,18 +820,52 @@ function serializeAttributeValues() {
});
}
// Toast уведомления
function showToast(message, type = 'info') {
// Создаем контейнер если его нет
let container = document.getElementById('toastContainer');
if (!container) {
container = document.createElement('div');
container.id = 'toastContainer';
container.className = 'toast-container position-fixed bottom-0 end-0 p-3';
container.style.zIndex = '1100';
document.body.appendChild(container);
}
const bgClass = {
'success': 'bg-success',
'danger': 'bg-danger',
'warning': 'bg-warning',
'info': 'bg-info'
}[type] || 'bg-info';
const textClass = type === 'warning' ? 'text-dark' : 'text-white';
const toastHtml = `
<div class="toast ${bgClass} ${textClass}" role="alert">
<div class="d-flex">
<div class="toast-body">${escapeHtml(message)}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
`;
container.insertAdjacentHTML('beforeend', toastHtml);
const toastEl = container.lastElementChild;
const toast = new bootstrap.Toast(toastEl, { delay: 4000 });
toast.show();
toastEl.addEventListener('hidden.bs.toast', () => toastEl.remove());
}
// Инициализация при загрузке страницы
document.addEventListener('DOMContentLoaded', function() {
initializeParameterCards();
document.querySelectorAll('.attribute-card').forEach(card => {
initParamDeleteToggle(card);
});
// Добавляем сериализацию значений перед отправкой формы
const form = document.querySelector('form');
if (form) {
form.addEventListener('submit', function(e) {
// Перед отправкой формы сериализуем все значения параметров
serializeAttributeValues();
});
}

View File

@@ -94,6 +94,7 @@ urlpatterns = [
path('attributes/<int:pk>/delete/', views.ProductAttributeDeleteView.as_view(), name='attribute-delete'),
# API для атрибутов
path('api/attributes/', views.get_attributes_list_api, name='api-attributes-list'),
path('api/attributes/create/', views.create_attribute_api, name='api-attribute-create'),
path('api/attributes/<int:pk>/values/add/', views.add_attribute_value_api, name='attribute-add-value'),
path('api/attributes/<int:pk>/values/<int:value_id>/delete/', views.delete_attribute_value_api, name='attribute-delete-value'),

View File

@@ -102,6 +102,7 @@ from .attribute_views import (
create_attribute_api,
add_attribute_value_api,
delete_attribute_value_api,
get_attributes_list_api,
)
# API представления
@@ -193,6 +194,7 @@ __all__ = [
'create_attribute_api',
'add_attribute_value_api',
'delete_attribute_value_api',
'get_attributes_list_api',
# API
'search_products_and_variants',

View File

@@ -245,3 +245,30 @@ def delete_attribute_value_api(request, pk, value_id):
return JsonResponse({'success': False, 'error': 'Значение не найдено'})
except Exception as e:
return JsonResponse({'success': False, 'error': str(e)})
@login_required
def get_attributes_list_api(request):
"""
API для получения списка всех атрибутов с их значениями.
Используется для autocomplete в форме создания вариативного товара.
"""
attributes = ProductAttribute.objects.prefetch_related('values').order_by('position', 'name')
data = []
for attr in attributes:
data.append({
'id': attr.pk,
'name': attr.name,
'slug': attr.slug,
'values': [
{
'id': val.pk,
'value': val.value,
'slug': val.slug
}
for val in attr.values.all().order_by('position', 'value')
]
})
return JsonResponse({'success': True, 'attributes': data})

View File

@@ -13,7 +13,7 @@ from django.contrib.auth.decorators import login_required
from django.db import transaction
from user_roles.mixins import ManagerOwnerRequiredMixin
from ..models import ConfigurableProduct, ConfigurableProductOption, ProductKit, ConfigurableProductAttribute
from ..models import ConfigurableProduct, ConfigurableProductOption, ProductKit, ConfigurableProductAttribute, ProductAttribute
from ..forms import (
ConfigurableProductForm,
ConfigurableProductOptionFormSetCreate,
@@ -144,6 +144,9 @@ class ConfigurableProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixi
is_temporary=False
).order_by('name')
# Справочник атрибутов для autocomplete
context['product_attributes'] = ProductAttribute.objects.prefetch_related('values').order_by('position', 'name')
return context
def form_valid(self, form):
@@ -420,6 +423,9 @@ class ConfigurableProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixi
is_temporary=False
).order_by('name')
# Справочник атрибутов для autocomplete
context['product_attributes'] = ProductAttribute.objects.prefetch_related('values').order_by('position', 'name')
return context
def form_valid(self, form):