Files
octopus/myproject/products/admin.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

795 lines
35 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
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(
'<span style="color: red; font-weight: bold;">🗑️ Удалена</span>'
)
return format_html('<span style="color: green;">✓ Активна</span>')
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('<span style="color: #999;">Нет фото</span>')
quality_indicator = format_quality_badge(first_photo.quality_level, show_icon=True)
return format_html(
'<div style="display: flex; align-items: center; gap: 8px;">'
'<img src="{}" style="width: 50px; height: 50px; object-fit: cover; border-radius: 4px;" />'
'{}'
'</div>',
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(
'<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', '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(
'<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_with_quality(self, obj):
"""Превью фото с индикатором качества в списке товаров"""
first_photo = obj.photos.first()
if not first_photo or not first_photo.image:
return format_html('<span style="color: #999;">Нет фото</span>')
# Показываем фото с индикатором качества справа
quality_indicator = format_quality_badge(first_photo.quality_level, show_icon=True)
return format_html(
'<div style="display: flex; align-items: center; gap: 8px;">'
'<img src="{}" style="width: 50px; height: 50px; object-fit: cover; border-radius: 4px;" />'
'{}'
'</div>',
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(
'<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_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('<a href="{}">{}</a>', 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(
'<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_with_quality(self, obj):
"""Превью фото с индикатором качества в списке комплектов"""
first_photo = obj.photos.first()
if not first_photo or not first_photo.image:
return format_html('<span style="color: #999;">Нет фото</span>')
quality_indicator = format_quality_badge(first_photo.quality_level, show_icon=True)
return format_html(
'<div style="display: flex; align-items: center; gap: 8px;">'
'<img src="{}" style="width: 50px; height: 50px; object-fit: cover; border-radius: 4px;" />'
'{}'
'</div>',
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(
'<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']
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(
'<img src="{}" style="max-width: 250px; max-height: 250px; border-radius: 4px;" />',
obj.get_large_url()
)
return "Нет изображения"
image_preview.short_description = "Превью"
def quality_display(self, obj):
"""Отображение качества фото в inline таблице"""
if not obj.pk:
return format_html('<span style="color: #999;">Сохраните фото</span>')
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(
'<img src="{}" style="max-width: 250px; max-height: 250px; border-radius: 4px;" />',
obj.get_large_url()
)
return "Нет изображения"
image_preview.short_description = "Превью"
def quality_display(self, obj):
"""Отображение качества фото в inline таблице"""
if not obj.pk:
return format_html('<span style="color: #999;">Сохраните фото</span>')
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(
'<img src="{}" style="max-width: 250px; max-height: 250px; border-radius: 4px;" />',
obj.get_large_url()
)
return "Нет изображения"
image_preview.short_description = "Превью"
def quality_display(self, obj):
"""Отображение качества фото в inline таблице"""
if not obj.pk:
return format_html('<span style="color: #999;">Сохраните фото</span>')
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('<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)