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"} 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.
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> hack.exe.png → stored as hack_exe.png 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.
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.
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.