Files
octopus/myproject/products/forms.py
Andrey Smakotin 1a0360f8c0 Реализован полный CRUD для тегов товаров
Упрощена модель ProductTag:
- Удалены поля soft delete (is_deleted, deleted_at, deleted_by)
- Добавлено поле is_active для управления статусом
- Упрощены менеджеры и методы модели

Создан CRUD функционал:
- ProductTagForm: форма с автогенерацией slug
- Views: список, создание, просмотр, редактирование, удаление
- URL маршруты: /products/tags/*
- Шаблоны: list, form, detail, confirm_delete

Особенности:
- Поиск по названию и slug
- Фильтрация по статусу активности
- Статистика использования тегов в товарах/комплектах
- Пагинация (20 на страницу)
- Предупреждение при удалении с отображением связанных объектов
- Добавлена ссылка "Теги" в навигацию

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 23:14:01 +03:00

491 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from django import forms
from django.forms import inlineformset_factory
from .models import Product, ProductKit, ProductCategory, ProductTag, ProductPhoto, KitItem, ProductKitPhoto, ProductCategoryPhoto, ProductVariantGroup, ProductVariantGroupItem
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=['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(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, # Разрешить удалять дополнительные формы
)
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_slug(self):
"""Разрешаем пустой slug - он сгенерируется в модели"""
slug = self.cleaned_data.get('slug')
if slug == '' or slug is None:
return None
return slug