feat: Фаза 3 - Добавить индикаторы качества фото на фронтенд
Реализовано: - Создан набор переиспользуемых шаблонных тегов для отображения качества - quality_badge_mini: маленький значок в углу фото - quality_badge_full: полный индикатор с размером фото - quality_indicator: включаемый тег с позиционированием - quality_icon_only: только символ качества для списков - Добавлены шаблонные теги в: - product_detail.html: индикатор в углу миниатюр + в модальной галерее - product_list.html: иконка качества в таблице товаров - productkit_detail.html: индикатор в углу фото комплектов - Создан CSS с ненавязчивыми стилями: - Полупрозрачные индикаторы (opacity: 0.8) - Компактные размеры (не отвлекает от фото) - Отзывчивость на мобильных устройствах - Анимации при наведении - Обновлена админ панель: - Добавлены 3 новых экшена для поиска товаров по качеству - show_poor_quality_photos: фильтр на товары требующие обновления - show_excellent_quality_photos: фильтр на товары с хорошим качеством - show_all_quality_levels: статистика распределения качества Интеграция в базу template tags: - myproject/products/templatetags/quality_tags.py (новый файл) - myproject/static/css/quality_indicator.css (новый файл) - myproject/products/templates/products/includes/quality_badge.html (новый файл) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,18 @@
|
||||
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):
|
||||
@@ -27,6 +35,37 @@ class DeletedFilter(admin.SimpleListFilter):
|
||||
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)
|
||||
@@ -144,6 +183,87 @@ def hard_delete_selected(modeladmin, request, queryset):
|
||||
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.
|
||||
@@ -167,12 +287,19 @@ class ProductVariantGroupAdmin(admin.ModelAdmin):
|
||||
|
||||
|
||||
class ProductCategoryAdmin(admin.ModelAdmin):
|
||||
list_display = ('photo_preview', 'name', 'sku', 'slug', 'parent', 'is_active', 'get_deleted_status')
|
||||
list_filter = (DeletedFilter, 'is_active', 'parent')
|
||||
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]
|
||||
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 для доступа ко всем категориям (включая удаленные)"""
|
||||
@@ -191,8 +318,26 @@ class ProductCategoryAdmin(admin.ModelAdmin):
|
||||
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(
|
||||
@@ -241,13 +386,20 @@ class ProductTagAdmin(admin.ModelAdmin):
|
||||
|
||||
|
||||
class ProductAdmin(admin.ModelAdmin):
|
||||
list_display = ('photo_preview', 'name', 'sku', 'get_categories_display', 'cost_price', 'price', 'sale_price', 'get_variant_groups_display', 'is_active', 'get_deleted_status')
|
||||
list_filter = (DeletedFilter, 'is_active', 'categories', 'tags', 'variant_groups')
|
||||
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]
|
||||
actions = [
|
||||
restore_items,
|
||||
delete_selected,
|
||||
hard_delete_selected,
|
||||
show_poor_quality_photos,
|
||||
show_excellent_quality_photos,
|
||||
show_all_quality_levels,
|
||||
]
|
||||
|
||||
fieldsets = (
|
||||
('Основная информация', {
|
||||
@@ -313,8 +465,27 @@ class ProductAdmin(admin.ModelAdmin):
|
||||
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(
|
||||
@@ -337,12 +508,19 @@ class ProductAdmin(admin.ModelAdmin):
|
||||
|
||||
|
||||
class ProductKitAdmin(admin.ModelAdmin):
|
||||
list_display = ('photo_preview', 'name', 'slug', 'get_categories_display', 'pricing_method', 'get_price_display', 'is_active', 'get_deleted_status')
|
||||
list_filter = (DeletedFilter, 'is_active', 'pricing_method', 'categories', 'tags')
|
||||
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]
|
||||
actions = [
|
||||
restore_items,
|
||||
delete_selected,
|
||||
hard_delete_selected,
|
||||
show_poor_quality_photos,
|
||||
show_excellent_quality_photos,
|
||||
show_all_quality_levels,
|
||||
]
|
||||
|
||||
fieldsets = (
|
||||
('Основная информация', {
|
||||
@@ -369,7 +547,7 @@ class ProductKitAdmin(admin.ModelAdmin):
|
||||
def get_price_display(self, obj):
|
||||
"""Отображение финальной цены комплекта"""
|
||||
try:
|
||||
return f"{obj.actual_price} ₽"
|
||||
return f"{obj.actual_price} руб."
|
||||
except Exception:
|
||||
return "-"
|
||||
get_price_display.short_description = "Цена"
|
||||
@@ -401,8 +579,26 @@ class ProductKitAdmin(admin.ModelAdmin):
|
||||
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(
|
||||
@@ -459,8 +655,8 @@ class KitItemInline(nested_admin.NestedStackedInline):
|
||||
class ProductPhotoInline(admin.TabularInline):
|
||||
model = ProductPhoto
|
||||
extra = 1
|
||||
readonly_fields = ('image_preview',)
|
||||
fields = ('image', 'image_preview', 'order')
|
||||
readonly_fields = ('image_preview', 'quality_display')
|
||||
fields = ('image', 'image_preview', 'quality_display', 'order')
|
||||
|
||||
def image_preview(self, obj):
|
||||
"""Превью основного фото (большой размер 800×800)"""
|
||||
@@ -472,11 +668,23 @@ class ProductPhotoInline(admin.TabularInline):
|
||||
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',)
|
||||
fields = ('image', 'image_preview', 'order')
|
||||
readonly_fields = ('image_preview', 'quality_display')
|
||||
fields = ('image', 'image_preview', 'quality_display', 'order')
|
||||
|
||||
def image_preview(self, obj):
|
||||
"""Превью основного фото (большой размер 800×800)"""
|
||||
@@ -488,11 +696,23 @@ class ProductKitPhotoInline(nested_admin.NestedTabularInline):
|
||||
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',)
|
||||
fields = ('image', 'image_preview', 'order')
|
||||
readonly_fields = ('image_preview', 'quality_display')
|
||||
fields = ('image', 'image_preview', 'quality_display', 'order')
|
||||
|
||||
def image_preview(self, obj):
|
||||
"""Превью основного фото (большой размер 800×800)"""
|
||||
@@ -504,6 +724,18 @@ class ProductCategoryPhotoInline(admin.TabularInline):
|
||||
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]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user