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 | type[ModelView]] = (),
inspector: SQLAlchemyInspector | None = None,
fields: 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: Mapping[str, Mapping[str, str]]
| None = None,
)
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)
add_view ¶
add_view(view: ModelView | type[ModelView]) -> ModelView
Register a model with the admin.
add_page ¶
add_page(page: AdminPage | type[AdminPage]) -> AdminPage
Add a page of your own, served at /-/ and its name.
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.
add_static ¶
add_static(name: str, directory: str | Path) -> None
Serve a folder of files at /-/static/ and the name.
add_template_dir ¶
add_template_dir(directory: str | Path) -> None
Look for templates in one more folder, before the built in ones.
add_stylesheet ¶
add_stylesheet(href: str) -> None
Load a stylesheet on every page. A relative path starts at the admin.
add_script ¶
add_script(src: str) -> None
Load a script on every page. A relative path starts at the admin.
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.
Views¶
adminsite.ModelView ¶
ModelView(
inspector: SQLAlchemyInspector | None = None,
registry: FieldRegistry | None = None,
)
How one model appears in the admin.
Set the class attributes to describe the list and the form. Override
the get_ methods when the answer depends on who is asking.
get_list_display ¶
get_list_display(request: Any = None) -> tuple[str, ...]
The columns the list shows.
get_search_fields ¶
get_search_fields(request: Any = None) -> tuple[str, ...]
The paths the search box looks in.
get_filters ¶
get_filters(request: Any = None) -> tuple[SQLFilter, ...]
The filters offered beside the list.
get_form_fields ¶
get_form_fields(
request: Any = None, record: Any = None
) -> tuple[str, ...]
The fields the form shows, in order.
get_readonly_fields ¶
get_readonly_fields(
request: Any = None, record: Any = None
) -> tuple[str, ...]
The fields shown but not editable, named here or by themselves.
A primary key is readonly by its nature, but a form that names one means to set it, so a key stays editable unless it is named here.
get_actions ¶
get_actions(request: Any = None) -> tuple[Action, ...]
The actions this view offers, in the order they appear.
get_inlines ¶
get_inlines(
request: Any = None, record: Any = None
) -> tuple[Inline, ...]
The child records edited inside the form.
get_column_choices ¶
get_column_choices(request: Any = None) -> tuple[str, ...]
The columns the picker offers: the list's own, then the extras.
allows
async
¶
allows(
action: Permission | str,
*,
request: Any = None,
record: Any = None,
) -> bool
Whether the current user may do this, to this record.
scope_query ¶
scope_query(
statement: Select[Any], *, request: Any = None
) -> Select[Any]
Narrow every read to the rows this user may see.
This runs on the list, the count, a single record, an export and a bulk action, so a row can never leak through a path that forgot to check.
before_save
async
¶
before_save(context: SaveContext) -> None
Runs before the values are written.
Change context.values, or context.set("slug", ...), to store
something other than what was submitted. Raise RefusedError to
refuse the save, naming a field to put the message beside it.
after_save
async
¶
after_save(context: SaveContext) -> None
Runs after the flush, while the transaction is still open.
before_delete
async
¶
before_delete(context: DeleteContext) -> None
Runs before a record is deleted. Raise to refuse the delete.
after_delete
async
¶
after_delete(context: DeleteContext) -> None
Runs after the delete, while the transaction is still open.
adminsite.Inline
dataclass
¶
Inline(
name: str,
fields: Sequence[str] = (),
readonly_fields: Sequence[str] = (),
label: str = "",
extra: int = 1,
can_delete: bool = True,
display_template: str = "",
)
Child records edited inside their parent's form, such as order lines.
class OrderView(ModelView, model=Order):
inlines = (Inline("items", fields=("product", "quantity", "unit_price")),)
The name is a relationship on the parent that holds many records. Each child shows as a row of inputs, with a box to delete it, and new rows can be added in the form.
input_name ¶
input_name(index: int | str, path: str) -> str
The name a child's input carries in the submitted form.
adminsite.CountMode ¶
Bases: StrEnum
How hard to work to report how many records match.
adminsite.Pagination ¶
Bases: StrEnum
How the list moves from one page to the next.
adminsite.views.writing.SaveContext
dataclass
¶
SaveContext(
session: SessionAdapter,
record: Any,
values: dict[str, Any],
created: bool,
request: Any = None,
)
What a save hook is given. Everything here is inside one transaction.
In before_save the values have not been written onto the record yet,
so that is where to change them: context.values is the dictionary
that is about to be applied, and set writes into it. Setting an
attribute on context.record there would be overwritten a moment
later by the value from the form.
adminsite.views.writing.DeleteContext
dataclass
¶
DeleteContext(
session: SessionAdapter,
record: Any,
request: Any = None,
)
What a delete hook is given, inside the same transaction.
Actions¶
adminsite.actions.action.action ¶
action(
label: str = "",
*,
name: str = "",
confirm: str = "",
permission: str = Permission.EDIT,
dangerous: bool = False,
inputs: Sequence[Field] = (),
on: str = ON_SELECTION,
) -> Callable[[Handler], Handler]
Mark a method as an action.
@action(
"Mark as shipped",
confirm="Mark the chosen orders as shipped?",
inputs=[ChoiceField("carrier", choices=CARRIERS, required=True)],
)
async def ship(self, selection: Selection, carrier: str) -> str:
changed = await selection.update(status="shipped", carrier=carrier)
return f"{changed} orders sent with {carrier}."
Each input is asked for in a dialog before the action runs, checked like a form field, and passed to the method by name.
on says what it acts on, and what the method is given:
"selection", the default: the rows the user ticked, as aSelection."record": one record, from its row in the list or from its page, as(record, session)."view": nothing in particular, as(session). For work about the whole table, such as fetching from another system.
A method returns the message to show, or a response to send instead, such as a file to download.
adminsite.actions.Selection
dataclass
¶
Selection(
view: ModelView,
session: SessionAdapter,
spec: QuerySpec,
keys: Sequence[str] = (),
everything: bool = False,
request: Any = None,
changes: dict[str, dict[str, Change]] = dict(),
)
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.
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.
covered_keys
async
¶
covered_keys() -> list[str]
The keys of the records this covers, written as the URLs write them.
Filters¶
adminsite.backends.sqlalchemy.SQLFilter ¶
SQLFilter(
name: str,
*,
path: str | None = None,
label: str | None = None,
)
Bases: Filter
A filter that narrows a SQLAlchemy statement.
Write a custom filter by subclassing this and returning a condition.
Override apply instead when the filter needs to change the statement
itself, for example to add a join.
condition ¶
condition(
value: FilterValue, repository: SQLAlchemyRepository
) -> ColumnElement[bool] | None
The condition this filter adds, or nothing to leave the list be.
apply ¶
apply(
statement: Select[Any],
value: FilterValue,
repository: SQLAlchemyRepository,
) -> Select[Any]
Narrow the statement with this filter's condition.
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.
adminsite.filters.FilterValue
dataclass
¶
FilterValue(name: str, values: tuple[str, ...])
What the user picked for one filter.
Fields¶
adminsite.FieldOptions ¶
FieldOptions(name: str, **changes: Any)
Changes to the field adminsite worked out for a path.
Put it in a view's fields where only a label, a line of help or a
length changes and the field itself is already right:
class ProductView(ModelView, model=Product):
fields = (
FieldOptions("name", label="Product name"),
FieldOptions("description", help_text="Shown on the shop page."),
)
It takes whatever the field takes, so a link can be given a
display_template without naming its target again.
adminsite.Computed ¶
Computed(
name: str,
getter: Callable[[Any], Any],
*,
needs: Sequence[str] = (),
**options: Any,
)
Bases: Field
A value the view works out from a record, rather than a column.
class ProductView(ModelView, model=Product):
list_display = ("name", "capacity")
fields = (
Computed(
"capacity",
lambda product: f"{len(product.slots)}/{product.limit}",
label="Capacity",
needs=("slots",),
),
)
It shows in the list, on the record page and in the export, and is
never written, sorted or filtered. needs names the paths the function
reads, so they are loaded with the page instead of one query per row.
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:
Computed(
"tracking",
lambda order: Html('<a href="{}">Track</a>').format(order.tracking_url),
needs=("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 ¶
JSONField(
name: str,
*,
label: str | None = None,
required: bool = False,
readonly: bool = False,
help_text: str = "",
max_length: int | None = None,
default: Any = None,
format: str | None = None,
secret: bool | None = None,
)
Bases: Field
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 box, pretty printed, and a malformed one comes back as an error on the field with the text still there.
serialize ¶
serialize(value: Any) -> str
The document laid out over several lines, for the box.
Text goes back as it was typed, so a document that failed to parse comes back to its writer exactly as they left it.
adminsite.fields.FileField ¶
FileField(
name: str,
*,
storage: FileStorage,
accept: str = "",
max_size: int = 10 * MEGABYTE,
**options: Any,
)
Bases: Field
An uploaded file, kept in a storage, its key kept in a string column.
FileField("invoice", storage=LocalStorage("uploads"), accept=".pdf")
parse_upload ¶
parse_upload(
raw: Any, *, remove: bool, has_file: bool
) -> Any
Read a file input: a new file, None to remove, or UNCHANGED.
has_file says whether the record already holds a file, since a
required field is satisfied by the one already stored.
accepts ¶
accepts(upload: UploadFile) -> bool
Whether the upload matches accept, as a browser reads it.
adminsite.fields.ImageField ¶
ImageField(
name: str,
*,
storage: FileStorage,
accept: str = "image/png,image/jpeg,image/gif,image/webp",
max_size: int = 5 * MEGABYTE,
**options: Any,
)
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.
check ¶
check(upload: UploadFile) -> None
Refuse anything that is not really one of the image types.
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.
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.
Pages, plugins and the dashboard¶
adminsite.AdminPage ¶
AdminPage()
A page of your own inside the admin, such as a report or a settings form.
class SalesReport(AdminPage):
label = "Sales report"
group = "Reports"
template = "reports/sales.html"
async def get_context(self, request):
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.
allows
async
¶
allows(request: Request) -> bool
Whether this user may open the page. Everyone may, unless you say.
get_context
async
¶
get_context(request: Request) -> dict[str, Any]
The values the template needs, on top of what every page has.
get
async
¶
get(request: Request) -> Response
Show the page. Override it to answer with anything else.
post
async
¶
post(request: Request, form: dict[str, Any]) -> Response
Handle a form posted to the page, already checked for its token.
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:
class Reports(Plugin):
name = "reports"
def setup(self, admin):
admin.add_template_dir(Path(__file__).parent / "templates")
admin.add_static("reports", Path(__file__).parent / "static")
admin.add_stylesheet("-/static/reports/reports.css")
admin.add_page(SalesReport)
admin.add_route("/-/reports/export", export_sales)
admin = Admin(engine, plugins=[Reports()])
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.
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(...last month...),
format="€{:,.2f}",
)
load
async
¶
load(admin: Admin, request: Request) -> StatData
Read the number, and the one it is compared with.
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",
)
adminsite.RecentRecords ¶
RecentRecords(
title: str,
view: str,
*,
sort: str = "",
detail: 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", detail="total")
It reads through the view, so the view's permissions and scope apply.
adminsite.ModelCounts ¶
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.
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, and text with no translation stays in English rather than disappearing.
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.
To have the audit log say why an attempt failed, raise
SignInRefused("This account is switched off.", user=account)
instead of returning nothing.
load_user
async
¶
load_user(key: str) -> Any | None
Turn the key kept in the session back into a user.
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.
sign_in
async
¶
sign_in(
request: Request, username: str, password: str
) -> Any | None
Check the details and remember the user.
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.
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 model
sees it, which reads_everything says. Override it to let in, say,
an auditor who reads less.
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.
verify
async
¶
verify(username: str, password: str) -> Any | None
Check the password against the stored hash.
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. The format is
pbkdf2_sha256$iterations$salt$hash.
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.
Entries are written after the change they describe has committed, so a change that was rolled back never shows up in the history.
find
async
¶
find(query: AuditQuery, *, limit: int) -> list[AuditEntry]
The entries that match, newest first, at most limit of them.
history
async
¶
history(
view: str, record_key: str, *, limit: int = 100
) -> list[AuditEntry]
What happened to one record, 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.
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.
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.
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: 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.viewandrecord_key: one view, or one record of it.user: one person, matched againstuser_keyand againstuser.events: only these kinds of entry.sinceanduntil: fromsince, up to but not includinguntil.older_than: the(occurred_at, id)of the last entry already shown, to find the next 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.
user is the name the admin shows for the person, and user_key what
AuthProvider.identity returns for them, which does not change when the
name does. ip and user_agent say where the request came from.
error holds why something failed; it is empty when it worked.
inputs holds the values an action was run with, secrets masked.
Errors¶
adminsite.RefusedError ¶
RefusedError(message: str, *, field: str = '')
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="delay")
adminsite.PermissionDeniedError ¶
PermissionDeniedError(action: str, subject: str = '')
Bases: AdminSiteError
Raised when the current user may not do this.
adminsite.RecordNotFoundError ¶
RecordNotFoundError(model: type[object], key: object)
Bases: AdminSiteError
Raised when a key does not match any record.