Files
octopus/myproject/products/admin.py
Andrey Smakotin d92045c4c4 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>
2025-10-31 00:49:01 +03:00

569 lines
25 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.contrib import admin
from django.utils.html import format_html
from django.utils import timezone
import nested_admin
from .models import ProductCategory, ProductTag, Product, ProductKit, KitItem
from .models import ProductPhoto, ProductKitPhoto, ProductCategoryPhoto
from .models import ProductVariantGroup, KitItemPriority, SKUCounter
class DeletedFilter(admin.SimpleListFilter):
"""Фильтр для отображения удаленных/активных элементов"""
title = 'Статус удаления'
parameter_name = 'is_deleted'
def lookups(self, request, model_admin):
return (
('0', 'Активные'),
('1', 'Удаленные'),
)
def queryset(self, request, queryset):
# queryset уже содержит всё (включая удаленные) благодаря get_queryset()
if self.value() == '0':
return queryset.filter(is_deleted=False)
elif self.value() == '1':
return queryset.filter(is_deleted=True)
return queryset
def restore_items(modeladmin, request, queryset):
"""Action для восстановления удаленных элементов"""
updated = queryset.update(is_deleted=False, deleted_at=None, deleted_by=None)
modeladmin.message_user(request, f'✓ Восстановлено {updated} элемент(ов).')
restore_items.short_description = '✓ Восстановить выбранные элементы'
def delete_selected(modeladmin, request, queryset):
"""
Переопределенный action удаления который вызывает .delete() на каждый объект
чтобы гарантировать мягкое удаление вместо hard delete
"""
count = 0
for obj in queryset:
obj.delete() # Вызывает delete() метод модели (мягкое удаление)
count += 1
modeladmin.message_user(request, f'✓ Удалено {count} элемент(ов).')
delete_selected.short_description = '🗑️ Удалить выбранные элементы'
def hard_delete_selected(modeladmin, request, queryset):
"""
Action для БЕЗОПАСНОГО полного удаления из БД.
Удаляет объект только если у него нет опасных связей.
БЕЗОПАСНЫЕ связи (удаляются вместе с объектом):
- photos: фотографии объекта (создаются только для этого объекта)
ОПАСНЫЕ связи (блокируют удаление):
- kit_items: товар используется в составе комплекта
- kit_items_direct: товар включен как компонент комплекта
- ManyToMany: товар связан с другими объектами
"""
from django.contrib import messages
items_to_delete = []
items_with_relations = []
# Связи которые БЕЗОПАСНО удалять вместе с объектом (они зависят от объекта)
SAFE_RELATIONS = {'photos'}
for obj in queryset:
# Проверяем есть ли опасные связанные объекты
try:
has_dangerous_relations = False
dangerous_relation_details = []
for relation in obj._meta.get_fields():
# Пропускаем обычные поля
if not hasattr(relation, 'related_model'):
continue
# Для reverse relations получаем count
if hasattr(relation, 'get_accessor_name'):
try:
accessor_name = relation.get_accessor_name()
# Пропускаем безопасные связи (удалятся при cascade delete)
if accessor_name in SAFE_RELATIONS:
continue
related_manager = getattr(obj, accessor_name, None)
if related_manager and hasattr(related_manager, 'count'):
count = related_manager.count()
if count > 0:
has_dangerous_relations = True
relation_name = relation.related_model.__name__
dangerous_relation_details.append(f"{relation_name} ({count})")
except (AttributeError, Exception):
# Пропускаем ошибки доступа к связям
pass
if has_dangerous_relations:
details_str = ', '.join(dangerous_relation_details)
items_with_relations.append(f"{str(obj)} [{details_str}]")
else:
items_to_delete.append(obj)
except Exception as e:
# На случай любой неожиданной ошибки при проверке
items_with_relations.append(f"{str(obj)} (ошибка проверки: {str(e)})")
# Удаляем безопасные элементы
deleted_count = 0
for obj in items_to_delete:
try:
# ВАЖНО: Сначала явно удаляем все фотографии
# Это гарантирует что delete() метод каждого фото вызовется
# и удалит файлы из media/ папки
# Нужно для всех моделей которые имеют 'photos' связь
# Для Product/ProductKit/ProductCategory - есть .photos
if hasattr(obj, 'photos'):
photos = list(obj.photos.all()) # Получаем копию списка перед удалением
for photo in photos:
photo.delete() # Вызывает ProductPhoto.delete() который удалит файлы
# Потом удаляем сам объект
obj.hard_delete()
deleted_count += 1
except Exception as e:
items_with_relations.append(f"{str(obj)} (ошибка удаления: {str(e)})")
# Выводим результаты
if deleted_count > 0:
modeladmin.message_user(
request,
f'✓ Безопасно удалено {deleted_count} элемент(ов) (с фотографиями из media папки).',
messages.SUCCESS
)
if items_with_relations:
error_msg = f'⚠️ Не удалось удалить {len(items_with_relations)} элемент(ов) (имеют зависимые связи):\n' + '\n'.join(f'{item}' for item in items_with_relations)
modeladmin.message_user(request, error_msg, messages.WARNING)
hard_delete_selected.short_description = '🔴 Безопасно удалить навсегда (только без связей)'
def disable_delete_selected(admin_class):
"""
Декоратор для отключения стандартного Django delete_selected action.
Наш custom delete_selected будет использоваться вместо него.
"""
admin_class.actions = [action for action in admin_class.actions
if action.__name__ != 'delete_selected']
return admin_class
@admin.register(ProductVariantGroup)
class ProductVariantGroupAdmin(admin.ModelAdmin):
list_display = ['name', 'get_products_count', 'created_at']
search_fields = ['name', 'description']
list_filter = ['created_at']
readonly_fields = ['created_at', 'updated_at']
def get_products_count(self, obj):
return obj.products.count()
get_products_count.short_description = 'Товаров'
class ProductCategoryAdmin(admin.ModelAdmin):
list_display = ('photo_preview', 'name', 'sku', 'slug', 'parent', 'is_active', 'get_deleted_status')
list_filter = (DeletedFilter, 'is_active', 'parent')
prepopulated_fields = {'slug': ('name',)}
search_fields = ('name', 'sku')
readonly_fields = ('photo_preview_large', 'deleted_at', 'deleted_by')
actions = [restore_items, delete_selected, hard_delete_selected]
def get_queryset(self, request):
"""Переопределяем queryset для доступа ко всем категориям (включая удаленные)"""
qs = ProductCategory.all_objects.all()
ordering = self.get_ordering(request)
if ordering:
qs = qs.order_by(*ordering)
return qs
def get_deleted_status(self, obj):
"""Показывает статус удаления"""
if obj.is_deleted:
return format_html(
'<span style="color: red; font-weight: bold;">🗑️ Удалена</span>'
)
return format_html('<span style="color: green;">✓ Активна</span>')
get_deleted_status.short_description = 'Статус'
def photo_preview(self, obj):
"""Превью фото в списке категорий"""
first_photo = obj.photos.first()
if first_photo and first_photo.image:
return format_html(
'<img src="{}" style="width: 50px; height: 50px; object-fit: cover; border-radius: 4px;" />',
first_photo.image.url
)
return "Нет фото"
photo_preview.short_description = "Фото"
def photo_preview_large(self, obj):
"""Большое превью фото в форме редактирования"""
first_photo = obj.photos.first()
if first_photo and first_photo.image:
return format_html(
'<img src="{}" style="max-width: 300px; max-height: 300px; border-radius: 8px;" />',
first_photo.image.url
)
return "Нет фото"
photo_preview_large.short_description = "Превью основного фото"
class ProductTagAdmin(admin.ModelAdmin):
list_display = ('name', 'slug', 'get_deleted_status')
list_filter = (DeletedFilter,)
prepopulated_fields = {'slug': ('name',)}
search_fields = ('name',)
readonly_fields = ('deleted_at', 'deleted_by')
actions = [restore_items, delete_selected, hard_delete_selected]
def get_queryset(self, request):
"""Переопределяем queryset для доступа ко всем тегам (включая удаленные)"""
qs = ProductTag.all_objects.all()
ordering = self.get_ordering(request)
if ordering:
qs = qs.order_by(*ordering)
return qs
def get_deleted_status(self, obj):
"""Показывает статус удаления"""
if obj.is_deleted:
return format_html(
'<span style="color: red; font-weight: bold;">🗑️ Удален</span>'
)
return format_html('<span style="color: green;">✓ Активен</span>')
get_deleted_status.short_description = 'Статус'
class ProductAdmin(admin.ModelAdmin):
list_display = ('photo_preview', 'name', 'sku', 'get_categories_display', 'cost_price', 'price', 'sale_price', 'get_variant_groups_display', 'is_active', 'get_deleted_status')
list_filter = (DeletedFilter, 'is_active', 'categories', 'tags', 'variant_groups')
search_fields = ('name', 'sku', 'description', 'search_keywords')
filter_horizontal = ('categories', 'tags', 'variant_groups')
readonly_fields = ('photo_preview_large', 'deleted_at', 'deleted_by')
autocomplete_fields = []
actions = [restore_items, delete_selected, hard_delete_selected]
fieldsets = (
('Основная информация', {
'fields': ('name', 'sku', 'variant_suffix', 'description', 'short_description', 'categories', 'unit')
}),
('Цены', {
'fields': ('cost_price', 'price', 'sale_price'),
'description': 'price - основная цена, sale_price - цена со скидкой (опционально)'
}),
('Дополнительно', {
'fields': ('tags', 'variant_groups', 'is_active')
}),
('Удаление', {
'fields': ('deleted_at', 'deleted_by'),
'classes': ('collapse',),
'description': 'Информация о мягком удалении товара.'
}),
('Поиск', {
'fields': ('search_keywords',),
'classes': ('collapse',),
'description': 'Поле для улучшенного поиска. Автоматически генерируется при сохранении, но вы можете добавить дополнительные ключевые слова (синонимы, альтернативные названия и т.д.).'
}),
('Фото', {
'fields': ('photo_preview_large',),
'classes': ('collapse',),
}),
)
def get_queryset(self, request):
"""Переопределяем queryset для доступа ко всем товарам (включая удаленные)"""
qs = Product.all_objects.all()
ordering = self.get_ordering(request)
if ordering:
qs = qs.order_by(*ordering)
return qs
def get_deleted_status(self, obj):
"""Показывает статус удаления"""
if obj.is_deleted:
return format_html(
'<span style="color: red; font-weight: bold;">🗑️ Удален</span>'
)
return format_html('<span style="color: green;">✓ Активен</span>')
get_deleted_status.short_description = 'Статус'
def get_categories_display(self, obj):
categories = obj.categories.all()[:3]
if not categories:
return "-"
result = ", ".join([cat.name for cat in categories])
if obj.categories.count() > 3:
result += f" (+{obj.categories.count() - 3})"
return result
get_categories_display.short_description = 'Категории'
def get_variant_groups_display(self, obj):
groups = obj.variant_groups.all()[:3]
if not groups:
return "-"
result = ", ".join([g.name for g in groups])
if obj.variant_groups.count() > 3:
result += f" (+{obj.variant_groups.count() - 3})"
return result
get_variant_groups_display.short_description = 'Группы вариантов'
def photo_preview(self, obj):
"""Превью фото в списке товаров"""
first_photo = obj.photos.first()
if first_photo and first_photo.image:
return format_html(
'<img src="{}" style="width: 50px; height: 50px; object-fit: cover; border-radius: 4px;" />',
first_photo.image.url
)
return "Нет фото"
photo_preview.short_description = "Фото"
def photo_preview_large(self, obj):
"""Большое превью фото в форме редактирования"""
first_photo = obj.photos.first()
if first_photo and first_photo.image:
return format_html(
'<img src="{}" style="max-width: 300px; max-height: 300px; border-radius: 8px;" />',
first_photo.image.url
)
return "Нет фото"
photo_preview_large.short_description = "Превью основного фото"
class ProductKitAdmin(admin.ModelAdmin):
list_display = ('photo_preview', 'name', 'slug', 'get_categories_display', 'pricing_method', 'get_price_display', 'is_active', 'get_deleted_status')
list_filter = (DeletedFilter, 'is_active', 'pricing_method', 'categories', 'tags')
prepopulated_fields = {'slug': ('name',)}
filter_horizontal = ('categories', 'tags')
readonly_fields = ('photo_preview_large', 'deleted_at', 'deleted_by')
actions = [restore_items, delete_selected, hard_delete_selected]
fieldsets = (
('Основная информация', {
'fields': ('name', 'sku', 'slug', 'description', 'short_description', 'categories')
}),
('Ценообразование', {
'fields': ('pricing_method', 'cost_price', 'price', 'sale_price', 'markup_percent', 'markup_amount'),
'description': 'Метод ценообразования определяет как вычисляется цена комплекта. price используется при методе "Ручная цена".'
}),
('Дополнительно', {
'fields': ('tags', 'is_active')
}),
('Удаление', {
'fields': ('deleted_at', 'deleted_by'),
'classes': ('collapse',),
'description': 'Информация о мягком удалении комплекта.'
}),
('Фото', {
'fields': ('photo_preview_large',),
'classes': ('collapse',),
}),
)
def get_price_display(self, obj):
"""Отображение финальной цены комплекта"""
try:
return f"{obj.actual_price}"
except Exception:
return "-"
get_price_display.short_description = "Цена"
def get_queryset(self, request):
"""Переопределяем queryset для доступа ко всем комплектам (включая удаленные)"""
qs = ProductKit.all_objects.all()
ordering = self.get_ordering(request)
if ordering:
qs = qs.order_by(*ordering)
return qs
def get_deleted_status(self, obj):
"""Показывает статус удаления"""
if obj.is_deleted:
return format_html(
'<span style="color: red; font-weight: bold;">🗑️ Удален</span>'
)
return format_html('<span style="color: green;">✓ Активен</span>')
get_deleted_status.short_description = 'Статус'
def get_categories_display(self, obj):
categories = obj.categories.all()[:3]
if not categories:
return "-"
result = ", ".join([cat.name for cat in categories])
if obj.categories.count() > 3:
result += f" (+{obj.categories.count() - 3})"
return result
get_categories_display.short_description = 'Категории'
def photo_preview(self, obj):
"""Превью фото в списке комплектов"""
first_photo = obj.photos.first()
if first_photo and first_photo.image:
return format_html(
'<img src="{}" style="width: 50px; height: 50px; object-fit: cover; border-radius: 4px;" />',
first_photo.image.url
)
return "Нет фото"
photo_preview.short_description = "Фото"
def photo_preview_large(self, obj):
"""Большое превью фото в форме редактирования"""
first_photo = obj.photos.first()
if first_photo and first_photo.image:
return format_html(
'<img src="{}" style="max-width: 300px; max-height: 300px; border-radius: 8px;" />',
first_photo.image.url
)
return "Нет фото"
photo_preview_large.short_description = "Превью основного фото"
class KitItemPriorityInline(nested_admin.NestedTabularInline):
model = KitItemPriority
extra = 0 # Не показывать пустые формы
fields = ['product', 'priority']
autocomplete_fields = ['product']
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.select_related('product')
def formfield_for_foreignkey(self, db_field, request, **kwargs):
"""Показывать только товары из выбранной группы вариантов"""
if db_field.name == "product":
# Получаем kit_item из родительского объекта через request
# Это будет работать автоматически с nested_admin
pass
return super().formfield_for_foreignkey(db_field, request, **kwargs)
class KitItemInline(nested_admin.NestedStackedInline):
model = KitItem
extra = 0 # Не показывать пустые формы
fields = ['product', 'variant_group', 'quantity', 'notes']
autocomplete_fields = ['product']
inlines = [KitItemPriorityInline]
class Media:
css = {
'all': ('admin/css/custom_nested.css',)
}
class ProductPhotoInline(admin.TabularInline):
model = ProductPhoto
extra = 1
readonly_fields = ('image_preview',)
fields = ('image', 'image_preview', 'order')
def image_preview(self, obj):
"""Превью основного фото (большой размер 800×800)"""
if obj.image:
return format_html(
'<img src="{}" style="max-width: 250px; max-height: 250px; border-radius: 4px;" />',
obj.get_large_url()
)
return "Нет изображения"
image_preview.short_description = "Превью"
class ProductKitPhotoInline(nested_admin.NestedTabularInline):
model = ProductKitPhoto
extra = 0 # Не показывать пустые формы
readonly_fields = ('image_preview',)
fields = ('image', 'image_preview', 'order')
def image_preview(self, obj):
"""Превью основного фото (большой размер 800×800)"""
if obj.image:
return format_html(
'<img src="{}" style="max-width: 250px; max-height: 250px; border-radius: 4px;" />',
obj.get_large_url()
)
return "Нет изображения"
image_preview.short_description = "Превью"
class ProductCategoryPhotoInline(admin.TabularInline):
model = ProductCategoryPhoto
extra = 1
readonly_fields = ('image_preview',)
fields = ('image', 'image_preview', 'order')
def image_preview(self, obj):
"""Превью основного фото (большой размер 800×800)"""
if obj.image:
return format_html(
'<img src="{}" style="max-width: 250px; max-height: 250px; border-radius: 4px;" />',
obj.get_large_url()
)
return "Нет изображения"
image_preview.short_description = "Превью"
class ProductKitAdminWithItems(ProductKitAdmin):
inlines = [KitItemInline]
# Update admin classes to include photo inlines
class ProductAdminWithPhotos(ProductAdmin):
inlines = [ProductPhotoInline]
class ProductKitAdminWithItemsAndPhotos(nested_admin.NestedModelAdmin, ProductKitAdmin):
inlines = [KitItemInline, ProductKitPhotoInline]
class ProductCategoryAdminWithPhotos(ProductCategoryAdmin):
inlines = [ProductCategoryPhotoInline]
@admin.register(KitItem)
class KitItemAdmin(admin.ModelAdmin):
list_display = ['__str__', 'kit', 'get_type', 'quantity', 'has_priorities']
list_filter = ['kit']
list_select_related = ['kit', 'product', 'variant_group']
inlines = [KitItemPriorityInline]
fields = ['kit', 'product', 'variant_group', 'quantity', 'notes']
def get_type(self, obj):
if obj.variant_group:
return format_html('<span style="color: #0066cc;">Группа: {}</span>', obj.variant_group.name)
return f"Товар: {obj.product.name if obj.product else '-'}"
get_type.short_description = 'Тип'
def has_priorities(self, obj):
return obj.priorities.exists()
has_priorities.boolean = True
has_priorities.short_description = 'Приоритеты настроены'
@admin.register(SKUCounter)
class SKUCounterAdmin(admin.ModelAdmin):
list_display = ['counter_type', 'current_value', 'get_next_preview']
list_filter = ['counter_type']
readonly_fields = ['get_next_preview']
def get_next_preview(self, obj):
"""Показывает, каким будет следующий артикул"""
next_val = obj.current_value + 1
if obj.counter_type == 'product':
return format_html('<strong>PROD-{:06d}</strong>', next_val)
elif obj.counter_type == 'kit':
return format_html('<strong>KIT-{:06d}</strong>', next_val)
elif obj.counter_type == 'category':
return format_html('<strong>CAT-{:04d}</strong>', next_val)
return str(next_val)
get_next_preview.short_description = 'Следующий артикул'
def has_delete_permission(self, request, obj=None):
# Запрещаем удаление счетчиков
return False
admin.site.register(ProductCategory, ProductCategoryAdminWithPhotos)
admin.site.register(ProductTag, ProductTagAdmin)
admin.site.register(Product, ProductAdminWithPhotos)
admin.site.register(ProductKit, ProductKitAdminWithItemsAndPhotos)