feat: Implement safe soft delete and hard delete actions in admin

- Override Django's delete_selected action to enforce soft deletion
  (calls .delete() on each object instead of queryset.delete())
- Add hard_delete_selected action for safe permanent deletion
  - Checks for dangerous relations (KitItem, etc.) before deleting
  - Only allows deletion if no critical dependencies exist
  - Safely deletes photos from media/ folder by explicitly calling
    ProductPhoto.delete() which triggers ImageProcessor cleanup
- Add delete() and hard_delete() method overrides to ProductTag model
  (Product, ProductKit, ProductCategory already had these)
- Integrate all three actions into admin classes:
  ProductCategoryAdmin, ProductTagAdmin, ProductAdmin, ProductKitAdmin
- Add get_queryset() and get_deleted_status() methods to admin classes
  for proper soft delete support

Now when admin clicks "Delete":
1. Regular "Удалить" = soft delete (is_deleted=True, stays in DB)
2. "Безопасно удалить" = hard delete (only if no dependencies, removes from DB)
3. "Восстановить" = restores soft-deleted items

Fixes issue where items were hard-deleted from admin instead of soft-deleted.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-22 22:31:57 +03:00
parent 4ac548bad9
commit a9b16bf212
2 changed files with 453 additions and 22 deletions

View File

@@ -1,11 +1,159 @@
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']
@@ -19,11 +167,29 @@ class ProductVariantGroupAdmin(admin.ModelAdmin):
class ProductCategoryAdmin(admin.ModelAdmin):
list_display = ('photo_preview', 'name', 'sku', 'slug', 'parent', 'is_active')
list_filter = ('is_active', 'parent')
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',)
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):
"""Превью фото в списке категорий"""
@@ -49,18 +215,39 @@ class ProductCategoryAdmin(admin.ModelAdmin):
class ProductTagAdmin(admin.ModelAdmin):
list_display = ('name', 'slug')
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', 'sale_price', 'get_variant_groups_display', 'is_active')
list_filter = ('is_active', 'categories', 'tags', 'variant_groups')
list_display = ('photo_preview', 'name', 'sku', 'get_categories_display', 'cost_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',)
readonly_fields = ('photo_preview_large', 'deleted_at', 'deleted_by')
autocomplete_fields = []
actions = [restore_items, delete_selected, hard_delete_selected]
fieldsets = (
('Основная информация', {
@@ -72,6 +259,11 @@ class ProductAdmin(admin.ModelAdmin):
('Дополнительно', {
'fields': ('tags', 'variant_groups', 'is_active')
}),
('Удаление', {
'fields': ('deleted_at', 'deleted_by'),
'classes': ('collapse',),
'description': 'Информация о мягком удалении товара.'
}),
('Поиск', {
'fields': ('search_keywords',),
'classes': ('collapse',),
@@ -83,6 +275,23 @@ class ProductAdmin(admin.ModelAdmin):
}),
)
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:
@@ -127,11 +336,29 @@ class ProductAdmin(admin.ModelAdmin):
class ProductKitAdmin(admin.ModelAdmin):
list_display = ('photo_preview', 'name', 'slug', 'get_categories_display', 'pricing_method', 'is_active')
list_filter = ('is_active', 'pricing_method', 'categories', 'tags')
list_display = ('photo_preview', 'name', 'slug', 'get_categories_display', 'pricing_method', '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',)
readonly_fields = ('photo_preview_large', 'deleted_at', 'deleted_by')
actions = [restore_items, delete_selected, hard_delete_selected]
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]