Skip to content

Reference

The classes you use most, with their signatures and docstrings.

The admin

adminsite.Admin

Admin(
    source: Database | SessionSource,
    *,
    title: str = "Admin",
    banner: str = "",
    views: Sequence[
        ModelView[Any] | type[ModelView[Any]]
    ] = (),
    inspector: SQLAlchemyInspector | None = None,
    field_registry: FieldRegistry | None = None,
    template_dirs: Sequence[str | Path] = (),
    auth: AuthProvider | None = None,
    secret_key: str = "",
    audit: AuditStore | bool = False,
    saved_views: SavedViews | bool = False,
    session_cookie: str | None = None,
    pages: Sequence[AdminPage | type[AdminPage]] = (),
    plugins: Sequence[Plugin] = (),
    dashboard: Sequence[Widget] | None = None,
    api: bool = False,
    session_https_only: bool = False,
    session_max_age: int | None = 14 * 24 * 3600,
    language: str = "en",
    languages: Sequence[str] = (),
    translations: Translations | None = None,
    timezone: str = "UTC",
    timezones: Sequence[str] = (),
    database_timezone: str = "UTC",
)

The admin itself: a small ASGI app you mount into your own.

admin = Admin(engine, title="Acme")
admin.add_view(OrderView)
app.mount("/admin", admin)

Parameters:

Name Type Description Default
source Database | SessionSource

Where the records live: an engine, an async engine, or a session factory for either. Sync and async behave the same.

required
title str

The name in the sidebar, the browser tab and the sign in page. It names the session cookie too.

'Admin'
banner str

One line above every page and the sign in page, such as "Staging: changes here are not real". Escaped, unless given as Html.

''
views Sequence[ModelView[Any] | type[ModelView[Any]]]

The views to show, each a class such as OrderView, an alias such as ModelView[Tag], or a view already built.

()
inspector SQLAlchemyInspector | None

Reads the models' columns and relationships. Rarely needed.

None
field_registry FieldRegistry | None

Picks the field for each type of column. Left out, adminsite.fields.default_registry, where a project registers fields of its own.

None
template_dirs Sequence[str | Path]

Folders searched for templates before the built in ones, to override one of the admin's or to add your own.

()
auth AuthProvider | None

Decides who may sign in. Left out, the admin is open to anyone who can reach it.

None
secret_key str

Signs the session cookie. Required with auth; keep it out of your repository.

''
audit AuditStore | bool

True to write down who changed what in a SQLite file of its own, or an AuditStore, such as AuditLog(engine), to keep it elsewhere.

False
saved_views SavedViews | bool

True to let people save lists they come back to, in a SQLite file of their own, or a SavedViews that keeps them elsewhere.

False
session_cookie str | None

The session cookie's name. Left out, one made from the title, so two admins in one app keep separate sessions.

None
pages Sequence[AdminPage | type[AdminPage]]

Pages of your own, each an AdminPage class or instance.

()
plugins Sequence[Plugin]

Plugins, each set up as the admin is built.

()
dashboard Sequence[Widget] | None

The cards on the overview page. Left out, how many records each view holds; an empty list, no cards.

None
api bool

True to answer JSON under /-/api, through the same views and permissions as the pages.

False
session_https_only bool

True to send the session cookie over HTTPS only. Switch it on wherever the admin is served over HTTPS.

False
session_max_age int | None

How long the session cookie lasts, in seconds; two weeks unless you say. None keeps it for the browser session.

14 * 24 * 3600
language str

The language the admin speaks, such as "fa", and the default for anyone whose browser asks for none of languages.

'en'
languages Sequence[str]

More languages people may switch to, in the account menu at the foot of the sidebar. The first visit follows the browser.

()
translations Translations | None

Your own wording, by language and then by the English text, such as {"en": {"Username": "Email"}}. It wins over the built in translations, and adds a language adminsite does not ship.

None
timezone str

The time zone times are shown in, such as "Asia/Tehran", until a person's browser says its own, and for an API token.

'UTC'
timezones Sequence[str]

More time zones people may switch to, in the account menu at the foot of the sidebar. Until they do, times follow the browser.

()
database_timezone str

The time zone the database keeps times in, for a column that keeps none of its own as DateTime(timezone=True) does.

'UTC'

Raises:

Type Description
AdminSiteError

When auth is given without a secret_key, or a time zone is not one the time zone database knows.

add_view

add_view(
    view: ModelView[Any] | type[ModelView[Any]],
) -> ModelView[Any]

Register a view, given as its class, ModelView[Tag] or an instance.

Parameters:

Name Type Description Default
view ModelView[Any] | type[ModelView[Any]]

The view, as views takes it.

required

Returns:

Type Description
ModelView[Any]

The view as the admin built it.

add_page

add_page(page: AdminPage | type[AdminPage]) -> AdminPage

Add a page of your own, served at /-/ and its name.

Parameters:

Name Type Description Default
page AdminPage | type[AdminPage]

The page, as an AdminPage class or instance.

required

Returns:

Type Description
AdminPage

The page as the admin built it.

Raises:

Type Description
AdminSiteError

When a page of that name is already there, or the admin keeps the name for itself.

use

use(plugin: Plugin) -> Plugin

Let a plugin add what it brings, by calling its setup.

Parameters:

Name Type Description Default
plugin Plugin

The plugin.

required

Returns:

Type Description
Plugin

The same plugin.

add_route

add_route(
    path: str,
    endpoint: Endpoint,
    *,
    methods: Sequence[str] = ("GET",),
    name: str | None = None,
    guarded: bool = True,
) -> None

Answer one more path, called as endpoint(admin, request).

The path has to start with /-/, which no model name can take, and the route sits behind the admin's sign in unless guarded is off.

Parameters:

Name Type Description Default
path str

The path under the admin, such as "/-/reports/export".

required
endpoint Endpoint

An async function given the admin and the request, which answers with a response.

required
methods Sequence[str]

The HTTP methods it answers.

('GET',)
name str | None

The route's name, for building its URL.

None
guarded bool

False to answer people who have not signed in.

True

Raises:

Type Description
AdminSiteError

When the path is not under /-/, or the admin has already answered its first request.

add_static

add_static(name: str, directory: str | Path) -> None

Serve a folder of files at /-/static/ and the name.

Parameters:

Name Type Description Default
name str

The folder's name in the URL.

required
directory str | Path

The folder on disk.

required

Raises:

Type Description
AdminSiteError

When the admin has already answered its first request.

add_template_dir

add_template_dir(directory: str | Path) -> None

Look for templates in one more folder, before the built in ones.

Parameters:

Name Type Description Default
directory str | Path

The folder on disk.

required

add_stylesheet

add_stylesheet(href: str) -> None

Load a stylesheet on every page.

Parameters:

Name Type Description Default
href str

The stylesheet's address. A relative one starts at the admin, such as "-/static/reports/reports.css".

required

add_script

add_script(src: str) -> None

Load a script on every page.

Parameters:

Name Type Description Default
src str

The script's address. A relative one starts at the admin.

required

render_template async

render_template(
    name: str,
    request: Request,
    context: dict[str, Any] | None = None,
    status_code: int = 200,
) -> Response

Render a template of your own, found in the admin's template dirs.

The template gets what every page of the admin has, such as the sidebar, as well as context.

Parameters:

Name Type Description Default
name str

The template's path inside one of the template folders.

required
request Request

The request being answered.

required
context dict[str, Any] | None

More values for the template.

None
status_code int

The response's status.

200

Returns:

Type Description
Response

The page, as an HTML response.

adminsite.database.Database

Database(source: SessionSource)

Hands out sessions, hiding whether the engine is async or not.

Parameters:

Name Type Description Default
source SessionSource

An engine, an async engine, or a session factory for either, as Admin takes it.

required

session async

session() -> AsyncIterator[SessionAdapter]

Open a session and close it when the block ends.

Returns:

Type Description
AsyncIterator[SessionAdapter]

A context manager that hands over the session: async with database.session() as session:.

adminsite.database.SessionAdapter

SessionAdapter()

Bases: ABC

One way to talk to the database, whether the session is async or not.

execute abstractmethod async

execute(statement: Executable) -> Result

Run a statement and return its result.

Parameters:

Name Type Description Default
statement Executable

A select, an insert, an update or a delete.

required

Returns:

Type Description
Result

SQLAlchemy's result, rows read as they come.

scalar abstractmethod async

scalar(statement: Executable) -> Any

Run a statement and return a single value.

Parameters:

Name Type Description Default
statement Executable

A select of one value, such as a count.

required

Returns:

Type Description
Any

The first column of the first row, or None without one.

scalars abstractmethod async

scalars(statement: Executable) -> ScalarResult[Any]

Run a statement and return the first column of every row.

Parameters:

Name Type Description Default
statement Executable

A select, usually of whole records.

required

Returns:

Type Description
ScalarResult[Any]

The values, such as the records a select(Order) finds.

get abstractmethod async

get(model: type[T], key: Any) -> T | None

Load one record by primary key, or nothing if it is missing.

Parameters:

Name Type Description Default
model type[T]

The model to load.

required
key Any

The primary key, a tuple for a composite one.

required

Returns:

Type Description
T | None

The record, or None.

add abstractmethod async

add(record: Any) -> None

Put a new record into the session.

Parameters:

Name Type Description Default
record Any

The record, inserted at the next flush or commit.

required

delete abstractmethod async

delete(record: Any) -> None

Mark a record for deletion.

Parameters:

Name Type Description Default
record Any

The record, deleted at the next flush or commit.

required

flush abstractmethod async

flush() -> None

Send pending changes to the database without committing.

commit async

commit() -> None

Commit the open transaction, then run the work waiting on it.

If the work before it or the commit itself fails, everything is rolled back, so what waits on a rollback runs. A change the database refuses raises adminsite's IntegrityError.

run abstractmethod async

run(work: Callable[[Session], T]) -> T

Run a function against the underlying sync session.

For SQLAlchemy calls the adapter has no method for: through run_sync with an async engine, and on the session's own thread with a sync one.

Parameters:

Name Type Description Default
work Callable[[Session], T]

A function given SQLAlchemy's sync Session.

required

Returns:

Type Description
T

What the function returns.

adminsite.inspector.SQLAlchemyInspector

SQLAlchemyInspector()

Describes SQLAlchemy models: their columns, relationships and keys.

Views

adminsite.ModelView

ModelView(
    inspector: SQLAlchemyInspector | None = None,
    registry: FieldRegistry | None = None,
)

Bases: Generic[M]

How one model appears in the admin: class OrderView(ModelView[Order]).

class OrderView(ModelView[Order]):
    fields = [Order.id, Order.customer, Order.status, Order.total]
    searchable_fields = [Order.id, Link(Order.customer, Customer.name)]
    fields_default_sort = [Descending(Order.created_at)]

fields lists every field once, in order, for every page. The other class attributes describe the list and the forms, and the get_ methods and can_access_field answer per request, for when the answer depends on who is asking. A view with no settings needs no class: register ModelView[Tag].

Parameters:

Name Type Description Default
inspector SQLAlchemyInspector | None

Reads the model's columns and relationships. The admin passes its own when it builds the view from its class.

None
registry FieldRegistry | None

Picks the field for each type of column. The admin passes its field_registry when it builds the view from its class.

None

model instance-attribute

model: type[M]

The model the view shows, taken from ModelView[Order].

name class-attribute instance-attribute

name: str = ''

The view's name in its URLs, such as orders in /admin/orders.

Left empty, the model's name, plural and in snake case: order_items. Two views of one model each need a name of their own.

label class-attribute instance-attribute

label: str = ''

What one record is called in headings and buttons, such as "Order".

Left empty, the model's name in words: OrderItem reads "Order item".

label_plural class-attribute instance-attribute

label_plural: str = ''

What the records are called in the sidebar and the list's heading.

Left empty, the model's name in words, made plural: "Order items".

group class-attribute instance-attribute

group: str = ''

The sidebar section the view sits under, such as "Sales".

Views with no group sit together, under no heading.

icon class-attribute instance-attribute

icon: str = ''

The view's icon in the sidebar: inline SVG markup, or a picture's address.

Markup goes into the page as it is, so keep it to icons you control. A relative address starts at the admin, so a plugin's add_static folder works.

record_title class-attribute instance-attribute

record_title: str = ''

How a record is named in headings, links to it and the history.

A template of the model's columns, such as "Order #{id}" or "{name} ({email})", each name checked when the admin starts. Left empty, the model's own __str__, or else the view's label and the record's key, "Order #12". Links from other models name the record the same way. get_record_title names one no template can.

fields class-attribute instance-attribute

fields: Sequence[
    ColumnReference | Field[Any] | ComputedField[M, Any]
] = ()

Every field of the view, once, in the order each page shows them.

A column is named by its attribute, Order.total, by a Link for a column of a related model, or by its name as a string, "total" or "customer.email". A field, such as Field(Order.total, read_only=True), gives the column options. Left empty, the view shows every column of the model, a foreign key as its relationship.

exclude_fields_from_list class-attribute instance-attribute

exclude_fields_from_list: Sequence[ColumnReference] = ()

Fields left off the list, as exclude_from_list on a field does.

exclude_fields_from_detail class-attribute instance-attribute

exclude_fields_from_detail: Sequence[ColumnReference] = ()

Fields left off the record page, as exclude_from_detail on a field does.

exclude_fields_from_create class-attribute instance-attribute

exclude_fields_from_create: Sequence[ColumnReference] = ()

Fields left off the form for a new record, as exclude_from_create does.

exclude_fields_from_edit class-attribute instance-attribute

exclude_fields_from_edit: Sequence[ColumnReference] = ()

Fields left off the edit form, as exclude_from_edit on a field does.

exclude_fields_from_export class-attribute instance-attribute

exclude_fields_from_export: Sequence[ColumnReference] = ()

Fields left out of the CSV export, as exclude_from_export on a field does.

searchable_fields class-attribute instance-attribute

searchable_fields: Sequence[ColumnReference] = ()

The columns the search box looks in.

Text matches anywhere in the value, a number only exactly. Left empty, the list has no search box, and the command palette skips the view.

sortable_fields class-attribute instance-attribute

sortable_fields: Sequence[ColumnReference] | None = None

The columns people may sort the list by.

Left out, every column the list shows, but no relationship and no column reached through one holding many records. An empty list, none.

fields_default_sort class-attribute instance-attribute

fields_default_sort: Sequence[
    ColumnReference | Descending
] = ()

The order the list starts in.

Descending(Order.created_at), or "-created_at", sorts newest first. Left empty, the list goes by the record's key.

list_filters class-attribute instance-attribute

list_filters: Sequence[ColumnReference | SQLFilter[M]] = ()

The filters beside the list.

A column gets the filter that suits its type; a SQLFilter of your own decides for itself.

inline_editable_fields class-attribute instance-attribute

inline_editable_fields: Sequence[ColumnReference] = ()

The fields whose values can be changed straight from the list.

A cell of each opens a small editor under it, which saves that one value as the edit form does: allows, can_access_field, get_readonly_fields, the save hooks and the audit log all apply, and a cell this user may not change stays plain text. The list's first column opens the record, so it cannot be one of them.

page_size class-attribute instance-attribute

page_size: int = 25

How many rows a page of the list holds.

page_size_options class-attribute instance-attribute

page_size_options: Sequence[int] = ()

The page sizes people may switch between, such as [25, 100, 500].

Left empty, the size stays at page_size.

count_mode class-attribute instance-attribute

count_mode: CountMode = CountMode.EXACT

How hard the list works to say how many records match.

The rows never wait for it: the total follows them in a request of its own. CountMode.ESTIMATED suits a table of millions of rows on Postgres or MySQL, and CountMode.NONE skips the count. Either also leaves the counts off the filters' options, unless a filter is given show_counts=True.

global_search: bool = True

Whether the command palette looks through this view's records.

pagination class-attribute instance-attribute

pagination: Pagination = Pagination.OFFSET

How the list moves between pages: page numbers, or a keyset for big tables.

list_refresh_seconds class-attribute instance-attribute

list_refresh_seconds: int | None = None

How often an open list reads its rows again, in seconds, such as 30.

New and changed records then show up without a reload, under the search, the filters, the sort and the page in use. The list waits while someone is at work on it, with a row ticked, a menu, a dialog or a value's editor open, the focus in the table or its text selected, and while its tab is hidden, which asks the database nothing; shown again, it reads them at once. Left None, a list changes only when someone asks it to.

deferred_fields class-attribute instance-attribute

deferred_fields: Sequence[ColumnReference] = ()

Columns the list leaves out of its query, such as a large JSON payload.

The record page, the form and the API load them as usual. A column the list shows is loaded whatever this says, as is anything record_title reads.

inlines class-attribute instance-attribute

inlines: Sequence[Inline] = ()

Child records edited inside this model's form, such as order lines.

form_layout class-attribute instance-attribute

form_layout: Sequence[LayoutEntry] = ()

How the forms and the record page arrange the fields.

Panels, fieldsets, rows and tabs, built from PanelWidget, FieldsetWidget, RowWidget and TabsWidget; a tuple is a row and a list a column. Left empty, one field under another, in the order of fields. A field the layout leaves out comes last.

can_create class-attribute instance-attribute

can_create: bool = True

Whether records can be added. allows decides per user.

can_view_detail class-attribute instance-attribute

can_view_detail: bool = True

Whether records have a page of their own. Without it, rows open the form.

can_export class-attribute instance-attribute

can_export: bool = True

Whether the list can be downloaded as CSV.

can_edit class-attribute instance-attribute

can_edit: bool = True

Whether records can be changed.

can_delete class-attribute instance-attribute

can_delete: bool = True

Whether records can be deleted.

can_delete_selected class-attribute instance-attribute

can_delete_selected: bool = True

Whether the ticked rows can be deleted together.

The Delete action then sits in the bar that rises when rows are ticked. can_delete has to allow it too.

can_import class-attribute instance-attribute

can_import: bool = False

Whether records can be imported from a CSV or Excel file.

Off until you switch it on, since it writes many records at once.

import_limit class-attribute instance-attribute

import_limit: int = 10000

The most rows one imported file may hold.

in_sidebar class-attribute instance-attribute

in_sidebar: bool = True

Whether the view is listed in the sidebar.

False leaves it out of the sidebar, the command palette's pages and the overview's counts, for a view whose records are only opened from other records. Its pages, links and pickers stay as they are.

can_access_field

can_access_field(
    request: Request,
    field: BaseField,
    action: RequestAction,
) -> bool

Whether this user sees the field on this page.

Every field is shown to everyone by default. Answer False to keep one from someone, such as a cost price from staff who are not managers: it leaves the page, the export and the API, and no form reads it back.

Parameters:

Name Type Description Default
request Request

The request, with the signed in user on request.state.user.

required
field BaseField

The field, whose name is the path it shows, such as "total" or "customer.email".

required
action RequestAction

The page asking, such as RequestAction.LIST.

required

Returns:

Type Description
bool

True to show the field, False to keep it from this user.

get_searchable_fields

get_searchable_fields(
    request: Request,
) -> Sequence[ColumnReference]

The columns the search box looks in, for this user.

Parameters:

Name Type Description Default
request Request

The request, with the signed in user on request.state.user.

required

Returns:

Type Description
Sequence[ColumnReference]

Columns, as searchable_fields names them; that setting by default.

search_condition

search_condition(
    term: str, *, request: Request
) -> ColumnElement[bool] | None

The condition the search box matches with, or None for the usual one.

The usual one looks for the term inside every search field, which a large table cannot answer from an index. Return a condition of your own, such as an exact match on a normalised phone number, and the list, its count, the export, "select all matching", the command palette and pickers all use it.

Parameters:

Name Type Description Default
term str

What was typed into the search box.

required
request Request

The request, with the signed in user on request.state.user.

required

Returns:

Type Description
ColumnElement[bool] | None

A condition on the view's model, or None for the usual search.

get_list_filters

get_list_filters(
    request: Request,
) -> Sequence[ColumnReference | SQLFilter[M]]

The filters offered beside the list, for this user.

Parameters:

Name Type Description Default
request Request

The request, with the signed in user on request.state.user.

required

Returns:

Type Description
Sequence[ColumnReference | SQLFilter[M]]

Columns and filters, as list_filters takes them; that setting by default.

get_fields_default_sort

get_fields_default_sort(
    request: Request,
) -> Sequence[ColumnReference | Descending]

The order the list starts in, for this user.

Parameters:

Name Type Description Default
request Request

The request, with the signed in user on request.state.user.

required

Returns:

Type Description
Sequence[ColumnReference | Descending]

Columns, any of them Descending, as fields_default_sort takes them; that setting by default.

get_deferred_fields

get_deferred_fields(
    request: Request,
) -> Sequence[ColumnReference]

The columns the list leaves out of its query, for this user.

Parameters:

Name Type Description Default
request Request

The request, with the signed in user on request.state.user.

required

Returns:

Type Description
Sequence[ColumnReference]

Columns, as deferred_fields names them; that setting by default.

get_readonly_fields

get_readonly_fields(
    request: Request, record: M | None
) -> Sequence[ColumnReference]

Fields shown on this record's form but not editable there.

A field with read_only=True is never editable. Answer with more for one record or one user, such as the customer of a shipped order.

Parameters:

Name Type Description Default
request Request

The request, with the signed in user on request.state.user.

required
record M | None

The record on the form, or None on the form for a new record.

required

Returns:

Type Description
Sequence[ColumnReference]

The fields to lock as well, named as in fields. None by default.

get_inlines

get_inlines(
    request: Request, record: M | None
) -> Sequence[Inline]

The child records edited inside the form, for this user and record.

Parameters:

Name Type Description Default
request Request

The request, with the signed in user on request.state.user.

required
record M | None

The record on the form, or None on the form for a new record.

required

Returns:

Type Description
Sequence[Inline]

Inlines from inlines, whose rows are checked when the admin starts; all of them by default.

form_only_values async

form_only_values(
    session: SessionAdapter,
    record: M | None,
    *,
    request: Request,
) -> Mapping[str, Any]

The values form-only fields start from, by name.

Nothing by default, so they start empty. Answer with, say, settings kept as rows of another table, to edit them as one value.

Parameters:

Name Type Description Default
session SessionAdapter

The session the form is read in.

required
record M | None

The record on the form, or None on the form for a new record.

required
request Request

The request, with the signed in user on request.state.user.

required

Returns:

Type Description
Mapping[str, Any]

Each form-only field's starting value, by the field's name.

get_record_title

get_record_title(record: M) -> str

Name a record, for a heading, a link to it and the history.

record_title first, then the model's own __str__. A model with neither is named by the view's label and the record's key, "Order

12", rather than by where it sits in memory.

Parameters:

Name Type Description Default
record M

The record, with its columns loaded but not its links. It is passed by position, so the parameter may be named after the model.

required

Returns:

Type Description
str

The record's name.

get_actions

get_actions(request: Request) -> Sequence[Action]

The actions this view offers this user, in the order they appear.

Delete comes last, where the view allows deleting several at once, unless the view has an action of its own by that name.

Parameters:

Name Type Description Default
request Request

The request, with the signed in user on request.state.user.

required

Returns:

Type Description
Sequence[Action]

The view's actions, each a method marked with @action. The permission each one asks for is checked as well.

allows async

allows(
    action: Permission | str,
    *,
    request: Request,
    record: M | None,
) -> bool

Whether the current user may do this, to this record.

By default it answers from the can_ settings, such as can_edit, and then from the grants the auth provider's grants_for gave the user, if it gave any. Everything else is allowed. An override that calls super().allows(...) keeps both.

Parameters:

Name Type Description Default
action Permission | str

What the user wants to do: a Permission, such as Permission.EDIT, or the permission an action asks for.

required
request Request

The request, with the signed in user on request.state.user.

required
record M | None

The record the question is about, or None when it is about the view as a whole, such as whether its list may be exported.

required

Returns:

Type Description
bool

True when the user may.

scope_query

scope_query(
    statement: Statement, *, request: Request
) -> Statement

Narrow every read to the rows this user may see.

This runs on the list, the count, a single record, an export, a bulk action and the history on the Activity page, so a row can never leak through a path that forgot to check. A record it leaves out reads as Hidden where another view links to it. To keep records off the list but still reachable, such as deleted ones, use list_query.

Hand the statement back unchanged for a user it leaves nothing out for, such as a superuser. The Activity page then shows them the history of deleted records too, which no scope can be checked against.

Parameters:

Name Type Description Default
statement Statement

A select of the view's model, about to run.

required
request Request

The request, with the signed in user on request.state.user.

required

Returns:

Type Description
Statement

The statement, narrowed, such as with a where. Unchanged by default.

list_query

list_query(
    statement: Statement, *, request: Request
) -> Statement

Narrow the list to the rows it shows, such as the ones not deleted.

Unlike scope_query, this leaves every record reachable. It runs on the list, its count and filters, the export, "select all matching", the command palette, the dashboard and the pickers on other forms, but not on a record's own page or where another record links to it. A record it leaves out still opens from a link, and is named there.

scope_query runs first, so this only ever narrows what the user may see.

Parameters:

Name Type Description Default
statement Statement

A select of the view's model, about to run.

required
request Request

The request, with the signed in user on request.state.user.

required

Returns:

Type Description
Statement

The statement, narrowed, such as with a where. Unchanged by default.

before_save async

before_save(context: SaveContext[M]) -> None

Runs before the values are written.

context.values[Order.slug].set(...) stores something other than what was submitted.

Parameters:

Name Type Description Default
context SaveContext[M]

The record, the values about to be written, the session and the request.

required

Raises:

Type Description
RefusedError

To refuse the save, with a message for the user. Name a field to put the message beside its input.

save_record async

save_record(context: SaveContext[M]) -> None

Writes the values onto the record and stores it, on a create or an edit.

Override it to store the record another way, such as through your own code: context.values[Order.total].get() reads each value. Call super().save_record(context) to keep the usual write and add to it. It runs between before_save and after_save, in the same transaction. The rows of the record's inline tables are on context.record already, and are stored with it.

It stores context.record, which the page opens next, so a new record has to end up in context.session.

Parameters:

Name Type Description Default
context SaveContext[M]

The record, the values to write, the session and the request. context.created tells a create from an edit.

required

Raises:

Type Description
RefusedError

To refuse the save, with a message for the user.

after_save async

after_save(context: SaveContext[M]) -> None

Runs after the flush, while the transaction is still open.

The record has its primary key by now, and the values the database set in its own columns.

Parameters:

Name Type Description Default
context SaveContext[M]

The record, the values written, the session and the request.

required

Raises:

Type Description
RefusedError

To roll the save back, with a message for the user.

after_save_committed async

after_save_committed(context: SaveContext[M]) -> None

Runs once the save has committed, such as to send an email.

The change is stored by now, so nothing here can undo it: an error is written to the server's log, and the save still succeeds. The transaction is over, so write through a session of your own.

Parameters:

Name Type Description Default
context SaveContext[M]

The record, the values written and the request.

required

before_delete async

before_delete(context: DeleteContext[M]) -> None

Runs before a record is deleted.

Parameters:

Name Type Description Default
context DeleteContext[M]

The record, the session and the request.

required

Raises:

Type Description
RefusedError

To refuse the delete, with a message for the user.

delete_record async

delete_record(context: DeleteContext[M]) -> None

Removes the record from the database.

Override it to delete another way, such as by keeping the row and filling in its deleted_at. Everything else stays as it is: the Delete buttons, the permission, the hooks and the audit log. It runs between before_delete and after_delete, in the same transaction, for each record a Delete covers: from its page, from the list and through the API.

Parameters:

Name Type Description Default
context DeleteContext[M]

The record, the session and the request.

required

Raises:

Type Description
RefusedError

To refuse the delete, with a message for the user.

after_delete async

after_delete(context: DeleteContext[M]) -> None

Runs after the delete, while the transaction is still open.

Parameters:

Name Type Description Default
context DeleteContext[M]

The record, the session and the request.

required

Raises:

Type Description
RefusedError

To roll the delete back, with a message for the user.

after_delete_committed async

after_delete_committed(context: DeleteContext[M]) -> None

Runs once the delete has committed.

As with after_save_committed, an error here is logged and the delete still stands.

Parameters:

Name Type Description Default
context DeleteContext[M]

The record and the request.

required

adminsite.RequestAction

Bases: StrEnum

The page a field is asked for, as can_access_field is told it.

LIST class-attribute instance-attribute

LIST = 'list'

The list.

DETAIL class-attribute instance-attribute

DETAIL = 'detail'

The record page.

CREATE class-attribute instance-attribute

CREATE = 'create'

The form for a new record.

EDIT class-attribute instance-attribute

EDIT = 'edit'

The form for a record that exists.

EXPORT class-attribute instance-attribute

EXPORT = 'export'

The CSV export.

adminsite.Permission

Bases: StrEnum

The things a user can be allowed to do with a view.

VIEW class-attribute instance-attribute

VIEW = 'view'

Opening the view's list, and seeing it in the sidebar.

CREATE class-attribute instance-attribute

CREATE = 'create'

Adding a record.

EDIT class-attribute instance-attribute

EDIT = 'edit'

Changing a record. The permission an action asks for, unless it says.

DELETE class-attribute instance-attribute

DELETE = 'delete'

Deleting a record, alone or with others.

EXPORT class-attribute instance-attribute

EXPORT = 'export'

Downloading the list as CSV.

VIEW_DETAIL class-attribute instance-attribute

VIEW_DETAIL = 'detail'

Opening one record's page.

A view whose list says everything can switch it off.

IMPORT class-attribute instance-attribute

IMPORT = 'import'

Importing records from a file.

HISTORY class-attribute instance-attribute

HISTORY = 'history'

Reading the audit log.

A record's History tab, and the view's entries on the Activity page.

adminsite.views.ViewRegistry

ViewRegistry()

Holds the views of one admin, in the order they were added.

find

find(name: str) -> ModelView[Any] | None

Find a view by name, or nothing if there is none.

Parameters:

Name Type Description Default
name str

The view's name, as in its URL, such as "orders".

required

Returns:

Type Description
ModelView[Any] | None

The view, or None.

Link(
    relation: QueryableAttribute[Any],
    column: QueryableAttribute[V_co] | Link[V_co],
)

Bases: Generic[V_co]

A column of a related model, such as Link(Order.customer, Customer.email).

A longer way nests links: Link(Order.customer, Link(Customer.address, Address.city)).

Parameters:

Name Type Description Default
relation QueryableAttribute[Any]

The relationship that leads to the model owning the column, such as Order.customer.

required
column QueryableAttribute[V_co] | Link[V_co]

The column on that model, or another Link to go further.

required

adminsite.ColumnReference module-attribute

ColumnReference: TypeAlias = (
    str | QueryableAttribute[Any] | Link[Any]
)

A column named by its attribute, by a Link, or by its name as a string.

Such as Order.total, Link(Order.customer, Customer.email) or "total".

adminsite.Descending dataclass

Descending(column: ColumnReference)

A column sorted from the highest value down, such as newest first.

Parameters:

Name Type Description Default
column ColumnReference

The column, as a setting names it. The string "-created_at" means the same as Descending(Order.created_at).

required

adminsite.Inline dataclass

Inline(
    relation: str | QueryableAttribute[Any],
    fields: Sequence[ColumnReference | Field[Any]] = (),
    label: str = "",
    blank_rows: int = 1,
    can_delete: bool = True,
    record_title: str = "",
)

Child records edited inside their parent's form, such as order lines.

class OrderView(ModelView[Order]):
    inlines = [
        Inline(
            Order.items,
            fields=[OrderItem.product, OrderItem.quantity, OrderItem.unit_price],
        )
    ]

Each child shows as a row of inputs, with a button to remove it, and new rows can be added in the form. Everything is saved in one transaction with the parent.

Parameters:

Name Type Description Default
relation str | QueryableAttribute[Any]

The relationship on the parent that holds the children, by attribute or by name, such as Order.items.

required
fields Sequence[ColumnReference | Field[Any]]

The child's fields, in order, written as a view's are, so Field(OrderItem.added_at, read_only=True) shows a value it never edits. Left empty, every field but those the relationship fills in.

()
label str

The heading above the rows. Left empty, the relationship's name.

''
blank_rows int

How many blank rows the table starts with while it has no rows yet.

1
can_delete bool

Whether rows can be removed, which deletes the child.

True
record_title str

How a child is named, as a view's record_title.

''

adminsite.PanelWidget dataclass

PanelWidget(
    title: str,
    children: Sequence[LayoutEntry] = (),
    description: str = "",
    collapsible: bool = False,
    collapsed: bool = False,
)

Fields under a titled card, on the forms and the record page.

class ProductView(ModelView[Product]):
    form_layout = [
        PanelWidget("Product", [Product.name, Product.description]),
        PanelWidget("Price", [(Product.price, Product.cost)]),
    ]

Parameters:

Name Type Description Default
title str

The card's title. A panel holding one field leaves that field's label to it.

required
children Sequence[LayoutEntry]

The fields and widgets inside, in order: a tuple of them is a row and a list a column, as everywhere in a layout.

()
description str

A line under the title, saying what the fields are for.

''
collapsible bool

Whether the panel can be folded away.

False
collapsed bool

Whether it starts folded. A folded panel opens when it holds a mistake.

False

adminsite.FieldsetWidget dataclass

FieldsetWidget(
    legend: str, children: Sequence[LayoutEntry] = ()
)

Fields in a bordered group with a caption, inside a panel.

Parameters:

Name Type Description Default
legend str

The caption on the border.

required
children Sequence[LayoutEntry]

The fields and widgets inside, as a panel takes them.

()

adminsite.RowWidget dataclass

RowWidget(children: Sequence[LayoutEntry] = ())

Fields side by side on a wide screen, one under the other on a phone.

A tuple of fields is a row too: (Product.price, Product.stock).

Parameters:

Name Type Description Default
children Sequence[LayoutEntry]

The fields and widgets side by side, in order.

()

adminsite.TabsWidget dataclass

TabsWidget(tabs: Sequence[tuple[str, LayoutEntry]] = ())

Fields split into tabs, as (label, fields) pairs, one tab shown at a time.

The form opens on the first tab holding a mistake, and marks each tab that holds one. The record page shows each tab as a panel of its own.

Parameters:

Name Type Description Default
tabs Sequence[tuple[str, LayoutEntry]]

Each tab as a pair of its label and what it holds: a field, a widget, or a list of them.

()

adminsite.CountMode

Bases: StrEnum

How hard to work to report how many records match.

EXACT class-attribute instance-attribute

EXACT = 'exact'

Count every match.

ESTIMATED class-attribute instance-attribute

ESTIMATED = 'estimated'

The row count the database keeps, where that is cheap and close enough.

Postgres and MySQL keep one in their statistics. It is used when nothing narrows the list and the table holds more than 10,000 rows, shown as "about 2,500,000". A search, a filter or a scope is counted exactly. SQLite keeps no estimate, so it counts exactly.

NONE class-attribute instance-attribute

NONE = 'none'

No count at all.

The pager learns whether there is a next page by reading one row more, so a page is a single query.

adminsite.Pagination

Bases: StrEnum

How the list moves from one page to the next.

OFFSET class-attribute instance-attribute

OFFSET = 'offset'

Page numbers.

Simple, but the database still walks every row before the page, so deep pages of a big table get slow.

KEYSET class-attribute instance-attribute

KEYSET = 'keyset'

Continue after the last row seen, so every page costs the same.

There is no jumping to page 40, only Previous and Next. A sort by a column that can be empty, or through a relationship, falls back to page numbers.

adminsite.SaveContext dataclass

SaveContext(
    session: SessionAdapter,
    record: M,
    values: SaveValues,
    created: bool,
    request: Request,
)

Bases: Generic[M]

What a save hook is given: SaveContext[Order] for an order's save.

before_save, save_record and after_save run inside the save's transaction, after_save_committed once it has committed. In before_save the values have not been written onto the record yet, so that is where to change them: context.values[Order.slug].set(...). Setting an attribute on context.record there would be overwritten a moment later by the value from the form.

session instance-attribute

session: SessionAdapter

The session the save runs in, for reading and writing other tables.

record instance-attribute

record: M

The record being saved. On create, a new instance.

values instance-attribute

values: SaveValues

The values the save stores, each read and changed by its column.

created instance-attribute

created: bool

Whether the save adds a new record rather than changing one.

request instance-attribute

request: Request

The request, to see who is asking.

adminsite.views.SaveValues

SaveValues(
    record: object,
    stored: Mapping[str, Any],
    *,
    form_only: Callable[[str], bool] = lambda path: False,
)

The values a save stores, each named by its column: values[Order.note].

A string names a column too, or a value the form asks for that is no column, such as a password to hash: values["password"]. in tells whether the save stores a value for a column at all. adminsite builds it for each save and hands it to the hooks as context.values.

Parameters:

Name Type Description Default
record object

The record being saved.

required
stored Mapping[str, Any]

The values the save stores, by path.

required
form_only Callable[[str], bool]

Says whether a name is one of the view's form-only fields.

lambda path: False

adminsite.views.SaveValue

SaveValue(values: SaveValues, path: str)

Bases: Generic[T]

One column's value in a save, which a hook reads and can change.

Parameters:

Name Type Description Default
values SaveValues

The save's values.

required
path str

The column's name, or a dotted path such as "customer.email".

required

get

get() -> T

The value the save stores in the column.

When the save leaves the column as it is, such as a read-only field, that is the record's own value. On a new record, such a column is None until the insert fills in its default, as the record's own attribute is; read it in after_save.

Returns:

Type Description
T

The value, of the column's type.

Raises:

Type Description
AdminSiteError

When the record does not hold the value yet, such as a link it was loaded without, since reading it would need a query. Put it in the view's fields, or read it with context.session.

set

set(value: T) -> None

Store this value instead of the one given.

Parameters:

Name Type Description Default
value T

The value to store, of the column's type.

required

adminsite.DeleteContext dataclass

DeleteContext(
    session: SessionAdapter, record: M, request: Request
)

Bases: Generic[M]

What a delete hook is given: DeleteContext[Order] for an order's delete.

before_delete, delete_record and after_delete run inside the delete's transaction, after_delete_committed once it has committed.

session instance-attribute

session: SessionAdapter

The session the delete runs in, for reading and writing other tables.

record instance-attribute

record: M

The record being deleted.

request instance-attribute

request: Request

The request, to see who is asking.

adminsite.Statement module-attribute

Statement = TypeVar('Statement', bound=Select[Any])

The select a view's scope_query is given, and hands back narrowed.

A type variable, so the hook returns the same kind of select it was given: statement.where(...) does, and a new select(Order) is a type error.

Actions

adminsite.actions.action

action(
    label: str = "",
    *,
    name: str = "",
    confirm: str = "",
    permission: str = Permission.EDIT,
    dangerous: bool = False,
    icon: str = "",
    inputs: Sequence[BaseField] = (),
    on: ActionTarget = ON_SELECTION,
    audit_answer: bool = True,
) -> Callable[[Handler], Handler]

Mark a method as an action.

from typing import Literal


class OrderView(ModelView[Order]):
    @action("Mark as shipped", confirm="Mark the chosen orders as shipped?")
    async def ship(
        self, selection: Selection[Order], *, carrier: Literal["DHL", "UPS"]
    ) -> str:
        orders = await selection.records()
        for order in orders:
            order.status = OrderStatus.SHIPPED
        return f"{len(orders)} orders sent with {carrier}."

Each parameter the method does not get handed is asked for in a dialog before the action runs, checked like a form field, and passed to the method by name. Its type picks the input, and Annotated[str, Input(label="Reason")] words it.

on says what it acts on:

  • "selection", the default: the rows the user ticked, handed to a parameter typed Selection[Order].
  • "record": one record, from its row in the list or from its page, handed to a parameter typed with the view's model.
  • "view": nothing in particular. For work about the whole table, such as fetching from another system.

A parameter typed Request, AsyncSession or SessionAdapter is handed the request or the session the action runs in.

A method returns the message to show, a Message for more than a line of text, or a response to send instead, such as a file to download. Raise RefusedError to stop it with a message; everything it did is rolled back.

Parameters:

Name Type Description Default
label str

The button's text. Left empty, the method's name in words: mark_paid reads "Mark paid".

''
name str

The action's name in its URL and the audit log. Left empty, the method's name.

''
confirm str

A question asked in a dialog before it runs.

''
permission str

What the user needs to run it, as allows decides: Permission.EDIT unless you say, or a name of your own.

EDIT
dangerous bool

Whether the button is drawn in red. A menu of actions lists these last, under a line.

False
icon str

An icon drawn before the label wherever the action is offered: inline SVG markup, or a picture's address, as a view's icon takes.

''
inputs Sequence[BaseField]

Fields to ask for as they are, each passed to the parameter of its name. The parameters' types usually say enough.

()
on ActionTarget

What it acts on: "selection", "record" or "view", as above.

ON_SELECTION
audit_answer bool

Whether the audit log keeps the answer. False for one that holds something shown only once, such as a new API key; the entry still says who ran it, on what and when.

True

Returns:

Type Description
Callable[[Handler], Handler]

A decorator that marks the method and hands it back as it was.

Raises:

Type Description
AdminSiteError

When on is none of the three, or an input takes a name the action form uses itself.

adminsite.actions.Selection dataclass

Selection(
    view: ModelView[M],
    session: SessionAdapter,
    spec: QuerySpec,
    request: Request,
    keys: Sequence[str] = (),
    everything: bool = False,
    changes: dict[str, dict[str, Change]] = dict(),
)

Bases: Generic[M]

The rows an action runs over.

Either the rows that were ticked, or every row the current search and filters match. The second kind is never loaded into memory, so an action over a large table stays one statement. Selection[Order] holds orders, so records() returns them as orders.

view instance-attribute

view: ModelView[M]

The view the action belongs to.

session instance-attribute

session: SessionAdapter

The session the action runs in, committed when it succeeds.

request instance-attribute

request: Request

The request that ran the action.

keys class-attribute instance-attribute

keys: Sequence[str] = ()

The keys of the ticked rows, as the URLs write them.

everything class-attribute instance-attribute

everything: bool = False

Whether it covers every row the search and filters match, not the keys.

update async

update(**values: Any) -> int

Change every row this covers, in one statement.

This does not run the save hooks, because it never loads the records. Use records when the hooks matter.

Parameters:

Name Type Description Default
**values Any

The new values, by column name, such as status=OrderStatus.SHIPPED.

{}

Returns:

Type Description
int

How many rows changed.

delete async

delete() -> int

Delete every row this covers, in one statement.

Like update, it skips the hooks, and relies on the database for cascades.

Returns:

Type Description
int

How many rows were deleted.

count async

count() -> int

How many rows this covers.

Returns:

Type Description
int

The number of rows.

records async

records(
    *, paths: Sequence[ColumnReference] = ()
) -> list[M]

Load the records, for work that needs each one in turn.

Parameters:

Name Type Description Default
paths Sequence[ColumnReference]

Links to load with them, such as Order.customer, so work on each record never waits on a query of its own.

()

Returns:

Type Description
list[M]

The records, as the view's model.

statement

statement() -> Select[Any]

A statement selecting the primary keys this covers.

Returns:

Type Description
Select[Any]

A select() of the keys, within the view's scope and its list_query, to use in your own queries.

covered_keys async

covered_keys() -> list[str]

The keys of the records this covers, written as the URLs write them.

Returns:

Type Description
list[str]

The keys, a composite one with its parts joined by commas.

adminsite.actions.Input dataclass

Input(
    *,
    label: str = "",
    help_text: str = "",
    multiline: bool = False,
    accept: str = "",
    max_size: int | None = None,
    secret: bool | None = None,
)

How an action asks for one value, written in the parameter's annotation.

from typing import Annotated

from adminsite.actions import Input

reason: Annotated[str, Input(label="Reason", multiline=True)]

An option the type has no use for, such as multiline on an int, stops the admin when it starts.

Parameters:

Name Type Description Default
label str

The text above the input. Left empty, the parameter's name in words: tracking_number reads "Tracking number".

''
help_text str

A line under the input.

''
multiline bool

A box of several lines, for a str.

False
accept str

For an UploadFile, the types offered, as a browser's accept takes them, such as ".csv".

''
max_size int | None

For an UploadFile, the largest file taken, in bytes.

None
secret bool | None

Whether the audit log keeps *** instead of the value. Left as None, a name such as password or api_key decides.

None

adminsite.Message dataclass

Message(
    text: str,
    link: str = "",
    link_text: str = "",
    copy: str = "",
)

What an action answers, when a line of text is not enough.

Message("The new key is ready.", copy="sk_live_k3j9x2")
Message("The export is on its way.", link="/admin/exports", link_text="Exports")

Parameters:

Name Type Description Default
text str

What happened. It is escaped, unless given as Html.

required
link str

An address to follow, written after the text.

''
link_text str

The words of the link. Left empty, "Open".

''
copy str

A value shown with a button that copies it, such as a new API key. The audit log, which keeps the text, never keeps it.

''

Filters

adminsite.filters.SQLFilter

SQLFilter(
    name: str,
    *,
    path: str | None = None,
    label: str | None = None,
)

Bases: Filter, Generic[M]

A filter that narrows a SQLAlchemy statement: SQLFilter[Product].

Write a custom filter by subclassing this, offering its choices from options and returning a condition from condition. Override apply instead when the filter needs to change the statement itself, for example to add a join.

Parameters:

Name Type Description Default
name str

The filter's name in the URL, such as "delivery" in ?delivery=late.

required
path str | None

The column it reads, such as "customer.region". Left out, the name.

None
label str | None

The filter's label in the list's toolbar. Left out, the name in words.

None

multiple class-attribute instance-attribute

multiple = False

Whether several options can be picked at once, all in value.values.

template class-attribute instance-attribute

template = 'choice'

The control the list draws: "choice", "range", "relation" or "text".

show_counts class-attribute instance-attribute

show_counts: bool | None = None

Whether each option says how many records it matches.

Counting reads every record the list may show, so None, the default, leaves it to the view: the options are counted where its count_mode is CountMode.EXACT, and not on a view too big to count.

options async

options(context: FilterContext) -> Sequence[FilterOption]

List the choices to offer. Empty when the filter is free text.

Parameters:

Name Type Description Default
context FilterContext

Asks the database for counts and values, within the view's scope, such as await context.count_by(self.path).

required

Returns:

Type Description
Sequence[FilterOption]

The choices, in the order they are offered.

condition

condition(
    value: FilterValue, repository: SQLAlchemyRepository[M]
) -> ColumnElement[bool] | None

The condition this filter adds, or nothing to leave the list be.

Parameters:

Name Type Description Default
value FilterValue

What the user picked: value.first, or every one in value.values.

required
repository SQLAlchemyRepository[M]

The repository of the view's model.

required

Returns:

Type Description
ColumnElement[bool] | None

A condition on the view's model, or None to leave the list be.

apply

apply(
    statement: Select[Any],
    value: FilterValue,
    repository: SQLAlchemyRepository[M],
) -> Select[Any]

Narrow the statement with this filter's condition.

Parameters:

Name Type Description Default
statement Select[Any]

The select the list is about to run.

required
value FilterValue

What the user picked.

required
repository SQLAlchemyRepository[M]

The repository of the view's model.

required

Returns:

Type Description
Select[Any]

The statement, narrowed.

adminsite.filters.FilterOption dataclass

FilterOption(
    value: str, label: str, count: int | None = None
)

One choice a filter offers, with how many records it would match.

Parameters:

Name Type Description Default
value str

What the URL carries when it is picked, such as "late".

required
label str

What the list shows for it, such as "Overdue".

required
count int | None

How many records it would match, shown beside it. None shows no count.

None

adminsite.filters.FilterValue dataclass

FilterValue(
    name: str, values: tuple[str, ...], source: Any = None
)

What the user picked for one filter.

name instance-attribute

name: str

The filter's name, as the URL carries it.

values instance-attribute

values: tuple[str, ...]

Every value picked, as text, in the order the URL carries them.

source class-attribute instance-attribute

source: Any = field(default=None, compare=False, repr=False)

The filter these were read for.

So a filter that get_list_filters adds for a single request is applied by the query that request makes.

first property

first: str

The first value, which is all a single choice filter uses.

adminsite.filters.ChoiceFilter

ChoiceFilter(
    name: str,
    *,
    choices: Sequence[tuple[str, str]] = (),
    show_counts: bool | None = None,
    **options: Any,
)

Bases: SQLFilter[Any]

Pick one or more values from a fixed list, such as a status.

Parameters:

Name Type Description Default
name str

The filter's name in the URL.

required
choices Sequence[tuple[str, str]]

The options as (value, label) pairs.

()
show_counts bool | None

Whether each option says how many records it matches. Left out, they are counted where the view counts its records exactly.

None
**options Any

path and label, as SQLFilter takes them.

{}

adminsite.filters.BooleanFilter

BooleanFilter(
    name: str,
    *,
    yes_label: str = "Yes",
    no_label: str = "No",
    show_counts: bool | None = None,
    **options: Any,
)

Bases: SQLFilter[Any]

A yes or no column.

Parameters:

Name Type Description Default
name str

The filter's name in the URL.

required
yes_label str

What the option for yes says.

'Yes'
no_label str

What the option for no says.

'No'
show_counts bool | None

Whether each option says how many records it matches. Left out, they are counted where the view counts its records exactly.

None
**options Any

path and label, as SQLFilter takes them.

{}

adminsite.filters.NumberRangeFilter

NumberRangeFilter(
    name: str,
    *,
    presets: Sequence[tuple[str, str]] = (),
    **options: Any,
)

Bases: SQLFilter[Any]

A range of numbers, written as min,max with either side empty.

Parameters:

Name Type Description Default
name str

The filter's name in the URL.

required
presets Sequence[tuple[str, str]]

Ranges offered as options, as (value, label) pairs, each value written as min,max: ("0,50", "Under 50").

()
**options Any

path and label, as SQLFilter takes them.

{}

adminsite.filters.DateRangeFilter

DateRangeFilter(
    name: str,
    *,
    now: datetime | None = None,
    **options: Any,
)

Bases: SQLFilter[Any]

A period, either one of the shortcuts or from,to as dates.

The dates are days on the clock of the person filtering: a day starts at their midnight, wherever the database keeps its times.

Parameters:

Name Type Description Default
name str

The filter's name in the URL.

required
now datetime | None

The moment the shortcuts, such as the last 7 days, count from, in the database's time zone unless it has one of its own. Left out, the time each request is read.

None
**options Any

path and label, as SQLFilter takes them.

{}

adminsite.filters.RelationFilter

RelationFilter(
    name: str, *, key: str = "id", **options: Any
)

Bases: SQLFilter[Any]

Records linked to one of the chosen related records.

Parameters:

Name Type Description Default
name str

The filter's name in the URL.

required
key str

The related model's column the chosen values are matched against.

'id'
**options Any

path and label, as SQLFilter takes them.

{}

adminsite.filters.TextFilter

TextFilter(
    name: str,
    *,
    path: str | None = None,
    label: str | None = None,
)

Bases: SQLFilter[Any]

Records whose text contains what was typed.

Fields

adminsite.Field dataclass

Field(
    column: QueryableAttribute[V] | Link[V] | str,
    *,
    label: str = "",
    help_text: str = "",
    empty_text: str = "",
    required: bool | None = None,
    read_only: bool = False,
    max_length: int | None = None,
    default: V | None = None,
    format: str | None = None,
    secret: bool | None = None,
    form_only: bool = False,
    exclude_from_list: bool = False,
    exclude_from_detail: bool = False,
    exclude_from_create: bool = False,
    exclude_from_edit: bool = False,
    exclude_from_export: bool = False,
    hidden_in_list: bool = False,
)

Bases: BaseField, Generic[V]

A column's field: Field(Order.created_at, label="Placed").

On its own it is the field adminsite picks for the column, with the options given. The kinds, such as DecimalField or TextAreaField, are built on it and choose the field themselves. Either way, an option left out comes from the column: its label, its length, whether it may be empty.

Every option of BaseField applies as well.

Parameters:

Name Type Description Default
column QueryableAttribute[V] | Link[V] | str

The column, by its attribute, by a Link for a column of a related model, or by its name as a string.

required
default V | None

What a new record's input starts with, of the column's type of value.

None

column_types class-attribute

column_types: tuple[type[Any], ...] = ()

The types of value this kind's column may hold, as V says them.

A view checks a column named by a string against them when it starts. Empty for a kind that takes any column.

adminsite.BaseField dataclass

BaseField(
    *,
    label: str = "",
    help_text: str = "",
    empty_text: str = "",
    required: bool | None = None,
    read_only: bool = False,
    max_length: int | None = None,
    default: Any = None,
    format: str | None = None,
    secret: bool | None = None,
    form_only: bool = False,
    exclude_from_list: bool = False,
    exclude_from_detail: bool = False,
    exclude_from_create: bool = False,
    exclude_from_edit: bool = False,
    exclude_from_export: bool = False,
    hidden_in_list: bool = False,
)

What every field has: a label, the pages it is left off, how it reads.

Field and the kinds built on it show a column; ComputedField shows a value worked out from the record. Every kind takes the options below, and refuses one it never reads when the admin starts, such as max_length on a date. A field of your own subclasses Field and says how to show and read its value with the methods below.

Parameters:

Name Type Description Default
label str

The field's label. Left empty, the column's label, or the name written as words: created_at reads "Created at".

''
help_text str

A line under the input, saying how to fill it in.

''
empty_text str

What an empty value means, such as "Every region" for a multi-select left empty. The list and the record page show it faint, like a placeholder, where they would show a hyphen, so it never reads as a value someone set, and the export writes it into the empty cell. The form is unchanged.

''
required bool | None

Whether the form refuses the field left empty. Left as None, the column decides: required where it cannot be null and has no default. A default only fills in a new record, so on a record that exists, a field whose column cannot be null is required, unless it stores empty text.

None
read_only bool

Shown on the form and never read back from it, even if someone adds the input back by hand.

False
max_length int | None

The most characters the input takes. Left as None, the column's length, as String(80) gives it.

None
default Any

What a new record's input starts with, and what an action's dialog opens with. A stored value always wins over it.

None
format str | None

How the value is written wherever it is shown, as str.format takes it: "€{:,.2f}" for money. The input keeps the plain value.

None
secret bool | None

Whether the audit log keeps *** instead of the value, in what a save changed and what an action was run with. Left as None, a name such as password or api_key decides.

None
form_only bool

Whether the field is an input that is no column. It is on the form under a name of its own, never read from the record or written to it, and its value reaches before_save and after_save, which store it where it belongs.

False
exclude_from_list bool

Leaves the field off the list.

False
exclude_from_detail bool

Leaves the field off the record page.

False
exclude_from_create bool

Leaves the field off the form for a new record.

False
exclude_from_edit bool

Leaves the field off the edit form.

False
exclude_from_export bool

Leaves the field out of the CSV export.

False
hidden_in_list bool

Offers the field in the list's Columns menu, and keeps it off the list until someone turns it on.

False

name class-attribute instance-attribute

name: str = field(init=False)

The path the field shows, such as "total" or "customer.email".

widget class-attribute instance-attribute

widget = 'text'

The name of the input the form draws, such as "text" or "number".

It picks the template adminsite/widgets/<widget>.html, or a plain input where there is none. A project can override one or add its own.

python_type class-attribute

python_type: type[Any] = str

The type to_python checks submitted text against, with Pydantic.

error_message class-attribute instance-attribute

error_message = 'Enter a valid value.'

What the form says when the text is not a value of python_type.

stored class-attribute instance-attribute

stored = True

Whether the value is stored on the record.

A computed field is not, so it is never loaded, written, sorted or filtered.

keeps_value_when_blank class-attribute instance-attribute

keeps_value_when_blank = False

Whether an input left empty on a record that exists keeps its value.

True for a password, so a form saved without one keeps the old.

stores_empty_text class-attribute instance-attribute

stores_empty_text = False

Whether an input left empty stores "" where the column cannot be null.

True for text, and for a file, whose column keeps its key as text. Only a record that exists stores it: a new one is left to the column's default.

unused_options class-attribute

unused_options: frozenset[str] = frozenset()

The options this kind never reads, refused when the admin starts.

So a date field given max_length stops the admin starting instead of refusing every date typed in.

display

display(value: Any) -> str

Format the value for reading. An empty value shows as nothing.

Parameters:

Name Type Description Default
value Any

The value, as the record holds it.

required

Returns:

Type Description
str

The text the list, the record page and the export show.

text_for

text_for(record: Any, value: Any) -> str

The text shown for this value, given the record it belongs to.

Everything that shows a value goes through here: the list, the record page and the export. Override it where the text depends on another column, such as an amount that reads differently per currency; override display where the value alone is enough.

Parameters:

Name Type Description Default
record Any

The record the value belongs to.

required
value Any

The value, as the record holds it.

required

Returns:

Type Description
str

The text shown, in the field's format where it has one.

hint

hint() -> str

How to fill the input in, shown under it when there is no help text.

A method, so it is worded in the language of each request.

Returns:

Type Description
str

The hint, or nothing.

serialize

serialize(value: Any) -> str

Format the value for a form input.

Parameters:

Name Type Description Default
value Any

The value, as the record holds it.

required

Returns:

Type Description
str

The text the input starts with, which parse reads back.

parse

parse(raw: str | None) -> Any

Turn submitted text into a Python value, or raise.

Empty text is None, or refused where the field is required. Anything else goes to to_python, stripped.

Parameters:

Name Type Description Default
raw str | None

The text submitted for the field, or None when it is missing.

required

Returns:

Type Description
Any

The value to store.

Raises:

Type Description
FieldValidationError

When the text is not a value the field takes, with a message for the form.

to_python

to_python(text: str) -> Any

Convert non empty text, assuming it is present and stripped.

Override it to read a value of your own, and call it through super() to keep the checks on length and type.

Parameters:

Name Type Description Default
text str

The submitted text, stripped and not empty.

required

Returns:

Type Description
Any

The value to store.

Raises:

Type Description
FieldValidationError

When the text is too long or not of python_type, with a message for the form.

check_options

check_options() -> None

Refuse options that do not fit together, once the column filled its part.

The view calls it for every field it is given, when it is built, so a mistake stops the admin starting rather than the page that shows it.

Raises:

Type Description
AdminSiteError

When two options contradict each other, such as a tone given for a value the field does not have.

adminsite.fields.ComputedField dataclass

ComputedField(
    name: str,
    getter: Callable[[M], V] | None = None,
    *,
    label: str = "",
    help_text: str = "",
    empty_text: str = "",
    required: bool | None = None,
    read_only: bool = False,
    max_length: int | None = None,
    default: V | None = None,
    format: str | None = None,
    secret: bool | None = None,
    form_only: bool = False,
    exclude_from_list: bool = False,
    exclude_from_detail: bool = False,
    exclude_from_create: bool = False,
    exclude_from_edit: bool = False,
    exclude_from_export: bool = False,
    hidden_in_list: bool = False,
    needs: Sequence[ColumnReference] = (),
    load: Loader[M, V] | None = None,
)

Bases: BaseField, Generic[M, V]

A value the view works out from a record, rather than a column.

def capacity(product: Product) -> str:
    return f"{len(product.slots)}/{product.limit}"


class ProductView(ModelView[Product]):
    fields = [
        Product.name,
        ComputedField("capacity", capacity, needs=[Product.slots]),
    ]

It shows in the list, on the record page and in the export, and is never written, sorted or filtered. The function takes the view's model, so one written for another model is a type error.

A value that takes a query, such as a count of related rows, comes from load instead, which runs once for the page, however many rows it holds:

from collections.abc import Sequence


async def order_counts(
    session: SessionAdapter, customers: Sequence[Customer]
) -> dict[int, int]:
    rows = await session.execute(
        select(Order.customer_id, func.count())
        .where(Order.customer_id.in_([customer.id for customer in customers]))
        .group_by(Order.customer_id)
    )
    return {customer_id: count for customer_id, count in rows.all()}


ComputedField("orders", load=order_counts, default=0)

It takes label, help_text, empty_text, format, hidden_in_list and the flags that leave it off the list, the record page or the export, as BaseField describes them.

Parameters:

Name Type Description Default
name str

The field's name, such as "capacity". Its label is made from it, unless label is given.

required
getter Callable[[M], V] | None

A function given the record, which answers with the value.

None
needs Sequence[ColumnReference]

What the function reads, such as Product.slots, loaded with the page instead of one query per row.

()
load Loader[M, V] | None

An async function given the session and every record on the page, which answers with each record's value by its primary key, a tuple for a composite key. Give it or getter, not both.

None
default V | None

The value of a record load leaves out.

None

Raises:

Type Description
AdminSiteError

When it is given both getter and load, or neither, or form_only.

adminsite.fields.StringField dataclass

StringField(
    column: QueryableAttribute[V] | Link[V] | str,
    *,
    label: str = "",
    help_text: str = "",
    empty_text: str = "",
    required: bool | None = None,
    read_only: bool = False,
    max_length: int | None = None,
    default: V | None = None,
    format: str | None = None,
    secret: bool | None = None,
    form_only: bool = False,
    exclude_from_list: bool = False,
    exclude_from_detail: bool = False,
    exclude_from_create: bool = False,
    exclude_from_edit: bool = False,
    exclude_from_export: bool = False,
    hidden_in_list: bool = False,
)

Bases: Field[str | None]

A short piece of text, edited on one line.

adminsite.fields.TextAreaField dataclass

TextAreaField(
    column: QueryableAttribute[V] | Link[V] | str,
    *,
    label: str = "",
    help_text: str = "",
    empty_text: str = "",
    required: bool | None = None,
    read_only: bool = False,
    max_length: int | None = None,
    default: V | None = None,
    format: str | None = None,
    secret: bool | None = None,
    form_only: bool = False,
    exclude_from_list: bool = False,
    exclude_from_detail: bool = False,
    exclude_from_create: bool = False,
    exclude_from_edit: bool = False,
    exclude_from_export: bool = False,
    hidden_in_list: bool = False,
)

Bases: StringField

A longer piece of text, edited in a box.

adminsite.fields.EmailField dataclass

EmailField(
    column: QueryableAttribute[V] | Link[V] | str,
    *,
    label: str = "",
    help_text: str = "",
    empty_text: str = "",
    required: bool | None = None,
    read_only: bool = False,
    max_length: int | None = None,
    default: V | None = None,
    format: str | None = None,
    secret: bool | None = None,
    form_only: bool = False,
    exclude_from_list: bool = False,
    exclude_from_detail: bool = False,
    exclude_from_create: bool = False,
    exclude_from_edit: bool = False,
    exclude_from_export: bool = False,
    hidden_in_list: bool = False,
)

Bases: StringField

Text holding an email address.

adminsite.fields.IntegerField dataclass

IntegerField(
    column: QueryableAttribute[V] | Link[V] | str,
    *,
    label: str = "",
    help_text: str = "",
    empty_text: str = "",
    required: bool | None = None,
    read_only: bool = False,
    max_length: int | None = None,
    default: V | None = None,
    format: str | None = None,
    secret: bool | None = None,
    form_only: bool = False,
    exclude_from_list: bool = False,
    exclude_from_detail: bool = False,
    exclude_from_create: bool = False,
    exclude_from_edit: bool = False,
    exclude_from_export: bool = False,
    hidden_in_list: bool = False,
)

Bases: Field[int | None]

A whole number.

adminsite.fields.FloatField dataclass

FloatField(
    column: QueryableAttribute[V] | Link[V] | str,
    *,
    label: str = "",
    help_text: str = "",
    empty_text: str = "",
    required: bool | None = None,
    read_only: bool = False,
    max_length: int | None = None,
    default: V | None = None,
    format: str | None = None,
    secret: bool | None = None,
    form_only: bool = False,
    exclude_from_list: bool = False,
    exclude_from_detail: bool = False,
    exclude_from_create: bool = False,
    exclude_from_edit: bool = False,
    exclude_from_export: bool = False,
    hidden_in_list: bool = False,
)

Bases: Field[float | None]

A number that can have a fractional part.

adminsite.fields.DecimalField dataclass

DecimalField(
    column: QueryableAttribute[V] | Link[V] | str,
    *,
    label: str = "",
    help_text: str = "",
    empty_text: str = "",
    required: bool | None = None,
    read_only: bool = False,
    max_length: int | None = None,
    default: V | None = None,
    format: str | None = None,
    secret: bool | None = None,
    form_only: bool = False,
    exclude_from_list: bool = False,
    exclude_from_detail: bool = False,
    exclude_from_create: bool = False,
    exclude_from_edit: bool = False,
    exclude_from_export: bool = False,
    hidden_in_list: bool = False,
)

Bases: Field[Decimal | None]

A number kept exact, such as a price.

adminsite.fields.BooleanField dataclass

BooleanField(
    column: QueryableAttribute[V] | Link[V] | str,
    *,
    label: str = "",
    help_text: str = "",
    empty_text: str = "",
    required: bool | None = None,
    read_only: bool = False,
    max_length: int | None = None,
    default: V | None = None,
    format: str | None = None,
    secret: bool | None = None,
    form_only: bool = False,
    exclude_from_list: bool = False,
    exclude_from_detail: bool = False,
    exclude_from_create: bool = False,
    exclude_from_edit: bool = False,
    exclude_from_export: bool = False,
    hidden_in_list: bool = False,
    tones: Tone | Mapping[bool, Tone | None] | None = None,
)

Bases: Field[bool | None]

A yes or no value.

Its badge is green for yes and grey for no, unless tones says: for a flag whose yes is the bad case, {True: "rose", False: None} draws a rose badge when it is set and nothing when it is not.

Parameters:

Name Type Description Default
tones Tone | Mapping[bool, Tone | None] | None

The colour of each value's badge, by name, or one name for both. None for a value draws it with no badge.

None

adminsite.fields.UUIDField dataclass

UUIDField(
    column: QueryableAttribute[V] | Link[V] | str,
    *,
    label: str = "",
    help_text: str = "",
    empty_text: str = "",
    required: bool | None = None,
    read_only: bool = False,
    max_length: int | None = None,
    default: V | None = None,
    format: str | None = None,
    secret: bool | None = None,
    form_only: bool = False,
    exclude_from_list: bool = False,
    exclude_from_detail: bool = False,
    exclude_from_create: bool = False,
    exclude_from_edit: bool = False,
    exclude_from_export: bool = False,
    hidden_in_list: bool = False,
)

Bases: Field[UUID | None]

An identifier in UUID form.

adminsite.fields.DateField dataclass

DateField(
    column: QueryableAttribute[V] | Link[V] | str,
    *,
    label: str = "",
    help_text: str = "",
    empty_text: str = "",
    required: bool | None = None,
    read_only: bool = False,
    max_length: int | None = None,
    default: V | None = None,
    format: str | None = None,
    secret: bool | None = None,
    form_only: bool = False,
    exclude_from_list: bool = False,
    exclude_from_detail: bool = False,
    exclude_from_create: bool = False,
    exclude_from_edit: bool = False,
    exclude_from_export: bool = False,
    hidden_in_list: bool = False,
)

Bases: Field[date | None]

A calendar date.

adminsite.fields.DateTimeField dataclass

DateTimeField(
    column: QueryableAttribute[V] | Link[V] | str,
    *,
    label: str = "",
    help_text: str = "",
    empty_text: str = "",
    required: bool | None = None,
    read_only: bool = False,
    max_length: int | None = None,
    default: V | None = None,
    format: str | None = None,
    secret: bool | None = None,
    form_only: bool = False,
    exclude_from_list: bool = False,
    exclude_from_detail: bool = False,
    exclude_from_create: bool = False,
    exclude_from_edit: bool = False,
    exclude_from_export: bool = False,
    hidden_in_list: bool = False,
    with_timezone: bool | None = None,
)

Bases: Field[datetime | None]

A date together with a time, on the clock of the person reading it.

A time the column keeps without a zone is taken to be in the admin's database_timezone. What a person types is read on their own clock, and saved the way the column keeps it.

Parameters:

Name Type Description Default
with_timezone bool | None

Whether the column keeps the time zone, as one declared DateTime(timezone=True) does. It is then given a time with its zone, and otherwise a plain one. Left as None, the column decides.

None

adminsite.fields.TimeField dataclass

TimeField(
    column: QueryableAttribute[V] | Link[V] | str,
    *,
    label: str = "",
    help_text: str = "",
    empty_text: str = "",
    required: bool | None = None,
    read_only: bool = False,
    max_length: int | None = None,
    default: V | None = None,
    format: str | None = None,
    secret: bool | None = None,
    form_only: bool = False,
    exclude_from_list: bool = False,
    exclude_from_detail: bool = False,
    exclude_from_create: bool = False,
    exclude_from_edit: bool = False,
    exclude_from_export: bool = False,
    hidden_in_list: bool = False,
)

Bases: Field[time | None]

A time of day.

adminsite.fields.ListField dataclass

ListField(
    column: QueryableAttribute[V] | Link[V] | str,
    *,
    label: str = "",
    help_text: str = "",
    empty_text: str = "",
    required: bool | None = None,
    read_only: bool = False,
    max_length: int | None = None,
    default: V | None = None,
    format: str | None = None,
    secret: bool | None = None,
    form_only: bool = False,
    exclude_from_list: bool = False,
    exclude_from_detail: bool = False,
    exclude_from_create: bool = False,
    exclude_from_edit: bool = False,
    exclude_from_export: bool = False,
    hidden_in_list: bool = False,
    item: Field[Any] | None = None,
)

Bases: Field[Sequence[Any] | None]

A list of values, such as a Postgres ARRAY column: one value per line.

Chosen for an array column by itself, with each value read by the field its type calls for, so ARRAY(Integer) takes whole numbers and ARRAY(String(2)) two letters at most. Give item to choose it:

ListField(Product.sizes, item=IntegerField("sizes"))

Parameters:

Name Type Description Default
item Field[Any] | None

The field each value is read and shown by. Left out, the one the column's type of item calls for.

None

adminsite.fields.PasswordField dataclass

PasswordField(
    column: QueryableAttribute[V] | Link[V] | str,
    *,
    label: str = "",
    help_text: str = "",
    empty_text: str = "",
    required: bool | None = None,
    read_only: bool = False,
    max_length: int | None = None,
    default: V | None = None,
    format: str | None = None,
    secret: bool | None = True,
    form_only: bool = True,
    exclude_from_list: bool = False,
    exclude_from_detail: bool = False,
    exclude_from_create: bool = False,
    exclude_from_edit: bool = False,
    exclude_from_export: bool = False,
    hidden_in_list: bool = False,
)

Bases: StringField

A password to set: typed in, never shown, changed only when typed.

class UserView(ModelView[User]):
    fields = [
        User.email,
        PasswordField("password", required=True),
    ]

    async def before_save(self, context: SaveContext[User]) -> None:
        password = context.values["password"].get()
        if password:
            context.values[User.password_hash].set(hash_password(password))

It is form only: what is typed reaches the hooks, never the record, and the input is never filled in. required holds for a new record; on an existing one, leaving it empty keeps the password it has.

adminsite.fields.FieldRegistry

FieldRegistry()

Decides which field type to use for a column.

register

register(
    python_type: type[Any], field_class: type[Field[Any]]
) -> None

Use this field type for columns holding this Python type.

Parameters:

Name Type Description Default
python_type type[Any]

The type of value a column holds, such as Decimal or a type of your own.

required
field_class type[Field[Any]]

The field adminsite picks for such a column.

required

adminsite.fields.default_registry module-attribute

default_registry = build_default_registry()

The registry every admin uses unless it is given another.

Register a field here to have it picked for every column of a type: default_registry.register(Percentage, PercentField).

adminsite.fields.EnumField dataclass

EnumField(
    column: QueryableAttribute[V] | Link[V] | str,
    *,
    label: str = "",
    help_text: str = "",
    empty_text: str = "",
    required: bool | None = None,
    read_only: bool = False,
    max_length: int | None = None,
    default: V | None = None,
    format: str | None = None,
    secret: bool | None = None,
    form_only: bool = False,
    exclude_from_list: bool = False,
    exclude_from_detail: bool = False,
    exclude_from_create: bool = False,
    exclude_from_edit: bool = False,
    exclude_from_export: bool = False,
    hidden_in_list: bool = False,
    enum: type[Enum] | None = None,
    choices: Sequence[tuple[str, str]] = (),
    multiple: bool = False,
    tones: Tones | None = None,
)

Bases: Field[Any]

A value picked from a fixed set, such as a status.

The set comes from the column, an Enum or a string column with its values listed, or from enum or choices given here:

EnumField(
    Order.status, tones={OrderStatus.PAID: "green", OrderStatus.FAILED: "rose"}
)
EnumField(Product.size, choices=[("S", "Small"), ("L", "Large")])

Every option of BaseField applies as well, but max_length.

Parameters:

Name Type Description Default
column QueryableAttribute[V] | Link[V] | str

The column, as Field takes it.

required
enum type[Enum] | None

The Enum whose members are the options, for a string column. The options are keyed by each member's value, which is what the column stores.

None
choices Sequence[tuple[str, str]]

The options as (value, label) pairs. On an Enum column they relabel or narrow its members, each named by its name or its value, and the field still hands over the member.

()
multiple bool

Whether it holds several options at once, in the order they were picked, which suits a JSON column and an action that asks for a few categories.

False
tones Tones | None

Which colour each value's badge is, by name, so a failed status is rose wherever it sits among the choices. A value left out is grey, and None draws it with no badge. One name, such as "grey", colours every value alike. Left out, a value takes the tone of its place.

None

adminsite.fields.RelationField dataclass

RelationField(
    column: QueryableAttribute[V] | Link[V] | str,
    *,
    label: str = "",
    help_text: str = "",
    empty_text: str = "",
    required: bool | None = None,
    read_only: bool = False,
    max_length: int | None = None,
    default: V | None = None,
    format: str | None = None,
    secret: bool | None = None,
    form_only: bool = False,
    exclude_from_list: bool = False,
    exclude_from_detail: bool = False,
    exclude_from_create: bool = False,
    exclude_from_edit: bool = False,
    exclude_from_export: bool = False,
    hidden_in_list: bool = False,
    target: type[Any] | None = None,
    collection: bool = False,
    record_title: str | None = None,
    view: type[ModelView[Any]] | str | None = None,
    ordered: bool = False,
)

Bases: Field[Any]

A link to one or many other records: RelationField(Order.customer).

The relationship says which model it links to and whether it holds many records. Every option of BaseField applies as well, but max_length.

Parameters:

Name Type Description Default
column QueryableAttribute[V] | Link[V] | str

The relationship, such as Order.customer, or its name.

required
target type[Any] | None

The model of the linked records. The view fills it in from the relationship.

None
collection bool

Whether it links to many records. The view fills it in from the relationship.

False
record_title str | None

How each linked record is named here, such as "{name} ({email})". Left out, as the linked view names it.

None
view type[ModelView[Any]] | str | None

The view its links open and its picker lists from, as a class or by its name, for a model shown by more than one view. Left out, the first view registered for that model.

None
ordered bool

For a link to many whose order means something, such as servers tried in turn. The form can put its records in order, and saving writes the link again in that order when it changed.

False

Raises:

Type Description
AdminSiteError

When ordered is given for a link to one record.

adminsite.Html

Bases: Markup

Text written into the page as markup instead of being escaped.

A field that returns it can put a link, a badge or an icon in a cell:

def tracking(order: Order) -> Html:
    return Html('<a href="../{}">Track</a>').format(order.tracking_url)


ComputedField("tracking", tracking, needs=[Order.tracking_url])

Everything interpolated with format or % is escaped, so a value from the database cannot carry markup of its own into the page. Anything written straight into the string is not, so keep that to markup you wrote.

The CSV export and the JSON API send the text without the tags, since markup belongs on the page and not in a spreadsheet.

adminsite.fields.JSONField dataclass

JSONField(
    column: QueryableAttribute[V] | Link[V] | str,
    *,
    label: str = "",
    help_text: str = "",
    empty_text: str = "",
    required: bool | None = None,
    read_only: bool = False,
    max_length: int | None = None,
    default: V | None = None,
    format: str | None = None,
    secret: bool | None = None,
    form_only: bool = False,
    exclude_from_list: bool = False,
    exclude_from_detail: bool = False,
    exclude_from_create: bool = False,
    exclude_from_edit: bool = False,
    exclude_from_export: bool = False,
    hidden_in_list: bool = False,
    schema: Any = None,
    partial: bool = False,
)

Bases: Field[Any]

A JSON column: an object, a list, or any other JSON document.

Chosen for JSON columns by itself. The form edits the document in a code box that colours it and says where a mistake is while it is typed; the record page lays it out, each object and list folding on its line.

Give it a schema, a Pydantic type or a JSON Schema dict, and the form is built from it instead: a switch for a yes or no, a number input that keeps to its limits, a picker for a few fixed values, rows for a list of objects. The document is checked against the schema when it is saved, by Pydantic where the schema is a Pydantic type, and the record page names each value by its title.

JSONField(Shop.settings, schema=ShopSettings)

Every option of BaseField applies as well, but max_length.

Parameters:

Name Type Description Default
column QueryableAttribute[V] | Link[V] | str

The JSON column, as Field takes it.

required
schema Any

A Pydantic type or a JSON Schema dict to build the form from and check the document against. Or a function given the record that answers with either, for a table whose rows hold documents of different shapes; it may answer None where the record has none, and the form is then a code box.

None
partial bool

Whether each property may be left unset, and only those set are saved, as an override that changes a few settings keeps them.

False

adminsite.fields.FileField dataclass

FileField(
    column: QueryableAttribute[V] | Link[V] | str,
    *,
    label: str = "",
    help_text: str = "",
    empty_text: str = "",
    required: bool | None = None,
    read_only: bool = False,
    max_length: int | None = None,
    default: V | None = None,
    format: str | None = None,
    secret: bool | None = None,
    form_only: bool = False,
    exclude_from_list: bool = False,
    exclude_from_detail: bool = False,
    exclude_from_create: bool = False,
    exclude_from_edit: bool = False,
    exclude_from_export: bool = False,
    hidden_in_list: bool = False,
    storage: FileStorage,
    accept: str = "",
    max_size: int = 10 * MEGABYTE,
)

Bases: Field[str | None]

An uploaded file, kept in a storage, its key kept in a string column.

FileField(Product.datasheet, storage=LocalStorage("uploads"), accept=".pdf")

The form shows the current file with a box to remove it. A new upload replaces the old file, which is deleted once the save has committed; deleting the record keeps its files. Every option of BaseField applies as well, but max_length.

Parameters:

Name Type Description Default
column QueryableAttribute[V] | Link[V] | str

The string column that keeps the file's key.

required
storage FileStorage

Where the files go, such as LocalStorage("uploads").

required
accept str

The types taken, as a browser's accept takes them: extensions such as ".pdf", types such as "application/pdf", or "image/*". Checked on the server too.

''
max_size int

The largest file taken, in bytes: 10 MB unless you say.

10 * MEGABYTE

adminsite.fields.ImageField dataclass

ImageField(
    column: QueryableAttribute[V] | Link[V] | str,
    *,
    label: str = "",
    help_text: str = "",
    empty_text: str = "",
    required: bool | None = None,
    read_only: bool = False,
    max_length: int | None = None,
    default: V | None = None,
    format: str | None = None,
    secret: bool | None = None,
    form_only: bool = False,
    exclude_from_list: bool = False,
    exclude_from_detail: bool = False,
    exclude_from_create: bool = False,
    exclude_from_edit: bool = False,
    exclude_from_export: bool = False,
    hidden_in_list: bool = False,
    storage: FileStorage,
    accept: str = "image/png,image/jpeg,image/gif,image/webp",
    max_size: int = 5 * MEGABYTE,
)

Bases: FileField

An uploaded picture, shown as a thumbnail in the list and the form.

Takes PNG, JPEG, GIF and WebP, checked by the file's first bytes, not just its name. SVG is left out on purpose: it can carry script. The options are FileField's, with accept set to those four types and max_size to 5 MB.

adminsite.files.FileStorage

Where uploaded files go.

Subclass it to keep files somewhere else, such as S3: save returns the key kept in the column, and response answers a request for the file, for example with a redirect to a signed URL.

save async

save(upload: UploadFile) -> str

Store an upload and return the key to keep in the column.

Parameters:

Name Type Description Default
upload UploadFile

The uploaded file, as Starlette reads it.

required

Returns:

Type Description
str

The key the column keeps, which url, response and delete are given later.

delete async

delete(key: str) -> None

Remove a stored file. A missing file is not an error.

Parameters:

Name Type Description Default
key str

The key save returned.

required

url

url(key: str) -> str

A public address for the file, or nothing to serve it through the admin.

Parameters:

Name Type Description Default
key str

The key save returned.

required

Returns:

Type Description
str

The address, or an empty string to have the admin serve the file through response, behind its sign in.

response async

response(key: str, content_type: str = '') -> Response

Answer a request for the file, made through the admin.

Parameters:

Name Type Description Default
key str

The key save returned.

required
content_type str

The file's type, guessed from its name.

''

Returns:

Type Description
Response

The file, or a redirect to where it is, such as a signed URL.

adminsite.files.LocalStorage

LocalStorage(
    directory: str | Path, *, url_prefix: str = ""
)

Bases: FileStorage

Keeps files in a folder on this machine.

uploads = LocalStorage("uploads")

Files are served through the admin, behind its sign in, unless you give url_prefix for a place your application already serves them from. Each file gets a key such as 2026/09/k3j9x2-invoice.pdf: the month, a random part so names never clash, and the original name.

Parameters:

Name Type Description Default
directory str | Path

The folder the files go in.

required
url_prefix str

Where your application already serves the folder from, such as "https://cdn.example.com/uploads". Left empty, the admin serves the files.

''

url

url(key: str) -> str

The public address, when the files are served elsewhere.

Parameters:

Name Type Description Default
key str

The key save returned.

required

Returns:

Type Description
str

The address under url_prefix, or an empty string without one.

path_for

path_for(key: str) -> Path

Where a key lives on disk, refusing any key that leaves the folder.

Parameters:

Name Type Description Default
key str

The key save returned.

required

Returns:

Type Description
Path

The file's path inside the folder.

Raises:

Type Description
HTTPException

A 404, for a key that would leave the folder.

Pages, plugins and the dashboard

adminsite.AdminPage

AdminPage()

A page of your own inside the admin, such as a report or a settings form.

from typing import Any


class SalesReport(AdminPage):
    label = "Sales report"
    group = "Reports"
    template = "reports/sales.html"

    async def get_context(self, request: Request) -> dict[str, Any]:
        async with self.admin.database.session() as session:
            total = await session.scalar(select(func.sum(Order.total)))
        return {"total": total}

The template lives in one of the admin's template_dirs and usually extends adminsite/base.html, so the page keeps the sidebar and header. It is served at /-/sales_report and listed in the sidebar and the command palette.

name class-attribute instance-attribute

name: str = ''

The page's name in its URL, /-/ and the name.

Left empty, the class's name in snake case, less a _page ending: SalesReport is served at /-/sales_report.

label class-attribute instance-attribute

label: str = ''

The page's name in the sidebar. Left empty, the name in words.

group class-attribute instance-attribute

group: str = ''

The sidebar section the page sits under, as a view's group.

icon class-attribute instance-attribute

icon: str = ''

The page's icon in the sidebar, as a view's icon.

template class-attribute instance-attribute

template: str = ''

The template get renders, from one of the admin's template_dirs.

admin instance-attribute

admin: Admin

The admin the page was added to, set when it is added.

allows async

allows(request: Request) -> bool

Whether this user may open the page. Everyone may, unless you say.

Where the auth provider's grants_for gave the user grants, the page follows them, under "-/" and its name.

Parameters:

Name Type Description Default
request Request

The request, with the signed in user on request.state.user.

required

Returns:

Type Description
bool

True when the user may open it. A page they may not is left out of the sidebar and answers 403.

get_context async

get_context(request: Request) -> dict[str, Any]

The values the template needs, on top of what every page has.

Parameters:

Name Type Description Default
request Request

The request being answered.

required

Returns:

Type Description
dict[str, Any]

The values, by the names the template reads them by. The page itself is there as page too.

get async

get(request: Request) -> Response

Show the page. Override it to answer with anything else.

Parameters:

Name Type Description Default
request Request

The request being answered.

required

Returns:

Type Description
Response

The template rendered with get_context's values.

Raises:

Type Description
AdminSiteError

When the page has no template.

post async

post(request: Request, form: dict[str, Any]) -> Response

Handle a form posted to the page, already checked for its token.

Parameters:

Name Type Description Default
request Request

The request being answered.

required
form dict[str, Any]

The submitted values, by input name.

required

Returns:

Type Description
Response

What to answer, such as a redirect back to the page. By default a 405, since a page takes no forms until it says so.

adminsite.Plugin

Something that adds to an admin, packaged so it can be shared.

A plugin gets the admin once, in setup, and adds what it brings with the same methods a project uses:

from pathlib import Path

HERE = Path(__file__).parent


class Reports(Plugin):
    name = "reports"

    def setup(self, admin: Admin) -> None:
        admin.add_template_dir(HERE / "templates")
        admin.add_static("reports", HERE / "static")
        admin.add_stylesheet("-/static/reports/reports.css")
        admin.add_page(SalesReport)
        admin.add_route("/-/reports/export", export_sales)


admin = Admin(engine, plugins=[Reports()])

name class-attribute instance-attribute

name: str = ''

A short name for the plugin, such as "reports".

setup

setup(admin: Admin) -> None

Add the plugin's views, pages, routes, templates and assets.

Parameters:

Name Type Description Default
admin Admin

The admin being built, whose add_ methods the plugin calls.

required

adminsite.Widget

One card on the overview page.

Subclass it for a card of your own: set template to a template in your template_dirs and return what it needs from load. The template gets widget and data.

title class-attribute instance-attribute

title: str = ''

The card's title.

width class-attribute instance-attribute

width: int = 1

How many of the four columns the card spans on a wide screen.

template class-attribute instance-attribute

template: str = ''

The template that draws the card, given widget and data.

allows async

allows(admin: Admin, request: Request) -> bool

Whether this user sees the card. Everyone does, unless you say.

Parameters:

Name Type Description Default
admin Admin

The admin the overview belongs to.

required
request Request

The request, with the signed in user on request.state.user.

required

Returns:

Type Description
bool

True to show the card.

load async

load(admin: Admin, request: Request) -> Any

Read what the card shows.

A card whose load fails says it could not be loaded, and the rest of the overview still loads.

Parameters:

Name Type Description Default
admin Admin

The admin the overview belongs to, whose database.session() opens a session.

required
request Request

The request being answered.

required

Returns:

Type Description
Any

What the template gets as data.

adminsite.Stat

Stat(
    title: str,
    value: Source,
    *,
    previous: Source | None = None,
    format: str = "{:,}",
    hint: str = "",
    link: str = "",
    width: int = 1,
)

Bases: Widget

A single number, such as revenue this month, with an optional change.

Stat(
    "Revenue this month",
    select(func.sum(Order.total)).where(Order.created_at >= month_start),
    previous=select(func.sum(Order.total)).where(
        Order.created_at >= last_month_start, Order.created_at < month_start
    ),
    format="€{:,.2f}",
)

Parameters:

Name Type Description Default
title str

The card's title.

required
value Source

Where the number comes from: a select of one value, or an async function given the session that answers with it.

required
previous Source | None

The same for the period before. The card then shows the change, such as "+12% on the period before", in green or red.

None
format str

How the number is written, as str.format takes it, such as "€{:,.2f}".

'{:,}'
hint str

A line under the number, when there is no change to show.

''
link str

Where clicking the card goes. A relative link starts at the admin, so "orders?status=PAID" opens the filtered list.

''
width int

How many of the four columns the card spans.

1

adminsite.Chart

Chart(
    title: str,
    rows: Source,
    *,
    kind: str = "bar",
    format: str = "{:,}",
    width: int = 2,
)

Bases: Widget

Bars or a line over labelled values, drawn as plain SVG.

The source gives rows of a label and a value, such as orders per day:

day = func.date(Order.created_at)
Chart(
    "Orders per day",
    select(day, func.count()).group_by(day).order_by(day),
    kind="line",
)

Hovering a bar shows its value, and screen readers get the same numbers as a table. Dates as labels are written short, such as "Sep 14".

Parameters:

Name Type Description Default
title str

The card's title.

required
rows Source

Where the rows come from: a select of a label and a value, or an async function given the session that answers with such rows.

required
kind str

"bar" or "line".

'bar'
format str

How each value is written, as str.format takes it.

'{:,}'
width int

How many of the four columns the card spans.

2

adminsite.RecentRecords

RecentRecords(
    title: str,
    view: str,
    *,
    sort: str = "",
    value: str = "",
    limit: int = 5,
    width: int = 2,
)

Bases: Widget

The latest records of a view, linking to each.

RecentRecords("Latest orders", "orders", sort="-created_at", value="total")

It reads through the view, so the view's permissions and scope apply. A field the view's can_access_field keeps from the user on the list is not shown as value, and not used as sort.

Parameters:

Name Type Description Default
title str

The card's title.

required
view str

The view's name, as in its URL, such as "orders".

required
sort str

The order, such as "-created_at". Left empty, the view's fields_default_sort.

''
value str

A path shown at the end of each record, such as "total".

''
limit int

How many records.

5
width int

How many of the four columns the card spans.

2

adminsite.ModelCounts

Bases: Widget

How many records each view holds: the overview when nothing else is set.

adminsite.messages.add_message

add_message(
    request: Request,
    text: str | Message,
    kind: str = "info",
) -> None

Leave a message for the page the user lands on next.

Parameters:

Name Type Description Default
request Request

The request being answered.

required
text str | Message

What to say, escaped unless given as Html, or a Message.

required
kind str

"error" draws it as a problem; anything else, such as the default "info", as good news.

'info'

Saved views

adminsite.SavedViews

SavedViews(
    source: Database | SessionSource | str = DEFAULT_URL,
    *,
    create_table: bool | None = None,
)

Bases: Store

Where people keep the lists they come back to.

Like the audit log, the views go to a SQLite file of their own by default. Pass your engine to keep them in your database; the table is then adminsite_saved_views on saved_view_metadata.

Parameters:

Name Type Description Default
source Database | SessionSource | str

Where the views are kept: a SQLite URL for a file of their own, or your engine or session factory.

DEFAULT_URL
create_table bool | None

Whether to create the table on first use. Left as None, only in the SQLite file adminsite owns, and never uninvited in your own database, where your migrations add it.

None

visible_to async

visible_to(view: str, owner: str | None) -> list[SavedView]

A person's own views of a list, and the ones others shared.

Parameters:

Name Type Description Default
view str

The name of the view whose list it is, such as "orders".

required
owner str | None

Who is asking, as AuthProvider.identity names them, or None without sign in.

required

Returns:

Type Description
list[SavedView]

The views, by name.

save async

save(saved: SavedView) -> SavedView

Keep a view, and return it with its id.

Parameters:

Name Type Description Default
saved SavedView

The view to keep.

required

Returns:

Type Description
SavedView

The same view, with the id it was stored under.

delete async

delete(key: int, owner: str | None) -> bool

Remove a view, if it belongs to this person.

Parameters:

Name Type Description Default
key int

The view's id.

required
owner str | None

Who is asking, as AuthProvider.identity names them, or None without sign in.

required

Returns:

Type Description
bool

Whether a view was removed.

Translations

adminsite.i18n.gettext

gettext(text: str, **values: Any) -> str

Translate a piece of English text into the current language.

A project's own translations win over the built in ones, English included, so a word of the admin's own can be changed without copying the template it is in. Text with no translation stays in English rather than disappearing.

Parameters:

Name Type Description Default
text str

The English text, with any values as named placeholders, such as "{thing} saved.".

required
**values Any

The values for the placeholders, filled in after the text is translated.

{}

Returns:

Type Description
str

The text in the language of the current request.

adminsite.i18n.ngettext

ngettext(
    singular: str, plural: str, number: int, **values: Any
) -> str

Translate a text with a count into the form its language writes for it.

English has two forms, "1 row" and "2 rows". Russian has three, and Arabic six, so a translation holds one text for each plural category its language uses, such as {"one": ..., "few": ..., "many": ...}, keyed by the English singular.

Parameters:

Name Type Description Default
singular str

The English text for one, such as "{count} row".

required
plural str

The English text for any other number, such as "{count} rows".

required
number int

The number that picks the form. It also fills {count}, its thousands grouped as the language groups them, unless count is given, as for a total that reads "about 1,200".

required
**values Any

The other placeholders.

{}

Returns:

Type Description
str

The text in the language of the current request.

Signing in

adminsite.auth.AuthProvider

Decides who may use the admin.

Subclass it and write verify. The session handling here is enough for most projects, and load_user is where you turn the key kept in the session back into whatever your application calls a user.

verify async

verify(username: str, password: str) -> Any | None

Return the user for these details, or nothing.

Parameters:

Name Type Description Default
username str

What was typed as the username.

required
password str

What was typed as the password.

required

Returns:

Type Description
Any | None

Whatever your application calls a user, or None when the details do not match.

Raises:

Type Description
SignInRefusedError

Instead of returning None, to have the audit log say why the attempt failed, such as SignInRefusedError("This account is switched off.", user=account).

load_user async

load_user(key: str) -> Any | None

Turn the key kept in the session back into a user.

The key itself by default. Override it to read the user from your own table, so a user switched off since is signed out at once.

Parameters:

Name Type Description Default
key str

What identity returned when the user signed in.

required

Returns:

Type Description
Any | None

The user, or None to sign them out.

grants_for async

grants_for(user: Any) -> Grants | None

What this user may do, view by view, or None to leave it to the views.

None by default, so each view answers from its own settings and its allows. A provider that keeps roles answers with the Grants they add up to, and each view and page then follows them, unless its own allows says otherwise. It runs on every request, once the user is loaded.

Parameters:

Name Type Description Default
user Any

The user, as load_user returned them.

required

Returns:

Type Description
Grants | None

The grants, or None.

change_password async

change_password(
    request: Request, current: str, new: str
) -> None

Change the signed in user's own password.

The account menu offers Change password only where a provider writes this, as AdminUsers does.

Parameters:

Name Type Description Default
request Request

The request, with the signed in user on request.state.user.

required
current str

The password they have now, typed again to prove it.

required
new str

The password they want, already typed twice alike.

required

Raises:

Type Description
RefusedError

When the current password is wrong or the new one will not do, with a message for the form. field="current" or field="new" puts it beside that box.

authenticate_token async

authenticate_token(token: str) -> Any | None

Return the user an API token belongs to, or nothing.

The JSON API calls this for Authorization: Bearer <token>. Nobody gets in this way until you write it, for example by looking the token up in a table of API keys.

Parameters:

Name Type Description Default
token str

The token, without "Bearer ".

required

Returns:

Type Description
Any | None

The user, or None to refuse the request.

identity

identity(user: Any) -> str

The key to keep in the session for this user.

The audit log keeps it too, as user_key, which does not change when the user's name does.

Parameters:

Name Type Description Default
user Any

What verify returned.

required

Returns:

Type Description
str

The key, such as the user's id as text. str(user) by default.

current_user async

current_user(request: Request) -> Any | None

Who is signed in, if anyone.

Parameters:

Name Type Description Default
request Request

The request being answered.

required

Returns:

Type Description
Any | None

The user, as load_user answers, or None.

sign_in async

sign_in(
    request: Request, username: str, password: str
) -> Any | None

Check the details and remember the user.

Parameters:

Name Type Description Default
request Request

The request being answered.

required
username str

What was typed as the username.

required
password str

What was typed as the password.

required

Returns:

Type Description
Any | None

The user, now kept in the session, or None when verify refused.

sign_in_note async

sign_in_note(request: Request) -> str

A line above the sign in form, such as how to make the first account.

Nothing by default. AdminUsers says how to make the first superuser while nobody can sign in.

Parameters:

Name Type Description Default
request Request

The request being answered.

required

Returns:

Type Description
str

The line, escaped unless given as Html, or "" for none.

sign_in_failed async

sign_in_failed(request: Request, username: str) -> str

Called when a sign in fails. Returns what to tell the person.

Override it to record the attempt, to make the next one wait, or to say something other than the default. Whatever it returns is shown above the form, so keep it vague: a message that says the username exists tells an attacker so too.

Parameters:

Name Type Description Default
request Request

The request being answered.

required
username str

What was typed as the username.

required

Returns:

Type Description
str

What to tell the person.

sign_in_values async

sign_in_values(request: Request) -> Mapping[str, str]

What the sign in form starts with, by input name.

Nothing by default. A public demo can fill in its shared username and password, so visitors only press Sign in. Never put real credentials here: anyone who opens the page can read them.

Parameters:

Name Type Description Default
request Request

The request being answered.

required

Returns:

Type Description
Mapping[str, str]

Starting values by input name: "username" and "password".

may_read_sign_ins async

may_read_sign_ins(
    request: Request, *, reads_everything: bool
) -> bool

Whether this person sees who signed in, on the Activity page.

Signing in belongs to no model, so no view's permissions decide it. By default only someone who may read the history of every record of every model sees it, which reads_everything says. Override it to let in, say, an auditor who reads less.

Parameters:

Name Type Description Default
request Request

The request being answered.

required
reads_everything bool

Whether this person may read the history of every view, with no scope_query leaving out any of its records.

required

Returns:

Type Description
bool

True to show them the sign ins.

sign_out async

sign_out(request: Request) -> None

Forget the user.

Parameters:

Name Type Description Default
request Request

The request being answered.

required

adminsite.Grants dataclass

Grants(
    permissions: Mapping[str, Collection[str]] = dict(),
    everything: bool = False,
)

What one person may do, view by view, as their roles add up.

An auth provider answers with these from AuthProvider.grants_for, and each view and page then follows them, unless its own allows says otherwise:

from adminsite import Grants

Grants({"orders": {"view", "edit", "refund"}, "-/sales_report": {"view"}})

A view is named as in its URL, such as "orders", and a page of your own by "-/" and its name. Any permission on a view lets the person see it, since there is nothing to do there otherwise.

Parameters:

Name Type Description Default
permissions Mapping[str, Collection[str]]

The permissions granted on each view and page, by name: Permission values, such as "edit", or the permission an action asks for.

dict()
everything bool

True to allow everything, as for a superuser.

False

permissions class-attribute instance-attribute

permissions: Mapping[str, Collection[str]] = field(
    default_factory=dict
)

The permissions granted on each view and page, by name.

everything class-attribute instance-attribute

everything: bool = False

Whether everything is allowed, as for a superuser.

allows

allows(name: str, permission: Permission | str) -> bool

Whether the view or page with this name grants the permission.

Parameters:

Name Type Description Default
name str

The view's name, such as "orders", or a page's, such as "-/sales_report".

required
permission Permission | str

What the person wants to do, such as Permission.EDIT.

required

Returns:

Type Description
bool

True when it is granted. Seeing the view, and opening a record's page, come with any permission on it.

adminsite.auth.PasswordAuth

PasswordAuth(users: Mapping[str, str])

Bases: AuthProvider

Signs in against a fixed set of usernames and password hashes.

from adminsite.auth import PasswordAuth, hash_password

PasswordAuth({"nima": hash_password("letmein")})

Passwords have to be hashed with hash_password, so a plain one never ends up in your settings or your repository. This suits a small internal tool. Anything larger should subclass AuthProvider and check its own user table.

Parameters:

Name Type Description Default
users Mapping[str, str]

Each username with its password hash.

required

Raises:

Type Description
AdminSiteError

When a password is not hashed.

verify async

verify(username: str, password: str) -> Any | None

Check the password against the stored hash.

Parameters:

Name Type Description Default
username str

What was typed as the username.

required
password str

What was typed as the password.

required

Returns:

Type Description
Any | None

The username, as the user.

Raises:

Type Description
SignInRefusedError

When there is no such username, or the password is wrong, so the audit log says which.

adminsite.users.AdminUsers

AdminUsers(*, create_table: bool = False)

Bases: AuthProvider, Plugin

Admin users and roles, kept in the admin's own database.

from adminsite import Admin
from adminsite.users import AdminUsers

admin = Admin(
    engine,
    views=[OrderView, CustomerView],
    auth=AdminUsers(),
    secret_key=settings.admin_secret_key,
)

Each person signs in with a username and a password, and may do what their roles grant, or everything as a superuser. The tables are adminsite_users, adminsite_roles and adminsite_user_roles, on user_metadata. adminsite never creates them in your database uninvited, so add user_metadata to your migrations, or pass create_table=True.

Parameters:

Name Type Description Default
create_table bool

True to create the tables on first use, for a quick start.

False

create_user async

create_user(
    username: str,
    password: str,
    *,
    superuser: bool = False,
    roles: Sequence[str] = (),
) -> User

Add someone who may sign in, such as the first superuser.

await users.create_user("lena", "a long password", roles=["Support"])

Parameters:

Name Type Description Default
username str

What they sign in with, kept in small letters.

required
password str

Their password, hashed before it is kept.

required
superuser bool

True to let them do everything, managing users and roles included.

False
roles Sequence[str]

The names of the roles they hold, each made already.

()

Returns:

Type Description
User

The user, saved.

Raises:

Type Description
AdminSiteError

When the username is empty or taken, the password is too short, or no role has one of the names.

create_role async

create_role(
    name: str,
    permissions: Mapping[str, Collection[str]],
    *,
    description: str | None = None,
) -> Role

Add a role, such as one a project starts with.

await users.create_role("Support", {"orders": ["view", "edit", "refund"]})

Parameters:

Name Type Description Default
name str

What the role is called, such as "Support".

required
permissions Mapping[str, Collection[str]]

What it grants, by view, as Role.permissions keeps it.

required
description str | None

What the role is for, in a sentence.

None

Returns:

Type Description
Role

The role, saved.

Raises:

Type Description
AdminSiteError

When the name is empty, or a role has it already.

adminsite.users.User

Bases: _Base

Someone who may sign in to the admin.

id class-attribute instance-attribute

id: Mapped[int] = mapped_column(primary_key=True)

The user's key, which the session and the audit log keep.

username class-attribute instance-attribute

username: Mapped[str] = mapped_column(
    String(150), unique=True
)

What they sign in with, kept in small letters.

password_hash class-attribute instance-attribute

password_hash: Mapped[str] = mapped_column(String(200))

Their password, as hash_password made it.

is_active class-attribute instance-attribute

is_active: Mapped[bool] = mapped_column(default=True)

Whether they may sign in. Switched off, they are signed out at once.

is_superuser class-attribute instance-attribute

is_superuser: Mapped[bool] = mapped_column(default=False)

Whether they may do everything, managing users and roles included.

last_sign_in_at class-attribute instance-attribute

last_sign_in_at: Mapped[datetime | None] = mapped_column(
    default=None
)

When they last signed in, or None if they never have.

created_at class-attribute instance-attribute

created_at: Mapped[datetime] = mapped_column(default=_now)

When the account was made.

roles class-attribute instance-attribute

roles: Mapped[list[Role]] = relationship(
    secondary=user_roles, back_populates="users"
)

The roles they hold.

has_role

has_role(name: str) -> bool

Whether they hold the role with this name.

if request.state.user.has_role("Support"):
    ...

Parameters:

Name Type Description Default
name str

The role's name, such as "Support".

required

Returns:

Type Description
bool

True when one of their roles is called that.

adminsite.users.Role

Bases: _Base

Permissions given together, such as everything a support agent needs.

A person may hold several roles, and may do whatever any of them grants.

id class-attribute instance-attribute

id: Mapped[int] = mapped_column(primary_key=True)

The role's key.

name class-attribute instance-attribute

name: Mapped[str] = mapped_column(String(100), unique=True)

What the role is called, such as "Support".

description class-attribute instance-attribute

description: Mapped[str | None] = mapped_column(
    Text, default=None
)

What the role is for, in a sentence.

permissions class-attribute instance-attribute

permissions: Mapped[dict[str, list[str]]] = mapped_column(
    JSON, default=dict
)

What the role grants, by view: {"orders": ["view", "edit"]}.

A page of your own is named by "-/" and its name, such as "-/sales_report".

users class-attribute instance-attribute

users: Mapped[list[User]] = relationship(
    secondary=user_roles, back_populates="roles"
)

The people who hold the role.

adminsite.auth.hash_password

hash_password(
    password: str, *, iterations: int = ITERATIONS
) -> str

Hash a password for storing.

from adminsite.auth import hash_password

hash_password("letmein")

Keep the result, not the password.

Parameters:

Name Type Description Default
password str

The password.

required
iterations int

How many rounds of PBKDF2 to run. More is slower to check, and slower to guess.

ITERATIONS

Returns:

Type Description
str

The hash, as pbkdf2_sha256$iterations$salt$hash.

adminsite.auth.verify_password

verify_password(password: str, stored: str) -> bool

Check a password against a stored hash, in constant time.

Parameters:

Name Type Description Default
password str

The password typed.

required
stored str

A hash hash_password made.

required

Returns:

Type Description
bool

Whether they match. A hash in any other form never does.

adminsite.auth.SignInRefusedError

SignInRefusedError(reason: str, user: object = None)

Bases: AdminSiteError

Raise this from AuthProvider.verify to refuse a sign in, saying why.

The reason goes to the audit log, never to the person signing in, who sees only what sign_in_failed returns. Pass the account when there is one, such as an inactive user, so the attempt is filed under them.

Parameters:

Name Type Description Default
reason str

Why the sign in was refused, for the audit log.

required
user object

The account the attempt was for, where there is one.

None

Audit log

adminsite.audit.AuditLog

AuditLog(
    source: Database | SessionSource | str = DEFAULT_URL,
    *,
    create_table: bool | None = None,
)

Bases: Store

Where the admin writes down who changed what.

By default the entries go to a SQLite file of their own, which needs no setup. Pass your engine to keep them in your own database instead; the table is then adminsite_audit_log on audit_metadata, which you add to your migrations.

Kept in the admin's own database, a change and its entries are saved in one transaction: both or neither. Kept anywhere else, entries are written once the change has committed, so a change that was rolled back never shows up, but one whose entries fail to write is not logged.

Parameters:

Name Type Description Default
source Database | SessionSource | str

Where the entries are kept: a SQLite URL for a file of their own, or your engine or session factory.

DEFAULT_URL
create_table bool | None

Whether to create the table on first use. Left as None, only in the SQLite file adminsite owns, and never uninvited in your own database, where your migrations add it.

None

find async

find(query: AuditQuery, *, limit: int) -> list[AuditEntry]

The entries that match, newest first, at most limit of them.

Parameters:

Name Type Description Default
query AuditQuery

Which entries to find.

required
limit int

The most entries to return.

required

Returns:

Type Description
list[AuditEntry]

The entries, newest first.

history async

history(
    view: str, record_key: str, *, limit: int = 100
) -> list[AuditEntry]

What happened to one record, newest first.

Parameters:

Name Type Description Default
view str

The view's name, such as "orders".

required
record_key str

The record's key, as the URLs write it.

required
limit int

The most entries to return.

100

Returns:

Type Description
list[AuditEntry]

The entries, newest first.

recent async

recent(
    *,
    view: str | None = None,
    views: Sequence[str] | None = None,
    limit: int = 100,
) -> list[AuditEntry]

The latest entries, across the admin, for some views or for one.

Parameters:

Name Type Description Default
view str | None

Only this view's entries.

None
views Sequence[str] | None

Only these views' entries.

None
limit int

The most entries to return.

100

Returns:

Type Description
list[AuditEntry]

The entries, newest first.

record async

record(entries: Sequence[AuditEntry]) -> None

Write entries down, a batch at a time.

Parameters:

Name Type Description Default
entries Sequence[AuditEntry]

The entries to write.

required

adminsite.audit.AuditStore

Bases: Protocol

Where the audit log is written and read back.

AuditLog is the one that ships. Write your own to keep the log in a table of your application, or to send it on elsewhere; the History tab and the Activity page read it through find either way.

A store that keeps its entries in the admin's own database can say so, and have each change and its entries saved in one transaction, with two more methods:

  • lives_in(database) -> bool: whether the entries are kept in that Database.
  • async record_within(session, entries): write the entries through that session, inside its transaction, without committing.

Without them, entries are written once the change has committed.

record async

record(entries: Sequence[AuditEntry]) -> None

Keep these entries.

Parameters:

Name Type Description Default
entries Sequence[AuditEntry]

The entries to keep, once the change has committed.

required

find async

find(query: AuditQuery, *, limit: int) -> list[AuditEntry]

The entries that match, newest first, at most limit of them.

Newest first means by occurred_at, then by id, both descending, which is also the order older_than pages through.

Parameters:

Name Type Description Default
query AuditQuery

Which entries to find.

required
limit int

The most entries to return.

required

Returns:

Type Description
list[AuditEntry]

The entries, newest first.

adminsite.audit.AuditQuery dataclass

AuditQuery(
    views: Sequence[str] | None = None,
    view: str | None = None,
    record_key: str | None = None,
    user: str | None = None,
    events: Sequence[AuditEvent] = (),
    since: datetime | None = None,
    until: datetime | None = None,
    older_than: tuple[datetime, int] | None = None,
)

Which entries to find. Every field narrows the result; empty means all.

views class-attribute instance-attribute

views: Sequence[str] | None = None

Only entries of these views, such as the ones a person may read.

An empty string among them stands for entries of no view, such as signing in.

view class-attribute instance-attribute

view: str | None = None

Only entries of this view.

record_key class-attribute instance-attribute

record_key: str | None = None

Only entries of this record, of view.

user class-attribute instance-attribute

user: str | None = None

Only one person's entries, matched against user_key and user.

events class-attribute instance-attribute

events: Sequence[AuditEvent] = ()

Only these kinds of entry.

since class-attribute instance-attribute

since: datetime | None = None

Only entries from this time on, in UTC.

until class-attribute instance-attribute

until: datetime | None = None

Only entries before this time, in UTC.

older_than class-attribute instance-attribute

older_than: tuple[datetime, int] | None = None

The (occurred_at, id) of the last entry shown, for the page after it.

adminsite.audit.AuditEntry dataclass

AuditEntry(
    view: str,
    record_key: str,
    event: AuditEvent,
    changes: Mapping[str, Change] = dict(),
    record_title: str | None = None,
    action: str | None = None,
    batch: str | None = None,
    user: str | None = None,
    message: str | None = None,
    occurred_at: datetime = (
        lambda: datetime.now(UTC).replace(tzinfo=None)
    )(),
    id: int | None = None,
    user_key: str | None = None,
    ip: str | None = None,
    user_agent: str | None = None,
    error: str | None = None,
    inputs: Mapping[str, Any] = dict(),
)

One thing that happened in the admin, who did it, and from where.

view instance-attribute

view: str

The name of the view it happened in, or empty for signing in.

record_key instance-attribute

record_key: str

The record's key, as the URLs write it, or empty for none.

event instance-attribute

event: AuditEvent

What happened, such as AuditEvent.UPDATED.

changes class-attribute instance-attribute

changes: Mapping[str, Change] = field(default_factory=dict)

Each field that changed, by name, as a pair of its values before and after.

record_title class-attribute instance-attribute

record_title: str | None = None

The record's name when it happened.

action class-attribute instance-attribute

action: str | None = None

The name of the action that ran, for an action.

batch class-attribute instance-attribute

batch: str | None = None

An id the entries of one bulk action share.

user class-attribute instance-attribute

user: str | None = None

The name the admin shows for the person.

message class-attribute instance-attribute

message: str | None = None

What an action answered.

occurred_at class-attribute instance-attribute

occurred_at: datetime = field(
    default_factory=lambda: datetime.now(UTC).replace(
        tzinfo=None
    )
)

When it happened, in UTC.

id class-attribute instance-attribute

id: int | None = None

The entry's id, once it is stored.

user_key class-attribute instance-attribute

user_key: str | None = None

What AuthProvider.identity returns for the person.

It does not change when their name does.

ip class-attribute instance-attribute

ip: str | None = None

The address the request came from.

user_agent class-attribute instance-attribute

user_agent: str | None = None

The browser or program that sent the request.

error class-attribute instance-attribute

error: str | None = None

Why it failed, or None when it worked.

inputs class-attribute instance-attribute

inputs: Mapping[str, Any] = field(default_factory=dict)

The values an action was run with, or an export's search and filters.

Secrets are kept as ***.

succeeded property

succeeded: bool

Whether what the entry describes worked.

as_row

as_row() -> dict[str, Any]

The entry as a row for the audit table.

Returns:

Type Description
dict[str, Any]

The row's values, by column name.

from_row classmethod

from_row(row: Mapping[Any, Any]) -> AuditEntry

Read an entry back from a row of the audit table.

Parameters:

Name Type Description Default
row Mapping[Any, Any]

The row, as the table's columns name its values.

required

Returns:

Type Description
AuditEntry

The entry.

Errors

adminsite.RefusedError

RefusedError(message: str, *, field: ColumnReference = '')

Bases: AdminSiteError

Raise this from a hook to refuse a save or a delete.

The message is shown to the user on the form or above the list, and the transaction is rolled back. Any other exception is a fault, and is reported as one.

Name a field and the message appears next to that input instead of above the form:

raise RefusedError("Keep this above the check delay.", field=Check.validation_delay)

The field is one of the view's own, not one of an inline's rows.

Parameters:

Name Type Description Default
message str

What to tell the user, such as "This customer's account is closed.".

required
field ColumnReference

The field to put the message beside, as a setting names it. Left empty, the message goes above the form.

''

adminsite.PermissionDeniedError

PermissionDeniedError(action: str, subject: str = '')

Bases: AdminSiteError

Raised when the current user may not do this.

The admin answers it with a "Not allowed" page and a 403, or a 403 in JSON from the API.

Parameters:

Name Type Description Default
action str

The permission refused, such as "edit".

required
subject str

What it was refused on, such as "Orders".

''

adminsite.RecordNotFoundError

RecordNotFoundError(model: type[object], key: object)

Bases: AdminSiteError

Raised when a key does not match any record.

Parameters:

Name Type Description Default
model type[object]

The model searched.

required
key object

The key that matched nothing.

required

adminsite.exceptions.IntegrityError

Bases: AdminSiteError

Raised when the database refuses a change.

A value that must be unique is already taken, a required one is missing, or other records still refer to the one being deleted. SessionAdapter.transaction() and commit() raise this in place of SQLAlchemy's own error, which stays attached as the cause. Its message is worded for people and holds none of the values. A save or a delete through the view turns it into a RefusedError, which the form or the list shows.

adminsite.exceptions.FieldValidationError

FieldValidationError(field_name: str, message: str)

Bases: AdminSiteError

Raised when a submitted value cannot be stored in a field.

A field's parse or to_python raises it, and the form shows the message under that field.

Parameters:

Name Type Description Default
field_name str

The path of the field, such as "total".

required
message str

What to tell the person, such as "Enter a whole number.".

required

adminsite.AdminSiteError

Bases: Exception

Base class for every error adminsite raises.