Удалено ненужное поле 'notes' из формы создания/редактирования комплектов: - Удалено из модели KitItem - Удалено из формы KitItemForm - Удалено из template kititem_formset.html - Удалено из formset'ов KitItemFormSetCreate и KitItemFormSetUpdate - Создана миграция БД для удаления поля из базы данных Теперь каждый товар в комплекте отображается с 4 полями: - Товар (или Группа вариантов) - Количество - Кнопка удаления - ID (скрытое) 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
454 lines
20 KiB
Python
454 lines
20 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
|
||
|
||
|
||
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', 'short_description', 'categories',
|
||
'tags', 'unit', 'cost_price', 'price', 'sale_price', 'is_active'
|
||
]
|
||
labels = {
|
||
'name': 'Название',
|
||
'sku': 'Артикул',
|
||
'description': 'Описание',
|
||
'short_description': 'Краткое описание',
|
||
'categories': 'Категории',
|
||
'tags': 'Теги',
|
||
'unit': 'Единица измерения',
|
||
'cost_price': 'Себестоимость',
|
||
'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['short_description'].widget.attrs.update({
|
||
'class': 'form-control',
|
||
'rows': 2,
|
||
'placeholder': 'Краткое описание для превью и площадок'
|
||
})
|
||
self.fields['cost_price'].widget.attrs.update({'class': 'form-control'})
|
||
self.fields['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', 'short_description', 'categories',
|
||
'tags', 'sale_price', 'price_adjustment_type', 'price_adjustment_value', 'is_active'
|
||
]
|
||
labels = {
|
||
'name': 'Название',
|
||
'sku': 'Артикул',
|
||
'description': 'Описание',
|
||
'short_description': 'Краткое описание',
|
||
'categories': 'Категории',
|
||
'tags': 'Теги',
|
||
'sale_price': 'Цена со скидкой',
|
||
'price_adjustment_type': 'Как изменить итоговую цену',
|
||
'price_adjustment_value': 'Значение корректировки',
|
||
'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['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['is_active'].widget.attrs.update({'class': 'form-check-input'})
|
||
|
||
def clean(self):
|
||
"""
|
||
Валидация формы комплекта.
|
||
Проверяет:
|
||
1. Что если выбран тип корректировки, указано значение
|
||
2. Что заполнено максимум одно поле корректировки (увеличение или уменьшение)
|
||
"""
|
||
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=['id', '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=['id', '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(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
|
||
|
||
|
||
# ==================== 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, # Разрешить удалять дополнительные формы
|
||
)
|