Добавлены imports для ConfigurableProduct, ConfigurableProductOption, ConfigurableProductAttribute. Создан ConfigurableProductAdmin с инлайнами для вариантов и атрибутов. Поля variant_sku отображается в readonly режиме. Добавлен счетчик вариантов в list_display с цветовой индикацией. Организованы fieldsets для удобного редактирования.
998 lines
44 KiB
Python
998 lines
44 KiB
Python
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, CostPriceHistory
|
||
from .models import ConfigurableProduct, ConfigurableProductOption, ConfigurableProductAttribute
|
||
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 = 'status'
|
||
|
||
def lookups(self, request, model_admin):
|
||
return (
|
||
('active', 'Активные'),
|
||
('archived', 'Архивные'),
|
||
('discontinued', 'Снятые'),
|
||
)
|
||
|
||
def queryset(self, request, queryset):
|
||
# queryset уже содержит всё благодаря get_queryset()
|
||
# Проверяем есть ли поле status или is_deleted на модели
|
||
if hasattr(queryset.model, 'status'):
|
||
if self.value() == 'active':
|
||
return queryset.filter(status='active')
|
||
elif self.value() == 'archived':
|
||
return queryset.filter(status='archived')
|
||
elif self.value() == 'discontinued':
|
||
return queryset.filter(status='discontinued')
|
||
elif hasattr(queryset.model, 'is_deleted'):
|
||
# Для старой системы (Category, Tag)
|
||
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 для восстановления удаленных элементов"""
|
||
if hasattr(queryset.model, 'status'):
|
||
# Новая система со статусом
|
||
updated = queryset.update(status='active', archived_at=None, archived_by=None)
|
||
else:
|
||
# Старая система с is_deleted
|
||
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', 'get_status_display')
|
||
list_filter = (DeletedFilter, QualityLevelFilter, 'categories', 'tags', 'variant_groups')
|
||
search_fields = ('name', 'sku', 'description', 'search_keywords')
|
||
filter_horizontal = ('categories', 'tags', 'variant_groups')
|
||
readonly_fields = ('photo_preview_large', 'archived_at', 'archived_by', 'cost_price', 'cost_price_details_display')
|
||
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', 'price', 'sale_price')
|
||
}),
|
||
('Себестоимость', {
|
||
'fields': ('cost_price_details_display',),
|
||
'description': 'Себестоимость рассчитывается автоматически на основе партий товара (FIFO метод). Редактировать вручную невозможно.'
|
||
}),
|
||
('Дополнительно', {
|
||
'fields': ('tags', 'variant_groups', 'status')
|
||
}),
|
||
('Архивирование', {
|
||
'fields': ('archived_at', 'archived_by'),
|
||
'classes': ('collapse',),
|
||
'description': 'Информация об архивировании товара (статус "Архивный" или "Снят").'
|
||
}),
|
||
('Поиск', {
|
||
'fields': ('search_keywords',),
|
||
'classes': ('collapse',),
|
||
'description': 'Поле для улучшенного поиска. Автоматически генерируется при сохранении, но вы можете добавить дополнительные ключевые слова (синонимы, альтернативные названия и т.д.).'
|
||
}),
|
||
('Фото', {
|
||
'fields': ('photo_preview_large',),
|
||
'classes': ('collapse',),
|
||
}),
|
||
)
|
||
|
||
def cost_price_details_display(self, obj):
|
||
"""
|
||
Отображает детали расчета себестоимости товара в админ-панели.
|
||
Показывает: текущая себестоимость, количество партий, их цены и даты.
|
||
"""
|
||
from django.utils.html import format_html
|
||
from decimal import Decimal
|
||
|
||
# Получаем детали стоимости
|
||
details = obj.cost_price_details
|
||
|
||
if not details or not details.get('batches'):
|
||
return format_html(
|
||
'<div style="padding: 12px; background-color: #f9f9f9; border-radius: 4px; border-left: 4px solid #ffc107;">'
|
||
'<strong>Нет партий</strong><br/>'
|
||
'<span style="color: #666;">Себестоимость установится при поступлении первой партии товара</span>'
|
||
'</div>'
|
||
)
|
||
|
||
# Текущая себестоимость
|
||
current_cost = details.get('cached_cost', Decimal('0'))
|
||
calculated_cost = details.get('calculated_cost', Decimal('0'))
|
||
total_qty = details.get('total_quantity', Decimal('0'))
|
||
is_synced = details.get('is_synced', True)
|
||
batches = details.get('batches', [])
|
||
|
||
# Статус синхронизации
|
||
sync_status = '✓ Синхронизирована' if is_synced else '⚠️ Несинхронизирована'
|
||
sync_color = '#28a745' if is_synced else '#dc3545'
|
||
|
||
# HTML для партий
|
||
batches_html = '<div style="margin-top: 10px;"><strong style="font-size: 13px;">Партии:</strong><ul style="margin: 5px 0; padding-left: 20px;">'
|
||
for batch in batches:
|
||
batches_html += (
|
||
f'<li style="font-size: 12px; margin: 3px 0;">'
|
||
f'{batch.get("warehouse_name", "—")} | '
|
||
f'Кол-во: {batch.get("quantity", 0)} | '
|
||
f'Цена: {batch.get("cost_price", 0)} руб. | '
|
||
f'Дата: {batch.get("created_at", "—")}'
|
||
f'</li>'
|
||
)
|
||
batches_html += '</ul></div>'
|
||
|
||
return format_html(
|
||
'<div style="padding: 12px; background-color: #f9f9f9; border-radius: 4px; border-left: 4px solid #28a745;">'
|
||
'<div><strong style="font-size: 14px;">Текущая себестоимость:</strong> <span style="font-size: 16px; color: #28a745; font-weight: bold;">{} руб.</span></div>'
|
||
'<div style="margin-top: 8px; font-size: 12px; color: #666;">'
|
||
'Статус: <span style="color: {}; font-weight: bold;">{}</span><br/>'
|
||
'Всего в партиях: {} шт.<br/>'
|
||
'Рассчитанная: {} руб.'
|
||
'</div>'
|
||
'{}'
|
||
'<div style="margin-top: 8px; font-size: 11px; color: #999;">'
|
||
'Себестоимость рассчитывается автоматически при поступлении товара (FIFO метод).<br/>'
|
||
'Редактировать вручную невозможно.'
|
||
'</div>'
|
||
'</div>',
|
||
current_cost,
|
||
sync_color,
|
||
sync_status,
|
||
total_qty,
|
||
calculated_cost,
|
||
batches_html
|
||
)
|
||
cost_price_details_display.short_description = 'Себестоимость товара'
|
||
|
||
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_status_display(self, obj):
|
||
"""Показывает статус товара"""
|
||
status_colors = {
|
||
'active': ('green', '✓ Активный'),
|
||
'archived': ('orange', '📦 Архивный'),
|
||
'discontinued': ('red', '🗑️ Снят'),
|
||
}
|
||
color, label = status_colors.get(obj.status, ('gray', obj.status))
|
||
return format_html(
|
||
'<span style="color: {}; font-weight: bold;">{}</span>',
|
||
color, label
|
||
)
|
||
get_status_display.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', 'get_status_display')
|
||
list_filter = (DeletedFilter, 'is_temporary', QualityLevelFilter, 'categories', 'tags')
|
||
prepopulated_fields = {'slug': ('name',)}
|
||
filter_horizontal = ('categories', 'tags')
|
||
readonly_fields = ('photo_preview_large', 'base_price', 'archived_at', 'archived_by', 'order')
|
||
actions = [
|
||
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', 'status')
|
||
}),
|
||
('Архивирование', {
|
||
'fields': ('archived_at', 'archived_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 = super().get_queryset(request)
|
||
ordering = self.get_ordering(request)
|
||
if ordering:
|
||
qs = qs.order_by(*ordering)
|
||
return qs
|
||
|
||
def get_status_display(self, obj):
|
||
"""Показывает статус комплекта"""
|
||
status_colors = {
|
||
'active': ('green', '✓ Активный'),
|
||
'archived': ('orange', '📦 Архивный'),
|
||
'discontinued': ('red', '🗑️ Снят'),
|
||
}
|
||
color, label = status_colors.get(obj.status, ('gray', obj.status))
|
||
return format_html(
|
||
'<span style="color: {}; font-weight: bold;">{}</span>',
|
||
color, label
|
||
)
|
||
get_status_display.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.register(CostPriceHistory)
|
||
class CostPriceHistoryAdmin(admin.ModelAdmin):
|
||
list_display = ['product', 'get_price_change', 'reason', 'created_at']
|
||
list_filter = ['reason', 'created_at', 'product']
|
||
search_fields = ['product__name', 'product__sku', 'notes']
|
||
readonly_fields = ['product', 'old_cost_price', 'new_cost_price', 'reason', 'related_object_id', 'related_object_type', 'notes', 'created_at']
|
||
date_hierarchy = 'created_at'
|
||
|
||
fieldsets = (
|
||
('Информация о изменении', {
|
||
'fields': ('product', 'reason', 'created_at')
|
||
}),
|
||
('Себестоимость', {
|
||
'fields': ('old_cost_price', 'new_cost_price', 'get_price_change')
|
||
}),
|
||
('Связанные объекты', {
|
||
'fields': ('related_object_type', 'related_object_id'),
|
||
'classes': ('collapse',),
|
||
}),
|
||
('Примечания', {
|
||
'fields': ('notes',),
|
||
'classes': ('collapse',),
|
||
}),
|
||
)
|
||
|
||
def get_price_change(self, obj):
|
||
"""Показывает изменение цены в красивом формате"""
|
||
change = obj.new_cost_price - obj.old_cost_price
|
||
change_percent = (change / obj.old_cost_price * 100) if obj.old_cost_price != 0 else 0
|
||
|
||
if change > 0:
|
||
color = '#dc3545' # red
|
||
symbol = '↑'
|
||
elif change < 0:
|
||
color = '#28a745' # green
|
||
symbol = '↓'
|
||
else:
|
||
color = '#6c757d' # gray
|
||
symbol = '='
|
||
|
||
return format_html(
|
||
'<span style="color: {}; font-weight: bold;">{} {} {:.2f} руб. ({:+.2f}%)</span>',
|
||
color,
|
||
symbol,
|
||
obj.old_cost_price,
|
||
abs(change),
|
||
change_percent
|
||
)
|
||
get_price_change.short_description = 'Изменение'
|
||
|
||
def has_delete_permission(self, request, obj=None):
|
||
# История не должна удаляться вручную
|
||
return False
|
||
|
||
def has_add_permission(self, request):
|
||
# История создается только автоматически через сигналы
|
||
return False
|
||
|
||
|
||
admin.site.register(ProductCategory, ProductCategoryAdminWithPhotos)
|
||
admin.site.register(ProductTag, ProductTagAdmin)
|
||
admin.site.register(Product, ProductAdminWithPhotos)
|
||
admin.site.register(ProductKit, ProductKitAdminWithItemsAndPhotos)
|
||
|
||
|
||
# === Админка для ConfigurableProduct ===
|
||
|
||
class ConfigurableProductOptionInline(admin.TabularInline):
|
||
"""Инлайн для вариантов вариативного товара"""
|
||
model = ConfigurableProductOption
|
||
extra = 0
|
||
fields = ('kit', 'product', 'variant_sku', 'is_default', 'attributes')
|
||
readonly_fields = ('variant_sku',)
|
||
verbose_name = "Вариант"
|
||
verbose_name_plural = "Варианты"
|
||
|
||
|
||
class ConfigurableProductAttributeInline(admin.TabularInline):
|
||
"""Инлайн для атрибутов родительского товара"""
|
||
model = ConfigurableProductAttribute
|
||
extra = 0
|
||
fields = ('name', 'option', 'kit', 'product', 'position', 'visible')
|
||
verbose_name = "Атрибут"
|
||
verbose_name_plural = "Атрибуты"
|
||
|
||
|
||
@admin.register(ConfigurableProduct)
|
||
class ConfigurableProductAdmin(admin.ModelAdmin):
|
||
"""Админка для вариативных товаров"""
|
||
list_display = ('name', 'sku', 'status', 'get_options_count', 'created_at')
|
||
list_filter = ('status', 'created_at')
|
||
search_fields = ('name', 'sku', 'description')
|
||
readonly_fields = ('created_at', 'updated_at', 'slug')
|
||
inlines = [ConfigurableProductOptionInline, ConfigurableProductAttributeInline]
|
||
|
||
fieldsets = (
|
||
('Основная информация', {
|
||
'fields': ('name', 'sku', 'slug', 'status')
|
||
}),
|
||
('Описание', {
|
||
'fields': ('short_description', 'description')
|
||
}),
|
||
('Служебная информация', {
|
||
'fields': ('created_at', 'updated_at'),
|
||
'classes': ('collapse',)
|
||
}),
|
||
)
|
||
|
||
def get_options_count(self, obj):
|
||
"""Количество вариантов"""
|
||
count = obj.options.count()
|
||
return format_html(
|
||
'<span style="font-weight: bold; color: {};">{}</span>',
|
||
'#28a745' if count > 0 else '#6c757d',
|
||
count
|
||
)
|
||
get_options_count.short_description = 'Вариантов'
|