diff --git a/myproject/products/admin.py b/myproject/products/admin.py index c9e2886..2e1d7af 100644 --- a/myproject/products/admin.py +++ b/myproject/products/admin.py @@ -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('✓ Активна') get_deleted_status.short_description = 'Статус' + def photo_with_quality(self, obj): + """Превью фото с индикатором качества в списке категорий""" + first_photo = obj.photos.first() + if not first_photo or not first_photo.image: + return format_html('Нет фото') + + quality_indicator = format_quality_badge(first_photo.quality_level, show_icon=True) + + return format_html( + '
' + '' + '{}' + '
', + first_photo.image.url, + quality_indicator + ) + photo_with_quality.short_description = "Фото" + def photo_preview(self, obj): - """Превью фото в списке категорий""" + """Превью фото в списке категорий (старый метод, сохранен для совместимости)""" first_photo = obj.photos.first() if first_photo and first_photo.image: return format_html( @@ -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('Нет фото') + + # Показываем фото с индикатором качества справа + quality_indicator = format_quality_badge(first_photo.quality_level, show_icon=True) + + return format_html( + '
' + '' + '{}' + '
', + first_photo.image.url, + quality_indicator + ) + photo_with_quality.short_description = "Фото" + def photo_preview(self, obj): - """Превью фото в списке товаров""" + """Превью фото в списке товаров (старый метод, сохранен для совместимости)""" first_photo = obj.photos.first() if first_photo and first_photo.image: return format_html( @@ -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('Нет фото') + + quality_indicator = format_quality_badge(first_photo.quality_level, show_icon=True) + + return format_html( + '
' + '' + '{}' + '
', + first_photo.image.url, + quality_indicator + ) + photo_with_quality.short_description = "Фото" + def photo_preview(self, obj): - """Превью фото в списке комплектов""" + """Превью фото в списке комплектов (старый метод, сохранен для совместимости)""" first_photo = obj.photos.first() if first_photo and first_photo.image: return format_html( @@ -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('Сохраните фото') + 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('Сохраните фото') + 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('Сохраните фото') + 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] diff --git a/myproject/products/templates/products/includes/quality_badge.html b/myproject/products/templates/products/includes/quality_badge.html new file mode 100644 index 0000000..0f9fb27 --- /dev/null +++ b/myproject/products/templates/products/includes/quality_badge.html @@ -0,0 +1,17 @@ +{% if show %} +
+ {% if has_warning %} + ⚠️ + {% else %} + + {{ symbol }} + + {% endif %} + {% if size_text %} + {{ size_text }} + {% endif %} +
+{% endif %} diff --git a/myproject/products/templates/products/product_detail.html b/myproject/products/templates/products/product_detail.html index d0c885f..df6947d 100644 --- a/myproject/products/templates/products/product_detail.html +++ b/myproject/products/templates/products/product_detail.html @@ -1,4 +1,5 @@ {% extends 'base.html' %} +{% load quality_tags %} {% block title %}{{ product.name }}{% endblock %} @@ -28,9 +29,9 @@
{% for photo in product_photos %}
-
+
-
+ + + {% quality_indicator photo %}
{% if photo.order == 0 %} @@ -46,6 +50,11 @@ {% else %} Позиция: {{ photo.order }} {% endif %} + + +
+ {{ photo|quality_badge_full }} +
@@ -105,6 +114,9 @@ {% endif %}
@@ -155,8 +167,85 @@ Себестоимость: - {{ product.cost_price }} руб. + + {{ product.cost_price }} руб. + {% if product.cost_price_details.batches %} + + {% else %} + Нет партий на складе + {% endif %} + + {% if product.cost_price_details.batches %} + + +
+
+
Разбивка себестоимости по партиям (FIFO)
+ +
+
+
+ Кешированная стоимость: {{ product.cost_price_details.cached_cost }} руб. +
+
+
+
+ Рассчитанная стоимость: {{ product.cost_price_details.calculated_cost }} руб. + {% if not product.cost_price_details.is_synced %} +
⚠ Требуется синхронизация! + {% endif %} +
+
+
+ +
+ + + + + + + + + + + + {% for batch in product.cost_price_details.batches %} + + + + + + + + {% endfor %} + + + + + + + + +
СкладКоличествоСебестоимость за ед.Общая стоимостьДата создания
{{ batch.warehouse_name }}{{ batch.quantity }}{{ batch.cost_price }} руб.{{ batch.total_value }} руб.{{ batch.created_at|date:"d.m.Y H:i" }}
Итого:{{ product.cost_price_details.total_quantity }} + Средневзвешенная: {{ product.cost_price_details.calculated_cost }} руб. +
+
+ +
+ + + Средневзвешенная себестоимость рассчитывается как: (Σ количество × стоимость) / Σ количество + +
+
+
+ + + {% endif %} Цена: @@ -228,10 +317,17 @@ document.addEventListener('DOMContentLoaded', function() { const currentSlideEl = document.getElementById('currentSlide'); const mainBadgeEl = document.getElementById('mainBadge'); - // Массив с информацией о фотографиях + // Массив с информацией о фотографиях (включая качество) const 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 %} ]; @@ -244,14 +340,44 @@ document.addEventListener('DOMContentLoaded', function() { } }); - // Обновление счетчика и бейджа при переключении слайдов + // Обновление счетчика, бейджа и статуса качества при переключении слайдов photoCarousel.addEventListener('slid.bs.carousel', function (event) { const activeIndex = event.to; + const photoInfo = photos[activeIndex]; + + // Обновляем счетчик слайдов if (currentSlideEl) { 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 = ' Требует обновления'; + } 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 = `${info.symbol} ${info.label}${sizeInfo}`; + } + + qualityStatusEl.innerHTML = qualityHTML; } }); @@ -269,6 +395,32 @@ document.addEventListener('DOMContentLoaded', function() { // Добавляем обработчик клавиш при открытии модального окна photoGalleryModal.addEventListener('shown.bs.modal', function () { document.addEventListener('keydown', handleKeydown); + + // Инициализируем статус качества первого фото + const qualityStatusEl = document.getElementById('galleryQualityStatus'); + if (qualityStatusEl && photos[0]) { + const photoInfo = photos[0]; + let qualityHTML = ''; + + if (photoInfo.quality_warning) { + qualityHTML = ' Требует обновления'; + } 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 = `${info.symbol} ${info.label}${sizeInfo}`; + } + + qualityStatusEl.innerHTML = qualityHTML; + } }); // Удаляем обработчик клавиш при закрытии модального окна diff --git a/myproject/products/templates/products/product_list.html b/myproject/products/templates/products/product_list.html index 5790598..4ce1af1 100644 --- a/myproject/products/templates/products/product_list.html +++ b/myproject/products/templates/products/product_list.html @@ -1,4 +1,5 @@ {% extends 'base.html' %} +{% load quality_tags %} {% block title %}Список товаров{% endblock %} @@ -30,8 +31,11 @@ {% if product.photos.all %} {% with photo=product.photos.first %} - - {{ product.name }} + +
+ {{ product.name }} + {{ photo|quality_icon_only }} +
{% endwith %} {% else %} Нет фото diff --git a/myproject/products/templates/products/productkit_detail.html b/myproject/products/templates/products/productkit_detail.html index 135f83b..9c0e364 100644 --- a/myproject/products/templates/products/productkit_detail.html +++ b/myproject/products/templates/products/productkit_detail.html @@ -1,4 +1,6 @@ {% extends 'base.html' %} +{% load inventory_filters %} +{% load quality_tags %} {% block title %}{{ kit.name }} - Комплект{% endblock %} @@ -50,17 +52,17 @@
Цена:
{% if kit.sale_price %} - {{ kit.calculated_price|floatformat:2 }} ₽ - {{ kit.sale_price|floatformat:2 }} ₽ + {{ kit.calculated_price|floatformat:2 }} руб. + {{ kit.sale_price|floatformat:2 }} руб. Акция {% else %} - {{ kit.actual_price|floatformat:2 }} ₽ + {{ kit.actual_price|floatformat:2 }} руб. {% endif %}
Себестоимость:
- {{ kit.calculate_cost|floatformat:2 }} ₽ + {{ kit.calculate_cost|floatformat:2 }} руб.
Ценообразование:
@@ -70,7 +72,7 @@ {% if kit.price %}
Ручная цена:
-
{{ kit.price }} ₽
+
{{ kit.price }} руб.
{% endif %} {% if kit.markup_percent %} @@ -80,7 +82,7 @@ {% if kit.markup_amount %}
Фиксированная наценка:
-
{{ kit.markup_amount }} ₽
+
{{ kit.markup_amount }} руб.
{% endif %}
Статус:
@@ -155,7 +157,7 @@ Варианты {% endif %} - {{ item.quantity }} + {{ item.quantity|smart_quantity }} {% if item.notes %} {{ item.notes }} @@ -187,14 +189,24 @@
{% for photo in productkit_photos %}
-
+
- {{ kit.name }} + data-bs-target="#photoModal{{ photo.pk }}" + title="Нажмите для увеличения"> + {{ kit.name }} + + + {% quality_indicator photo %} +
{% if photo.order == 0 %} + {% else %} + {% endif %}
diff --git a/myproject/products/templatetags/quality_tags.py b/myproject/products/templatetags/quality_tags.py new file mode 100644 index 0000000..157993b --- /dev/null +++ b/myproject/products/templatetags/quality_tags.py @@ -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( + '' + '⚠️' + '' + ) + + # Символы для разных уровней качества + quality_symbols = { + 'excellent': ('🟢', 'Отлично'), + 'good': ('🟡', 'Хорошо'), + 'acceptable': ('🟠', 'Приемлемо'), + 'poor': ('🔴', 'Плохо'), + 'very_poor': ('🔴', 'Очень плохо'), + } + + symbol, label = quality_symbols.get(quality_level, ('⚪', 'Неизвестно')) + + return format_html( + '' + '{}' + '', + 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( + '' + '⚠️ Требует обновления' + '' + ) + + # Символы и названия + 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( + '{} {}{}', + 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, '⚪') diff --git a/myproject/static/css/quality_indicator.css b/myproject/static/css/quality_indicator.css new file mode 100644 index 0000000..2a6eff6 --- /dev/null +++ b/myproject/static/css/quality_indicator.css @@ -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; + } +} diff --git a/myproject/templates/base.html b/myproject/templates/base.html index 38e23c9..2b2bb5e 100644 --- a/myproject/templates/base.html +++ b/myproject/templates/base.html @@ -11,6 +11,9 @@ + + +