992 lines
49 KiB
Python
992 lines
49 KiB
Python
# Кастомная конфигурация Django Admin для LPON сайта.
|
||
# Регистрируем модели с удобным интерфейсом.
|
||
|
||
from typing import Any
|
||
from django import forms
|
||
from django.forms import Textarea
|
||
from django.http import HttpRequest
|
||
from django.contrib import admin
|
||
from django.utils.html import format_html, mark_safe
|
||
from easy_thumbnails.files import get_thumbnailer
|
||
from .models import (
|
||
TbImageMetadata, TbArticle, TbArtist, TbItem, TbLabel, TbSeller,
|
||
TbOffer, TbSource, TbOfferHistory, TbMusicStyle
|
||
)
|
||
from .utils_validators import validate_entity_for_admin_form, generate_admin_save_message
|
||
|
||
|
||
# ============================================================================
|
||
# ЛОКАЛЬНЫЕ ХЕЛПЕРЫ ДЛЯ АДМИНКИ
|
||
# ============================================================================
|
||
|
||
def get_related_article_description(model_class):
|
||
"""
|
||
Генерирует HTML-описание для fieldset'а 'Связанная публикация'.
|
||
|
||
Описание объясняет роль связанной статьи и предлагает создавать её вручную.
|
||
Использует verbose_name из модели для универсальности.
|
||
|
||
Args:
|
||
model_class: Класс модели (TbLabel, TbArtist, TbMusicStyle и т.д.)
|
||
|
||
Returns:
|
||
str: HTML-описание для fieldset'а
|
||
|
||
Пример:
|
||
'description': get_related_article_description(TbLabel)
|
||
"""
|
||
# Получаем наименование модели в родительном падеже (для описания)
|
||
verbose_name = model_class._meta.verbose_name.upper()
|
||
|
||
# Получаем наименование во множественном числе (для списков альбомов)
|
||
verbose_name_plural = model_class._meta.verbose_name_plural.upper()
|
||
|
||
return (f'Прикреп­ленная статья (если есть) будет отображаться на странице <u>{verbose_name}</u>'
|
||
f' на сайте. Также позволяет получать список всех альбомов <u>{verbose_name_plural}</u>,'
|
||
f' управлять SEO-атрибутами для улучшения видимости поисковых систем, иметь красивый'
|
||
f' slag для URL-странички, подсчитывать число просмотров и добавлений'
|
||
f' в избранные. <b style=\'color: green;\'>ОЧЕНЬ РЕКОМЕН­ДУЕТСЯ СОЗДАВАТЬ'
|
||
f' И ПРИВЯЗЫВАТЬ СТАТЬЮ ВРУЧНУЮ</b>. Если публикация не создана вручную,'
|
||
f' то она будет создана автоматически (пустая) при сохранении <u>{verbose_name}</u>,'
|
||
f' со всеми SEO-атрибутами и slag, но автоматика несовершенна.<br /> ')
|
||
|
||
|
||
# ============================================================================
|
||
# МИКСИНЫ ДЛЯ АДМИНКИ
|
||
# ============================================================================
|
||
class RequestInFormMixin(admin.ModelAdmin):
|
||
"""
|
||
Миксин для передачи request объекта в форму.
|
||
|
||
Используется когда форма нуждается в доступе к request для проверки POST параметров
|
||
или другой информации о текущем HTTP-запросе.
|
||
|
||
Переопределяет get_form() и передает request в __init__ формы через kwargs.
|
||
"""
|
||
|
||
def get_form(self, request, obj=None, **kwargs):
|
||
"""
|
||
Переопределяем get_form чтобы передать request в форму.
|
||
Создаем оборачивающий класс который передаст request в __init__.
|
||
"""
|
||
FormClass = super().get_form(request, obj, **kwargs)
|
||
|
||
# Сохраняем request в замыкании для доступа в классе
|
||
request_ref = request
|
||
|
||
class FormWithRequest(FormClass):
|
||
"""Оборачивающий класс который передает request при инстанцировании"""
|
||
def __init__(form_instance, *args, **init_kwargs):
|
||
# Добавляем request в kwargs перед вызовом __init__ родителя
|
||
init_kwargs['request'] = request_ref
|
||
super().__init__(*args, **init_kwargs)
|
||
|
||
return FormWithRequest
|
||
|
||
|
||
class CodeMirrorFormMixin(forms.ModelForm):
|
||
"""
|
||
Миксин для форм с поддержкой CodeMirror редактора.
|
||
|
||
Предоставляет:
|
||
- Готовый Media класс с CSS и JS для CodeMirror
|
||
- Helper метод setup_codemirror_field() для конфигурации полей
|
||
- Базовые атрибуты для активации CodeMirror
|
||
|
||
Использование:
|
||
class MyForm(CodeMirrorFormMixin):
|
||
class Meta:
|
||
model = MyModel
|
||
fields = (...)
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
super().__init__(*args, **kwargs)
|
||
self.setup_codemirror_field('field_name', 'json', 'codemirror-width-l')
|
||
"""
|
||
|
||
# ===== MEDIA КЛАСС ДЛЯ CODEMIRROR =====
|
||
class Media:
|
||
"""Подключаем CSS и JS для CodeMirror редактора"""
|
||
css = {
|
||
'all': ('codemirror/codemirror-styles.css',) # Стили для CodeMirror
|
||
}
|
||
js = (
|
||
'codemirror/editor.js', # Основной CodeMirror
|
||
'codemirror/codemirror-patch.js', # Патч для управления высотой/шириной
|
||
)
|
||
|
||
# ===== БАЗОВЫЕ АТРИБУТЫ CODEMIRROR =====
|
||
CODEMIRROR_ATTRS_BASE = {
|
||
'data-codemirror-editor': '1',
|
||
'data-width': '100%', # Ширина для патча (100% займет полную ширину)
|
||
}
|
||
|
||
def setup_codemirror_field(self, field_name: str, language: str = 'text', css_class: str = 'codemirror-width-l'):
|
||
"""
|
||
Конфигурирует поле для использования CodeMirror редактором.
|
||
|
||
Применяет Textarea виджет с нужными атрибутами и CSS классами для CodeMirror.
|
||
|
||
Args:
|
||
field_name: Имя поля в форме (например, 's_label')
|
||
language: Язык для подсветки синтаксиса (text, json, html, url и т.д.)
|
||
css_class: CSS класс для управления размерами (codemirror-width-s, codemirror-width-l и т.д.)
|
||
|
||
Пример:
|
||
def __init__(self, *args, **kwargs):
|
||
super().__init__(*args, **kwargs)
|
||
self.setup_codemirror_field('s_label', 'text', 'codemirror-width-xl codemirror-no-lines')
|
||
self.setup_codemirror_field('j_metadata', 'json', 'codemirror-width-l')
|
||
"""
|
||
# Собираем атрибуты для поля
|
||
attrs = {
|
||
**self.CODEMIRROR_ATTRS_BASE,
|
||
'data-language': language,
|
||
'class': css_class,
|
||
}
|
||
|
||
# Применяем Textarea виджет с атрибутами
|
||
self.fields[field_name].widget = Textarea(attrs=attrs)
|
||
|
||
|
||
# ============================================================================
|
||
# АДМИНИСТРИРОВАНИЕ TbImageMetadata
|
||
#
|
||
# Кастомная форма для админки TbImageMetadata
|
||
class TbImageMetadataAdminForm(CodeMirrorFormMixin):
|
||
"""
|
||
Кастомная форма для TbImageMetadata в админке.
|
||
Добавляет виртуальные поля для редактирования метаданных filer_image
|
||
(default_alt_text и default_caption), которые не хранятся в TbImageMetadata, но есть в filer_image.
|
||
|
||
Наследует от CodeMirrorFormMixin для поддержки редактора CodeMirror.
|
||
"""
|
||
# Виртуальные поля для заполнения метаданных filer_image
|
||
alt_text = forms.CharField(
|
||
max_length=255,
|
||
required=False,
|
||
widget=forms.TextInput(attrs={
|
||
'placeholder': 'Введите alt-текст для картинки',
|
||
}),
|
||
label='ALT (новый)',
|
||
help_text='Текст для alt-атрибута картинки <tt><img alt="" .../></tt>.'
|
||
' Будет сохранён в filer_image.default_alt_text'
|
||
)
|
||
title_text = forms.CharField(
|
||
max_length=255,
|
||
required=False,
|
||
widget=forms.TextInput(attrs={
|
||
'placeholder': 'Введите title-описание картинки',
|
||
}),
|
||
label='TITLE (новый)',
|
||
help_text='Текст для title-атрибута картинки <tt><img title="" .../></tt>.'
|
||
' Будет сохранён в filer_image.default_caption'
|
||
)
|
||
copyright_text = forms.CharField(
|
||
max_length=255,
|
||
required=False,
|
||
widget=forms.TextInput(attrs={
|
||
'placeholder': 'XXXX, Авторские права на изображение',
|
||
}),
|
||
label='Copyright',
|
||
help_text='Авторские права на изображение (например: <tt>2025, Sergei Erjemin</tt>. Будет сохранён в filer_image.author'
|
||
)
|
||
|
||
class Meta:
|
||
model = TbImageMetadata
|
||
fields = ('image', 'i_img_sort', 'j_img_metadata')
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
"""
|
||
При инициализации формы подгружаем текущие значения alt/caption из filer_image.
|
||
Настраиваем поля для работы с CodeMirror редактором.
|
||
"""
|
||
super().__init__(*args, **kwargs)
|
||
|
||
# Если редактируем существующую запись, получаем текущие значения из filer
|
||
if self.instance and self.instance.pk and hasattr(self.instance, 'image') and self.instance.image:
|
||
try:
|
||
# Получаем связанные данные из filer_image
|
||
filer_image = self.instance.image
|
||
|
||
# Устанавливаем значения из filer в виртуальные поля
|
||
self.fields['alt_text'].initial = filer_image.default_alt_text or ''
|
||
self.fields['title_text'].initial = filer_image.default_caption or ''
|
||
self.fields['copyright_text'].initial = filer_image.author or ''
|
||
|
||
except Exception:
|
||
# Если ошибка при получении filer_image, просто оставляем пустые значения
|
||
pass
|
||
|
||
# Активируем CodeMirror для виртуальных полей
|
||
self.setup_codemirror_field('alt_text', language='text',
|
||
css_class='codemirror-width-m codemirror-no-lines')
|
||
self.setup_codemirror_field('title_text', language='text',
|
||
css_class='codemirror-width-l codemirror-no-lines')
|
||
self.setup_codemirror_field('copyright_text', language='text',
|
||
css_class='codemirror-width-m codemirror-no-lines')
|
||
|
||
# Активируем CodeMirror для реальных полей
|
||
self.setup_codemirror_field('i_img_sort', language='text',
|
||
css_class='codemirror-width-s codemirror-no-lines')
|
||
self.setup_codemirror_field('j_img_metadata', language='json',
|
||
css_class='codemirror-width-xl')
|
||
|
||
|
||
# Админка для TbImageMetadata с кастомной формой
|
||
class ImageMetadataAdmin(admin.ModelAdmin):
|
||
"""
|
||
Админ для метаданных изображений TbImageMetadata с поддержкой редактирования метаданных filer_image.
|
||
|
||
Позволяет пользователю заполнить default_alt_text и default_caption для картинки в filer
|
||
прямо в админке TbImageMetadata, без необходимости отдельного редактирования filer.
|
||
|
||
Использует TbImageMetadataAdminForm с наследованием от CodeMirrorFormMixin для поддержки CodeMirror.
|
||
"""
|
||
form = TbImageMetadataAdminForm # Используем кастомную форму с виртуальными полями (Media подключится через миксин)
|
||
|
||
list_display = ('id', 'image_thumbnail', 'image', '_display_alt_text', 'i_img_sort')
|
||
list_display_links = ('id', 'image_thumbnail', 'image')
|
||
list_filter = ('i_img_sort',)
|
||
ordering = ('image', 'i_img_sort')
|
||
readonly_fields = ('_display_alt_text', '_display_title_text')
|
||
|
||
fieldsets = (
|
||
('Изображение', {
|
||
'fields': ('image', 'i_img_sort'),
|
||
'description': 'Файл изображения и порядок его отображения (i_img_sort)',
|
||
}),
|
||
('Метаданные (JSON)', {
|
||
'fields': ('j_img_metadata',),
|
||
'description': 'Гибкие дополнительные данные в JSON формате (источник, тип, достоверность и т.д.). '
|
||
'Пример: {"source": "discogs", "reality": "abstract", "confidence": 0.95}',
|
||
}),
|
||
('Метаданные filer (SEO для картинок)', {
|
||
'fields': ('_display_alt_text', '_display_title_text', 'alt_text', 'title_text',
|
||
'copyright_text'),
|
||
'description': 'Редактируемые поля для заполнения ALT-, TITLE- и ©-текста в filer. Если не заполнить,'
|
||
' текущие значения останутся без изменений (и не будут заполнены при создании).',
|
||
# 'classes': ('collapse',),
|
||
}),
|
||
)
|
||
|
||
def image_thumbnail(self, obj):
|
||
"""
|
||
Отображает миниатюру картинки (40x40) в списке и в list_display_links.
|
||
Использует easy_thumbnails для автоматического создания и кэширования миниатюр.
|
||
"""
|
||
if obj and obj.image:
|
||
try:
|
||
# Получаем thumbnailer для картинки через easy_thumbnails
|
||
thumbnailer = get_thumbnailer(obj.image.file)
|
||
|
||
# Генерируем или получаем уже созданную миниатюру размером 40x40
|
||
thumbnail = thumbnailer.get_thumbnail({
|
||
'size': (40, 40), # Размер миниатюры: 40x40 пикселей
|
||
'crop': 'smart', # Умное обрезание для сохранения центра картинки
|
||
'quality': 95, # Качество JPEG/WebP (95% для хорошего вида)
|
||
})
|
||
|
||
# Возвращаем HTML тег img с миниатюрой
|
||
# format_html автоматически экранирует опасные символы
|
||
return format_html(
|
||
'<img src="{}" width="40" height="40" alt="{}" '
|
||
'style="border-radius: 4px; object-fit: cover; cursor: pointer;"/>',
|
||
thumbnail.url,
|
||
obj.image.name or 'картинка' # Alt текст для доступности
|
||
)
|
||
except Exception as e:
|
||
# Если ошибка при генерации миниатюры (нет файла, ошибка формата и т.д.)
|
||
return mark_safe('<span style="color: #ccc;">(ошибка)</span>')
|
||
|
||
# Если картинка не привязана
|
||
return mark_safe('<span style="color: #ccc;">(нет картинки)</span>')
|
||
|
||
# Установляем название столбца в админке
|
||
image_thumbnail.short_description = 'Миниатюра (40x40)'
|
||
|
||
def _display_alt_text(self, obj):
|
||
"""
|
||
Display-метод для отображения текущего alt-текста в filer (read-only).
|
||
Показывает, какой текст сейчас установлен в filer_image.
|
||
"""
|
||
if obj and obj.pk and obj.image:
|
||
try:
|
||
current = obj.image.default_alt_text or '(пусто)'
|
||
return f'ALT: {current}'
|
||
except Exception:
|
||
return '(ошибка при получении)'
|
||
return '(новая запись, значение будет установлено после сохранения)'
|
||
|
||
_display_alt_text.short_description = 'ALT из filer'
|
||
|
||
def _display_title_text(self, obj):
|
||
"""
|
||
Display-метод для отображения текущего caption в filer (read-only).
|
||
Показывает, какой текст сейчас установлен в filer_image.
|
||
"""
|
||
if obj and obj.pk and obj.image:
|
||
try:
|
||
current = obj.image.default_caption or '(пусто)'
|
||
return f'TITLE: {current}'
|
||
except Exception:
|
||
return '(ошибка при получении)'
|
||
return '(новая запись, значение будет установлено после сохранения)'
|
||
|
||
_display_title_text.short_description = 'TITLE из filer'
|
||
|
||
def save_model(
|
||
self,
|
||
request: HttpRequest,
|
||
obj: TbImageMetadata,
|
||
form: forms.ModelForm,
|
||
change: bool,
|
||
) -> None:
|
||
"""
|
||
Переопределяем save_model для обновления метаданных filer_image.
|
||
|
||
Если пользователь заполнил виртуальные поля alt_text или title_text,
|
||
их значения сохраняются в соответствующие поля filer_image.
|
||
Если поля не заполнены, текущие значения в filer остаются без изменений.
|
||
"""
|
||
# Сначала сохраняем саму запись TbImageMetadata
|
||
super().save_model(request, obj, form, change)
|
||
|
||
# Работаем с meta-данными filer только если картинка привязана
|
||
if obj.image:
|
||
try:
|
||
filer_image = obj.image
|
||
|
||
# Обновляем alt_text (ALT), если было заполнено в форме
|
||
alt_text = form.cleaned_data.get('alt_text', '').strip()
|
||
if alt_text: # Если пользователь что-то ввел
|
||
filer_image.default_alt_text = alt_text
|
||
|
||
# Обновляем caption (TITLE), если было заполнено в форме
|
||
caption = form.cleaned_data.get('title_text', '').strip()
|
||
if caption: # Если пользователь что-то ввел
|
||
filer_image.default_caption = caption
|
||
|
||
# Обновляем author (copyrughight), если было заполнено в форме
|
||
author = form.cleaned_data.get('copyright_text', '').strip()
|
||
if author:
|
||
filer_image.author = author
|
||
|
||
# Сохраняем filer_image с новыми метаданными
|
||
filer_image.save()
|
||
|
||
except Exception as e:
|
||
# Логируем ошибку, но не прерываем процесс сохранения
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
logger.error(f'Ошибка при сохранении метаданных filer_image: {e}')
|
||
|
||
|
||
# ============================================================================
|
||
# АДМИНКА для музыкальных стилей, таблица TbMusicStyle
|
||
#
|
||
# Кастомная форма для MusicStyleAdmin
|
||
class MusicStyleAdminForm(CodeMirrorFormMixin):
|
||
"""
|
||
Кастомная форма для админки музыкальных стилей.
|
||
Добавляет виджеты CodeMirror для текстовых полей
|
||
"""
|
||
class Meta:
|
||
model = TbMusicStyle
|
||
fields = ('s_style_name', 'j_style_metadata', 'k_style_to_article',)
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
"""
|
||
При инициализации формы подгружаем CodeMirror редактор
|
||
"""
|
||
# Извлекаем request из kwargs если он есть
|
||
self.request = kwargs.pop('request', None)
|
||
|
||
super().__init__(*args, **kwargs)
|
||
|
||
# Конфигурируем поля для CodeMirror
|
||
self.setup_codemirror_field('s_style_name', language='text',
|
||
css_class='codemirror-width-xl codemirror-no-lines')
|
||
self.setup_codemirror_field('j_style_metadata', language='json',
|
||
css_class='codemirror-width-l codemirror-min-height-5')
|
||
|
||
def clean(self):
|
||
"""
|
||
Валидируем форму: проверяем на совпадения (дубликаты) основного поля s_style_name.
|
||
Используем GET параметр ignore_validate для пропуска валидации при переотправке.
|
||
"""
|
||
# Получаем очищенные данные формы (может быть None, но обычно это dict)
|
||
cleaned_data: dict[str, Any] | None = super().clean()
|
||
|
||
# Если clean() вернул None, возвращаем пустой dict (для совместимости)
|
||
if cleaned_data is None:
|
||
cleaned_data = {}
|
||
|
||
# После проверки выше, очищены данные гарантированно dict[str, Any]
|
||
assert isinstance(cleaned_data, dict), "cleaned_data должен быть словарём"
|
||
|
||
# Используем универсальный хелпер для проверки дубликатов
|
||
# Модель берется автоматически из self.Meta.model
|
||
# Передаем request для проверки GET параметра ignore_validate
|
||
validate_entity_for_admin_form(
|
||
self,
|
||
cleaned_data,
|
||
main_field_name='s_style_name',
|
||
metadata_field_name='j_style_metadata',
|
||
request=self.request,
|
||
)
|
||
|
||
return cleaned_data
|
||
|
||
# Админка для TbMusicStyle с кастомной формой MusicStyleAdminForm
|
||
class MusicStyleAdmin(RequestInFormMixin, admin.ModelAdmin):
|
||
"""Админ для музыкальных стилей"""
|
||
form = MusicStyleAdminForm
|
||
|
||
# Media наследуется автоматически из CodeMirrorFormMixin
|
||
# ...(no custom Media needed)
|
||
|
||
list_display = ('id', 's_style_name', 'j_style_metadata', 't_style_created', 't_style_updated',)
|
||
list_display_links = ('id', 's_style_name',)
|
||
search_fields = ('s_style_name', 'j_style_metadata',)
|
||
readonly_fields = ('t_style_created', 't_style_updated',)
|
||
|
||
fieldsets = (
|
||
('Основные данные о музыкальном стиле', {
|
||
'fields': ('s_style_name', 'j_style_metadata',),
|
||
}),
|
||
('Связанная публикация', {
|
||
'fields': ('k_style_to_article',),
|
||
'description': get_related_article_description(TbMusicStyle),
|
||
# 'classes': ('collapse',),
|
||
}),
|
||
('Служебная информация', {
|
||
'fields': ('t_style_created', 't_style_updated'),
|
||
'classes': ('collapse',),
|
||
}),
|
||
)
|
||
|
||
# Обогащение Media из формы (CodeMirrorFormMixin)
|
||
# Используем наследование для расширения, а не перекрытия
|
||
# Это гарантирует что все CSS/JS из формы будут загружены
|
||
class Media(MusicStyleAdminForm.Media):
|
||
css = {'all': (*MusicStyleAdminForm.Media.css['all'], 'css/validation-override.css',)}
|
||
js = (*MusicStyleAdminForm.Media.js, 'js/form-field-watcher.js',)
|
||
|
||
def save_model(
|
||
self,
|
||
request: HttpRequest,
|
||
obj: TbMusicStyle,
|
||
form: forms.ModelForm,
|
||
change: bool,
|
||
) -> None:
|
||
"""
|
||
Переопределяем save_model для добавления информативных сообщений в админку.
|
||
|
||
Максимально поджарый код - все сложности с получением старого значения
|
||
и определением типа операции делает хелпер generate_admin_save_message().
|
||
"""
|
||
# Стандартное сохранение записи через Django
|
||
# (в т.ч. автоматическое создание связанной статьи в методе save модели TbMusicStyle)
|
||
super().save_model(request, obj, form, change)
|
||
|
||
# Генерируем и отправляем информативное сообщение о сохранении
|
||
# Хелпер сам:
|
||
# - определяет тип операции (create vs update)
|
||
# - формирует нужное сообщение (success vs warning)
|
||
generate_admin_save_message(
|
||
request=request,
|
||
obj=obj,
|
||
is_new=not change, # Django: change=False для новых, True для существующих
|
||
related_article=obj.k_style_to_article,
|
||
obj_field_name='s_style_name',
|
||
article_title_field='s_article_title',
|
||
)
|
||
|
||
|
||
# ============================================================================
|
||
# АДМИНКА для Исполнителей/Групп/Артистов, таблица TbArtist
|
||
#
|
||
# Кастомная форма для ArtistAdmin
|
||
class ArtistAdminForm(CodeMirrorFormMixin):
|
||
"""
|
||
Кастомная форма для админки артистов.
|
||
Добавляет виджеты CodeMirror для текстовых полей
|
||
"""
|
||
class Meta:
|
||
model = TbArtist
|
||
fields = ('s_artist', 'k_artist_to_article', 'j_artist_metadata', )
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
"""
|
||
При инициализации формы подгружаем CodeMirror редактор
|
||
"""
|
||
# Извлекаем request из kwargs если он есть
|
||
self.request = kwargs.pop('request', None)
|
||
|
||
super().__init__(*args, **kwargs)
|
||
|
||
# Конфигурируем поля для CodeMirror
|
||
self.setup_codemirror_field('s_artist', language='text',
|
||
css_class='codemirror-width-xl codemirror-no-lines')
|
||
self.setup_codemirror_field('j_artist_metadata', language='json',
|
||
css_class='codemirror-width-l codemirror-min-height-5')
|
||
|
||
def clean(self):
|
||
"""
|
||
Валидируем форму: проверяем на совпадения (дубликаты) основного поля s_artist.
|
||
Используем GET параметр ignore_validate для пропуска валидации при переотправке.
|
||
"""
|
||
# Получаем очищенные данные формы (может быть None, но обычно это dict)
|
||
cleaned_data: dict[str, Any] | None = super().clean()
|
||
|
||
# Если clean() вернул None, возвращаем пустой dict (для совместимости)
|
||
if cleaned_data is None:
|
||
cleaned_data = {}
|
||
|
||
# После проверки выше, очищены данные гарантированно dict[str, Any]
|
||
assert isinstance(cleaned_data, dict), "cleaned_data должен быть словарём"
|
||
|
||
# Используем универсальный хелпер для проверки дубликатов
|
||
# Модель берется автоматически из self.Meta.model
|
||
# Передаем request для проверки GET параметра ignore_validate
|
||
validate_entity_for_admin_form(
|
||
self,
|
||
cleaned_data,
|
||
main_field_name='s_artist',
|
||
metadata_field_name='j_artist_metadata',
|
||
request=self.request,
|
||
)
|
||
|
||
return cleaned_data
|
||
|
||
# Админка для TbArtist с кастомной формой ArtistAdminForm
|
||
class ArtistAdmin(RequestInFormMixin, admin.ModelAdmin):
|
||
"""Админ для артистов"""
|
||
form = ArtistAdminForm # Используем кастомную форму с CodeMirror
|
||
|
||
list_display = ('id', 's_artist', 't_artist_created')
|
||
list_display_links = ('id', 's_artist',)
|
||
search_fields = ('s_artist',)
|
||
readonly_fields = ('t_artist_created', 't_artist_updated')
|
||
fieldsets = (
|
||
('Основные данные об исполнителе (артисте, группе)', {
|
||
'fields': ('s_artist', 'j_artist_metadata',),
|
||
}),
|
||
('Связанная публикация', {
|
||
'fields': ('k_artist_to_article', ),
|
||
'description': get_related_article_description(TbArtist),
|
||
# 'classes': ('collapse',),
|
||
}),
|
||
('Служебная информация', {
|
||
'fields': ('t_artist_created', 't_artist_updated'),
|
||
'classes': ('collapse',),
|
||
}),
|
||
)
|
||
|
||
# Обогащение Media из формы (CodeMirrorFormMixin)
|
||
# Используем наследование для расширения, а не перекрытия
|
||
# Это гарантирует что все CSS/JS из формы будут загружены
|
||
class Media(ArtistAdminForm.Media):
|
||
css = {'all': (*ArtistAdminForm.Media.css['all'], 'css/validation-override.css', )}
|
||
js = (*ArtistAdminForm.Media.js, 'js/form-field-watcher.js', )
|
||
|
||
def save_model(
|
||
self,
|
||
request: HttpRequest,
|
||
obj: TbArtist,
|
||
form: forms.ModelForm,
|
||
change: bool,
|
||
) -> None:
|
||
"""
|
||
Переопределяем save_model для добавления информативных сообщений в админку.
|
||
|
||
Максимально поджарый код - все сложности с получением старого значения
|
||
и определением типа операции делает хелпер generate_admin_save_message().
|
||
"""
|
||
# Стандартное сохранение записи через Django
|
||
# (в т.ч. автоматическое создание связанной статьи в методе save модели TbArtist)
|
||
super().save_model(request, obj, form, change)
|
||
|
||
# Генерируем и отправляем информативное сообщение о сохранении
|
||
# Хелпер сам:
|
||
# - определяет тип операции (create vs update)
|
||
# - формирует нужное сообщение (success vs warning)
|
||
generate_admin_save_message(
|
||
request=request,
|
||
obj=obj,
|
||
is_new=not change, # Django: change=False для новых, True для существующих
|
||
related_article=obj.k_artist_to_article,
|
||
obj_field_name='s_artist',
|
||
article_title_field='s_article_title',
|
||
)
|
||
|
||
|
||
# ================
|
||
# АДМИН-ПАНЕЛЬ ДЛЯ ЛЕЙБЛОВ/ИЗДАТЕЛЕЙ
|
||
#
|
||
# Кастомная форма
|
||
class LabelAdminForm(CodeMirrorFormMixin):
|
||
"""
|
||
Кастомная форма для админки лейблов (Label).
|
||
Добавляет виджеты CodeMirror для текстовых полей
|
||
"""
|
||
|
||
class Meta:
|
||
model = TbLabel
|
||
fields = ('s_label', 'k_label_to_article', 'j_label_metadata',)
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
"""
|
||
При инициализации формы подгружаем CodeMirror редактор.
|
||
Получаем request из kwargs, переданных из get_form_kwargs в AdminClass.
|
||
"""
|
||
# Извлекаем request из kwargs если он есть
|
||
self.request = kwargs.pop('request', None)
|
||
|
||
super().__init__(*args, **kwargs)
|
||
|
||
# Конфигурируем поля для CodeMirror
|
||
self.setup_codemirror_field('s_label', language='text',
|
||
css_class='codemirror-width-xl codemirror-no-lines')
|
||
self.setup_codemirror_field('j_label_metadata', language='json',
|
||
css_class='codemirror-width-l codemirror-min-height-5')
|
||
|
||
def clean(self):
|
||
"""
|
||
Валидируем форму: проверяем на совпадения (дубликаты) основного поля s_label.
|
||
Используем GET параметр ignore_validate для пропуска валидации при переотправке.
|
||
"""
|
||
# Получаем очищенные данные формы (может быть None, но обычно это dict)
|
||
cleaned_data: dict[str, Any] | None = super().clean()
|
||
|
||
# Если clean() вернул None, возвращаем пустой dict (для совместимости)
|
||
if cleaned_data is None:
|
||
cleaned_data = {}
|
||
|
||
# После проверки выше, очищены данные гарантированно dict[str, Any]
|
||
assert isinstance(cleaned_data, dict), "cleaned_data должен быть словарём"
|
||
|
||
# Используем универсальный хелпер для проверки дубликатов
|
||
# Модель берется автоматически из self.Meta.model
|
||
# Передаем request для проверки GET параметра ignore_validate
|
||
validate_entity_for_admin_form(
|
||
self,
|
||
cleaned_data,
|
||
main_field_name='s_label',
|
||
metadata_field_name='j_label_metadata',
|
||
request=self.request,
|
||
)
|
||
|
||
return cleaned_data
|
||
|
||
# Админ для лейбла (Label) через миксин
|
||
class LabelAdmin(RequestInFormMixin, admin.ModelAdmin):
|
||
"""Админ для лейблов с поддержкой передачи request в форму"""
|
||
form = LabelAdminForm # Используем кастомную форму с CodeMirror
|
||
|
||
list_display = ('id', 's_label', 't_label_created')
|
||
list_display_links = ('id', 's_label',)
|
||
search_fields = ('s_label',)
|
||
readonly_fields = ('t_label_created', 't_label_updated')
|
||
|
||
fieldsets = (
|
||
('Основные данные о лейбле/издателе', {
|
||
'fields': ('s_label', 'j_label_metadata',),
|
||
}),
|
||
('Связанная публикация', {
|
||
'fields': ('k_label_to_article', ),
|
||
'description': get_related_article_description(TbLabel),
|
||
# 'classes': ('collapse',),
|
||
}),
|
||
('Служебная информация', {
|
||
'fields': ('t_label_created', 't_label_updated'),
|
||
'classes': ('collapse',),
|
||
}),
|
||
)
|
||
|
||
# Обогащение Media из формы (CodeMirrorFormMixin)
|
||
# Используем наследование для расширения, а не перекрытия
|
||
# Это гарантирует, что все CSS/JS из формы будут загружены
|
||
class Media(LabelAdminForm.Media):
|
||
css = {'all': (*LabelAdminForm.Media.css['all'], 'css/validation-override.css', )}
|
||
js = (*LabelAdminForm.Media.js, 'js/form-field-watcher.js', )
|
||
|
||
def save_model(
|
||
self,
|
||
request: HttpRequest,
|
||
obj: TbLabel,
|
||
form: forms.ModelForm,
|
||
change: bool,
|
||
) -> None:
|
||
"""
|
||
Переопределяем save_model для добавления информативных сообщений в админку.
|
||
|
||
Максимально поджарый код - все сложности с получением старого значения
|
||
и определением типа операции делает хелпер generate_admin_save_message().
|
||
"""
|
||
# Стандартное сохранение записи через Django
|
||
# (в т.ч. автоматическое создание связанной статьи в методе save модели TbLabel)
|
||
super().save_model(request, obj, form, change)
|
||
|
||
# Генерируем и отправляем информативное сообщение о сохранении
|
||
# Хелпер сам:
|
||
# - определяет тип операции (create vs update)
|
||
# - формирует нужное сообщение (success vs warning)
|
||
generate_admin_save_message(
|
||
request=request,
|
||
obj=obj,
|
||
is_new=not change, # Django: change=False для новых, True для существующих
|
||
related_article=obj.k_label_to_article,
|
||
obj_field_name='s_label',
|
||
article_title_field='s_article_title',
|
||
)
|
||
|
||
|
||
# ================
|
||
# АДМИН-ПАНЕЛЬ ДЛЯ ПРОДАВЦА/SELLER
|
||
#
|
||
# Кастомная форма
|
||
class SellerAdminForm(CodeMirrorFormMixin):
|
||
"""
|
||
Кастомная форма для админки продавца (Seller).
|
||
Добавляет виджеты CodeMirror для текстовых полей
|
||
"""
|
||
class Meta:
|
||
model = TbSeller
|
||
fields = ('id', 's_seller', 'l_seller_currency', 'k_seller_to_article', 'l_seller_type',
|
||
'j_seller_metadata',)
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
"""
|
||
При инициализации формы подгружаем CodeMirror редактор
|
||
"""
|
||
super().__init__(*args, **kwargs)
|
||
|
||
# Конфигурируем поля для CodeMirror
|
||
self.setup_codemirror_field('s_seller', language='text',
|
||
css_class='codemirror-width-l codemirror-no-lines')
|
||
self.setup_codemirror_field('j_seller_metadata', language='json',
|
||
css_class='codemirror-width-xl codemirror-min-height-5')
|
||
|
||
# Админ для продавца (Seller)
|
||
class SellerAdmin(admin.ModelAdmin):
|
||
"""Админ для продавцов"""
|
||
form = SellerAdminForm # Используем кастомную форму с CodeMirror
|
||
|
||
list_display = ('id', 's_seller', 'l_seller_type', 'l_seller_currency', 't_seller_created',)
|
||
list_display_links = ('id', 's_seller',)
|
||
list_filter = ('l_seller_type', 'l_seller_currency',)
|
||
search_fields = ('s_seller',)
|
||
readonly_fields = ('t_seller_created', 't_seller_updated',)
|
||
fieldsets = (
|
||
('Основные данные о продавце', {
|
||
'fields': ('s_seller', 'l_seller_currency', 'l_seller_type',
|
||
'j_seller_metadata',),
|
||
}),
|
||
('Связанная публикация', {
|
||
'fields': ('k_seller_to_article', ),
|
||
'description': get_related_article_description(TbSeller),
|
||
# 'classes': ('collapse',),
|
||
}),
|
||
('Служебная информация', {
|
||
'fields': ('t_seller_created', 't_seller_updated'),
|
||
'classes': ('collapse',),
|
||
}),
|
||
)
|
||
|
||
# ВАЖНО. Так как продавцам не нужны синонимы, то CSS и JS для админки обогащать не нужно.
|
||
def save_model(
|
||
self,
|
||
request: HttpRequest,
|
||
obj: TbSeller,
|
||
form: forms.ModelForm,
|
||
change: bool,
|
||
) -> None:
|
||
"""
|
||
Переопределяем save_model для добавления информативных сообщений в админку.
|
||
|
||
Максимально поджарый код - все сложности с получением старого значения
|
||
и определением типа операции делает хелпер generate_admin_save_message().
|
||
"""
|
||
# Стандартное сохранение записи через Django
|
||
# (в т.ч. автоматическое создание связанной статьи в методе save модели TbSeller)
|
||
super().save_model(request, obj, form, change)
|
||
|
||
# Генерируем и отправляем информативное сообщение о сохранении
|
||
# Хелпер сам:
|
||
# - определяет тип операции (create vs update)
|
||
# - формирует нужное сообщение (success vs warning)
|
||
generate_admin_save_message(
|
||
request=request,
|
||
obj=obj,
|
||
is_new=not change, # Django: change=False для новых, True для существующих
|
||
related_article=obj.k_seller_to_article,
|
||
obj_field_name='s_seller',
|
||
article_title_field='s_article_title',
|
||
)
|
||
|
||
|
||
|
||
# ============================================================================
|
||
# АДМИНКА ИСТОЧНИКОВ ДАННЫХ
|
||
#
|
||
# Кастомная форма
|
||
class SourceAdminForm(CodeMirrorFormMixin):
|
||
"""
|
||
Кастомная форма для админки источников данных (TbSource).
|
||
Добавляет виджеты CodeMirror для текстовых полей
|
||
"""
|
||
class Meta:
|
||
model = TbSource
|
||
fields = ('id', 'k_source_to_seller', 's_source_name', 'l_source_type', 't_source_data',
|
||
'source_file', 's_source_url', 'j_source_metadata',)
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
"""
|
||
При инициализации формы подгружаем CodeMirror редактор
|
||
"""
|
||
super().__init__(*args, **kwargs)
|
||
|
||
# Конфигурируем поля для CodeMirror
|
||
self.setup_codemirror_field('s_source_name', language='text',
|
||
css_class='codemirror-width-l codemirror-no-lines')
|
||
self.setup_codemirror_field('s_source_url', language='url',
|
||
css_class='codemirror-width-xl codemirror-no-lines')
|
||
self.setup_codemirror_field('j_source_metadata', language='json',
|
||
css_class='codemirror-width-l codemirror-min-height-5')
|
||
|
||
|
||
class SourceAdmin(admin.ModelAdmin):
|
||
"""Админ для источников"""
|
||
form = SourceAdminForm # Используем кастомную форму с CodeMirror
|
||
|
||
list_display = ('id', 's_source_name', 'k_source_to_seller', 'l_source_type', 't_source_data')
|
||
list_display_links = ('id', 's_source_name',)
|
||
list_filter = ('l_source_type', 't_source_data')
|
||
search_fields = ('s_source_name',)
|
||
readonly_fields = ('t_source_created', 't_source_updated')
|
||
|
||
|
||
# ============================================================================
|
||
# АДМИНКА РЕЛИЗОВ/АЛЬБОМОВ/ТОВАРОВ
|
||
#
|
||
# Кастомная форма
|
||
class ItemAdminForm(CodeMirrorFormMixin):
|
||
"""
|
||
Кастомная форма для админки источников данных (TbItem).
|
||
Добавляет виджеты CodeMirror для текстовых полей
|
||
"""
|
||
class Meta:
|
||
model = TbItem
|
||
fields = ('id', 's_item', 'k_item_to_artist', 'k_item_to_style', 'k_item_to_article',
|
||
's_item_date', 't_item_date', 'i_discogs_master_id', 'j_item_metadata')
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
"""
|
||
При инициализации формы подгружаем CodeMirror редактор
|
||
"""
|
||
# Извлекаем request из kwargs если он есть
|
||
self.request = kwargs.pop('request', None)
|
||
|
||
super().__init__(*args, **kwargs)
|
||
|
||
# Конфигурируем поля для CodeMirror
|
||
self.setup_codemirror_field('s_item', language='text',
|
||
css_class='codemirror-width-l codemirror-no-lines')
|
||
self.setup_codemirror_field('s_item_date', language='text',
|
||
css_class='codemirror-width-s codemirror-no-lines')
|
||
self.setup_codemirror_field('i_discogs_master_id', language='text',
|
||
css_class='codemirror-width-l codemirror-no-lines')
|
||
self.setup_codemirror_field('j_item_metadata', language='json',
|
||
css_class='codemirror-width-l codemirror-min-height-5')
|
||
|
||
|
||
class ItemAdmin(RequestInFormMixin, admin.ModelAdmin):
|
||
"""Админ для товаров"""
|
||
form = ItemAdminForm # Используем кастомную форму с CodeMirror
|
||
|
||
list_display = ('id', 's_item', 't_item_date', 't_item_created')
|
||
list_display_links = ('id', 's_item',)
|
||
list_filter = ('t_item_date', 't_item_created')
|
||
search_fields = ('s_item',)
|
||
filter_horizontal = ('k_item_to_artist', 'k_item_to_style')
|
||
readonly_fields = ('t_item_created', 't_item_updated')
|
||
|
||
|
||
# ============================================================================
|
||
# Остальные ModelAdmin классы
|
||
# ============================================================================
|
||
|
||
class OfferAdmin(admin.ModelAdmin):
|
||
"""Админ для предложений"""
|
||
list_display = ('id', 's_offer', 'k_offer_to_item', 'f_offer_price', 'i_offer_quantity', 'i_offer_views')
|
||
list_filter = ('l_offer_condition_media', 'l_offer_condition_sleeve', 't_offer_created', 'l_offer_to_format')
|
||
search_fields = ('s_offer',)
|
||
filter_horizontal = ('k_offer_to_image',)
|
||
readonly_fields = ('s_offer_skip32', 't_offer_created', 't_offer_updated', 'i_offer_views', 'i_offer_favorites')
|
||
|
||
|
||
class ArticleAdmin(admin.ModelAdmin):
|
||
"""Админ для статей"""
|
||
list_display = ('id', 's_article_title', 'l_article_type', 'b_article_published', 't_article_created')
|
||
list_filter = ('l_article_type', 'b_article_published', 't_article_created')
|
||
search_fields = ('s_article_title', 'slug')
|
||
prepopulated_fields = {'slug': ('s_article_title',)}
|
||
readonly_fields = ('t_article_created', 't_article_updated')
|
||
# filter_horizontal = ('k_article_to_styles',)
|
||
|
||
fieldsets = (
|
||
('Основная информация', {
|
||
'fields': ('s_article_title', 'slug', 'l_article_type', 'b_article_published'),
|
||
}),
|
||
('Изображение', {
|
||
'fields': ('k_article_to_image',),
|
||
'description': 'Обложка или иллюстрация статьи. Используется в списках и на странице статьи.',
|
||
}),
|
||
('Содержание', {
|
||
'fields': ('s_article_title_html', 's_article_teaser_html', 's_article_content_html'),
|
||
}),
|
||
('SEO и метаданные', {
|
||
'fields': ('seo_title', 'seo_description', 'seo_keywords'),
|
||
'classes': ('collapse',),
|
||
}),
|
||
('Публикация', {
|
||
'fields': ('t_article_ended', 'i_article_views', 'i_article_favorites'),
|
||
'classes': ('collapse',),
|
||
}),
|
||
('Служебная информация', {
|
||
'fields': ('t_article_created', 't_article_updated'),
|
||
'classes': ('collapse',),
|
||
}),
|
||
)
|
||
|
||
|
||
class OfferHistoryAdmin(admin.ModelAdmin):
|
||
"""Админ для истории изменений офферов"""
|
||
list_display = ('id', 'k_history_to_offer', 'f_history_price', 'i_history_quantity', 't_history_created')
|
||
list_filter = ('t_history_created',)
|
||
readonly_fields = ('t_history_created',)
|
||
|
||
|
||
# ============================================================================
|
||
# Регистрация моделей в дефолтном admin.site
|
||
# ============================================================================
|
||
admin.site.register(TbImageMetadata, ImageMetadataAdmin)
|
||
admin.site.register(TbArticle, ArticleAdmin)
|
||
admin.site.register(TbMusicStyle, MusicStyleAdmin)
|
||
admin.site.register(TbArtist, ArtistAdmin)
|
||
admin.site.register(TbItem, ItemAdmin)
|
||
admin.site.register(TbLabel, LabelAdmin)
|
||
admin.site.register(TbSeller, SellerAdmin)
|
||
admin.site.register(TbSource, SourceAdmin)
|
||
admin.site.register(TbOffer, OfferAdmin)
|
||
admin.site.register(TbOfferHistory, OfferHistoryAdmin)
|
||
|
||
# User и Group уже зарегистрированы auth приложением
|
||
|
||
# ============================================================================
|
||
# Кастомизация админ-сайта через ready() в apps.py (переименование через verbose_name)
|
||
# ============================================================================
|
||
|