Добавлена модель атрибутов для вариативных товаров (ConfigurableKitProductAttribute)
- Создана модель ConfigurableKitProductAttribute с полями name, option, position, visible - Добавлены формы и formsets для управления атрибутами родительского товара - Обновлены CRUD представления для работы с атрибутами (создание/редактирование) - Добавлен блок атрибутов в шаблоны создания/редактирования - Обновлена страница детального просмотра с отображением атрибутов товара - Добавлен JavaScript для динамического добавления форм атрибутов - Реализована валидация дубликатов атрибутов в formset - Атрибуты сохраняются в transaction.atomic() вместе с вариантами Теперь можно определять схему атрибутов для экспорта на WooCommerce без использования JSON или ID, только name и option.
This commit is contained in:
@@ -1,6 +1,10 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.forms import inlineformset_factory
|
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):
|
class ProductForm(forms.ModelForm):
|
||||||
@@ -577,3 +581,229 @@ class ProductTagForm(forms.ModelForm):
|
|||||||
if slug == '' or slug is None:
|
if slug == '' or slug is None:
|
||||||
return None
|
return None
|
||||||
return slug
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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='Родитель (вариативный товар)'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -32,7 +32,7 @@ from .variants import ProductVariantGroup, ProductVariantGroupItem
|
|||||||
from .products import Product
|
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
|
from .photos import BasePhoto, ProductPhoto, ProductKitPhoto, ProductCategoryPhoto, PhotoProcessingStatus
|
||||||
@@ -63,6 +63,9 @@ __all__ = [
|
|||||||
'ProductKit',
|
'ProductKit',
|
||||||
'KitItem',
|
'KitItem',
|
||||||
'KitItemPriority',
|
'KitItemPriority',
|
||||||
|
'ConfigurableKitProduct',
|
||||||
|
'ConfigurableKitOption',
|
||||||
|
'ConfigurableKitProductAttribute',
|
||||||
|
|
||||||
# Photos
|
# Photos
|
||||||
'BasePhoto',
|
'BasePhoto',
|
||||||
|
|||||||
@@ -378,3 +378,113 @@ class KitItemPriority(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.product.name} (приоритет {self.priority})"
|
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}"
|
||||||
|
|||||||
274
myproject/products/static/products/js/configurablekit_detail.js
Normal file
274
myproject/products/static/products/js/configurablekit_detail.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
494
myproject/products/templates/products/configurablekit_form.html
Normal file
494
myproject/products/templates/products/configurablekit_form.html
Normal 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"> </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"> </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"> </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"> </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 %}
|
||||||
146
myproject/products/templates/products/configurablekit_list.html
Normal file
146
myproject/products/templates/products/configurablekit_list.html
Normal 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 %}
|
||||||
@@ -77,4 +77,16 @@ urlpatterns = [
|
|||||||
path('tags/<int:pk>/', views.ProductTagDetailView.as_view(), name='tag-detail'),
|
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>/update/', views.ProductTagUpdateView.as_view(), name='tag-update'),
|
||||||
path('tags/<int:pk>/delete/', views.ProductTagDeleteView.as_view(), name='tag-delete'),
|
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'),
|
||||||
]
|
]
|
||||||
@@ -79,6 +79,18 @@ from .tag_views import (
|
|||||||
ProductTagDeleteView,
|
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 представления
|
# API представления
|
||||||
from .api_views import search_products_and_variants, validate_kit_cost, create_temporary_kit_api, create_tag_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',
|
'ProductTagUpdateView',
|
||||||
'ProductTagDeleteView',
|
'ProductTagDeleteView',
|
||||||
|
|
||||||
|
# ConfigurableKitProduct CRUD
|
||||||
|
'ConfigurableKitProductListView',
|
||||||
|
'ConfigurableKitProductCreateView',
|
||||||
|
'ConfigurableKitProductDetailView',
|
||||||
|
'ConfigurableKitProductUpdateView',
|
||||||
|
'ConfigurableKitProductDeleteView',
|
||||||
|
'add_option_to_configurable',
|
||||||
|
'remove_option_from_configurable',
|
||||||
|
'set_option_as_default',
|
||||||
|
|
||||||
# API
|
# API
|
||||||
'search_products_and_variants',
|
'search_products_and_variants',
|
||||||
'validate_kit_cost',
|
'validate_kit_cost',
|
||||||
|
|||||||
367
myproject/products/views/configurablekit_views.py
Normal file
367
myproject/products/views/configurablekit_views.py
Normal 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': 'Вариант установлен как по умолчанию'})
|
||||||
@@ -21,6 +21,9 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.resolver_match.namespace == 'products' and request.resolver_match.url_name in 'all-products,product-list,productkit-list,product-detail,product-create,product-update,productkit-detail,productkit-create,productkit-update' %}active{% endif %}" href="{% url 'products:all-products' %}">Товары</a>
|
<a class="nav-link {% if request.resolver_match.namespace == 'products' and request.resolver_match.url_name in 'all-products,product-list,productkit-list,product-detail,product-create,product-update,productkit-detail,productkit-create,productkit-update' %}active{% endif %}" href="{% url 'products:all-products' %}">Товары</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.resolver_match.namespace == 'products' and 'configurablekit' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'products:configurablekit-list' %}">Вариативные товары</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.resolver_match.namespace == 'products' and 'variantgroup' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'products:variantgroup-list' %}">Варианты</a>
|
<a class="nav-link {% if request.resolver_match.namespace == 'products' and 'variantgroup' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'products:variantgroup-list' %}">Варианты</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
Reference in New Issue
Block a user