FastAPI + HTMX + Jinja2 admin interface framework — a modern replacement for Flask-Admin
Project description
fasthx-admin
A modern admin interface framework for FastAPI built with HTMX, Jinja2, and Bootstrap 5. Designed as a drop-in replacement for Flask-Admin with full control over rendering.
Screenshots
Dashboard
Dashboard with clickable summary cards, recent items, and quick actions
List View
List view with search, sorting, pagination, and row actions
Form with Sections
Create/edit form with accordion sections and AJAX select fields
Detail View
Detail view with formatted fields
Toast Notifications
Toast notifications for validation errors and action feedback
AI Settings
Configure AI provider, model, API key, and connection settings
AI Context & Tools
Manage context items and enable/disable AI-callable tools
AI Chat Widget
Built-in AI assistant with tool calling and markdown support
Table of Contents
- Features
- Installation
- Quick Start
- Architecture Overview
- Database Setup
- Defining Models
- The Admin Class
- CRUDView Configuration
- Column Filters
- Custom Endpoints
- Dependent Dropdowns
- Toast Notifications
- Modals
- Validation
- Model Lifecycle Hooks
- Audit Logging
- Progress Bar
- Authentication
- AI Chat (Optional)
- Custom Pages (Dashboard, Wizard, etc.)
- Custom Navigation Links
- Templates
- Theming
- Icons
- Auto-Generated Routes
- Environment Variables
- Flask-Admin Migration Guide
- Running the Demo
- Tech Stack
Features
- Auto-generated CRUD -- list, detail, create, edit, delete routes from SQLAlchemy models
- Dark/light theme -- toggle with localStorage persistence, no flash on load
- HTMX-powered -- live search, sortable columns, auto-polling status cells, dependent dropdowns, progress bars
- Accordion form sections -- group form fields into collapsible sections
- Custom column formatters -- render badges, links, icons, code blocks in table cells
- Custom row actions -- per-row buttons with HTMX (deploy, build, reset, etc.)
- Multi row actions -- bulk operations on selected rows with checkboxes and "With Selected" dropdown
- Collapsible sidebar categories -- click category headers to collapse/expand, state persisted in localStorage, active category auto-expands
- Responsive sidebar -- auto-grouped from model metadata, collapses on mobile
- View-level access control -- restrict views by user or group (
allowed_users,allowed_groups) with both sidebar and route-level enforcement - Inline header filters -- per-column text filters in the table header with FK relationship support
- FK-aware search -- search through foreign key relationships using dotted notation (e.g.,
"serverid.hostname") - OIDC/Keycloak auth -- Resource Owner Password Credentials flow with group-based access
- Dev mode -- set
AUTH_DISABLED=1to bypass auth entirely - Foreign key dropdowns -- auto-populated from related models
- AJAX select fields -- searchable, paginated foreign key selects via HTMX (replaces Flask-Admin's
form_ajax_refs) - Pagination -- configurable page size with prev/next navigation
- Built-in templates -- 7 page templates + 8 partials, all customizable
- AI chat widget (optional) -- pluggable LLM-powered assistant with tool calling, settings UI, and OpenAI-compatible provider
Installation
pip install fasthx-admin
With AI chat support (adds httpx):
pip install fasthx-admin[ai]
With development extras (uvicorn, pytest, httpx):
pip install fasthx-admin[dev]
Quick Start
A minimal working app in one file:
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from sqlalchemy import Column, Integer, String
from starlette.middleware.sessions import SessionMiddleware
from fasthx_admin import Admin, CRUDView, Base, init_db
# 1. Initialise the database
engine = init_db("sqlite:///./app.db", connect_args={"check_same_thread": False})
# 2. Define a model
class Customer(Base):
__tablename__ = "customers"
id = Column(Integer, primary_key=True)
name = Column(String(100), nullable=False)
email = Column(String(200))
__admin_category__ = "CRM"
__admin_icon__ = "people"
__admin_name__ = "Customers"
def __repr__(self):
return f"<Customer {self.name}>"
# 3. Create the app with lifespan
@asynccontextmanager
async def lifespan(app):
Base.metadata.create_all(bind=engine)
yield
app = FastAPI(lifespan=lifespan)
app.add_middleware(SessionMiddleware, secret_key=os.environ.get("SESSION_SECRET", "change-me"))
# 4. Create admin and register views
admin = Admin(app, title="My Admin")
class CustomerView(CRUDView):
model = Customer
column_list = ["id", "name", "email"]
admin.add_view(CustomerView)
Run it:
AUTH_DISABLED=1 uvicorn app:app --reload
# Open http://127.0.0.1:8000/customers
This gives you a full CRUD interface with list/detail/create/edit/delete, search, sorting, pagination, and a sidebar -- all from 30 lines of code.
Architecture Overview
fasthx_admin/
├── __init__.py # Public API exports
├── database.py # init_db(), get_db(), Base
├── auth.py # OIDC login, get_current_user, AUTH_DISABLED
├── crud.py # CRUDView base class + Admin factory
├── templates/ # Jinja2 templates (base, list, form, detail, wizard, partials)
└── static/ # CSS (dark/light theme) + JS (theme toggle, HTMX hooks)
How it works:
- You define SQLAlchemy models inheriting from
Base - You subclass
CRUDViewfor each model, setting class-level configuration - The
Adminfactory instantiates your views, introspects the models, and auto-registers FastAPI routes - Built-in Jinja2 templates render list tables, detail pages, and forms
- HTMX handles dynamic interactions (search, polling, dropdowns) without page reloads
Database Setup
fasthx_admin uses a configurable database via init_db(). Call it once at startup before creating tables.
from fasthx_admin import init_db, Base
# SQLite (development)
engine = init_db(
"sqlite:///./app.db",
connect_args={"check_same_thread": False}
)
# PostgreSQL (production)
engine = init_db("postgresql://user:pass@localhost/mydb")
# Create tables
Base.metadata.create_all(bind=engine)
Available functions
| Function | Description |
|---|---|
init_db(url, **kwargs) |
Create engine + session factory. Returns the engine. kwargs are passed to create_engine(). |
get_db() |
FastAPI dependency that yields a database session. Auto-closes when done. |
get_engine() |
Returns the current engine (raises RuntimeError if init_db not called). |
Base |
SQLAlchemy declarative base -- use this for all your models. |
Defining Models
Models are standard SQLAlchemy models that inherit from Base. Add optional metadata attributes to control how they appear in the admin sidebar:
from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, Enum as SAEnum
from sqlalchemy.orm import relationship
from fasthx_admin import Base
import enum
class DeviceStatus(str, enum.Enum):
ONLINE = "online"
OFFLINE = "offline"
ERROR = "error"
class Device(Base):
__tablename__ = "devices"
id = Column(Integer, primary_key=True, index=True)
hostname = Column(String(100), nullable=False)
ip_address = Column(String(45))
status = Column(SAEnum(DeviceStatus), default=DeviceStatus.OFFLINE)
site_id = Column(Integer, ForeignKey("sites.id"))
site = relationship("Site", back_populates="devices")
# --- Admin UI metadata ---
__admin_category__ = "Network" # Sidebar group heading
__admin_icon__ = "router" # Bootstrap Icons name (https://icons.getbootstrap.com)
__admin_name__ = "Devices" # Display label in sidebar
def __repr__(self):
return f"<Device {self.hostname}>"
Model metadata attributes
| Attribute | Purpose | Default |
|---|---|---|
__admin_category__ |
Groups this model under a sidebar heading | "Other" |
__admin_icon__ |
Bootstrap Icons icon name | "table" |
__admin_name__ |
Display name in the sidebar and page titles | Table name, title-cased |
The __repr__ method is used to display items in foreign key dropdowns, so make it human-readable.
The Admin Class
Admin is the central factory that ties everything together.
from fasthx_admin import Admin
admin = Admin(
app, # Your FastAPI app (required)
title="My Admin", # Brand name in sidebar + page titles
static_url="/static/fasthx-admin", # Where package CSS/JS are served
mount_statics=True, # Auto-mount built-in static files
public_pages={"login.html"}, # Templates that skip auth check
)
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
app |
FastAPI |
required | Your FastAPI application instance |
templates |
Jinja2Templates |
None |
Custom templates (uses built-in if None) |
title |
str |
"Admin" |
Brand name shown in sidebar header and page titles |
static_url |
str |
"/static/fasthx-admin" |
URL path where static assets are mounted |
mount_statics |
bool |
True |
Whether to auto-mount built-in CSS/JS |
public_pages |
set[str] |
{"login.html"} |
Template names that don't require authentication |
ai_chat |
bool |
False |
Enable the AI chat widget and settings pages (requires fasthx-admin[ai]) |
extra_templates_dirs |
list[str] |
None |
Additional template directories to search before the built-in ones (for overriding or extending templates) |
settings_admin_groups |
list[str] |
None |
Keycloak group DNs allowed to see the Settings sidebar category (e.g. ["/Edge-Admins"]) |
settings_admin_users |
list[str] |
None |
Usernames allowed to see the Settings sidebar category (e.g. ["admin"]) |
Methods
| Method | Description |
|---|---|
admin.add_view(ViewClass) |
Instantiate a CRUDView subclass and register its routes. Returns the instance. |
admin.get_view("name") |
Look up a registered view by its name attribute. |
admin.add_link(name, url, display_name, icon="link", category="Other") |
Add a custom navigation link to the sidebar (non-CRUD). |
admin.get_nav_categories() |
Returns the sidebar navigation structure as a dict. |
admin.templates |
The Jinja2Templates instance -- use for rendering custom pages. |
What Admin does automatically
- Mounts static files -- CSS and JS at the configured
static_url - Sets up Jinja2 templates -- uses the package's built-in templates
- Wraps TemplateResponse -- every template automatically gets:
current_user-- the logged-in user (or mock user if auth disabled)nav_categories-- sidebar navigation built from all registered viewsstatic_url-- path to static assetsadmin_title-- the configured title- Auth redirect -- non-public pages redirect to
/loginif unauthenticated
CRUDView Configuration
CRUDView is the heart of fasthx-admin. Subclass it and set class-level attributes to configure each model's admin interface.
Basic Attributes
class DeviceView(CRUDView):
model = Device # Required: SQLAlchemy model class
name = "devices" # URL prefix (default: model.__tablename__)
display_name = "Network Devices" # Sidebar + page title (default: model.__admin_name__)
category = "Network" # Sidebar group (default: model.__admin_category__)
icon = "router" # Bootstrap Icons name (default: model.__admin_icon__)
page_size = 25 # Records per page (default: 20)
Column Configuration
Control which columns appear in the list table:
class DeviceView(CRUDView):
model = Device
# Option A: Explicitly list columns to show (in order)
column_list = ["id", "hostname", "ip_address", "status", "site_id"]
# Option B: Exclude specific columns (show everything else)
column_exclude = ["deploy_progress"]
# If neither is set, all model columns are shown
# Rename column headers
column_labels = {
"site_id": "Site",
"ip_address": "IP Address",
}
# Restrict which columns are searchable (default: all String columns)
column_searchable = ["hostname", "ip_address"]
# Search through foreign key relationships using dotted notation
# This joins the related table and searches the specified column
column_searchable = ["hostname", "serverid.hostname"]
# Or search all string columns on the related table (no dot)
column_searchable = ["hostname", "serverid"]
# Restrict which columns are sortable (default: all columns)
column_sortable = ["id", "hostname", "status"]
Column Formatters
Column formatters are functions that transform raw values into HTML for display. They receive (value, obj) where value is the column value and obj is the full SQLAlchemy model instance.
def format_status_badge(value, obj):
"""Render an enum value as a coloured badge."""
colors = {
DeviceStatus.ONLINE: "success",
DeviceStatus.OFFLINE: "secondary",
DeviceStatus.ERROR: "danger",
}
color = colors.get(value, "secondary")
label = value.value.title() if hasattr(value, "value") else str(value)
return f'<span class="badge bg-{color}">{label}</span>'
def format_ip_code(value, obj):
"""Render a value in monospace."""
return f'<code>{value}</code>'
def format_site_link(value, obj):
"""Render a foreign key as a clickable link to the related item."""
if obj.site:
return f'<a href="/sites/{obj.site.id}">{obj.site.name}</a>'
return str(value) if value else ""
def format_external_link(value, obj):
"""Render a URL as a clickable external link."""
return f'<a href="https://{value}" target="_blank">{value} <i class="bi bi-box-arrow-up-right"></i></a>'
class DeviceView(CRUDView):
model = Device
column_formatters = {
"status": format_status_badge,
"ip_address": format_ip_code,
"site_id": format_site_link,
}
Formatters return raw HTML strings. The templates render them with | safe so Bootstrap classes, icons, and links all work.
Form Configuration
Control which fields appear in create/edit forms:
class DeviceView(CRUDView):
model = Device
# Explicitly list form fields (default: all columns except 'id')
form_columns = ["hostname", "ip_address", "status", "site_id"]
Field types are auto-detected from the SQLAlchemy column type:
| SQLAlchemy Type | HTML Input Type |
|---|---|
Integer, Float |
<input type="number"> |
String, VARCHAR |
<input type="text"> |
Text |
<textarea> |
Boolean |
<input type="checkbox"> (toggle switch) |
DateTime |
<input type="datetime-local"> |
Date |
<input type="date"> |
Enum |
<select> with enum values |
| Foreign Key | <select> auto-populated from related model |
Detail View
The detail view (GET /{name}/{id}) shows a read-only page for a single record. By default it displays all model columns — not just the ones in column_list.
class DeviceView(CRUDView):
model = Device
# List view shows a subset
column_list = ["id", "hostname", "status"]
# Detail view shows ALL columns by default — no config needed
# Or customize which columns appear in the detail view:
detail_columns = ["id", "hostname", "ip_address", "serial_number",
"status", "site_id", "created_at", "updated_at"]
You can also exclude specific columns instead of listing them all:
class DeviceView(CRUDView):
model = Device
detail_columns_exclude = ["password", "api_key", "created_at"]
detail_columns_exclude works with both explicit detail_columns lists and the default (all columns). If both detail_columns and detail_columns_exclude are set, exclusions are applied after the explicit list.
column_labels and column_formatters apply to detail view fields when matching keys exist.
New in 0.5.8: Added
detail_columns_excludefor excluding columns without listing all others. New in 0.5.4: Detail view now shows all model columns by default instead of onlycolumn_listcolumns. Usedetail_columnsto customize.
Form Sections (Accordion Groups)
Group form fields into collapsible accordion sections:
class DeviceView(CRUDView):
model = Device
form_sections = {
"Device Info": ["hostname", "ip_address"],
"Status": ["status"],
"Relationships": ["site_id"],
}
The first section is expanded by default. If form_sections is None, all fields render in a flat list.
Form Widget Overrides
Customize individual form fields with extra attributes or replace their type entirely. Any key in the override dict is merged into the field metadata, so you can change field types, add attributes, or tweak behavior per field.
Supported override keys:
| Key | Description | Example |
|---|---|---|
type |
Change the HTML input type. Use "select" for dropdowns, "textarea" for multi-line text, "checkbox" for booleans, or any HTML input type ("text", "number", "email", "date", etc.) |
"type": "select" |
choices |
List of (value, label) tuples for select fields |
"choices": [("v1", "Version 1")] |
label |
Override the auto-generated field label | "label": "Firmware" |
required |
Override whether the field shows as required | "required": False |
placeholder |
Placeholder text for text inputs | "placeholder": "e.g. edge-001" |
hx_get |
HTMX hx-get URL for dependent dropdowns |
"hx_get": "/api/options" |
hx_target |
HTMX hx-target selector |
"hx_target": "#other_field" |
hx_trigger |
HTMX hx-trigger event (defaults to "change") |
"hx_trigger": "change" |
hx_swap |
HTMX hx-swap strategy (defaults to "innerHTML") |
"hx_swap": "outerHTML" |
depends_on |
Field key of a checkbox — this field is only visible when that checkbox is checked | "depends_on": "is_ha" |
description |
Tooltip text shown as an info icon next to the field label (Bootstrap tooltip) | "description": "Must be a public IP" |
Examples:
class EdgeView(CRUDView):
model = Edge
form_widget_overrides = {
# Turn a text field into a select with static choices
"firmware_version": {
"type": "select",
"choices": [
("6.4", "Version 6.4"),
("7.2", "Version 7.2"),
("7.4", "Version 7.4"),
],
},
# Override the label and make a field optional
"serial_number": {
"label": "S/N",
"required": False,
},
# Add placeholder text
"hostname": {
"placeholder": "e.g. edge-001",
},
# Change a text field to a textarea
"notes": {
"type": "textarea",
},
# Add HTMX attributes to trigger dependent dropdowns
"customer_id": {
"hx_get": "/api/orchestrators-for-customer",
"hx_target": "#orchestrator_id",
},
# Add a tooltip to explain a field
"wan_ip": {
"description": "Must be a public routable IP address",
},
}
Conditional field visibility:
Use depends_on to show fields only when a checkbox is checked. This is useful for toggling optional sections like HA (High Availability) settings:
class LaunchPadView(CRUDView):
model = LaunchPad
form_widget_overrides = {
# These fields are hidden unless the "is_ha" checkbox is checked
"ha_mode": {
"depends_on": "is_ha",
"type": "select",
"choices": [("active-standby", "Active-Standby"), ("active-active", "Active-Active")],
},
"ha_switch_mode": {
"depends_on": "is_ha",
"type": "select",
"choices": [("manual", "Manual"), ("automatic", "Automatic")],
},
}
When is_ha is unchecked, the ha_mode and ha_switch_mode fields are hidden. When the user toggles it on, the fields appear instantly (no server round-trip).
Field tooltips:
Use description to add a Bootstrap tooltip info icon next to any field label. Useful for providing context or instructions without cluttering the form:
class CustomerView(CRUDView):
model = Customer
form_widget_overrides = {
"prisma_tsg_id": {
"type": "select",
"description": "The Prisma tenant service group to associate with this customer",
},
"contract_end": {
"description": "Leave blank for month-to-month agreements",
},
}
Hovering over the info icon displays the tooltip. Works on all field types including checkboxes, selects, and AJAX selects.
AJAX Select (Searchable Foreign Keys)
For foreign key fields with large option sets, use form_ajax_refs to replace the standard dropdown with a searchable, paginated select powered by HTMX. This is the fasthx-admin equivalent of Flask-Admin's form_ajax_refs.
from myapp.models import Offering, Server
class OfferingView(CRUDView):
model = Offering
form_ajax_refs = {
"serverid": {
"model": Server, # The related SQLAlchemy model
"fields": ["hostname"], # Columns to search against (ilike)
"placeholder": "Please select uCPE", # Search input placeholder
"page_size": 10, # Results per page (default: 10)
}
}
How it works:
- The form renders a text search input above a multi-row
<select>(instead of a single dropdown with all options) - As the user types, HTMX fires a
GET /{view}/ajax/{field}?q=<term>request after a 300ms debounce - The endpoint filters the target model using
ilikeon the configuredfieldsand returns paginated<option>HTML fragments - If more results exist beyond
page_size, an "infinite scroll" trigger auto-loads the next page when the user scrolls to the bottom of the select list (usinghx-trigger="intersect once") - On edit forms, the currently selected value is pre-populated in the select
Configuration options:
| Key | Type | Default | Description |
|---|---|---|---|
model |
SQLAlchemy model | (required) | The related model to search |
fields |
list[str] |
[] |
Model columns to search with ilike |
placeholder |
str |
"Type to search..." |
Placeholder text for the search input |
page_size |
int |
10 |
Number of results per HTMX request |
Auto-registered endpoint:
Each form_ajax_refs entry registers a GET /{view_name}/ajax/{field_key} route that accepts:
q-- search term (optional)page-- page number (default: 1)
Row Actions
Add custom action buttons to each row in the list table:
class EdgeView(CRUDView):
model = FortiEdge
row_actions = [
{
"label": "Deploy", # Button text
"icon": "rocket", # Bootstrap Icons name
"hx_post": "/edges/{id}/deploy", # HTMX POST URL ({id} is replaced per row)
"hx_target": "closest tr", # HTMX target element
"hx_swap": "afterend", # HTMX swap strategy
"class": "btn-outline-success", # Bootstrap button class
},
{
"label": "Reset",
"icon": "arrow-counterclockwise",
"hx_post": "/edges/{id}/reset",
"hx_target": "closest tr",
"hx_swap": "outerHTML",
"class": "btn-outline-warning",
"confirm": "Reset this edge device?", # Confirmation dialog
},
]
Every row also gets View and Edit buttons automatically (based on permissions), plus a Delete button with confirmation.
Link-based row actions (file downloads, navigation)
For actions that should trigger a file download or navigate to a URL (instead of an HTMX swap), use href instead of hx_post:
class EdgeView(CRUDView):
model = FortiEdge
row_actions = [
{
"label": "Template",
"icon": "download",
"href": "/edges/{id}/template", # Regular link ({id} replaced per row)
"confirm": "Download onboarding template?",
},
]
This renders a standard <a> link instead of an HTMX button, so the browser handles the response natively — essential for file downloads where the endpoint returns a StreamingResponse with Content-Disposition: attachment.
Row action fields
| Field | Description |
|---|---|
label |
Button text |
icon |
Bootstrap Icons name (optional) |
hx_post |
HTMX POST URL. {id} is replaced with the row's primary key. |
hx_target |
HTMX target selector (default: "closest tr") |
hx_swap |
HTMX swap strategy (default: "afterend") |
href |
Regular link URL. Use instead of hx_post for downloads or navigation. {id} is replaced with the row's primary key. |
download |
If true, adds the download attribute to the link (optional, use with href) |
target |
Link target (e.g., "_blank") for opening in a new tab (optional, use with href) |
class |
CSS class for the button (default: "btn-outline-primary") |
confirm |
If set, shows a confirmation dialog before executing |
Multi Row Actions
Add bulk actions that operate on multiple selected rows. When multi_row_actions is set, a checkbox column appears on the left of the table with a "Select all" checkbox in the header. A "With Selected" dropdown appears in the toolbar when items are checked:
class OfferingView(CRUDView):
model = Offering
multi_row_actions = [
{
"label": "Delete Selected",
"icon": "trash",
"hx_post": "/offerings/bulk-delete",
"confirm": "Delete all selected items?",
"class": "text-danger",
},
{
"label": "Activate Selected",
"icon": "check-circle",
"hx_post": "/offerings/bulk-activate",
},
]
Define the bulk action endpoint on your view. Selected IDs are sent as ids form data:
@CRUDView.endpoint("/{name}/bulk-delete", methods=["POST"], response_class=HTMLResponse)
async def bulk_delete(self, request: Request, db: Session = Depends(get_db)):
form = await request.form()
ids = form.getlist("ids")
db.query(self.model).filter(self.model.id.in_(ids)).delete(synchronize_session=False)
db.commit()
return toast_response(f"Deleted {len(ids)} items", type="success")
Multi row action fields
| Field | Description |
|---|---|
label |
Button text in the dropdown |
icon |
Bootstrap Icons name (optional) |
hx_post |
POST URL for the bulk action |
confirm |
If set, shows a confirmation dialog before executing |
class |
CSS class for the dropdown item (e.g., "text-danger") |
New in 0.5.13: Added
multi_row_actionsfor bulk operations on selected rows.
HTMX Polling Columns
Auto-refresh specific table cells at an interval. The framework auto-generates GET endpoints that return the current value.
class EdgeView(CRUDView):
model = FortiEdge
htmx_columns = {
"status": {
"url": "/edges/{id}/status", # Polling URL ({id} replaced per row)
"trigger": "every 5s", # HTMX trigger interval
},
}
This auto-generates a GET /edges/{item_id}/status endpoint that returns the current status value rendered through partials/status_cell.html. No custom endpoint code needed.
You can combine this with column formatters -- the initial render uses your formatter, and polling updates use the status_cell partial.
Permissions
Control which operations are available:
class AuditLogView(CRUDView):
model = AuditLog
can_create = False # Hide "Create" button
can_edit = False # Hide "Edit" button on each row
can_delete = False # Hide "Delete" button on each row
All default to True.
Sidebar Visibility
Restrict which users or groups can see a view in the sidebar. By default all views are visible to everyone. When allowed_users or allowed_groups is set, only matching users see the view in the sidebar.
class InternalToolsView(CRUDView):
model = InternalTool
allowed_users = ["admin", "devops"] # Only these usernames see this view
class NetworkView(CRUDView):
model = NetworkDevice
allowed_groups = ["/Edge-Admins", "/NOC"] # Only members of these groups see this view
| Attribute | Type | Default | Description |
|---|---|---|---|
allowed_users |
list[str] |
None |
Usernames that can see this view in the sidebar. None = visible to all. |
allowed_groups |
list[str] |
None |
Group DNs that can see this view. None = visible to all. |
If both are set, matching either list grants access. This controls both sidebar visibility and route access — unauthorized users receive a 403 response when accessing any route on the view directly.
The same allowed_users and allowed_groups parameters are available on admin.add_link() for custom navigation links:
admin.add_link("debug", "/debug", "Debug Tools", icon="bug", category="Dev",
allowed_users=["admin"])
Note: The Settings category (AI Settings) uses a separate mechanism — settings_admin_users and settings_admin_groups on the Admin constructor. Unlike views, Settings is hidden by default and requires an explicit allow list to be visible:
admin = Admin(app, ai_chat=True, settings_admin_users=["admin"])
Column Filters
Add dropdown filters to any list view with the column_filters attribute:
class CustomerView(CRUDView):
model = Customer
column_filters = ["currentstatus", "region"]
This renders filter dropdowns above the table, populated with distinct values from each column. Labels come from column_labels if set. Features:
- Filters apply alongside search and sorting
- Active filters carry through pagination, search, sort, and export links
- A "Clear" button appears when any filter is active
- Filter params use the format
?flt_columnname=valuein the URL - Enum columns are supported — values are converted automatically
class ServerView(CRUDView):
model = Server
column_filters = ["status", "datacenter", "os_type"]
column_labels = {
"status": "Status",
"datacenter": "Data Center",
"os_type": "OS Type",
}
export_types = ["csv"] # export respects active filters
Inline Header Filters
Add per-column text filter inputs directly in the table header with column_header_filters. Each column gets a small text input that filters using "contains" matching with a 300ms debounce:
class OfferingView(CRUDView):
model = Offering
column_list = ["id", "serverid", "ipaddress", "status"]
column_header_filters = ["ipaddress", "status"]
Header filters support foreign key relationships using dotted notation, the same as column_searchable:
class OfferingView(CRUDView):
model = Offering
column_list = ["id", "serverid", "ipaddress", "status"]
# Filter the serverid column by searching server.hostname
column_header_filters = ["serverid.hostname", "ipaddress", "status"]
Header filters work alongside the search box and column_filters dropdown — all are combined (AND logic). Filter values are preserved across pagination, sorting, and URL reloads via cf_ query parameters.
New in 0.5.10: Added
column_header_filtersfor inline per-column filtering. New in 0.5.11: Added dotted FK notation support forcolumn_header_filters.
Export
Add CSV and/or XLSX export buttons to any list view with the export_types attribute:
class CustomerView(CRUDView):
model = Customer
export_types = ["csv", "xlsx"]
This adds an "Export" dropdown next to the Create button in the list view. The export:
- Uses the columns defined in
column_listwith labels fromcolumn_labelsas headers - Respects the current search query and sort order
- Downloads all matching records (not just the current page)
Supported formats:
| Format | Dependency |
|---|---|
csv |
None (built-in) |
xlsx |
openpyxl (pip install openpyxl) |
The export endpoint is at /{name}/export/{format} and accepts the same q, sort, and order query params as the list view.
Custom Endpoints
Add custom routes to a CRUDView using the @CRUDView.endpoint decorator. These are registered alongside the auto-generated CRUD routes.
Endpoint Decorator (Recommended)
Decorate methods directly on the class. Use {name} in the path — it's automatically replaced with self.name at init time.
from fastapi import Request, Depends
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from fasthx_admin import CRUDView, get_db
class OrchestratorView(CRUDView):
model = Orchestrator
# Custom action: trigger a build
@CRUDView.endpoint("/{name}/{item_id}/build", methods=["POST"], response_class=HTMLResponse)
async def build(self, request: Request, item_id: int, db: Session = Depends(get_db)):
orch = db.query(self.model).filter(self.model.id == item_id).first()
if not orch:
return HTMLResponse("Not found", status_code=404)
orch.build_status = BuildStatus.BUILDING
db.commit()
# HX-Redirect tells HTMX to do a full page navigation
return HTMLResponse("", headers={"HX-Redirect": f"/{self.name}"})
# Custom API: return filtered options for a dependent dropdown
# For non-{name} paths, use the literal path string
@CRUDView.endpoint("/api/devices-for-site", methods=["GET"], response_class=HTMLResponse)
async def devices_for_site(self, request: Request, site_id: int = 0, db: Session = Depends(get_db)):
options = []
if site_id:
devices = db.query(Device).filter(Device.site_id == site_id).all()
options = [{"id": d.id, "label": d.hostname} for d in devices]
return self.templates.TemplateResponse("partials/dropdown_options.html", {
"request": request,
"options": options,
"selected": None,
})
Key points:
{name}in the path is replaced with the view'snameattributemethods=["POST"]ormethods=["GET"]— defaults to["GET"]if omitted- Any extra kwargs (e.g.
response_class) are passed to FastAPI'sadd_api_route selfgives direct access toself.model,self.name,self.templates, etc.
setup_endpoints Override (Legacy)
The older setup_endpoints() override still works and can be used alongside decorators:
class MyView(CRUDView):
model = MyModel
def setup_endpoints(self):
@self.router.post(f"/{self.name}/{{item_id}}/action", response_class=HTMLResponse)
async def action(request: Request, item_id: int, db: Session = Depends(get_db)):
...
Instance State in Custom Endpoints
If your view needs to track state (like deployment progress), add it in __init__:
class EdgeView(CRUDView):
model = FortiEdge
def __init__(self, templates):
self.deploy_progress = {} # Must be set BEFORE super().__init__
super().__init__(templates)
@CRUDView.endpoint("/{name}/{item_id}/deploy", methods=["POST"], response_class=HTMLResponse)
async def deploy(self, request: Request, item_id: int, db: Session = Depends(get_db)):
self.deploy_progress[item_id] = {"progress": 0, "status": "deploying"}
# ... start deployment logic
Dependent Dropdowns
A common pattern: selecting a value in one dropdown filters the options in another. This uses HTMX + form_widget_overrides + a custom endpoint.
Single Target
Step 1: Configure the trigger dropdown
class DeviceView(CRUDView):
model = Device
form_widget_overrides = {
"site_id": {
"hx_get": "/api/devices-for-site", # Endpoint to call on change
"hx_target": "#device_id", # Target <select> to update
},
}
Step 2: Create the endpoint
@CRUDView.endpoint("/api/devices-for-site", methods=["GET"], response_class=HTMLResponse)
async def devices_for_site(self, request: Request, site_id: int = 0, db: Session = Depends(get_db)):
options = []
if site_id:
items = db.query(Device).filter(Device.site_id == site_id).all()
options = [{"id": d.id, "label": d.hostname} for d in items]
return self.templates.TemplateResponse("partials/dropdown_options.html", {
"request": request,
"options": options,
"selected": None,
})
The partials/dropdown_options.html template renders <option> tags that replace the target <select>'s contents.
Multiple Targets
To update multiple dropdowns from a single trigger, use dropdown_options_multi.html with HTMX out-of-band swaps. The primary target is updated normally, and additional targets are updated via hx-swap-oob.
Step 1: Configure the trigger dropdown (same as single target — hx_target points to the primary target)
class EdgeView(CRUDView):
model = Edge
form_widget_overrides = {
"customer_id": {
"hx_get": "/api/options-for-customer",
"hx_target": "#orchestrator_id", # Primary target
},
}
Step 2: Create the endpoint with oob_targets
@CRUDView.endpoint("/api/options-for-customer", methods=["GET"], response_class=HTMLResponse)
async def options_for_customer(self, request: Request, customer_id: int = 0, db: Session = Depends(get_db)):
orch_options = []
region_options = []
if customer_id:
orchs = db.query(Orchestrator).filter(Orchestrator.customer_id == customer_id).all()
orch_options = [{"id": o.id, "label": o.name} for o in orchs]
regions = db.query(Region).filter(Region.customer_id == customer_id).all()
region_options = [{"id": r.id, "label": r.name} for r in regions]
return self.templates.TemplateResponse("partials/dropdown_options_multi.html", {
"request": request,
"options": orch_options, # Primary target options
"selected": None,
"oob_targets": [ # Additional targets (out-of-band)
{"id": "region_id", "options": region_options, "selected": None},
# Add more targets as needed:
# {"id": "another_field", "options": other_options, "selected": None},
],
})
The response updates #orchestrator_id directly and swaps #region_id (and any other entries in oob_targets) out-of-band. TomSelect is automatically re-synced on all updated selects.
Toast Notifications
fasthx-admin includes a built-in toast notification system powered by Bootstrap toasts and HTMX triggers. Toasts appear in the bottom-right corner and auto-dismiss after 5 seconds.
toast_response helper
Use toast_response() in custom endpoints to show a toast after an action:
from fasthx_admin import CRUDView, toast_response
class EdgeView(CRUDView):
model = FortiEdge
@CRUDView.endpoint("/{name}/{item_id}/deploy", methods=["POST"])
async def deploy(self, request: Request, item_id: int, db: Session = Depends(get_db)):
edge = db.query(self.model).filter(self.model.id == item_id).first()
if not edge:
return toast_response("Edge not found", type="danger", status_code=404)
# ... start deployment ...
return toast_response("Deployment started!", type="success", redirect=f"/{self.name}")
Parameters:
| Parameter | Description |
|---|---|
message |
The toast message text |
type |
"success", "danger", "warning", or "info" (default) |
title |
Optional title (defaults to capitalised type) |
redirect |
Optional URL — adds HX-Redirect header for page navigation after toast |
status_code |
HTTP status code (default 200) |
JavaScript API
You can also trigger toasts from client-side JavaScript:
showToast({ message: "Saved!", type: "success" });
showToast({ message: "Something went wrong", type: "danger", title: "Error" });
Modals
fasthx-admin includes a built-in modal system using Bootstrap 5 modals and HTMX. Use modals to preview content, confirm actions, or display data without leaving the list view — ideal for file previews, detail popups, and download confirmations.
modal_response helper
Use modal_response() in custom endpoints to display content in a modal:
from fasthx_admin import CRUDView, modal_response
class EdgeView(CRUDView):
model = FortiEdge
row_actions = [
{
"label": "Template",
"icon": "download",
"hx_get": "/edges/{id}/template", # Uses hx_get — modal_response handles targeting
},
]
@CRUDView.endpoint("/{name}/{item_id}/template", methods=["GET"])
async def template_preview(self, request: Request, item_id: int, db: Session = Depends(get_db)):
item = db.query(self.model).filter(self.model.id == item_id).first()
if not item:
return HTMLResponse("Not found", status_code=404)
template_text = "# Generated config for " + item.hostname
return modal_response(
title=f"Template — {item.hostname}",
body=f"<pre>{template_text}</pre>",
actions=[
{
"label": "Download",
"icon": "download",
"href": f"/edges/{item_id}/template/download",
"css_class": "btn btn-primary",
},
],
size="modal-lg",
)
The modal opens automatically when the response arrives. A "Close" button is always included in the footer.
Parameters
| Parameter | Description |
|---|---|
title |
Modal header title (auto-escaped) |
body |
HTML string for the modal body (trusted, not escaped — same as column_formatters) |
actions |
Optional list of action button dicts (see below) |
size |
Optional modal size: "modal-sm", "modal-lg", or "modal-xl" (default: standard) |
status_code |
HTTP status code (default 200) |
Action buttons
Each action in the actions list is a dict:
| Field | Description |
|---|---|
label |
Button text |
icon |
Bootstrap Icons name (optional) |
href |
Link URL — renders as <a> (use for downloads, navigation) |
hx_post |
HTMX POST URL — renders as <button> |
hx_get |
HTMX GET URL — renders as <button> |
css_class |
CSS classes (default: "btn btn-secondary") |
How it works
- A row action with
hx_getsends a GET request to your endpoint modal_response()returns HTML for the modal content withHX-RetargetandHX-Reswapheaders that redirect the response into#admin-modal .modal-content- An
HX-Trigger: showModalheader tells the client JS to open the modal - The JS applies any size class and calls
bootstrap.Modal.show()
Because modal_response() uses HX-Retarget, it works regardless of what hx_target is set on the triggering element — the response always ends up in the modal.
JavaScript API
You can also open the modal from client-side JavaScript:
showModal(); // Open with current content
showModal({ size: 'modal-lg' }); // Open with large size
Terminal Console
fasthx-admin includes a terminal console widget — a dark, monospace, scrollable output area inside a modal. Use it for log viewing, streaming command output, script runners, and interactive shells.
console_response helper
Use console_response() in custom endpoints to display terminal-style output:
from fasthx_admin import CRUDView, console_response
class EdgeView(CRUDView):
model = FortiEdge
row_actions = [
{
"label": "Logs",
"icon": "terminal",
"hx_get": "/edges/{id}/logs",
},
]
@CRUDView.endpoint("/{name}/{item_id}/logs", methods=["GET"])
async def view_logs(self, request: Request, item_id: int, db: Session = Depends(get_db)):
item = db.query(self.model).filter(self.model.id == item_id).first()
if not item:
return HTMLResponse("Not found", status_code=404)
log_text = f"[INFO] Device {item.hostname} booted\n[OK] Services started\n"
return console_response(
title=f"Logs — {item.hostname}",
output=log_text,
)
Streaming output via SSE
For real-time output, point the console at an SSE endpoint using stream_url. Use console_sse_message() to format each line:
from fastapi.responses import StreamingResponse
from fasthx_admin import console_response, console_sse_message
@CRUDView.endpoint("/{name}/{item_id}/run-check", methods=["POST"])
async def run_check(self, request: Request, item_id: int, db: Session = Depends(get_db)):
return console_response(
title="Running diagnostics...",
output="",
stream_url=f"/edges/{item_id}/check-stream",
)
@CRUDView.endpoint("/{name}/{item_id}/check-stream", methods=["GET"])
async def check_stream(self, request: Request, item_id: int):
async def generate():
yield console_sse_message("Starting diagnostics...\n", css_class="ansi-green")
for step in ["Checking connectivity", "Verifying config", "Running tests"]:
await asyncio.sleep(1)
yield console_sse_message(f" {step}... OK\n")
yield console_sse_message("\nAll checks passed.\n", css_class="ansi-green")
return StreamingResponse(generate(), media_type="text/event-stream")
Interactive input
Enable a command prompt by setting input_enabled=True. The form POSTs to input_action and appends the response to the output area:
@CRUDView.endpoint("/{name}/shell", methods=["GET"])
async def admin_shell(self, request: Request):
return console_response(
title="Admin Shell",
output="Ready.\n",
input_enabled=True,
input_action=f"/{self.name}/shell/exec",
)
@CRUDView.endpoint("/{name}/shell/exec", methods=["POST"])
async def shell_exec(self, request: Request):
import html
form = await request.form()
cmd = form.get("command", "")
result = f"echo: {cmd}"
return HTMLResponse(f"<pre>$ {html.escape(cmd)}\n{html.escape(result)}\n</pre>")
ANSI colour support
Console output supports ANSI escape codes for coloured text. Codes are converted server-side to CSS classes:
from fasthx_admin import ansi_to_html
# In your output:
text = "\033[32mSuccess\033[0m — \033[1;31mErrors: 0\033[0m"
return console_response("Results", text) # ansi=True by default
Supported codes: colours 30-37 (standard), 90-97 (bright), bold (1), italic (3), underline (4), reset (0).
Parameters
| Parameter | Description |
|---|---|
title |
Console header title (auto-escaped) |
output |
Initial text content (ANSI codes converted when ansi=True) |
input_enabled |
Show a command input prompt (default False) |
input_action |
hx-post URL for the input form (required when input_enabled=True) |
input_placeholder |
Placeholder text for the input field (default "$ ") |
stream_url |
SSE endpoint URL for streaming output |
stream_event |
SSE event name to listen for (default "output") |
ansi |
Auto-convert ANSI escape codes (default True) |
size |
Modal size class (default "modal-xl") |
status_code |
HTTP status code (default 200) |
console_sse_message
| Parameter | Description |
|---|---|
text |
Text content for this SSE message |
event |
SSE event name — must match stream_event in console_response (default "output") |
ansi |
Convert ANSI codes to HTML (default True) |
css_class |
Optional CSS class(es) for the <pre> element (e.g. "ansi-green") |
How it works
console_response()returns HTML with a.console-outputarea inside the existing#admin-modalHX-Trigger: showConsoletells client JS to open the modal and start auto-scrolling- For streaming: the HTMX SSE extension connects to
stream_urland appends<pre>fragments - For input: the form POSTs to
input_actionand appends the response to the output area - SSE connections are automatically cleaned up when the modal is closed
JavaScript API
showConsole(); // Open with current content
showConsole({ size: 'modal-xl' }); // Open with extra-large size
Validation
Raise ValidationError inside on_model_change() to abort a create or edit. The form re-renders with the user's values preserved and a danger toast shows the error message.
from fasthx_admin import CRUDView, ValidationError
class CustomerView(CRUDView):
model = Customer
def on_model_change(self, item, form_data, is_new, db, request=None):
if not item.name or len(item.name.strip()) < 2:
raise ValidationError("Customer name must be at least 2 characters")
if is_new and not item.sid:
raise ValidationError("SID is required for new customers")
Since on_model_change receives db, you can query related models for cross-table validation:
class OfferingView(CRUDView):
model = Offering
def on_model_change(self, item, form_data, is_new, db, request=None):
product = db.query(Product).get(item.productid) if item.productid else None
if product and product.name == "Fortigate":
license_obj = db.query(VnfLicenses).get(item.vnflicensesid)
if license_obj and "Forti" not in license_obj.license:
raise ValidationError("Please select a Forti license")
Model Lifecycle Hooks
CRUDView provides lifecycle hooks that run before and after creates, edits, and deletes. These are useful for audit logging, cache invalidation, sending notifications, syncing external systems, or any side effect that should happen around model changes.
| Hook | When it runs | Can abort? |
|---|---|---|
on_model_change(item, form_data, is_new, db, request) |
After _apply_form_data(), before db.commit() |
Yes — raise ValidationError |
after_model_change(item, form_data, is_new, db, request) |
After successful commit | No |
on_model_delete(item, db) |
Before db.delete() and db.commit() |
Yes — raise ValidationError |
after_model_delete(item, db) |
After successful delete commit | No |
Use on_model_change for both validation and mutation before save. There is no separate validate() hook — keep it simple with one hook before commit and one after.
New in 0.5.2:
on_model_changeandafter_model_changereceive therequestparameter for access to the current user session and request context. Defaults toNonefor backward compatibility.New in 0.5.3: Removed the separate
validate()hook. All validation now belongs inon_model_change(), which has full access todbandrequest.
Example: Audit logging
from fasthx_admin import CRUDView
class CustomerView(CRUDView):
model = Customer
def after_model_change(self, item, form_data, is_new, db, request=None):
user = get_current_user(request) or {}
action = "created" if is_new else "updated"
db.add(AuditLog(entity="customer", entity_id=item.id, action=action, user=user.get("username")))
db.commit()
def after_model_delete(self, item, db):
db.add(AuditLog(entity="customer", entity_id=item.id, action="deleted"))
db.commit()
For standardized audit logging across many views (with automatic old/new diffing on edits and field-level filtering), see Audit Logging.
Example: Prevent deletion
class OrderView(CRUDView):
model = Order
def on_model_delete(self, item, db):
if item.status == "shipped":
raise ValidationError("Cannot delete a shipped order")
Example: Validation
from fasthx_admin import CRUDView, ValidationError
class OfferingView(CRUDView):
model = Offering
def on_model_change(self, item, form_data, is_new, db, request=None):
# Validate
if not item.hostname:
raise ValidationError("Hostname is required")
product = db.query(Product).get(item.productid)
if product and product.name == "Fortigate":
license_obj = db.query(VnfLicenses).get(item.vnflicensesid)
if license_obj and "Forti" not in license_obj.license:
raise ValidationError("Please select a Forti license")
# Mutate
if is_new and item.serverid:
server = db.query(Server).get(item.serverid)
item.ipaddress = server.getnextip(serverid=server.id)
Example: Set current user on create
from fasthx_admin import CRUDView, get_current_user
class TicketView(CRUDView):
model = Ticket
def on_model_change(self, item, form_data, is_new, db, request=None):
if is_new:
user = get_current_user(request) or {}
item.created_by = user.get("username", "Unknown")
Example: Sync external system on change
class ServerView(CRUDView):
model = Server
def on_model_change(self, item, form_data, is_new, db, request=None):
if not is_new and item.ipaddress != form_data.get("ipaddress"):
# Update DNS before commit
update_dns_record(item.hostname, form_data.get("ipaddress"))
How it fits in the save flow:
- User submits create or edit form
_apply_form_data()sets values on the model instanceon_model_change()runs — validate and mutate, raiseValidationErrorto abortdb.commit()after_model_change()runs
Delete flow:
on_model_delete()runs — raiseValidationErrorto abortdb.delete()+db.commit()after_model_delete()runs
Audit Logging
fasthx-admin ships with a built-in audit log mechanism that fires on successful create, edit, and delete. It is opt-in per view and routes events to a single user-supplied callable, so you can persist them however you like (database, Python logging, an external sink).
Enabling
Register an audit callable on Admin, then set audit_log = True on each view you want tracked:
from fasthx_admin import Admin, CRUDView
def audit_logger(event: dict) -> None:
# Persist however you like — DB row, log line, message queue, etc.
...
app = FastAPI()
admin = Admin(app, audit_logger=audit_logger)
class CustomerView(CRUDView):
model = Customer
audit_log = True
audit_log_exclude = ["password_hash"] # optional — drop sensitive fields
If audit_logger is not configured on Admin, or audit_log is False on the view, no audit events fire and there is zero overhead.
Event shape
The callable receives a single dict per action:
{
"action": "create" | "update" | "delete",
"model_name": "Customer", # __name__ of the model class
"view_name": "customers", # CRUDView.name (URL slug)
"item_id": 42, # primary key value (pk_field)
"user": {"username": "...", "groups": [...]} | None, # get_current_user(request)
"data": {...}, # see below
"request": <Request>, # current FastAPI request
}
The data field varies by action:
| Action | data contents |
|---|---|
create |
Full snapshot of the new row ({col: value, ...}) |
update |
{"old": {...}, "new": {...}} — only columns whose value changed |
delete |
Snapshot of the row captured before deletion |
Columns listed in audit_log_exclude are stripped from all snapshots.
Hook points
Audit events fire after the successful-commit branch of each handler:
- Create: after
db.commit()andafter_model_change()— item has its new primary key. - Update: snapshots column values before
_apply_form_data(), diffs against post-commit state, emits only changed fields. - Delete: snapshots the row before
db.delete(), emits afterafter_model_delete().
If on_model_change or on_model_delete raises ValidationError, the transaction rolls back and no audit event fires.
Failure isolation
Exceptions raised inside your audit_logger are caught by the package and written to the fasthx_admin.audit logger via logging.exception. A broken audit sink will never break the user flow or roll back the user's save.
Example: writing to a UserLog table
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime
from fasthx_admin import Admin, Base, get_db
class UserLog(Base):
__tablename__ = "user_log"
id = Column(Integer, primary_key=True)
username = Column(String)
category = Column(String)
action = Column(String)
log = Column(String)
date = Column(DateTime, default=datetime.utcnow)
def audit_logger(event: dict) -> None:
user = event.get("user") or {}
db = next(get_db())
try:
db.add(UserLog(
username=user.get("username", "anonymous"),
category=f"admin.{event['model_name']}",
action=event["action"],
log=repr(event["data"]),
))
db.commit()
finally:
db.close()
admin = Admin(app, audit_logger=audit_logger)
Example: routing to Python logging
import json, logging
audit = logging.getLogger("admin.audit")
def audit_logger(event: dict) -> None:
user = (event.get("user") or {}).get("username", "anonymous")
audit.info(
"%s %s[%s] by %s: %s",
event["action"], event["model_name"], event["item_id"], user,
json.dumps(event["data"], default=str),
)
Auditing custom endpoints
The audit_log = True flag only covers the built-in create/edit/delete routes. For custom endpoints registered with @CRUDView.endpoint(...) there are two opt-in paths:
Flag on the decorator — zero-effort auto-logging, fires after a successful return:
class ServerView(CRUDView):
model = Server
audit_log = True
@CRUDView.endpoint("/{name}/{item_id}/reset", methods=["POST"], audit=True)
async def reset(self, request: Request, item_id: int, db: Session = Depends(get_db)):
...
return HTMLResponse("")
Defaults emitted:
action= the function name ("reset") — override withaudit_action="...".item_id= pulled from theitem_idpath param if present.data={"path_params": {...}, "query_params": {...}}(body not captured — see below).- Fires only on successful return; if the endpoint raises, no event is emitted.
Explicit self.audit(...) call — when you need to capture before/after snapshots, body data, or control exactly when the event fires:
@CRUDView.endpoint("/{name}/{item_id}/approve", methods=["POST"])
async def approve(self, request: Request, item_id: int, db: Session = Depends(get_db)):
item = db.query(self.model).get(item_id)
old = self._audit_snapshot(item)
item.status = "approved"
db.commit()
self.audit(
"approve",
item=item,
request=request,
data={"old": old, "new": self._audit_snapshot(item)},
)
return HTMLResponse("")
self.audit(action, *, item=None, request=None, data=None, item_id=None) is a no-op when self.audit_log is False or Admin.audit_logger is unset, so leaving the calls in place is free.
Why not auto-capture request body? Reading request.form() / request.json() in the wrapper consumes the stream and conflicts with endpoint code that reads it itself. Path + query params are always safe to inspect; body is the caller's job via self.audit(...).
When to use audit_log vs. lifecycle hooks
- Use
audit_logwhen you want uniform tracking across many views with a single sink. Opt-in per view via one boolean (covers create/edit/delete); addaudit=Trueon custom endpoints to extend it to them. - Use
after_model_change/after_model_deletewhen the logic is specific to one model (e.g. "only log price changes on Orders") or when you need access to the raw form data. Both mechanisms can be used at the same time.
Progress Bar
fasthx-admin includes a built-in Redis-backed progress bar that uses HTMX auto-polling to show real-time task progress. The progress bar appears inline in the list table, polls every 2 seconds, and stops automatically on completion or error.
How it works
- Set
progress_redis_urlon your view and add"progress": Trueto a row action - The framework auto-registers a
GET /{name}/{item_id}/progresspolling endpoint - Your action endpoint kicks off the work and returns
self.progress_response() - Your background worker updates a Redis key as it progresses
- The polling endpoint reads Redis and renders the progress bar until complete
Redis key convention
{view.name}:{item_id}:progress
| Redis value | Progress | Status |
|---|---|---|
Key doesn't exist (None) |
0% | "Waiting..." |
"Error" |
100% (red) | "Failed" |
"0" through "99" |
That percentage (animated) | "Deploying..." |
"100" or higher |
100% (green) | "Complete" |
Setup
class EdgeView(CRUDView):
model = FortiEdge
progress_redis_url = "redis://localhost:6379/0"
htmx_columns = {
"status": {
"url": "/edges/{id}/status",
"trigger": "every 5s",
"terminal_states": ["online", "failed", "error"],
},
}
column_formatters = {
"status": format_edge_status,
}
row_actions = [
{
"label": "Deploy",
"icon": "rocket",
"hx_post": "/edges/{id}/deploy",
"progress": True, # enables the progress bar
"confirm": "Start deployment?",
},
{
"label": "Reset",
"icon": "arrow-counterclockwise",
"hx_post": "/edges/{id}/reset",
"hx_swap": "none",
"confirm": "Reset status?",
},
]
That's it for configuration. The "progress": True flag tells fasthx-admin to:
- Auto-register
GET /edges/{item_id}/progress(reads from Redis, renders the progress bar) - Use
hx-swap="afterend"andhx-target="closest tr"for this action (inserts the progress bar row below the clicked row)
Action endpoint
Your action endpoint just needs to start the work and return self.progress_response():
@CRUDView.endpoint("/{name}/{item_id}/deploy", methods=["POST"], response_class=HTMLResponse)
async def deploy(self, request: Request, item_id: int, db: Session = Depends(get_db)):
item = db.query(self.model).filter(self.model.id == item_id).first()
if not item:
return toast_response("Not found", type="danger")
item.status = "deploying"
db.commit()
# Kick off background work (Celery, HTTP call, etc.)
deploy_task.delay(item_id)
# Return progress bar + auto-restart any htmx_columns polling
return self.progress_response(request, item_id, item=item)
The item=item parameter is optional but recommended -- when provided, progress_response() automatically generates OOB (Out-of-Band) swaps for any htmx_columns defined on the view. This restarts their polling so status columns update alongside the progress bar, with no manual OOB HTML needed.
What your worker does
Your background process (Celery task, external service, etc.) just updates the Redis key:
import redis
r = redis.Redis.from_url("redis://localhost:6379/0", decode_responses=True)
# During work:
r.set("edges:42:progress", "25") # 25%
r.set("edges:42:progress", "50") # 50%
r.set("edges:42:progress", "100") # Complete
# On failure:
r.set("edges:42:progress", "Error")
progress_response() reference
self.progress_response(request, item_id, item=None, progress=0, status="Starting...")
| Parameter | Type | Description |
|---|---|---|
request |
Request |
The FastAPI request object |
item_id |
int/str |
The item's primary key |
item |
object |
Optional SQLAlchemy model instance. When provided, OOB swaps are auto-generated for all htmx_columns to restart their polling |
progress |
int |
Initial progress percentage (default: 0) |
status |
str |
Initial status text (default: "Starting...") |
Progress bar states
The progress bar template handles three visual states:
| State | Bar color | Animation | Badge |
|---|---|---|---|
| In progress (0-99%) | Blue | Striped + animated | Status text (e.g. "Deploying...") |
| Complete (100%) | Green | None | "Complete" |
| Error | Red | None | "Failed" |
Authentication
fasthx-admin includes OIDC/Keycloak authentication out of the box.
Development mode (no auth server needed)
AUTH_DISABLED=1 uvicorn app:app --reload
When AUTH_DISABLED=1, all requests get a mock user {"username": "dev", "groups": ["/Edge-Admins"]}.
Production mode (Keycloak)
- Create a
client_secrets.jsonin your project root:
{
"web": {
"token_uri": "https://keycloak.example.com/realms/myrealm/protocol/openid-connect/token",
"userinfo_uri": "https://keycloak.example.com/realms/myrealm/protocol/openid-connect/userinfo",
"client_id": "my-admin-client",
"client_secret": "your-client-secret"
}
}
- Add login/logout routes to your app:
from fasthx_admin import get_current_user, oidc_login, AuthError
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
if get_current_user(request):
return RedirectResponse("/dashboard", status_code=303)
return admin.templates.TemplateResponse("login.html", {
"request": request,
"error": None,
"username": None,
})
@app.post("/login", response_class=HTMLResponse)
async def login_submit(request: Request):
form = await request.form()
username = form.get("username", "").strip()
password = form.get("password", "")
try:
user = oidc_login(username, password)
except AuthError as e:
return admin.templates.TemplateResponse("login.html", {
"request": request,
"error": str(e),
"username": username,
})
request.session["user"] = user
return RedirectResponse("/dashboard", status_code=303)
@app.get("/logout")
async def logout(request: Request):
request.session.clear()
return RedirectResponse("/login", status_code=303)
Auth functions
| Function | Description |
|---|---|
get_current_user(request) |
Returns user dict from session, or mock user if AUTH_DISABLED |
oidc_login(username, password) |
Exchanges credentials via Keycloak, returns {"username": ..., "groups": [...]} |
AuthError |
Exception raised on auth failure (invalid creds, wrong group, network error) |
AUTH_DISABLED |
Boolean, True when AUTH_DISABLED env var is set |
Configuring allowed groups
By default, users must be in one of these Keycloak groups:
from fasthx_admin.auth import ALLOWED_GROUPS
# Modify at startup to match your Keycloak groups
ALLOWED_GROUPS.clear()
ALLOWED_GROUPS.extend(["/my-admin-group", "/superusers"])
AI Chat (Optional)
fasthx-admin ships with an optional AI chat widget that adds a floating assistant to every page. It supports any OpenAI-compatible API (OpenAI, vLLM, Ollama, LiteLLM, etc.), a decorator-based tool registry so the AI can call your Python functions, and a settings UI stored in the database.
Enabling AI Chat
Pass ai_chat=True when creating the Admin instance:
from fasthx_admin import Admin
admin = Admin(app, title="My Admin", ai_chat=True)
This automatically:
- Creates a
fasthx_admin_ai_settingstable in your database - Mounts chat API endpoints under
/ai/ - Adds "AI Settings" and "AI Context & Tools" links in the sidebar under a "Settings" category
- Includes the chat widget on every page (once enabled in settings)
Installing the AI Dependency
The AI chat uses httpx for async HTTP calls to the LLM API. Install it via the ai extra:
pip install fasthx-admin[ai]
If you already have httpx installed (e.g. from the dev extra), no additional install is needed.
Registering Tools
Tools let the AI call your Python functions to answer questions with live data. Use the @tool_registry.tool() decorator:
from fasthx_admin import tool_registry
@tool_registry.tool(description="Get the total number of customers")
def customer_count(db=None):
"""Returns the total number of customers."""
count = db.query(Customer).count()
return f"There are {count} customers."
@tool_registry.tool(description="Look up a customer by name")
def find_customer(name: str, db=None):
"""Find a customer by name (partial match)."""
results = db.query(Customer).filter(Customer.name.ilike(f"%{name}%")).all()
if not results:
return f"No customers found matching '{name}'."
return "\n".join(f"- {c.name} (SID: {c.sid})" for c in results)
@tool_registry.tool(description="Get edge device statistics")
async def edge_stats(db=None):
"""Returns edge device status breakdown."""
total = db.query(FortiEdge).count()
return f"Total edges: {total}"
Key points:
dbparameter -- if your function accepts adbparameter, it automatically receives the current SQLAlchemy session- Async support -- tools can be
async defor regulardef - Type hints -- parameter types (str, int, float, bool) are extracted and sent to the AI in OpenAI function-calling format
- Return a string -- the return value is sent back to the AI as the tool result
- Tools must be enabled in the settings UI before the AI can use them
Configuring via the Settings UI
After enabling ai_chat=True, navigate to Settings > AI Settings in the sidebar. The settings page has four sections:
| Section | Fields | Description |
|---|---|---|
| General | Enable/disable toggle | Master switch for the chat widget |
| Connection | Base URL, API key, model | Your LLM endpoint (e.g. https://api.openai.com, http://localhost:11434) |
| Parameters | Temperature, max tokens, timeout | Generation parameters |
| System Prompt | Large text area | Base instructions for the AI |
The Context & Tools page (linked from the settings page) lets you:
- Add context items -- named text segments injected into the system prompt (e.g. business rules, schema descriptions)
- Toggle context items on/off
- Enable/disable registered tools individually
All settings are stored in the fasthx_admin_ai_settings database table as key-value pairs.
How It Works
User types message in chat widget
→ POST /ai/chat {message: "..."}
→ Load settings from DB (cached 30s)
→ Build system prompt (base + enabled context items)
→ Load session history (in-memory, keyed by cookie)
→ Call LLM provider with messages + enabled tools
→ If AI requests tool calls:
→ Execute tools via registry (with DB session)
→ Send tool results back to AI
→ Get final response
→ Save to session history (max 50 messages)
→ Return {response, tool_calls_made}
- Session history is stored in-memory on the server, keyed by a
fasthx_chat_sidcookie - History persists across page navigations but resets on server restart
- The chat widget renders markdown responses using
marked.js+DOMPurify(loaded from CDN) - Widget size and expanded/minimized state persist in
localStorage
Custom Providers
The built-in OpenAICompatibleProvider works with any API that speaks the OpenAI chat completions format. To integrate a different API, subclass AIProvider:
from fasthx_admin import AIProvider
class MyCustomProvider(AIProvider):
name = "my_provider"
async def chat(self, messages, tools=None, **kwargs):
# Call your LLM API here
# Must return: {"response": str, "tool_calls": list | None}
...
def get_config_fields(self):
# Return list of settings fields for the UI
return [
{"key": "api_key", "label": "API Key", "type": "password", "default": ""},
]
AI Chat API Endpoints
All endpoints are mounted under /ai/:
| Method | Path | Description |
|---|---|---|
POST |
/ai/chat |
Send a message, get AI response (JSON) |
POST |
/ai/clear |
Clear the current session's chat history |
GET |
/ai/history |
Get the current session's message history (JSON) |
GET |
/ai/settings |
AI settings page (HTML) |
POST |
/ai/settings |
Save AI settings |
GET |
/ai/settings/context |
Context & tools settings page (HTML) |
POST |
/ai/settings/context |
Save context items and tool toggles |
The POST /ai/chat endpoint expects JSON {"message": "..."} and returns:
{
"response": "The AI's markdown response",
"tool_calls": [
{"name": "customer_count", "arguments": {}, "result": "There are 4 customers."}
]
}
Custom Pages (Dashboard, Wizard, etc.)
The auto-generated CRUD views handle model pages. For custom pages like dashboards, wizards, or tools, add standard FastAPI routes and use admin.templates for rendering.
Dashboard example
The built-in dashboard.html template is fully data-driven. It renders four optional sections — summary cards, a recent items table, a status breakdown panel, and quick action buttons — all configured entirely from Python. Each section only renders if you pass the corresponding context variable, so you can mix and match.
@app.get("/dashboard", response_class=HTMLResponse)
async def dashboard(request: Request, db: Session = Depends(get_db)):
total = db.query(Device).count()
online = db.query(Device).filter(Device.status == "online").count()
error = db.query(Device).filter(Device.status == "error").count()
return admin.templates.TemplateResponse("dashboard.html", {
"request": request,
"active_page": "dashboard",
"dashboard_cards": [...], # summary cards
"dashboard_table": {...}, # recent items table
"dashboard_stats": {...}, # status breakdown sidebar
"dashboard_actions": [...], # quick action buttons
})
Set active_page to match a sidebar link's name to highlight it.
Summary cards (dashboard_cards)
A list of dicts. Each card is a clickable link with a large value, label, and icon. Cards are rendered in a 4-column grid (col-md-3) and wrap automatically.
| Key | Required | Description |
|---|---|---|
label |
yes | Card title text (e.g. "Total Devices") |
value |
yes | The number or text to display prominently |
icon |
yes | Bootstrap Icons name without the bi- prefix (e.g. "shield", "check-circle") |
link |
yes | URL the card links to when clicked |
color |
no | CSS class for the value text (e.g. "text-success", "text-danger") |
icon_color |
no | CSS class for the icon (e.g. "text-warning"). Defaults to "text-primary" |
bg |
no | CSS class for the icon background (e.g. "bg-success-subtle"). Defaults to "bg-primary-subtle" |
dashboard_cards = [
{
"label": "Total Devices",
"value": total,
"icon": "shield",
"link": "/devices",
},
{
"label": "Online",
"value": online,
"color": "text-success",
"icon": "check-circle",
"icon_color": "text-success",
"bg": "bg-success-subtle",
"link": "/devices?q=online",
},
{
"label": "Errors",
"value": error,
"color": "text-danger",
"icon": "exclamation-triangle",
"icon_color": "text-danger",
"bg": "bg-danger-subtle",
"link": "/devices?q=error",
},
]
Recent items table (dashboard_table)
A dict that defines the table title, columns, and data. No template override needed — columns and rendering are configured from Python.
| Key | Required | Description |
|---|---|---|
title |
no | Table header text. Defaults to "Recent Items" |
link |
no | URL for the "View All" button |
link_text |
no | Button text. Defaults to "View All" |
columns |
yes | List of column definitions (see below) |
rows |
yes | List of dicts or SQLAlchemy model instances to display as rows |
Column definition keys:
| Key | Required | Description |
|---|---|---|
key |
yes | Attribute name or dict key to read from each item |
label |
yes | Column header text |
link |
no | URL template with {id} placeholder — renders the cell as a clickable link (e.g. "/devices/{id}") |
code |
no | If true, renders the value in <code> tags |
status |
no | If true, renders the value as a colored status badge via partials/status_cell.html |
If none of link, code, or status are set, the value renders as plain text.
dashboard_table = {
"title": "Recent Devices",
"link": "/devices",
"columns": [
{"key": "name", "label": "Name", "link": "/devices/{id}"},
{"key": "serial", "label": "Serial", "code": True},
{"key": "region", "label": "Region"},
{"key": "status", "label": "Status", "status": True},
],
"rows": db.query(Device).order_by(Device.id.desc()).limit(10).all(),
}
Items can be dicts or SQLAlchemy model objects — the template handles both. When using model objects, make sure the key values match the model's attribute names. For computed or relationship values, pass dicts instead:
dashboard_table = {
"title": "Recent Orders",
"link": "/orders",
"columns": [
{"key": "id", "label": "Order #", "link": "/orders/{id}"},
{"key": "customer_name", "label": "Customer"},
{"key": "total", "label": "Total"},
{"key": "status", "label": "Status", "status": True},
],
"rows": [
{
"id": o.id,
"customer_name": o.customer.name,
"total": f"${o.total:.2f}",
"status": o.status.value,
}
for o in db.query(Order).order_by(Order.id.desc()).limit(10).all()
],
}
Status breakdown and counters (dashboard_stats)
A dict that populates the sidebar panel with status badges and summary counters.
| Key | Required | Description |
|---|---|---|
title |
no | Panel header text. Defaults to "Status Breakdown" |
status_breakdown |
no | Dict of {status_name: count} — each entry renders as a status badge with a count |
counters_title |
no | Heading above the counters section |
counters |
no | List of {"label": "...", "value": ...} dicts shown below the breakdown |
dashboard_stats = {
"title": "Status Breakdown",
"status_breakdown": {
"online": 12,
"deploying": 3,
"error": 1,
},
"counters_title": "Summary",
"counters": [
{"label": "Total Customers", "value": db.query(Customer).count()},
{"label": "Total Regions", "value": db.query(Region).count()},
],
}
The status_breakdown keys are rendered using partials/status_cell.html, which maps known status names to colored badges (online = green, deploying = yellow, error = red, etc.). Unknown status names render as a grey badge with the name as-is.
Quick actions (dashboard_actions)
A list of dicts. Each entry renders as a button in the sidebar.
| Key | Required | Description |
|---|---|---|
label |
yes | Button text |
url |
yes | URL the button links to |
icon |
no | Bootstrap Icons name without the bi- prefix |
class |
no | CSS class for the button. Defaults to "btn-outline-secondary" |
dashboard_actions = [
{"label": "Deploy Wizard", "url": "/wizard", "icon": "magic", "class": "btn-primary"},
{"label": "Add Device", "url": "/devices/create", "icon": "plus-lg"},
{"label": "Add Customer", "url": "/customers/create", "icon": "plus-lg"},
]
Full example
Putting it all together:
@app.get("/dashboard", response_class=HTMLResponse)
async def dashboard(request: Request, db: Session = Depends(get_db)):
total = db.query(Customer).count()
active = db.query(Customer).filter(Customer.status == "active").count()
return admin.templates.TemplateResponse("dashboard.html", {
"request": request,
"active_page": "dashboard",
"dashboard_cards": [
{"label": "Total", "value": total, "icon": "people", "link": "/customers"},
{"label": "Active", "value": active, "icon": "check-circle",
"color": "text-success", "icon_color": "text-success",
"bg": "bg-success-subtle", "link": "/customers?q=active"},
],
"dashboard_table": {
"title": "Recent Customers",
"link": "/customers",
"columns": [
{"key": "name", "label": "Name", "link": "/customers/{id}"},
{"key": "email", "label": "Email"},
{"key": "status", "label": "Status", "status": True},
],
"rows": db.query(Customer).order_by(Customer.id.desc()).limit(10).all(),
},
"dashboard_stats": {
"status_breakdown": {"active": active, "inactive": total - active},
"counters": [{"label": "Total Customers", "value": total}],
},
"dashboard_actions": [
{"label": "Add Customer", "url": "/customers/create", "icon": "plus-lg", "class": "btn-primary"},
],
})
Root redirect
@app.get("/")
async def root():
return RedirectResponse("/dashboard")
Custom Navigation Links
Use admin.add_link() to add non-CRUD links to the sidebar. These appear alongside your CRUDView entries, grouped by category.
admin.add_link("reports", "/reports", "Reports", icon="graph-up", category="Analytics")
admin.add_link("docs", "/docs", "API Docs", icon="book", category="Tools")
| Parameter | Type | Default | Description |
|---|---|---|---|
name |
str |
required | Unique identifier for the link |
url |
str |
required | URL the link points to |
display_name |
str |
required | Text shown in the sidebar |
icon |
str |
"link" |
Bootstrap Icons name |
category |
str |
"Other" |
Sidebar category group |
allowed_users |
list[str] |
None |
Usernames that can see this link. None = visible to all. |
allowed_groups |
list[str] |
None |
Group DNs that can see this link. None = visible to all. |
Sidebar categories are collapsible -- click a category header to collapse/expand its items. The collapse state is persisted in localStorage. Categories containing the currently active page auto-expand.
Templates
fasthx-admin ships with these built-in templates:
| Template | Purpose |
|---|---|
base.html |
Main layout -- sidebar, topbar, theme toggle, content area |
login.html |
Standalone login page with Keycloak SSO branding |
dashboard.html |
Summary cards, recent items table, status breakdown, quick actions |
list.html |
CRUD list view with search, sortable columns, pagination, row actions |
detail.html |
Read-only detail view showing all fields |
form.html |
Create/edit form with optional accordion sections |
wizard.html |
Multi-step wizard container |
Partials (HTMX targets and includes)
| Partial | Purpose |
|---|---|
partials/table_body.html |
Table rows (HTMX target for live search) |
partials/row_actions.html |
View/Edit/Delete + custom action buttons |
partials/status_cell.html |
Status badge renderer (online/offline/deploying/error/etc.) |
partials/_form_field.html |
Single form field renderer (text/select/checkbox/textarea) |
partials/dropdown_options.html |
<option> tags for single-target dependent dropdown responses |
partials/dropdown_options_multi.html |
<option> tags with OOB swaps for multi-target dependent dropdowns |
partials/progress_bar.html |
Animated deployment progress bar with auto-polling |
partials/_wizard_indicators.html |
Wizard step progress indicators |
partials/wizard_step.html |
Wizard step content (all 4 steps) |
Using custom templates
Use extra_templates_dirs to add directories that are searched before the built-in ones. Templates in your directories override built-in templates with the same name:
admin = Admin(app, extra_templates_dirs=["my_templates"])
Alternatively, pass your own Jinja2Templates instance for full control:
from fastapi.templating import Jinja2Templates
templates = Jinja2Templates(directory="my_templates")
admin = Admin(app, templates=templates, mount_statics=True)
Template context variables
Every template rendered through admin.templates.TemplateResponse() automatically receives:
| Variable | Description |
|---|---|
current_user |
Dict with username and groups, or None |
nav_categories |
Sidebar navigation structure |
active_page |
Which sidebar item to highlight |
static_url |
URL prefix for static assets |
admin_title |
The configured admin title |
ai_chat_enabled |
Whether the AI chat widget is active |
Theming
The built-in CSS supports dark and light themes via Bootstrap's data-bs-theme attribute. Dark is the default.
Color palette
| Variable | Dark | Light |
|---|---|---|
--accent |
#10b981 (emerald green) |
same |
--bg-base |
#1f1f1f |
#f3f4f6 |
--bg-surface |
#303030 |
#ffffff |
--text |
#ffffff |
#1f1f1f |
--danger |
#ef4444 |
same |
--warning |
#f59e0b |
same |
--info |
#3b82f6 |
same |
Theme is toggled via the sun/moon button in the topbar and persisted in localStorage.
Icons
fasthx-admin uses Bootstrap Icons 1.11.3 loaded via CDN. Over 2,000 icons are available.
Setting icons on models
Use the __admin_icon__ attribute on your SQLAlchemy model:
class Device(Base):
__tablename__ = "devices"
__admin_icon__ = "router" # Any Bootstrap Icons name
Setting icons on views
Override the icon at the view level with the icon class attribute:
class DeviceView(CRUDView):
model = Device
icon = "hdd-network" # Overrides model's __admin_icon__
The default icon is "table" if neither the model nor view specifies one.
Finding icon names
Browse the full icon set at icons.getbootstrap.com. Use the name shown on each icon's page (e.g., "people", "gear", "cart", "shield-lock").
Auto-Generated Routes
For each registered CRUDView, these routes are created automatically:
| Method | URL | Description |
|---|---|---|
GET |
/{name} |
List view with search, sort, pagination |
GET |
/{name}/create |
Create form |
POST |
/{name}/create |
Submit new record |
GET |
/{name}/{id} |
Detail view |
GET |
/{name}/{id}/edit |
Edit form |
POST |
/{name}/{id}/edit |
Submit edit |
POST |
/{name}/{id}/delete |
Delete record |
Plus for each htmx_columns entry:
| Method | URL | Description |
|---|---|---|
GET |
/{name}/{id}/{field} |
Returns current field value (for polling) |
Example: A view with name = "devices" generates:
GET /devices-- list all devicesGET /devices/create-- show create formPOST /devices/create-- create a deviceGET /devices/42-- show device #42GET /devices/42/edit-- edit form for device #42POST /devices/42/edit-- save editsPOST /devices/42/delete-- delete device #42
Environment Variables
| Variable | Purpose | Default |
|---|---|---|
AUTH_DISABLED |
Set to 1, true, or yes to bypass authentication |
auth enabled |
SESSION_SECRET |
Secret key for session cookie signing | set in your app |
OIDC_SECRETS |
Path to Keycloak client_secrets.json |
./client_secrets.json |
Flask-Admin Migration Guide
fasthx-admin is designed as a drop-in conceptual replacement for Flask-Admin. Here's how the concepts map:
| Flask-Admin | fasthx-admin | Notes |
|---|---|---|
ModelView |
CRUDView subclass |
Same pattern: subclass + class attributes |
admin.add_view(MyView(Model, db.session)) |
admin.add_view(MyView) |
No session arg needed; uses get_db dependency |
column_formatters |
column_formatters |
Same API: {col: fn(value, obj) -> html} |
column_list |
column_list |
Identical |
column_labels |
column_labels |
Identical |
column_searchable_list |
column_searchable |
Renamed |
column_sortable_list |
column_sortable |
Renamed |
column_exclude_list |
column_exclude |
Renamed |
form_columns |
form_columns |
Identical |
form_create_rules + FieldSet() |
form_sections |
Dict instead of list of rules |
form_args |
form_widget_overrides |
Renamed, supports HTMX attrs |
form_ajax_refs |
form_ajax_refs |
Same concept; uses HTMX instead of Select2 |
column_extra_row_actions |
row_actions |
List of dicts with HTMX attrs |
on_model_change(form, model, is_created) |
on_model_change(item, form_data, is_new, db, request) |
Handles both validation and mutation; uses form_data dict instead of WTForms |
after_model_change(form, model, is_created) |
after_model_change(item, form_data, is_new, db, request) |
Same concept; includes request for user context |
on_model_delete(model) |
on_model_delete(item, db) |
Same concept; db session passed explicitly |
after_model_delete(model) |
after_model_delete(item, db) |
Same concept |
column_filters |
column_filters |
List of column names for filter dropdowns |
column_export_list |
export_types |
List of format strings (["csv", "xlsx"]) |
@expose() custom endpoints |
setup_endpoints() override |
Define on self.router |
Markup() in formatters |
Raw HTML strings | Templates use | safe filter |
Running the Demo
The package includes a full demo application in examples/demo/:
git clone https://github.com/talbiston/fasthx-admin.git
cd fasthx-admin
pip install -e .[dev] # install from project root
cd examples/demo
AUTH_DISABLED=1 uvicorn app:app --reload
The demo includes:
- 3 CRUD views -- Customers, Orchestrators, FortiEdges
- Dashboard -- summary cards, recent items, status breakdown
- Deploy Wizard -- 4-step wizard with dependent dropdowns and live progress
- Custom formatters -- status badges, links, monospace serial numbers
- Row actions -- Build, Deploy, Reset with HTMX
- HTMX polling -- live status updates on build_status and edge status columns
- 25 seed records -- auto-generated on first startup
Tech Stack
| Layer | Technology |
|---|---|
| Backend | FastAPI |
| ORM | SQLAlchemy |
| Templates | Jinja2 |
| Frontend | HTMX 2.0 (CDN) |
| CSS | Bootstrap 5.3 (CDN) |
| Icons | Bootstrap Icons (CDN) |
| Auth | OIDC / Keycloak (via requests) |
| AI Chat | httpx (optional [ai] extra) + marked.js / DOMPurify (CDN) |
| Server | Uvicorn (dev dependency) |
| Selects | Tom Select 2.4 (CDN) -- searchable dropdowns |
| JavaScript | Minimal -- theme toggle + HTMX event hooks + Tom Select init + AI chat widget |
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file fasthx_admin-0.5.29.tar.gz.
File metadata
- Download URL: fasthx_admin-0.5.29.tar.gz
- Upload date:
- Size: 673.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
51bfdb424f28bca37a68171d63a2da41956ebe02615f8bedfd55c5d0497cc009
|
|
| MD5 |
205c73416bdb28b6485402a13cfe2f8f
|
|
| BLAKE2b-256 |
a02d01d793c32cabe36b1453007655f444ff73ab5157f6b8145f6f95009574ae
|
File details
Details for the file fasthx_admin-0.5.29-py3-none-any.whl.
File metadata
- Download URL: fasthx_admin-0.5.29-py3-none-any.whl
- Upload date:
- Size: 103.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6ebf2f4e05409ca481ee6f43d1d4765de3fa82bd0edbbaa7173a1c4fce426f6d
|
|
| MD5 |
61e1bc95fda936d7661a19106b96e402
|
|
| BLAKE2b-256 |
a756639128adafbb40c4fd87977e13a064501677739b7a98ab1881c4c0eb4994
|