Добавлена поддержка выбора основной категории (primary_category) для товаров и наборов, а также новая модель IntegrationCategoryMapping для связи категорий с внешними площадками. Теперь можно указать категорию товара, которая будет использоваться при экспорте на внешние площадки (Recommerce, WooCommerce и др.), с возможностью настройки маппинга категорий для каждого типа интеграции.
1170 lines
51 KiB
Python
1170 lines
51 KiB
Python
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
|