Files
octopus/myproject/products/forms.py
Andrey Smakotin 1fb280607a feat(integrations): добавить поле primary_category и маппинг категорий для интеграций
Добавлена поддержка выбора основной категории (primary_category) для товаров и наборов, а также новая модель IntegrationCategoryMapping для связи категорий с внешними площадками. Теперь можно указать категорию товара, которая будет использоваться при экспорте на внешние площадки (Recommerce, WooCommerce и др.), с возможностью настройки маппинга категорий для каждого типа интеграции.
2026-01-14 01:53:38 +03:00

1170 lines
51 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from django import forms
from django.forms import inlineformset_factory
from .models import (
Product, ProductKit, ProductCategory, ProductTag, ProductPhoto, KitItem,
ProductKitPhoto, ProductCategoryPhoto, ProductVariantGroup, ProductVariantGroupItem,
ConfigurableProduct, ConfigurableProductOption, ConfigurableProductAttribute,
ProductAttribute, ProductAttributeValue, ProductSalesUnit, UnitOfMeasure
)
class SKUUniqueMixin:
"""
Миксин для валидации уникальности артикула среди всех моделей.
Проверяет уникальность SKU среди: Product, ProductKit, ProductCategory, ConfigurableProduct.
Также защищает зарезервированные префиксы от ручного ввода.
"""
# Зарезервированные префиксы для автогенерации
RESERVED_PREFIXES = {
'PROD-': 'товаров',
'KIT-': 'комплектов',
'CAT-': 'категорий',
'VAR-': 'вариативных товаров',
}
def clean_sku(self):
"""
Проверяет уникальность артикула среди всех моделей с артикулами.
Если SKU пустой - пропускаем (будет сгенерирован автоматически).
Запрещает ручной ввод зарезервированных префиксов.
"""
sku = self.cleaned_data.get('sku')
# Пустое значение - ок, будет автогенерация
if not sku or sku.strip() == '':
return None
sku = sku.strip()
# Проверяем зарезервированные префиксы
# Но пропускаем проверку, если это редактирование и артикул не изменился
sku_upper = sku.upper()
is_existing_sku = self.instance.pk and self.instance.sku == sku
if not is_existing_sku:
for prefix, entity_type in self.RESERVED_PREFIXES.items():
if sku_upper.startswith(prefix):
raise forms.ValidationError(
f'Префикс "{prefix}" зарезервирован для автоматической генерации артикулов {entity_type}. '
f'Пожалуйста, используйте другой артикул или оставьте поле пустым для автогенерации.'
)
# Проверяем во всех моделях
models_to_check = [
(Product, 'товар'),
(ProductKit, 'комплект'),
(ProductCategory, 'категория'),
(ConfigurableProduct, 'вариативный товар'),
]
for model, model_name in models_to_check:
queryset = model.objects.filter(sku=sku)
# Исключаем текущий объект при редактировании
if self.instance.pk and isinstance(self.instance, model):
queryset = queryset.exclude(pk=self.instance.pk)
if queryset.exists():
existing = queryset.first()
raise forms.ValidationError(
f'Артикул "{sku}" уже используется в {model_name} "{existing.name}". '
f'Пожалуйста, выберите другой артикул.'
)
return sku
class ProductForm(SKUUniqueMixin, forms.ModelForm):
"""
Форма для создания и редактирования товара.
Поле photos НЕ включено в форму - файлы обрабатываются отдельно в view.
"""
categories = forms.ModelMultipleChoiceField(
queryset=ProductCategory.objects.filter(is_active=True),
widget=forms.CheckboxSelectMultiple,
required=False,
label="Категории"
)
primary_category = forms.ModelChoiceField(
queryset=ProductCategory.objects.filter(is_active=True),
required=False,
empty_label="Не выбрана",
label="Основная категория",
help_text="Используется для интеграций с внешними площадками",
widget=forms.Select(attrs={'class': 'form-select'})
)
tags = forms.ModelMultipleChoiceField(
queryset=ProductTag.objects.filter(is_active=True),
widget=forms.CheckboxSelectMultiple,
required=False,
label="Теги"
)
class Meta:
model = Product
fields = [
'name', 'sku', 'description', 'short_description', 'categories',
'primary_category', 'tags', 'base_unit', 'price', 'sale_price', 'status',
'is_new', 'is_popular', 'is_special'
]
labels = {
'name': 'Название',
'sku': 'Артикул',
'description': 'Описание',
'short_description': 'Краткое описание',
'categories': 'Категории',
'tags': 'Теги',
'base_unit': 'Единица измерения',
'price': 'Основная цена',
'sale_price': 'Цена со скидкой',
'status': 'Статус'
}
widgets = {
'base_unit': forms.Select(attrs={'class': 'form-control'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Make fields more user-friendly
self.fields['name'].widget.attrs.update({
'class': 'form-control form-control-lg fw-semibold',
'placeholder': 'Введите название товара'
})
self.fields['sku'].widget.attrs.update({
'class': 'form-control',
'placeholder': 'Артикул (необязательно, будет сгенерирован автоматически)'
})
self.fields['description'].widget.attrs.update({
'class': 'form-control',
'rows': 3
})
self.fields['short_description'].widget.attrs.update({
'class': 'form-control',
'rows': 2,
'placeholder': 'Краткое описание для превью и площадок'
})
self.fields['price'].widget.attrs.update({'class': 'form-control'})
self.fields['sale_price'].widget.attrs.update({'class': 'form-control'})
self.fields['base_unit'].widget.attrs.update({'class': 'form-control'})
self.fields['status'].widget.attrs.update({'class': 'form-control'})
# Фильтруем только активные единицы измерения
from .models import UnitOfMeasure
self.fields['base_unit'].queryset = UnitOfMeasure.objects.filter(
is_active=True
).order_by('position', 'code')
self.fields['base_unit'].required = False
self.fields['base_unit'].help_text = 'Базовая единица для учета товара на складе. На основе этой единицы можно создать единицы продажи.'
# Маркетинговые флаги (switch-стиль)
for flag_field in ['is_new', 'is_popular', 'is_special']:
self.fields[flag_field].widget.attrs.update({
'class': 'form-check-input',
'role': 'switch'
})
def clean_name(self):
"""Валидация уникальности имени для активных товаров"""
name = self.cleaned_data.get('name')
if name:
# Проверяем уникальность имени среди активных товаров
# Исключаем текущий товар при редактировании (self.instance.pk)
existing = Product.objects.filter(
name=name,
status='active'
)
if self.instance.pk:
existing = existing.exclude(pk=self.instance.pk)
if existing.exists():
raise forms.ValidationError(
f'Товар с названием "{name}" уже существует. '
f'Пожалуйста, используйте другое название.'
)
return name
class ProductKitForm(SKUUniqueMixin, forms.ModelForm):
"""
Форма для создания и редактирования комплекта.
Цена комплекта вычисляется автоматически из цен компонентов.
"""
categories = forms.ModelMultipleChoiceField(
queryset=ProductCategory.objects.filter(is_active=True),
widget=forms.CheckboxSelectMultiple,
required=False,
label="Категории"
)
primary_category = forms.ModelChoiceField(
queryset=ProductCategory.objects.filter(is_active=True),
required=False,
empty_label="Не выбрана",
label="Основная категория",
help_text="Используется для интеграций с внешними площадками",
widget=forms.Select(attrs={'class': 'form-select'})
)
tags = forms.ModelMultipleChoiceField(
queryset=ProductTag.objects.filter(is_active=True),
widget=forms.CheckboxSelectMultiple,
required=False,
label="Теги"
)
class Meta:
model = ProductKit
fields = [
'name', 'sku', 'description', 'short_description', 'categories',
'primary_category', 'tags', 'sale_price', 'price_adjustment_type', 'price_adjustment_value', 'status'
]
labels = {
'name': 'Название',
'sku': 'Артикул',
'description': 'Описание',
'short_description': 'Краткое описание',
'categories': 'Категории',
'tags': 'Теги',
'sale_price': 'Цена со скидкой',
'price_adjustment_type': 'Как изменить итоговую цену',
'price_adjustment_value': 'Значение корректировки',
'status': 'Статус'
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Make fields more user-friendly
self.fields['name'].widget.attrs.update({
'class': 'form-control',
'placeholder': 'Введите название комплекта'
})
self.fields['sku'].widget.attrs.update({
'class': 'form-control',
'placeholder': 'Артикул (необязательно, будет сгенерирован автоматически)'
})
self.fields['description'].widget.attrs.update({
'class': 'form-control',
'rows': 3
})
self.fields['short_description'].widget.attrs.update({
'class': 'form-control',
'rows': 2,
'placeholder': 'Краткое описание для превью и площадок'
})
self.fields['sale_price'].widget.attrs.update({'class': 'form-control'})
self.fields['price_adjustment_type'].widget.attrs.update({'class': 'form-control'})
self.fields['price_adjustment_value'].widget.attrs.update({
'class': 'form-control',
'step': '0.01',
'placeholder': '0'
})
self.fields['status'].widget.attrs.update({'class': 'form-control'})
def clean_name(self):
"""Валидация уникальности имени для активных комплектов"""
name = self.cleaned_data.get('name')
if name:
existing = ProductKit.objects.filter(
name=name,
status='active',
is_temporary=False
)
if self.instance.pk:
existing = existing.exclude(pk=self.instance.pk)
if existing.exists():
raise forms.ValidationError(
f'Комплект с названием "{name}" уже существует. '
f'Пожалуйста, используйте другое название.'
)
return name
def clean(self):
"""Дополнительная валидация: если выбран тип корректировки, указано значение"""
cleaned_data = super().clean()
adjustment_type = cleaned_data.get('price_adjustment_type')
adjustment_value = cleaned_data.get('price_adjustment_value')
# Если выбран тип корректировки (не 'none'), значение обязательно
if adjustment_type and adjustment_type != 'none':
if not adjustment_value or adjustment_value == 0:
raise forms.ValidationError(
'Укажите значение корректировки цены (> 0)'
)
return cleaned_data
class KitItemForm(forms.ModelForm):
"""
Форма для одного компонента комплекта.
Валидирует, что указан либо product, либо variant_group (но не оба).
Если обе поля пусты - это пустая форма, которая будет удалена.
"""
class Meta:
model = KitItem
fields = ['product', 'variant_group', 'quantity']
labels = {
'product': 'Конкретный товар',
'variant_group': 'Группа вариантов',
'quantity': 'Количество'
}
widgets = {
'product': forms.Select(attrs={'class': 'form-control'}),
'variant_group': forms.Select(attrs={'class': 'form-control'}),
'quantity': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001', 'min': '0'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Устанавливаем значение по умолчанию для quantity = 1
if not self.instance.pk: # Только для новых форм (создание, не редактирование)
self.fields['quantity'].initial = 1
def clean(self):
cleaned_data = super().clean()
product = cleaned_data.get('product')
variant_group = cleaned_data.get('variant_group')
quantity = cleaned_data.get('quantity')
# Если оба поля пусты - это пустая форма (не валидируем, она будет удалена)
if not product and not variant_group:
# Для пустых форм обнуляем количество
cleaned_data['quantity'] = None
return cleaned_data
# Валидация: должен быть указан либо product, либо variant_group (но не оба)
if product and variant_group:
raise forms.ValidationError(
"Нельзя указывать одновременно товар и группу вариантов. Выберите что-то одно."
)
# Валидация: если выбран товар/группа, количество обязательно и должно быть > 0
if (product or variant_group):
if not quantity or quantity <= 0:
raise forms.ValidationError('Необходимо указать количество больше 0')
return cleaned_data
# Кастомный базовый формсет с валидацией на дубликаты
class BaseKitItemFormSet(forms.BaseInlineFormSet):
def clean(self):
"""Проверка на дубликаты товаров в комплекте"""
if any(self.errors):
# Не проверяем дубликаты если есть другие ошибки
return
products = []
variant_groups = []
for form in self.forms:
if self.can_delete and self._should_delete_form(form):
continue
product = form.cleaned_data.get('product')
variant_group = form.cleaned_data.get('variant_group')
# Проверка дубликатов товаров
if product:
if product in products:
raise forms.ValidationError(
f'Товар "{product.name}" добавлен в комплект более одного раза. '
f'Каждый товар может быть добавлен только один раз.'
)
products.append(product)
# Проверка дубликатов групп вариантов
if variant_group:
if variant_group in variant_groups:
raise forms.ValidationError(
f'Группа вариантов "{variant_group.name}" добавлена более одного раза. '
f'Каждая группа может быть добавлена только один раз.'
)
variant_groups.append(variant_group)
# Формсет для создания комплектов (с пустой формой для удобства)
KitItemFormSetCreate = inlineformset_factory(
ProductKit,
KitItem,
form=KitItemForm,
formset=BaseKitItemFormSet,
fields=['product', 'variant_group', 'quantity'],
extra=1, # Показать 1 пустую форму для первого компонента
can_delete=True, # Разрешить удаление компонентов
min_num=0, # Минимум 0 компонентов (можно создать пустой комплект)
validate_min=False, # Не требовать минимум компонентов
can_delete_extra=True, # Разрешить удалять дополнительные формы
)
# Формсет для редактирования комплектов (без пустых форм, только существующие компоненты)
KitItemFormSetUpdate = inlineformset_factory(
ProductKit,
KitItem,
form=KitItemForm,
formset=BaseKitItemFormSet,
fields=['product', 'variant_group', 'quantity'],
extra=0, # НЕ показывать пустые формы при редактировании
can_delete=True, # Разрешить удаление компонентов
min_num=0, # Минимум 0 компонентов
validate_min=False, # Не требовать минимум компонентов
can_delete_extra=True, # Разрешить удалять дополнительные формы
)
# Для обратной совместимости (если где-то еще используется KitItemFormSet)
KitItemFormSet = KitItemFormSetCreate
class ProductCategoryForm(SKUUniqueMixin, forms.ModelForm):
"""
Форма для создания и редактирования категории товаров.
Поле photos НЕ включено в форму - файлы обрабатываются отдельно в view.
"""
parent = forms.ModelChoiceField(
queryset=ProductCategory.objects.filter(is_active=True),
required=False,
empty_label="Нет (корневая категория)",
label="Родительская категория",
widget=forms.Select(attrs={'class': 'form-control'})
)
class Meta:
model = ProductCategory
fields = ['name', 'sku', 'slug', 'parent', 'is_active']
labels = {
'name': 'Название',
'sku': 'Артикул',
'slug': 'URL-идентификатор',
'parent': 'Родительская категория',
'is_active': 'Активна'
}
help_texts = {
'sku': 'Оставьте пустым для автоматической генерации (CAT-XXXX)',
'slug': 'Оставьте пустым для автоматической генерации из названия',
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Make fields more user-friendly
self.fields['name'].widget.attrs.update({
'class': 'form-control form-control-lg fw-semibold',
'placeholder': 'Введите название категории'
})
self.fields['sku'].widget.attrs.update({
'class': 'form-control',
'placeholder': 'CAT-XXXX (автоматически)'
})
self.fields['slug'].widget.attrs.update({
'class': 'form-control',
'placeholder': 'url-identifier (автоматически)'
})
self.fields['slug'].required = False # Делаем поле необязательным
self.fields['is_active'].widget.attrs.update({'class': 'form-check-input'})
# Исключаем текущую категорию и её потомков из списка родительских
# (чтобы не создать циклическую зависимость)
if self.instance and self.instance.pk:
# Получаем все потомки текущей категории
descendants = self._get_descendants(self.instance)
# Исключаем текущую категорию и все её потомки
exclude_ids = [self.instance.pk] + [cat.pk for cat in descendants]
self.fields['parent'].queryset = ProductCategory.objects.filter(
is_active=True
).exclude(pk__in=exclude_ids)
def clean_name(self):
"""Валидация уникальности имени для активных категорий"""
name = self.cleaned_data.get('name')
if name:
# Проверяем уникальность имени среди активных категорий
existing = ProductCategory.objects.filter(
name=name,
is_deleted=False
)
if self.instance.pk:
existing = existing.exclude(pk=self.instance.pk)
if existing.exists():
raise forms.ValidationError(
f'Категория с названием "{name}" уже существует. '
f'Пожалуйста, используйте другое название.'
)
return name
def clean_slug(self):
"""Преобразуем пустую строку в None для автогенерации slug"""
slug = self.cleaned_data.get('slug')
if slug == '' or slug is None:
return None
return slug
def _get_descendants(self, category):
"""Рекурсивно получает всех потомков категории"""
descendants = []
children = category.children.all()
for child in children:
descendants.append(child)
descendants.extend(self._get_descendants(child))
return descendants
# ==================== VARIANT GROUP FORMS ====================
class ProductVariantGroupForm(forms.ModelForm):
"""
Форма для создания и редактирования группы вариантов товара.
"""
class Meta:
model = ProductVariantGroup
fields = ['name', 'description']
labels = {
'name': 'Название группы',
'description': 'Описание (для какого букета или ситуации создана)'
}
widgets = {
'name': forms.TextInput(attrs={
'class': 'form-control form-control-lg fw-semibold',
'placeholder': 'Например: Роза красная Freedom'
}),
'description': forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': 'Описание группы и её использования'
}),
}
class ProductVariantGroupItemForm(forms.ModelForm):
"""
Форма для добавления товара в группу с приоритетом.
"""
class Meta:
model = ProductVariantGroupItem
fields = ['product', 'priority']
labels = {
'product': 'Товар',
'priority': 'Приоритет'
}
widgets = {
'product': forms.Select(attrs={'class': 'form-control form-control-sm'}),
'priority': forms.NumberInput(attrs={
'class': 'form-control form-control-sm',
'min': '1',
'placeholder': '1, 2, 3...'
}),
}
# Базовый формсет с валидацией на дубликаты
class BaseProductVariantGroupItemFormSet(forms.BaseInlineFormSet):
def clean(self):
"""Проверка на дубликаты товаров в группе"""
if any(self.errors):
return
products = []
for form in self.forms:
if self.can_delete and self._should_delete_form(form):
continue
product = form.cleaned_data.get('product')
# Проверка дубликатов товаров
if product:
if product in products:
raise forms.ValidationError(
f'Товар "{product.name}" уже добавлен в группу. '
f'Каждый товар может быть добавлен только один раз.'
)
products.append(product)
# Формсет для создания групп (с пустой формой для добавления товара)
ProductVariantGroupItemFormSetCreate = inlineformset_factory(
ProductVariantGroup,
ProductVariantGroupItem,
form=ProductVariantGroupItemForm,
formset=BaseProductVariantGroupItemFormSet,
fields=['product', 'priority'],
extra=1, # Показать 1 пустую форму для первого товара
can_delete=True, # Разрешить удаление товаров
min_num=0, # Минимум 0 товаров (можно создать пустую группу)
validate_min=False, # Не требовать минимум товаров
can_delete_extra=True, # Разрешить удалять дополнительные формы
)
# Формсет для редактирования групп (без пустых форм, только существующие товары)
ProductVariantGroupItemFormSetUpdate = inlineformset_factory(
ProductVariantGroup,
ProductVariantGroupItem,
form=ProductVariantGroupItemForm,
formset=BaseProductVariantGroupItemFormSet,
fields=['product', 'priority'],
extra=0, # НЕ показывать пустые формы при редактировании
can_delete=True, # Разрешить удаление товаров
min_num=0, # Минимум 0 товаров
validate_min=False, # Не требовать минимум товаров
can_delete_extra=True, # Разрешить удалять дополнительные формы
)
class ProductTagForm(forms.ModelForm):
"""
Форма для создания и редактирования тегов товаров.
"""
class Meta:
model = ProductTag
fields = ['name', 'slug', 'is_active']
labels = {
'name': 'Название',
'slug': 'URL-идентификатор',
'is_active': 'Активен'
}
help_texts = {
'slug': 'Оставьте пустым для автоматической генерации из названия',
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['name'].widget.attrs.update({
'class': 'form-control form-control-lg fw-semibold',
'placeholder': 'Введите название тега'
})
self.fields['slug'].widget.attrs.update({
'class': 'form-control',
'placeholder': 'url-identifier (автоматически)'
})
self.fields['slug'].required = False
self.fields['is_active'].widget.attrs.update({'class': 'form-check-input'})
def clean(self):
"""Валидация уникальности имени для активных тегов"""
cleaned_data = super().clean()
name = cleaned_data.get('name')
is_active = cleaned_data.get('is_active', True)
if name and is_active:
# Проверяем уникальность имени среди активных тегов
existing = ProductTag.objects.filter(
name=name,
is_active=True
)
if self.instance.pk:
existing = existing.exclude(pk=self.instance.pk)
if existing.exists():
self.add_error('name',
f'Тег с названием "{name}" уже существует. '
f'Пожалуйста, используйте другое название.'
)
return cleaned_data
def clean_slug(self):
"""Разрешаем пустой slug - он сгенерируется в модели"""
slug = self.cleaned_data.get('slug')
if slug == '' or slug is None:
return None
return slug
# ==================== CONFIGURABLE KIT FORMS ====================
class ConfigurableProductForm(SKUUniqueMixin, forms.ModelForm):
"""
Форма для создания и редактирования вариативного товара.
"""
primary_category = forms.ModelChoiceField(
queryset=ProductCategory.objects.filter(is_active=True),
required=False,
empty_label="Не выбрана",
label="Основная категория",
help_text="Используется для интеграций с внешними площадками",
widget=forms.Select(attrs={'class': 'form-select'})
)
class Meta:
model = ConfigurableProduct
fields = ['name', 'sku', 'description', 'short_description', 'primary_category', 'status']
labels = {
'name': 'Название',
'sku': 'Артикул',
'description': 'Полное описание',
'short_description': 'Краткое описание',
'status': 'Статус'
}
help_texts = {
'sku': 'Оставьте пустым для автогенерации в формате VAR-XXXXXX',
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['name'].widget.attrs.update({
'class': 'form-control',
'placeholder': 'Введите название вариативного товара'
})
self.fields['sku'].widget.attrs.update({
'class': 'form-control',
'placeholder': 'VAR-XXXXXX (автогенерация)'
})
self.fields['sku'].required = False
self.fields['description'].widget.attrs.update({
'class': 'form-control',
'rows': 5
})
self.fields['short_description'].widget.attrs.update({
'class': 'form-control',
'rows': 3,
'placeholder': 'Краткое описание для экспорта'
})
self.fields['status'].widget.attrs.update({'class': 'form-select'})
class ConfigurableProductOptionForm(forms.ModelForm):
"""
Форма для добавления варианта (комплекта) к вариативному товару.
Атрибуты варианта выбираются динамически на основе parent_attributes.
"""
kit = forms.ModelChoiceField(
queryset=ProductKit.objects.filter(status='active', is_temporary=False).order_by('name'),
required=True,
label="Комплект",
widget=forms.Select(attrs={'class': 'form-select'})
)
class Meta:
model = ConfigurableProductOption
# Убрали 'attributes' - он будет заполняться через ConfigurableProductOptionAttribute
fields = ['kit', 'is_default']
labels = {
'kit': 'Комплект',
'is_default': 'По умолчанию'
}
widgets = {
'is_default': forms.CheckboxInput(attrs={
'class': 'form-check-input is-default-switch',
'role': 'switch'
})
}
def __init__(self, *args, **kwargs):
"""
Динамически генерируем поля для выбора атрибутов на основе parent_attributes.
"""
super().__init__(*args, **kwargs)
# Получаем instance (ConfigurableProductOption)
if self.instance and self.instance.parent_id:
parent = self.instance.parent
# Получаем все уникальные названия атрибутов родителя
attributes = parent.parent_attributes.all().order_by('position', 'name').distinct('name')
# Создаем поле для каждого названия атрибута
for attr in attributes:
attr_name = attr.name
field_name = f'attribute_{attr_name}'
# Получаем все возможные значения для этого атрибута
options = parent.parent_attributes.filter(name=attr_name).values_list('id', 'option')
# Создаем ChoiceField для выбора значения
self.fields[field_name] = forms.ModelChoiceField(
queryset=parent.parent_attributes.filter(name=attr_name),
required=True,
label=attr_name,
widget=forms.Select(attrs={'class': 'form-select', 'data-attribute-name': attr_name}),
to_field_name='id'
)
# Если редактируем существующий вариант - устанавливаем текущее значение
if self.instance.pk:
current_attr = self.instance.attributes_set.filter(attribute__name=attr_name).first()
if current_attr:
self.fields[field_name].initial = current_attr.attribute
class BaseConfigurableProductOptionFormSet(forms.BaseInlineFormSet):
def clean(self):
"""Проверка на дубликаты комплектов и что все атрибуты заполнены"""
if any(self.errors):
return
kits = []
default_count = 0
for idx, form in enumerate(self.forms):
if self.can_delete and self._should_delete_form(form):
continue
# Пропускаем пустые формы (extra формы при создании)
if not form.cleaned_data or not form.cleaned_data.get('kit'):
continue
kit = form.cleaned_data.get('kit')
is_default = form.cleaned_data.get('is_default')
# Проверка дубликатов комплектов
if kit:
if kit in kits:
raise forms.ValidationError(
f'Комплект "{kit.name}" добавлен более одного раза. '
f'Каждый комплект может быть добавлен только один раз.'
)
kits.append(kit)
# Считаем количество "is_default"
if is_default:
default_count += 1
# Проверяем что все атрибуты заполнены для варианта с kit
parent = self.instance
if parent and parent.pk:
# Получаем все уникальные названия атрибутов родителя
attribute_names = list(
parent.parent_attributes
.all()
.order_by('position', 'name')
.distinct('name')
.values_list('name', flat=True)
)
# Если у товара есть параметры, вариант ОБЯЗАН иметь значения для них
if attribute_names:
# Проверяем что каждый атрибут выбран
missing_attributes = []
for attr_name in attribute_names:
field_name = f'attribute_{attr_name}'
if field_name not in form.cleaned_data or not form.cleaned_data[field_name]:
missing_attributes.append(attr_name)
if missing_attributes:
attrs_str = ', '.join(f'"{attr}"' for attr in missing_attributes)
raise forms.ValidationError(
f'Вариант {idx + 1}: необходимо заполнить атрибут(ы) {attrs_str}.'
)
else:
# Если у товара нет параметров, вариант без привязки к параметрам бессмысленен
raise forms.ValidationError(
f'Вариант {idx + 1}: сначала добавьте параметры товара в разделе "Параметры товара".'
)
# Проверяем, что не более одного "is_default"
if default_count > 1:
raise forms.ValidationError(
'Можно установить только один вариант как "по умолчанию".'
)
# Формсет для создания вариативного товара
ConfigurableProductOptionFormSetCreate = inlineformset_factory(
ConfigurableProduct,
ConfigurableProductOption,
form=ConfigurableProductOptionForm,
formset=BaseConfigurableProductOptionFormSet,
fields=['kit', 'is_default'], # Убрали 'attributes' - заполняется через ConfigurableProductOptionAttribute
extra=0, # Не требуем пустые формы (варианты скрыты в UI)
can_delete=True,
min_num=0,
validate_min=False,
can_delete_extra=True,
)
# Формсет для редактирования вариативного товара
ConfigurableProductOptionFormSetUpdate = inlineformset_factory(
ConfigurableProduct,
ConfigurableProductOption,
form=ConfigurableProductOptionForm,
formset=BaseConfigurableProductOptionFormSet,
fields=['kit', 'is_default'], # Убрали 'attributes' - заполняется через ConfigurableProductOptionAttribute
extra=0, # НЕ показывать пустые формы
can_delete=True,
min_num=0,
validate_min=False,
can_delete_extra=True,
)
# === Формы для атрибутов родительского вариативного товара ===
class ConfigurableProductAttributeForm(forms.ModelForm):
"""
Форма для добавления атрибута родительского товара в карточном интерфейсе.
На фронтенде: одна карточка параметра (имя + позиция + видимость)
+ множество инлайн значений через JavaScript
Пример структуры:
- name: "Длина"
- position: 0
- visible: True
- values: [50, 60, 70] (будут созданы как отдельные ConfigurableProductAttribute)
"""
class Meta:
model = ConfigurableProductAttribute
fields = ['name', 'position', 'visible']
labels = {
'name': 'Название параметра',
'position': 'Порядок',
'visible': 'Видимый на витрине'
}
widgets = {
'name': forms.TextInput(attrs={
'class': 'form-control param-name-input',
'placeholder': 'Например: Длина, Цвет, Размер'
}),
'position': forms.NumberInput(attrs={
'class': 'form-control param-position-input',
'min': '0',
'value': '0'
}),
'visible': forms.CheckboxInput(attrs={
'class': 'form-check-input param-visible-input'
})
}
class BaseConfigurableProductAttributeFormSet(forms.BaseInlineFormSet):
def clean(self):
"""Проверка на дубликаты параметров и что у каждого параметра есть значения"""
if any(self.errors):
return
parameter_names = []
for form in self.forms:
if self.can_delete and self._should_delete_form(form):
continue
# Пропускаем пустые формы
if not form.cleaned_data.get('name'):
continue
name = form.cleaned_data.get('name').strip()
# Проверка дубликатов параметров (в карточном интерфейсе каждый параметр должен быть один раз)
if name in parameter_names:
raise forms.ValidationError(
f'Параметр "{name}" добавлен более одного раза. '
f'Каждый параметр должен быть добавлен только один раз.'
)
parameter_names.append(name)
# Формсет для создания атрибутов родительского товара (карточный интерфейс)
ConfigurableProductAttributeFormSetCreate = inlineformset_factory(
ConfigurableProduct,
ConfigurableProductAttribute,
form=ConfigurableProductAttributeForm,
formset=BaseConfigurableProductAttributeFormSet,
# Убрали 'option' - значения будут добавляться через JavaScript в карточку
fields=['name', 'position', 'visible'],
extra=0, # Пользователь добавляет параметры через кнопку "Добавить параметр"
can_delete=True,
min_num=0,
validate_min=False,
can_delete_extra=True,
)
# Формсет для редактирования атрибутов родительского товара
ConfigurableProductAttributeFormSetUpdate = inlineformset_factory(
ConfigurableProduct,
ConfigurableProductAttribute,
form=ConfigurableProductAttributeForm,
formset=BaseConfigurableProductAttributeFormSet,
# Убрали 'option' - значения будут добавляться через JavaScript в карточку
fields=['name', 'position', 'visible'],
extra=0,
can_delete=True,
min_num=0,
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,
)
class ProductSalesUnitForm(forms.ModelForm):
"""
Форма для создания и редактирования единицы продажи товара
"""
class Meta:
model = ProductSalesUnit
fields = [
'product', 'unit', 'name', 'conversion_factor',
'price', 'sale_price', 'min_quantity', 'quantity_step',
'is_default', 'is_active', 'position'
]
labels = {
'product': 'Товар',
'unit': 'Единица измерения',
'name': 'Название',
'conversion_factor': 'Коэффициент конверсии',
'price': 'Цена продажи',
'sale_price': 'Цена со скидкой',
'min_quantity': 'Минимальное количество',
'quantity_step': 'Шаг количества',
'is_default': 'По умолчанию',
'is_active': 'Активна',
'position': 'Порядок сортировки'
}
widgets = {
'product': forms.Select(attrs={'class': 'form-control'}),
'unit': forms.Select(attrs={'class': 'form-control'}),
'name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Например: Ветка большая, Стебель средний'
}),
'conversion_factor': forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.000001',
'min': '0.000001',
'placeholder': '15.0'
}),
'price': forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.01',
'min': '0',
'placeholder': '0.00'
}),
'sale_price': forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.01',
'min': '0',
'placeholder': 'Оставьте пустым если нет скидки'
}),
'min_quantity': forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.001',
'min': '0.001',
'value': '1'
}),
'quantity_step': forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.001',
'min': '0.001',
'value': '1'
}),
'is_default': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'position': forms.NumberInput(attrs={'class': 'form-control', 'value': '0'}),
}
help_texts = {
'conversion_factor': 'Сколько единиц продажи получается из 1 базовой единицы товара. Например: 15 (из 1 банча получается 15 больших веток)',
'price': 'Цена за одну единицу продажи',
'sale_price': 'Опционально: цена со скидкой (должна быть меньше основной)',
'min_quantity': 'Минимальное количество для заказа',
'quantity_step': 'С каким шагом можно заказывать (например: 0.5, 1)',
'is_default': 'Эта единица будет выбрана по умолчанию при добавлении товара в заказ',
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Фильтруем только активные единицы измерения
self.fields['unit'].queryset = UnitOfMeasure.objects.filter(
is_active=True
).order_by('position', 'code')
# Фильтруем только активные товары
self.fields['product'].queryset = Product.objects.filter(
status='active'
).order_by('name')
# Сделать sale_price необязательным
self.fields['sale_price'].required = False