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

list_editable

Columns editable in place, without opening the form.

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

The whole table becomes one form with one Save button. A control per row posting on its own would be one request per cell and nothing to submit any of them with when JavaScript is off — Django’s list_editable is the same shape for the same reason.

All the rows or none: one bad value rolls the whole submission back, because a table half-saved is worse than one that refused and nothing on screen would say which half went in.

A column has to be in list_display to be editable there, has to be writable, and takes the same narrow set of controls an inline row does.

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"}

fieldsets

Without it every field lands in one grid, in spec order — which a short form wants and a twenty-five-column model does not.

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

A None title renders the section without a heading, for the opening group that needs no name. collapsed renders it shut — as a <details>, so it still opens with JavaScript off.

Naming a field twice is a start-up error, because two controls posting one name means the second silently wins. So is leaving out a field the database requires: the form would render, save, and fail at the NOT NULL constraint with a name nobody outside the database recognises. Leaving out an optional field is allowed and unremarked — narrowing a form is what a section list is for.

inlines

A model’s children, edited on its own page.

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

The foreign key back to the parent is inferred when the child points at it once; fk_name names it when there are two. It is never offered as a column — it is set by the relation, and a dropdown for it in every row is an invitation to move a line to a different order by accident.

Saved in the parent’s transaction. A child that fails to parse leaves the parent unwritten as well, because half an order is worse than none of it.

extra blank rows are rendered by the server, which is how a child is added with JavaScript off; the “Add another” button clones a row when script is running. can_delete puts a box on each row — a checkbox rather than a button, because removing a saved child is part of the parent’s save and has to survive the round trip.

A tabular inline takes a deliberately narrow set of controls: text, numbers, money, dates, booleans, enums and to-one relations. A map, an upload card or a rich-text editor is taller than the row holding it and worse than the form it saved a trip to. Named in fields, one of those is a start-up error; left to the default, it is skipped.

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.

bulk_editable

Fields a bulk edit may set across every selected row.

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

Empty by default, which switches the action off. Opt-in rather than free, unlike delete: a delete announces itself and asks, while one mis-set column across forty rows is a silent change nobody sees until later.

Choosing the action opens a page that asks which field and what value, and that page posts the write — the same shape the delete confirmation takes, and what makes it work with JavaScript off.

The allow-list is this declaration, not the spec: a field that is perfectly writable on the form is still refused here unless it is named. It can only narrow what FieldSpec.editable and readonly_fields already settled, never widen it.

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.