Files
octopus/myproject/products/forms.py
Andrey Smakotin 2fb6253d06 fix: Исправить сохранение множественных товаров в комплект
ПРОБЛЕМА:
При создании комплекта с несколькими товарами сохранялся только первый товар.

ПРИЧИНЫ И РЕШЕНИЯ:

1. Неправильный префикс в JavaScript (productkit_create.html)
   - Динамически добавляемые формы создавались с префиксом kititem_set-
   - Django ожидает префикс kititem-
   - ИСПРАВЛЕНО: изменены все name атрибуты с kititem_set- на kititem-

2. NULL constraint для quantity (models.py)
   - Поле KitItem.quantity было NOT NULL
   - Пустые формы пытались сохраняться с NULL
   - ИСПРАВЛЕНО: добавлены null=True, blank=True к полю quantity

3. Неправильная валидация пустых форм (forms.py)
   - Не было логики для обработки пустых компонентов
   - ИСПРАВЛЕНО: пустые формы получают quantity=None, заполненные требуют quantity>0

4. Неправильный порядок сохранения (productkit_views.py)
   - Формсет не имел правильного prefixсе
   - ИСПРАВЛЕНО: явно установлен prefix='kititem' везде (get_context_data, form_valid)

 РЕЗУЛЬТАТ: Теперь можно создавать комплекты с неограниченным количеством товаров

🧪 ТЕСТИРОВАНО:
- Комплект 0 товаров ✓
- Комплект 1 товар ✓
- Комплект 3 товара ✓

🤖 Generated with Claude Code
2025-10-23 23:48:39 +03:00

311 lines
14 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
class ProductForm(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.all(),
widget=forms.CheckboxSelectMultiple,
required=False,
label="Теги"
)
class Meta:
model = Product
fields = [
'name', 'sku', 'description', 'categories',
'tags', 'unit', 'cost_price', 'sale_price', 'is_active'
]
labels = {
'name': 'Название',
'sku': 'Артикул',
'description': 'Описание',
'categories': 'Категории',
'tags': 'Теги',
'unit': 'Единица измерения',
'cost_price': 'Себестоимость',
'sale_price': 'Цена продажи',
'is_active': 'Активен'
}
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['cost_price'].widget.attrs.update({'class': 'form-control'})
self.fields['sale_price'].widget.attrs.update({'class': 'form-control'})
self.fields['unit'].widget.attrs.update({'class': 'form-control'})
self.fields['is_active'].widget.attrs.update({'class': 'form-check-input'})
class ProductKitForm(forms.ModelForm):
"""
Форма для создания и редактирования комплекта.
"""
categories = forms.ModelMultipleChoiceField(
queryset=ProductCategory.objects.filter(is_active=True),
widget=forms.CheckboxSelectMultiple,
required=False,
label="Категории"
)
tags = forms.ModelMultipleChoiceField(
queryset=ProductTag.objects.all(),
widget=forms.CheckboxSelectMultiple,
required=False,
label="Теги"
)
class Meta:
model = ProductKit
fields = [
'name', 'sku', 'description', 'categories',
'tags', 'pricing_method', 'fixed_price', 'markup_percent', 'markup_amount', 'is_active'
]
labels = {
'name': 'Название',
'sku': 'Артикул',
'description': 'Описание',
'categories': 'Категории',
'tags': 'Теги',
'pricing_method': 'Метод ценообразования',
'fixed_price': 'Фиксированная цена',
'markup_percent': 'Процент наценки',
'markup_amount': 'Фиксированная наценка',
'is_active': 'Активен'
}
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['pricing_method'].widget.attrs.update({'class': 'form-control'})
self.fields['fixed_price'].widget.attrs.update({'class': 'form-control'})
self.fields['markup_percent'].widget.attrs.update({'class': 'form-control'})
self.fields['markup_amount'].widget.attrs.update({'class': 'form-control'})
self.fields['is_active'].widget.attrs.update({'class': 'form-check-input'})
class KitItemForm(forms.ModelForm):
"""
Форма для одного компонента комплекта.
Валидирует, что указан либо product, либо variant_group (но не оба).
Если обе поля пусты - это пустая форма, которая будет удалена.
"""
class Meta:
model = KitItem
fields = ['product', 'variant_group', 'quantity', 'notes']
labels = {
'product': 'Конкретный товар',
'variant_group': 'Группа вариантов',
'quantity': 'Количество',
'notes': 'Примечание'
}
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'}),
'notes': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Опциональное примечание'}),
}
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=['id', 'product', 'variant_group', 'quantity', 'notes'],
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=['id', 'product', 'variant_group', 'quantity', 'notes'],
extra=0, # НЕ показывать пустые формы при редактировании
can_delete=True, # Разрешить удаление компонентов
min_num=0, # Минимум 0 компонентов
validate_min=False, # Не требовать минимум компонентов
can_delete_extra=True, # Разрешить удалять дополнительные формы
)
# Для обратной совместимости (если где-то еще используется KitItemFormSet)
KitItemFormSet = KitItemFormSetCreate
class ProductCategoryForm(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_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