-

Разберём задачу на конкретном примере. У меня есть сайт с цитатами (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) и базе данных, но запускается из консоли через python manage.py ....

- class Meta: - model = TbDictumAndQuotes - fields = '__all__'
-

Шаг 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)

-

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

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

+Теперь, чтобы "починить" всю базу, нам достаточно набрать одну строку в терминале, и Django сделает всю грязную работу за нас. + +## Запуск + +```bash +# Тестовый прогон (безопасно, ничего не сохраняет) +python manage.py reprocess_typography --dry-run + +# Боевой запуск (изменяет данные в базе!) +python manage.py reprocess_typography +```