Как «подружить» Django Admin и типограф etpgrf: Виртуальные поля для настроек
+Массовая типографика контента в Django: пользовательские команды управления (Custom Management Commands)
Многие контент-проекты сталкиваются с дилеммой: хочется красивой типографики (правильные кавычки «ёлочки», длинные тире —, неразрывные пробелы ). Самое очевидное решение — переопределить метод safe(), но каждый блок текста — заглоаок, тизер, статья — требуют разных настроек. В заголовках хочестя иметь «висячую пунктуацию» и запретить переносы в словах, для основного текста публикации — наоборот, одна публикация на английском и нужны кавычки “лапки”, другая на русском — и нужны «ёлочки».
Хранить все настройки в базе данных (добавляя десятки полей bool в модель) — плохая идея. Это «засоряет» схему данных параметрами отображения, раздувает базу «мусорной информацией» и некрасиво с точки зрения архитектуры.
Решение: Если у вас Django в качестве бэкенда, то наилучший подход — использовать «Виртуальные поля» (Virtual Fields) в Django Admin: Добавить настройки типографа прямо в форму редактирования админки, применить эти настройки при сохранении, и забыть о них, сохранив в базу только готовый, красивый HTML.
+Если вы работали с Django, вы наверняка использовали команду manage.py сотни раз: python manage.py runserver, migrate, createsuperuser.
Но знаете ли вы, что можете легко создавать свои собственные команды? Это один из самых мощных и недооцененных инструментов Django для автоматизации рутины. В этом посте я расскажу, что такое Management Command, зачем она нужна и как её использовать на примере реальной задачи: массовой типографики контента в базе данных.
Разберём задачу на конкретном примере. У меня есть сайт с цитатами (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="Формат спецсимволов" - ) +Management Command — это обычный Python-скрипт, который "встраивается" в экосистему Django. Он имеет доступ к моделям, настройкам (settings.py) и базе данных, но запускается из консоли через
- class Meta: - model = TbDictumAndQuotes - fields = '__all__'python manage.py ....
Шаг 3. Настраиваем ModelAdmin
-Теперь, там же в admin.py, подключаем эту форму к нашему классу админки. Главный трюк — использовать fieldsets, чтобы сгруппировать эти новые поля в отдельный, сворачиваемый блок «Настройки типографа».
-class AdmDictumAndQuotesAdmin(admin.ModelAdmin): - form = DictumAdminForm # Подключаем нашу форму - # ... другие настройки админки (list_display, search_fields и т.д.) ... - # ... - # ... +Зачем это нужно?
+Представьте ситуации:
+
-
+
- Нужно пересчитать рейтинг для десятки тысяч товаров на сайте или сделать типографику всех публикаций после подключения etpgrf-типографа (или любого другого). +
- Необходимо обновить цены для всех товарных карточек в соответствии с таблицей excel или по API (или спарсить данные с внешнего сайта). +
- Нужно отправить e-mail рассылку пользователям. +
- Нужно пометить комментарии или публикации как архивные в соответствии с правилом или почистить базу от старых логов. +
Делать это через views.py (views) — плохая идея (страница может отвалиться по тайм-ауту). Писать отделный скрипт рядом manage.py — неудобно (нужно вручную настраивать `DJANGO_SETTINGS_MODULE`). Встроенные команды решают эти проблемы элегантно.
- # Группировка "виртуальных полей" типографа в отдельный блок - 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',), - # }), - ) +Как это работает?
+Django использует систему "автообнаружения" (auto-discovery). Чтобы ваша команда появилась в списке `manage.py`, нужно соблюсти строгую иерархию папок внутри вашего приложения (например, web):
+web/ +├── __init__.py +├── models.py +└── management/ 🠀 1. Создаем папку management + ├── __init__.py + └── nginx/ + ├── commands/ 🠀 2. Внутри неё папку commands + ├── __init__.py + └── my_cool_script.py 🠀 3. Наш файл с командой+
Файл my_cool_script.py автоматически превратится в команду: python manage.py my_cool_script. Django будет искать его в папках management/commands/ внутри каждого установленного приложения. Это позволяет легко организовать код и держать все "команды обслуживания" в одном месте.
Для справки: Механизм Custom Management Commands появился еще в Django 0.96 (в глубокой древности) и с тех пор является стандартом де-факто для написания скриптов обслуживания.
- # ... -Шаг 4. Перехват сохранения (save_model)
-Сейчас, когда у нас есть форма с дополнительными полями, нам нужно:
--
-
- Переопределить метод
save_modelв нашем классе админки.
- - Внутри этого метода прочитать значения виртуальных полей из формы (
LayoutProcessor,Hyphenatorи другие).
- - Инициализировать
Typographerс этими настройками.
- - Обработать текст из полей
szContent, сохранив результат вszContentHTML.
-
- def save_model(self, request, obj, form, change):
- # 1. Читаем базовые настройки языка и режима из формы
- langs = form.cleaned_data.get('etp_language', 'ru').split(',')
+ Анатомия команды
+ Вот пример простейшей команды. Мы наследуемся от класса BaseCommand и переопределяем метод handle:
+
+# web/management/commands/hello.py
+from django.core.management.base import BaseCommand
- # 2. Собираем LayoutProcessor
- layout_option = False
- # Включаем layout по умолчанию с базовыми настройками (инициалы, юниты)
- layout_option = LayoutProcessor(
- langs=langs,
- process_initials_and_acronyms=True,
- process_units=True
+class Command(BaseCommand):
+ help = 'Выводит приветствие' # Описание для --help
+
+ def add_arguments(self, parser):
+ # Можно добавлять аргументы, как в argparse
+ parser.add_argument('name', type=str, help='Имя пользователя')
+
+ def handle(self, *args, **options):
+ # Главная логика
+ name = options['name']
+ self.stdout.write(f"Привет, {name}!")
+
+Запуск:
+```bash
+python manage.py hello Иван
+# Вывод: Привет, Иван!
+```
+
+## Реальный пример: Массовая типографика
+
+В нашем проекте возникла задача: у нас есть тысячи цитат, сохраненных со старой разметкой. Мы хотим "прогнать" их все через новый типограф `etpgrf` с новыми настройками (висячая пунктуация слева).
+
+Вот как мы это реализовали в `reprocess_typography.py`:
+
+```python
+from django.core.management.base import BaseCommand
+from web.models import TbDictumAndQuotes
+# Импорты библиотеки etpgrf
+from etpgrf.typograph import Typographer
+from etpgrf.sanitizer import SanitizerProcessor
+# ... другие процессоры, если нужно ...
+
+class Command(BaseCommand):
+ help = 'Переобрабатывает все цитаты'
+
+ def add_arguments(self, parser):
+ # Добавляем флаг "сухой прогон"
+ parser.add_argument(
+ '--dry-run',
+ action='store_true',
+ help='Запустить без сохранения изменений',
)
+ # Ограничение количества
+ parser.add_argument('--limit', type=int, help='Сколько записей обработать')
+ # Смещение (для пагинации)
+ parser.add_argument('--offset', type=int, default=0, help='Сколько пропустить')
- # 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'),
+ def handle(self, *args, **options):
+ # 1. Настраиваем типограф
+ # ... инициализация процессоров ...
+ settings = {
+ # ...
+ 'sanitizer': SanitizerProcessor(mode="html"), # Режим HTML-очистки
+ 'hanging_punctuation': 'left', # Режим висячей пунктуации
+ # ...
}
+ typographer = Typographer(**settings)
- # Инициализируем типограф с настройками из формы
+ # 2. Получаем записи из базы с учетом limit/offset
+ qs = TbDictumAndQuotes.objects.all().order_by('id')
+
+ start = options['offset']
+ if options['limit']:
+ qs = qs[start : start + options['limit']]
+ else:
+ qs = qs[start:]
+
+ self.stdout.write(f"Найдено {qs.count()} цитат...")
+
+ # 3. Бежим по циклу с прогресс-баром (tqdm если есть)
try:
- # DEBUG: Проверка, какой класс используется
- if Typographer.__module__ == __name__: # Если класс определен в этом же файле (заглушка)
- self.message_user(request, "ВНИМАНИЕ: Используется заглушка Typographer! Библиотека etpgrf не найдена.", level='WARNING')
+ from tqdm import tqdm
+ iterator = tqdm(qs)
+ except ImportError:
+ iterator = qs
- t = Typographer(**options)
+ for dq in iterator:
+ try:
+ # Обрабатываем текст
+ new_html = typographer.process(dq.szContent)
- # Обрабатываем контент
- if obj.szContent:
- # В онлайн-типографе используется .process(text)
- old_html = obj.szContentHTML or ""
- processed = t.process(obj.szContent)
- obj.szContentHTML = processed
+ if options['dry_run']:
+ self.stdout.write(f"[{dq.id}] Предпросмотр: {new_html[:50]}...")
+ else:
+ dq.szContentHTML = new_html
+ dq.save(update_fields=['szContentHTML'])
- # 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.stdout.write(self.style.ERROR(f"Ошибка id={dq.id}: {e}"))
- except Exception as e:
- # Возникла ошибка при обработке типографом, сохраняем оригинальный текст и показываем сообщение об ошибке
- self.message_user(request, f"Ошибка типографа: {e}", level='ERROR')
- if not obj.szContentHTML: obj.szContentHTML = obj.szContent
+ self.stdout.write(self.style.SUCCESS(f"\nГотово!"))
+```
- 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 +1. **`self.stdout.write` вместо `print`**: Это важно. Это позволяет Django перехватывать вывод (например, для тестов) и корректно работать с кодировками. +2. **`self.style.SUCCESS / ERROR`**: Раскрашивает текст в консоли (зеленый/красный). Очень удобно для визуального восприятия логов. +3. **Аргументы (`--dry-run`)**: Позволяют безопасно тестировать скрипт на продакшене перед тем, как реально менять данные. +4. **`update_fields`**: При сохранении мы не перезаписываем всю модель целиком (что могло бы затереть изменения, сделанные кем-то другим в ту же секунду), а обновляем только конкретные колонки. - super(TbDictumAndQuotes, self).save(*args, **kwargs)-
Результат
-Теперь редактор видит обычную админку, пишет текст, открывает блок «Настройки типографа», выбирает нужные опции (например, «Включить висячую пунктуацию слева») и нажимает «Сохранить».
-При сохранении Django читает эти настройки, инициализирует типограф, обрабатывает текст и сохраняет в базу уже готовый HTML. В итоге:
--
-
- Чистый исходный текст в поле
szContent(для правки в будущем).
- - Готовый, красивый HTML в поле
szContentHTML(с , висячими кавычками и т. п., который можно сразу выводить на сайте.
-
При этом таблица базы данных не замусорена колонками is_hanging_punctuation_enabled, которые нужны только в момент сохранения. Кроме того, это абсолютно безопасно: «виртуальные поля» существуют только в форме админки и не являются полями модели — Django их не сериализует и не пытается сохранить в БД, схема данных не меняется, а сами значения живут лишь в момент сохранения и влияют только на обработку текста.