refactor: Создать базовый класс BaseProductEntity и реструктурировать Product/ProductKit
Основные изменения: ## Модели (models.py) - Создан абстрактный класс BaseProductEntity с общими полями: * Идентификация: name, sku, slug * Описания: description, short_description (новое поле) * Статус: is_active, timestamps, soft delete * Managers: objects, all_objects, active - Product: * Унаследован от BaseProductEntity * sale_price переименован в price (основная цена) * Добавлено новое поле sale_price (цена со скидкой, nullable) * Добавлено property actual_price - ProductKit: * Унаследован от BaseProductEntity * fixed_price переименован в price (ручная цена) * pricing_method: 'fixed' → 'manual' * Добавлено поле sale_price (цена со скидкой) * Добавлено поле cost_price (nullable) * Добавлены properties: calculated_price, actual_price * Обновлен calculate_price_with_substitutions() ## Forms (forms.py) - ProductForm: добавлено short_description, price, sale_price - ProductKitForm: добавлено short_description, cost_price, price, sale_price ## Admin (admin.py) - ProductAdmin: обновлены list_display и fieldsets с новыми полями - ProductKitAdmin: добавлены fieldsets, метод get_price_display() ## Templates - product_form.html: добавлены поля price, sale_price, short_description - product_detail.html: показывает зачеркнутую цену + скидку + бейджик "Акция" - product_list.html: отображение цен со скидкой и бейджиком "Акция" - all_products_list.html: единообразное отображение цен - productkit_detail.html: отображение скидок с бейджиком "Акция" ## API (api_views.py) - Обновлены все endpoints для использования поля price вместо sale_price Результат: единообразная архитектура с поддержкой скидок, DRY-принцип, логичные названия полей, красивое отображение акций в UI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
from django import forms
|
||||
from django.forms import inlineformset_factory
|
||||
from .models import Product, ProductKit, ProductCategory, ProductTag, ProductPhoto, KitItem, ProductKitPhoto, ProductCategoryPhoto
|
||||
from .models import Product, ProductKit, ProductCategory, ProductTag, ProductPhoto, KitItem, ProductKitPhoto, ProductCategoryPhoto, ProductVariantGroup, ProductVariantGroupItem
|
||||
|
||||
|
||||
class ProductForm(forms.ModelForm):
|
||||
@@ -25,18 +25,20 @@ class ProductForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Product
|
||||
fields = [
|
||||
'name', 'sku', 'description', 'categories',
|
||||
'tags', 'unit', 'cost_price', 'sale_price', 'is_active'
|
||||
'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': 'Себестоимость',
|
||||
'sale_price': 'Цена продажи',
|
||||
'price': 'Основная цена',
|
||||
'sale_price': 'Цена со скидкой',
|
||||
'is_active': 'Активен'
|
||||
}
|
||||
|
||||
@@ -55,7 +57,13 @@ class ProductForm(forms.ModelForm):
|
||||
'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'})
|
||||
@@ -82,17 +90,21 @@ class ProductKitForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = ProductKit
|
||||
fields = [
|
||||
'name', 'sku', 'description', 'categories',
|
||||
'tags', 'pricing_method', 'fixed_price', 'markup_percent', 'markup_amount', 'is_active'
|
||||
'name', 'sku', 'description', 'short_description', 'categories',
|
||||
'tags', 'pricing_method', 'cost_price', 'price', 'sale_price',
|
||||
'markup_percent', 'markup_amount', 'is_active'
|
||||
]
|
||||
labels = {
|
||||
'name': 'Название',
|
||||
'sku': 'Артикул',
|
||||
'description': 'Описание',
|
||||
'short_description': 'Краткое описание',
|
||||
'categories': 'Категории',
|
||||
'tags': 'Теги',
|
||||
'pricing_method': 'Метод ценообразования',
|
||||
'fixed_price': 'Фиксированная цена',
|
||||
'cost_price': 'Себестоимость',
|
||||
'price': 'Ручная цена',
|
||||
'sale_price': 'Цена со скидкой',
|
||||
'markup_percent': 'Процент наценки',
|
||||
'markup_amount': 'Фиксированная наценка',
|
||||
'is_active': 'Активен'
|
||||
@@ -113,8 +125,15 @@ class ProductKitForm(forms.ModelForm):
|
||||
'class': 'form-control',
|
||||
'rows': 3
|
||||
})
|
||||
self.fields['short_description'].widget.attrs.update({
|
||||
'class': 'form-control',
|
||||
'rows': 2,
|
||||
'placeholder': 'Краткое описание для превью и площадок'
|
||||
})
|
||||
self.fields['pricing_method'].widget.attrs.update({'class': 'form-control'})
|
||||
self.fields['fixed_price'].widget.attrs.update({'class': 'form-control'})
|
||||
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['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'})
|
||||
@@ -308,3 +327,104 @@ class ProductCategoryForm(forms.ModelForm):
|
||||
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, # Разрешить удалять дополнительные формы
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user