tmp: Как «подружить» Django Admin и типограф etpgrf: Виртуальные поля для настроек (вёрстка в песочнице).
This commit is contained in:
@@ -21,54 +21,222 @@
|
|||||||
{# Правая колонка: Контент #}
|
{# Правая колонка: Контент #}
|
||||||
<div class="col-lg-10 border-start ps-lg-4 post-page-content">
|
<div class="col-lg-10 border-start ps-lg-4 post-page-content">
|
||||||
|
|
||||||
<h1>Чистые, типогра­фированные заголовки в админке Django: убираем HTML-мнемоники</h1>
|
<h1>Как «подружить» Django Admin и типограф etpgrf: Виртуальные поля для настроек</h1>
|
||||||
|
|
||||||
<div class="lead bg-secondary bg-opacity-10 p-3 rounded">
|
<div class="lead bg-secondary bg-opacity-10 p-3 rounded">
|
||||||
<p>Библиотека типографа <tt>etpgrf</tt> написана на Python, и потому его исполь­зование в Django — наиболее распрос­траненный случай. Когда в Django вы храните в базе данных типогра­фированный текст (с HTML-мнемониками: неразрывными пробелами <code>&nbsp;</code>, мягкими переносами <code>&shy;</code> и тому подобное), то стандартный список в админке Django превращается в не очень удобо­читаемую кашу из спецсимволов. Рассказываем, как это исправить за пару строк кода.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<p>Многие контент-проекты сталкиваются с дилеммой: хочется красивой типографики (правильные кавычки «ёлочки», длинные тире <code>—</code>, неразрывные пробелы <code>&nbsp;</code>). Самое очевидное решение — переопре­делить метод <code>safe()</code>, но каждый блок текста — заглоаок, тизер, статья — требуют разных настроек. В заголовках хочестя иметь «висячую пунктуацию» и запретить переносы в словах, для основного текста публикации — наоборот, одна публикация на английском и нужны кавычки “лапки”, другая на русском — и нужны «ёлочки».</p>
|
||||||
|
<p>Хранить все настройки в базе данных (добавляя десятки полей <code>bool</code> в модель) — плохая идея. Это «засоряет» схему данных параметрами отображения, раздувает базу «мусорной информацией» и некрасиво с точки зрения архитектуры.</p>
|
||||||
|
<p><strong>Решение:</strong> Если у вас Django в качестве бэкенда, то наилучший подход — использовать «Виртуальные поля» (Virtual Fields) в Django Admin: Добавить настройки типографа прямо в форму редакти­рования админки, применить эти настройки при сохранении, и забыть о них, сохранив в базу только готовый, красивый HTML.</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
<hr>
|
<hr>
|
||||||
|
<div class="post-content mt-4">
|
||||||
|
|
||||||
<div class="row align-items-start">
|
<p>Разберём задачу на конкретном примере. У меня есть сайт с цитатами (<a href="https://dq.cube2.ru/" target="_blank">DQ – коллекция цитат. Место для вдумчивого чтения</a>, в нем большое внимание уделяется типографике, а значит при размещении цитат через иметь возможность не только редакти­ровать текст, но и управлять настройками типографа. В проекте есть модель <code>Dictum</code> с полем <code>content</code> (исходный текст) и <code>content_html</code> (типогра­фированный HTML для вывода на сайте). Я хочу, чтобы редактор мог управлять настройками типографа (язык, кавычки, переносы) прямо в админке, не меняя структуру БД.</p>
|
||||||
<div class="col border-end">
|
<h2>Инструменты</h2>
|
||||||
|
|
||||||
<h2>Проблема</h2>
|
|
||||||
<p>Представьте, что у вас есть модель <code>Post</code> в которой хранятся записи блога, и вы прогнали заголовки через типограф (например, через наш <a href="/">онлайн-типограф</a> или с помощью библиотеки <tt>etpgrf</tt>). В базе данных заголовок выглядит так:</p>
|
|
||||||
<pre class="border p-3 my-3 bg-secondary bg-opacity-25">Политика безопасности и&nbsp;конфи&shy;денциаль&shy;ности</pre>
|
|
||||||
<p>На сайте это будет отлично выглядеть для браузера, но в панели админис­тратора или контент-менеджера это ужасно, так как Django «маскирует» <code>&</code> и все HTML-мнемоники будут видны во всей красе. В списке объектов (Change List) будет виден «сырой» HTML-код, и читать такое сложно, особенно если мнемоник много.</p>
|
|
||||||
<h2>Решение</h2>
|
|
||||||
<p>Нам нужно, чтобы в списке (<code>list_display</code>) отображался «чистый» текст, а в форме редакти­рования оставался исходный HTML (чтобы его было легче править).</p>
|
|
||||||
<p>Для этого мы добавим в класс <code>ModelAdmin</code> специальный метод, который будет декодировать HTML-мнемоники перед выводом в админку.</p>
|
|
||||||
<h3>Шаг 1. Импортируем модуль html</h3>
|
|
||||||
<p>В Python есть встроенная библиотека <code>html</code>, которая умеет делать <code>unescape</code> — превращать мнемоники, например <code>&nbsp;</code> или <code>&shy;</code>, unicode. <code>&nbsp;</code> превратится в неразрывный пробел, а <code>&shy;</code> в мягкий перенос.</p>
|
|
||||||
<h3>Шаг 2. Создаем метод в админке</h3>
|
|
||||||
<p>В файле <code>admin.py</code>:</p>
|
|
||||||
<pre class="border p-3 my-3 rounded bg-secondary bg-opacity-25">from django.contrib import admin
|
|
||||||
import html
|
|
||||||
from .models import Post
|
|
||||||
|
|
||||||
@admin.register(Post)
|
|
||||||
class PostAdmin(admin.ModelAdmin):
|
|
||||||
# В list_display указываем не поле 'title', а наш метод 'clean_title'
|
|
||||||
list_display = ('clean_title', 'is_published', 'published_at')
|
|
||||||
|
|
||||||
# ... остальные настройки ...
|
|
||||||
|
|
||||||
# Декоратор @admin.display позволяет настроить название колонки и сортировку
|
|
||||||
@admin.display(description='Заголовок', ordering='title')
|
|
||||||
def clean_title(self, obj):
|
|
||||||
# Декодируем HTML-сущности ( -> U+00A0)
|
|
||||||
return html.unescape(obj.title)
|
|
||||||
</pre>
|
|
||||||
<h2>Как это работает?</h2>
|
|
||||||
<ul>
|
<ul>
|
||||||
<li>Метод <code>clean_title</code> получает объект модели.</li>
|
<li><strong>Django 6.0</strong> (или любая актуальная версия) – «движок».</li>
|
||||||
<li><code>html.unescape(obj.title)</code> превращает HTML-мнемоники в Unicode: <code>&nbsp;</code> в символ неразрывного пробела (<tt>U+00A0</tt>); <code>&shy;</code> — в мягкий перенос (<tt>U+00AD</tt>) и так далее.</li>
|
<li><strong>etpgrf</strong> — библиотека типографики.</li>
|
||||||
<li>Unicode не маскируется адимнкой Django, символы выглядят как обычный текст (или вообще не видны), поэтому список становится чистым и читаемым (и даже, более того, в списке уже будут типогра­фированные поля <code>title</code>).</li>
|
|
||||||
<li>При этом сортировка по колонке (<code>ordering='title'</code>) продолжает работать по ориги­нальному полю в базе данных.</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<p>Теперь админка выглядит опрятно, а типографика на сайте остается безупречной!</p>
|
<h2>Реализация</h2>
|
||||||
|
<p>Идея в том, чтобы создать в админке дополни­тельные «Виртуальные поля», которых нет в модели. Эти поля будут исполь­зоваться только для настройки типографа при сохранении. Например, можно добавить выпадающий список для выбора языка (русский, английский), галочку для включения обработки кавычек, и т. д. При сохранении мы будем читать эти поля, настраивать типограф и сохранять результат в базу.</p>
|
||||||
|
<h3>Шаг 1. Добавляем необходимые импорты модулей etpgrf-типографа в <tt>admin.py</tt></h3>
|
||||||
|
<pre class="border p-3 my-3 rounded bg-secondary bg-opacity-25">from django.contrib import admin
|
||||||
|
# Импортируем классы из нашей библиотеки типографики etpgrf
|
||||||
|
try:
|
||||||
|
from etpgrf.typograph import Typographer
|
||||||
|
from etpgrf.layout import LayoutProcessor
|
||||||
|
from etpgrf.hyphenation import Hyphenator
|
||||||
|
from etpgrf.sanitizer import Sanitizer
|
||||||
|
except ImportError:
|
||||||
|
# Заглушки на случай, если библиотека не установлена
|
||||||
|
class Typographer:
|
||||||
|
def __init__(self, **kwargs): pass
|
||||||
|
def process(self, text): return text
|
||||||
|
class LayoutProcessor:
|
||||||
|
def __init__(self, **kwargs): pass
|
||||||
|
class Hyphenator:
|
||||||
|
def __init__(self, **kwargs): pass</pre>
|
||||||
|
<h3>Шаг 2. Создаем кастомную форму</h3>
|
||||||
|
<p>Вместо стандартной формы админки (в <tt>admin.py</tt>, мы определим свою, унаследовав её от <code>forms.ModelForm</code>. В ней мы добавим поля, которых <strong>нет в модели</strong>:</p>
|
||||||
|
<pre class="border p-3 my-3 rounded bg-secondary bg-opacity-25">
|
||||||
|
from django import forms
|
||||||
|
from .models import TbDictumAndQuotes
|
||||||
|
|
||||||
|
class DictumAdminForm(forms.ModelForm):
|
||||||
|
# Виртуальные поля для настройки типографа
|
||||||
|
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="Заменять прямые кавычки на «ёлочки» или “лапки”"
|
||||||
|
)
|
||||||
|
etp_hanging_punctuation = forms.ChoiceField(
|
||||||
|
label="Висячая пунктуация",
|
||||||
|
choices=[('no', 'Нет'), ('left', 'Слева'), ('right', 'Справа'), ('both', 'Обе стороны')],
|
||||||
|
initial='left',
|
||||||
|
required=False,
|
||||||
|
help_text="Выносить кавычки за границу текстового блока"
|
||||||
|
)
|
||||||
|
etp_hyphenation = forms.BooleanField(
|
||||||
|
label="Переносы",
|
||||||
|
initial=True,
|
||||||
|
required=False,
|
||||||
|
help_text="Расставлять мягкие переносы (&shy;)"
|
||||||
|
)
|
||||||
|
etp_mode = forms.ChoiceField(
|
||||||
|
label="Режим вывода",
|
||||||
|
choices=[('mixed', 'Смешанный (Mixed)'), ('unicode', 'Юникод (Unicode)'), ('mnemonic', 'Мнемоники')],
|
||||||
|
initial='mixed',
|
||||||
|
required=False,
|
||||||
|
help_text="Формат спецсимволов"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = TbDictumAndQuotes
|
||||||
|
fields = '__all__'</pre>
|
||||||
|
<h3>Шаг 3. Настраиваем ModelAdmin</h3>
|
||||||
|
<p>Теперь, там же в <tt>admin.py</tt>, подключаем эту форму к нашему классу админки. Главный трюк — использовать <code>fieldsets</code>, чтобы сгруп­пировать эти новые поля в отдельный, свора­чиваемый блок «Настройки типографа».</p>
|
||||||
|
<pre class="border p-3 my-3 rounded bg-secondary bg-opacity-25">
|
||||||
|
class AdmDictumAndQuotesAdmin(admin.ModelAdmin):
|
||||||
|
form = DictumAdminForm # Подключаем нашу форму
|
||||||
|
# ... другие настройки админки (list_display, search_fields и т.д.) ...
|
||||||
|
# ...
|
||||||
|
# ...
|
||||||
|
|
||||||
|
# Группировка "виртуальных полей" типографа в отдельный блок
|
||||||
|
fieldsets = (
|
||||||
|
# Основные поля модели (можно оставить как есть, или сгруппировать по своему усмотрению).
|
||||||
|
# Поле szContent может быть в этом же блоке, так как мы его как раз типографируем.
|
||||||
|
# (None, {
|
||||||
|
# 'fields': ( ... 'szContent', ... )
|
||||||
|
# }),
|
||||||
|
('Настройки типографа (Etpgrf)', {
|
||||||
|
'classes': ('collapse',),
|
||||||
|
'fields': (
|
||||||
|
('etp_language', 'etp_mode'),
|
||||||
|
('etp_hyphenation', 'etp_hanging_punctuation'),
|
||||||
|
),
|
||||||
|
'description': 'Настройки применяются при сохранении. Результат записывается в скрытые HTML-поля.'
|
||||||
|
}),
|
||||||
|
# ... другие fieldsets, если нужно ...
|
||||||
|
# ...
|
||||||
|
# ... Например, можно добавить отдельный блок для отображения результата типографирования (только для чтения) ...
|
||||||
|
# ('HTML Результат (ReadOnly)', {
|
||||||
|
# 'classes': ('collapse',),
|
||||||
|
# 'fields': ('szContentHTML',),
|
||||||
|
# }),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ...</pre>
|
||||||
|
<h3>Шаг 4. Перехват сохранения (save_model)</h3>
|
||||||
|
<p>Сейчас, когда у нас есть форма с дополни­тельными полями, нам нужно:</p>
|
||||||
|
<ol>
|
||||||
|
<li>Переопре­делить метод <code>save_model</code> в нашем классе админки.</li>
|
||||||
|
<li>Внутри этого метода прочитать значения виртуальных полей из формы (<code>LayoutProcessor</code>, <code>Hyphenator</code> и другие).</li>
|
||||||
|
<li>Инициали­зировать <code>Typographer</code> с этими настройками.</li>
|
||||||
|
<li>Обработать текст из полей <code>szContent</code>, сохранив результат в <code>szContentHTML</code>.</li>
|
||||||
|
</ol>
|
||||||
|
<pre class="border p-3 my-3 rounded bg-secondary bg-opacity-25">
|
||||||
|
def save_model(self, request, obj, form, change):
|
||||||
|
# 1. Читаем базовые настройки языка и режима из формы
|
||||||
|
langs = form.cleaned_data.get('etp_language', 'ru').split(',')
|
||||||
|
|
||||||
|
# 2. Собираем LayoutProcessor
|
||||||
|
layout_option = False
|
||||||
|
# Включаем layout по умолчанию с базовыми настройками (инициалы, юниты)
|
||||||
|
layout_option = LayoutProcessor(
|
||||||
|
langs=langs,
|
||||||
|
process_initials_and_acronyms=True,
|
||||||
|
process_units=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Собираем Hyphenator (переносы слов)
|
||||||
|
hyphenation_enabled = form.cleaned_data.get('etp_hyphenation', True)
|
||||||
|
hyphenation_option = False
|
||||||
|
if hyphenation_enabled:
|
||||||
|
hyphenation_option = Hyphenator(
|
||||||
|
langs=langs,
|
||||||
|
max_unhyphenated_len=12
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Читаем Hanging Punctuation (висячая пунктуация)
|
||||||
|
hanging_val = form.cleaned_data.get('etp_hanging_punctuation', 'no')
|
||||||
|
hanging_option = None
|
||||||
|
if hanging_val != 'no':
|
||||||
|
hanging_option = hanging_val
|
||||||
|
|
||||||
|
# 5. Собираем все настройки типографа в словарь
|
||||||
|
options = {
|
||||||
|
'langs': langs,
|
||||||
|
'process_html': True,
|
||||||
|
'quotes': form.cleaned_data.get('etp_quotes', True),
|
||||||
|
'layout': layout_option,
|
||||||
|
'unbreakables': True,
|
||||||
|
'hyphenation': hyphenation_option,
|
||||||
|
'symbols': True,
|
||||||
|
'hanging_punctuation': hanging_option,
|
||||||
|
'mode': form.cleaned_data.get('etp_mode', 'mixed'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Инициализируем типограф с настройками из формы
|
||||||
|
try:
|
||||||
|
# DEBUG: Проверка, какой класс используется
|
||||||
|
if Typographer.__module__ == __name__: # Если класс определен в этом же файле (заглушка)
|
||||||
|
self.message_user(request, "ВНИМАНИЕ: Используется заглушка Typographer! Библиотека etpgrf не найдена.", level='WARNING')
|
||||||
|
|
||||||
|
t = Typographer(**options)
|
||||||
|
|
||||||
|
# Обрабатываем контент
|
||||||
|
if obj.szContent:
|
||||||
|
# В онлайн-типографе используется .process(text)
|
||||||
|
old_html = obj.szContentHTML or ""
|
||||||
|
processed = t.process(obj.szContent)
|
||||||
|
obj.szContentHTML = processed
|
||||||
|
|
||||||
|
# DEBUG: Проверка изменений
|
||||||
|
if processed != old_html and processed != obj.szContent:
|
||||||
|
self.message_user(request, f"Типограф: szContentHTML обновлен (len changed: {len(old_html)} -> {len(processed)})", level='INFO')
|
||||||
|
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Возникла ошибка при обработке типографом, сохраняем оригинальный текст и показываем сообщение об ошибке
|
||||||
|
self.message_user(request, f"Ошибка типографа: {e}", level='ERROR')
|
||||||
|
if not obj.szContentHTML: obj.szContentHTML = obj.szContent
|
||||||
|
|
||||||
|
super().save_model(request, obj, form, change)</pre>
|
||||||
|
<h3>Шаг 5. Очистка модели</h3>
|
||||||
|
<p>Так как логика обработки и сохранения поля <code>szContentHTML</code> «переехала» в админку, нам нужно <strong>убрать</strong> логику его записи из метода <code>save()</code> модели внутри <tt>models.py</tt>.
|
||||||
|
</p><p>Теперь метод <code>save()</code> в <tt>models.py</tt> должен быть максимально простым:</p>
|
||||||
|
<pre class="border p-3 my-3 rounded bg-secondary bg-opacity-25">
|
||||||
|
class TbDictumAndQuotes(models.Model):
|
||||||
|
# ... поля ...
|
||||||
|
# ...
|
||||||
|
# ...
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# Типографирование поля szContentHTML перенесено в админку (через admin.save_model).
|
||||||
|
# Здесь оставляем только базовую подстраховку: если HTML пуст, заполняем оригиналом.
|
||||||
|
if not self.szContentHTML and self.szContent:
|
||||||
|
self.szContentHTML = self.szContent
|
||||||
|
|
||||||
|
super(TbDictumAndQuotes, self).save(*args, **kwargs)</pre>
|
||||||
|
<h2>Результат</h2>
|
||||||
|
<p>Теперь редактор видит обычную админку, пишет текст, открывает блок «Настройки типографа», выбирает нужные опции (например, «Включить висячую пунктуацию слева») и нажимает «Сохранить».</p>
|
||||||
|
<p>При сохранении Django читает эти настройки, инициа­лизирует типограф, обрабатывает текст и сохраняет в базу уже готовый HTML. В итоге:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Чистый исходный текст в поле <code>szContent</code> (для правки в будущем).</li>
|
||||||
|
<li>Готовый, красивый HTML в поле <code>szContentHTML</code> (с <code>&nbsp;</code>, висячими кавычками и т. п., который можно сразу выводить на сайте.</li>
|
||||||
|
</ul>
|
||||||
|
<p>При этом таблица базы данных не замусорена колонками <code>is_hanging_punctuation_enabled</code>, которые нужны только в момент сохранения. <strong>Кроме того, это абсолютно безопасно:</strong> «виртуальные поля» существуют только в форме админки и не являются полями модели — Django их не сериализует и не пытается сохранить в БД, схема данных не меняется, а сами значения живут лишь в момент сохранения и влияют только на обработку текста.</p>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user