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

8 · An upload that is refused

Uploads come back out of /admin/media/…, which is the admin's own origin — the origin holding the session cookie. So a file the browser can be persuaded to execute as a document is not a curiosity; it is script running as whoever opens it.

Four defences, because any one alone leaves the hole open: an allow-list on the extension, a deny-list checked alongside it, the leading bytes checked against the name, and a Content-Type at serve time decided from those bytes rather than from the name.

A refusal is a message on the field, beside whatever else on the form was also wrong — never a 500, and never a lost form.

Three uploads, three outcomes
media={
    "allowed_extensions": {"pdf", "png", "jpg", "csv", "xlsx"},
    "allowed_image_extensions": {"png", "jpg", "webp"},
}

#  photo.png  containing a real PNG      → stored
#  photo.png  containing an ELF binary   → refused, mismatch named
#  hack.exe.png containing a real PNG    → stored as hack_exe.png
#  logo.svg   containing anything at all → refused; SVG carries <script>
REFUSED the bytes contradict the name “‘photo.png’ is named .png but its contents are a program.”
RENAMED a dangerous extension buried mid-name hack.exe.png → stored as hack_exe.png
NEVER svg, html, php… Allow-listing one raises at start-up, not at upload time

example.com.pdf is not refused — it is stored as example_com.pdf. A rule that produces a mystery error for an ordinary filename is a rule people work around.

9 · The token API

One setting mounts four routes at auth_url. They authenticate through exactly the call the admin's own sign-in form makes, so the lockout counters, the timing-safe comparison and the rehash-on-login all apply — against the same user model and the same password hashes.

Request and response shapes are OAuth 2's, so every generated client and every curl example from any other API works without translation. Off by default: adding public endpoints to somebody else's application without being asked is not a library's decision to make.

Each refresh retires the token presented. A token presented twice cannot be told apart from a stolen one being used alongside the real client, so it is treated as one and the whole family is revoked.

Two lines, and your own routes are authenticated
FastFortSettings(secret_key=..., auth={"api_enabled": True})

# ...and on your own routes:
from fastfort.auth import bearer_user

current_user = bearer_user(fort)

@app.get("/orders")
async def orders(user: Annotated[Any, Depends(current_user)]):
    return await orders_for(user)
POST /auth/token
identity + password → access + refresh
POST /auth/refresh
refresh → a new pair, retiring the old
POST /auth/logout
retires the family; 204 either way
GET /auth/me
id, identity, is_staff — and nothing else

bearer_user hands your route the user row, not a claims dict — and rejects an account deactivated since the token was issued, which a signature check alone would keep admitting for the rest of that token's life.

10 · The budget a client gets

Three budgets rather than one, because the three things an admin serves cost wildly different amounts. A list page is a query; a save is a transaction; a sign-in is an Argon2 hash — which is slow by design, and that makes the sign-in form the cheapest thing on any site to attack.

A hash tuned to take 100 ms means eleven unauthenticated requests a second saturate a core, and the sender spends nothing. So the sign-in budget is the tightest, and it is charged in middleware before the handler runs: a refused request never reaches the hash at all.

Not a defence against a distributed flood — a million hosts are a million keys. It is a defence against the single loud client.

The refusal
rate_limit={
    "read_per_minute": 600,    # GET: lists, forms, the dashboard
    "write_per_minute": 120,   # everything that writes
    "login_per_minute": 10,    # POST to sign-in, right password or wrong
}

HTTP/1.1 429 Too Many Requests
Retry-After: 34
X-RateLimit-Limit: 10
Cache-Control: no-store

Too many requests. Try again in 34 seconds.

The defaults are loose because the budget is per address, and an address is not a person: a whole office behind one NAT is one address. Behind a proxy, set security.forwarded_depth — or every visitor shares one bucket.

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.