"""
Админка для приложения products.
Все модели tenant-only, поэтому используют TenantAdminOnlyMixin
для скрытия от public admin (localhost/admin/).
"""
from django.contrib import admin
from django.utils.html import format_html
from django.utils import timezone
from django.db.models import Q
import nested_admin
from .models import ProductCategory, ProductTag, Product, ProductKit, KitItem
from .models import ProductPhoto, ProductKitPhoto, ProductCategoryPhoto
from .models import ProductVariantGroup, KitItemPriority, SKUCounter, CostPriceHistory
from .models import ConfigurableProduct, ConfigurableProductOption, ConfigurableProductAttribute
from .models import UnitOfMeasure, ProductSalesUnit
from .admin_displays import (
format_quality_badge,
format_quality_display,
format_photo_quality_column,
format_photo_inline_quality,
format_photo_preview_with_quality,
)
from tenants.admin_mixins import TenantAdminOnlyMixin
class DeletedFilter(admin.SimpleListFilter):
"""Фильтр для отображения удаленных/активных элементов"""
title = 'Статус'
parameter_name = 'status'
def lookups(self, request, model_admin):
return (
('active', 'Активные'),
('archived', 'Архивные'),
('discontinued', 'Снятые'),
)
def queryset(self, request, queryset):
# queryset уже содержит всё благодаря get_queryset()
# Проверяем есть ли поле status или is_deleted на модели
if hasattr(queryset.model, 'status'):
if self.value() == 'active':
return queryset.filter(status='active')
elif self.value() == 'archived':
return queryset.filter(status='archived')
elif self.value() == 'discontinued':
return queryset.filter(status='discontinued')
elif hasattr(queryset.model, 'is_deleted'):
# Для старой системы (Category, Tag)
if self.value() == '0':
return queryset.filter(is_deleted=False)
elif self.value() == '1':
return queryset.filter(is_deleted=True)
return queryset
class QualityLevelFilter(admin.SimpleListFilter):
"""Фильтр для отображения товаров по качеству их фотографий"""
title = 'Качество фото'
parameter_name = 'photo_quality'
def lookups(self, request, model_admin):
return (
('excellent', '🟢 Отлично'),
('good', '🟡 Хорошо'),
('acceptable', '🟠 Приемлемо'),
('poor', '🔴 Плохо'),
('very_poor', '🔴🔴 Очень плохо'),
('warning', '⚠️ Требует обновления'),
('no_warning', '✓ Готово к выгрузке'),
)
def queryset(self, request, queryset):
if self.value() == 'warning':
# Товары, у которых есть фото с quality_warning=True
return queryset.filter(photos__quality_warning=True).distinct()
elif self.value() == 'no_warning':
# Товары, у которых есть фото БЕЗ warning (excellent или good)
return queryset.filter(
photos__quality_level__in=['excellent', 'good']
).distinct()
elif self.value():
# По конкретному уровню качества
return queryset.filter(photos__quality_level=self.value()).distinct()
return queryset
def restore_items(modeladmin, request, queryset):
"""Action для восстановления удаленных элементов"""
if hasattr(queryset.model, 'status'):
# Новая система со статусом
updated = queryset.update(status='active', archived_at=None, archived_by=None)
else:
# Старая система с is_deleted
updated = queryset.update(is_deleted=False, deleted_at=None, deleted_by=None)
modeladmin.message_user(request, f'✓ Восстановлено {updated} элемент(ов).')
restore_items.short_description = '✓ Восстановить выбранные элементы'
def delete_selected(modeladmin, request, queryset):
"""
Переопределенный action удаления который вызывает .delete() на каждый объект
чтобы гарантировать мягкое удаление вместо hard delete
"""
count = 0
for obj in queryset:
obj.delete() # Вызывает delete() метод модели (мягкое удаление)
count += 1
modeladmin.message_user(request, f'✓ Удалено {count} элемент(ов).')
delete_selected.short_description = '🗑️ Удалить выбранные элементы'
def hard_delete_selected(modeladmin, request, queryset):
"""
Action для БЕЗОПАСНОГО полного удаления из БД.
Удаляет объект только если у него нет опасных связей.
БЕЗОПАСНЫЕ связи (удаляются вместе с объектом):
- photos: фотографии объекта (создаются только для этого объекта)
ОПАСНЫЕ связи (блокируют удаление):
- kit_items: товар используется в составе комплекта
- kit_items_direct: товар включен как компонент комплекта
- ManyToMany: товар связан с другими объектами
"""
from django.contrib import messages
items_to_delete = []
items_with_relations = []
# Связи которые БЕЗОПАСНО удалять вместе с объектом (они зависят от объекта)
SAFE_RELATIONS = {'photos'}
for obj in queryset:
# Проверяем есть ли опасные связанные объекты
try:
has_dangerous_relations = False
dangerous_relation_details = []
for relation in obj._meta.get_fields():
# Пропускаем обычные поля
if not hasattr(relation, 'related_model'):
continue
# Для reverse relations получаем count
if hasattr(relation, 'get_accessor_name'):
try:
accessor_name = relation.get_accessor_name()
# Пропускаем безопасные связи (удалятся при cascade delete)
if accessor_name in SAFE_RELATIONS:
continue
related_manager = getattr(obj, accessor_name, None)
if related_manager and hasattr(related_manager, 'count'):
count = related_manager.count()
if count > 0:
has_dangerous_relations = True
relation_name = relation.related_model.__name__
dangerous_relation_details.append(f"{relation_name} ({count})")
except (AttributeError, Exception):
# Пропускаем ошибки доступа к связям
pass
if has_dangerous_relations:
details_str = ', '.join(dangerous_relation_details)
items_with_relations.append(f"{str(obj)} [{details_str}]")
else:
items_to_delete.append(obj)
except Exception as e:
# На случай любой неожиданной ошибки при проверке
items_with_relations.append(f"{str(obj)} (ошибка проверки: {str(e)})")
# Удаляем безопасные элементы
deleted_count = 0
for obj in items_to_delete:
try:
# ВАЖНО: Сначала явно удаляем все фотографии
# Это гарантирует что delete() метод каждого фото вызовется
# и удалит файлы из media/ папки
# Нужно для всех моделей которые имеют 'photos' связь
# Для Product/ProductKit/ProductCategory - есть .photos
if hasattr(obj, 'photos'):
photos = list(obj.photos.all()) # Получаем копию списка перед удалением
for photo in photos:
photo.delete() # Вызывает ProductPhoto.delete() который удалит файлы
# Потом удаляем сам объект
obj.hard_delete()
deleted_count += 1
except Exception as e:
items_with_relations.append(f"{str(obj)} (ошибка удаления: {str(e)})")
# Выводим результаты
if deleted_count > 0:
modeladmin.message_user(
request,
f'✓ Безопасно удалено {deleted_count} элемент(ов) (с фотографиями из media папки).',
messages.SUCCESS
)
if items_with_relations:
error_msg = f'⚠️ Не удалось удалить {len(items_with_relations)} элемент(ов) (имеют зависимые связи):\n' + '\n'.join(f' • {item}' for item in items_with_relations)
modeladmin.message_user(request, error_msg, messages.WARNING)
hard_delete_selected.short_description = '🔴 Безопасно удалить навсегда (только без связей)'
def show_poor_quality_photos(modeladmin, request, queryset):
"""
Action для фильтрации товаров с фото требующими обновления.
Перенаправляет на список с применённым фильтром по качеству.
"""
from django.shortcuts import redirect
from django.urls import reverse
model_name = modeladmin.model._meta.model_name
app_name = modeladmin.model._meta.app_label
# Перенаправляем на список товаров с фильтром по warning фото
url = reverse(f'admin:{app_name}_{model_name}_changelist')
return redirect(f'{url}?photo_quality=warning')
show_poor_quality_photos.short_description = '⚠️ Показать товары с фото требующими обновления'
def show_excellent_quality_photos(modeladmin, request, queryset):
"""
Action для фильтрации товаров с фото отличного и хорошего качества.
Перенаправляет на список с применённым фильтром по качеству.
"""
from django.shortcuts import redirect
from django.urls import reverse
model_name = modeladmin.model._meta.model_name
app_name = modeladmin.model._meta.app_label
# Перенаправляем на список товаров с фильтром по excellent/good
url = reverse(f'admin:{app_name}_{model_name}_changelist')
return redirect(f'{url}?photo_quality=no_warning')
show_excellent_quality_photos.short_description = '✓ Показать товары с хорошим качеством фото'
def show_all_quality_levels(modeladmin, request, queryset):
"""
Action для показа распределения товаров по качеству фотографий.
Выводит статистику в сообщении admin.
"""
from django.contrib import messages
from django.db.models import Count
# Получаем статистику по качеству фотографий
quality_stats = queryset.filter(photos__isnull=False).values(
'photos__quality_level'
).annotate(count=Count('id', distinct=True)).order_by('-count')
warning_count = queryset.filter(photos__quality_warning=True).distinct().count()
total_with_photos = queryset.filter(photos__isnull=False).distinct().count()
if not quality_stats:
modeladmin.message_user(
request,
f'⚠️ В выбранных товарах ({queryset.count()}) нет фотографий.',
messages.WARNING
)
return
# Формируем сообщение со статистикой
quality_names = {
'excellent': '🟢 Отлично',
'good': '🟡 Хорошо',
'acceptable': '🟠 Приемлемо',
'poor': '🔴 Плохо',
'very_poor': '🔴🔴 Очень плохо',
}
stats_text = f'📊 Статистика качества фото в выбранных товарах:\n'
stats_text += f'Всего товаров с фото: {total_with_photos}\n'
stats_text += f'Требуют обновления: {warning_count}\n\n'
for item in quality_stats:
level = item['photos__quality_level']
count = item['count']
label = quality_names.get(level, level)
stats_text += f' {label}: {count} товар(ов)\n'
modeladmin.message_user(request, stats_text, messages.INFO)
show_all_quality_levels.short_description = '📊 Показать статистику качества фото'
def disable_delete_selected(admin_class):
"""
Декоратор для отключения стандартного Django delete_selected action.
Наш custom delete_selected будет использоваться вместо него.
"""
admin_class.actions = [action for action in admin_class.actions
if action.__name__ != 'delete_selected']
return admin_class
@admin.register(ProductVariantGroup)
class ProductVariantGroupAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
list_display = ['name', 'get_products_count', 'created_at']
search_fields = ['name', 'description']
list_filter = ['created_at']
readonly_fields = ['created_at', 'updated_at']
def get_products_count(self, obj):
return obj.products.count()
get_products_count.short_description = 'Товаров'
class ProductCategoryAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
list_display = ('photo_with_quality', 'name', 'sku', 'slug', 'parent', 'is_active', 'get_deleted_status')
list_filter = (DeletedFilter, 'is_active', QualityLevelFilter, 'parent')
prepopulated_fields = {'slug': ('name',)}
search_fields = ('name', 'sku')
readonly_fields = ('photo_preview_large', 'deleted_at', 'deleted_by')
actions = [
restore_items,
delete_selected,
hard_delete_selected,
show_poor_quality_photos,
show_excellent_quality_photos,
show_all_quality_levels,
]
def get_queryset(self, request):
"""Переопределяем queryset для доступа ко всем категориям (включая удаленные)"""
qs = ProductCategory.all_objects.all()
ordering = self.get_ordering(request)
if ordering:
qs = qs.order_by(*ordering)
return qs
def get_deleted_status(self, obj):
"""Показывает статус удаления"""
if obj.is_deleted:
return format_html(
'🗑️ Удалена'
)
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(
'
',
first_photo.image.url
)
return "Нет фото"
photo_preview.short_description = "Фото"
def photo_preview_large(self, obj):
"""Большое превью фото в форме редактирования"""
first_photo = obj.photos.first()
if first_photo and first_photo.image:
return format_html(
'
',
first_photo.image.url
)
return "Нет фото"
photo_preview_large.short_description = "Превью основного фото"
class ProductTagAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
list_display = ('name', 'slug', 'is_active')
list_filter = ('is_active',)
prepopulated_fields = {'slug': ('name',)}
search_fields = ('name',)
class ProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
list_display = ('photo_with_quality', 'name', 'sku', 'get_categories_display', 'cost_price', 'price', 'sale_price', 'get_variant_groups_display', 'get_status_display')
list_filter = (DeletedFilter, QualityLevelFilter, 'categories', 'tags', 'variant_groups')
search_fields = ('name', 'sku', 'description', 'search_keywords')
filter_horizontal = ('categories', 'tags', 'variant_groups')
readonly_fields = ('photo_preview_large', 'archived_at', 'archived_by', 'cost_price', 'cost_price_details_display')
autocomplete_fields = ['base_unit', 'primary_category']
actions = [
restore_items,
delete_selected,
hard_delete_selected,
show_poor_quality_photos,
show_excellent_quality_photos,
show_all_quality_levels,
]
fieldsets = (
('Основная информация', {
'fields': ('name', 'sku', 'variant_suffix', 'description', 'short_description', 'categories', 'primary_category', 'base_unit', 'unit', 'price', 'sale_price')
}),
('Себестоимость', {
'fields': ('cost_price_details_display',),
'description': 'Себестоимость рассчитывается автоматически на основе партий товара (FIFO метод). Редактировать вручную невозможно.'
}),
('Дополнительно', {
'fields': ('tags', 'variant_groups', 'status', 'is_new', 'is_popular', 'is_special')
}),
('Архивирование', {
'fields': ('archived_at', 'archived_by'),
'classes': ('collapse',),
'description': 'Информация об архивировании товара (статус "Архивный" или "Снят").'
}),
('Поиск', {
'fields': ('search_keywords',),
'classes': ('collapse',),
'description': 'Поле для улучшенного поиска. Автоматически генерируется при сохранении, но вы можете добавить дополнительные ключевые слова (синонимы, альтернативные названия и т.д.).'
}),
('Фото', {
'fields': ('photo_preview_large',),
'classes': ('collapse',),
}),
)
def cost_price_details_display(self, obj):
"""
Отображает детали расчета себестоимости товара в админ-панели.
Показывает: текущая себестоимость, количество партий, их цены и даты.
"""
from django.utils.html import format_html
from decimal import Decimal
# Получаем детали стоимости
details = obj.cost_price_details
if not details or not details.get('batches'):
return format_html(
''
'Нет партий
'
'Себестоимость установится при поступлении первой партии товара'
'
'
)
# Текущая себестоимость
current_cost = details.get('cached_cost', Decimal('0'))
calculated_cost = details.get('calculated_cost', Decimal('0'))
total_qty = details.get('total_quantity', Decimal('0'))
is_synced = details.get('is_synced', True)
batches = details.get('batches', [])
# Статус синхронизации
sync_status = '✓ Синхронизирована' if is_synced else '⚠️ Несинхронизирована'
sync_color = '#28a745' if is_synced else '#dc3545'
# HTML для партий
batches_html = 'Партии:'
for batch in batches:
batches_html += (
f'- '
f'{batch.get("warehouse_name", "—")} | '
f'Кол-во: {batch.get("quantity", 0)} | '
f'Цена: {batch.get("cost_price", 0)} руб. | '
f'Дата: {batch.get("created_at", "—")}'
f'
'
)
batches_html += '
'
return format_html(
''
'
Текущая себестоимость: {} руб.
'
'
'
'Статус: {}
'
'Всего в партиях: {} шт.
'
'Рассчитанная: {} руб.'
'
'
'{}'
'
'
'Себестоимость рассчитывается автоматически при поступлении товара (FIFO метод).
'
'Редактировать вручную невозможно.'
'
'
'
',
current_cost,
sync_color,
sync_status,
total_qty,
calculated_cost,
batches_html
)
cost_price_details_display.short_description = 'Себестоимость товара'
def get_queryset(self, request):
"""Переопределяем queryset для доступа ко всем товарам (включая удаленные)"""
qs = Product.all_objects.all()
ordering = self.get_ordering(request)
if ordering:
qs = qs.order_by(*ordering)
return qs
def get_status_display(self, obj):
"""Показывает статус товара"""
status_colors = {
'active': ('green', '✓ Активный'),
'archived': ('orange', '📦 Архивный'),
'discontinued': ('red', '🗑️ Снят'),
}
color, label = status_colors.get(obj.status, ('gray', obj.status))
return format_html(
'{}',
color, label
)
get_status_display.short_description = 'Статус'
def get_categories_display(self, obj):
categories = obj.categories.all()[:3]
if not categories:
return "-"
result = ", ".join([cat.name for cat in categories])
if obj.categories.count() > 3:
result += f" (+{obj.categories.count() - 3})"
return result
get_categories_display.short_description = 'Категории'
def get_variant_groups_display(self, obj):
groups = obj.variant_groups.all()[:3]
if not groups:
return "-"
result = ", ".join([g.name for g in groups])
if obj.variant_groups.count() > 3:
result += f" (+{obj.variant_groups.count() - 3})"
return result
get_variant_groups_display.short_description = 'Группы вариантов'
def photo_with_quality(self, obj):
"""Превью фото с индикатором качества в списке товаров"""
first_photo = obj.photos.first()
if not first_photo or not first_photo.image:
return format_html('Нет фото')
# Показываем фото с индикатором качества справа
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(
'
',
first_photo.image.url
)
return "Нет фото"
photo_preview.short_description = "Фото"
def photo_preview_large(self, obj):
"""Большое превью фото в форме редактирования"""
first_photo = obj.photos.first()
if first_photo and first_photo.image:
return format_html(
'
',
first_photo.image.url
)
return "Нет фото"
photo_preview_large.short_description = "Превью основного фото"
class ProductKitAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
list_display = ('photo_with_quality', 'name', 'slug', 'get_categories_display', 'get_price_display', 'is_temporary', 'get_order_link', 'get_status_display')
list_filter = (DeletedFilter, 'is_temporary', QualityLevelFilter, 'categories', 'tags')
prepopulated_fields = {'slug': ('name',)}
filter_horizontal = ('categories', 'tags')
readonly_fields = ('photo_preview_large', 'base_price', 'archived_at', 'archived_by', 'order')
autocomplete_fields = ['primary_category']
actions = [
show_poor_quality_photos,
show_excellent_quality_photos,
show_all_quality_levels,
]
fieldsets = (
('Основная информация', {
'fields': ('name', 'sku', 'slug', 'description', 'short_description', 'categories', 'primary_category')
}),
('Ценообразование', {
'fields': ('base_price', 'price', 'sale_price', 'price_adjustment_type', 'price_adjustment_value'),
'description': 'base_price - сумма цен компонентов (вычисляется автоматически). price - итоговая цена с учетом корректировок. sale_price - цена со скидкой (опционально).'
}),
('Временный комплект', {
'fields': ('is_temporary', 'order'),
'description': 'Временные комплекты создаются для конкретных заказов и не показываются в каталоге.'
}),
('Дополнительно', {
'fields': ('tags', 'status', 'is_new', 'is_popular', 'is_special')
}),
('Архивирование', {
'fields': ('archived_at', 'archived_by'),
'classes': ('collapse',),
'description': 'Информация об архивировании комплекта (статус "Архивный" или "Снят").'
}),
('Фото', {
'fields': ('photo_preview_large',),
'classes': ('collapse',),
}),
)
def get_price_display(self, obj):
"""Отображение финальной цены комплекта"""
try:
return f"{obj.actual_price} руб."
except Exception:
return "-"
get_price_display.short_description = "Цена"
def get_order_link(self, obj):
"""Отображение ссылки на заказ для временных комплектов"""
if obj.order:
from django.urls import reverse
url = reverse('admin:orders_order_change', args=[obj.order.pk])
return format_html('{}', url, obj.order.order_number)
return '-'
get_order_link.short_description = "Заказ"
def get_queryset(self, request):
"""Переопределяем queryset для доступа ко всем комплектам"""
qs = super().get_queryset(request)
ordering = self.get_ordering(request)
if ordering:
qs = qs.order_by(*ordering)
return qs
def get_status_display(self, obj):
"""Показывает статус комплекта"""
status_colors = {
'active': ('green', '✓ Активный'),
'archived': ('orange', '📦 Архивный'),
'discontinued': ('red', '🗑️ Снят'),
}
color, label = status_colors.get(obj.status, ('gray', obj.status))
return format_html(
'{}',
color, label
)
get_status_display.short_description = 'Статус'
def get_categories_display(self, obj):
categories = obj.categories.all()[:3]
if not categories:
return "-"
result = ", ".join([cat.name for cat in categories])
if obj.categories.count() > 3:
result += f" (+{obj.categories.count() - 3})"
return result
get_categories_display.short_description = 'Категории'
def photo_with_quality(self, obj):
"""Превью фото с индикатором качества в списке комплектов"""
first_photo = obj.photos.first()
if not first_photo or not first_photo.image:
return format_html('Нет фото')
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(
'
',
first_photo.image.url
)
return "Нет фото"
photo_preview.short_description = "Фото"
def photo_preview_large(self, obj):
"""Большое превью фото в форме редактирования"""
first_photo = obj.photos.first()
if first_photo and first_photo.image:
return format_html(
'
',
first_photo.image.url
)
return "Нет фото"
photo_preview_large.short_description = "Превью основного фото"
class KitItemPriorityInline(nested_admin.NestedTabularInline):
model = KitItemPriority
extra = 0 # Не показывать пустые формы
fields = ['product', 'priority']
autocomplete_fields = ['product']
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.select_related('product')
def formfield_for_foreignkey(self, db_field, request, **kwargs):
"""Показывать только товары из выбранной группы вариантов"""
if db_field.name == "product":
# Получаем kit_item из родительского объекта через request
# Это будет работать автоматически с nested_admin
pass
return super().formfield_for_foreignkey(db_field, request, **kwargs)
class KitItemInline(nested_admin.NestedStackedInline):
model = KitItem
extra = 0 # Не показывать пустые формы
fields = ['product', 'variant_group', 'quantity']
autocomplete_fields = ['product']
inlines = [KitItemPriorityInline]
class Media:
css = {
'all': ('admin/css/custom_nested.css',)
}
class ProductPhotoInline(admin.TabularInline):
model = ProductPhoto
extra = 1
readonly_fields = ('image_preview', 'quality_display')
fields = ('image', 'image_preview', 'quality_display', 'order')
def image_preview(self, obj):
"""Превью основного фото (большой размер 800×800)"""
if obj.image:
return format_html(
'
',
obj.get_large_url()
)
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', 'quality_display')
fields = ('image', 'image_preview', 'quality_display', 'order')
def image_preview(self, obj):
"""Превью основного фото (большой размер 800×800)"""
if obj.image:
return format_html(
'
',
obj.get_large_url()
)
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', 'quality_display')
fields = ('image', 'image_preview', 'quality_display', 'order')
def image_preview(self, obj):
"""Превью основного фото (большой размер 800×800)"""
if obj.image:
return format_html(
'
',
obj.get_large_url()
)
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 ProductSalesUnitInline(admin.TabularInline):
"""Инлайн для единиц продажи товара"""
model = ProductSalesUnit
extra = 0
fields = (
'unit', 'name', 'conversion_factor', 'price', 'sale_price',
'min_quantity', 'quantity_step', 'is_default', 'is_active'
)
autocomplete_fields = ['unit']
verbose_name = "Единица продажи"
verbose_name_plural = "Единицы продажи"
class ProductKitAdminWithItems(ProductKitAdmin):
inlines = [KitItemInline]
# Update admin classes to include photo inlines
class ProductAdminWithPhotos(ProductAdmin):
inlines = [ProductPhotoInline, ProductSalesUnitInline]
class ProductKitAdminWithItemsAndPhotos(nested_admin.NestedModelAdmin, ProductKitAdmin):
inlines = [KitItemInline, ProductKitPhotoInline]
class ProductCategoryAdminWithPhotos(ProductCategoryAdmin):
inlines = [ProductCategoryPhotoInline]
@admin.register(KitItem)
class KitItemAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
list_display = ['__str__', 'kit', 'get_type', 'quantity', 'has_priorities']
list_filter = ['kit']
list_select_related = ['kit', 'product', 'variant_group']
inlines = [KitItemPriorityInline]
fields = ['kit', 'product', 'variant_group', 'quantity']
def get_type(self, obj):
if obj.variant_group:
return format_html('Группа: {}', obj.variant_group.name)
return f"Товар: {obj.product.name if obj.product else '-'}"
get_type.short_description = 'Тип'
def has_priorities(self, obj):
return obj.priorities.exists()
has_priorities.boolean = True
has_priorities.short_description = 'Приоритеты настроены'
@admin.register(SKUCounter)
class SKUCounterAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
list_display = ['counter_type', 'current_value', 'get_next_preview']
list_filter = ['counter_type']
readonly_fields = ['get_next_preview']
def get_next_preview(self, obj):
"""Показывает, каким будет следующий артикул"""
next_val = obj.current_value + 1
if obj.counter_type == 'product':
return format_html('PROD-{:06d}', next_val)
elif obj.counter_type == 'kit':
return format_html('KIT-{:06d}', next_val)
elif obj.counter_type == 'category':
return format_html('CAT-{:04d}', next_val)
return str(next_val)
get_next_preview.short_description = 'Следующий артикул'
def has_delete_permission(self, request, obj=None):
# Запрещаем удаление счетчиков
return False
@admin.register(CostPriceHistory)
class CostPriceHistoryAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
list_display = ['product', 'get_price_change', 'reason', 'created_at']
list_filter = ['reason', 'created_at', 'product']
search_fields = ['product__name', 'product__sku', 'notes']
readonly_fields = ['product', 'old_cost_price', 'new_cost_price', 'reason', 'related_object_id', 'related_object_type', 'notes', 'created_at']
date_hierarchy = 'created_at'
fieldsets = (
('Информация о изменении', {
'fields': ('product', 'reason', 'created_at')
}),
('Себестоимость', {
'fields': ('old_cost_price', 'new_cost_price', 'get_price_change')
}),
('Связанные объекты', {
'fields': ('related_object_type', 'related_object_id'),
'classes': ('collapse',),
}),
('Примечания', {
'fields': ('notes',),
'classes': ('collapse',),
}),
)
def get_price_change(self, obj):
"""Показывает изменение цены в красивом формате"""
change = obj.new_cost_price - obj.old_cost_price
change_percent = (change / obj.old_cost_price * 100) if obj.old_cost_price != 0 else 0
if change > 0:
color = '#dc3545' # red
symbol = '↑'
elif change < 0:
color = '#28a745' # green
symbol = '↓'
else:
color = '#6c757d' # gray
symbol = '='
return format_html(
'{} {} {:.2f} руб. ({:+.2f}%)',
color,
symbol,
obj.old_cost_price,
abs(change),
change_percent
)
get_price_change.short_description = 'Изменение'
def has_delete_permission(self, request, obj=None):
# История не должна удаляться вручную
return False
def has_add_permission(self, request):
# История создается только автоматически через сигналы
return False
# === Админка для единиц измерения ===
@admin.register(UnitOfMeasure)
class UnitOfMeasureAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
"""Админка для справочника единиц измерения"""
list_display = ('code', 'name', 'short_name', 'position', 'is_active')
list_filter = ('is_active',)
search_fields = ('code', 'name', 'short_name')
list_editable = ('position', 'is_active')
ordering = ('position', 'code')
fieldsets = (
('Основная информация', {
'fields': ('code', 'name', 'short_name')
}),
('Настройки', {
'fields': ('position', 'is_active')
}),
)
@admin.register(ProductSalesUnit)
class ProductSalesUnitAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
"""Админка для единиц продажи товаров"""
list_display = (
'product', 'name', 'unit', 'conversion_factor',
'get_price_display', 'min_quantity', 'is_default', 'is_active'
)
list_filter = ('is_active', 'is_default', 'unit')
search_fields = ('product__name', 'product__sku', 'name')
autocomplete_fields = ['product', 'unit']
list_editable = ('is_default', 'is_active')
ordering = ('product', 'position')
fieldsets = (
('Товар и единица', {
'fields': ('product', 'unit', 'name')
}),
('Конверсия', {
'fields': ('conversion_factor',),
'description': 'Сколько единиц продажи получается из 1 базовой единицы товара. '
'Например: 1 банч = 15 веток → conversion_factor = 15'
}),
('Ценообразование', {
'fields': ('price', 'sale_price'),
'description': 'Цена за единицу продажи. sale_price - цена со скидкой (опционально).'
}),
('Ограничения', {
'fields': ('min_quantity', 'quantity_step'),
'description': 'min_quantity - минимальное количество для заказа. '
'quantity_step - шаг изменения количества.'
}),
('Настройки', {
'fields': ('position', 'is_default', 'is_active')
}),
)
def get_price_display(self, obj):
"""Отображение цены с учетом скидки"""
if obj.sale_price:
return format_html(
'{} '
'{}',
obj.price, obj.sale_price
)
return obj.price
get_price_display.short_description = 'Цена'
admin.site.register(ProductCategory, ProductCategoryAdminWithPhotos)
admin.site.register(ProductTag, ProductTagAdmin)
admin.site.register(Product, ProductAdminWithPhotos)
admin.site.register(ProductKit, ProductKitAdminWithItemsAndPhotos)
# === Админка для ConfigurableProduct ===
class ConfigurableProductOptionInline(admin.TabularInline):
"""Инлайн для вариантов вариативного товара"""
model = ConfigurableProductOption
extra = 0
fields = ('kit', 'product', 'variant_sku', 'is_default', 'attributes')
readonly_fields = ('variant_sku',)
verbose_name = "Вариант"
verbose_name_plural = "Варианты"
class ConfigurableProductAttributeInline(admin.TabularInline):
"""Инлайн для атрибутов родительского товара"""
model = ConfigurableProductAttribute
extra = 0
fields = ('name', 'option', 'kit', 'product', 'position', 'visible')
verbose_name = "Атрибут"
verbose_name_plural = "Атрибуты"
@admin.register(ConfigurableProduct)
class ConfigurableProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
"""Админка для вариативных товаров"""
list_display = ('name', 'sku', 'status', 'get_options_count', 'created_at')
list_filter = ('status', 'created_at')
search_fields = ('name', 'sku', 'description')
readonly_fields = ('created_at', 'updated_at', 'slug')
inlines = [ConfigurableProductOptionInline, ConfigurableProductAttributeInline]
autocomplete_fields = ['primary_category']
fieldsets = (
('Основная информация', {
'fields': ('name', 'sku', 'slug', 'status', 'primary_category')
}),
('Описание', {
'fields': ('short_description', 'description')
}),
('Служебная информация', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
def get_options_count(self, obj):
"""Количество вариантов"""
count = obj.options.count()
return format_html(
'{}',
'#28a745' if count > 0 else '#6c757d',
count
)
get_options_count.short_description = 'Вариантов'