Files
2018-lpon-site/lpon_site/frontend/admin.py
T

1253 lines
64 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Кастомная конфигурация Django Admin для LPON сайта.
# Регистрируем модели с удобным интерфейсом.
import etpgrf
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.core.exceptions import ValidationError
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
from .utils import make_slug
# ============================================================================
# ЛОКАЛЬНЫЕ ХЕЛПЕРЫ ДЛЯ АДМИНКИ
# ============================================================================
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'Прикреп&shy;ленная статья (если есть) будет отображаться на&nbsp;странице <u>{verbose_name}</u>'
f' на&nbsp;сайте. Также позволяет получать список всех альбомов <u>{verbose_name_plural}</u>,'
f' управлять SEO-атрибутами для&nbsp;улучшения видимости поисковых систем, иметь красивый'
f' slag для&nbsp;URL-странички, подсчитывать число просмотров и&nbsp;добавлений'
f' в&nbsp;избранные. <b style=\'color: green;\'>ОЧЕНЬ РЕКОМЕН&shy;ДУЕТСЯ СОЗДАВАТЬ'
f' И&nbsp;ПРИВЯЗЫВАТЬ СТАТЬЮ ВРУЧНУЮ</b>. Если публикация не&nbsp;создана вручную,'
f' то&nbsp;она будет создана автоматически (пустая) при&nbsp;сохранении <u>{verbose_name}</u>,'
f' со&nbsp;всеми SEO-атрибутами и&nbsp;slag, но&nbsp;автоматика несовершенна.<br />&nbsp;')
# ============================================================================
# МИКСИНЫ ДЛЯ АДМИНКИ
# ============================================================================
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>&lt;img alt="" .../&gt;</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>&lt;img title="" .../&gt;</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')
# ============================================================================
# АДМИНКА СТАТЕЙ
#
# Статьи очень важная сущность сайта. Через них на сайте отпределяютя URL (slag), SEO-поля и "заголовочные" картинки
# многих сущностей (артистов, лейблов, музыкальных стилей и т.д.). Также тут опредлелентся, как на сайте будут
# отображаться все эти сущности сверстанными в HTML.
# Кастомная форма
class ArticleAdminForm(CodeMirrorFormMixin):
"""
Кастомная форма для админки статей (TbArticle).
Добавляет виджеты CodeMirror для текстовых полей.
"""
# Виртуальные поля для настройки типографа
etp_enable = forms.BooleanField(
label="Включить типограф",
initial=True,
required=False,
help_text="Включить автоматическую типографику для HTML полей (заголовок, тизер, контент)&nbsp;&nbsp;&nbsp;"
)
etp_language = forms.ChoiceField(
label="Язык типографики",
choices=[('ru', 'Русский'), ('en', 'English'), ('ru,en', 'Ru + En')],
initial='ru',
required=False
)
etp_quotes = forms.BooleanField(
label="Кавычки",
initial=True,
required=False,
help_text="Заменять кавычки</br>(«ёлочки» для русского, “лапки” для английского)&nbsp;&nbsp;&nbsp;"
)
etp_hyphenation = forms.BooleanField(
label="Расставлять переносы",
initial=True,
required=False,
help_text="Расставлять мягкие переносы (&amp;shy;)&nbsp;&nbsp;&nbsp;</br>"
"в словах длиннее <b>14</b> символов&nbsp;&nbsp;&nbsp;"
)
etp_sanitize = forms.BooleanField(
label="Очистка HTML",
initial=True,
required=False,
help_text="Удалять весь HTML из исходного текста&nbsp;&nbsp;&nbsp;"
)
etp_hanging_punctuation = forms.BooleanField(
label="Висячая пунктуация",
initial=True,
required=False,
help_text="Выносить пунктуацию в начало строк</br>&nbsp;&nbsp;&nbsp;"
"(только для заголовков... для тизера и контента отключается автоматически)&nbsp;&nbsp;&nbsp;"
)
etp_mode = forms.ChoiceField(
label="Режим вывода",
choices=[('mixed', 'Смешанный (Mixed)'), ('unicode', 'Юникод (Unicode)'), ('mnemonic', 'Мнемоники')],
initial='mixed',
required=False,
help_text="Формат спецсимволов (например, кавычек, тире, многоточий) в&nbsp;HTML: смешанный, юникод"
"&nbsp;или мнемоники&nbsp;&nbsp;&nbsp;"
)
class Meta:
model = TbArticle
fields = (
# Виртуальные поля для настройки типографа
'etp_enable', 'etp_language', 'etp_quotes', 'etp_hyphenation', 'etp_sanitize',
'etp_hanging_punctuation', 'etp_mode',
# Остальные поля модели TbArticle
's_article_title', 'slug', 'l_article_type', 'b_article_published', 'k_article_to_image',
's_article_title_html', 's_article_teaser_html', 's_article_content_html', 'seo_title',
'seo_description', 'seo_keywords', 't_article_ended', 'i_article_views', 'i_article_favorites',
)
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_article_title', language='text',
css_class='codemirror-width-l codemirror-no-lines')
self.setup_codemirror_field('slug', language='text',
css_class='codemirror-width-l codemirror-no-lines')
self.setup_codemirror_field('s_article_title_html', language='html',
css_class='codemirror-width-l codemirror-min-height-2')
self.setup_codemirror_field('s_article_teaser_html', language='html',
css_class='codemirror-width-xl codemirror-min-height-5')
self.setup_codemirror_field('s_article_content_html', language='html',
css_class='codemirror-width-xl codemirror-min-height-10')
self.setup_codemirror_field('seo_title', language='text',
css_class='codemirror-width-l codemirror-no-lines')
self.setup_codemirror_field('seo_description', language='text',
css_class='codemirror-width-l codemirror-no-lines codemirror-min-height-2')
self.setup_codemirror_field('seo_keywords', language='text',
css_class='codemirror-width-l codemirror-no-lines codemirror-min-height-2')
self.setup_codemirror_field('i_article_views', language='text',
css_class='codemirror-width-s codemirror-no-lines')
self.setup_codemirror_field('i_article_favorites', language='text',
css_class='codemirror-width-s codemirror-no-lines')
def clean(self):
"""
Валидируем форму: при редактировании проверяем изменился ли s_article_title_html
и может ли измениться slug. Если да — показываем красную кнопку подтверждения.
Используем GET параметр ignore_validate для пропуска при переотправке.
"""
cleaned_data = super().clean()
# Проверяем только при редактировании и не установлен ignore_validate
if self.instance.pk and self.request and not self.request.GET.get('ignore_validate'):
# Получаем старый объект из БД
old_obj = TbArticle.objects.get(pk=self.instance.pk)
if old_obj.s_article_title_html != cleaned_data.get('s_article_title_html'):
# Заголовок изменился, проверяем, нужно ли менять slug
title_source = cleaned_data.get('s_article_title_html') or cleaned_data.get('s_article_title') or ''
new_potential_slug = make_slug(title_source)
# Если новый slug не совпадает с началом текущего...
if not self.instance.slug.startswith(new_potential_slug):
# ...предлагаем изменить slug
error_html = (
'<div class="confirmation-button-container">'
' <big>Ой! Кажется, вы изменили заголовок статьи!</big></br>'
f' Текущий slug: <b><tt><u>{self.instance.slug}</u></tt></b></br>'
f' Для измененного заголовка «<tt><i><u>{cleaned_data.get('s_article_title_html')}</u></i></tt>»'
f' лучше сделать slug <b><tt><u>{new_potential_slug}</u></tt></b></br></br>'
' Пожалуйста, проверьте, что это правильно. Если вы уверены — нажмите подтверждение.<br></br>'
' <button type="button" onclick="markSubmitButtonsToIgnoreValidation();">'
' ✓ Я ПРОВЕРИЛ И УВЕРЕН!'
' </button></br>&nbsp;'
'</div>'
)
raise ValidationError(mark_safe(error_html))
return cleaned_data
class ArticleAdmin(RequestInFormMixin, admin.ModelAdmin):
"""Админ для статей с поддержкой передачи request в форму"""
form = ArticleAdminForm # Используем кастомную форму с CodeMirror
list_display = ('id', 's_article_title', 'l_article_type', 'b_article_published', 't_article_created')
list_display_links = ('id', 's_article_title',)
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': ('t_article_ended',),
'classes': ('collapse',),
}),
('Типограф', {
'fields': (('etp_enable',), ('etp_language', 'etp_mode'), ('etp_quotes', 'etp_hyphenation', 'etp_sanitize', 'etp_hanging_punctuation')),
'classes': ('collapse',),
'description': 'Типограф применяется при сохранении и срабатывает на HTML-поля (ЗАГОЛОВОК, ТИЗЕР СТАТЬИ'
' и СТАТЬЯ). Если выключить — HTML будет сохранен без изменений.',
}),
('Содержание', {
'fields': ('s_article_title_html', 's_article_teaser_html', 's_article_content_html'),
}),
('Просмотры и избранное', {
'fields': ('i_article_views', 'i_article_favorites',),
'classes': ('collapse',),
}),
('SEO и метаданные', {
'fields': ('seo_title', 'seo_description', 'seo_keywords'),
'classes': ('collapse',),
}),
('Служебная информация', {
'fields': ('t_article_created', 't_article_updated'),
'classes': ('collapse',),
}),
)
# Подключаем CSS и JS для валидации
# Переиспользуем стили и скрипты из админок Label/MusicStyle
class Media(ArticleAdminForm.Media):
css = {'all': (*ArticleAdminForm.Media.css['all'], 'css/validation-override.css', )}
js = (*ArticleAdminForm.Media.js, 'js/form-field-watcher.js', )
def save_model(self, request, obj, form, change):
"""
Переопределяем save_model для применения типографа.
Username используется в TbArticle.save() для установки IMG_FROM в метаданные картинки.
Args:
request: HTTP-запрос (содержит info о пользователе)
obj: инстанция TbArticle для сохранения
form: валидированная форма
change: True если редактирование, False если создание
"""
# Типографика для HTML-полей (s_article_title_html, s_article_teaser_html, s_article_content_html)
# Проверяем, включен ли типограф
if form.cleaned_data.get('etp_enable', True):
# Получаем все настройки из формы
langs = form.cleaned_data.get('etp_language', 'ru').split(',')
# 1. LayoutProcessor: включаем layout с базовыми настройками
layout_option = etpgrf.LayoutProcessor(
langs=langs,
process_initials_and_acronyms=True,
process_units=True
)
# 2. Hyphenator (переносы слов)
hyphenation_option = False
if form.cleaned_data.get('etp_hyphenation', True):
hyphenation_option = etpgrf.Hyphenator(
langs=langs,
max_unhyphenated_len=14
)
# 3. Sanitizer (очистка HTML перед типографированием)
# Режимы: 'html' (удаляет все теги), 'etp' (только висячая пунктуация), None/False (ничего не делает)
if form.cleaned_data.get('etp_sanitize', True):
sanitizer_option = 'html' # Удаляет все HTML-теги
else:
sanitizer_option = False # Санитайзер отключен
# 4. Базовые настройки типографа (используются для всех полей)
base_options = {
'langs': langs,
'process_html': True,
'quotes': form.cleaned_data.get('etp_quotes', True),
'layout': layout_option,
'unbreakables': True,
'hyphenation': hyphenation_option,
'sanitizer': sanitizer_option,
'symbols': True,
'mode': form.cleaned_data.get('etp_mode', 'mixed'),
}
# 5. Для заголовков: висячая пунктуация может быть включена
options_title = {
**base_options,
'hanging_punctuation': form.cleaned_data.get('etp_hanging_punctuation', True),
}
t_title = etpgrf.Typographer(**options_title)
if obj.s_article_title_html:
obj.s_article_title_html = t_title.process(obj.s_article_title_html)
# 6. Для тизера и контента: висячая пунктуация всегда отключена
options_body = {
**base_options,
'hanging_punctuation': False,
}
t_body = etpgrf.Typographer(**options_body)
if obj.s_article_teaser_html:
obj.s_article_teaser_html = t_body.process(obj.s_article_teaser_html)
if obj.s_article_content_html:
obj.s_article_content_html = t_body.process(obj.s_article_content_html)
# Сохраняем username текущего пользователя для передачи в TbArticle.save() для IMG_FROM
if request and request.user:
obj._admin_username = request.user.username
# Вызываем родительский save_model который вызовет obj.save()
super().save_model(request, obj, form, change)
# ============================================================================
# Остальные ModelAdmin классы
# Их еще предстоит обжужукать (натянуть CodeMirror, обвесить валидаторами, переопределить save_model и т.д.)
# ============================================================================
class OfferAdmin(admin.ModelAdmin):
"""Админ для предложений"""
class OfferImageInline(admin.TabularInline):
"""
Inline для управления картинками оффера.
Позволяет добавлять, редактировать и удалять картинки через:
- FilerImageField для красивого выбора
- i_img_sort для сортировки
- j_img_metadata для метаданных
"""
model = TbImageMetadata
fk_name = 'm_offer'
extra = 1 # одна пустая строка для добавления новой картинки
fields = ('image', 'i_img_sort', 'j_img_metadata')
ordering = ('i_img_sort',)
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',)
inlines = [OfferImageInline] # Управление картинками в inline
readonly_fields = ('s_offer_skip32', 't_offer_created', 't_offer_updated', 'i_offer_views', 'i_offer_favorites')
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)
# ============================================================================