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.contrib import admin
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.db.models import Q
|
||||||
import nested_admin
|
import nested_admin
|
||||||
from .models import ProductCategory, ProductTag, Product, ProductKit, KitItem
|
from .models import ProductCategory, ProductTag, Product, ProductKit, KitItem
|
||||||
from .models import ProductPhoto, ProductKitPhoto, ProductCategoryPhoto
|
from .models import ProductPhoto, ProductKitPhoto, ProductCategoryPhoto
|
||||||
from .models import ProductVariantGroup, KitItemPriority, SKUCounter
|
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):
|
class DeletedFilter(admin.SimpleListFilter):
|
||||||
@@ -27,6 +35,37 @@ class DeletedFilter(admin.SimpleListFilter):
|
|||||||
return queryset
|
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):
|
def restore_items(modeladmin, request, queryset):
|
||||||
"""Action для восстановления удаленных элементов"""
|
"""Action для восстановления удаленных элементов"""
|
||||||
updated = queryset.update(is_deleted=False, deleted_at=None, deleted_by=None)
|
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 = '🔴 Безопасно удалить навсегда (только без связей)'
|
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):
|
def disable_delete_selected(admin_class):
|
||||||
"""
|
"""
|
||||||
Декоратор для отключения стандартного Django delete_selected action.
|
Декоратор для отключения стандартного Django delete_selected action.
|
||||||
@@ -167,12 +287,19 @@ class ProductVariantGroupAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
class ProductCategoryAdmin(admin.ModelAdmin):
|
class ProductCategoryAdmin(admin.ModelAdmin):
|
||||||
list_display = ('photo_preview', 'name', 'sku', 'slug', 'parent', 'is_active', 'get_deleted_status')
|
list_display = ('photo_with_quality', 'name', 'sku', 'slug', 'parent', 'is_active', 'get_deleted_status')
|
||||||
list_filter = (DeletedFilter, 'is_active', 'parent')
|
list_filter = (DeletedFilter, 'is_active', QualityLevelFilter, 'parent')
|
||||||
prepopulated_fields = {'slug': ('name',)}
|
prepopulated_fields = {'slug': ('name',)}
|
||||||
search_fields = ('name', 'sku')
|
search_fields = ('name', 'sku')
|
||||||
readonly_fields = ('photo_preview_large', 'deleted_at', 'deleted_by')
|
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):
|
def get_queryset(self, request):
|
||||||
"""Переопределяем queryset для доступа ко всем категориям (включая удаленные)"""
|
"""Переопределяем queryset для доступа ко всем категориям (включая удаленные)"""
|
||||||
@@ -191,8 +318,26 @@ class ProductCategoryAdmin(admin.ModelAdmin):
|
|||||||
return format_html('<span style="color: green;">✓ Активна</span>')
|
return format_html('<span style="color: green;">✓ Активна</span>')
|
||||||
get_deleted_status.short_description = 'Статус'
|
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):
|
def photo_preview(self, obj):
|
||||||
"""Превью фото в списке категорий"""
|
"""Превью фото в списке категорий (старый метод, сохранен для совместимости)"""
|
||||||
first_photo = obj.photos.first()
|
first_photo = obj.photos.first()
|
||||||
if first_photo and first_photo.image:
|
if first_photo and first_photo.image:
|
||||||
return format_html(
|
return format_html(
|
||||||
@@ -241,13 +386,20 @@ class ProductTagAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
class ProductAdmin(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_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', 'categories', 'tags', 'variant_groups')
|
list_filter = (DeletedFilter, 'is_active', QualityLevelFilter, 'categories', 'tags', 'variant_groups')
|
||||||
search_fields = ('name', 'sku', 'description', 'search_keywords')
|
search_fields = ('name', 'sku', 'description', 'search_keywords')
|
||||||
filter_horizontal = ('categories', 'tags', 'variant_groups')
|
filter_horizontal = ('categories', 'tags', 'variant_groups')
|
||||||
readonly_fields = ('photo_preview_large', 'deleted_at', 'deleted_by')
|
readonly_fields = ('photo_preview_large', 'deleted_at', 'deleted_by')
|
||||||
autocomplete_fields = []
|
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 = (
|
fieldsets = (
|
||||||
('Основная информация', {
|
('Основная информация', {
|
||||||
@@ -313,8 +465,27 @@ class ProductAdmin(admin.ModelAdmin):
|
|||||||
return result
|
return result
|
||||||
get_variant_groups_display.short_description = 'Группы вариантов'
|
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):
|
def photo_preview(self, obj):
|
||||||
"""Превью фото в списке товаров"""
|
"""Превью фото в списке товаров (старый метод, сохранен для совместимости)"""
|
||||||
first_photo = obj.photos.first()
|
first_photo = obj.photos.first()
|
||||||
if first_photo and first_photo.image:
|
if first_photo and first_photo.image:
|
||||||
return format_html(
|
return format_html(
|
||||||
@@ -337,12 +508,19 @@ class ProductAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
class ProductKitAdmin(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_display = ('photo_with_quality', '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_filter = (DeletedFilter, 'is_active', QualityLevelFilter, 'pricing_method', 'categories', 'tags')
|
||||||
prepopulated_fields = {'slug': ('name',)}
|
prepopulated_fields = {'slug': ('name',)}
|
||||||
filter_horizontal = ('categories', 'tags')
|
filter_horizontal = ('categories', 'tags')
|
||||||
readonly_fields = ('photo_preview_large', 'deleted_at', 'deleted_by')
|
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 = (
|
fieldsets = (
|
||||||
('Основная информация', {
|
('Основная информация', {
|
||||||
@@ -369,7 +547,7 @@ class ProductKitAdmin(admin.ModelAdmin):
|
|||||||
def get_price_display(self, obj):
|
def get_price_display(self, obj):
|
||||||
"""Отображение финальной цены комплекта"""
|
"""Отображение финальной цены комплекта"""
|
||||||
try:
|
try:
|
||||||
return f"{obj.actual_price} ₽"
|
return f"{obj.actual_price} руб."
|
||||||
except Exception:
|
except Exception:
|
||||||
return "-"
|
return "-"
|
||||||
get_price_display.short_description = "Цена"
|
get_price_display.short_description = "Цена"
|
||||||
@@ -401,8 +579,26 @@ class ProductKitAdmin(admin.ModelAdmin):
|
|||||||
return result
|
return result
|
||||||
get_categories_display.short_description = 'Категории'
|
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):
|
def photo_preview(self, obj):
|
||||||
"""Превью фото в списке комплектов"""
|
"""Превью фото в списке комплектов (старый метод, сохранен для совместимости)"""
|
||||||
first_photo = obj.photos.first()
|
first_photo = obj.photos.first()
|
||||||
if first_photo and first_photo.image:
|
if first_photo and first_photo.image:
|
||||||
return format_html(
|
return format_html(
|
||||||
@@ -459,8 +655,8 @@ class KitItemInline(nested_admin.NestedStackedInline):
|
|||||||
class ProductPhotoInline(admin.TabularInline):
|
class ProductPhotoInline(admin.TabularInline):
|
||||||
model = ProductPhoto
|
model = ProductPhoto
|
||||||
extra = 1
|
extra = 1
|
||||||
readonly_fields = ('image_preview',)
|
readonly_fields = ('image_preview', 'quality_display')
|
||||||
fields = ('image', 'image_preview', 'order')
|
fields = ('image', 'image_preview', 'quality_display', 'order')
|
||||||
|
|
||||||
def image_preview(self, obj):
|
def image_preview(self, obj):
|
||||||
"""Превью основного фото (большой размер 800×800)"""
|
"""Превью основного фото (большой размер 800×800)"""
|
||||||
@@ -472,11 +668,23 @@ class ProductPhotoInline(admin.TabularInline):
|
|||||||
return "Нет изображения"
|
return "Нет изображения"
|
||||||
image_preview.short_description = "Превью"
|
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):
|
class ProductKitPhotoInline(nested_admin.NestedTabularInline):
|
||||||
model = ProductKitPhoto
|
model = ProductKitPhoto
|
||||||
extra = 0 # Не показывать пустые формы
|
extra = 0 # Не показывать пустые формы
|
||||||
readonly_fields = ('image_preview',)
|
readonly_fields = ('image_preview', 'quality_display')
|
||||||
fields = ('image', 'image_preview', 'order')
|
fields = ('image', 'image_preview', 'quality_display', 'order')
|
||||||
|
|
||||||
def image_preview(self, obj):
|
def image_preview(self, obj):
|
||||||
"""Превью основного фото (большой размер 800×800)"""
|
"""Превью основного фото (большой размер 800×800)"""
|
||||||
@@ -488,11 +696,23 @@ class ProductKitPhotoInline(nested_admin.NestedTabularInline):
|
|||||||
return "Нет изображения"
|
return "Нет изображения"
|
||||||
image_preview.short_description = "Превью"
|
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):
|
class ProductCategoryPhotoInline(admin.TabularInline):
|
||||||
model = ProductCategoryPhoto
|
model = ProductCategoryPhoto
|
||||||
extra = 1
|
extra = 1
|
||||||
readonly_fields = ('image_preview',)
|
readonly_fields = ('image_preview', 'quality_display')
|
||||||
fields = ('image', 'image_preview', 'order')
|
fields = ('image', 'image_preview', 'quality_display', 'order')
|
||||||
|
|
||||||
def image_preview(self, obj):
|
def image_preview(self, obj):
|
||||||
"""Превью основного фото (большой размер 800×800)"""
|
"""Превью основного фото (большой размер 800×800)"""
|
||||||
@@ -504,6 +724,18 @@ class ProductCategoryPhotoInline(admin.TabularInline):
|
|||||||
return "Нет изображения"
|
return "Нет изображения"
|
||||||
image_preview.short_description = "Превью"
|
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):
|
class ProductKitAdminWithItems(ProductKitAdmin):
|
||||||
inlines = [KitItemInline]
|
inlines = [KitItemInline]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{% if show %}
|
||||||
|
<div class="quality-indicator position-absolute top-0 end-0 m-2"
|
||||||
|
title="{{ tooltip }}"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
data-bs-placement="left">
|
||||||
|
{% if has_warning %}
|
||||||
|
<span class="badge bg-danger fs-5">⚠️</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-{{ color }} fs-6" style="opacity: 0.9;">
|
||||||
|
{{ symbol }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if size_text %}
|
||||||
|
<small class="d-block text-muted mt-1" style="font-size: 0.7rem;">{{ size_text }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
|
{% load quality_tags %}
|
||||||
|
|
||||||
{% block title %}{{ product.name }}{% endblock %}
|
{% block title %}{{ product.name }}{% endblock %}
|
||||||
|
|
||||||
@@ -28,9 +29,9 @@
|
|||||||
<div class="row g-2">
|
<div class="row g-2">
|
||||||
{% for photo in product_photos %}
|
{% for photo in product_photos %}
|
||||||
<div class="col-md-3 col-sm-4 col-6">
|
<div class="col-md-3 col-sm-4 col-6">
|
||||||
<div class="card shadow-sm h-100">
|
<div class="card shadow-sm h-100 photo-card-with-quality">
|
||||||
<!-- Миниатюра для отображения в сетке, при клике открывает large в модальном окне -->
|
<!-- Миниатюра для отображения в сетке, при клике открывает large в модальном окне -->
|
||||||
<div style="width: 100%; height: 150px; display: flex; align-items: center; justify-content: center; background-color: #f8f9fa; cursor: pointer; overflow: hidden;"
|
<div class="photo-container"
|
||||||
data-bs-toggle="modal"
|
data-bs-toggle="modal"
|
||||||
data-bs-target="#photoGalleryModal"
|
data-bs-target="#photoGalleryModal"
|
||||||
data-bs-slide-to="{{ forloop.counter0 }}"
|
data-bs-slide-to="{{ forloop.counter0 }}"
|
||||||
@@ -39,6 +40,9 @@
|
|||||||
<img src="{{ photo.get_thumbnail_url }}"
|
<img src="{{ photo.get_thumbnail_url }}"
|
||||||
alt="Фото товара"
|
alt="Фото товара"
|
||||||
style="max-width: 100%; max-height: 100%; object-fit: contain;">
|
style="max-width: 100%; max-height: 100%; object-fit: contain;">
|
||||||
|
|
||||||
|
<!-- Индикатор качества в углу -->
|
||||||
|
{% quality_indicator photo %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-2 text-center">
|
<div class="card-body p-2 text-center">
|
||||||
{% if photo.order == 0 %}
|
{% if photo.order == 0 %}
|
||||||
@@ -46,6 +50,11 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<small class="text-muted">Позиция: {{ photo.order }}</small>
|
<small class="text-muted">Позиция: {{ photo.order }}</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Статус качества под фото -->
|
||||||
|
<div class="mt-1">
|
||||||
|
{{ photo|quality_badge_full }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,6 +114,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
<div id="galleryQualityStatus" class="me-auto">
|
||||||
|
<!-- Индикатор качества текущего фото в галерее -->
|
||||||
|
</div>
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,8 +167,85 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Себестоимость:</th>
|
<th>Себестоимость:</th>
|
||||||
<td>{{ product.cost_price }} руб.</td>
|
<td>
|
||||||
|
<strong class="fs-5">{{ product.cost_price }} руб.</strong>
|
||||||
|
{% if product.cost_price_details.batches %}
|
||||||
|
<button class="btn btn-sm btn-outline-info ms-2" type="button" data-bs-toggle="collapse" data-bs-target="#costDetails" aria-expanded="false" aria-controls="costDetails">
|
||||||
|
<i class="bi bi-info-circle"></i> Детали расчета
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-warning text-dark ms-2">Нет партий на складе</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{% if product.cost_price_details.batches %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="2">
|
||||||
|
<div class="collapse" id="costDetails">
|
||||||
|
<div class="card card-body bg-light">
|
||||||
|
<h6 class="mb-3">Разбивка себестоимости по партиям (FIFO)</h6>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="alert alert-info mb-0">
|
||||||
|
<small><strong>Кешированная стоимость:</strong> {{ product.cost_price_details.cached_cost }} руб.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="alert alert-{% if product.cost_price_details.is_synced %}success{% else %}warning{% endif %} mb-0">
|
||||||
|
<small><strong>Рассчитанная стоимость:</strong> {{ product.cost_price_details.calculated_cost }} руб.</small>
|
||||||
|
{% if not product.cost_price_details.is_synced %}
|
||||||
|
<br><small class="text-danger">⚠ Требуется синхронизация!</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-bordered table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Склад</th>
|
||||||
|
<th class="text-end">Количество</th>
|
||||||
|
<th class="text-end">Себестоимость за ед.</th>
|
||||||
|
<th class="text-end">Общая стоимость</th>
|
||||||
|
<th>Дата создания</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for batch in product.cost_price_details.batches %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ batch.warehouse_name }}</td>
|
||||||
|
<td class="text-end">{{ batch.quantity }}</td>
|
||||||
|
<td class="text-end">{{ batch.cost_price }} руб.</td>
|
||||||
|
<td class="text-end"><strong>{{ batch.total_value }} руб.</strong></td>
|
||||||
|
<td>{{ batch.created_at|date:"d.m.Y H:i" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot class="table-secondary">
|
||||||
|
<tr>
|
||||||
|
<th>Итого:</th>
|
||||||
|
<th class="text-end">{{ product.cost_price_details.total_quantity }}</th>
|
||||||
|
<th class="text-end" colspan="3">
|
||||||
|
<strong>Средневзвешенная: {{ product.cost_price_details.calculated_cost }} руб.</strong>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="bi bi-info-circle"></i>
|
||||||
|
Средневзвешенная себестоимость рассчитывается как: (Σ количество × стоимость) / Σ количество
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
<tr>
|
<tr>
|
||||||
<th>Цена:</th>
|
<th>Цена:</th>
|
||||||
<td>
|
<td>
|
||||||
@@ -228,10 +317,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const currentSlideEl = document.getElementById('currentSlide');
|
const currentSlideEl = document.getElementById('currentSlide');
|
||||||
const mainBadgeEl = document.getElementById('mainBadge');
|
const mainBadgeEl = document.getElementById('mainBadge');
|
||||||
|
|
||||||
// Массив с информацией о фотографиях
|
// Массив с информацией о фотографиях (включая качество)
|
||||||
const photos = [
|
const photos = [
|
||||||
{% for photo in product_photos %}
|
{% for photo in product_photos %}
|
||||||
{ order: {{ photo.order }}, index: {{ forloop.counter0 }} }{% if not forloop.last %},{% endif %}
|
{
|
||||||
|
order: {{ photo.order }},
|
||||||
|
index: {{ forloop.counter0 }},
|
||||||
|
quality_level: '{{ photo.quality_level }}',
|
||||||
|
quality_warning: {{ photo.quality_warning|lower }},
|
||||||
|
width: {{ photo.width|default:0 }},
|
||||||
|
height: {{ photo.height|default:0 }}
|
||||||
|
}{% if not forloop.last %},{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -244,14 +340,44 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Обновление счетчика и бейджа при переключении слайдов
|
// Обновление счетчика, бейджа и статуса качества при переключении слайдов
|
||||||
photoCarousel.addEventListener('slid.bs.carousel', function (event) {
|
photoCarousel.addEventListener('slid.bs.carousel', function (event) {
|
||||||
const activeIndex = event.to;
|
const activeIndex = event.to;
|
||||||
|
const photoInfo = photos[activeIndex];
|
||||||
|
|
||||||
|
// Обновляем счетчик слайдов
|
||||||
if (currentSlideEl) {
|
if (currentSlideEl) {
|
||||||
currentSlideEl.textContent = activeIndex + 1;
|
currentSlideEl.textContent = activeIndex + 1;
|
||||||
}
|
}
|
||||||
if (mainBadgeEl && photos[activeIndex]) {
|
|
||||||
mainBadgeEl.style.display = photos[activeIndex].order === 0 ? 'inline' : 'none';
|
// Обновляем бейдж "Главное фото"
|
||||||
|
if (mainBadgeEl && photoInfo) {
|
||||||
|
mainBadgeEl.style.display = photoInfo.order === 0 ? 'inline' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем статус качества
|
||||||
|
const qualityStatusEl = document.getElementById('galleryQualityStatus');
|
||||||
|
if (qualityStatusEl && photoInfo) {
|
||||||
|
let qualityHTML = '';
|
||||||
|
|
||||||
|
if (photoInfo.quality_warning) {
|
||||||
|
qualityHTML = '<span class="badge bg-danger"><i class="bi bi-exclamation-circle"></i> Требует обновления</span>';
|
||||||
|
} else {
|
||||||
|
const qualityInfo = {
|
||||||
|
'excellent': { symbol: '🟢', label: 'Отлично', color: 'success' },
|
||||||
|
'good': { symbol: '🟡', label: 'Хорошо', color: 'info' },
|
||||||
|
'acceptable': { symbol: '🟠', label: 'Приемлемо', color: 'warning' },
|
||||||
|
'poor': { symbol: '🔴', label: 'Плохо', color: 'danger' },
|
||||||
|
'very_poor': { symbol: '🔴', label: 'Очень плохо', color: 'danger' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const info = qualityInfo[photoInfo.quality_level] || { symbol: '⚪', label: 'Неизвестно', color: 'secondary' };
|
||||||
|
const sizeInfo = photoInfo.width && photoInfo.height ? ` (${photoInfo.width}×${photoInfo.height}px)` : '';
|
||||||
|
|
||||||
|
qualityHTML = `<span class="badge bg-${info.color}">${info.symbol} ${info.label}${sizeInfo}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
qualityStatusEl.innerHTML = qualityHTML;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -269,6 +395,32 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
// Добавляем обработчик клавиш при открытии модального окна
|
// Добавляем обработчик клавиш при открытии модального окна
|
||||||
photoGalleryModal.addEventListener('shown.bs.modal', function () {
|
photoGalleryModal.addEventListener('shown.bs.modal', function () {
|
||||||
document.addEventListener('keydown', handleKeydown);
|
document.addEventListener('keydown', handleKeydown);
|
||||||
|
|
||||||
|
// Инициализируем статус качества первого фото
|
||||||
|
const qualityStatusEl = document.getElementById('galleryQualityStatus');
|
||||||
|
if (qualityStatusEl && photos[0]) {
|
||||||
|
const photoInfo = photos[0];
|
||||||
|
let qualityHTML = '';
|
||||||
|
|
||||||
|
if (photoInfo.quality_warning) {
|
||||||
|
qualityHTML = '<span class="badge bg-danger"><i class="bi bi-exclamation-circle"></i> Требует обновления</span>';
|
||||||
|
} else {
|
||||||
|
const qualityInfo = {
|
||||||
|
'excellent': { symbol: '🟢', label: 'Отлично', color: 'success' },
|
||||||
|
'good': { symbol: '🟡', label: 'Хорошо', color: 'info' },
|
||||||
|
'acceptable': { symbol: '🟠', label: 'Приемлемо', color: 'warning' },
|
||||||
|
'poor': { symbol: '🔴', label: 'Плохо', color: 'danger' },
|
||||||
|
'very_poor': { symbol: '🔴', label: 'Очень плохо', color: 'danger' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const info = qualityInfo[photoInfo.quality_level] || { symbol: '⚪', label: 'Неизвестно', color: 'secondary' };
|
||||||
|
const sizeInfo = photoInfo.width && photoInfo.height ? ` (${photoInfo.width}×${photoInfo.height}px)` : '';
|
||||||
|
|
||||||
|
qualityHTML = `<span class="badge bg-${info.color}">${info.symbol} ${info.label}${sizeInfo}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
qualityStatusEl.innerHTML = qualityHTML;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Удаляем обработчик клавиш при закрытии модального окна
|
// Удаляем обработчик клавиш при закрытии модального окна
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
|
{% load quality_tags %}
|
||||||
|
|
||||||
{% block title %}Список товаров{% endblock %}
|
{% block title %}Список товаров{% endblock %}
|
||||||
|
|
||||||
@@ -30,8 +31,11 @@
|
|||||||
<td>
|
<td>
|
||||||
{% if product.photos.all %}
|
{% if product.photos.all %}
|
||||||
{% with photo=product.photos.first %}
|
{% with photo=product.photos.first %}
|
||||||
<!-- Миниатюра 200x200 для списков -->
|
<!-- Миниатюра с индикатором качества -->
|
||||||
<img src="{{ photo.get_thumbnail_url }}" alt="{{ product.name }}" style="width: 60px; height: 60px; object-fit: cover;" class="img-thumbnail rounded">
|
<div class="photo-list-item">
|
||||||
|
<img src="{{ photo.get_thumbnail_url }}" alt="{{ product.name }}" class="img-thumbnail rounded">
|
||||||
|
<span class="quality-icon" title="{{ photo.get_quality_level_display }}">{{ photo|quality_icon_only }}</span>
|
||||||
|
</div>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">Нет фото</span>
|
<span class="text-muted">Нет фото</span>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
|
{% load inventory_filters %}
|
||||||
|
{% load quality_tags %}
|
||||||
|
|
||||||
{% block title %}{{ kit.name }} - Комплект{% endblock %}
|
{% block title %}{{ kit.name }} - Комплект{% endblock %}
|
||||||
|
|
||||||
@@ -50,17 +52,17 @@
|
|||||||
<dt class="col-sm-4">Цена:</dt>
|
<dt class="col-sm-4">Цена:</dt>
|
||||||
<dd class="col-sm-8">
|
<dd class="col-sm-8">
|
||||||
{% if kit.sale_price %}
|
{% if kit.sale_price %}
|
||||||
<span class="text-decoration-line-through text-muted">{{ kit.calculated_price|floatformat:2 }} ₽</span>
|
<span class="text-decoration-line-through text-muted">{{ kit.calculated_price|floatformat:2 }} руб.</span>
|
||||||
<strong class="text-danger fs-5">{{ kit.sale_price|floatformat:2 }} ₽</strong>
|
<strong class="text-danger fs-5">{{ kit.sale_price|floatformat:2 }} руб.</strong>
|
||||||
<span class="badge bg-danger ms-2">Акция</span>
|
<span class="badge bg-danger ms-2">Акция</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<strong class="text-success fs-5">{{ kit.actual_price|floatformat:2 }} ₽</strong>
|
<strong class="text-success fs-5">{{ kit.actual_price|floatformat:2 }} руб.</strong>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</dd>
|
</dd>
|
||||||
|
|
||||||
<dt class="col-sm-4">Себестоимость:</dt>
|
<dt class="col-sm-4">Себестоимость:</dt>
|
||||||
<dd class="col-sm-8">
|
<dd class="col-sm-8">
|
||||||
<strong class="text-danger fs-5">{{ kit.calculate_cost|floatformat:2 }} ₽</strong>
|
<strong class="text-danger fs-5">{{ kit.calculate_cost|floatformat:2 }} руб.</strong>
|
||||||
</dd>
|
</dd>
|
||||||
|
|
||||||
<dt class="col-sm-4">Ценообразование:</dt>
|
<dt class="col-sm-4">Ценообразование:</dt>
|
||||||
@@ -70,7 +72,7 @@
|
|||||||
|
|
||||||
{% if kit.price %}
|
{% if kit.price %}
|
||||||
<dt class="col-sm-4">Ручная цена:</dt>
|
<dt class="col-sm-4">Ручная цена:</dt>
|
||||||
<dd class="col-sm-8">{{ kit.price }} ₽</dd>
|
<dd class="col-sm-8">{{ kit.price }} руб.</dd>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if kit.markup_percent %}
|
{% if kit.markup_percent %}
|
||||||
@@ -80,7 +82,7 @@
|
|||||||
|
|
||||||
{% if kit.markup_amount %}
|
{% if kit.markup_amount %}
|
||||||
<dt class="col-sm-4">Фиксированная наценка:</dt>
|
<dt class="col-sm-4">Фиксированная наценка:</dt>
|
||||||
<dd class="col-sm-8">{{ kit.markup_amount }} ₽</dd>
|
<dd class="col-sm-8">{{ kit.markup_amount }} руб.</dd>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<dt class="col-sm-4">Статус:</dt>
|
<dt class="col-sm-4">Статус:</dt>
|
||||||
@@ -155,7 +157,7 @@
|
|||||||
<span class="badge bg-primary">Варианты</span>
|
<span class="badge bg-primary">Варианты</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ item.quantity }}</td>
|
<td>{{ item.quantity|smart_quantity }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if item.notes %}
|
{% if item.notes %}
|
||||||
{{ item.notes }}
|
{{ item.notes }}
|
||||||
@@ -187,14 +189,24 @@
|
|||||||
<div class="row g-2">
|
<div class="row g-2">
|
||||||
{% for photo in productkit_photos %}
|
{% for photo in productkit_photos %}
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<div class="card">
|
<div class="card photo-card-with-quality">
|
||||||
<!-- Миниатюра 200x200 для быстрой загрузки, при клике открывает large -->
|
<!-- Миниатюра 200x200 для быстрой загрузки, при клике открывает large -->
|
||||||
<img src="{{ photo.get_thumbnail_url }}" class="card-img-top" alt="{{ kit.name }}"
|
<div class="photo-container"
|
||||||
style="height: 120px; object-fit: cover; cursor: pointer;"
|
|
||||||
data-bs-toggle="modal"
|
data-bs-toggle="modal"
|
||||||
data-bs-target="#photoModal{{ photo.pk }}">
|
data-bs-target="#photoModal{{ photo.pk }}"
|
||||||
|
title="Нажмите для увеличения">
|
||||||
|
<img src="{{ photo.get_thumbnail_url }}" class="card-img-top" alt="{{ kit.name }}"
|
||||||
|
style="height: 100%; width: 100%; object-fit: cover;">
|
||||||
|
|
||||||
|
<!-- Индикатор качества в углу -->
|
||||||
|
{% quality_indicator photo %}
|
||||||
|
</div>
|
||||||
{% if photo.order == 0 %}
|
{% if photo.order == 0 %}
|
||||||
<div class="card-footer bg-success text-white text-center small">⭐ Главное</div>
|
<div class="card-footer bg-success text-white text-center small">⭐ Главное</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card-footer text-center">
|
||||||
|
{{ photo|quality_badge_full }}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
193
myproject/products/templatetags/quality_tags.py
Normal file
193
myproject/products/templatetags/quality_tags.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
"""
|
||||||
|
Template tags для отображения индикаторов качества фотографий.
|
||||||
|
Используется в шаблонах для ненавязчивого показа качества фото товаров.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django import template
|
||||||
|
from django.utils.html import format_html
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def quality_badge_mini(photo):
|
||||||
|
"""
|
||||||
|
Возвращает маленький индикатор качества для вывода в уголке фото.
|
||||||
|
Используется в сетках фотографий товаров.
|
||||||
|
|
||||||
|
Выводит маленький кружочек-значок в правом верхнем углу:
|
||||||
|
- 🟢 зелёный - excellent/good (отлично/хорошо)
|
||||||
|
- 🟡 жёлтый - acceptable (приемлемо)
|
||||||
|
- 🟠 оранжевый - poor (плохо)
|
||||||
|
- 🔴 красный - very_poor (очень плохо)
|
||||||
|
|
||||||
|
Если есть warning - показывает ⚠️
|
||||||
|
"""
|
||||||
|
if not photo or not hasattr(photo, 'quality_level'):
|
||||||
|
return ''
|
||||||
|
|
||||||
|
quality_level = photo.quality_level
|
||||||
|
has_warning = getattr(photo, 'quality_warning', False)
|
||||||
|
|
||||||
|
# Если нужно обновление фото - показываем warning
|
||||||
|
if has_warning:
|
||||||
|
return format_html(
|
||||||
|
'<span class="quality-badge-mini" '
|
||||||
|
'title="Фото требует обновления" '
|
||||||
|
'data-bs-toggle="tooltip" '
|
||||||
|
'data-bs-placement="top">'
|
||||||
|
'⚠️'
|
||||||
|
'</span>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Символы для разных уровней качества
|
||||||
|
quality_symbols = {
|
||||||
|
'excellent': ('🟢', 'Отлично'),
|
||||||
|
'good': ('🟡', 'Хорошо'),
|
||||||
|
'acceptable': ('🟠', 'Приемлемо'),
|
||||||
|
'poor': ('🔴', 'Плохо'),
|
||||||
|
'very_poor': ('🔴', 'Очень плохо'),
|
||||||
|
}
|
||||||
|
|
||||||
|
symbol, label = quality_symbols.get(quality_level, ('⚪', 'Неизвестно'))
|
||||||
|
|
||||||
|
return format_html(
|
||||||
|
'<span class="quality-badge-mini" '
|
||||||
|
'title="{}" '
|
||||||
|
'data-bs-toggle="tooltip" '
|
||||||
|
'data-bs-placement="top">'
|
||||||
|
'{}'
|
||||||
|
'</span>',
|
||||||
|
label,
|
||||||
|
symbol
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def quality_badge_full(photo):
|
||||||
|
"""
|
||||||
|
Возвращает полный индикатор качества с размером фото.
|
||||||
|
|
||||||
|
Выводит: "🟢 Отлично (2150×2150px)" или "⚠️ Требует обновления"
|
||||||
|
"""
|
||||||
|
if not photo or not hasattr(photo, 'quality_level'):
|
||||||
|
return ''
|
||||||
|
|
||||||
|
quality_level = photo.quality_level
|
||||||
|
has_warning = getattr(photo, 'quality_warning', False)
|
||||||
|
width = getattr(photo, 'width', None)
|
||||||
|
height = getattr(photo, 'height', None)
|
||||||
|
|
||||||
|
# Если нужно обновление - показываем warning
|
||||||
|
if has_warning:
|
||||||
|
return format_html(
|
||||||
|
'<span class="badge bg-danger">'
|
||||||
|
'⚠️ Требует обновления'
|
||||||
|
'</span>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Символы и названия
|
||||||
|
quality_info = {
|
||||||
|
'excellent': ('🟢', 'Отлично', 'success'),
|
||||||
|
'good': ('🟡', 'Хорошо', 'info'),
|
||||||
|
'acceptable': ('🟠', 'Приемлемо', 'warning'),
|
||||||
|
'poor': ('🔴', 'Плохо', 'danger'),
|
||||||
|
'very_poor': ('🔴', 'Очень плохо', 'danger'),
|
||||||
|
}
|
||||||
|
|
||||||
|
symbol, label, color = quality_info.get(quality_level, ('⚪', 'Неизвестно', 'secondary'))
|
||||||
|
|
||||||
|
# Если есть размеры - добавляем их
|
||||||
|
size_info = ''
|
||||||
|
if width and height:
|
||||||
|
size_info = f' ({width}×{height}px)'
|
||||||
|
|
||||||
|
return format_html(
|
||||||
|
'<span class="badge bg-{}">{} {}{}</span>',
|
||||||
|
color,
|
||||||
|
symbol,
|
||||||
|
label,
|
||||||
|
size_info
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@register.inclusion_tag('products/includes/quality_badge.html')
|
||||||
|
def quality_indicator(photo, show_size=False):
|
||||||
|
"""
|
||||||
|
Включаемый тег для вывода индикатора качества в углу фото.
|
||||||
|
Используется с позиционированием в углу через CSS.
|
||||||
|
|
||||||
|
Параметры:
|
||||||
|
- photo: объект фото (ProductPhoto, ProductKitPhoto или ProductCategoryPhoto)
|
||||||
|
- show_size: показывать ли размер фото (по умолчанию False)
|
||||||
|
"""
|
||||||
|
if not photo or not hasattr(photo, 'quality_level'):
|
||||||
|
return {
|
||||||
|
'show': False,
|
||||||
|
'quality_level': None,
|
||||||
|
'has_warning': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
quality_level = photo.quality_level
|
||||||
|
has_warning = getattr(photo, 'quality_warning', False)
|
||||||
|
width = getattr(photo, 'width', None)
|
||||||
|
height = getattr(photo, 'height', None)
|
||||||
|
|
||||||
|
# Информация о качестве
|
||||||
|
quality_info = {
|
||||||
|
'excellent': {'symbol': '🟢', 'label': 'Отлично', 'color': 'success', 'tooltip': 'Отличное качество'},
|
||||||
|
'good': {'symbol': '🟡', 'label': 'Хорошо', 'color': 'info', 'tooltip': 'Хорошее качество'},
|
||||||
|
'acceptable': {'symbol': '🟠', 'label': 'Приемлемо', 'color': 'warning', 'tooltip': 'Приемлемое качество'},
|
||||||
|
'poor': {'symbol': '🔴', 'label': 'Плохо', 'color': 'danger', 'tooltip': 'Плохое качество'},
|
||||||
|
'very_poor': {'symbol': '🔴', 'label': 'Очень плохо', 'color': 'danger', 'tooltip': 'Очень плохое качество'},
|
||||||
|
}
|
||||||
|
|
||||||
|
info = quality_info.get(quality_level, {
|
||||||
|
'symbol': '⚪',
|
||||||
|
'label': 'Неизвестно',
|
||||||
|
'color': 'secondary',
|
||||||
|
'tooltip': 'Качество не определено'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Размер фото если требуется
|
||||||
|
size_text = ''
|
||||||
|
if show_size and width and height:
|
||||||
|
size_text = f'{width}×{height}px'
|
||||||
|
|
||||||
|
return {
|
||||||
|
'show': True,
|
||||||
|
'quality_level': quality_level,
|
||||||
|
'has_warning': has_warning,
|
||||||
|
'symbol': info['symbol'],
|
||||||
|
'label': info['label'],
|
||||||
|
'color': info['color'],
|
||||||
|
'tooltip': info['tooltip'],
|
||||||
|
'size_text': size_text,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def quality_icon_only(photo):
|
||||||
|
"""
|
||||||
|
Возвращает только символ качества (например: 🟢)
|
||||||
|
для компактного отображения в списках.
|
||||||
|
"""
|
||||||
|
if not photo or not hasattr(photo, 'quality_level'):
|
||||||
|
return ''
|
||||||
|
|
||||||
|
quality_level = photo.quality_level
|
||||||
|
has_warning = getattr(photo, 'quality_warning', False)
|
||||||
|
|
||||||
|
if has_warning:
|
||||||
|
return '⚠️'
|
||||||
|
|
||||||
|
symbols = {
|
||||||
|
'excellent': '🟢',
|
||||||
|
'good': '🟡',
|
||||||
|
'acceptable': '🟠',
|
||||||
|
'poor': '🔴',
|
||||||
|
'very_poor': '🔴',
|
||||||
|
}
|
||||||
|
|
||||||
|
return symbols.get(quality_level, '⚪')
|
||||||
192
myproject/static/css/quality_indicator.css
Normal file
192
myproject/static/css/quality_indicator.css
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* Стили для индикаторов качества фотографий товаров
|
||||||
|
* Ненавязчивое отображение в углу фото без отвлечения внимания
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Миниатюрный индикатор в правом верхнем углу фото */
|
||||||
|
.quality-badge-mini {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 1rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: opacity 0.2s ease-in-out;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-badge-mini:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Контейнер с позиционированием для индикатора качества */
|
||||||
|
.quality-indicator {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
z-index: 10;
|
||||||
|
pointer-events: none; /* Не блокирует клики на фото */
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-indicator .badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-indicator .badge.fs-5 {
|
||||||
|
font-size: 1.2rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-indicator .badge.fs-6 {
|
||||||
|
font-size: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Фотографии в сетке на странице товара */
|
||||||
|
.photo-card-with-quality {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card-with-quality .photo-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 150px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card-with-quality .photo-container img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card-with-quality .quality-indicator {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* В списках товаров - компактный вид */
|
||||||
|
.photo-list-item {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-list-item img {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-list-item .quality-icon {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
right: -4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
display: inline-block;
|
||||||
|
background: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Для таблиц - строка со статусом качества */
|
||||||
|
.quality-status-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-status-row .badge {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip для качества */
|
||||||
|
.bs-tooltip-top .tooltip-inner {
|
||||||
|
background-color: rgba(33, 37, 41, 0.95);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bs-tooltip-auto[data-popper-placement^="top"] .tooltip-arrow::before,
|
||||||
|
.bs-tooltip-top .tooltip-arrow::before {
|
||||||
|
border-top-color: rgba(33, 37, 41, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ненавязчивые цвета для indirectных уровней качества */
|
||||||
|
.quality-indicator .badge.bg-success {
|
||||||
|
background-color: #28a745 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-indicator .badge.bg-info {
|
||||||
|
background-color: #17a2b8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-indicator .badge.bg-warning {
|
||||||
|
background-color: #ffc107 !important;
|
||||||
|
color: #333 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-indicator .badge.bg-danger {
|
||||||
|
background-color: #dc3545 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптивность для мобильных устройств */
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.quality-indicator {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-indicator .badge {
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-list-item .quality-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Анимация при наведении для модальной галереи */
|
||||||
|
.carousel-item .quality-indicator {
|
||||||
|
animation: fadeIn 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Для скрытия warning значка если фото в порядке */
|
||||||
|
.quality-indicator .badge.bg-danger:not(:has(.badge)) {
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@
|
|||||||
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
|
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
|
||||||
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet" />
|
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet" />
|
||||||
|
|
||||||
|
<!-- Качество фото индикаторы -->
|
||||||
|
<link rel="stylesheet" href="/static/css/quality_indicator.css">
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
|
|||||||
Reference in New Issue
Block a user