ModelAdmin

Every declarative option, what it changes on screen, and what happens when you get one wrong.

Available since v0.1.0

ModelAdmin describes how one model is presented. It is Django’s vocabulary, with one difference that matters: every name you write is checked against the real model when the admin is built, so a typo is a start-up error listing all of them at once — not a 500 the first time somebody opens that page.

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",)

Registration

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

Runs at import time and buffers the registration; include_admin, autodiscover and mount drain the buffer. That indirection is why an admin.py can be written without importing your application — a models module and an admin module that both imported main would be a circular import in every project.

key overrides the derived registry key. It becomes the URL: /admin/<key>/. Pass it when two models in different packages would otherwise derive the same one, and pass it in general — a key that follows your domain (catalog.product) reads better in a URL than one that follows your file layout.

The list view

list_display

The columns, in order. Empty means the primary key plus the first few readable fields, capped at six — so a bare registration still shows a useful table.

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

Relation columns render through the related object’s __str__, which is what makes a foreign key readable instead of an integer. A many-to-many renders as chips. A geometry renders as a summary — Polygon · 14 points — rather than the WKB hex that reads as corruption.

Which cells link to the record. Defaults to the first column.

list_display_links = ("sku", "name")

ordering

Django-style, a leading - for descending. Without it, newest-first by primary key — the useful default for an admin.

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

Naming a column that is not sortable is a start-up error.

list_filter

The filter panel. Restricted to what the spec marks filterable, so a free-text column cannot become a dropdown of ten thousand values.

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

You never name a control. The column’s type picks it — see Filters for the full mapping.

Two kinds of field are refused, and the refusal is the useful part:

  • Free text. A String or Text column’s filter would be a dropdown holding every distinct value in the table.
  • Many-to-many. Multi-valued, so there is no single value to match against.

Both remain searchable, sortable and displayable. Naming one here is a ConfigurationError at build time, saying which and why.

search_fields

What the search box covers. Without any, the box is not drawn at all — which is better than a box that silently matches nothing.

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

Only text-like columns qualify. inet is deliberately excluded even though it reads like text: PostgreSQL has no LIKE for it, so an icontains over one fails with operator does not exist, and a search box that 500s is worse than one that skips the column.

list_per_page

Overrides admin.page_size for this model. The list also offers a size control, capped at admin.max_page_size.

Relations to preload. Without them, a list showing a related name costs one extra query per row.

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

The form

readonly_fields

Shown, never written. Merged into the spec’s own allow-list, so this narrows and never widens.

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

password_fields

Columns holding a password hash. Their control takes a new password and a confirmation, hashes it with Argon2id, and leaves the stored value alone when both are blank.

Detected when not declared: a field the adapter typed as a password, or a sensitive column whose name says so — which is how a plain String called hashed_password is picked up without you saying anything.

formfield_overrides

Which control renders a field, overriding what its type would choose. Keyed by field name or by FieldType, so you can retype one column or every column of a kind.

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

A name beats a type when both match. Names are checked against the spec at declaration time, and an unknown widget name is a build-time error rather than a field that silently renders read-only.

field_labels

Labels for individual fields, overriding what the adapter derived from the column.

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

Bulk actions

actions

Offered once rows are selected. "delete" is built in and enabled by default; anything else names a method carrying @admin.action.

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

Set actions = () to offer none — which is how a model whose rows must never be removed in bulk says so. The per-row delete button is unaffected.

@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."

The method receives the adapter for this model and the selected rows, and returns the message to show. It never commits. The request’s unit of work commits on a clean exit and rolls back on an exception, so an action that fails on its fortieth row leaves nothing behind.

A method carrying the mark but left out of actions is not reachable by posting its name.

Export and import

exportable · export_fields

Export is on by default: the current view out as CSV, Excel or JSON, with filters, search and ordering applied, so the file matches the table it came from.

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

Turn it off on a model whose rows should not leave the admin in a file.

importable · import_fields

Import is off by default, and the asymmetry is deliberate: reading rows out is a permission the gate already grants; writing several thousand of them in one request is a different thing to hand somebody by accident.

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

import_fields never widens what may be written — FieldSpec.editable is checked first, so naming a read-only field here does not make it writable. Narrowing is the point: a price list that may update prices but never the supplier.

Presentation

verbose_name · verbose_name_plural

Overrides for the sidebar and the page headings. Derived from the model name when unset.

These are not translated, and that is deliberate. A model’s name is your word for your own domain, and FastFort has no business guessing it in eleven languages — the same reason Django does not translate your model names either. FastFort translates its own interface: the buttons, the filters, the messages.

group_name

The sidebar heading this model sits under. Without it, the namespace half of the registry key is used — catalog.product groups under Catalog.

icon

A name from fastfort.ui.icons, drawn beside the sidebar entry. Checked at declaration time, so a typo is an error naming every available icon rather than a silently blank slot.

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

Computed columns

@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 names the real column to sort by, since a computed value has none of its own. Without it the header renders unsortable, rather than producing an ordering the database cannot honour.

When you get one wrong

Every option above is validated against the model spec when the ModelAdmin is built — which happens the first time that model’s page is reached, not at mount(). Problems are collected before raising, so one run reports all of them:

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, …

Because the check is per-model and lazy, the way to surface all of them at once is to open each page, or to instantiate the admins yourself in a test:

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

Worth having in a project’s suite: it turns a page nobody opened before a deploy into a failing test.