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', '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(
'🗑️ Удален'
)
return format_html('✓ Активен')
get_deleted_status.short_description = 'Статус'
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', 'pricing_method', 'get_price_display', 'is_active', 'get_deleted_status')
list_filter = (DeletedFilter, 'is_active', QualityLevelFilter, '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,
show_poor_quality_photos,
show_excellent_quality_photos,
show_all_quality_levels,
]
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(
'🗑️ Удален'
)
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', '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', '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', 'notes']
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)