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
app/admin/catalog.py
@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

+ Add
cable Availability: In stock Category: Cables Filters
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
4 of 1 284 results 123
Sorting is a link and searching is a form — both work with JavaScript switched off. The script upgrades them in place; it never owns them.

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, annotated
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.
EnumA dropdown of the column's members, read off the column.
BooleanThree states: any, yes, no — never a checkbox.
Nullable booleanFour, because “not set” is its own answer.
Foreign keyA picker; autocomplete once the table outgrows a dropdown.
NumericTwo bounds, from and to, either optional.
Date · datetimeA range with presets: today, last 7 days, this month.
Time · durationThe same two-bound shape, in the column's own units.
Free text · M2MRefused. A dropdown of every distinct string, or of a multi-valued field, is not a filter.

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.

app/admin/catalog.py
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

Export + Add
2 selected ActivateDeactivateMark discontinuedDelete selected
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
1 284 results 123
The bar is rendered by the server from `action_specs()`. With JavaScript off it is a form with a submit button per action, which is exactly what it still is with JavaScript on.

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 NULL becomes False the 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: value pair 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.

app/models/lab.py — one of every type
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.

The three effects, in the models
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")
    )
DELETE ORM cascade, or ON DELETE CASCADE “3 invoice lines will be deleted too”
CLEAR nullable FK, or ON DELETE SET NULL “12 zones kept, without the link”
PROTECT 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.

app/admin/catalog.py
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.

app/models/logistics.py
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"}

Run it yourself

Every screen above comes from the demo application: 17 models on PostgreSQL and PostGIS, with real data. Two commands to have it on your machine.