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 %}
+
+ | {{ batch.warehouse_name }} |
+ {{ batch.quantity }} |
+ {{ batch.cost_price }} руб. |
+ {{ batch.total_value }} руб. |
+ {{ batch.created_at|date:"d.m.Y H:i" }} |
+
+ {% endfor %}
+
+
+
+ | Итого: |
+ {{ 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 %}
-
-
+
+
+ 
+ {{ 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 %}
-
+
- 
+ data-bs-target="#photoModal{{ photo.pk }}"
+ title="Нажмите для увеличения">
+ 
+
+
+ {% 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 @@
+
+
+
|