from django.contrib import admin from django.utils.html import format_html from django.utils import timezone from django.db.models import Q import nested_admin from .models import ProductCategory, ProductTag, Product, ProductKit, KitItem from .models import ProductPhoto, ProductKitPhoto, ProductCategoryPhoto from .models import ProductVariantGroup, KitItemPriority, SKUCounter from .admin_displays import ( format_quality_badge, format_quality_display, format_photo_quality_column, format_photo_inline_quality, format_photo_preview_with_quality, ) 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 class QualityLevelFilter(admin.SimpleListFilter): """Фильтр для отображения товаров по качеству их фотографий""" title = 'Качество фото' parameter_name = 'photo_quality' def lookups(self, request, model_admin): return ( ('excellent', '🟢 Отлично'), ('good', '🟡 Хорошо'), ('acceptable', '🟠 Приемлемо'), ('poor', '🔴 Плохо'), ('very_poor', '🔴🔴 Очень плохо'), ('warning', '⚠️ Требует обновления'), ('no_warning', '✓ Готово к выгрузке'), ) def queryset(self, request, queryset): if self.value() == 'warning': # Товары, у которых есть фото с quality_warning=True return queryset.filter(photos__quality_warning=True).distinct() elif self.value() == 'no_warning': # Товары, у которых есть фото БЕЗ warning (excellent или good) return queryset.filter( photos__quality_level__in=['excellent', 'good'] ).distinct() elif self.value(): # По конкретному уровню качества return queryset.filter(photos__quality_level=self.value()).distinct() 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 show_poor_quality_photos(modeladmin, request, queryset): """ Action для фильтрации товаров с фото требующими обновления. Перенаправляет на список с применённым фильтром по качеству. """ from django.shortcuts import redirect from django.urls import reverse model_name = modeladmin.model._meta.model_name app_name = modeladmin.model._meta.app_label # Перенаправляем на список товаров с фильтром по warning фото url = reverse(f'admin:{app_name}_{model_name}_changelist') return redirect(f'{url}?photo_quality=warning') show_poor_quality_photos.short_description = '⚠️ Показать товары с фото требующими обновления' def show_excellent_quality_photos(modeladmin, request, queryset): """ Action для фильтрации товаров с фото отличного и хорошего качества. Перенаправляет на список с применённым фильтром по качеству. """ from django.shortcuts import redirect from django.urls import reverse model_name = modeladmin.model._meta.model_name app_name = modeladmin.model._meta.app_label # Перенаправляем на список товаров с фильтром по excellent/good url = reverse(f'admin:{app_name}_{model_name}_changelist') return redirect(f'{url}?photo_quality=no_warning') show_excellent_quality_photos.short_description = '✓ Показать товары с хорошим качеством фото' def show_all_quality_levels(modeladmin, request, queryset): """ Action для показа распределения товаров по качеству фотографий. Выводит статистику в сообщении admin. """ from django.contrib import messages from django.db.models import Count # Получаем статистику по качеству фотографий quality_stats = queryset.filter(photos__isnull=False).values( 'photos__quality_level' ).annotate(count=Count('id', distinct=True)).order_by('-count') warning_count = queryset.filter(photos__quality_warning=True).distinct().count() total_with_photos = queryset.filter(photos__isnull=False).distinct().count() if not quality_stats: modeladmin.message_user( request, f'⚠️ В выбранных товарах ({queryset.count()}) нет фотографий.', messages.WARNING ) return # Формируем сообщение со статистикой quality_names = { 'excellent': '🟢 Отлично', 'good': '🟡 Хорошо', 'acceptable': '🟠 Приемлемо', 'poor': '🔴 Плохо', 'very_poor': '🔴🔴 Очень плохо', } stats_text = f'📊 Статистика качества фото в выбранных товарах:\n' stats_text += f'Всего товаров с фото: {total_with_photos}\n' stats_text += f'Требуют обновления: {warning_count}\n\n' for item in quality_stats: level = item['photos__quality_level'] count = item['count'] label = quality_names.get(level, level) stats_text += f' {label}: {count} товар(ов)\n' modeladmin.message_user(request, stats_text, messages.INFO) show_all_quality_levels.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_with_quality', 'name', 'sku', 'slug', 'parent', 'is_active', 'get_deleted_status') list_filter = (DeletedFilter, 'is_active', QualityLevelFilter, '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, show_poor_quality_photos, show_excellent_quality_photos, show_all_quality_levels, ] 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( '🗑️ Удалена' ) return format_html('✓ Активна') get_deleted_status.short_description = 'Статус' def photo_with_quality(self, obj): """Превью фото с индикатором качества в списке категорий""" first_photo = obj.photos.first() if not first_photo or not first_photo.image: return format_html('Нет фото') quality_indicator = format_quality_badge(first_photo.quality_level, show_icon=True) return format_html( '
' '' '{}' '
', first_photo.image.url, quality_indicator ) photo_with_quality.short_description = "Фото" def photo_preview(self, obj): """Превью фото в списке категорий (старый метод, сохранен для совместимости)""" first_photo = obj.photos.first() if first_photo and first_photo.image: return format_html( '', 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( '', first_photo.image.url ) return "Нет фото" photo_preview_large.short_description = "Превью основного фото" class ProductTagAdmin(admin.ModelAdmin): list_display = ('name', 'slug', 'is_active') list_filter = ('is_active',) prepopulated_fields = {'slug': ('name',)} search_fields = ('name',) class ProductAdmin(admin.ModelAdmin): list_display = ('photo_with_quality', 'name', 'sku', 'get_categories_display', 'cost_price', 'price', 'sale_price', 'get_variant_groups_display', 'is_active', 'get_deleted_status') list_filter = (DeletedFilter, 'is_active', QualityLevelFilter, '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, show_poor_quality_photos, show_excellent_quality_photos, show_all_quality_levels, ] 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( '🗑️ Удален' ) return format_html('✓ Активен') 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_with_quality(self, obj): """Превью фото с индикатором качества в списке товаров""" first_photo = obj.photos.first() if not first_photo or not first_photo.image: return format_html('Нет фото') # Показываем фото с индикатором качества справа quality_indicator = format_quality_badge(first_photo.quality_level, show_icon=True) return format_html( '
' '' '{}' '
', first_photo.image.url, quality_indicator ) photo_with_quality.short_description = "Фото" def photo_preview(self, obj): """Превью фото в списке товаров (старый метод, сохранен для совместимости)""" first_photo = obj.photos.first() if first_photo and first_photo.image: return format_html( '', 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( '', first_photo.image.url ) return "Нет фото" photo_preview_large.short_description = "Превью основного фото" class ProductKitAdmin(admin.ModelAdmin): list_display = ('photo_with_quality', 'name', 'slug', 'get_categories_display', 'get_price_display', 'is_temporary', 'get_order_link', 'is_active', 'get_deleted_status') list_filter = (DeletedFilter, 'is_active', 'is_temporary', QualityLevelFilter, 'categories', 'tags') prepopulated_fields = {'slug': ('name',)} filter_horizontal = ('categories', 'tags') readonly_fields = ('photo_preview_large', 'base_price', 'deleted_at', 'deleted_by', 'order') actions = [ restore_items, delete_selected, hard_delete_selected, show_poor_quality_photos, show_excellent_quality_photos, show_all_quality_levels, ] fieldsets = ( ('Основная информация', { 'fields': ('name', 'sku', 'slug', 'description', 'short_description', 'categories') }), ('Ценообразование', { 'fields': ('base_price', 'price', 'sale_price', 'price_adjustment_type', 'price_adjustment_value'), 'description': 'base_price - сумма цен компонентов (вычисляется автоматически). price - итоговая цена с учетом корректировок. sale_price - цена со скидкой (опционально).' }), ('Временный комплект', { 'fields': ('is_temporary', 'order'), 'description': 'Временные комплекты создаются для конкретных заказов и не показываются в каталоге.' }), ('Дополнительно', { '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_order_link(self, obj): """Отображение ссылки на заказ для временных комплектов""" if obj.order: from django.urls import reverse url = reverse('admin:orders_order_change', args=[obj.order.pk]) return format_html('{}', url, obj.order.order_number) return '-' get_order_link.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( '🗑️ Удален' ) return format_html('✓ Активен') 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_with_quality(self, obj): """Превью фото с индикатором качества в списке комплектов""" first_photo = obj.photos.first() if not first_photo or not first_photo.image: return format_html('Нет фото') quality_indicator = format_quality_badge(first_photo.quality_level, show_icon=True) return format_html( '
' '' '{}' '
', first_photo.image.url, quality_indicator ) photo_with_quality.short_description = "Фото" def photo_preview(self, obj): """Превью фото в списке комплектов (старый метод, сохранен для совместимости)""" first_photo = obj.photos.first() if first_photo and first_photo.image: return format_html( '', 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( '', 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'] 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', 'quality_display') fields = ('image', 'image_preview', 'quality_display', 'order') def image_preview(self, obj): """Превью основного фото (большой размер 800×800)""" if obj.image: return format_html( '', obj.get_large_url() ) return "Нет изображения" image_preview.short_description = "Превью" def quality_display(self, obj): """Отображение качества фото в inline таблице""" if not obj.pk: return format_html('Сохраните фото') return format_quality_display( obj.quality_level, width=getattr(obj, 'width', None), height=getattr(obj, 'height', None), warning=obj.quality_warning ) quality_display.short_description = "Качество" class ProductKitPhotoInline(nested_admin.NestedTabularInline): model = ProductKitPhoto extra = 0 # Не показывать пустые формы readonly_fields = ('image_preview', 'quality_display') fields = ('image', 'image_preview', 'quality_display', 'order') def image_preview(self, obj): """Превью основного фото (большой размер 800×800)""" if obj.image: return format_html( '', obj.get_large_url() ) return "Нет изображения" image_preview.short_description = "Превью" def quality_display(self, obj): """Отображение качества фото в inline таблице""" if not obj.pk: return format_html('Сохраните фото') return format_quality_display( obj.quality_level, width=getattr(obj, 'width', None), height=getattr(obj, 'height', None), warning=obj.quality_warning ) quality_display.short_description = "Качество" class ProductCategoryPhotoInline(admin.TabularInline): model = ProductCategoryPhoto extra = 1 readonly_fields = ('image_preview', 'quality_display') fields = ('image', 'image_preview', 'quality_display', 'order') def image_preview(self, obj): """Превью основного фото (большой размер 800×800)""" if obj.image: return format_html( '', obj.get_large_url() ) return "Нет изображения" image_preview.short_description = "Превью" def quality_display(self, obj): """Отображение качества фото в inline таблице""" if not obj.pk: return format_html('Сохраните фото') return format_quality_display( obj.quality_level, width=getattr(obj, 'width', None), height=getattr(obj, 'height', None), warning=obj.quality_warning ) quality_display.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'] def get_type(self, obj): if obj.variant_group: return format_html('Группа: {}', 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('PROD-{:06d}', next_val) elif obj.counter_type == 'kit': return format_html('KIT-{:06d}', next_val) elif obj.counter_type == 'category': return format_html('CAT-{:04d}', 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)