ModelAdmin

Каждая декларативная опция — что она меняет на экране и что происходит, если задать её неправильно.

Доступно с версии v0.1.0

ModelAdmin описывает, как представляется одна модель. Это словарь Django с одним существенным отличием: каждое указанное вами имя проверяется относительно реальной модели при сборке админки, поэтому опечатка — это ошибка запуска, перечисляющая их все сразу, а не 500-я ошибка, когда кто-то впервые откроет эту страницу.

from fastfort import admin

from app.models import Product


@admin.register(Product, key="catalog.product")
class ProductAdmin(admin.ModelAdmin):
    list_display = ("id", "sku", "name", "price", "is_active")
    list_filter = ("is_active", "category")
    search_fields = ("sku", "name")
    ordering = ("-created_at",)

Регистрация

@admin.register(model, *, key=None)

Выполняется во время импорта и буферизует регистрацию; include_admin, autodiscover и mount опустошают этот буфер. Именно благодаря этой косвенности admin.py можно писать, не импортируя ваше приложение — иначе модуль моделей и модуль админки, оба импортирующие main, в любом проекте образовали бы циклический импорт.

key переопределяет автоматически выведенный ключ реестра. Он становится URL: /admin/<key>/. Передавайте его, когда две модели из разных пакетов иначе получили бы одинаковый ключ, и вообще передавайте его всегда — ключ, следующий логике вашего домена (catalog.product), в URL читается лучше, чем ключ, следующий структуре файлов.

Список записей

list_display

Столбцы, по порядку. Если не задано — первичный ключ плюс несколько первых читаемых полей, не более шести — так что даже голая регистрация показывает полезную таблицу.

list_display = ("id", "sku", "name", "category", "price", "is_active")

Столбцы отношений отображаются через __str__ связанного объекта — именно это делает внешний ключ читаемым, а не просто целым числом. Многие-ко-многим отображаются как чипы. Геометрия отображается как сводка — Polygon · 14 points — а не как WKB-hex, который выглядит как повреждённые данные.

Какие ячейки становятся ссылкой на запись. По умолчанию — первый столбец.

list_display_links = ("sku", "name")

ordering

В стиле Django: ведущий - означает убывающий порядок. Без него — сначала новые, по первичному ключу — полезное значение по умолчанию для админки.

ordering = ("-created_at", "name")

Указание столбца, который нельзя сортировать, — ошибка запуска.

list_filter

Панель фильтров. Ограничена тем, что спецификация помечает как фильтруемое — так что столбец со свободным текстом не может превратиться в выпадающий список из десяти тысяч значений.

list_filter = ("availability", "is_active", "category", "price", "released_on")

Вы никогда не указываете элемент управления напрямую. Его выбирает тип столбца — полное соответствие смотрите в разделе Фильтры.

Два вида полей отклоняются, и именно в этом отказе есть польза:

  • Свободный текст. Фильтр по столбцу String или Text превратился бы в выпадающий список со всеми различными значениями таблицы.
  • Многие-ко-многим. Значение множественное, поэтому нет единого значения для сравнения.

Оба типа остаются доступными для поиска, сортировки и отображения. Указание одного из них здесь — это ConfigurationError при сборке, с указанием, какое поле и почему.

search_fields

Что охватывает поле поиска. Если не указать ни одного поля, поле поиска вообще не отрисовывается — это лучше, чем поле, которое молча ничего не находит.

search_fields = ("sku", "name", "description")

Подходят только текстоподобные столбцы. inet намеренно исключён, хотя выглядит как текст: у PostgreSQL нет LIKE для этого типа, поэтому icontains по нему завершается ошибкой operator does not exist, а поле поиска, которое отдаёт 500-ю ошибку, хуже, чем поле, которое просто пропускает столбец.

list_per_page

Переопределяет admin.page_size для этой модели. Список также предлагает элемент управления размером страницы, ограниченный admin.max_page_size.

Отношения для предзагрузки. Без них список, показывающий имя связанного объекта, стоит одного дополнительного запроса на каждую строку.

select_related = ("category", "supplier")   # to-one, joined
prefetch_related = ("tags",)                # to-many, second query

list_editable

Столбцы, редактируемые прямо в списке, без открытия формы.

list_display = ("id", "name", "stock", "is_active")
list_editable = ("stock", "is_active")

Вся таблица становится одной формой с одной кнопкой «Сохранить». Элемент управления, отправляющий данные сам по себе, означал бы один запрос на ячейку и полное отсутствие способа отправить их без JavaScript — у Django list_editable устроен так же и по той же причине.

Все строки или ни одной: одно некорректное значение откатывает всю отправку, потому что наполовину сохранённая таблица хуже той, что отказала, — на экране ничего не сказало бы, какая половина записалась.

Столбец должен присутствовать в list_display, быть доступным для записи и использовать тот же узкий набор элементов управления, что и строка inline.

Форма

readonly_fields

Отображаются, но никогда не записываются. Объединяется с собственным списком разрешённых полей спецификации, поэтому это только сужает, но никогда не расширяет доступ.

readonly_fields = ("created_at", "updated_at", "embedding")

password_fields

Столбцы, хранящие хеш пароля. Их элемент управления принимает новый пароль и его подтверждение, хеширует его с помощью Argon2id и оставляет сохранённое значение без изменений, если оба поля пусты.

Определяется автоматически, если не объявлено явно: поле, которое адаптер типизировал как пароль, либо конфиденциальный столбец, чьё имя об этом говорит — именно так обычный String с именем hashed_password подхватывается без единого слова с вашей стороны.

formfield_overrides

Какой элемент управления отрисовывает поле, переопределяя выбор, сделанный на основе его типа. Ключом служит имя поля или FieldType, поэтому можно переопределить тип одного столбца либо всех столбцов определённого рода.

formfield_overrides = {
    "brand_colour": "color",   # a 7-char string is a colour only because you say so
    "photo": "image",
    "datasheet": "file",
    "description": "richtext",
}

Если совпадают оба варианта, имя побеждает тип. Имена проверяются относительно спецификации на этапе объявления, и неизвестное имя виджета — это ошибка сборки, а не поле, которое молча становится доступным только для чтения.

field_labels

Подписи для отдельных полей, переопределяющие то, что адаптер вывел из столбца.

field_labels = {"sku": "Stock code", "metadata_json": "Metadata"}

fieldsets

Без него все поля попадают в одну сетку, в порядке спецификации, — что устраивает короткую форму и совершенно не устраивает модель с двадцатью пятью столбцами.

fieldsets = (
    (None, {"fields": ("name", "sku", "category")}),
    ("Pricing", {"fields": ("price", "cost"), "description": "Shown to customers."}),
    ("Logistics", {"fields": ("weight", "dimensions"), "collapsed": True}),
)

Заголовок None отрисовывает секцию без заголовка — для первой группы, которой имя не нужно. collapsed отрисовывает её свёрнутой: это <details>, поэтому она всё равно раскрывается без JavaScript.

Назвать поле дважды — ошибка на этапе запуска, потому что два элемента управления с одним именем означают, что молча побеждает второй. Так же и пропустить поле, которое требует база данных: форма отрисовалась бы, сохранилась и упала на ограничении NOT NULL с именем, которое за пределами базы никому ни о чём не говорит. Пропустить необязательное поле можно, и это не вызывает замечаний — сужение формы как раз и есть смысл списка секций.

inlines

Дочерние записи модели, редактируемые на её собственной странице.

from fastfort import admin

from app.models import Order, OrderLine


class OrderLineInline(admin.TabularInline):
    model = OrderLine
    fields = ("sku", "quantity", "unit_price")
    extra = 1


@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    inlines = (OrderLineInline,)

Внешний ключ на родителя определяется автоматически, если потомок указывает на него один раз; fk_name называет его, когда таких ключей два. Как столбец он не предлагается никогда — его задаёт сама связь, а выпадающий список для него в каждой строке это приглашение случайно перенести позицию в другой заказ.

Сохраняется в транзакции родителя. Потомок, который не разобрался, оставляет и родителя незаписанным, потому что половина заказа хуже, чем ничего.

extra пустых строк отрисовывает сервер — именно так потомок добавляется без JavaScript; кнопка «Добавить ещё» клонирует строку, когда скрипт работает. can_delete ставит галочку в каждой строке — именно галочку, а не кнопку, потому что удаление сохранённого потомка это часть сохранения родителя и должно пережить круговой рейс до сервера.

Табличный inline принимает намеренно узкий набор элементов управления: текст, числа, деньги, даты, логические значения, перечисления и связи «к одному». Карта, карточка загрузки или редактор форматированного текста выше строки, которая их содержит, и хуже той формы, поход на которую они экономили. Названный в fields, такой столбец даёт ошибку на этапе запуска; оставленный на усмотрение по умолчанию — просто пропускается.

Массовые действия

actions

Предлагаются после выбора строк. "delete" встроено и включено по умолчанию; всё остальное — имя метода, помеченного @admin.action.

actions = ("delete", "activate", "mark_discontinued")

Установите actions = (), чтобы не предлагать ни одного действия — именно так модель, чьи строки никогда не должны удаляться массово, заявляет об этом. На кнопку удаления отдельной строки это не влияет.

@admin.action(label, *, icon=None, danger=False, confirm=None)

@admin.action(
    "Mark discontinued",
    icon="trash",
    danger=True,
    confirm="Mark {count} products discontinued?",
)
async def mark_discontinued(self, adapter, objects):
    for product in objects:
        await adapter.update(product, {"availability": "discontinued"})
    return f"{len(objects)} products discontinued."

Метод получает адаптер этой модели и выбранные строки, а возвращает сообщение для показа. Он никогда не коммитит. Unit of work запроса коммитится при штатном завершении и откатывается при исключении, поэтому действие, упавшее на сороковой строке, не оставляет после себя ничего.

Метод, помеченный меткой, но не включённый в actions, недостижим — отправка его имени в запросе ни к чему не приведёт.

bulk_editable

Поля, которые массовое редактирование может задать для каждой выбранной строки.

bulk_editable = ("status", "category", "is_active")

По умолчанию пусто, что отключает действие. Подключается явно, в отличие от delete: удаление заявляет о себе и спрашивает, а один неверно выставленный столбец на сорока строках это молчаливое изменение, которое замечают позже.

Выбор действия открывает страницу с вопросом, какое поле и какое значение, и уже эта страница отправляет запись, — та же форма, что и у подтверждения удаления, и именно она позволяет всему этому работать без JavaScript.

Список разрешённого — это объявление, а не спецификация: поле, вполне доступное для записи в форме, здесь всё равно будет отклонено, если оно не названо. Оно может только сузить то, что уже решили FieldSpec.editable и readonly_fields, но никогда не расширить.

Экспорт и импорт

exportable · export_fields

Экспорт включён по умолчанию: текущее представление выгружается в CSV, Excel или JSON с применёнными фильтрами, поиском и сортировкой, так что файл совпадает с таблицей, из которой он получен.

exportable = True                    # the default
export_fields = ("sku", "name", "price", "stock")   # defaults to list_display

Отключите его для модели, чьи строки не должны покидать админку в виде файла.

importable · import_fields

Импорт выключен по умолчанию, и эта асимметрия намеренна: чтение строк — это право, которое уже даёт шлюз доступа; а запись нескольких тысяч строк в одном запросе — это совсем другое, что нельзя вручить кому-то по ошибке.

importable = True
import_fields = ("sku", "name", "price", "stock")

import_fields никогда не расширяет то, что может быть записано — сначала проверяется FieldSpec.editable, поэтому указание здесь поля только для чтения не делает его доступным для записи. Смысл именно в сужении: прайс-лист может обновлять цены, но никогда — поставщика.

Оформление

verbose_name · verbose_name_plural

Переопределения для боковой панели и заголовков страниц. Если не заданы, выводятся из имени модели.

Они не переводятся, и это намеренно. Название модели — это ваше слово для вашей предметной области, и FastFort не вправе угадывать его на одиннадцати языках — по той же причине Django тоже не переводит имена ваших моделей. FastFort переводит только собственный интерфейс: кнопки, фильтры, сообщения.

group_name

Заголовок в боковой панели, под которым размещается эта модель. Если не задан, используется пространство имён из ключа реестра — catalog.product группируется под Catalog.

icon

Имя из fastfort.ui.icons, отображаемое рядом с пунктом боковой панели. Проверяется на этапе объявления, поэтому опечатка — это ошибка, перечисляющая все доступные иконки, а не молча пустой слот.

icon = "box"   # users, shield, key, folder, tag, truck, map-pin, database, …

Вычисляемые столбцы

@admin.display(*, label=None, ordering=None, boolean=False)

@admin.display(label="Margin", ordering="price")
def margin_display(self, obj):
    return f"{obj.price - obj.cost:,.0f}"

ordering указывает реальный столбец для сортировки, поскольку у вычисляемого значения своего нет. Без него заголовок отрисовывается несортируемым, вместо того чтобы породить сортировку, которую база данных не сможет выполнить.

Если что-то указано неверно

Каждая из перечисленных выше опций проверяется относительно спецификации модели при сборке ModelAdmin — а это происходит при первом обращении к странице этой модели, а не при вызове mount(). Проблемы собираются до того, как выбрасывается исключение, поэтому один запуск сообщает обо всех сразу:

ConfigurationError: ProductAdmin is misconfigured:
  - list_display names 'pirce', which catalog.product has no
  - list_filter names 'description'; free-text and multi-valued fields
    cannot be offered as a filter
  - ordering names 'rank', which is not sortable
  - search_fields names 'price', which is not a text field
  - icon names 'rocket', which is not one of: bell, book, box, calendar, …
  - actions names 'archive', which is missing the @admin.action mark
Hint: Fields available on catalog.product: availability, category, cost,
      created_at, description, embedding, id, image, is_active, name, price, …

Поскольку проверка выполняется для каждой модели отдельно и лениво, чтобы увидеть все проблемы сразу, нужно либо открыть каждую страницу, либо самостоятельно инстанцировать админки в тесте:

def test_every_admin_is_valid(fort):
    for entry in fort.registry:
        spec = fort.backend.introspect(entry.model, key=entry.key)
        entry.admin(spec)   # raises ConfigurationError if anything is wrong

Стоит добавить в набор тестов проекта: это превращает страницу, которую никто не открыл перед деплоем, в проваливающийся тест.