Screen by screen
Which code produces which page
Every declaration in a ModelAdmin has a visible consequence. This page
pairs each one with the screen it produces, using the real source of the
demo application — not simplified excerpts.
The screens below are rendered as markup rather than pasted as screenshots. They follow your theme, read on a phone, and every label in them is text a search engine can find — which a 200 KB PNG of the same thing is not.
1 · The list view
list_display is the table, in order.
list_display_links chooses which cells link to the record — the
first column, unless you say otherwise. ordering decides which
header arrives marked, and in which direction.
select_related and prefetch_related are why rendering
the category, supplier and tags columns costs three
queries rather than three per row.
- list_display
- the columns, left to right
- list_display_links
- which cells are links
- ordering
- the arrow on the header
- search_fields
- whether a search box is drawn at all
- icon · group_name
- the sidebar entry and its heading
@admin.register(Product, key="catalog.product")
class ProductAdmin(admin.ModelAdmin):
list_display = (
"id", "sku", "name", "category", "supplier",
"tags", "price", "stock", "availability", "is_active",
)
list_display_links = ("sku", "name")
list_filter = ("availability", "is_active", "category",
"supplier", "price", "released_on")
search_fields = ("sku", "name", "description")
ordering = ("-created_at",)
select_related = ("category", "supplier")
prefetch_related = ("tags",)
icon = "box"
group_name = "Catalog"
actions = ("delete", "activate", "deactivate", "mark_discontinued") /admin/catalog.product/?q=cable&availability=in_stock&o=-created_at Products
| ID | SKU | Name | Category | Supplier | Price | Stock | Availability | Active ↓ | |
|---|---|---|---|---|---|---|---|---|---|
| 1042 | CBL-USB-C-2M | USB-C cable, 2 m | Cables | Tashkent Supply | 48 000 | 312 | In stock | Yes | |
| 1041 | CBL-HDMI-3M | HDMI 2.1 cable, 3 m | Cables | Tashkent Supply | 129 000 | 48 | In stock | Yes | |
| 1039 | CBL-ETH-CAT6 | Cat 6 patch, 5 m | Cables | Silk Road Trading | 22 500 | 0 | Backorder | Yes | |
| 1036 | PWR-USB-65W | 65 W GaN charger | Power | Nordic Components | 310 000 | 97 | In stock | Yes |
2 · The filter panel
One tuple, six shapes of control. FastFort reads the column's own type and draws the control that fits it — you never name a widget for a filter.
list_filter is restricted to what the spec marks
filterable, and two kinds are deliberately outside it: a free-text
column, whose dropdown would be every distinct value in the table, and a
many-to-many, which has no single value to match against. Naming either is a
ConfigurationError with a sentence explaining which and why.
list_filter = (
"availability", # enum → dropdown of the column's own members
"is_active", # boolean → three-state: any / yes / no
"category", # FK → searchable picker, autocomplete-backed
"supplier", # FK → same
"price", # numeric → two bounds, "from" and "to"
"released_on", # date → range, with presets
)
# Two kinds are refused, at start-up, with a sentence saying why:
#
# "name" a free-text column — the dropdown would be every distinct
# value in the table
# "tags" a many-to-many — multi-valued, so there is no single value
# to match a filter against
#
# Both are still searchable, sortable and shown as columns. 3 · Bulk actions
Tick a row and a bar appears. delete is built in;
everything else is a method carrying @admin.action, so the label
and the code that implements it cannot drift apart.
danger=True draws it in the danger colour and asks first.
confirm is what the question says, with {count}
substituted. An action that never commits is the point: the request's unit of
work commits on a clean exit and rolls back on an exception, so one that fails
on its fortieth row leaves nothing behind.
Set actions = () and the bar never appears — which is how a model
whose rows must not be removed in bulk says so.
actions = ("delete", "activate", "deactivate", "mark_discontinued")
@admin.action(
"Mark discontinued",
icon="trash",
danger=True,
confirm="Mark {count} products discontinued? They will stop appearing in the shop.",
)
async def mark_discontinued(self, adapter, objects):
for product in objects:
await adapter.update(
product, {"availability": Availability.DISCONTINUED, "is_active": False}
)
return f"{len(objects)} products discontinued." /admin/catalog.product/ Products
| SKU | Name | Price | Availability | |
|---|---|---|---|---|
| CBL-ETH-CAT6 | Cat 6 patch, 5 m | 22 500 | Backorder | |
| PWR-USB-30W | 30 W charger | 180 000 | Backorder | |
| CBL-USB-C-2M | USB-C cable, 2 m | 48 000 | In stock |
4 · The form, and every column type
You do not choose controls. The column's type does, and the four cases below are the ones that go wrong when something else tries.
- A nullable boolean gets three states. Drawn as a checkbox, a
NULLbecomesFalsethe first time anyone presses Save — a silent change to data nobody asked to change. - An interval gets four labelled number boxes, not a text field
expecting
2 08:30:00. - An hstore gets one
key: valuepair per line, not the{'k': 'v'}repr that reads as corruption. - A range gets two boxes and a bounds selector, so nobody types a bracket. Its endpoints use the same control table a standalone column of that type does, so the two can never drift.
A type nothing classifies degrades to a read-only row — a working page with one uneditable field, rather than a 500.
class Specimen(Base):
verified: Mapped[bool | None] # nullable boolean
runs_for: Mapped[dt.timedelta | None] # interval
payload: Mapped[dict] = mapped_column(JSONB())
attributes: Mapped[dict[str, str] | None] = mapped_column(HSTORE())
keywords: Mapped[list[str]] = mapped_column(ARRAY(String(40)))
seats: Mapped[Any | None] = mapped_column(INT4RANGE())
ip_address: Mapped[str | None] = mapped_column(INET())
flags: Mapped[str | None] = mapped_column(BIT(8))
location: Mapped[WKBElement | None] = mapped_column(
Geography(geometry_type="POINT", srid=4326)
)
# One override, because no column type can say "this VARCHAR is a colour".
class SpecimenAdmin(admin.ModelAdmin):
formfield_overrides = {"brand_colour": "color", "photo": "image"} 5 · The delete confirmation
The page walks the graph before anything is written and reports, per related model, what would actually happen. It reads the ORM's mapper and the database's own constraints rather than guessing — so a model that never declared a cascade is reported as nulling its children, honestly, instead of being promised a cascade the ORM will not perform.
Relations are found from the child side, so a foreign key whose model never declared a back reference is still found. Cascades are followed three levels deep, and the plan is checked twice — on this page, and again immediately before the write, because a confirmation page may have been open for an hour.
class Invoice(Base):
# Declared, so the plan reports "will be deleted too".
lines: Mapped[list[InvoiceLine]] = relationship(
back_populates="invoice", cascade="all, delete-orphan"
)
class InvoiceLine(Base):
# NOT NULL, no database cascade → this is what PROTECT means.
invoice_id: Mapped[int] = mapped_column(sa.ForeignKey("invoices.id"))
class Zone(Base):
# Nullable with SET NULL → "kept, without the link".
hub_id: Mapped[int | None] = mapped_column(
sa.ForeignKey("hubs.id", ondelete="SET NULL")
) ON DELETE CASCADE “3 invoice lines will be deleted too” ON DELETE SET NULL “12 zones kept, without the link” NOT NULL FK with nothing cascading Blocks the delete, with a sentence — not an IntegrityError Five rows are named, the count stops at a thousand and renders “1000+”, and the inline delete buttons use a cheap spec-level hint that costs no query at all.
6 · Import and export
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. Import is off by default, and the asymmetry is deliberate — reading rows out is a permission the gate already grants; writing several thousand in one request is a different thing to hand somebody by accident.
The importer uses the same parsers the form does. Foreign keys resolve by name or id, every bad cell is reported at once with its line number, and nothing is written unless the whole file parses.
No openpyxl on the read path, no pandas anywhere.
class ProductAdmin(admin.ModelAdmin):
importable = True
# Never widens: FieldSpec.editable is checked first, so naming a
# read-only field here would not make it writable. Narrowing is
# the point — this file may update prices and stock, nothing else.
import_fields = ("sku", "name", "price", "cost",
"stock", "availability", "is_active") 7 · The map, and spatial filters
A geometry column gets a map beside its text input — never instead of it. The map writes into the box; with JavaScript off the box is still a field you can paste WKT or GeoJSON into, and the form still submits.
It is hand-rolled Web Mercator, not Leaflet: 256-pixel tiles positioned by
transform, one layer per zoom so a zoom is instant and the previous
level stays underneath until the new tiles have loaded. All seven geometry kinds
draw, including a polygon with holes.
?last_seen__dwithin=41.3111,69.2797,5000 reads 5000 as
metres — because the column is a geography. On a
geometry the same number would be degrees, which is a radius of
roughly half the planet. That distinction is why the spec carries a
geography flag at all.
class Hub(Base):
location: Mapped[WKBElement | None] = mapped_column(
Geography(geometry_type="POINT", srid=4326) # → a draggable pin
)
catchment: Mapped[WKBElement | None] = mapped_column(
Geometry(geometry_type="POLYGON", srid=4326) # → a vertex editor
)
# Off by default. Naming a tile host is a project saying it has read that
# host's terms — and that host is the one origin the CSP img-src is widened by.
ui={"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
"map_attribution": "© OpenStreetMap contributors"}