Добавлена модель атрибутов для вариативных товаров (ConfigurableKitProductAttribute)

- Создана модель ConfigurableKitProductAttribute с полями name, option, position, visible
- Добавлены формы и formsets для управления атрибутами родительского товара
- Обновлены CRUD представления для работы с атрибутами (создание/редактирование)
- Добавлен блок атрибутов в шаблоны создания/редактирования
- Обновлена страница детального просмотра с отображением атрибутов товара
- Добавлен JavaScript для динамического добавления форм атрибутов
- Реализована валидация дубликатов атрибутов в formset
- Атрибуты сохраняются в transaction.atomic() вместе с вариантами

Теперь можно определять схему атрибутов для экспорта на WooCommerce без использования JSON или ID, только name и option.
This commit is contained in:
2025-11-18 09:24:49 +03:00
parent bdea6b5398
commit c4260f6b1c
15 changed files with 2017 additions and 2 deletions

View File

@@ -1,6 +1,10 @@
from django import forms
from django.forms import inlineformset_factory
from .models import Product, ProductKit, ProductCategory, ProductTag, ProductPhoto, KitItem, ProductKitPhoto, ProductCategoryPhoto, ProductVariantGroup, ProductVariantGroupItem
from .models import (
Product, ProductKit, ProductCategory, ProductTag, ProductPhoto, KitItem,
ProductKitPhoto, ProductCategoryPhoto, ProductVariantGroup, ProductVariantGroupItem,
ConfigurableKitProduct, ConfigurableKitOption, ConfigurableKitProductAttribute
)
class ProductForm(forms.ModelForm):
@@ -577,3 +581,229 @@ class ProductTagForm(forms.ModelForm):
if slug == '' or slug is None:
return None
return slug
# ==================== CONFIGURABLE KIT FORMS ====================
class ConfigurableKitProductForm(forms.ModelForm):
"""
Форма для создания и редактирования вариативного товара.
"""
class Meta:
model = ConfigurableKitProduct
fields = ['name', 'sku', 'description', 'short_description', 'status']
labels = {
'name': 'Название',
'sku': 'Артикул',
'description': 'Полное описание',
'short_description': 'Краткое описание',
'status': 'Статус'
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['name'].widget.attrs.update({
'class': 'form-control',
'placeholder': 'Введите название вариативного товара'
})
self.fields['sku'].widget.attrs.update({
'class': 'form-control',
'placeholder': 'Артикул (необязательно)'
})
self.fields['description'].widget.attrs.update({
'class': 'form-control',
'rows': 5
})
self.fields['short_description'].widget.attrs.update({
'class': 'form-control',
'rows': 3,
'placeholder': 'Краткое описание для экспорта'
})
self.fields['status'].widget.attrs.update({'class': 'form-select'})
class ConfigurableKitOptionForm(forms.ModelForm):
"""
Форма для добавления варианта (комплекта) к вариативному товару.
"""
kit = forms.ModelChoiceField(
queryset=ProductKit.objects.filter(status='active', is_temporary=False).order_by('name'),
required=True,
label="Комплект",
widget=forms.Select(attrs={'class': 'form-select'})
)
class Meta:
model = ConfigurableKitOption
fields = ['kit', 'attributes', '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'
})
}
class BaseConfigurableKitOptionFormSet(forms.BaseInlineFormSet):
def clean(self):
"""Проверка на дубликаты комплектов в вариативном товаре"""
if any(self.errors):
return
kits = []
default_count = 0
for form in self.forms:
if self.can_delete and self._should_delete_form(form):
continue
kit = form.cleaned_data.get('kit')
is_default = form.cleaned_data.get('is_default')
# Проверка дубликатов комплектов
if kit:
if kit in kits:
raise forms.ValidationError(
f'Комплект "{kit.name}" добавлен более одного раза. '
f'Каждый комплект может быть добавлен только один раз.'
)
kits.append(kit)
# Считаем количество "is_default"
if is_default:
default_count += 1
# Проверяем, что не более одного "is_default"
if default_count > 1:
raise forms.ValidationError(
'Можно установить только один вариант как "по умолчанию".'
)
# Формсет для создания вариативного товара
ConfigurableKitOptionFormSetCreate = inlineformset_factory(
ConfigurableKitProduct,
ConfigurableKitOption,
form=ConfigurableKitOptionForm,
formset=BaseConfigurableKitOptionFormSet,
fields=['kit', 'attributes', 'is_default'],
extra=1, # Показать 1 пустую форму
can_delete=True,
min_num=0,
validate_min=False,
can_delete_extra=True,
)
# Формсет для редактирования вариативного товара
ConfigurableKitOptionFormSetUpdate = inlineformset_factory(
ConfigurableKitProduct,
ConfigurableKitOption,
form=ConfigurableKitOptionForm,
formset=BaseConfigurableKitOptionFormSet,
fields=['kit', 'attributes', 'is_default'],
extra=0, # НЕ показывать пустые формы
can_delete=True,
min_num=0,
validate_min=False,
can_delete_extra=True,
)
# === Формы для атрибутов родительского вариативного товара ===
class ConfigurableKitProductAttributeForm(forms.ModelForm):
"""
Форма для добавления атрибута родительского товара.
Пример: name="Цвет", option="Красный"
"""
class Meta:
model = ConfigurableKitProductAttribute
fields = ['name', 'option', 'position', 'visible']
labels = {
'name': 'Название атрибута',
'option': 'Значение опции',
'position': 'Порядок',
'visible': 'Видимый'
}
widgets = {
'name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Например: Цвет, Размер, Длина'
}),
'option': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Например: Красный, M, 60см'
}),
'position': forms.NumberInput(attrs={
'class': 'form-control',
'min': '0',
'value': '0'
}),
'visible': forms.CheckboxInput(attrs={
'class': 'form-check-input'
})
}
class BaseConfigurableKitProductAttributeFormSet(forms.BaseInlineFormSet):
def clean(self):
"""Проверка на дубликаты атрибутов"""
if any(self.errors):
return
attributes = []
for form in self.forms:
if self.can_delete and self._should_delete_form(form):
continue
name = form.cleaned_data.get('name')
option = form.cleaned_data.get('option')
# Проверка дубликатов
if name and option:
attr_tuple = (name.strip(), option.strip())
if attr_tuple in attributes:
raise forms.ValidationError(
f'Атрибут "{name}: {option}" добавлен более одного раза. '
f'Каждая комбинация атрибут-значение должна быть уникальной.'
)
attributes.append(attr_tuple)
# Формсет для создания атрибутов родительского товара
ConfigurableKitProductAttributeFormSetCreate = inlineformset_factory(
ConfigurableKitProduct,
ConfigurableKitProductAttribute,
form=ConfigurableKitProductAttributeForm,
formset=BaseConfigurableKitProductAttributeFormSet,
fields=['name', 'option', 'position', 'visible'],
extra=1,
can_delete=True,
min_num=0,
validate_min=False,
can_delete_extra=True,
)
# Формсет для редактирования атрибутов родительского товара
ConfigurableKitProductAttributeFormSetUpdate = inlineformset_factory(
ConfigurableKitProduct,
ConfigurableKitProductAttribute,
form=ConfigurableKitProductAttributeForm,
formset=BaseConfigurableKitProductAttributeFormSet,
fields=['name', 'option', 'position', 'visible'],
extra=0,
can_delete=True,
min_num=0,
validate_min=False,
can_delete_extra=True,
)

View File

@@ -0,0 +1,52 @@
# Generated by Django 5.0.10 on 2025-11-17 19:29
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ConfigurableKitProduct',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200, verbose_name='Название')),
('sku', models.CharField(blank=True, db_index=True, max_length=100, null=True, verbose_name='Артикул')),
('slug', models.SlugField(blank=True, max_length=200, unique=True, verbose_name='URL-идентификатор')),
('description', models.TextField(blank=True, null=True, verbose_name='Описание')),
('short_description', models.TextField(blank=True, help_text='Используется для карточек товаров, превью и площадок', null=True, verbose_name='Краткое описание')),
('status', models.CharField(choices=[('active', 'Активный'), ('archived', 'Архивный'), ('discontinued', 'Снят')], db_index=True, default='active', max_length=20, verbose_name='Статус')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('archived_at', models.DateTimeField(blank=True, null=True, verbose_name='Время архивирования')),
('archived_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='archived_%(class)s_set', to=settings.AUTH_USER_MODEL, verbose_name='Архивировано пользователем')),
],
options={
'verbose_name': 'Конфигурируемый товар (из комплектов)',
'verbose_name_plural': 'Конфигурируемые товары (из комплектов)',
},
),
migrations.CreateModel(
name='ConfigurableKitOption',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attributes', models.TextField(blank=True, verbose_name='Атрибуты варианта (для внешних площадок)')),
('is_default', models.BooleanField(default=False, verbose_name='Вариант по умолчанию')),
('kit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='as_configurable_option_in', to='products.productkit', verbose_name='Комплект (вариант)')),
('parent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='products.configurablekitproduct', verbose_name='Родитель (конфигурируемый товар)')),
],
options={
'verbose_name': 'Вариант комплекта',
'verbose_name_plural': 'Варианты комплектов',
'indexes': [models.Index(fields=['parent'], name='products_co_parent__56ecfa_idx'), models.Index(fields=['kit'], name='products_co_kit_id_3fa7fe_idx'), models.Index(fields=['parent', 'is_default'], name='products_co_parent__ffa4ca_idx')],
'unique_together': {('parent', 'kit')},
},
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.0.10 on 2025-11-17 19:30
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0002_configurablekitproduct_configurablekitoption'),
]
operations = [
migrations.AlterModelOptions(
name='configurablekitproduct',
options={'verbose_name': 'Вариативный товар (из комплектов)', 'verbose_name_plural': 'Вариативные товары (из комплектов)'},
),
migrations.AlterField(
model_name='configurablekitoption',
name='parent',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='products.configurablekitproduct', verbose_name='Родитель (вариативный товар)'),
),
]

View File

@@ -0,0 +1,32 @@
# Generated by Django 5.0.10 on 2025-11-17 21:38
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0003_alter_configurablekitproduct_options_and_more'),
]
operations = [
migrations.CreateModel(
name='ConfigurableKitProductAttribute',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Например: Цвет, Размер, Длина', max_length=150, verbose_name='Название атрибута')),
('option', models.CharField(help_text='Например: Красный, M, 60см', max_length=150, verbose_name='Значение опции')),
('position', models.PositiveIntegerField(default=0, help_text='Меньше = выше в списке', verbose_name='Порядок отображения')),
('visible', models.BooleanField(default=True, help_text='Показывать ли атрибут на странице товара', verbose_name='Видимый на витрине')),
('parent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='parent_attributes', to='products.configurablekitproduct', verbose_name='Родительский товар')),
],
options={
'verbose_name': 'Атрибут вариативного товара',
'verbose_name_plural': 'Атрибуты вариативных товаров',
'ordering': ['parent', 'position', 'name', 'option'],
'indexes': [models.Index(fields=['parent', 'name'], name='products_co_parent__4a7869_idx'), models.Index(fields=['parent', 'position'], name='products_co_parent__0904e2_idx')],
'unique_together': {('parent', 'name', 'option')},
},
),
]

View File

@@ -32,7 +32,7 @@ from .variants import ProductVariantGroup, ProductVariantGroupItem
from .products import Product
# Комплекты
from .kits import ProductKit, KitItem, KitItemPriority
from .kits import ProductKit, KitItem, KitItemPriority, ConfigurableKitProduct, ConfigurableKitOption, ConfigurableKitProductAttribute
# Фотографии
from .photos import BasePhoto, ProductPhoto, ProductKitPhoto, ProductCategoryPhoto, PhotoProcessingStatus
@@ -63,6 +63,9 @@ __all__ = [
'ProductKit',
'KitItem',
'KitItemPriority',
'ConfigurableKitProduct',
'ConfigurableKitOption',
'ConfigurableKitProductAttribute',
# Photos
'BasePhoto',

View File

@@ -378,3 +378,113 @@ class KitItemPriority(models.Model):
def __str__(self):
return f"{self.product.name} (приоритет {self.priority})"
class ConfigurableKitProduct(BaseProductEntity):
"""
Вариативный товар, объединяющий несколько наших ProductKit
как варианты для внешних площадок (WooCommerce и подобные).
"""
class Meta:
verbose_name = "Вариативный товар (из комплектов)"
verbose_name_plural = "Вариативные товары (из комплектов)"
# Уникальность активного имени наследуется из BaseProductEntity
def __str__(self):
return self.name
def delete(self, *args, **kwargs):
"""
Физическое удаление вариативного товара из БД.
При удалении удаляются только связи (ConfigurableKitOption),
но сами ProductKit остаются нетронутыми благодаря CASCADE на уровне связей.
"""
# Используем super() для вызова стандартного Django delete, минуя BaseProductEntity.delete()
super(BaseProductEntity, self).delete(*args, **kwargs)
class ConfigurableKitProductAttribute(models.Model):
"""
Атрибут родительского вариативного товара.
Определяет схему атрибутов для экспорта на WooCommerce и подобные площадки.
Например: name="Цвет", option="Красный" или name="Размер", option="M".
"""
parent = models.ForeignKey(
ConfigurableKitProduct,
on_delete=models.CASCADE,
related_name='parent_attributes',
verbose_name="Родительский товар"
)
name = models.CharField(
max_length=150,
verbose_name="Название атрибута",
help_text="Например: Цвет, Размер, Длина"
)
option = models.CharField(
max_length=150,
verbose_name="Значение опции",
help_text="Например: Красный, M, 60см"
)
position = models.PositiveIntegerField(
default=0,
verbose_name="Порядок отображения",
help_text="Меньше = выше в списке"
)
visible = models.BooleanField(
default=True,
verbose_name="Видимый на витрине",
help_text="Показывать ли атрибут на странице товара"
)
class Meta:
verbose_name = "Атрибут вариативного товара"
verbose_name_plural = "Атрибуты вариативных товаров"
ordering = ['parent', 'position', 'name', 'option']
unique_together = [['parent', 'name', 'option']]
indexes = [
models.Index(fields=['parent', 'name']),
models.Index(fields=['parent', 'position']),
]
def __str__(self):
return f"{self.parent.name} - {self.name}: {self.option}"
class ConfigurableKitOption(models.Model):
"""
Отдельный вариант внутри ConfigurableKitProduct, указывающий на конкретный ProductKit.
Атрибуты варианта хранятся простым текстом (можно расширить до JSON позже).
"""
parent = models.ForeignKey(
ConfigurableKitProduct,
on_delete=models.CASCADE,
related_name='options',
verbose_name="Родитель (вариативный товар)"
)
kit = models.ForeignKey(
ProductKit,
on_delete=models.CASCADE,
related_name='as_configurable_option_in',
verbose_name="Комплект (вариант)"
)
attributes = models.TextField(
blank=True,
verbose_name="Атрибуты варианта (для внешних площадок)"
)
is_default = models.BooleanField(
default=False,
verbose_name="Вариант по умолчанию"
)
class Meta:
verbose_name = "Вариант комплекта"
verbose_name_plural = "Варианты комплектов"
unique_together = [['parent', 'kit']]
indexes = [
models.Index(fields=['parent']),
models.Index(fields=['kit']),
models.Index(fields=['parent', 'is_default']),
]
def __str__(self):
return f"{self.parent.name}{self.kit.name}"

View File

@@ -0,0 +1,274 @@
/**
* Управление вариантами вариативного товара (ConfigurableKitProduct)
*/
document.addEventListener('DOMContentLoaded', function() {
// Проверяем наличие данных конфигурации
const configDataEl = document.getElementById('configurableKitData');
if (!configDataEl) return;
const config = JSON.parse(configDataEl.textContent);
const modal = new bootstrap.Modal(document.getElementById('addOptionModal'));
// Элементы формы
const saveBtn = document.getElementById('saveOptionBtn');
const kitSelect = document.getElementById('kitSelect');
const attributesInput = document.getElementById('attributesInput');
const isDefaultCheck = document.getElementById('isDefaultCheck');
const errorDiv = document.getElementById('addOptionError');
const spinner = document.getElementById('saveOptionSpinner');
// Добавление варианта
saveBtn.addEventListener('click', async function() {
const kitId = kitSelect.value;
if (!kitId) {
showError('Пожалуйста, выберите комплект');
return;
}
// Показываем спиннер
spinner.classList.remove('d-none');
saveBtn.disabled = true;
hideError();
const formData = new FormData();
formData.append('kit_id', kitId);
formData.append('attributes', attributesInput.value.trim());
formData.append('is_default', isDefaultCheck.checked ? 'true' : 'false');
formData.append('csrfmiddlewaretoken', getCsrfToken());
try {
const response = await fetch(config.addOptionUrl, {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
// Добавляем строку в таблицу
addOptionToTable(data.option);
// Закрываем модальное окно
modal.hide();
// Очищаем форму
resetForm();
// Показываем уведомление
showSuccessMessage('Вариант успешно добавлен');
} else {
showError(data.error || 'Ошибка при добавлении варианта');
}
} catch (error) {
console.error('Error:', error);
showError('Произошла ошибка при сохранении');
} finally {
spinner.classList.add('d-none');
saveBtn.disabled = false;
}
});
// Удаление варианта
document.addEventListener('click', async function(e) {
if (e.target.closest('.remove-option-btn')) {
const btn = e.target.closest('.remove-option-btn');
const optionId = btn.dataset.optionId;
if (!confirm('Вы уверены, что хотите удалить этот вариант?')) {
return;
}
btn.disabled = true;
try {
const url = config.removeOptionUrlTemplate.replace('{optionId}', optionId);
const response = await fetch(url, {
method: 'POST',
headers: {
'X-CSRFToken': getCsrfToken()
}
});
const data = await response.json();
if (data.success) {
// Удаляем строку из таблицы
const row = btn.closest('tr');
row.remove();
// Проверяем, есть ли ещё варианты
checkIfTableEmpty();
showSuccessMessage('Вариант удалён');
} else {
showError(data.error || 'Ошибка при удалении');
btn.disabled = false;
}
} catch (error) {
console.error('Error:', error);
alert('Произошла ошибка при удалении');
btn.disabled = false;
}
}
});
// Установка по умолчанию
document.addEventListener('click', async function(e) {
if (e.target.closest('.set-default-btn')) {
const btn = e.target.closest('.set-default-btn');
const optionId = btn.dataset.optionId;
btn.disabled = true;
try {
const url = config.setDefaultUrlTemplate.replace('{optionId}', optionId);
const response = await fetch(url, {
method: 'POST',
headers: {
'X-CSRFToken': getCsrfToken()
}
});
const data = await response.json();
if (data.success) {
// Обновляем UI
updateDefaultBadges(optionId);
showSuccessMessage('Вариант установлен как по умолчанию');
} else {
showError(data.error || 'Ошибка при установке');
btn.disabled = false;
}
} catch (error) {
console.error('Error:', error);
alert('Произошла ошибка');
btn.disabled = false;
}
}
});
// Вспомогательные функции
function addOptionToTable(option) {
const tbody = document.querySelector('#optionsTable tbody');
const noOptionsMsg = document.getElementById('noOptionsMessage');
// Если таблицы нет, создаём её
if (!tbody) {
const container = document.getElementById('optionsTableContainer');
if (noOptionsMsg) noOptionsMsg.remove();
container.innerHTML = `
<div class="table-responsive">
<table class="table table-hover table-sm mb-0" id="optionsTable">
<thead class="table-light">
<tr>
<th>Комплект</th>
<th>Артикул</th>
<th>Цена</th>
<th>Атрибуты</th>
<th style="width: 120px;">По умолчанию</th>
<th style="width: 150px;">Действия</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
`;
}
const newTbody = document.querySelector('#optionsTable tbody');
const kitDetailUrl = config.kitDetailUrlTemplate.replace('{kitId}', option.kit_id);
const row = document.createElement('tr');
row.dataset.optionId = option.id;
row.innerHTML = `
<td>
<a href="${kitDetailUrl}" class="text-decoration-none">
${option.kit_name}
</a>
</td>
<td><small class="text-muted">${option.kit_sku}</small></td>
<td><strong>${option.kit_price}</strong> руб.</td>
<td><small class="text-muted option-attributes">${option.attributes}</small></td>
<td class="text-center">
${option.is_default
? '<span class="badge bg-primary default-badge">Да</span>'
: `<button class="btn btn-sm btn-outline-secondary set-default-btn" data-option-id="${option.id}">Установить</button>`
}
</td>
<td>
<button class="btn btn-sm btn-outline-danger remove-option-btn" data-option-id="${option.id}">
<i class="bi bi-trash"></i> Удалить
</button>
</td>
`;
newTbody.appendChild(row);
}
function updateDefaultBadges(newDefaultId) {
// Убираем все badges "По умолчанию"
document.querySelectorAll('.default-badge').forEach(badge => {
const td = badge.closest('td');
const optionId = badge.closest('tr').dataset.optionId;
td.innerHTML = `<button class="btn btn-sm btn-outline-secondary set-default-btn" data-option-id="${optionId}">Установить</button>`;
});
// Добавляем badge к новому default
const targetRow = document.querySelector(`tr[data-option-id="${newDefaultId}"]`);
if (targetRow) {
const td = targetRow.querySelector('td:nth-child(5)');
td.innerHTML = '<span class="badge bg-primary default-badge">Да</span>';
}
}
function checkIfTableEmpty() {
const tbody = document.querySelector('#optionsTable tbody');
if (tbody && tbody.children.length === 0) {
const container = document.getElementById('optionsTableContainer');
container.innerHTML = '<p class="text-muted text-center py-4" id="noOptionsMessage">Нет вариантов. Нажмите "Добавить вариант" для добавления.</p>';
}
}
function resetForm() {
kitSelect.value = '';
attributesInput.value = '';
isDefaultCheck.checked = false;
hideError();
}
function showError(message) {
errorDiv.textContent = message;
errorDiv.classList.remove('d-none');
}
function hideError() {
errorDiv.classList.add('d-none');
}
function getCsrfToken() {
return document.querySelector('[name=csrfmiddlewaretoken]').value;
}
function showSuccessMessage(message) {
// Простое alert, можно заменить на toast-уведомление
const alertDiv = document.createElement('div');
alertDiv.className = 'alert alert-success alert-dismissible fade show position-fixed top-0 start-50 translate-middle-x mt-3';
alertDiv.style.zIndex = '9999';
alertDiv.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(alertDiv);
setTimeout(() => {
alertDiv.remove();
}, 3000);
}
// Сброс формы при закрытии модального окна
document.getElementById('addOptionModal').addEventListener('hidden.bs.modal', function() {
resetForm();
});
});

View File

@@ -0,0 +1,49 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Удаление вариативного товара{% endblock %}
{% block content %}
<div class="container-fluid px-4 py-3">
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb breadcrumb-sm mb-0">
<li class="breadcrumb-item"><a href="{% url 'products:configurablekit-list' %}">Вариативные товары</a></li>
<li class="breadcrumb-item"><a href="{% url 'products:configurablekit-detail' object.pk %}">{{ object.name }}</a></li>
<li class="breadcrumb-item active">Удаление</li>
</ol>
</nav>
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card border-0 shadow-sm">
<div class="card-header bg-danger text-white">
<h5 class="mb-0"><i class="bi bi-exclamation-triangle me-2"></i>Подтверждение удаления</h5>
</div>
<div class="card-body">
<p class="mb-3">
Вы уверены, что хотите удалить вариативный товар <strong>{{ object.name }}</strong>?
</p>
{% if object.options.count > 0 %}
<div class="alert alert-warning">
<i class="bi bi-exclamation-circle me-2"></i>
<strong>Внимание:</strong> У этого вариативного товара есть {{ object.options.count }} вариант(ов).
При удалении связи с комплектами будут удалены.
</div>
{% endif %}
<form method="post" class="d-inline">
{% csrf_token %}
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash me-1"></i>Да, удалить
</button>
<a href="{% url 'products:configurablekit-detail' object.pk %}" class="btn btn-secondary">
<i class="bi bi-x-circle me-1"></i>Отмена
</a>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,198 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}{{ configurable_kit.name }} - Детали{% endblock %}
{% block content %}
{% csrf_token %}
<div class="container-fluid px-4 py-3">
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb breadcrumb-sm mb-0">
<li class="breadcrumb-item"><a href="{% url 'products:configurablekit-list' %}">Вариативные товары</a></li>
<li class="breadcrumb-item active">{{ configurable_kit.name }}</li>
</ol>
</nav>
<!-- Заголовок -->
<div class="mb-3 d-flex justify-content-between align-items-center">
<h4 class="mb-0">{{ configurable_kit.name }}</h4>
<div>
<a href="{% url 'products:configurablekit-update' configurable_kit.pk %}" class="btn btn-warning btn-sm">
<i class="bi bi-pencil me-1"></i>Редактировать
</a>
<a href="{% url 'products:configurablekit-delete' configurable_kit.pk %}" class="btn btn-danger btn-sm">
<i class="bi bi-trash me-1"></i>Удалить
</a>
</div>
</div>
<div class="row">
<!-- Основная информация -->
<div class="col-lg-8">
<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">
<table class="table table-sm">
<tbody>
<tr>
<th style="width: 200px;">Название:</th>
<td>{{ configurable_kit.name }}</td>
</tr>
<tr>
<th>Артикул:</th>
<td>{{ configurable_kit.sku|default:"—" }}</td>
</tr>
<tr>
<th>Статус:</th>
<td>
{% if configurable_kit.status == 'active' %}
<span class="badge bg-success">Активный</span>
{% elif configurable_kit.status == 'archived' %}
<span class="badge bg-warning">Архивный</span>
{% else %}
<span class="badge bg-secondary">Снятый</span>
{% endif %}
</td>
</tr>
<tr>
<th>Краткое описание:</th>
<td>{{ configurable_kit.short_description|default:"—" }}</td>
</tr>
<tr>
<th>Описание:</th>
<td>{{ configurable_kit.description|default:"—" }}</td>
</tr>
<tr>
<th>Дата создания:</th>
<td>{{ configurable_kit.created_at|date:"d.m.Y H:i" }}</td>
</tr>
<tr>
<th>Дата обновления:</th>
<td>{{ configurable_kit.updated_at|date:"d.m.Y H:i" }}</td>
</tr>
</tbody>
</table>
</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">
{% if configurable_kit.options.all %}
<div class="table-responsive">
<table class="table table-hover table-sm mb-0">
<thead class="table-light">
<tr>
<th>Комплект</th>
<th>Артикул</th>
<th>Цена</th>
<th>Атрибуты</th>
<th style="width: 120px;">По умолчанию</th>
</tr>
</thead>
<tbody>
{% for option in configurable_kit.options.all %}
<tr>
<td>
<a href="{% url 'products:productkit-detail' option.kit.pk %}" class="text-decoration-none">
{{ option.kit.name }}
</a>
</td>
<td><small class="text-muted">{{ option.kit.sku|default:"—" }}</small></td>
<td><strong>{{ option.kit.actual_price }}</strong> руб.</td>
<td><small class="text-muted">{{ option.attributes|default:"—" }}</small></td>
<td class="text-center">
{% if option.is_default %}
<span class="badge bg-primary">Да</span>
{% else %}
<span class="badge bg-secondary">Нет</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted text-center py-4">
Нет вариантов. Перейдите в режим редактирования для добавления.
</p>
{% endif %}
</div>
</div>
<!-- Атрибуты родительского товара -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-white">
<h5 class="mb-0">Атрибуты товара</h5>
</div>
<div class="card-body">
{% if configurable_kit.parent_attributes.all %}
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th>Название атрибута</th>
<th>Значение опции</th>
<th>Порядок</th>
<th>Видимый</th>
</tr>
</thead>
<tbody>
{% for attr in configurable_kit.parent_attributes.all %}
<tr>
<td><strong>{{ attr.name }}</strong></td>
<td>{{ attr.option }}</td>
<td><span class="badge bg-secondary">{{ attr.position }}</span></td>
<td>
{% if attr.visible %}
<span class="badge bg-success">Да</span>
{% else %}
<span class="badge bg-secondary">Нет</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted text-center py-4">
Нет атрибутов. Перейдите в режим редактирования для добавления.
</p>
{% endif %}
</div>
</div>
</div>
<!-- Боковая панель -->
<div class="col-lg-4">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white">
<h6 class="mb-0">Справка</h6>
</div>
<div class="card-body">
<p class="small text-muted">
Вариативный товар предназначен для экспорта на WooCommerce и подобные площадки как Variable Product.
</p>
<p class="small text-muted">
Каждый вариант — это отдельный ProductKit с собственной ценой, артикулом и атрибутами.
</p>
<hr>
<p class="small text-muted mb-1">
<strong>Количество вариантов:</strong> {{ configurable_kit.options.count }}
</p>
<p class="small text-muted mb-0">
<strong>Атрибутов товара:</strong> {{ configurable_kit.parent_attributes.count }}
</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,494 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}{% if object %}Редактирование{% else %}Создание{% endif %} вариативного товара{% endblock %}
{% block extra_css %}
<style>
/* Скрываем чекбоксы DELETE в formset */
input[name*="DELETE"] {
display: none !important;
}
/* Стили для switch */
.form-check-input.is-default-switch {
cursor: pointer;
width: 3rem;
height: 1.5rem;
}
.default-switch-label {
font-size: 0.875rem;
margin-left: 0.5rem;
line-height: 1.5rem;
}
/* Выравнивание switch по центру */
.col-md-2 .form-check.form-switch {
display: flex;
align-items: center;
min-height: 38px;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-4 py-3">
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb breadcrumb-sm mb-0">
<li class="breadcrumb-item"><a href="{% url 'products:configurablekit-list' %}">Вариативные товары</a></li>
<li class="breadcrumb-item active">{% if object %}Редактирование{% else %}Создание{% endif %}</li>
</ol>
</nav>
<form method="post">
{% csrf_token %}
<div class="row">
<!-- Основная информация -->
<div class="col-lg-8">
<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">
<div class="mb-3">
<label for="{{ form.name.id_for_label }}" class="form-label">Название <span class="text-danger">*</span></label>
{{ form.name }}
{% if form.name.errors %}
<div class="text-danger small">{{ form.name.errors.0 }}</div>
{% endif %}
</div>
<div class="mb-3">
<label for="{{ form.sku.id_for_label }}" class="form-label">Артикул</label>
{{ form.sku }}
{% if form.sku.errors %}
<div class="text-danger small">{{ form.sku.errors.0 }}</div>
{% endif %}
</div>
<div class="mb-3">
<label for="{{ form.short_description.id_for_label }}" class="form-label">Краткое описание</label>
{{ form.short_description }}
{% if form.short_description.errors %}
<div class="text-danger small">{{ form.short_description.errors.0 }}</div>
{% endif %}
</div>
<div class="mb-3">
<label for="{{ form.description.id_for_label }}" class="form-label">Полное описание</label>
{{ form.description }}
{% if form.description.errors %}
<div class="text-danger small">{{ form.description.errors.0 }}</div>
{% endif %}
</div>
<div class="mb-0">
<label for="{{ form.status.id_for_label }}" class="form-label">Статус</label>
{{ form.status }}
{% if form.status.errors %}
<div class="text-danger small">{{ form.status.errors.0 }}</div>
{% endif %}
</div>
</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">&nbsp;</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">
<h5 class="mb-0">Атрибуты товара (для WooCommerce)</h5>
</div>
<div class="card-body">
<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 }}
</div>
{% endif %}
<div id="attributeFormsetContainer">
{% for form in attribute_formset %}
<div class="attribute-form border rounded p-3 mb-3" style="background: #f8f9fa;">
{{ form.id }}
{% if form.instance.pk %}
<input type="hidden" name="attributes-{{ forloop.counter0 }}-id" value="{{ form.instance.pk }}">
{% endif %}
<div class="row g-2">
<div class="col-md-3">
<label class="form-label small">{{ form.name.label }}</label>
{{ form.name }}
{% if form.name.errors %}
<div class="text-danger small">{{ form.name.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
<label class="form-label small">{{ form.option.label }}</label>
{{ form.option }}
{% if form.option.errors %}
<div class="text-danger small">{{ form.option.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-2">
<label class="form-label small">{{ form.position.label }}</label>
{{ form.position }}
{% if form.position.errors %}
<div class="text-danger small">{{ form.position.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-2">
<label class="form-label small d-block">{{ form.visible.label }}</label>
<div class="form-check">
{{ form.visible }}
<label class="form-check-label" for="{{ form.visible.id_for_label }}">
Показывать
</label>
</div>
{% if form.visible.errors %}
<div class="text-danger small">{{ form.visible.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-2">
{% if attribute_formset.can_delete %}
<label class="form-label small d-block">&nbsp;</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="addAttributeBtn">
<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>Сохранить
</button>
<a href="{% if object %}{% url 'products:configurablekit-detail' object.pk %}{% else %}{% url 'products:configurablekit-list' %}{% endif %}"
class="btn btn-secondary">
<i class="bi bi-x-circle me-1"></i>Отмена
</a>
</div>
</div>
<!-- Боковая панель -->
<div class="col-lg-4">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white">
<h6 class="mb-0">Справка</h6>
</div>
<div class="card-body">
<p class="small text-muted">
Вариативный товар объединяет несколько комплектов как варианты для экспорта на WooCommerce и подобные площадки.
</p>
<p class="small text-muted">
Каждый вариант — это отдельный ProductKit с собственной ценой, артикулом и атрибутами.
</p>
</div>
</div>
</div>
</div>
</form>
</div>
<script>
// Добавление новых форм вариантов
document.getElementById('addOptionBtn').addEventListener('click', function() {
const container = document.getElementById('optionFormsetContainer');
const totalForms = document.querySelector('[name="options-TOTAL_FORMS"]');
const formIdx = parseInt(totalForms.value);
// Создаём новую форму 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">
<label class="form-label small">Комплект</label>
<select name="options-${formIdx}-kit" id="id_options-${formIdx}-kit" class="form-select">
<option value="">---------</option>
{% for kit in option_formset.empty_form.fields.kit.queryset %}
<option value="{{ kit.id }}">{{ kit.name }}{% if kit.sku %} ({{ kit.sku }}){% endif %}</option>
{% 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>
<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"
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">
<label class="form-label small d-block">&nbsp;</label>
<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> Удалить
</label>
</div>
</div>
</div>
`;
container.insertAdjacentHTML('beforeend', newFormHtml);
totalForms.value = formIdx + 1;
// Переинициализируем логику switch после добавления новой формы
initDefaultSwitches();
});
// Скрытие удаленных форм
document.addEventListener('change', function(e) {
if (e.target.type === 'checkbox' && e.target.name && e.target.name.includes('DELETE')) {
const form = e.target.closest('.option-form');
if (e.target.checked) {
form.style.opacity = '0.5';
form.style.textDecoration = 'line-through';
} else {
form.style.opacity = '1';
form.style.textDecoration = 'none';
}
}
});
// Логика для switch "По умолчанию"
function initDefaultSwitches() {
const container = document.getElementById('optionFormsetContainer');
// Функция для обновления текста label
function updateSwitchLabel(switchInput) {
const label = switchInput.closest('.form-check').querySelector('.default-switch-label');
if (label) {
label.textContent = switchInput.checked ? 'Да' : 'Нет';
}
}
// Функция для проверки и установки единственного варианта по умолчанию
function ensureSingleDefault() {
const visibleSwitches = Array.from(container.querySelectorAll('.is-default-switch')).filter(sw => {
const form = sw.closest('.option-form');
const deleteCheckbox = form.querySelector('input[name*="DELETE"]');
return !deleteCheckbox || !deleteCheckbox.checked;
});
// Если только один вариант - включаем его автоматически
if (visibleSwitches.length === 1) {
visibleSwitches[0].checked = true;
visibleSwitches[0].disabled = true;
updateSwitchLabel(visibleSwitches[0]);
} else {
// Если вариантов несколько - убираем disabled
visibleSwitches.forEach(sw => {
sw.disabled = false;
});
}
// Проверяем, есть ли хотя бы один включенный
const hasChecked = visibleSwitches.some(sw => sw.checked);
if (!hasChecked && visibleSwitches.length > 0) {
// Если ни один не включен, включаем первый
visibleSwitches[0].checked = true;
updateSwitchLabel(visibleSwitches[0]);
}
}
// Обработчик изменения switch
container.addEventListener('change', function(e) {
if (e.target.classList.contains('is-default-switch')) {
if (e.target.checked) {
// Выключаем все остальные
const allSwitches = container.querySelectorAll('.is-default-switch');
allSwitches.forEach(sw => {
if (sw !== e.target) {
sw.checked = false;
updateSwitchLabel(sw);
}
});
}
updateSwitchLabel(e.target);
ensureSingleDefault();
}
// При изменении DELETE тоже проверяем
if (e.target.name && e.target.name.includes('DELETE')) {
ensureSingleDefault();
}
});
// Инициализация при загрузке
ensureSingleDefault();
container.querySelectorAll('.is-default-switch').forEach(updateSwitchLabel);
}
// Запускаем инициализацию
initDefaultSwitches();
// === Добавление новых форм атрибутов ===
document.getElementById('addAttributeBtn').addEventListener('click', function() {
const container = document.getElementById('attributeFormsetContainer');
const totalForms = document.querySelector('[name="attributes-TOTAL_FORMS"]');
const formIdx = parseInt(totalForms.value);
// Создаём новую форму HTML
const newFormHtml = `
<div class="attribute-form border rounded p-3 mb-3" style="background: #f8f9fa;">
<div class="row g-2">
<div class="col-md-3">
<label class="form-label small">Название атрибута</label>
<input type="text" name="attributes-${formIdx}-name"
id="id_attributes-${formIdx}-name"
class="form-control"
placeholder="Например: Цвет, Размер, Длина">
</div>
<div class="col-md-3">
<label class="form-label small">Значение опции</label>
<input type="text" name="attributes-${formIdx}-option"
id="id_attributes-${formIdx}-option"
class="form-control"
placeholder="Например: Красный, M, 60см">
</div>
<div class="col-md-2">
<label class="form-label small">Порядок</label>
<input type="number" name="attributes-${formIdx}-position"
id="id_attributes-${formIdx}-position"
class="form-control"
min="0" value="0">
</div>
<div class="col-md-2">
<label class="form-label small d-block">Видимый</label>
<div class="form-check">
<input type="checkbox" name="attributes-${formIdx}-visible"
id="id_attributes-${formIdx}-visible"
class="form-check-input" checked>
<label class="form-check-label" for="id_attributes-${formIdx}-visible">
Показывать
</label>
</div>
</div>
<div class="col-md-2">
<label class="form-label small d-block">&nbsp;</label>
<input type="checkbox" name="attributes-${formIdx}-DELETE"
id="id_attributes-${formIdx}-DELETE"
style="display:none;">
<label for="id_attributes-${formIdx}-DELETE" class="btn btn-sm btn-outline-danger d-block">
<i class="bi bi-trash"></i> Удалить
</label>
</div>
</div>
</div>
`;
container.insertAdjacentHTML('beforeend', newFormHtml);
totalForms.value = formIdx + 1;
});
// Скрытие удаленных атрибутов
document.addEventListener('change', function(e) {
if (e.target.type === 'checkbox' && e.target.name && e.target.name.includes('attributes') && e.target.name.includes('DELETE')) {
const form = e.target.closest('.attribute-form');
if (form) {
if (e.target.checked) {
form.style.opacity = '0.5';
form.style.textDecoration = 'line-through';
} else {
form.style.opacity = '1';
form.style.textDecoration = 'none';
}
}
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,146 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Вариативные товары{% endblock %}
{% block content %}
<div class="container-fluid px-4 py-3">
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb breadcrumb-sm mb-0">
<li class="breadcrumb-item active">Вариативные товары</li>
</ol>
</nav>
<!-- Заголовок и кнопки действий -->
<div class="mb-3 d-flex justify-content-between align-items-center">
<h4 class="mb-0">Вариативные товары</h4>
<div>
{% for button in action_buttons %}
<a href="{{ button.url }}" class="btn {{ button.class }} btn-sm">
<i class="bi bi-{{ button.icon }} me-1"></i>{{ button.text }}
</a>
{% endfor %}
</div>
</div>
<!-- Поиск и фильтры -->
<div class="card border-0 shadow-sm mb-3">
<div class="card-body p-3">
<form method="get" class="row g-2">
<div class="col-md-6">
<input type="text" name="search" class="form-control form-control-sm"
placeholder="Поиск по названию, артикулу, описанию..."
value="{{ filters.current.search }}">
</div>
<div class="col-md-3">
<select name="status" class="form-select form-select-sm">
<option value="">Все статусы</option>
<option value="active" {% if filters.current.status == 'active' %}selected{% endif %}>Активные</option>
<option value="archived" {% if filters.current.status == 'archived' %}selected{% endif %}>Архивные</option>
<option value="discontinued" {% if filters.current.status == 'discontinued' %}selected{% endif %}>Снятые</option>
</select>
</div>
<div class="col-md-3">
<button type="submit" class="btn btn-outline-primary btn-sm w-100">
<i class="bi bi-search"></i> Поиск
</button>
</div>
</form>
</div>
</div>
<!-- Таблица -->
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover table-sm mb-0">
<thead class="table-light">
<tr>
<th>Название</th>
<th>Артикул</th>
<th style="width: 120px;">Статус</th>
<th style="width: 100px;">Вариантов</th>
<th style="width: 150px;">Дата создания</th>
<th style="width: 180px;">Действия</th>
</tr>
</thead>
<tbody>
{% for item in configurable_kits %}
<tr>
<td class="fw-semibold">
<a href="{% url 'products:configurablekit-detail' item.pk %}" class="text-decoration-none">
{{ item.name }}
</a>
</td>
<td>
<small class="text-muted">{{ item.sku|default:"-" }}</small>
</td>
<td>
{% if item.status == 'active' %}
<span class="badge bg-success">Активный</span>
{% elif item.status == 'archived' %}
<span class="badge bg-warning">Архивный</span>
{% else %}
<span class="badge bg-secondary">Снятый</span>
{% endif %}
</td>
<td class="text-center">
<span class="badge bg-info">{{ item.options.count }}</span>
</td>
<td><small class="text-muted">{{ item.created_at|date:"d.m.Y H:i" }}</small></td>
<td>
<a href="{% url 'products:configurablekit-detail' item.pk %}"
class="btn btn-sm btn-outline-primary" title="Просмотр">
<i class="bi bi-eye"></i>
</a>
<a href="{% url 'products:configurablekit-update' item.pk %}"
class="btn btn-sm btn-outline-warning" title="Редактировать">
<i class="bi bi-pencil"></i>
</a>
<a href="{% url 'products:configurablekit-delete' item.pk %}"
class="btn btn-sm btn-outline-danger" title="Удалить">
<i class="bi bi-trash"></i>
</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-center text-muted py-4">
Нет вариативных товаров. <a href="{% url 'products:configurablekit-create' %}">Создать первый</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Пагинация -->
{% if is_paginated %}
<nav class="mt-4" aria-label="Pagination">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1{% if filters.current.search %}&search={{ filters.current.search }}{% endif %}{% if filters.current.status %}&status={{ filters.current.status }}{% endif %}">Первая</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if filters.current.search %}&search={{ filters.current.search }}{% endif %}{% if filters.current.status %}&status={{ filters.current.status }}{% 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 filters.current.search %}&search={{ filters.current.search }}{% endif %}{% if filters.current.status %}&status={{ filters.current.status }}{% endif %}">Следующая</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if filters.current.search %}&search={{ filters.current.search }}{% endif %}{% if filters.current.status %}&status={{ filters.current.status }}{% endif %}">Последняя</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
</div>
{% endblock %}

View File

@@ -77,4 +77,16 @@ urlpatterns = [
path('tags/<int:pk>/', views.ProductTagDetailView.as_view(), name='tag-detail'),
path('tags/<int:pk>/update/', views.ProductTagUpdateView.as_view(), name='tag-update'),
path('tags/<int:pk>/delete/', views.ProductTagDeleteView.as_view(), name='tag-delete'),
# CRUD URLs for ConfigurableKitProduct
path('configurable-kits/', views.ConfigurableKitProductListView.as_view(), name='configurablekit-list'),
path('configurable-kits/create/', views.ConfigurableKitProductCreateView.as_view(), name='configurablekit-create'),
path('configurable-kits/<int:pk>/', views.ConfigurableKitProductDetailView.as_view(), name='configurablekit-detail'),
path('configurable-kits/<int:pk>/update/', views.ConfigurableKitProductUpdateView.as_view(), name='configurablekit-update'),
path('configurable-kits/<int:pk>/delete/', views.ConfigurableKitProductDeleteView.as_view(), name='configurablekit-delete'),
# API для управления вариантами ConfigurableKitProduct
path('configurable-kits/<int:pk>/options/add/', views.add_option_to_configurable, name='configurablekit-add-option'),
path('configurable-kits/<int:pk>/options/<int:option_id>/remove/', views.remove_option_from_configurable, name='configurablekit-remove-option'),
path('configurable-kits/<int:pk>/options/<int:option_id>/set-default/', views.set_option_as_default, name='configurablekit-set-default-option'),
]

View File

@@ -79,6 +79,18 @@ from .tag_views import (
ProductTagDeleteView,
)
# CRUD представления для ConfigurableKitProduct
from .configurablekit_views import (
ConfigurableKitProductListView,
ConfigurableKitProductCreateView,
ConfigurableKitProductDetailView,
ConfigurableKitProductUpdateView,
ConfigurableKitProductDeleteView,
add_option_to_configurable,
remove_option_from_configurable,
set_option_as_default,
)
# API представления
from .api_views import search_products_and_variants, validate_kit_cost, create_temporary_kit_api, create_tag_api
@@ -145,6 +157,16 @@ __all__ = [
'ProductTagUpdateView',
'ProductTagDeleteView',
# ConfigurableKitProduct CRUD
'ConfigurableKitProductListView',
'ConfigurableKitProductCreateView',
'ConfigurableKitProductDetailView',
'ConfigurableKitProductUpdateView',
'ConfigurableKitProductDeleteView',
'add_option_to_configurable',
'remove_option_from_configurable',
'set_option_as_default',
# API
'search_products_and_variants',
'validate_kit_cost',

View File

@@ -0,0 +1,367 @@
"""
CRUD представления для вариативных товаров (ConfigurableKitProduct).
"""
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.views.generic import ListView, CreateView, DetailView, UpdateView, DeleteView
from django.urls import reverse_lazy
from django.db.models import Q, Prefetch
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.views.decorators.http import require_POST
from django.contrib.auth.decorators import login_required
from django.db import transaction
from ..models import ConfigurableKitProduct, ConfigurableKitOption, ProductKit, ConfigurableKitProductAttribute
from ..forms import (
ConfigurableKitProductForm,
ConfigurableKitOptionFormSetCreate,
ConfigurableKitOptionFormSetUpdate,
ConfigurableKitProductAttributeFormSetCreate,
ConfigurableKitProductAttributeFormSetUpdate
)
class ConfigurableKitProductListView(LoginRequiredMixin, ListView):
model = ConfigurableKitProduct
template_name = 'products/configurablekit_list.html'
context_object_name = 'configurable_kits'
paginate_by = 20
def get_queryset(self):
queryset = super().get_queryset().prefetch_related(
Prefetch(
'options',
queryset=ConfigurableKitOption.objects.select_related('kit')
)
)
# Поиск
search_query = self.request.GET.get('search')
if search_query:
queryset = queryset.filter(
Q(name__icontains=search_query) |
Q(sku__icontains=search_query) |
Q(description__icontains=search_query)
)
# Фильтр по статусу
status_filter = self.request.GET.get('status')
if status_filter:
queryset = queryset.filter(status=status_filter)
return queryset.order_by('-created_at')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Данные для фильтров
context['filters'] = {
'current': {
'search': self.request.GET.get('search', ''),
'status': self.request.GET.get('status', ''),
}
}
# Кнопки действий
action_buttons = []
if self.request.user.has_perm('products.add_configurablekitproduct'):
action_buttons.append({
'url': reverse_lazy('products:configurablekit-create'),
'text': 'Создать вариативный товар',
'class': 'btn-primary',
'icon': 'plus-circle'
})
context['action_buttons'] = action_buttons
return context
class ConfigurableKitProductDetailView(LoginRequiredMixin, DetailView):
model = ConfigurableKitProduct
template_name = 'products/configurablekit_detail.html'
context_object_name = 'configurable_kit'
def get_queryset(self):
return super().get_queryset().prefetch_related(
Prefetch(
'options',
queryset=ConfigurableKitOption.objects.select_related('kit').order_by('id')
),
'parent_attributes'
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Добавляем доступные комплекты для выбора (активные, не временные)
context['available_kits'] = ProductKit.objects.filter(
status='active',
is_temporary=False
).order_by('name')
return context
class ConfigurableKitProductCreateView(LoginRequiredMixin, CreateView):
model = ConfigurableKitProduct
form_class = ConfigurableKitProductForm
template_name = 'products/configurablekit_form.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Formset для вариантов
if 'option_formset' in kwargs:
context['option_formset'] = kwargs['option_formset']
elif self.request.POST:
context['option_formset'] = ConfigurableKitOptionFormSetCreate(
self.request.POST,
prefix='options'
)
else:
context['option_formset'] = ConfigurableKitOptionFormSetCreate(
prefix='options'
)
# Formset для атрибутов родителя
if 'attribute_formset' in kwargs:
context['attribute_formset'] = kwargs['attribute_formset']
elif self.request.POST:
context['attribute_formset'] = ConfigurableKitProductAttributeFormSetCreate(
self.request.POST,
prefix='attributes'
)
else:
context['attribute_formset'] = ConfigurableKitProductAttributeFormSetCreate(
prefix='attributes'
)
return context
def form_valid(self, form):
# Пересоздаём formsets с POST данными
option_formset = ConfigurableKitOptionFormSetCreate(
self.request.POST,
prefix='options'
)
attribute_formset = ConfigurableKitProductAttributeFormSetCreate(
self.request.POST,
prefix='attributes'
)
if not form.is_valid():
messages.error(self.request, 'Пожалуйста, исправьте ошибки в основной форме.')
return self.form_invalid(form)
if not option_formset.is_valid():
messages.error(self.request, 'Пожалуйста, исправьте ошибки в вариантах.')
return self.render_to_response(self.get_context_data(form=form, option_formset=option_formset, attribute_formset=attribute_formset))
if not attribute_formset.is_valid():
messages.error(self.request, 'Пожалуйста, исправьте ошибки в атрибутах.')
return self.render_to_response(self.get_context_data(form=form, option_formset=option_formset, attribute_formset=attribute_formset))
try:
with transaction.atomic():
# Сохраняем основную форму
self.object = form.save()
# Сохраняем варианты
option_formset.instance = self.object
option_formset.save()
# Сохраняем атрибуты родителя
attribute_formset.instance = self.object
attribute_formset.save()
messages.success(self.request, f'Вариативный товар "{self.object.name}" успешно создан!')
return super().form_valid(form)
except Exception as e:
messages.error(self.request, f'Ошибка при сохранении: {str(e)}')
return self.form_invalid(form)
def get_success_url(self):
return reverse_lazy('products:configurablekit-detail', kwargs={'pk': self.object.pk})
class ConfigurableKitProductUpdateView(LoginRequiredMixin, UpdateView):
model = ConfigurableKitProduct
form_class = ConfigurableKitProductForm
template_name = 'products/configurablekit_form.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Formset для вариантов
if 'option_formset' in kwargs:
context['option_formset'] = kwargs['option_formset']
elif self.request.POST:
context['option_formset'] = ConfigurableKitOptionFormSetUpdate(
self.request.POST,
instance=self.object,
prefix='options'
)
else:
context['option_formset'] = ConfigurableKitOptionFormSetUpdate(
instance=self.object,
prefix='options'
)
# Formset для атрибутов родителя
if 'attribute_formset' in kwargs:
context['attribute_formset'] = kwargs['attribute_formset']
elif self.request.POST:
context['attribute_formset'] = ConfigurableKitProductAttributeFormSetUpdate(
self.request.POST,
instance=self.object,
prefix='attributes'
)
else:
context['attribute_formset'] = ConfigurableKitProductAttributeFormSetUpdate(
instance=self.object,
prefix='attributes'
)
return context
def form_valid(self, form):
# Пересоздаём formsets с POST данными
option_formset = ConfigurableKitOptionFormSetUpdate(
self.request.POST,
instance=self.object,
prefix='options'
)
attribute_formset = ConfigurableKitProductAttributeFormSetUpdate(
self.request.POST,
instance=self.object,
prefix='attributes'
)
if not form.is_valid():
messages.error(self.request, 'Пожалуйста, исправьте ошибки в основной форме.')
return self.form_invalid(form)
if not option_formset.is_valid():
messages.error(self.request, 'Пожалуйста, исправьте ошибки в вариантах.')
return self.render_to_response(self.get_context_data(form=form, option_formset=option_formset, attribute_formset=attribute_formset))
if not attribute_formset.is_valid():
messages.error(self.request, 'Пожалуйста, исправьте ошибки в атрибутах.')
return self.render_to_response(self.get_context_data(form=form, option_formset=option_formset, attribute_formset=attribute_formset))
try:
with transaction.atomic():
# Сохраняем основную форму
self.object = form.save()
# Сохраняем варианты
option_formset.save()
# Сохраняем атрибуты родителя
attribute_formset.save()
messages.success(self.request, f'Вариативный товар "{self.object.name}" успешно обновлён!')
return super().form_valid(form)
except Exception as e:
messages.error(self.request, f'Ошибка при сохранении: {str(e)}')
return self.form_invalid(form)
def get_success_url(self):
return reverse_lazy('products:configurablekit-detail', kwargs={'pk': self.object.pk})
class ConfigurableKitProductDeleteView(LoginRequiredMixin, DeleteView):
model = ConfigurableKitProduct
template_name = 'products/configurablekit_confirm_delete.html'
success_url = reverse_lazy('products:configurablekit-list')
def form_valid(self, form):
messages.success(self.request, f'Вариативный товар "{self.object.name}" успешно удалён!')
return super().form_valid(form)
# API для управления вариантами
@login_required
@require_POST
def add_option_to_configurable(request, pk):
"""
Добавить вариант (комплект) к вариативному товару.
"""
configurable = get_object_or_404(ConfigurableKitProduct, pk=pk)
kit_id = request.POST.get('kit_id')
attributes = request.POST.get('attributes', '')
is_default = request.POST.get('is_default') == 'true'
if not kit_id:
return JsonResponse({'success': False, 'error': 'Не указан комплект'}, status=400)
try:
kit = ProductKit.objects.get(pk=kit_id)
except ProductKit.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Комплект не найден'}, status=404)
# Проверяем, не добавлен ли уже этот комплект
if ConfigurableKitOption.objects.filter(parent=configurable, kit=kit).exists():
return JsonResponse({'success': False, 'error': 'Этот комплект уже добавлен как вариант'}, status=400)
# Если is_default=True, снимаем флаг с других
if is_default:
ConfigurableKitOption.objects.filter(parent=configurable, is_default=True).update(is_default=False)
# Создаём вариант
option = ConfigurableKitOption.objects.create(
parent=configurable,
kit=kit,
attributes=attributes,
is_default=is_default
)
return JsonResponse({
'success': True,
'option': {
'id': option.id,
'kit_id': kit.id,
'kit_name': kit.name,
'kit_sku': kit.sku or '',
'kit_price': str(kit.actual_price),
'attributes': option.attributes or '',
'is_default': option.is_default,
}
})
@login_required
@require_POST
def remove_option_from_configurable(request, pk, option_id):
"""
Удалить вариант из вариативного товара.
"""
configurable = get_object_or_404(ConfigurableKitProduct, pk=pk)
option = get_object_or_404(ConfigurableKitOption, pk=option_id, parent=configurable)
option.delete()
return JsonResponse({'success': True, 'message': 'Вариант удалён'})
@login_required
@require_POST
def set_option_as_default(request, pk, option_id):
"""
Установить вариант как по умолчанию.
"""
configurable = get_object_or_404(ConfigurableKitProduct, pk=pk)
option = get_object_or_404(ConfigurableKitOption, pk=option_id, parent=configurable)
# Снимаем флаг со всех других
ConfigurableKitOption.objects.filter(parent=configurable).update(is_default=False)
# Устанавливаем текущий
option.is_default = True
option.save(update_fields=['is_default'])
return JsonResponse({'success': True, 'message': 'Вариант установлен как по умолчанию'})