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:
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 viaMetricsUtils). On by default, omitted without the[metrics]extra, disable withmake_admin_router(show_metrics=False).GET /admin/logs— application logs (whenshow_logs=True): reads the structured JSON files written byconfigure_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/export— log 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.json— export the current result set (honoring search/filters/sort) as CSV or JSON. Row cap viamake_admin_router(export_max_rows=…)(default 5000).POST /admin/m/{slug}/bulk— bulk actions (delete / activate / deactivate + your custom actions) on the selected rows.GET/POST /admin/m/{slug}/new— create a record (whencan_create).GET /admin/m/{slug}/{identity}— detail view with Edit/Delete controls.GET/POST /admin/m/{slug}/{identity}/edit— edit a record (whencan_edit).POST /admin/m/{slug}/{identity}/delete— delete a record (whencan_delete).GET /admin/static/{path}— the panel's bundled CSS (admin.css).GET /admin/_ssr/htmx.js— the HTMX bundled in the SDK (ssr/_static/htmx.min.js), served without a session because the login page loads it too. The path followsprefix: withprefix="/backoffice"it becomes/backoffice/_ssr/htmx.js.
The panel renders without any third party
No admin page fetches a script, stylesheet or font from outside your application: the HTMX <script> points at {prefix}/_ssr/htmx.js, which serves the file bundled in the wheel byte for byte. The exception is what you configure yourself: AdminTheme's favicon_url, logo_url and custom_css_url load from wherever they point. A CDN outage or a tampered CDN file cannot reach the admin origin, which is the same origin as POST /admin/sql and the CRUD. It works with the [admin] extra alone — [ssr] is not needed.
CSP is still a separate step. The admin sends no Content-Security-Policy, and it still uses an inline <style> (the AdminTheme theme), an inline <script> on the list view, an onclick on the FK autocomplete and style= attributes on the progress bars. Serving HTMX locally removes the third-party dependency, but it is not enough for a script-src 'self' policy without 'unsafe-inline': under it, those parts stop working.
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.
Each code is good for one sign-in: UserModelAuthBackend records the accepted step in totp_last_step (an MFAMixin column; a custom backend that keeps its own state overrides claim_mfa_step). And the challenge limits wrong codes per principal: make_admin_router(mfa_throttle=...), by default 5 failures per 900 s on an InMemoryThrottleBackend; once spent, /admin/mfa answers 429 with Retry-After before looking at the code, even after a fresh login. With more than one worker, pass an AttemptThrottle on Redis.
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.email}! 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-dataautomatically whenupload_fieldsis set. - Create: a file is required only when the column is
NOT NULLwith 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>); useupload_storage(orUploadUtils) 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```pytbfenced 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 throughextra=, 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(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(andcan_deleteto 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 import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from tempest_fastapi_sdk import (
AdminSite,
MetricCard,
MetricPartition,
MetricTrend,
MetricValue,
)
from src.db.models import OrderModel
from src.db.repositories import OrderRepository
async def orders_today(session: AsyncSession) -> MetricValue:
total = await OrderRepository(session).count(filters={"start_in": date.today()})
return MetricValue(total, unit="orders")
async def revenue_between(session: AsyncSession, start: date, end: date) -> float:
"""Sum the revenue of the orders created in [start, end)."""
result = await session.execute(
select(func.coalesce(func.sum(OrderModel.total), 0)).where(
func.date(OrderModel.created_at) >= start,
func.date(OrderModel.created_at) < end,
)
)
return float(result.scalar_one())
async def revenue_trend(session: AsyncSession) -> MetricTrend:
tomorrow = date.today() + timedelta(days=1)
this_week = tomorrow - timedelta(days=7)
last_week = this_week - timedelta(days=7)
return MetricTrend(
value=await revenue_between(session, this_week, tomorrow),
previous=await revenue_between(session, last_week, this_week),
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="since midnight"),
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/directioncomputed for you).MetricPartition(segments=[(label, value), ...])— a breakdown with proportional bars;totalsummed 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 — and the failure is logged with its traceback
(logger.exception on the tempest_fastapi_sdk.admin.router logger,
naming the card's label), so the card missing from the page shows up in
the log.
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.
Password in the form (password_fields=)¶
A model inheriting BaseUserModel could not be created through the
panel. The form does not show hashed_password (nobody types a bcrypt
digest into an <input type="text">), the column is NOT NULL, and the
insert answered a 400 reading Conflict creating <Model> — which an
operator reads as "e-mail already taken".
password_fields= closes the loop: the box takes plaintext and the save
stores the hash.
# src/admin.py
from tempest_fastapi_sdk import AdminModel, AdminSite, PasswordPolicy
from src.db.models import LocalUserModel
site = AdminSite(title="Panel")
site.register(
AdminModel(
LocalUserModel,
password_fields=[LocalUserModel.hashed_password],
password_policy=PasswordPolicy(min_length=12, require_complexity=True),
list_display=[LocalUserModel.name, LocalUserModel.email],
),
)
What changes, field by field:
- Create:
<input type="password" autocomplete="new-password">, required while the column isNOT NULL. The value goes throughcheck_password_policyand, when rejected, becomes an error on the field — no longer aConflictException. The label drops thehashed_prefix (hashed_password→ Password): the box takes plaintext, and calling it "Hashed Password" would describe the column, not what you type. - Edit: the box starts empty, and empty means leave it alone. Filled in, it rotates the hash. The digest never pre-fills the input.
- Hashing: uses the model's own
set_passwordwhen it has one (that is whatBaseUserModelships, and where a project swaps the algorithm); otherwisePasswordUtils. - Never shown: out of
list_display(even a hand-declared one), out of the detail view and out of the CSV/JSON export.
password_fields does not combine with can_import=True
Hashing a plaintext column read from a file is a separate decision, and
the importer would post every row with no password at all. The
combination raises ValueError when the AdminModel is built — at
boot, not in production.
The policy comes from your settings
PasswordPolicy.from_settings(settings) reads
AUTH_PASSWORD_MIN_LENGTH, AUTH_PASSWORD_MAX_BYTES and
AUTH_PASSWORD_REQUIRE_COMPLEXITY off the Settings you already
compose from AuthSettings — so the panel enforces the same rule as
signup. With no argument, the AuthSettings defaults apply.
A column that never shows (exclude_fields=)¶
Not every credential is a password. A Web Push subscription stores
endpoint, p256dh and auth — whoever holds the three can push to that
device —, and a user stores a push_token and a totp_secret.
readonly_fields does not help: it locks the input and still shows the
value in the detail view, to every admin.
exclude_fields= takes the column out of the whole panel:
# src/admin.py
from tempest_fastapi_sdk import AdminModel, AdminSite
from src.db.models import LocalUserModel, WebPushSubscriptionModel
site = AdminSite(title="Panel")
site.register(
AdminModel(
LocalUserModel,
exclude_fields=[LocalUserModel.totp_secret, LocalUserModel.push_token],
),
)
site.register(
AdminModel(
WebPushSubscriptionModel,
exclude_fields=[
WebPushSubscriptionModel.endpoint,
WebPushSubscriptionModel.p256dh,
WebPushSubscriptionModel.auth,
],
can_create=False,
),
)
The column leaves every surface at once: the list, the detail view, the
create and edit forms (a POST that sends the field by hand is ignored), the
CSV import, the CSV/JSON export, the sortable header (?sort=endpoint is ignored and the
default ordering applies), the inline table on the parent's detail —
even when the Inline names the column in its own list_display —, the
before/after rows of the audit timeline, and the label another model's FK
<select> shows. All of them read the same set, hidden_field_names(),
which is hashed_password + password_fields + exclude_fields.
A configuration that contradicts itself fails when the AdminModel is
built, at boot, instead of rendering the secret:
- a name that is not a column of the model;
- an excluded column another option also names:
list_display,list_filter,search_fields,readonly_fields,upload_fields,autocomplete_fields,password_fields,identity_field,orderingor aLens(filtersororder_by); - a
NOT NULLcolumn with no default excluded whilecan_create=True— the form could never fill it, and every create would end inConflict creating <Model>. That is the push subscription above, hencecan_create=False.
And the audit table?¶
Hiding the timeline is not enough: with audit_model=, add_audited /
update_audited would write the value into the audit table itself, readable
by query or through the panel's SQL console. That is why an AdminModel
with audit_model= refuses an excluded column the model does not list in
__audit_redact__:
# src/db/models.py
from typing import ClassVar
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import BaseModel
class WebPushSubscriptionModel(BaseModel):
"""A device's Web Push subscription."""
__audit_redact__: ClassVar[frozenset[str]] = frozenset(
{"endpoint", "p256dh", "auth"}
)
platform: Mapped[str] = mapped_column(String(16))
endpoint: Mapped[str] = mapped_column(String(512))
p256dh: Mapped[str] = mapped_column(String(128))
auth: Mapped[str] = mapped_column(String(64))
The audit entry writes "[redacted]" in place of the value. On update the
diff is computed on the real values before the swap, so rotating the key
still shows — {"before": "[redacted]", "after": "[redacted]"} —, and
None stays None, so "not set" remains distinguishable from "set".
hashed_password is always redacted, declared or not.
can_import=True is still allowed
Unlike password_fields, there is no hidden decision in the import: the
excluded column is simply not importable, just as it is not editable.
Operator timezone (display_timezone=)¶
A TIMESTAMP(timezone=True) column stores an instant, and the admin
form renders <input type="datetime-local">, which carries no offset
at all. Without saying which zone the box is read in, the panel is a
raw column editor: the submission arrives with no tzinfo, and what it
means is then the driver's call — with asyncpg, the process TZ. In a
container running UTC an operator in Brazil types 20:00 and the row
keeps 20:00Z, three hours ahead
(measured in the consumer).
Declare the zone and the three views start agreeing:
from tempest_fastapi_sdk import AdminModel
from src.admin import site
from src.db.models import EventModel
site.register(
AdminModel(
model=EventModel,
list_display=[EventModel.name, EventModel.date],
display_timezone="America/Sao_Paulo",
),
)
What changes, and nothing else:
- List and detail convert every
datetimeto the zone and print the offset with it (2026-06-15 20:00:00-03:00), so the screen is never ambiguous. - The form pre-fills the box with the local hour
(
2026-06-15T20:00) and writesTimezone: America/Sao_Paulo — stored as UTC.underneath, becausedatetime-localhas nowhere to say it. - The submission is read as wall-clock time in that zone and stored in UTC. A value that already carries an offset is honored, not overridden.
Converting only on write is worse than not converting
The half-fix passes every test about writing and still moves the
row: the form shows the UTC hour, the operator saves without
touching anything, and the displayed value is re-read as local
time. Every save of an untouched form pushes the row three hours.
That is why both ends change together, and why the test that pins
the rule is a real POST of a form nobody edited.
Declare it once on the AdminSite
The zone belongs to the panel, not to one table:
AdminSite(display_timezone=...) applies to every model registered
after it, and an AdminModel that declares its own keeps it.
The export stays in UTC
CSV and JSON feed machines. A timestamp that changes meaning between the screen and the file is worse than one that always means the same thing, so the conversion lives in the views a person reads.
The zone is validated where it is declared
display_timezone="America/Sao_Paolo" (with the typo) raises
ValueError when the AdminModel is built, not in the request that
renders the form. A timezone resolved late has cost us before: the
cron's offset string died inside the scheduler loop, silently.
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:
HttpOnlyalways set.Secureflagged whencookie_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. - The cookie is not enough on its own: every authenticated route reloads the principal through the
auth_backendon each request. An account deleted, deactivated or stripped ofis_adminafter the login is sent to/admin/login(303) on the very next request, without waiting for the cookie to expire. - Per-session CSRF token is generated at login and required by every form POST (login, logout, create, edit, delete, bulk actions, SQL console runs, task cancellation).
secret_keymust be at least 32 bytes — short keys raiseValueErrorat 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 that is generic in the principal — the value authenticate returns and every other method consumes. Swap the default for LDAP / OAuth / external IAM by subclassing, and name your principal in the parameter:
from uuid import UUID
from sqlalchemy import select
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[AdminModel]):
"""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,
) -> AdminModel:
"""Exchange the code, then accept only a known admin e-mail."""
tokens = await my_oauth_client.exchange_code(password)
identity = await my_oauth_client.fetch_user(tokens)
if not identity.email_verified or identity.email != identifier:
raise AdminAuthError("not an admin")
result = await session.execute(
select(AdminModel).where(AdminModel.email == identity.email)
)
row = result.scalar_one_or_none()
if row is None:
raise AdminAuthError("not an admin")
return row
async def load_principal(
self,
session: AsyncSession,
principal_id: str,
) -> AdminModel | None:
"""Reload the admin row on every request."""
return await session.get(AdminModel, UUID(principal_id))
def principal_id(self, principal: AdminModel) -> str:
"""Return the identifier serialized into the cookie."""
return str(principal.id)
def display_name(self, principal: AdminModel) -> 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.
The [AdminModel] parameter is what makes the contract checkable
Without it the class resolves to AdminAuthBackend[Any] — how it behaved
before v0.263.0, and still valid. What the parameter buys you is the error
that shows up when authenticate returns one type and display_name reads
another. Measured with both mypy and basedpyright: both reject a
display_name(self, principal: dict[str, str]) on a class declared
AdminAuthBackend[LdapEntry] — mypy names Liskov outright
(This violates the Liskov substitution principle), basedpyright reports
reportIncompatibleMethodOverride.
An unparameterized subclass under mypy --strict
mypy --strict turns on disallow_any_generics, so class MyBackend(AdminAuthBackend)
now reports Missing type arguments for generic type "AdminAuthBackend".
Runtime did not change — this is the type-checker only. Write your principal
(AdminAuthBackend[MyRow]), or keep the exact previous behaviour with
AdminAuthBackend[Any]. The pyproject.toml that tempest new writes sets
strict = true.
6. Task panel — what is scheduled and what ran¶
TaskPanelService wires the admin to what the workers already produce: the
schedule declared on the broker and the runs a JobStore persisted.
from fastapi import FastAPI
from tempest_fastapi_sdk import (
AdminSite,
AsyncDatabaseManager,
UserModelAuthBackend,
make_admin_router,
)
from tempest_fastapi_sdk.admin import TaskPanelService
from tempest_fastapi_sdk.tasks import JobStore, TaskQueue
from src.core.settings import settings
from src.db.models import JobModel, UserModel
app: FastAPI = FastAPI()
db: AsyncDatabaseManager = AsyncDatabaseManager(settings.DATABASE_URL)
tq: TaskQueue = TaskQueue.rabbitmq(settings.RABBITMQ_URL)
jobs: JobStore[JobModel] = JobStore(db, model=JobModel)
app.include_router(
make_admin_router(
AdminSite(title="Ops"),
db=db,
auth_backend=UserModelAuthBackend(UserModel),
secret_key=settings.ADMIN_SECRET_KEY,
tasks=TaskPanelService(queue=tq, job_store=jobs),
)
)
That mounts three routes and a "Tasks" entry in the sidebar:
| Route | What it does |
|---|---|
GET /admin/tasks |
recent runs (filterable by status) + the declared schedule |
GET /admin/tasks/{job_id} |
one run: progress, stage, attempts, error |
POST /admin/tasks/{job_id}/cancel |
asks it to stop, while the status still allows it |
The panel does not show the broker's queue
It shows what is persisted (JobStore rows) and what is declared
(the broker's registry). Queue depth is absent because TaskIQ exposes
none — and a screen implying otherwise would be worse than no screen.
Either half is enough
queue= alone shows the schedule; job_store= alone shows the runs. A
section with no source is not rendered, rather than rendering empty and
implying there is nothing to see. Passing neither raises ValueError at
construction.
The Schedule column carries every declared trigger
A task may declare several triggers, and not every trigger is a cron:
TaskIQ accepts cron, interval and time (one-shot) entries in the
same list. The column shows one line per trigger, with the timezone
beside the expression whenever the task declared one:
| Declared | Shows as |
|---|---|
@tq.cron(daily(hour=2), cron_offset=CronOffset.BRASILIA) |
0 2 * * * -03:00 |
@tq.cron(daily(hour=2)) |
0 2 * * * |
@tq.interval(timedelta(seconds=30)) |
every 30s |
schedule=[{"time": when}] |
once at 2026-01-01 12:00:00+00:00 |
@tq.task |
on demand |
Showing the offset is not decoration. A cron expression reads as
complete without it, so nothing invites the reader to check — and
0 2 * * * with cron_offset=CronOffset.BRASILIA fires at 05:00 UTC.
Whoever opens the panel to learn when the backup runs would be three
hours off, on the one screen whose only alternative source is the code.
The panel shows what was declared, not TaskIQ's default: an
expression with no offset appears with no offset, not as (UTC).
Rendering the schedule yourself
ScheduledTask.triggers is the tuple of TaskTrigger, in registry
order, and TaskTrigger.cron_offset_label formats the timezone
("-03:00" for a fixed offset, the IANA key for a named zone). The flat
fields — cron, interval_seconds, cron_offset, run_at — summarize
the first trigger of each kind, for callers that need only one. A
schedule key the SDK does not model yet lands in TaskTrigger.extra
rather than being dropped.
Why there is no \"next run\" column
pycron, which arrives with TaskIQ, exposes is_now and has_been —
and no next(). Computing the next run would mean sweeping candidate
minutes on every render (up to ~44k iterations for a monthly cron) or
taking a new dependency for one column. The panel shows the expression —
with its offset beside it, because without one the expression is not
legible.
For the paginated, filterable, exportable list the admin already knows how to render, register the job model too — symmetric to the dead-letter panel:
from tempest_fastapi_sdk import AdminSite
from tempest_fastapi_sdk.tasks import make_job_admin_model
from src.db.models import JobModel
site: AdminSite = AdminSite(title="Ops")
site.register(make_job_admin_model(JobModel))
7. 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+AdminModelturn your models into a CRUD interface with no template of your own: the declaration is the screen.@admin_actionputs a domain operation in the list, and theAdminActionResultit returns is what the operator reads back.audit_model=renders the who-changed-what timeline inside the detail, andinlines=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.AdminThemecovers the look through typed fields;can_import=Trueopens CSV import, which writes every valid row at once and reports the rows it skipped — there is no preview step.exclude_fields=takes a credential that is not a password off every surface of the panel;readonly_fieldsonly locks the write.display_timezone=is what turns the datetime field into a widget: the screen reads and writes in the operator's zone, the column stays UTC.