Рефакторинг системы вариативных товаров и справочник атрибутов
Основные изменения: - Переименование ConfigurableKitProduct → ConfigurableProduct - Добавлена поддержка Product как варианта (не только ProductKit) - Создан справочник атрибутов (ProductAttribute, ProductAttributeValue) - CRUD для управления атрибутами с inline редактированием значений - Пересозданы миграции с нуля для всех приложений - Добавлена ссылка на атрибуты в навигацию 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
from django import forms
|
||||
from django.forms import inlineformset_factory
|
||||
from .models import (
|
||||
Product, ProductKit, ProductCategory, ProductTag, ProductPhoto, KitItem,
|
||||
Product, ProductKit, ProductCategory, ProductTag, ProductPhoto, KitItem,
|
||||
ProductKitPhoto, ProductCategoryPhoto, ProductVariantGroup, ProductVariantGroupItem,
|
||||
ConfigurableKitProduct, ConfigurableKitOption, ConfigurableKitProductAttribute
|
||||
ConfigurableProduct, ConfigurableProductOption, ConfigurableProductAttribute,
|
||||
ProductAttribute, ProductAttributeValue
|
||||
)
|
||||
|
||||
|
||||
@@ -583,12 +584,12 @@ class ProductTagForm(forms.ModelForm):
|
||||
|
||||
# ==================== CONFIGURABLE KIT FORMS ====================
|
||||
|
||||
class ConfigurableKitProductForm(forms.ModelForm):
|
||||
class ConfigurableProductForm(forms.ModelForm):
|
||||
"""
|
||||
Форма для создания и редактирования вариативного товара.
|
||||
"""
|
||||
class Meta:
|
||||
model = ConfigurableKitProduct
|
||||
model = ConfigurableProduct
|
||||
fields = ['name', 'sku', 'description', 'short_description', 'status']
|
||||
labels = {
|
||||
'name': 'Название',
|
||||
@@ -620,7 +621,7 @@ class ConfigurableKitProductForm(forms.ModelForm):
|
||||
self.fields['status'].widget.attrs.update({'class': 'form-select'})
|
||||
|
||||
|
||||
class ConfigurableKitOptionForm(forms.ModelForm):
|
||||
class ConfigurableProductOptionForm(forms.ModelForm):
|
||||
"""
|
||||
Форма для добавления варианта (комплекта) к вариативному товару.
|
||||
Атрибуты варианта выбираются динамически на основе parent_attributes.
|
||||
@@ -633,8 +634,8 @@ class ConfigurableKitOptionForm(forms.ModelForm):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConfigurableKitOption
|
||||
# Убрали 'attributes' - он будет заполняться через ConfigurableKitOptionAttribute
|
||||
model = ConfigurableProductOption
|
||||
# Убрали 'attributes' - он будет заполняться через ConfigurableProductOptionAttribute
|
||||
fields = ['kit', 'is_default']
|
||||
labels = {
|
||||
'kit': 'Комплект',
|
||||
@@ -653,7 +654,7 @@ class ConfigurableKitOptionForm(forms.ModelForm):
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Получаем instance (ConfigurableKitOption)
|
||||
# Получаем instance (ConfigurableProductOption)
|
||||
if self.instance and self.instance.parent_id:
|
||||
parent = self.instance.parent
|
||||
# Получаем все уникальные названия атрибутов родителя
|
||||
@@ -683,7 +684,7 @@ class ConfigurableKitOptionForm(forms.ModelForm):
|
||||
self.fields[field_name].initial = current_attr.attribute
|
||||
|
||||
|
||||
class BaseConfigurableKitOptionFormSet(forms.BaseInlineFormSet):
|
||||
class BaseConfigurableProductOptionFormSet(forms.BaseInlineFormSet):
|
||||
def clean(self):
|
||||
"""Проверка на дубликаты комплектов и что все атрибуты заполнены"""
|
||||
if any(self.errors):
|
||||
@@ -756,12 +757,12 @@ class BaseConfigurableKitOptionFormSet(forms.BaseInlineFormSet):
|
||||
|
||||
|
||||
# Формсет для создания вариативного товара
|
||||
ConfigurableKitOptionFormSetCreate = inlineformset_factory(
|
||||
ConfigurableKitProduct,
|
||||
ConfigurableKitOption,
|
||||
form=ConfigurableKitOptionForm,
|
||||
formset=BaseConfigurableKitOptionFormSet,
|
||||
fields=['kit', 'is_default'], # Убрали 'attributes' - заполняется через ConfigurableKitOptionAttribute
|
||||
ConfigurableProductOptionFormSetCreate = inlineformset_factory(
|
||||
ConfigurableProduct,
|
||||
ConfigurableProductOption,
|
||||
form=ConfigurableProductOptionForm,
|
||||
formset=BaseConfigurableProductOptionFormSet,
|
||||
fields=['kit', 'is_default'], # Убрали 'attributes' - заполняется через ConfigurableProductOptionAttribute
|
||||
extra=0, # Не требуем пустые формы (варианты скрыты в UI)
|
||||
can_delete=True,
|
||||
min_num=0,
|
||||
@@ -770,12 +771,12 @@ ConfigurableKitOptionFormSetCreate = inlineformset_factory(
|
||||
)
|
||||
|
||||
# Формсет для редактирования вариативного товара
|
||||
ConfigurableKitOptionFormSetUpdate = inlineformset_factory(
|
||||
ConfigurableKitProduct,
|
||||
ConfigurableKitOption,
|
||||
form=ConfigurableKitOptionForm,
|
||||
formset=BaseConfigurableKitOptionFormSet,
|
||||
fields=['kit', 'is_default'], # Убрали 'attributes' - заполняется через ConfigurableKitOptionAttribute
|
||||
ConfigurableProductOptionFormSetUpdate = inlineformset_factory(
|
||||
ConfigurableProduct,
|
||||
ConfigurableProductOption,
|
||||
form=ConfigurableProductOptionForm,
|
||||
formset=BaseConfigurableProductOptionFormSet,
|
||||
fields=['kit', 'is_default'], # Убрали 'attributes' - заполняется через ConfigurableProductOptionAttribute
|
||||
extra=0, # НЕ показывать пустые формы
|
||||
can_delete=True,
|
||||
min_num=0,
|
||||
@@ -786,7 +787,7 @@ ConfigurableKitOptionFormSetUpdate = inlineformset_factory(
|
||||
|
||||
# === Формы для атрибутов родительского вариативного товара ===
|
||||
|
||||
class ConfigurableKitProductAttributeForm(forms.ModelForm):
|
||||
class ConfigurableProductAttributeForm(forms.ModelForm):
|
||||
"""
|
||||
Форма для добавления атрибута родительского товара в карточном интерфейсе.
|
||||
На фронтенде: одна карточка параметра (имя + позиция + видимость)
|
||||
@@ -796,10 +797,10 @@ class ConfigurableKitProductAttributeForm(forms.ModelForm):
|
||||
- name: "Длина"
|
||||
- position: 0
|
||||
- visible: True
|
||||
- values: [50, 60, 70] (будут созданы как отдельные ConfigurableKitProductAttribute)
|
||||
- values: [50, 60, 70] (будут созданы как отдельные ConfigurableProductAttribute)
|
||||
"""
|
||||
class Meta:
|
||||
model = ConfigurableKitProductAttribute
|
||||
model = ConfigurableProductAttribute
|
||||
fields = ['name', 'position', 'visible']
|
||||
labels = {
|
||||
'name': 'Название параметра',
|
||||
@@ -822,7 +823,7 @@ class ConfigurableKitProductAttributeForm(forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class BaseConfigurableKitProductAttributeFormSet(forms.BaseInlineFormSet):
|
||||
class BaseConfigurableProductAttributeFormSet(forms.BaseInlineFormSet):
|
||||
def clean(self):
|
||||
"""Проверка на дубликаты параметров и что у каждого параметра есть значения"""
|
||||
if any(self.errors):
|
||||
@@ -850,11 +851,11 @@ class BaseConfigurableKitProductAttributeFormSet(forms.BaseInlineFormSet):
|
||||
|
||||
|
||||
# Формсет для создания атрибутов родительского товара (карточный интерфейс)
|
||||
ConfigurableKitProductAttributeFormSetCreate = inlineformset_factory(
|
||||
ConfigurableKitProduct,
|
||||
ConfigurableKitProductAttribute,
|
||||
form=ConfigurableKitProductAttributeForm,
|
||||
formset=BaseConfigurableKitProductAttributeFormSet,
|
||||
ConfigurableProductAttributeFormSetCreate = inlineformset_factory(
|
||||
ConfigurableProduct,
|
||||
ConfigurableProductAttribute,
|
||||
form=ConfigurableProductAttributeForm,
|
||||
formset=BaseConfigurableProductAttributeFormSet,
|
||||
# Убрали 'option' - значения будут добавляться через JavaScript в карточку
|
||||
fields=['name', 'position', 'visible'],
|
||||
extra=1,
|
||||
@@ -865,11 +866,11 @@ ConfigurableKitProductAttributeFormSetCreate = inlineformset_factory(
|
||||
)
|
||||
|
||||
# Формсет для редактирования атрибутов родительского товара
|
||||
ConfigurableKitProductAttributeFormSetUpdate = inlineformset_factory(
|
||||
ConfigurableKitProduct,
|
||||
ConfigurableKitProductAttribute,
|
||||
form=ConfigurableKitProductAttributeForm,
|
||||
formset=BaseConfigurableKitProductAttributeFormSet,
|
||||
ConfigurableProductAttributeFormSetUpdate = inlineformset_factory(
|
||||
ConfigurableProduct,
|
||||
ConfigurableProductAttribute,
|
||||
form=ConfigurableProductAttributeForm,
|
||||
formset=BaseConfigurableProductAttributeFormSet,
|
||||
# Убрали 'option' - значения будут добавляться через JavaScript в карточку
|
||||
fields=['name', 'position', 'visible'],
|
||||
extra=0,
|
||||
@@ -878,3 +879,86 @@ ConfigurableKitProductAttributeFormSetUpdate = inlineformset_factory(
|
||||
validate_min=False,
|
||||
can_delete_extra=True,
|
||||
)
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Формы для справочника атрибутов
|
||||
# ==========================================
|
||||
|
||||
class ProductAttributeForm(forms.ModelForm):
|
||||
"""Форма для создания и редактирования атрибута"""
|
||||
|
||||
class Meta:
|
||||
model = ProductAttribute
|
||||
fields = ['name', 'slug', 'description', 'position']
|
||||
labels = {
|
||||
'name': 'Название',
|
||||
'slug': 'Slug (URL)',
|
||||
'description': 'Описание',
|
||||
'position': 'Позиция'
|
||||
}
|
||||
help_texts = {
|
||||
'slug': 'Оставьте пустым для автогенерации'
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['name'].widget.attrs.update({
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Например: Длина стебля'
|
||||
})
|
||||
self.fields['slug'].widget.attrs.update({
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Автоматически'
|
||||
})
|
||||
self.fields['slug'].required = False
|
||||
self.fields['description'].widget.attrs.update({
|
||||
'class': 'form-control',
|
||||
'rows': 2,
|
||||
'placeholder': 'Опциональное описание'
|
||||
})
|
||||
self.fields['position'].widget.attrs.update({
|
||||
'class': 'form-control',
|
||||
'style': 'width: 100px;'
|
||||
})
|
||||
|
||||
|
||||
class ProductAttributeValueForm(forms.ModelForm):
|
||||
"""Форма для значения атрибута (inline)"""
|
||||
|
||||
class Meta:
|
||||
model = ProductAttributeValue
|
||||
fields = ['value', 'slug', 'position']
|
||||
labels = {
|
||||
'value': 'Значение',
|
||||
'slug': 'Slug',
|
||||
'position': 'Позиция'
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['value'].widget.attrs.update({
|
||||
'class': 'form-control form-control-sm',
|
||||
'placeholder': 'Например: 50'
|
||||
})
|
||||
self.fields['slug'].widget.attrs.update({
|
||||
'class': 'form-control form-control-sm',
|
||||
'placeholder': 'Авто'
|
||||
})
|
||||
self.fields['slug'].required = False
|
||||
self.fields['position'].widget.attrs.update({
|
||||
'class': 'form-control form-control-sm',
|
||||
'style': 'width: 70px;'
|
||||
})
|
||||
|
||||
|
||||
ProductAttributeValueFormSet = inlineformset_factory(
|
||||
ProductAttribute,
|
||||
ProductAttributeValue,
|
||||
form=ProductAttributeValueForm,
|
||||
fields=['value', 'slug', 'position'],
|
||||
extra=3,
|
||||
can_delete=True,
|
||||
min_num=0,
|
||||
validate_min=False,
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.0.10 on 2025-12-23 20:38
|
||||
# Generated by Django 5.0.10 on 2025-12-29 22:19
|
||||
|
||||
import django.db.models.deletion
|
||||
import products.models.photos
|
||||
@@ -28,6 +28,23 @@ class Migration(migrations.Migration):
|
||||
'verbose_name_plural': 'Компоненты комплектов',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProductAttribute',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text='Например: Длина стебля, Цвет, Размер', max_length=100, unique=True, verbose_name='Название')),
|
||||
('slug', models.SlugField(blank=True, help_text='Автоматически генерируется из названия', max_length=100, unique=True, verbose_name='Slug')),
|
||||
('description', models.TextField(blank=True, help_text='Опциональное описание атрибута', verbose_name='Описание')),
|
||||
('position', models.PositiveIntegerField(default=0, help_text='Порядок отображения в списке', verbose_name='Позиция')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Атрибут товара',
|
||||
'verbose_name_plural': 'Атрибуты товаров',
|
||||
'ordering': ['position', 'name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProductVariantGroup',
|
||||
fields=[
|
||||
@@ -68,7 +85,7 @@ class Migration(migrations.Migration):
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ConfigurableKitProduct',
|
||||
name='ConfigurableProduct',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200, verbose_name='Название')),
|
||||
@@ -83,32 +100,19 @@ class Migration(migrations.Migration):
|
||||
('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': 'Вариативные товары (из комплектов)',
|
||||
'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.JSONField(blank=True, default=dict, help_text='Структурированные атрибуты. Пример: {"length": "60", "color": "red"}', verbose_name='Атрибуты варианта')),
|
||||
('is_default', models.BooleanField(default=False, 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': 'Варианты комплектов',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ConfigurableKitProductAttribute',
|
||||
name='ConfigurableProductAttribute',
|
||||
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='Родительский товар')),
|
||||
('parent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='parent_attributes', to='products.configurableproduct', verbose_name='Родительский товар')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Атрибут вариативного товара',
|
||||
@@ -117,11 +121,24 @@ class Migration(migrations.Migration):
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ConfigurableKitOptionAttribute',
|
||||
name='ConfigurableProductOption',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('option', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attributes_set', to='products.configurablekitoption', verbose_name='Вариант')),
|
||||
('attribute', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='products.configurablekitproductattribute', verbose_name='Значение атрибута')),
|
||||
('attributes', models.JSONField(blank=True, default=dict, help_text='Структурированные атрибуты. Пример: {"length": "60", "color": "red"}', verbose_name='Атрибуты варианта')),
|
||||
('is_default', models.BooleanField(default=False, verbose_name='Вариант по умолчанию')),
|
||||
('parent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='products.configurableproduct', verbose_name='Родитель (вариативный товар)')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Вариант товара',
|
||||
'verbose_name_plural': 'Варианты товаров',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ConfigurableProductOptionAttribute',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('attribute', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='products.configurableproductattribute', verbose_name='Значение атрибута')),
|
||||
('option', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attributes_set', to='products.configurableproductoption', verbose_name='Вариант')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Атрибут варианта',
|
||||
@@ -215,6 +232,33 @@ class Migration(migrations.Migration):
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='configurableproductoption',
|
||||
name='product',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='as_configurable_option_in', to='products.product', verbose_name='Товар (вариант)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='configurableproductattribute',
|
||||
name='product',
|
||||
field=models.ForeignKey(blank=True, help_text='Какой Product связан с этим значением атрибута', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='as_attribute_value_in', to='products.product', verbose_name='Товар для этого значения'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProductAttributeValue',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('value', models.CharField(help_text='Например: 50, 60, 70 (для длины) или Красный, Белый (для цвета)', max_length=100, verbose_name='Значение')),
|
||||
('slug', models.SlugField(blank=True, max_length=100, verbose_name='Slug')),
|
||||
('position', models.PositiveIntegerField(default=0, help_text='Порядок отображения в списке значений', verbose_name='Позиция')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||
('attribute', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='values', to='products.productattribute', verbose_name='Атрибут')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Значение атрибута',
|
||||
'verbose_name_plural': 'Значения атрибутов',
|
||||
'ordering': ['position', 'value'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProductCategory',
|
||||
fields=[
|
||||
@@ -292,14 +336,14 @@ class Migration(migrations.Migration):
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='kit_items', to='products.productkit', verbose_name='Комплект'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='configurablekitproductattribute',
|
||||
model_name='configurableproductoption',
|
||||
name='kit',
|
||||
field=models.ForeignKey(blank=True, help_text='Какой ProductKit связан с этим значением атрибута', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='as_attribute_value_in', to='products.productkit', verbose_name='Комплект для этого значения'),
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='as_configurable_option_in', to='products.productkit', verbose_name='Комплект (вариант)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='configurablekitoption',
|
||||
model_name='configurableproductattribute',
|
||||
name='kit',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='as_configurable_option_in', to='products.productkit', verbose_name='Комплект (вариант)'),
|
||||
field=models.ForeignKey(blank=True, help_text='Какой ProductKit связан с этим значением атрибута', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='as_attribute_value_in', to='products.productkit', verbose_name='Комплект для этого значения'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProductKitPhoto',
|
||||
@@ -386,15 +430,15 @@ class Migration(migrations.Migration):
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='products.productvariantgroup', verbose_name='Группа вариантов'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='configurablekitoptionattribute',
|
||||
index=models.Index(fields=['option'], name='products_co_option__93b9f7_idx'),
|
||||
model_name='configurableproductoptionattribute',
|
||||
index=models.Index(fields=['option'], name='products_co_option__bd950a_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='configurablekitoptionattribute',
|
||||
index=models.Index(fields=['attribute'], name='products_co_attribu_ccc6d9_idx'),
|
||||
model_name='configurableproductoptionattribute',
|
||||
index=models.Index(fields=['attribute'], name='products_co_attribu_705d5a_idx'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='configurablekitoptionattribute',
|
||||
name='configurableproductoptionattribute',
|
||||
unique_together={('option', 'attribute')},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
@@ -409,6 +453,14 @@ class Migration(migrations.Migration):
|
||||
model_name='costpricehistory',
|
||||
index=models.Index(fields=['reason'], name='products_co_reason_959ee1_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='productattributevalue',
|
||||
index=models.Index(fields=['attribute', 'position'], name='products_pr_attribu_460f9e_idx'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='productattributevalue',
|
||||
unique_together={('attribute', 'value')},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='productcategory',
|
||||
index=models.Index(fields=['is_active'], name='products_pr_is_acti_226e74_idx'),
|
||||
@@ -438,36 +490,40 @@ class Migration(migrations.Migration):
|
||||
index=models.Index(fields=['quality_warning', 'category'], name='products_pr_quality_fc505a_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='configurablekitproductattribute',
|
||||
index=models.Index(fields=['parent', 'name'], name='products_co_parent__4a7869_idx'),
|
||||
model_name='configurableproductoption',
|
||||
index=models.Index(fields=['parent'], name='products_co_parent__36761a_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='configurablekitproductattribute',
|
||||
index=models.Index(fields=['parent', 'position'], name='products_co_parent__0904e2_idx'),
|
||||
model_name='configurableproductoption',
|
||||
index=models.Index(fields=['kit'], name='products_co_kit_id_9e9a00_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='configurablekitproductattribute',
|
||||
index=models.Index(fields=['kit'], name='products_co_kit_id_c5d506_idx'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='configurablekitproductattribute',
|
||||
unique_together={('parent', 'name', 'option', 'kit')},
|
||||
model_name='configurableproductoption',
|
||||
index=models.Index(fields=['product'], name='products_co_product_4d77ae_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='configurablekitoption',
|
||||
index=models.Index(fields=['parent'], name='products_co_parent__56ecfa_idx'),
|
||||
model_name='configurableproductoption',
|
||||
index=models.Index(fields=['parent', 'is_default'], name='products_co_parent__be8830_idx'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='configurableproductoption',
|
||||
constraint=models.CheckConstraint(check=models.Q(models.Q(('kit__isnull', False), ('product__isnull', True)), models.Q(('kit__isnull', True), ('product__isnull', False)), _connector='OR'), name='configurable_option_kit_xor_product'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='configurablekitoption',
|
||||
index=models.Index(fields=['kit'], name='products_co_kit_id_3fa7fe_idx'),
|
||||
model_name='configurableproductattribute',
|
||||
index=models.Index(fields=['parent', 'name'], name='products_co_parent__78337c_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='configurablekitoption',
|
||||
index=models.Index(fields=['parent', 'is_default'], name='products_co_parent__ffa4ca_idx'),
|
||||
model_name='configurableproductattribute',
|
||||
index=models.Index(fields=['parent', 'position'], name='products_co_parent__90f012_idx'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='configurablekitoption',
|
||||
unique_together={('parent', 'kit')},
|
||||
migrations.AddIndex(
|
||||
model_name='configurableproductattribute',
|
||||
index=models.Index(fields=['kit'], name='products_co_kit_id_db7ebb_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='configurableproductattribute',
|
||||
index=models.Index(fields=['product'], name='products_co_product_68c16a_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='productkitphoto',
|
||||
|
||||
@@ -31,8 +31,14 @@ from .variants import ProductVariantGroup, ProductVariantGroupItem
|
||||
# Продукты
|
||||
from .products import Product, CostPriceHistory
|
||||
|
||||
# Комплекты
|
||||
from .kits import ProductKit, KitItem, KitItemPriority, ConfigurableKitProduct, ConfigurableKitOption, ConfigurableKitProductAttribute
|
||||
# Комплекты и вариативные товары
|
||||
from .kits import (
|
||||
ProductKit, KitItem, KitItemPriority,
|
||||
ConfigurableProduct, ConfigurableProductOption, ConfigurableProductAttribute, ConfigurableProductOptionAttribute,
|
||||
)
|
||||
|
||||
# Атрибуты
|
||||
from .attributes import ProductAttribute, ProductAttributeValue
|
||||
|
||||
# Фотографии
|
||||
from .photos import BasePhoto, ProductPhoto, ProductKitPhoto, ProductCategoryPhoto, PhotoProcessingStatus
|
||||
@@ -60,13 +66,18 @@ __all__ = [
|
||||
'Product',
|
||||
'CostPriceHistory',
|
||||
|
||||
# Kits
|
||||
# Kits & Configurable Products
|
||||
'ProductKit',
|
||||
'KitItem',
|
||||
'KitItemPriority',
|
||||
'ConfigurableKitProduct',
|
||||
'ConfigurableKitOption',
|
||||
'ConfigurableKitProductAttribute',
|
||||
'ConfigurableProduct',
|
||||
'ConfigurableProductOption',
|
||||
'ConfigurableProductAttribute',
|
||||
'ConfigurableProductOptionAttribute',
|
||||
|
||||
# Attributes
|
||||
'ProductAttribute',
|
||||
'ProductAttributeValue',
|
||||
|
||||
# Photos
|
||||
'BasePhoto',
|
||||
|
||||
116
myproject/products/models/attributes.py
Normal file
116
myproject/products/models/attributes.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Модели для справочника атрибутов товаров.
|
||||
Используется для создания переиспользуемых атрибутов (Длина стебля, Цвет, Размер и т.д.)
|
||||
"""
|
||||
from django.db import models
|
||||
from django.utils.text import slugify
|
||||
from unidecode import unidecode
|
||||
|
||||
|
||||
class ProductAttribute(models.Model):
|
||||
"""
|
||||
Справочник атрибутов для вариативных товаров.
|
||||
Примеры: Длина стебля, Цвет, Размер, Упаковка.
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
unique=True,
|
||||
verbose_name="Название",
|
||||
help_text="Например: Длина стебля, Цвет, Размер"
|
||||
)
|
||||
slug = models.SlugField(
|
||||
max_length=100,
|
||||
unique=True,
|
||||
blank=True,
|
||||
verbose_name="Slug",
|
||||
help_text="Автоматически генерируется из названия"
|
||||
)
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
verbose_name="Описание",
|
||||
help_text="Опциональное описание атрибута"
|
||||
)
|
||||
position = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name="Позиция",
|
||||
help_text="Порядок отображения в списке"
|
||||
)
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name="Дата создания"
|
||||
)
|
||||
updated_at = models.DateTimeField(
|
||||
auto_now=True,
|
||||
verbose_name="Дата обновления"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Атрибут товара"
|
||||
verbose_name_plural = "Атрибуты товаров"
|
||||
ordering = ['position', 'name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(unidecode(self.name))
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def values_count(self):
|
||||
"""Количество значений у атрибута"""
|
||||
return self.values.count()
|
||||
|
||||
|
||||
class ProductAttributeValue(models.Model):
|
||||
"""
|
||||
Значения атрибутов.
|
||||
Примеры для атрибута "Длина стебля": 50, 60, 70, 80.
|
||||
"""
|
||||
attribute = models.ForeignKey(
|
||||
ProductAttribute,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='values',
|
||||
verbose_name="Атрибут"
|
||||
)
|
||||
value = models.CharField(
|
||||
max_length=100,
|
||||
verbose_name="Значение",
|
||||
help_text="Например: 50, 60, 70 (для длины) или Красный, Белый (для цвета)"
|
||||
)
|
||||
slug = models.SlugField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
verbose_name="Slug"
|
||||
)
|
||||
position = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name="Позиция",
|
||||
help_text="Порядок отображения в списке значений"
|
||||
)
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name="Дата создания"
|
||||
)
|
||||
updated_at = models.DateTimeField(
|
||||
auto_now=True,
|
||||
verbose_name="Дата обновления"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Значение атрибута"
|
||||
verbose_name_plural = "Значения атрибутов"
|
||||
ordering = ['position', 'value']
|
||||
unique_together = ['attribute', 'value']
|
||||
indexes = [
|
||||
models.Index(fields=['attribute', 'position']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.attribute.name}: {self.value}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(unidecode(self.value))
|
||||
super().save(*args, **kwargs)
|
||||
@@ -435,14 +435,18 @@ class KitItemPriority(models.Model):
|
||||
return f"{self.product.name} (приоритет {self.priority})"
|
||||
|
||||
|
||||
class ConfigurableKitProduct(BaseProductEntity):
|
||||
class ConfigurableProduct(BaseProductEntity):
|
||||
"""
|
||||
Вариативный товар, объединяющий несколько наших ProductKit
|
||||
Вариативный товар, объединяющий несколько ProductKit или Product
|
||||
как варианты для внешних площадок (WooCommerce и подобные).
|
||||
|
||||
Примеры использования:
|
||||
- Роза Фридом с вариантами длины стебля (50, 60, 70 см) — варианты это Product
|
||||
- Букет "Нежность" с вариантами количества роз (15, 25, 51) — варианты это ProductKit
|
||||
"""
|
||||
class Meta:
|
||||
verbose_name = "Вариативный товар (из комплектов)"
|
||||
verbose_name_plural = "Вариативные товары (из комплектов)"
|
||||
verbose_name = "Вариативный товар"
|
||||
verbose_name_plural = "Вариативные товары"
|
||||
# Уникальность активного имени наследуется из BaseProductEntity
|
||||
|
||||
def __str__(self):
|
||||
@@ -451,25 +455,25 @@ class ConfigurableKitProduct(BaseProductEntity):
|
||||
def delete(self, *args, **kwargs):
|
||||
"""
|
||||
Физическое удаление вариативного товара из БД.
|
||||
При удалении удаляются только связи (ConfigurableKitOption),
|
||||
но сами ProductKit остаются нетронутыми благодаря CASCADE на уровне связей.
|
||||
При удалении удаляются только связи (ConfigurableProductOption),
|
||||
но сами ProductKit/Product остаются нетронутыми благодаря CASCADE на уровне связей.
|
||||
"""
|
||||
# Используем super() для вызова стандартного Django delete, минуя BaseProductEntity.delete()
|
||||
super(BaseProductEntity, self).delete(*args, **kwargs)
|
||||
|
||||
|
||||
class ConfigurableKitProductAttribute(models.Model):
|
||||
class ConfigurableProductAttribute(models.Model):
|
||||
"""
|
||||
Атрибут родительского вариативного товара с привязкой к ProductKit.
|
||||
Атрибут родительского вариативного товара с привязкой к ProductKit или Product.
|
||||
|
||||
Каждое значение атрибута связано с конкретным ProductKit.
|
||||
Каждое значение атрибута может быть связано с ProductKit или Product.
|
||||
Например:
|
||||
- Длина: 50 → ProductKit (A)
|
||||
- Длина: 60 → ProductKit (B)
|
||||
- Длина: 70 → ProductKit (C)
|
||||
- Длина: 50 → Product (Роза 50см)
|
||||
- Длина: 60 → Product (Роза 60см)
|
||||
- Количество: 15 роз → ProductKit (Букет 15 роз)
|
||||
"""
|
||||
parent = models.ForeignKey(
|
||||
ConfigurableKitProduct,
|
||||
'ConfigurableProduct',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='parent_attributes',
|
||||
verbose_name="Родительский товар"
|
||||
@@ -484,6 +488,7 @@ class ConfigurableKitProductAttribute(models.Model):
|
||||
verbose_name="Значение опции",
|
||||
help_text="Например: Красный, M, 60см"
|
||||
)
|
||||
# Один из двух должен быть заполнен (kit XOR product) или оба пустые
|
||||
kit = models.ForeignKey(
|
||||
ProductKit,
|
||||
on_delete=models.CASCADE,
|
||||
@@ -493,6 +498,15 @@ class ConfigurableKitProductAttribute(models.Model):
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
product = models.ForeignKey(
|
||||
'Product',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='as_attribute_value_in',
|
||||
verbose_name="Товар для этого значения",
|
||||
help_text="Какой Product связан с этим значением атрибута",
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
position = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name="Порядок отображения",
|
||||
@@ -508,35 +522,60 @@ class ConfigurableKitProductAttribute(models.Model):
|
||||
verbose_name = "Атрибут вариативного товара"
|
||||
verbose_name_plural = "Атрибуты вариативных товаров"
|
||||
ordering = ['parent', 'position', 'name', 'option']
|
||||
unique_together = [['parent', 'name', 'option', 'kit']]
|
||||
indexes = [
|
||||
models.Index(fields=['parent', 'name']),
|
||||
models.Index(fields=['parent', 'position']),
|
||||
models.Index(fields=['kit']),
|
||||
models.Index(fields=['product']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
kit_str = self.kit.name if self.kit else "no kit"
|
||||
return f"{self.parent.name} - {self.name}: {self.option} ({kit_str})"
|
||||
variant_str = self.kit.name if self.kit else (self.product.name if self.product else "no variant")
|
||||
return f"{self.parent.name} - {self.name}: {self.option} ({variant_str})"
|
||||
|
||||
@property
|
||||
def variant(self):
|
||||
"""Возвращает связанный вариант (kit или product)"""
|
||||
return self.kit or self.product
|
||||
|
||||
@property
|
||||
def variant_type(self):
|
||||
"""Тип варианта: 'kit', 'product' или None"""
|
||||
if self.kit:
|
||||
return 'kit'
|
||||
elif self.product:
|
||||
return 'product'
|
||||
return None
|
||||
|
||||
|
||||
class ConfigurableKitOption(models.Model):
|
||||
class ConfigurableProductOption(models.Model):
|
||||
"""
|
||||
Отдельный вариант внутри ConfigurableKitProduct, указывающий на конкретный ProductKit.
|
||||
Отдельный вариант внутри ConfigurableProduct, указывающий на ProductKit ИЛИ Product.
|
||||
Атрибуты варианта хранятся в структурированном JSON формате.
|
||||
Пример: {"length": "60", "color": "red"}
|
||||
"""
|
||||
parent = models.ForeignKey(
|
||||
ConfigurableKitProduct,
|
||||
'ConfigurableProduct',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='options',
|
||||
verbose_name="Родитель (вариативный товар)"
|
||||
)
|
||||
# Один из двух должен быть заполнен (kit XOR product)
|
||||
kit = models.ForeignKey(
|
||||
ProductKit,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='as_configurable_option_in',
|
||||
verbose_name="Комплект (вариант)"
|
||||
verbose_name="Комплект (вариант)",
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
product = models.ForeignKey(
|
||||
'Product',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='as_configurable_option_in',
|
||||
verbose_name="Товар (вариант)",
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
attributes = models.JSONField(
|
||||
default=dict,
|
||||
@@ -550,39 +589,79 @@ class ConfigurableKitOption(models.Model):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Вариант комплекта"
|
||||
verbose_name_plural = "Варианты комплектов"
|
||||
unique_together = [['parent', 'kit']]
|
||||
verbose_name = "Вариант товара"
|
||||
verbose_name_plural = "Варианты товаров"
|
||||
indexes = [
|
||||
models.Index(fields=['parent']),
|
||||
models.Index(fields=['kit']),
|
||||
models.Index(fields=['product']),
|
||||
models.Index(fields=['parent', 'is_default']),
|
||||
]
|
||||
constraints = [
|
||||
# kit XOR product — один из двух должен быть заполнен
|
||||
models.CheckConstraint(
|
||||
check=(
|
||||
models.Q(kit__isnull=False, product__isnull=True) |
|
||||
models.Q(kit__isnull=True, product__isnull=False)
|
||||
),
|
||||
name='configurable_option_kit_xor_product'
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.parent.name} → {self.kit.name}"
|
||||
variant_name = self.kit.name if self.kit else (self.product.name if self.product else "N/A")
|
||||
return f"{self.parent.name} → {variant_name}"
|
||||
|
||||
@property
|
||||
def variant(self):
|
||||
"""Возвращает связанный вариант (kit или product)"""
|
||||
return self.kit or self.product
|
||||
|
||||
@property
|
||||
def variant_type(self):
|
||||
"""Тип варианта: 'kit' или 'product'"""
|
||||
return 'kit' if self.kit else 'product'
|
||||
|
||||
@property
|
||||
def variant_name(self):
|
||||
"""Название варианта"""
|
||||
return self.variant.name if self.variant else None
|
||||
|
||||
@property
|
||||
def variant_sku(self):
|
||||
"""SKU варианта"""
|
||||
return self.variant.sku if self.variant else None
|
||||
|
||||
@property
|
||||
def variant_price(self):
|
||||
"""Цена варианта"""
|
||||
if self.kit:
|
||||
return self.kit.actual_price
|
||||
elif self.product:
|
||||
return self.product.sale_price or self.product.price
|
||||
return None
|
||||
|
||||
|
||||
class ConfigurableKitOptionAttribute(models.Model):
|
||||
class ConfigurableProductOptionAttribute(models.Model):
|
||||
"""
|
||||
Связь между вариантом (ConfigurableKitOption) и
|
||||
конкретным значением атрибута (ConfigurableKitProductAttribute).
|
||||
Связь между вариантом (ConfigurableProductOption) и
|
||||
конкретным значением атрибута (ConfigurableProductAttribute).
|
||||
|
||||
Вместо хранения текстового поля attributes в ConfigurableKitOption,
|
||||
Вместо хранения текстового поля attributes в ConfigurableProductOption,
|
||||
мы создаем явные связи между вариантом и выбранными значениями атрибутов.
|
||||
|
||||
Пример:
|
||||
- option: ConfigurableKitOption (вариант "15 роз 60см")
|
||||
- attribute: ConfigurableKitProductAttribute (Длина: 60)
|
||||
- option: ConfigurableProductOption (вариант "15 роз 60см")
|
||||
- attribute: ConfigurableProductAttribute (Длина: 60)
|
||||
"""
|
||||
option = models.ForeignKey(
|
||||
ConfigurableKitOption,
|
||||
'ConfigurableProductOption',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='attributes_set',
|
||||
verbose_name="Вариант"
|
||||
)
|
||||
attribute = models.ForeignKey(
|
||||
ConfigurableKitProductAttribute,
|
||||
'ConfigurableProductAttribute',
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name="Значение атрибута"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Удалить атрибут{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-sm-6 col-md-5 col-lg-4">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body p-4 text-center">
|
||||
<div class="text-danger mb-3">
|
||||
<i class="bi bi-exclamation-triangle fs-1"></i>
|
||||
</div>
|
||||
|
||||
<h5 class="mb-3">Удалить атрибут?</h5>
|
||||
|
||||
<p class="mb-2">
|
||||
<strong>{{ object.name }}</strong>
|
||||
</p>
|
||||
|
||||
{% if values_count > 0 %}
|
||||
<div class="alert alert-warning py-2 small">
|
||||
<i class="bi bi-exclamation-circle"></i>
|
||||
Будет удалено <strong>{{ values_count }}</strong> значени{{ values_count|pluralize:"е,я,й" }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-trash"></i> Удалить
|
||||
</button>
|
||||
<a href="{% url 'products:attribute-list' %}" class="btn btn-light btn-sm">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
196
myproject/products/templates/products/attribute_detail.html
Normal file
196
myproject/products/templates/products/attribute_detail.html
Normal file
@@ -0,0 +1,196 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Атрибут: {{ attribute.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<!-- Заголовок -->
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<div>
|
||||
<a href="{% url 'products:attribute-list' %}" class="text-decoration-none text-muted small">
|
||||
<i class="bi bi-arrow-left"></i> Все атрибуты
|
||||
</a>
|
||||
<h4 class="mb-0 mt-1">
|
||||
<i class="bi bi-sliders text-primary"></i> {{ attribute.name }}
|
||||
</h4>
|
||||
<small class="text-muted">{{ attribute.slug }}</small>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<a href="{% url 'products:attribute-update' attribute.pk %}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-pencil"></i> Изменить
|
||||
</a>
|
||||
<a href="{% url 'products:attribute-delete' attribute.pk %}" class="btn btn-outline-danger btn-sm">
|
||||
<i class="bi bi-trash"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if attribute.description %}
|
||||
<p class="text-muted mb-4">{{ attribute.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Значения атрибута -->
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-list-ul"></i> Значения ({{ values|length }})</span>
|
||||
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addValueModal">
|
||||
<i class="bi bi-plus"></i> Добавить
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if values %}
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for value in values %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center py-2">
|
||||
<div>
|
||||
<span class="fw-medium">{{ value.value }}</span>
|
||||
<small class="text-muted ms-2">{{ value.slug }}</small>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge bg-light text-dark" title="Порядок сортировки"><i class="bi bi-arrows-vertical"></i> {{ value.position }}</span>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm py-0 px-1 delete-value-btn"
|
||||
data-value-id="{{ value.pk }}" title="Удалить">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="bi bi-list-ul fs-2 opacity-25"></i>
|
||||
<p class="mb-0 mt-2">Значений пока нет</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Метаданные -->
|
||||
<div class="text-muted small mt-3 text-center">
|
||||
<i class="bi bi-clock-history"></i>
|
||||
Создан: {{ attribute.created_at|date:"d.m.Y H:i" }} |
|
||||
Обновлен: {{ attribute.updated_at|date:"d.m.Y H:i" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно добавления значения -->
|
||||
<div class="modal fade" id="addValueModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Добавить значение</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Значение</label>
|
||||
<input type="text" id="new-value-input" class="form-control" placeholder="Например: 50">
|
||||
</div>
|
||||
<div id="add-value-message"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Отмена</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" id="add-value-btn">
|
||||
<i class="bi bi-plus"></i> Добавить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const addBtn = document.getElementById('add-value-btn');
|
||||
const valueInput = document.getElementById('new-value-input');
|
||||
const messageDiv = document.getElementById('add-value-message');
|
||||
|
||||
// Добавление значения
|
||||
addBtn.addEventListener('click', async function() {
|
||||
const value = valueInput.value.trim();
|
||||
if (!value) {
|
||||
showMessage('Введите значение', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
addBtn.disabled = true;
|
||||
addBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
|
||||
|
||||
try {
|
||||
const response = await fetch('{% url "products:attribute-add-value" attribute.pk %}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token }}'
|
||||
},
|
||||
body: JSON.stringify({value: value})
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showMessage('Значение добавлено', 'success');
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
} else {
|
||||
showMessage(data.error, 'danger');
|
||||
resetBtn();
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('Ошибка сети', 'danger');
|
||||
resetBtn();
|
||||
}
|
||||
});
|
||||
|
||||
// Удаление значения
|
||||
document.querySelectorAll('.delete-value-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
if (!confirm('Удалить это значение?')) return;
|
||||
|
||||
const valueId = this.dataset.valueId;
|
||||
this.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`{% url "products:attribute-delete-value" attribute.pk 0 %}`.replace('/0/', `/${valueId}/`), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token }}'
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.closest('li').remove();
|
||||
} else {
|
||||
alert(data.error);
|
||||
this.disabled = false;
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Ошибка сети');
|
||||
this.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function showMessage(text, type) {
|
||||
messageDiv.innerHTML = `<div class="alert alert-${type} py-1 px-2 small mb-0">${text}</div>`;
|
||||
}
|
||||
|
||||
function resetBtn() {
|
||||
addBtn.disabled = false;
|
||||
addBtn.innerHTML = '<i class="bi bi-plus"></i> Добавить';
|
||||
valueInput.focus();
|
||||
}
|
||||
|
||||
// Enter для добавления
|
||||
valueInput.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addBtn.click();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
180
myproject/products/templates/products/attribute_form.html
Normal file
180
myproject/products/templates/products/attribute_form.html
Normal file
@@ -0,0 +1,180 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}{% if object %}Редактировать атрибут{% else %}Создать атрибут{% endif %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="text-center mb-4">
|
||||
<i class="bi bi-sliders{% if not object %}-fill{% endif %} text-primary"></i>
|
||||
{% if object %}Редактировать атрибут{% else %}Новый атрибут{% endif %}
|
||||
</h5>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Основные поля атрибута -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">Название <span class="text-danger">*</span></label>
|
||||
{{ form.name }}
|
||||
{% if form.name.errors %}
|
||||
<small class="text-danger">{{ form.name.errors.0 }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label"><i class="bi bi-arrows-vertical"></i> Позиция</label>
|
||||
{{ form.position }}
|
||||
<small class="text-muted">Порядок сортировки</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Slug (URL)</label>
|
||||
{{ form.slug }}
|
||||
<small class="text-muted">{{ form.slug.help_text }}</small>
|
||||
{% if form.slug.errors %}
|
||||
<small class="text-danger d-block">{{ form.slug.errors.0 }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label">Описание</label>
|
||||
{{ form.description }}
|
||||
</div>
|
||||
|
||||
<!-- Значения атрибута (inline formset) -->
|
||||
<hr>
|
||||
<h6 class="mb-3">
|
||||
<i class="bi bi-list-ul"></i> Значения атрибута
|
||||
</h6>
|
||||
|
||||
{{ value_formset.management_form }}
|
||||
|
||||
<!-- Заголовки столбцов -->
|
||||
<div class="row align-items-center g-2 mb-2 small text-muted fw-medium">
|
||||
<div class="col">Значение</div>
|
||||
<div class="col-auto" style="width: 120px;">Slug</div>
|
||||
<div class="col-auto" style="width: 80px;" title="Порядок сортировки"><i class="bi bi-arrows-vertical"></i> Поз.</div>
|
||||
<div class="col-auto" style="width: 40px;"></div>
|
||||
</div>
|
||||
|
||||
<div id="value-formset">
|
||||
{% for value_form in value_formset %}
|
||||
<div class="value-row mb-2 {% if value_form.instance.pk %}{% else %}empty-form{% endif %}">
|
||||
<div class="row align-items-center g-2">
|
||||
<div class="col">
|
||||
{{ value_form.value }}
|
||||
{% if value_form.value.errors %}
|
||||
<small class="text-danger">{{ value_form.value.errors.0 }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-auto" style="width: 120px;">
|
||||
{{ value_form.slug }}
|
||||
</div>
|
||||
<div class="col-auto" style="width: 80px;">
|
||||
{{ value_form.position }}
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
{% if value_form.instance.pk %}
|
||||
<div class="form-check">
|
||||
{{ value_form.DELETE }}
|
||||
<label class="form-check-label text-danger small" for="{{ value_form.DELETE.id_for_label }}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</label>
|
||||
</div>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-outline-danger btn-sm remove-value-row">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{{ value_form.id }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<button type="button" id="add-value-btn" class="btn btn-outline-secondary btn-sm mt-2">
|
||||
<i class="bi bi-plus"></i> Добавить значение
|
||||
</button>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check2"></i>
|
||||
{% if object %}Сохранить{% else %}Создать{% endif %}
|
||||
</button>
|
||||
<a href="{% url 'products:attribute-list' %}" class="btn btn-light btn-sm">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if object %}
|
||||
<hr class="my-3">
|
||||
<small class="text-muted d-block text-center">
|
||||
<i class="bi bi-clock-history"></i> Обновлено: {{ object.updated_at|date:"d.m.Y H:i" }}
|
||||
</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const formset = document.getElementById('value-formset');
|
||||
const addBtn = document.getElementById('add-value-btn');
|
||||
const totalForms = document.querySelector('[name="values-TOTAL_FORMS"]');
|
||||
|
||||
// Шаблон для новой строки
|
||||
function createValueRow(index) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'value-row mb-2';
|
||||
row.innerHTML = `
|
||||
<div class="row align-items-center g-2">
|
||||
<div class="col">
|
||||
<input type="text" name="values-${index}-value" class="form-control form-control-sm" placeholder="Например: 50">
|
||||
</div>
|
||||
<div class="col-auto" style="width: 120px;">
|
||||
<input type="text" name="values-${index}-slug" class="form-control form-control-sm" placeholder="Авто">
|
||||
</div>
|
||||
<div class="col-auto" style="width: 80px;">
|
||||
<input type="number" name="values-${index}-position" value="0" class="form-control form-control-sm" style="width: 70px;">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="button" class="btn btn-outline-danger btn-sm remove-value-row">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" name="values-${index}-id" value="">
|
||||
</div>
|
||||
`;
|
||||
return row;
|
||||
}
|
||||
|
||||
// Добавление новой строки
|
||||
addBtn.addEventListener('click', function() {
|
||||
const currentTotal = parseInt(totalForms.value);
|
||||
const newRow = createValueRow(currentTotal);
|
||||
formset.appendChild(newRow);
|
||||
totalForms.value = currentTotal + 1;
|
||||
|
||||
// Фокус на новое поле
|
||||
newRow.querySelector('input[type="text"]').focus();
|
||||
});
|
||||
|
||||
// Удаление строки (делегирование событий)
|
||||
formset.addEventListener('click', function(e) {
|
||||
if (e.target.closest('.remove-value-row')) {
|
||||
const row = e.target.closest('.value-row');
|
||||
row.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
110
myproject/products/templates/products/attribute_list.html
Normal file
110
myproject/products/templates/products/attribute_list.html
Normal file
@@ -0,0 +1,110 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Атрибуты товаров{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<!-- Заголовок и кнопка создания -->
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h5 class="mb-0"><i class="bi bi-sliders text-primary"></i> Атрибуты товаров</h5>
|
||||
<a href="{% url 'products:attribute-create' %}" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-plus-lg"></i> Новый атрибут
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Поиск -->
|
||||
<form method="get" class="d-flex gap-2 mb-3">
|
||||
<input type="text" class="form-control form-control-sm" name="search"
|
||||
value="{{ search_query }}" placeholder="Поиск по названию...">
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-search"></i>
|
||||
</button>
|
||||
{% if search_query %}
|
||||
<a href="{% url 'products:attribute-list' %}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-x"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
<!-- Список атрибутов -->
|
||||
{% if attributes %}
|
||||
<!-- Заголовки столбцов -->
|
||||
<div class="d-flex align-items-center py-2 px-3 bg-light border rounded-top small fw-medium text-muted">
|
||||
<div class="flex-grow-1">Название</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span style="min-width: 70px; text-align: center;">Значения</span>
|
||||
<span style="min-width: 60px; text-align: center;">Действия</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-group list-group-flush border border-top-0 rounded-bottom">
|
||||
{% for attr in attributes %}
|
||||
<div class="list-group-item d-flex align-items-center py-2 px-3">
|
||||
<div class="flex-grow-1">
|
||||
<a href="{% url 'products:attribute-detail' attr.pk %}" class="text-decoration-none fw-medium">
|
||||
{{ attr.name }}
|
||||
</a>
|
||||
<small class="text-muted ms-2">{{ attr.slug }}</small>
|
||||
{% if attr.description %}
|
||||
<small class="text-muted d-block">{{ attr.description|truncatewords:10 }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge bg-secondary" title="Количество значений">
|
||||
{{ attr.num_values }} знач.
|
||||
</span>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="{% url 'products:attribute-update' attr.pk %}"
|
||||
class="btn btn-outline-secondary btn-sm py-0 px-1" title="Изменить">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<a href="{% url 'products:attribute-delete' attr.pk %}"
|
||||
class="btn btn-outline-danger btn-sm py-0 px-1" title="Удалить">
|
||||
<i class="bi bi-trash"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Пагинация -->
|
||||
{% if is_paginated %}
|
||||
<nav class="mt-3">
|
||||
<ul class="pagination pagination-sm justify-content-center mb-0">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if search_query %}&search={{ search_query }}{% endif %}">
|
||||
«
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ page_obj.number }}/{{ page_obj.paginator.num_pages }}</span>
|
||||
</li>
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if search_query %}&search={{ search_query }}{% endif %}">
|
||||
»
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-sliders fs-1 opacity-25"></i>
|
||||
<p class="mb-0 mt-2">Атрибутов пока нет</p>
|
||||
<p class="small">Создайте первый атрибут, например "Длина стебля"</p>
|
||||
<a href="{% url 'products:attribute-create' %}" class="btn btn-primary btn-sm mt-2">
|
||||
<i class="bi bi-plus-lg"></i> Создать атрибут
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -86,14 +86,26 @@ urlpatterns = [
|
||||
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'),
|
||||
# CRUD URLs for ProductAttribute (справочник атрибутов)
|
||||
path('attributes/', views.ProductAttributeListView.as_view(), name='attribute-list'),
|
||||
path('attributes/create/', views.ProductAttributeCreateView.as_view(), name='attribute-create'),
|
||||
path('attributes/<int:pk>/', views.ProductAttributeDetailView.as_view(), name='attribute-detail'),
|
||||
path('attributes/<int:pk>/update/', views.ProductAttributeUpdateView.as_view(), name='attribute-update'),
|
||||
path('attributes/<int:pk>/delete/', views.ProductAttributeDeleteView.as_view(), name='attribute-delete'),
|
||||
|
||||
# API для атрибутов
|
||||
path('api/attributes/create/', views.create_attribute_api, name='api-attribute-create'),
|
||||
path('api/attributes/<int:pk>/values/add/', views.add_attribute_value_api, name='attribute-add-value'),
|
||||
path('api/attributes/<int:pk>/values/<int:value_id>/delete/', views.delete_attribute_value_api, name='attribute-delete-value'),
|
||||
|
||||
# CRUD URLs for ConfigurableProduct
|
||||
path('configurable-kits/', views.ConfigurableProductListView.as_view(), name='configurablekit-list'),
|
||||
path('configurable-kits/create/', views.ConfigurableProductCreateView.as_view(), name='configurablekit-create'),
|
||||
path('configurable-kits/<int:pk>/', views.ConfigurableProductDetailView.as_view(), name='configurablekit-detail'),
|
||||
path('configurable-kits/<int:pk>/update/', views.ConfigurableProductUpdateView.as_view(), name='configurablekit-update'),
|
||||
path('configurable-kits/<int:pk>/delete/', views.ConfigurableProductDeleteView.as_view(), name='configurablekit-delete'),
|
||||
|
||||
# API для управления вариантами ConfigurableKitProduct
|
||||
# API для управления вариантами ConfigurableProduct
|
||||
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'),
|
||||
|
||||
@@ -80,18 +80,30 @@ from .tag_views import (
|
||||
ProductTagDeleteView,
|
||||
)
|
||||
|
||||
# CRUD представления для ConfigurableKitProduct
|
||||
# CRUD представления для ConfigurableProduct
|
||||
from .configurablekit_views import (
|
||||
ConfigurableKitProductListView,
|
||||
ConfigurableKitProductCreateView,
|
||||
ConfigurableKitProductDetailView,
|
||||
ConfigurableKitProductUpdateView,
|
||||
ConfigurableKitProductDeleteView,
|
||||
ConfigurableProductListView,
|
||||
ConfigurableProductCreateView,
|
||||
ConfigurableProductDetailView,
|
||||
ConfigurableProductUpdateView,
|
||||
ConfigurableProductDeleteView,
|
||||
add_option_to_configurable,
|
||||
remove_option_from_configurable,
|
||||
set_option_as_default,
|
||||
)
|
||||
|
||||
# CRUD представления для ProductAttribute (справочник атрибутов)
|
||||
from .attribute_views import (
|
||||
ProductAttributeListView,
|
||||
ProductAttributeCreateView,
|
||||
ProductAttributeDetailView,
|
||||
ProductAttributeUpdateView,
|
||||
ProductAttributeDeleteView,
|
||||
create_attribute_api,
|
||||
add_attribute_value_api,
|
||||
delete_attribute_value_api,
|
||||
)
|
||||
|
||||
# API представления
|
||||
from .api_views import search_products_and_variants, validate_kit_cost, create_temporary_kit_api, create_tag_api
|
||||
|
||||
@@ -162,16 +174,26 @@ __all__ = [
|
||||
'ProductTagUpdateView',
|
||||
'ProductTagDeleteView',
|
||||
|
||||
# ConfigurableKitProduct CRUD
|
||||
'ConfigurableKitProductListView',
|
||||
'ConfigurableKitProductCreateView',
|
||||
'ConfigurableKitProductDetailView',
|
||||
'ConfigurableKitProductUpdateView',
|
||||
'ConfigurableKitProductDeleteView',
|
||||
# ConfigurableProduct CRUD
|
||||
'ConfigurableProductListView',
|
||||
'ConfigurableProductCreateView',
|
||||
'ConfigurableProductDetailView',
|
||||
'ConfigurableProductUpdateView',
|
||||
'ConfigurableProductDeleteView',
|
||||
'add_option_to_configurable',
|
||||
'remove_option_from_configurable',
|
||||
'set_option_as_default',
|
||||
|
||||
# ProductAttribute CRUD
|
||||
'ProductAttributeListView',
|
||||
'ProductAttributeCreateView',
|
||||
'ProductAttributeDetailView',
|
||||
'ProductAttributeUpdateView',
|
||||
'ProductAttributeDeleteView',
|
||||
'create_attribute_api',
|
||||
'add_attribute_value_api',
|
||||
'delete_attribute_value_api',
|
||||
|
||||
# API
|
||||
'search_products_and_variants',
|
||||
'validate_kit_cost',
|
||||
|
||||
247
myproject/products/views/attribute_views.py
Normal file
247
myproject/products/views/attribute_views.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""
|
||||
CRUD представления для справочника атрибутов товаров (ProductAttribute, ProductAttributeValue).
|
||||
"""
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.views.generic import ListView, CreateView, DetailView, UpdateView, DeleteView
|
||||
from django.urls import reverse_lazy
|
||||
from django.db.models import Q, Count
|
||||
from django.db import IntegrityError
|
||||
from django.http import JsonResponse
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.contrib.auth.decorators import login_required
|
||||
import json
|
||||
|
||||
from ..models import ProductAttribute, ProductAttributeValue
|
||||
from ..forms import ProductAttributeForm, ProductAttributeValueFormSet
|
||||
|
||||
|
||||
class ProductAttributeListView(LoginRequiredMixin, ListView):
|
||||
"""Список всех атрибутов с поиском"""
|
||||
model = ProductAttribute
|
||||
template_name = 'products/attribute_list.html'
|
||||
context_object_name = 'attributes'
|
||||
paginate_by = 20
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# Аннотируем количество значений для каждого атрибута
|
||||
queryset = queryset.annotate(
|
||||
num_values=Count('values', distinct=True)
|
||||
)
|
||||
|
||||
# Поиск по названию и slug
|
||||
search_query = self.request.GET.get('search')
|
||||
if search_query:
|
||||
queryset = queryset.filter(
|
||||
Q(name__icontains=search_query) |
|
||||
Q(slug__icontains=search_query)
|
||||
)
|
||||
|
||||
return queryset.order_by('position', 'name')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['search_query'] = self.request.GET.get('search', '')
|
||||
return context
|
||||
|
||||
|
||||
class ProductAttributeDetailView(LoginRequiredMixin, DetailView):
|
||||
"""Детальная информация об атрибуте с его значениями"""
|
||||
model = ProductAttribute
|
||||
template_name = 'products/attribute_detail.html'
|
||||
context_object_name = 'attribute'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
attribute = self.get_object()
|
||||
|
||||
# Получаем все значения атрибута
|
||||
context['values'] = attribute.values.all().order_by('position', 'value')
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class ProductAttributeCreateView(LoginRequiredMixin, CreateView):
|
||||
"""Создание нового атрибута с inline значениями"""
|
||||
model = ProductAttribute
|
||||
form_class = ProductAttributeForm
|
||||
template_name = 'products/attribute_form.html'
|
||||
success_url = reverse_lazy('products:attribute-list')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
if self.request.POST:
|
||||
context['value_formset'] = ProductAttributeValueFormSet(self.request.POST, instance=self.object)
|
||||
else:
|
||||
context['value_formset'] = ProductAttributeValueFormSet(instance=self.object)
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
context = self.get_context_data()
|
||||
value_formset = context['value_formset']
|
||||
|
||||
try:
|
||||
self.object = form.save()
|
||||
|
||||
if value_formset.is_valid():
|
||||
value_formset.instance = self.object
|
||||
value_formset.save()
|
||||
else:
|
||||
return self.form_invalid(form)
|
||||
|
||||
messages.success(self.request, f'Атрибут "{self.object.name}" успешно создан.')
|
||||
return super().form_valid(form)
|
||||
|
||||
except IntegrityError as e:
|
||||
error_msg = str(e).lower()
|
||||
if 'unique' in error_msg:
|
||||
messages.error(
|
||||
self.request,
|
||||
f'Ошибка: атрибут с таким названием уже существует.'
|
||||
)
|
||||
else:
|
||||
messages.error(
|
||||
self.request,
|
||||
'Ошибка при сохранении атрибута. Пожалуйста, проверьте введённые данные.'
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
|
||||
|
||||
class ProductAttributeUpdateView(LoginRequiredMixin, UpdateView):
|
||||
"""Редактирование существующего атрибута с inline значениями"""
|
||||
model = ProductAttribute
|
||||
form_class = ProductAttributeForm
|
||||
template_name = 'products/attribute_form.html'
|
||||
success_url = reverse_lazy('products:attribute-list')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
if self.request.POST:
|
||||
context['value_formset'] = ProductAttributeValueFormSet(self.request.POST, instance=self.object)
|
||||
else:
|
||||
context['value_formset'] = ProductAttributeValueFormSet(instance=self.object)
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
context = self.get_context_data()
|
||||
value_formset = context['value_formset']
|
||||
|
||||
try:
|
||||
self.object = form.save()
|
||||
|
||||
if value_formset.is_valid():
|
||||
value_formset.save()
|
||||
else:
|
||||
return self.form_invalid(form)
|
||||
|
||||
messages.success(self.request, f'Атрибут "{self.object.name}" успешно обновлен.')
|
||||
return super().form_valid(form)
|
||||
|
||||
except IntegrityError as e:
|
||||
error_msg = str(e).lower()
|
||||
if 'unique' in error_msg:
|
||||
messages.error(
|
||||
self.request,
|
||||
f'Ошибка: атрибут с таким названием уже существует.'
|
||||
)
|
||||
else:
|
||||
messages.error(
|
||||
self.request,
|
||||
'Ошибка при сохранении атрибута. Пожалуйста, проверьте введённые данные.'
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
|
||||
|
||||
class ProductAttributeDeleteView(LoginRequiredMixin, DeleteView):
|
||||
"""Удаление атрибута с подтверждением"""
|
||||
model = ProductAttribute
|
||||
template_name = 'products/attribute_confirm_delete.html'
|
||||
success_url = reverse_lazy('products:attribute-list')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
attribute = self.get_object()
|
||||
|
||||
# Количество значений
|
||||
context['values_count'] = attribute.values.count()
|
||||
|
||||
return context
|
||||
|
||||
def delete(self, request, *args, **kwargs):
|
||||
attribute = self.get_object()
|
||||
attribute_name = attribute.name
|
||||
response = super().delete(request, *args, **kwargs)
|
||||
messages.success(request, f'Атрибут "{attribute_name}" успешно удален.')
|
||||
return response
|
||||
|
||||
|
||||
# API endpoints
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def create_attribute_api(request):
|
||||
"""API для быстрого создания атрибута"""
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
name = data.get('name', '').strip()
|
||||
|
||||
if not name:
|
||||
return JsonResponse({'success': False, 'error': 'Название обязательно'})
|
||||
|
||||
attribute = ProductAttribute.objects.create(name=name)
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'id': attribute.pk,
|
||||
'name': attribute.name,
|
||||
'slug': attribute.slug
|
||||
})
|
||||
except IntegrityError:
|
||||
return JsonResponse({'success': False, 'error': 'Атрибут с таким названием уже существует'})
|
||||
except Exception as e:
|
||||
return JsonResponse({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def add_attribute_value_api(request, pk):
|
||||
"""API для добавления значения к атрибуту"""
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
value = data.get('value', '').strip()
|
||||
|
||||
if not value:
|
||||
return JsonResponse({'success': False, 'error': 'Значение обязательно'})
|
||||
|
||||
attribute = ProductAttribute.objects.get(pk=pk)
|
||||
attr_value = ProductAttributeValue.objects.create(
|
||||
attribute=attribute,
|
||||
value=value
|
||||
)
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'id': attr_value.pk,
|
||||
'value': attr_value.value,
|
||||
'slug': attr_value.slug
|
||||
})
|
||||
except ProductAttribute.DoesNotExist:
|
||||
return JsonResponse({'success': False, 'error': 'Атрибут не найден'})
|
||||
except IntegrityError:
|
||||
return JsonResponse({'success': False, 'error': 'Такое значение уже существует'})
|
||||
except Exception as e:
|
||||
return JsonResponse({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def delete_attribute_value_api(request, pk, value_id):
|
||||
"""API для удаления значения атрибута"""
|
||||
try:
|
||||
value = ProductAttributeValue.objects.get(pk=value_id, attribute_id=pk)
|
||||
value.delete()
|
||||
return JsonResponse({'success': True})
|
||||
except ProductAttributeValue.DoesNotExist:
|
||||
return JsonResponse({'success': False, 'error': 'Значение не найдено'})
|
||||
except Exception as e:
|
||||
return JsonResponse({'success': False, 'error': str(e)})
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
CRUD представления для вариативных товаров (ConfigurableKitProduct).
|
||||
CRUD представления для вариативных товаров (ConfigurableProduct).
|
||||
"""
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
@@ -13,18 +13,18 @@ from django.contrib.auth.decorators import login_required
|
||||
from django.db import transaction
|
||||
|
||||
from user_roles.mixins import ManagerOwnerRequiredMixin
|
||||
from ..models import ConfigurableKitProduct, ConfigurableKitOption, ProductKit, ConfigurableKitProductAttribute
|
||||
from ..models import ConfigurableProduct, ConfigurableProductOption, ProductKit, ConfigurableProductAttribute
|
||||
from ..forms import (
|
||||
ConfigurableKitProductForm,
|
||||
ConfigurableKitOptionFormSetCreate,
|
||||
ConfigurableKitOptionFormSetUpdate,
|
||||
ConfigurableKitProductAttributeFormSetCreate,
|
||||
ConfigurableKitProductAttributeFormSetUpdate
|
||||
ConfigurableProductForm,
|
||||
ConfigurableProductOptionFormSetCreate,
|
||||
ConfigurableProductOptionFormSetUpdate,
|
||||
ConfigurableProductAttributeFormSetCreate,
|
||||
ConfigurableProductAttributeFormSetUpdate
|
||||
)
|
||||
|
||||
|
||||
class ConfigurableKitProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, ListView):
|
||||
model = ConfigurableKitProduct
|
||||
class ConfigurableProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, ListView):
|
||||
model = ConfigurableProduct
|
||||
template_name = 'products/configurablekit_list.html'
|
||||
context_object_name = 'configurable_kits'
|
||||
paginate_by = 20
|
||||
@@ -33,7 +33,7 @@ class ConfigurableKitProductListView(LoginRequiredMixin, ManagerOwnerRequiredMix
|
||||
queryset = super().get_queryset().prefetch_related(
|
||||
Prefetch(
|
||||
'options',
|
||||
queryset=ConfigurableKitOption.objects.select_related('kit')
|
||||
queryset=ConfigurableProductOption.objects.select_related('kit')
|
||||
)
|
||||
)
|
||||
|
||||
@@ -80,8 +80,8 @@ class ConfigurableKitProductListView(LoginRequiredMixin, ManagerOwnerRequiredMix
|
||||
return context
|
||||
|
||||
|
||||
class ConfigurableKitProductDetailView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DetailView):
|
||||
model = ConfigurableKitProduct
|
||||
class ConfigurableProductDetailView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DetailView):
|
||||
model = ConfigurableProduct
|
||||
template_name = 'products/configurablekit_detail.html'
|
||||
context_object_name = 'configurable_kit'
|
||||
|
||||
@@ -89,7 +89,7 @@ class ConfigurableKitProductDetailView(LoginRequiredMixin, ManagerOwnerRequiredM
|
||||
return super().get_queryset().prefetch_related(
|
||||
Prefetch(
|
||||
'options',
|
||||
queryset=ConfigurableKitOption.objects.select_related('kit').order_by('id')
|
||||
queryset=ConfigurableProductOption.objects.select_related('kit').order_by('id')
|
||||
),
|
||||
'parent_attributes'
|
||||
)
|
||||
@@ -104,9 +104,9 @@ class ConfigurableKitProductDetailView(LoginRequiredMixin, ManagerOwnerRequiredM
|
||||
return context
|
||||
|
||||
|
||||
class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, CreateView):
|
||||
model = ConfigurableKitProduct
|
||||
form_class = ConfigurableKitProductForm
|
||||
class ConfigurableProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, CreateView):
|
||||
model = ConfigurableProduct
|
||||
form_class = ConfigurableProductForm
|
||||
template_name = 'products/configurablekit_form.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
@@ -116,12 +116,12 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM
|
||||
if 'option_formset' in kwargs:
|
||||
context['option_formset'] = kwargs['option_formset']
|
||||
elif self.request.POST:
|
||||
context['option_formset'] = ConfigurableKitOptionFormSetCreate(
|
||||
context['option_formset'] = ConfigurableProductOptionFormSetCreate(
|
||||
self.request.POST,
|
||||
prefix='options'
|
||||
)
|
||||
else:
|
||||
context['option_formset'] = ConfigurableKitOptionFormSetCreate(
|
||||
context['option_formset'] = ConfigurableProductOptionFormSetCreate(
|
||||
prefix='options'
|
||||
)
|
||||
|
||||
@@ -129,12 +129,12 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM
|
||||
if 'attribute_formset' in kwargs:
|
||||
context['attribute_formset'] = kwargs['attribute_formset']
|
||||
elif self.request.POST:
|
||||
context['attribute_formset'] = ConfigurableKitProductAttributeFormSetCreate(
|
||||
context['attribute_formset'] = ConfigurableProductAttributeFormSetCreate(
|
||||
self.request.POST,
|
||||
prefix='attributes'
|
||||
)
|
||||
else:
|
||||
context['attribute_formset'] = ConfigurableKitProductAttributeFormSetCreate(
|
||||
context['attribute_formset'] = ConfigurableProductAttributeFormSetCreate(
|
||||
prefix='attributes'
|
||||
)
|
||||
|
||||
@@ -147,14 +147,14 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
from products.models.kits import ConfigurableKitOptionAttribute
|
||||
from products.models.kits import ConfigurableProductOptionAttribute
|
||||
|
||||
# Пересоздаём formsets с POST данными
|
||||
option_formset = ConfigurableKitOptionFormSetCreate(
|
||||
option_formset = ConfigurableProductOptionFormSetCreate(
|
||||
self.request.POST,
|
||||
prefix='options'
|
||||
)
|
||||
attribute_formset = ConfigurableKitProductAttributeFormSetCreate(
|
||||
attribute_formset = ConfigurableProductAttributeFormSetCreate(
|
||||
self.request.POST,
|
||||
prefix='attributes'
|
||||
)
|
||||
@@ -212,7 +212,7 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM
|
||||
# Сохраняем выбранные атрибуты для этого варианта
|
||||
for field_name, field_value in option_form.cleaned_data.items():
|
||||
if field_name.startswith('attribute_') and field_value:
|
||||
ConfigurableKitOptionAttribute.objects.create(
|
||||
ConfigurableProductOptionAttribute.objects.create(
|
||||
option=option,
|
||||
attribute=field_value
|
||||
)
|
||||
@@ -250,7 +250,7 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM
|
||||
from products.models.kits import ProductKit
|
||||
|
||||
# Сначала удаляем все старые атрибуты
|
||||
ConfigurableKitProductAttribute.objects.filter(parent=self.object).delete()
|
||||
ConfigurableProductAttribute.objects.filter(parent=self.object).delete()
|
||||
|
||||
# Получаем количество карточек параметров
|
||||
total_forms_str = self.request.POST.get('attributes-TOTAL_FORMS', '0')
|
||||
@@ -293,7 +293,7 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
kit_ids = []
|
||||
|
||||
# Создаём ConfigurableKitProductAttribute для каждого значения
|
||||
# Создаём ConfigurableProductAttribute для каждого значения
|
||||
for value_idx, value in enumerate(values):
|
||||
if value and value.strip():
|
||||
# Получаем соответствующий ID комплекта
|
||||
@@ -317,7 +317,7 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM
|
||||
# Комплект не найден - создаём без привязки
|
||||
pass
|
||||
|
||||
ConfigurableKitProductAttribute.objects.create(**create_kwargs)
|
||||
ConfigurableProductAttribute.objects.create(**create_kwargs)
|
||||
|
||||
def _validate_variant_kits(self, option_formset):
|
||||
"""
|
||||
@@ -376,9 +376,9 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredM
|
||||
return reverse_lazy('products:configurablekit-detail', kwargs={'pk': self.object.pk})
|
||||
|
||||
|
||||
class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, UpdateView):
|
||||
model = ConfigurableKitProduct
|
||||
form_class = ConfigurableKitProductForm
|
||||
class ConfigurableProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, UpdateView):
|
||||
model = ConfigurableProduct
|
||||
form_class = ConfigurableProductForm
|
||||
template_name = 'products/configurablekit_form.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
@@ -388,13 +388,13 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM
|
||||
if 'option_formset' in kwargs:
|
||||
context['option_formset'] = kwargs['option_formset']
|
||||
elif self.request.POST:
|
||||
context['option_formset'] = ConfigurableKitOptionFormSetUpdate(
|
||||
context['option_formset'] = ConfigurableProductOptionFormSetUpdate(
|
||||
self.request.POST,
|
||||
instance=self.object,
|
||||
prefix='options'
|
||||
)
|
||||
else:
|
||||
context['option_formset'] = ConfigurableKitOptionFormSetUpdate(
|
||||
context['option_formset'] = ConfigurableProductOptionFormSetUpdate(
|
||||
instance=self.object,
|
||||
prefix='options'
|
||||
)
|
||||
@@ -403,13 +403,13 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM
|
||||
if 'attribute_formset' in kwargs:
|
||||
context['attribute_formset'] = kwargs['attribute_formset']
|
||||
elif self.request.POST:
|
||||
context['attribute_formset'] = ConfigurableKitProductAttributeFormSetUpdate(
|
||||
context['attribute_formset'] = ConfigurableProductAttributeFormSetUpdate(
|
||||
self.request.POST,
|
||||
instance=self.object,
|
||||
prefix='attributes'
|
||||
)
|
||||
else:
|
||||
context['attribute_formset'] = ConfigurableKitProductAttributeFormSetUpdate(
|
||||
context['attribute_formset'] = ConfigurableProductAttributeFormSetUpdate(
|
||||
instance=self.object,
|
||||
prefix='attributes'
|
||||
)
|
||||
@@ -423,15 +423,15 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
from products.models.kits import ConfigurableKitOptionAttribute
|
||||
from products.models.kits import ConfigurableProductOptionAttribute
|
||||
|
||||
# Пересоздаём formsets с POST данными
|
||||
option_formset = ConfigurableKitOptionFormSetUpdate(
|
||||
option_formset = ConfigurableProductOptionFormSetUpdate(
|
||||
self.request.POST,
|
||||
instance=self.object,
|
||||
prefix='options'
|
||||
)
|
||||
attribute_formset = ConfigurableKitProductAttributeFormSetUpdate(
|
||||
attribute_formset = ConfigurableProductAttributeFormSetUpdate(
|
||||
self.request.POST,
|
||||
instance=self.object,
|
||||
prefix='attributes'
|
||||
@@ -489,7 +489,7 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM
|
||||
# Сохраняем выбранные атрибуты для этого варианта
|
||||
for field_name, field_value in option_form.cleaned_data.items():
|
||||
if field_name.startswith('attribute_') and field_value:
|
||||
ConfigurableKitOptionAttribute.objects.create(
|
||||
ConfigurableProductOptionAttribute.objects.create(
|
||||
option=option,
|
||||
attribute=field_value
|
||||
)
|
||||
@@ -527,7 +527,7 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM
|
||||
from products.models.kits import ProductKit
|
||||
|
||||
# Сначала удаляем все старые атрибуты
|
||||
ConfigurableKitProductAttribute.objects.filter(parent=self.object).delete()
|
||||
ConfigurableProductAttribute.objects.filter(parent=self.object).delete()
|
||||
|
||||
# Получаем количество карточек параметров
|
||||
total_forms_str = self.request.POST.get('attributes-TOTAL_FORMS', '0')
|
||||
@@ -570,7 +570,7 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
kit_ids = []
|
||||
|
||||
# Создаём ConfigurableKitProductAttribute для каждого значения
|
||||
# Создаём ConfigurableProductAttribute для каждого значения
|
||||
for value_idx, value in enumerate(values):
|
||||
if value and value.strip():
|
||||
# Получаем соответствующий ID комплекта
|
||||
@@ -594,7 +594,7 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM
|
||||
# Комплект не найден - создаём без привязки
|
||||
pass
|
||||
|
||||
ConfigurableKitProductAttribute.objects.create(**create_kwargs)
|
||||
ConfigurableProductAttribute.objects.create(**create_kwargs)
|
||||
|
||||
def _validate_variant_kits(self, option_formset):
|
||||
"""
|
||||
@@ -653,8 +653,8 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredM
|
||||
return reverse_lazy('products:configurablekit-detail', kwargs={'pk': self.object.pk})
|
||||
|
||||
|
||||
class ConfigurableKitProductDeleteView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DeleteView):
|
||||
model = ConfigurableKitProduct
|
||||
class ConfigurableProductDeleteView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DeleteView):
|
||||
model = ConfigurableProduct
|
||||
template_name = 'products/configurablekit_confirm_delete.html'
|
||||
success_url = reverse_lazy('products:configurablekit-list')
|
||||
|
||||
@@ -671,7 +671,7 @@ def add_option_to_configurable(request, pk):
|
||||
"""
|
||||
Добавить вариант (комплект) к вариативному товару.
|
||||
"""
|
||||
configurable = get_object_or_404(ConfigurableKitProduct, pk=pk)
|
||||
configurable = get_object_or_404(ConfigurableProduct, pk=pk)
|
||||
kit_id = request.POST.get('kit_id')
|
||||
attributes = request.POST.get('attributes', '')
|
||||
is_default = request.POST.get('is_default') == 'true'
|
||||
@@ -685,15 +685,15 @@ def add_option_to_configurable(request, pk):
|
||||
return JsonResponse({'success': False, 'error': 'Комплект не найден'}, status=404)
|
||||
|
||||
# Проверяем, не добавлен ли уже этот комплект
|
||||
if ConfigurableKitOption.objects.filter(parent=configurable, kit=kit).exists():
|
||||
if ConfigurableProductOption.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)
|
||||
ConfigurableProductOption.objects.filter(parent=configurable, is_default=True).update(is_default=False)
|
||||
|
||||
# Создаём вариант
|
||||
option = ConfigurableKitOption.objects.create(
|
||||
option = ConfigurableProductOption.objects.create(
|
||||
parent=configurable,
|
||||
kit=kit,
|
||||
attributes=attributes,
|
||||
@@ -720,8 +720,8 @@ 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)
|
||||
configurable = get_object_or_404(ConfigurableProduct, pk=pk)
|
||||
option = get_object_or_404(ConfigurableProductOption, pk=option_id, parent=configurable)
|
||||
|
||||
option.delete()
|
||||
|
||||
@@ -734,11 +734,11 @@ 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)
|
||||
configurable = get_object_or_404(ConfigurableProduct, pk=pk)
|
||||
option = get_object_or_404(ConfigurableProductOption, pk=option_id, parent=configurable)
|
||||
|
||||
# Снимаем флаг со всех других
|
||||
ConfigurableKitOption.objects.filter(parent=configurable).update(is_default=False)
|
||||
ConfigurableProductOption.objects.filter(parent=configurable).update(is_default=False)
|
||||
|
||||
# Устанавливаем текущий
|
||||
option.is_default = True
|
||||
|
||||
Reference in New Issue
Block a user