Рефакторинг системы вариативных товаров и справочник атрибутов

Основные изменения:
- Переименование 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:
2025-12-30 01:44:34 +03:00
parent 277a514a82
commit 79ff523adb
36 changed files with 1597 additions and 951 deletions

View File

@@ -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',