Skip to content

Admin site

Django-style management UI mounted under /admin. Operators sign in with a user row from your own database — there is no separate admin password store. Every registered model becomes browsable from the browser, so the database port can stay closed on private networks.

What you get (Django-admin parity):

  • List view with search, rich per-field filters (enum / FK / date-range) and sortable columns.
  • Full CRUD (create / edit / delete) and bulk actions.
  • CSV/JSON export and FK-select widgets.
  • Dashboard with live row counts + system metrics.
  • Optional TOTP MFA at login.
  • File/image upload fields.
  • Audit trail stamping created_by / updated_by.

Also covers in-place inline editing of 1-N children (Inline(editable=True)).

Requires the [admin] extra:

uv add "tempest-fastapi-sdk[admin]"

1. User model

Subclass BaseUserModel to get the four columns the admin auth backend expects (email, hashed_password, is_admin, last_login_at) on top of the standard BaseModel row:

# src/db/models/user.py
from tempest_fastapi_sdk import BaseUserModel


class UserModel(BaseUserModel):
    __tablename__ = "users"   # scaffold convention; admin slug derives from __tablename__

set_password() / check_password() delegate to PasswordUtils; normalize_email() lowercases and strips. The default is_active (inherited from BaseModel) and is_admin (defaults to False) gate access — only is_active=True AND is_admin=True rows may sign in.

Bootstrap the first admin via your CLI / migration / seed script. The full script wires an AsyncDatabaseManager, opens one session, inserts the row and commits — exactly the same pattern your repositories follow at runtime:

# scripts/create_admin.py
import asyncio

from tempest_fastapi_sdk import AsyncDatabaseManager

from src.core.settings import settings
from src.db.models import UserModel


async def main() -> None:
    db = AsyncDatabaseManager(settings.DATABASE_URL)
    await db.connect()
    try:
        async with db.get_session_context() as session:
            # ──────── the only admin-specific lines ────────
            admin = UserModel(email="root@example.com", is_admin=True)
            admin.set_password("hunter2")  # bcrypt via PasswordUtils
            session.add(admin)
            await session.commit()
    finally:
        await db.disconnect()


if __name__ == "__main__":
    asyncio.run(main())

The four highlighted lines under the divider comment are the only admin-bootstrap code; everything around them is the standard async DB lifecycle the SDK already uses.

2. Register your admin classes

AdminModel is a plain typed configuration instance — the constructor signature is the contract (no class-attribute / metaclass magic), and every field accepts a real SQLAlchemy column attribute (UserModel.email), so typos surface in your editor instead of at runtime. The defaults work out of the box; pass the fields you want to enrich the list view:

# src/admin/site.py
from sqlalchemy import desc

from tempest_fastapi_sdk import AdminModel, AdminSite

from src.db.models import UserModel, OrderModel

site = AdminSite(
    title="MyApp Admin",
    brand="servus-backend-admin",     # centered header text (optional; defaults to title)
    index_subtitle="Site administration",
    site_url="https://myapp.com",     # optional outbound "View site" link
)

site.register(AdminModel(
    model=UserModel,
    list_display=[UserModel.email, UserModel.is_admin, UserModel.is_active, UserModel.last_login_at],
    list_filter=[UserModel.is_active, UserModel.is_admin],
    search_fields=[UserModel.email],
    readonly_fields=[UserModel.id, UserModel.hashed_password, UserModel.created_at, UserModel.updated_at],
    ordering=desc(UserModel.created_at),
    page_size=25,
))

Every field reference also accepts a plain string (list_display=["email", ...]) for dynamic configuration, and ordering accepts a column (ascending), desc(column) / asc(column), or a Django-style "-created_at" string. register returns the instance and raises ValueError on a duplicate slug. Slugs default to the model's __tablename__ so URLs and database tables stay in sync.

Filters auto-pick a widget per column type

Each list_filter field renders the right widget by column type: boolean → Yes/No dropdown; enum → dropdown of the members; FK (whose target has a registered AdminModel) → dropdown of the related rows (labelled via search_fields); date/datetime → two date inputs (from/to, inclusive range); any other column → a text input (equality). All preserve search/sort/pagination in the URL.

Centered, customizable brand

The name shown in the center of the header comes from brand (optional). Without it, it falls back to title — so existing sites are unchanged. Use brand to show a distinct name (e.g. "servus-backend-admin") centered at the top of every page. The sidebar is fixed and overlays the header and footer on desktop (higher z-index) — automatic from the bundled CSS, no config.

2b. Shortcut — register every model at once (automap)

Instead of one register per table, point automap at the models package and the SDK discovers and registers every concrete BaseModel automatically. Abstract bases (BaseUserModel and friends — no __tablename__) are skipped on their own:

# src/admin/site.py
from tempest_fastapi_sdk import AdminModel, AdminSite

site = AdminSite(title="MyApp Admin", brand="servus-backend-admin")

# Load EVERY table under src/db/models in one shot:
site.automap("src.db.models")

Mix both styles: hand-register the models that need their own config, then let automap fill in the rest (it skips already-registered slugs by default):

# UserModel gets a tuned config...

from tempest_fastapi_sdk import AdminModel

from src.admin import site
from src.db.models import UserModel


site.register(AdminModel(
    model=UserModel,
    list_display=[UserModel.email, UserModel.is_admin],
    search_fields=[UserModel.email],
))

# ...and automap registers the rest with defaults.
site.automap("src.db.models")

automap accepts: exclude=[...] (class, class name, or table name to hide a model), skip_registered=False (raise ValueError on a collision, like register), and **admin_kwargs applied to all (page_size=50, can_delete=False, ...). To introspect without registering, call the discover_models("src.db.models") function directly.

Uniform config

automap's **admin_kwargs apply to every discovered model. When a model needs its own list_display / search_fields, register it by hand before automap (with the default skip_registered=True).

3. Mount the router

# src/api/app.py
from fastapi import FastAPI

from tempest_fastapi_sdk import UserModelAuthBackend, make_admin_router

from src.admin.site import site
from src.api.dependencies import db   # singleton from src/api/dependencies/resources.py
from src.core.settings import settings
from src.db.models import UserModel

app = FastAPI()
app.include_router(
    make_admin_router(
        site,
        db=db,
        auth_backend=UserModelAuthBackend(UserModel),
        secret_key=settings.JWT_SECRET,          # scaffold reuses JWT_SECRET — at least 32 bytes
        prefix="/admin",
        cookie_secure=not settings.DEBUG,        # True in production HTTPS
        show_logs=True,                          # enables the logs page + sidebar entry
        log_dir=settings.LOG_DIR,                # same dir passed to configure_logging
    )
)

make_admin_router mounts:

  • GET /admin/login, POST /admin/login, POST /admin/logout — auth flow.
  • GET/POST /admin/mfa — TOTP second-factor challenge between the password step and access, for MFA-enabled principals.
  • GET /admin/ — dashboard: a card per model with its live row count + Browse/New, plus a metrics panel (CPU/RAM/disk via MetricsUtils). On by default, omitted without the [metrics] extra, disable with make_admin_router(show_metrics=False).
  • GET /admin/logsapplication logs (when show_logs=True): reads the structured JSON files written by configure_logging(log_dir=…), with source filter (?source=), free-text search (?q=) and pagination. Color-coded level badges. Renders an empty state when no log files exist yet.
  • GET /admin/logs/exportlog export (?format=md|json): downloads the filtered selection as markdown (traceback in a fenced block, ready to paste into an issue) or verbatim JSON.
  • GET /admin/m/{slug}/ — list view with pagination + free-text search (?q=) + per-field filters (?filter_<field>=value) + clickable column sorting (?sort=<column>&dir=asc|desc).
  • GET /admin/m/{slug}/export.csv / export.jsonexport the current result set (honoring search/filters/sort) as CSV or JSON. Row cap via make_admin_router(export_max_rows=…) (default 5000).
  • POST /admin/m/{slug}/bulkbulk actions (delete / activate / deactivate + your custom actions) on the selected rows.
  • GET/POST /admin/m/{slug}/newcreate a record (when can_create).
  • GET /admin/m/{slug}/{identity} — detail view with Edit/Delete controls.
  • GET/POST /admin/m/{slug}/{identity}/editedit a record (when can_edit).
  • POST /admin/m/{slug}/{identity}/deletedelete a record (when can_delete).
  • GET /admin/static/{path} — bundled CSS/HTMX assets.

Write CRUD + permissions

Create/edit/delete are gated by AdminModel flags: can_create / can_edit / can_delete (all True by default; a disabled view returns 404). Every write POST carries the session CSRF token, verified server-side (403 on mismatch). Field widgets are derived from the column type — text / textarea (long strings) / number / checkbox / datetime-local / date / select for enums — with required-field + per-field validation errors re-rendered on the form. A write the database refuses (unique, FK, NOT NULL) comes back the same way: 400 with the repository's message (Conflict creating <Model>) above the form, never a 500.

Bulk actions: the list view shows per-row checkboxes + select-all and an action bar (delete / activate / deactivate) operating on the checked rows via POST .../bulk (CSRF + can_delete/can_edit flags), backed by BaseRepository.delete_batch / bulk_update.

FK-select: a foreign-key column whose target has a registered AdminModel renders as a dropdown of the related rows (like Django's FK select) on the form, instead of a raw UUID input. The option label comes from the referenced admin's first search_fields entry (fallback: a name/title/email attribute, then the id). Capped at 1000 rows; FKs to unmanaged tables stay UUID inputs.

MFA login: a principal with MFA enabled (MFAMixin's totp_secret/totp_enabled_at) goes through a TOTP challenge at /admin/mfa after the password — only a valid code grants access. Enable it via UserModelAuthBackend(UserModel, mfa_issuer=...); custom backends override mfa_enabled/verify_mfa.

Audit trail: create/edit through the admin stamps created_by/updated_by (from AuditMixin) with the acting admin's id; the detail view shows an Audit panel with timestamps and — when the model has the audit columns — the actor (UUID resolved to a display name via the auth backend). Models without AuditMixin show timestamps only.

In-place inline editing of children: see Inline(editable=True) below.

Custom actions (@admin_action)

Beyond the 3 built-ins (activate / deactivate / delete), you register your own actions — an async function decorated with @admin_action and passed to AdminModel(actions=[...]). Each becomes an option in the bulk-action dropdown, operating on the checked rows.

from tempest_fastapi_sdk import (
    AdminActionContext,
    AdminActionResult,
    AdminModel,
    EmailUtils,
    admin_action,
)

from src.admin import site
from src.core.settings import settings
from src.db.models import UserModel

mailer = EmailUtils(**settings.email_kwargs())


@admin_action(label="Send welcome")
async def send_welcome(ctx: AdminActionContext) -> AdminActionResult:
    """Runs on the selected rows; the message is shown on the list view."""
    users = await ctx.repository.list(filters={"id": ctx.ids})
    for user in users:
        await mailer.send(
            user.email,
            subject="Welcome",
            body=f"Hi {user.name}! Your account is ready.",
        )
    return AdminActionResult(f"Sent {len(users)} emails.")


site.register(AdminModel(model=UserModel, actions=[send_welcome]))

The handler receives an AdminActionContext:

Field What it is
ids Identities of the checked rows.
repository The model's BaseRepository, on the request session.
db_session The DB session (for work beyond the repository).
request The inbound request.
session The authenticated admin session.
principal The admin user row that triggered the action.

Return an AdminActionResult(message, category="success"|"error"|"warning") to flash a banner on the list view (or None for no banner). The function stays directly callable/testable — the decorator only attaches metadata. Use name= to pin the identifier (default: the function name) and dangerous=True to mark a destructive action.

File / image upload field

A String column that stores a file's path/key can render as a file input on the form. List the column in upload_fields and pass an upload_storage (the SDK's existing backends — LocalUploadStorage / MinIOUploadStorage). On submit the file is saved to storage and the returned key is written to the column.

from tempest_fastapi_sdk import AdminModel
from tempest_fastapi_sdk.utils import LocalUploadStorage

from src.admin import site
from src.db.models import DocumentModel


site.register(AdminModel(
    model=DocumentModel,
    upload_fields=[DocumentModel.attachment],   # String column holding the key
    upload_storage=LocalUploadStorage("media/"),  # or MinIOUploadStorage(...)
))

Installation

The upload backends do not ship with [admin]. LocalUploadStorage needs the [upload] extra — uv add "tempest-fastapi-sdk[upload]" (pulls in aiofiles); MinIOUploadStorage needs [minio]uv add "tempest-fastapi-sdk[minio]" (pulls in minio).

  • The form becomes multipart/form-data automatically when upload_fields is set.
  • Create: a file is required only when the column is NOT NULL with no default.
  • Edit: no new file → keeps the current value (shows "Current: …"); a file → replaces it.
  • The column stores the storage key (<slug>/<field>/<uuid>.<ext>); use upload_storage (or UploadUtils) to serve/download it later.

upload_fields requires upload_storage

Registering upload_fields without upload_storage raises ValueError at AdminModel construction — without storage there's nowhere to write the file.

Sidebar + burger navigation

Every authenticated page has a persistent sidebar: Dashboard, one link per registered model (grouped under "Models"), and — with show_logs=True — "Logs" under "System". The current page's entry is highlighted. On desktop the sidebar is always visible on the left; on mobile (≤768px) it becomes off-canvas, opened by the burger icon in the header and dismissed by tapping the scrim — pure CSS, no JS.

Logs page (show_logs=True)

GET /admin/logs reads the structured JSON files that configure_logging(log_dir=…) writes. Pass the same log_dir to make_admin_router. The page offers a source filter (all/debug/info/warning/error/critical/500), substring search on the message, and pagination, with color-coded level badges. It is opt-in (show_logs=False by default) because the payload exposes tracebacks and request metadata — only enable it behind the admin login. With no files in log_dir, the page shows an empty state.

500 tracebacks and export-to-issue

Every record carrying a traceback (the SDK's handlers log with exc_info=True) becomes a clickable item: the message itself is the trigger, so clicking anywhere on the entry reveals the trace, with the request correlation fields (path, method, status_code, request_id) alongside it. Plain <details>/<summary>, no JS, collapsed by default so a page full of 500s stays scannable — a record without a traceback grows no toggle at all.

GET /admin/logs/export?format=md|json downloads the filtered selection (the page's own ?source= and ?q=), newest first, up to 500 records:

  • format=md — each traceback goes into a ```pytb fenced block, so it survives a paste into an issue or PR with its indentation intact. The header states the source, the count, the active filter and — when the 500-record cap bites — how many records matched in total, so a partial export never reads as a complete one.
  • format=json — the records verbatim, including every field the application logged through extra=, for tooling to consume.

Below 600px the table becomes stacked cards (the header is dropped and each cell names itself), so the message and the traceback sit in the viewport with no horizontal scroll. The other list views keep the sideways scroll, which suits a list you skim.

The export inherits the admin session gate: a traceback is exactly the payload that must not be world-readable. To build your own export, render_entries_markdown and render_entries_json are exported at the package level.

Responsive by default

The bundled templates + CSS are responsive: on narrow screens (≤600px) the header stacks, search/filters/actions go full-width, tables get horizontal scroll (never breaking the layout), and the detail grid collapses to a single column. Column headers are clickable to toggle sort order (▲/▼).

Audit history in the detail (audit_model=)

The panel already stamps created_by / updated_by. To see what changed (not just who/when), pass an audit_model — the same BaseAuditLogModel table the BaseRepository already writes — and the detail view gains a per-row timeline.

First, the audit table and a repository that feeds it:

# src/db/models/audit.py
from tempest_fastapi_sdk import BaseAuditLogModel


class AuditLog(BaseAuditLogModel):
    __tablename__ = "audit_log"
# write the trail on writes (create/update/delete)

import asyncio

from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from tempest_fastapi_sdk import BaseRepository
from tempest_fastapi_sdk.db.audit import snapshot_model

from src.db.models import AuditLog, OrderModel, UserModel

current_user = UserModel(name="Ana", email="ana@example.com")
# In a service the session comes from `db.get_session_context()`; here, SQLite.
session = AsyncSession(create_async_engine("sqlite+aiosqlite:///:memory:"))


repo = BaseRepository(session, model=OrderModel, audit_model=AuditLog)


async def main() -> None:
    """Run this example."""
    order = await repo.add_audited(OrderModel(...), actor=str(current_user.id))

    before = snapshot_model(order)
    order.status = "shipped"
    await repo.update_audited(order, before, actor=str(current_user.id))


asyncio.run(main())

Then wire the same audit_model into the admin:

# src/admin/site.py

from tempest_fastapi_sdk import AdminModel

from src.admin import site
from src.db.models import AuditLog, OrderModel


site.register(AdminModel(model=OrderModel, audit_model=AuditLog))

Each order's detail now shows a History block: one entry per change (create / update / delete, color-coded), actor and date, and a field-by-field diff (before → after). The 50 most recent entries, newest first.

How the match works

The viewer looks up audit rows where entity == the model name (e.g. "OrderModel") and entity_id == the record id — exactly what add_audited / update_audited / delete_audited write. Without audit_model, the detail view is unchanged (only the created_by/updated_by stamps).

Autocomplete FK (autocomplete_fields=)

By default a FK field whose target has a registered admin becomes a <select> of every row (capped at 1000). On a large table that is unusable. Pass autocomplete_fields and the field becomes a search box (HTMX) that queries the target on demand:

# src/admin/site.py

from tempest_fastapi_sdk import AdminModel

from src.admin import site
from src.db.models import Company, Employee


site.register(AdminModel(model=Company, search_fields=[Company.name]))
site.register(
    AdminModel(model=Employee, autocomplete_fields=[Employee.company_id])
)

On the Employee form, company_id no longer lists every company: you type, the /admin/m/employee/autocomplete/company_id?q=… endpoint searches the Company admin's search_fields (ILIKE, OR, up to 20 results) and shows the options; clicking one pins the id into the field. On edit, the current company's label is pre-filled.

Requirements

The FK target must have a registered AdminModel (its search_fields drive the search). Without autocomplete_fields, the FK stays a <select> (fine for small tables).

Inlines — 1-N children on the detail (inlines=)

To see (and reach) the records that point at this one — a customer's orders, a team's members — declare inlines. The parent's detail view then lists each relation as a table, with a link to the child admin and an Add button that pre-fills the parent FK.

# src/admin/site.py

from tempest_fastapi_sdk import AdminModel, Inline

from src.admin import site
from src.db.models import Member, Team


site.register(
    AdminModel(
        model=Team,
        inlines=[Inline(Member, Member.team_id, list_display=[Member.name])],
    )
)
site.register(AdminModel(model=Member))   # the child needs an admin for links

A Team's detail now shows a Member table with its members; "Add" opens the Member create form with team_id pre-filled (via a query param). Columns come from the Inline's list_display (or, if omitted, the child admin's). Up to 50 rows per inline.

The child model must have a registered AdminModel for the links to work; without one, the rows render read-only.

In-place editing (editable=True)

To edit the children without leaving the parent's detail view, pass editable=True (and can_delete=True to allow removal). The table becomes a formset: one input row per existing child plus a blank row to add another.

from tempest_fastapi_sdk import AdminModel, Inline

from src.admin import site
from src.db.models import Member, Team


site.register(
    AdminModel(
        model=Team,
        inlines=[
            Inline(
                Member,
                Member.team_id,
                editable=True,
                can_delete=True,
            )
        ],
    )
)
site.register(AdminModel(model=Member))   # can_edit / can_delete apply here

On save (POST /admin/m/<parent>/<id>/inlines/<child>) everything happens in one transaction: existing rows are updated, a blank row with any value becomes a new child, and a checked delete box removes the row.

Transparent, no magic

  • The parent foreign key is implied — forced to the parent, never an input; the user can't pick (or re-point to) the wrong parent.
  • Each row is scoped to the parent: a child whose foreign key doesn't match is ignored, never cross-edited.
  • Upload/autocomplete columns stay on the child's own form (they don't enter the compact formset).
  • Validation errors re-render the formset in place, with a per-field message and nothing inserted.
  • Needs the child's AdminModel + can_edit (and can_delete to delete). Up to 50 rows per inline.

Dashboard: business-metric cards (dashboard_cards=)

The dashboard already shows CPU/RAM/counters. For your business metrics — orders today, revenue vs last week, users by plan — pass dashboard_cards. Each card is a MetricCard(label, compute) where compute is an async function that takes the session and returns one of three types:

from datetime import date, timedelta

from sqlalchemy.ext.asyncio import AsyncSession

from tempest_fastapi_sdk import (
    AdminSite,
    MetricCard,
    MetricPartition,
    MetricTrend,
    MetricValue,
)

from src.db.repositories import OrderRepository

today = date.today()
this_week = today - timedelta(days=7)
last_week = this_week - timedelta(days=7)


async def orders_today(session: AsyncSession) -> MetricValue:
    total = await OrderRepository(session).count(filters={"start_in": today()})
    return MetricValue(total, unit="orders")


async def revenue_trend(session: AsyncSession) -> MetricTrend:
    return MetricTrend(
        value=await this_week(session), previous=await last_week(session), unit="BRL"
    )


async def users_by_plan(session: AsyncSession) -> MetricPartition:
    return MetricPartition(segments=[("free", 120), ("pro", 30), ("enterprise", 4)])


site = AdminSite(
    title="Shop",
    dashboard_cards=[
        MetricCard("Orders today", orders_today, help_text="last 24h"),
        MetricCard("Revenue", revenue_trend),
        MetricCard("Users by plan", users_by_plan),
    ],
)
  • MetricValue(value, unit=None) — a big number.
  • MetricTrend(value, previous, unit=None) — a number + a ▲/▼ arrow and the percentage change vs the previous period (delta / pct / direction computed for you).
  • MetricPartition(segments=[(label, value), ...]) — a breakdown with proportional bars; total summed automatically.

The cards render at the top of the dashboard, computed on each load. A card whose compute raises is skipped — a broken metric never blanks the page.

CSV import (can_import=True)

The admin already exports the list (CSV/JSON). The counterpart: upload a CSV to bulk-create records. Enable it with can_import=True:

from tempest_fastapi_sdk import AdminModel

from src.admin import site
from src.db.models import Product


site.register(AdminModel(model=Product, can_import=True))

An Import CSV link appears on the list view → an upload page. The CSV needs a header row with the editable column names (the page lists them); each following row is validated and coerced with the same rules as the create form (types, required fields) and becomes a record.

The result is best-effort: valid rows are created, and a report lists the skipped rows with each field's error — one bad row never aborts the others.

Opt-in + requires create

can_import defaults to False and requires can_create (importing is bulk creation). File-upload columns are not imported from CSV.

Granular RBAC (access_policy=)

By default, anyone who logs into the admin (is_admin) can do everything each AdminModel's can_* flags allow. To give an admin access to only some models/actions — a "support" role that views orders but can't delete, an "editor" that only touches content — pass an access_policy to make_admin_router:

from fastapi import FastAPI

from tempest_fastapi_sdk import (
    AdminModel,
    AdminPermission,
    UserModelAuthBackend,
    make_admin_router,
)

from src.admin import site
from src.api.dependencies.resources import db
from src.core.settings import settings
from src.db.models import AuditLog, UserModel

app = FastAPI()


def policy(user: UserModel, admin: AdminModel, action: AdminPermission) -> bool:
    if user.role == "superadmin":
        return True
    if user.role == "support":
        return action is AdminPermission.VIEW       # read-only
    return admin.model is not AuditLog              # editor: everything but AuditLog


app.include_router(
    make_admin_router(
        site,
        db=db,
        auth_backend=UserModelAuthBackend(UserModel),
        secret_key=settings.JWT_SECRET,
        access_policy=policy,
    ),
)

The policy is asked (principal, admin, action) for every action — VIEW / CREATE / EDIT / DELETE. Denying yields 403; denying VIEW also hides the model from the dashboard and the sidebar nav, and denying create/edit/delete hides the matching buttons. Enforced across list, detail, create, edit, delete, bulk (delete → DELETE, rest → EDIT), export, import and FK autocomplete.

Composes with the can_* flags

The policy is additive to the AdminModel's can_create/can_edit/can_delete flags — both must allow. Without access_policy, nothing changes (every logged-in admin does everything). Sync or async.

Field widgets by column type

The create/edit form picks the widget from the column type, no config: bool → checkbox, Enum → select, int/float/Decimal → number, datetime/date/time → native inputs, long string → textarea, JSON column → a monospaced JSON editor (pretty-printed on open, json.loads + validation on submit — invalid JSON becomes a field error, not a string stored in its place). A FK becomes a <select> (or autocomplete, via autocomplete_fields), and upload_fields become file inputs.

Lenses — saved views (lenses=)

A lens is a named preset of filters + ordering, shown as a tab above the list. Instead of the operator re-entering "status=open, priority>=3, oldest first" every time, they click the tab:

from tempest_fastapi_sdk import AdminModel, Lens

from src.admin import site
from src.db.models import Ticket


site.register(
    AdminModel(
        model=Ticket,
        lenses=[
            Lens("Open", filters={"status": "open"}),
            Lens(
                "Urgent",
                filters={"status": "open", "priority__gte": 3},
                order_by="-created_at",
            ),
        ],
    )
)

The list gains All / Open / Urgent tabs. Clicking one applies the lens's filters (ANDed with whatever search/filters the user already set) and its ordering (order_by, -col = desc, unless the user clicked a header). The active lens is preserved across pagination, sort and export; "All" clears it.

Same filter conventions

filters uses the same dict as the repository (field__gte, name ILIKE, iterable → IN, …). The tab slug (?lens=) is derived from the name (lowercased, hyphenated).

4. Session security defaults

SignedCookieSessionStore uses itsdangerous.TimestampSigner (HMAC-SHA256) to sign a single cookie:

  • HttpOnly always set.
  • Secure flagged when cookie_secure=True (default; flip off in local HTTP dev).
  • SameSite=Lax ("lax"/"strict"/"none" accepted).
  • Default lifetime 8h; expired or tampered cookies are rejected silently.
  • Per-session CSRF token is generated at login and required by every form POST (login, logout, create, edit, delete, bulk actions).
  • secret_key must be at least 32 bytes — short keys raise ValueError at construction time.

Login looping? It's the cookie Secure flag over plain HTTP

If POST /admin/login returns 303 (looks like success), but the following GET /admin/ redirects back to the login — forever — the session cookie is not coming back. Almost-certain cause: cookie_secure=True while the admin is served over plain HTTP (no TLS terminator in front). The browser refuses to store a Secure cookie on a non-HTTPS connection, so no session ever persists.

# ❌ Tied to DEBUG: in production DEBUG=false → cookie_secure=True,
#    but with no HTTPS in front the login loops.
make_admin_router(..., cookie_secure=not settings.DEBUG)

# ✅ Dedicated toggle, independent of DEBUG:
make_admin_router(..., cookie_secure=settings.ADMIN_COOKIE_SECURE)

Right fix: put HTTPS in front (nginx/Caddy terminating TLS) and keep cookie_secure=True — the admin session cookie must not travel in the clear. Stopgap only when the admin genuinely runs over HTTP (intranet, MVP): cookie_secure=False, aware the session ships without Secure. Don't tie this flag to DEBUG — turning debug on in production is worse than the original problem.

5. Plug in a custom auth backend

AdminAuthBackend is an ABC, so swap the default for LDAP / OAuth / external IAM by subclassing:

from typing import Any

from sqlalchemy.ext.asyncio import AsyncSession

from tempest_fastapi_sdk import AdminAuthBackend, AdminAuthError, GoogleOAuthClient

from src.core.settings import settings
from src.db.models import AdminModel

my_oauth_client = GoogleOAuthClient(
    client_id=settings.GOOGLE_CLIENT_ID,
    client_secret=settings.GOOGLE_CLIENT_SECRET,
    redirect_uri=settings.GOOGLE_REDIRECT_URI,
)


class OAuthAdminBackend(AdminAuthBackend):
    """Trade the admin form's credential for an OAuth identity.

    OAuth has no password to check, so the form's `password` field carries
    the authorization code the provider redirected back with. The ABC names
    the parameter `password`; what this backend expects there is the code.
    """

    async def authenticate(
        self,
        session: AsyncSession,
        *,
        identifier: str,
        password: str,
    ) -> Any:
        """Exchange the code, then accept only an allowed admin e-mail."""
        tokens = await my_oauth_client.exchange_code(password)
        principal = await my_oauth_client.fetch_user(tokens)
        if not principal.email_verified or principal.email != identifier:
            raise AdminAuthError("not an admin")
        return principal

    async def load_principal(
        self,
        session: AsyncSession,
        principal_id: str,
    ) -> Any | None:
        """Reload the admin row the subject maps to, on every request."""
        return await session.get(AdminModel, principal_id)

    def principal_id(self, principal: Any) -> str:
        """Return the provider's stable subject identifier."""
        return principal.subject

    def display_name(self, principal: Any) -> str:
        """Return what the admin header shows."""
        return principal.email

Pass the instance via auth_backend= and the rest of the admin pipeline (sessions, dashboard, list, detail) keeps working unchanged.

6. Customize the look — AdminTheme

The admin CSS is driven entirely by CSS custom properties on :root. Instead of forking the stylesheet, you pass an AdminTheme with typed, documented parameters — colors, logo, favicon, font, radius, footer, dark mode — and the SDK injects a <style> block in the <head> (after admin.css, so it wins).

# src/admin/site.py
from tempest_fastapi_sdk import AdminSite, AdminTheme

theme: AdminTheme = AdminTheme(
    accent="#7c3aed",                       # primary color (links, buttons, active item)
    accent_hover="#6d28d9",                 # hover shade of the accent
    header_bg="#1e1b4b",                    # header/sidebar background
    radius="10px",                          # radius of buttons, inputs, cards, tables
    font_family="'Inter', system-ui, sans-serif",
    logo_url="/admin/static/logo.svg",      # header image (instead of the text brand)
    favicon_url="/admin/static/favicon.ico",
    footer_text="Servus | 2026",
    dark_mode=False,                         # dark content surfaces
)

site: AdminSite = AdminSite(title="Servus Admin", brand="Servus", theme=theme)

AdminTheme() with no arguments is a no-op: it reproduces the stock look. You only set what you want to change.

The golden rule

Every AdminTheme field maps to a :root CSS variable (or to a piece of chrome, like the logo). It is all typed — the editor autocompletes the options and mypy validates — and no string ever needs to be a CSS class name or selector.

Field Type Default Effect
accent str "#2563eb" Primary color: links, buttons, active sidebar item
accent_hover str "#1d4ed8" Hover/active shade of accent
danger str "#b91c1c" Destructive actions and error messages
header_bg str "#0f172a" Header background
sidebar_bg str | None None Sidebar background (falls back to header_bg)
page_bg str | None None Content background (mode default)
radius str "6px" Radius of buttons, inputs, cards, tables
font_family str | None None font-family for the whole panel
logo_url str | None None Header image instead of the text brand
logo_alt str "Logo" alt text for the logo image
favicon_url str | None None Browser-tab favicon
footer_text str "Powered by tempest-fastapi-sdk" Footer text
dark_mode bool False Dark content surfaces
custom_css_url str | None None Extra stylesheet, linked last

Dark mode

dark_mode=True switches the content surfaces (page background, text, table rows, inputs, borders) to a dark palette. The header/sidebar are already dark, so they are unaffected; accent and the other colors still apply. An explicit page_bg wins over dark mode.

Escape hatch for the rest

For anything the fields do not cover, point custom_css_url at your own stylesheet. It is linked after the theme, so it overrides everything — including AdminTheme.

Values are developer-set, not end-user input

The characters < > { } " are rejected in any string field (ValueError at construction), because they would break the injected <style> block or an HTML attribute. Never derive AdminTheme values from end-user input.

Recap: instantiate AdminTheme with the fields you want to change, pass it via AdminSite(theme=...), and the look changes across every page (login, dashboard, list, detail, forms) without touching CSS. For full control, custom_css_url.

Recap

  • AdminSite + AdminModel turn your models into a CRUD interface with no template of your own: the declaration is the screen.
  • @admin_action puts a domain operation in the list, and the AdminActionResult it returns is what the operator reads back.
  • audit_model= renders the who-changed-what timeline inside the detail, and inlines= brings 1-N children onto the same page.
  • autocomplete_fields= swaps the <select> that does not scale for an HTMX search — required the moment a foreign key has thousands of rows.
  • dashboard_cards= are business metrics, not system ones; lenses= are the saved views an operator would reach for again tomorrow.
  • access_policy= is RBAC per (principal, action, model): without it, anyone who reaches the admin can do everything.
  • AdminTheme covers the look through typed fields; can_import=True opens CSV import with a preview before anything is written.