mod: админка (15) в save_model работает типограф etpgrf и авто-slug с транслитерацией + превью-картинки в списке блог-постов

This commit is contained in:
2026-08-25 22:31:39 +03:00
parent 19d383dc89
commit 58c1ee46df
2 changed files with 148 additions and 3 deletions
+146 -3
View File
@@ -1,17 +1,41 @@
# Кастомная конфигурация Django Admin для Hypn0. # Кастомная конфигурация Django Admin для Hypn0.
# Регистрируем модели с удобным интерфейсом. # Регистрируем модели с удобным интерфейсом.
import etpgrf
import random
import re
import pytils
from django import forms from django import forms
from django.contrib import admin from django.contrib import admin
from django.forms import Textarea from django.forms import Textarea
from django.utils.html import format_html from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.http import HttpRequest from django.http import HttpRequest
from bs4 import BeautifulSoup
from html import unescape
from .models import ( from .models import (
TbHypn0Item, TbHypn0Item,
TbVote, TbVote,
TbBlogPost TbBlogPost
) )
# ============================================================================
# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ
# ============================================================================
def clean_html_to_plain_text(html_content: str) -> str:
"""
Очищает HTML-контент от тегов, скриптов, стилей и декодирует HTML-сущности.
Возвращает чистый читаемый текст.
"""
if not html_content:
return ""
soup = BeautifulSoup(html_content, "html.parser")
# Скрипты, стили и форматирование вырезаем целиком
for tag in soup(["script", "style", "noscript", "code", "kbd", "pre"]):
tag.decompose()
return unescape(soup.get_text()).strip()
# ============================================================================ # ============================================================================
# МИКСИНЫ ДЛЯ АДМИНКИ # МИКСИНЫ ДЛЯ АДМИНКИ
# ============================================================================ # ============================================================================
@@ -211,7 +235,9 @@ class TbHypn0ItemAdmin(admin.ModelAdmin):
@admin.display(description="Название (HTML)") @admin.display(description="Название (HTML)")
def title_preview(self, obj): def title_preview(self, obj):
return format_html(obj.s_title) if obj and obj.s_title:
return mark_safe(unescape(obj.s_title))
return ""
@admin.display(description="Превью SVG") @admin.display(description="Превью SVG")
def svg_preview(self, obj): def svg_preview(self, obj):
@@ -342,7 +368,7 @@ class TbBlogPostAdmin(RequestInFormMixin, admin.ModelAdmin):
""" """
form = BlogPostAdminForm form = BlogPostAdminForm
list_display = ("id", "s_title_display", "slug", "is_published", "d_published_at", "d_created_at") list_display = ("id", "article_thumbnail", "s_title_display", "slug", "is_published", "d_published_at", "d_created_at")
list_display_links = ("id", "s_title_display") list_display_links = ("id", "s_title_display")
list_filter = ("is_published", "d_published_at", "d_created_at") list_filter = ("is_published", "d_published_at", "d_created_at")
search_fields = ("s_title", "slug", "s_teaser", "s_content") search_fields = ("s_title", "slug", "s_teaser", "s_content")
@@ -370,6 +396,123 @@ class TbBlogPostAdmin(RequestInFormMixin, admin.ModelAdmin):
}), }),
) )
@admin.display(description="Обложка")
def article_thumbnail(self, obj):
"""
Отображает миниатюру изображения статьи в списке (аккуратный квадрат 36x36 px).
Работает со стандартным ImageField (f_cover_img) без внешних библиотек.
"""
if obj and obj.f_cover_img:
try:
return format_html(
'<img src="{}" width="20" height="20" style="border: 1px solid silver;" title="{}" alt="" />',
obj.f_cover_img.url,
obj.s_title,
)
except Exception:
return mark_safe('<span style="color: #999">(ошибка)</span>')
return mark_safe(
'<img width="20" height="20" style="border: 1px solid silver; background: #90909060;" title="Нет картинки" alt="" />'
)
def save_model(self, request, obj, form, change):
"""
Переопределяем save_model для применения:
1. Tипографа etpgrf для полей s_title, s_teaser и s_content
2. Генерации Slug c транслитерацией
Args:
request: HTTP-запрос (содержит info о пользователе)
obj: инстанция TbArticle для сохранения
form: валидированная форма
change: True если редактирование, False если создание
"""
# 1. ТИПОГРАФ ETPGRF
# Проверяем, включен ли типограф
if form.cleaned_data.get('etp_enable', True):
# Получаем все настройки из формы
langs = form.cleaned_data.get('etp_language', 'ru').split(',')
# 1.1. LayoutProcessor: включаем layout с базовыми настройками
layout_option = etpgrf.LayoutProcessor(
langs=langs,
process_initials_and_acronyms=True,
process_units=True
)
# 1.2. Hyphenator (переносы слов)
hyphenation_option = False
if form.cleaned_data.get('etp_hyphenation', True):
hyphenation_option = etpgrf.Hyphenator(
langs=langs,
max_unhyphenated_len=14
)
# 1.3. Sanitizer (очистка HTML перед типографированием)
# Режимы: 'html' (удаляет все теги), 'etp' (только висячая пунктуация), None/False (ничего не делает)
if form.cleaned_data.get('etp_sanitize', True):
sanitizer_option = 'html' # Удаляет все HTML-теги
else:
sanitizer_option = False # Санитайзер отключен
# 1.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'),
}
# 1.5. Для заголовков: висячая пунктуация может быть включена
options_title = {
**base_options,
'hanging_punctuation': form.cleaned_data.get('etp_hanging_punctuation', True),
}
t_title = etpgrf.Typographer(**options_title)
if obj.s_title:
obj.s_title = t_title.process(obj.s_title)
# 1.6. Для тизера и контента: висячая пунктуация всегда отключена
options_body = {
**base_options,
'hanging_punctuation': False,
}
t_body = etpgrf.Typographer(**options_body)
if obj.s_teaser:
obj.s_teaser = t_body.process(obj.s_teaser)
if obj.s_content:
obj.s_content = t_body.process(obj.s_content)
# 2. SLUG
if not obj.slug:
# 2.0. Если вдруг нет заголовка, то генерируем случайный slug
if not obj.s_title:
obj.slug = f"title-{random.randint(1, 4095):03x}"
else:
# 2.1. Очищаем текст от HTML и спецсимволов через вспомогательную функцию
plain_title = clean_html_to_plain_text(obj.s_title)
# 2.2. Транслитерируем и создаем slug (pytils подходит для русского)
obj.slug = pytils.translit.slugify(plain_title) if plain_title else ""
# 2.3. Нормализуем множественные дефисы, удаляем дефисы в начале/конце
obj.slug = re.sub(pattern=r"-+", repl="-", string=obj.slug).strip("-")
# 2.4. Если все еще нет slug (например заголовок из спец-символов) — генерируем
obj.slug = obj.slug or f"title-{random.randint(1, 4095):03x}"
# 3. Вызываем родительский save_model который вызовет obj.save()
super().save_model(request, obj, form, change)
@admin.display(description="Заголовок") @admin.display(description="Заголовок")
def s_title_display(self, obj): def s_title_display(self, obj):
return format_html(obj.s_title) # Отображаем HTML-заголовок статьи как безопасный HTML
if obj and obj.s_title:
return mark_safe(unescape(obj.s_title))
return ""
+2
View File
@@ -359,6 +359,8 @@ class TbBlogPost(models.Model):
) )
slug = models.SlugField( slug = models.SlugField(
max_length=200, max_length=200,
blank=True,
default="",
unique=True, unique=True,
verbose_name="URL-слаг", verbose_name="URL-слаг",
) )