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.
list_display_links
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
StringorTextcolumn’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.
select_related · prefetch_related
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.