Рефакторинг системы вариативных товаров и справочник атрибутов
Основные изменения: - Переименование 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:
@@ -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="Значение атрибута"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user