Разберём задачу на конкретном примере. У меня есть сайт с цитатами (DQ – коллекция цитат. Место для вдумчивого чтения, в нем большое внимание уделяется типографике, а значит при размещении цитат через иметь возможность не только редакти­ровать текст, но и управлять настройками типографа. В проекте есть модель Dictum с полем content (исходный текст) и content_html (типогра­фированный HTML для вывода на сайте). Я хочу, чтобы редактор мог управлять настройками типографа (язык, кавычки, переносы) прямо в админке, не меняя структуру БД.

Инструменты

  • Django 6.0 (или любая актуальная версия) – «движок».
  • etpgrf — библиотека типографики.

Реализация

Идея в том, чтобы создать в админке дополни­тельные «Виртуальные поля», которых нет в модели. Эти поля будут исполь­зоваться только для настройки типографа при сохранении. Например, можно добавить выпадающий список для выбора языка (русский, английский), галочку для включения обработки кавычек, и т. д. При сохранении мы будем читать эти поля, настраивать типограф и сохранять результат в базу.

Шаг 1. Добавляем необходимые импорты модулей etpgrf-типографа в admin.py

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

Шаг 2. Создаем кастомную форму

Вместо стандартной формы админки (в admin.py, мы определим свою, унаследовав её от forms.ModelForm. В ней мы добавим поля, которых нет в модели:

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="Расставлять мягкие переносы (­)"
    )
    etp_mode = forms.ChoiceField(
        label="Режим вывода",
        choices=[('mixed', 'Смешанный (Mixed)'), ('unicode', 'Юникод (Unicode)'), ('mnemonic', 'Мнемоники')],
        initial='mixed',
        required=False,
        help_text="Формат спецсимволов"
    )

    class Meta:
        model = TbDictumAndQuotes
        fields = '__all__'

Шаг 3. Настраиваем ModelAdmin

Теперь, там же в admin.py, подключаем эту форму к нашему классу админки. Главный трюк — использовать fieldsets, чтобы сгруп­пировать эти новые поля в отдельный, свора­чиваемый блок «Настройки типографа».

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',),
        # }),
    )

    # ...

Шаг 4. Перехват сохранения (save_model)

Сейчас, когда у нас есть форма с дополни­тельными полями, нам нужно:

  1. Переопре­делить метод save_model в нашем классе админки.
  2. Внутри этого метода прочитать значения виртуальных полей из формы (LayoutProcessor, Hyphenator и другие).
  3. Инициали­зировать Typographer с этими настройками.
  4. Обработать текст из полей szContent, сохранив результат в szContentHTML.
    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)

Шаг 5. Очистка модели

Так как логика обработки и сохранения поля szContentHTML «переехала» в админку, нам нужно убрать логику его записи из метода save() модели внутри models.py.

Теперь метод save() в models.py должен быть максимально простым:

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)

Результат

Теперь редактор видит обычную админку, пишет текст, открывает блок «Настройки типографа», выбирает нужные опции (например, «Включить висячую пунктуацию слева») и нажимает «Сохранить».

При сохранении Django читает эти настройки, инициа­лизирует типограф, обрабатывает текст и сохраняет в базу уже готовый HTML. В итоге:

  • Чистый исходный текст в поле szContent (для правки в будущем).
  • Готовый, красивый HTML в поле szContentHTML (с  , висячими кавычками и т. п., который можно сразу выводить на сайте.

При этом таблица базы данных не замусорена колонками is_hanging_punctuation_enabled, которые нужны только в момент сохранения. Кроме того, это абсолютно безопасно: «виртуальные поля» существуют только в форме админки и не являются полями модели — Django их не сериализует и не пытается сохранить в БД, схема данных не меняется, а сами значения живут лишь в момент сохранения и влияют только на обработку текста.