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="Категории" ) 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', '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="Категории" ) 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', '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): """ Форма для создания и редактирования вариативного товара. """ class Meta: model = ConfigurableProduct fields = ['name', 'sku', 'description', 'short_description', '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