Skip to content

Views

A ModelView says how one model appears in the admin. The model is its type argument, class attributes describe its pages, and methods whose names start with get_ answer per request.

The examples on this page use the example models.

from adminsite import Descending, Field, Link, ModelView
from adminsite.fields import TextAreaField


class OrderView(ModelView[Order]):
    group = "Sales"
    record_title = "Order #{id}"

    fields = [
        Order.id,
        Order.customer,
        Link(Order.customer, Customer.email),
        Order.status,
        Field(Order.total, read_only=True),
        TextAreaField(Order.note, exclude_from_list=True),
        Field(
            Order.created_at,
            hidden_in_list=True,
            exclude_from_create=True,
            exclude_from_edit=True,
        ),
    ]
    searchable_fields = [Order.id, Link(Order.customer, Customer.email)]
    sortable_fields = [Order.created_at, Order.total]
    fields_default_sort = [Descending(Order.created_at)]
    list_filters = [Order.status, Order.total, Order.created_at]
    page_size = 50

A view with no settings needs no class of its own: Admin(engine, views=[OrderView, ModelView[Tag]]).

ruff's RUF012

adminsite reads the settings once and never changes them, so RUF012, which asks for ClassVar on a list in a class body, can be switched off for the module that holds your views:

[tool.ruff.lint.per-file-ignores]
"app/admin.py" = ["RUF012"]

Name a column

A setting names a column by its attribute, Order.total, or by its name as a string, "total". Link(Order.customer, Customer.email) names a column of a related model, which a string writes as "customer.email". Links nest to go further: Link(OrderItem.order, Link(Order.customer, Customer.name)). Through a link to many records, such as Link(Customer.orders, Order.total), the column shows the value of every record it reaches.

A sort takes the same names, and Descending(Order.created_at), or the string "-created_at", sorts newest first.

A type checker catches a misspelt attribute. A misspelt string stops the admin when it starts, with a message that lists what the model has:

OrderView.fields: Order has no column or relationship "totl". Its columns: id, customer_id,
status, total, note, created_at. Its relationships: customer, items.

Choose the fields

fields lists the view's fields once, in order, and every page shows them in that order. An entry is a column, or a field with options: Field(Order.created_at, label="Placed") keeps the field adminsite picks for the column, and TextAreaField(Order.note) picks another kind. What the options leave out comes from the column, such as its label and its length. See Fields.

With fields empty, the view shows every column, and a foreign key such as customer_id shows as its relationship, customer.

A relationship such as Order.customer shows the linked record's name in the list and a picker in the form. Forms leave out what nobody types: a related model's column, a computed field, and a key the database fills in. A key people choose, such as a product code, is on the form for a new record and read only after that.

Leave a field off one page

A flag on the field or a list on the view leaves a field off one page. Both mean the same:

Flag on a field List on the view Leaves the field off
exclude_from_list exclude_fields_from_list the list
exclude_from_detail exclude_fields_from_detail the record page
exclude_from_create exclude_fields_from_create the form for a new record
exclude_from_edit exclude_fields_from_edit the form for an existing record
exclude_from_export exclude_fields_from_export the CSV export
class ProductView(ModelView[Product]):
    fields = [Product.name, Product.price, Product.cost, Product.description]
    exclude_fields_from_list = [Product.description]
    exclude_fields_from_export = [Product.cost]

Name the view and its records

name is the view's part of the URL. label and label_plural are what its records are called in headings, buttons and the sidebar, and group is the sidebar section it sits under. Each one left out comes from the model, as ModelView says.

record_title names a record wherever it appears, other models' pages included, such as "{name} ({email})". The names in braces are the model's columns. Without a record_title, a record is named by its model's __str__, or else by the view's label and its key, as in Order #12.

For a name no template can build, write get_record_title. It gets the record with its columns loaded, but not its links. The record comes by position, so the parameter can be named after the model:

class SupplierView(ModelView[Supplier]):
    def get_record_title(self, supplier: Supplier, /) -> str:
        if supplier.closed_at is not None:
            return f"{supplier.name} (closed)"
        return supplier.name

The list

ModelView lists every setting of the list, and Filters covers list_filters. The sections below show the rest at work.

searchable_fields lists the columns the search box looks in, and it finds the term anywhere inside them. On a table of millions that reads every row, so search_condition can return a condition of your own:

import re

from sqlalchemy import ColumnElement
from starlette.requests import Request


class ContactView(ModelView[Contact]):
    searchable_fields = [Contact.phone, Contact.name]

    def search_condition(
        self, term: str, *, request: Request
    ) -> ColumnElement[bool] | None:
        digits = re.sub(r"\D", "", term)
        if len(digits) >= 6:
            # "+1 (555) 0100" finds 15550100, through the index on phone.
            return Contact.phone == digits
        return None

Everything that searches the view uses the condition. Return None for the usual search, as above for a name.

Keep records off the list

list_query narrows the list without hiding anything, for records such as deleted or closed ones:

from adminsite import Statement
from starlette.requests import Request


class SupplierView(ModelView[Supplier]):
    def list_query(self, statement: Statement, *, request: Request) -> Statement:
        return statement.where(Supplier.closed_at.is_(None))

Closed suppliers are left out of the list, its count and filters, the export, "select all matching", the command palette and the pickers on other forms. A product still names its closed supplier and links to its page, and a form that holds one keeps it. To hide records from a user altogether, use scope_query, as Permissions shows.

Choose the columns

The Columns menu above the list hides and shows columns. A field with hidden_in_list=True starts hidden:

class OrderView(ModelView[Order]):
    fields = [
        Order.id,
        Order.customer,
        Order.status,
        Order.total,
        Field(Order.note, hidden_in_list=True),
        Field(Order.created_at, hidden_in_list=True),
    ]

The choice is kept in the URL and in the session, and the CSV export follows it.

Edit values in the list

A value that changes often, such as an order's status, can be changed straight from the list. Name its field in inline_editable_fields:

class OrderView(ModelView[Order]):
    fields = [Order.id, Order.customer, Order.status, Order.total, Order.note]
    inline_editable_fields = [Order.status, Order.note]

The cell opens the field's input from the edit form, and the save runs the same hooks and audit log. A cell can be changed only where the edit form would let this user change it. The save holds that one value, so Order.note in context.values tells a hook whether the note changed.

Rows per page

page_size sets how many rows a page holds, and page_size_options adds a menu to change it:

class OrderView(ModelView[Order]):
    page_size = 25
    page_size_options = [25, 100, 500]

Refresh the list on its own

A view whose records come in while people watch, such as new orders, can read its rows again every so often:

class OrderView(ModelView[Order]):
    list_refresh_seconds = 30

New and changed records show up without a reload. The list waits while someone is working in it, and a list in a tab nobody is looking at asks the database nothing.

Saved views

A saved view keeps a search, filters, a sort and columns under a name, such as "Unpaid this month". Switch them on for the admin:

admin = Admin(engine, views=[OrderView], saved_views=True)

The list gets a Saved views menu. A saved view belongs to the person who saved it, who can share it with everyone.

The views go to a SQLite file of their own, adminsite_views.db, as the audit log does. To keep them in your database instead:

from adminsite import SavedViews
from adminsite.saved_views import saved_view_metadata

admin = Admin(engine, saved_views=SavedViews(engine))

# In your Alembic env.py, so the table is part of your migrations:
target_metadata = [Base.metadata, saved_view_metadata]

The command palette

Ctrl+K, or Cmd+K on a Mac, jumps to any page or record. It searches the searchable_fields of each view the user may open. Leave a view out, such as a very large table, with global_search = False.

Large tables

Counting every match and reaching deep pages both get slow on a table of millions. Each has a setting:

from adminsite import CountMode, Descending, ModelView, Pagination


class EventView(ModelView[Event]):
    fields_default_sort = [Descending(Event.created_at)]
    count_mode = CountMode.ESTIMATED
    pagination = Pagination.KEYSET

CountMode.ESTIMATED shows the row count that Postgres and MySQL keep in their statistics, such as "about 2,500,000". A search or a filter is counted exactly once the rows are on screen, so put an index on the columns people filter by. SQLite counts exactly. CountMode.NONE shows no total. Either one leaves the counts off the filters' options, as Filters says.

Pagination.KEYSET goes on from the last row seen instead of skipping rows, so page 400 costs the same as page 1. The pager then shows Previous and Next instead of page numbers. A sort by a column that can be empty, or by a related model's column, falls back to page numbers. Put an index on the columns you sort by, primary key last, such as (created_at, id).

A large column the list does not show is still loaded on every row, unless deferred_fields names it:

class EventView(ModelView[Event]):
    fields = [Event.id, Event.kind, Event.created_at, Event.payload]
    exclude_fields_from_list = [Event.payload]
    deferred_fields = [Event.payload]

The record page, the form and the API still load it.

Forms

The forms show the view's fields in order. read_only=True shows a value nobody can change, and get_readonly_fields(request, record) locks more for one user or one record. A read-only value is never taken from the form, even if someone adds the input back. can_create, can_edit and can_delete switch pages off, as Permissions shows.

The record page

The record page shows every field with its label. Leave a field off the forms when nobody should send it back:

class UserView(ModelView[User]):
    fields = [User.name, User.email, User.is_active, User.signed_up_at, User.invoices]
    exclude_fields_from_create = [User.signed_up_at, User.invoices]
    exclude_fields_from_edit = [User.signed_up_at, User.invoices]

A link to many records, such as invoices, names the first 20 and says how many more there are.

Arrange the fields

form_layout puts the fields of a long form into groups, and the record page follows the same layout:

from adminsite import PanelWidget, TabsWidget


class ProductView(ModelView[Product]):
    fields = [
        Product.name,
        Product.slug,
        Product.description,
        Product.price,
        Product.cost,
        Product.photo,
        Product.datasheet,
    ]
    form_layout = [
        PanelWidget(
            "Product",
            [(Product.name, Product.slug), Product.description],
            description="What customers see in the shop.",
        ),
        PanelWidget("Price", [(Product.price, Product.cost)]),
        TabsWidget([("Photo", [Product.photo]), ("Datasheet", [Product.datasheet])]),
    ]
Entry What it draws
A field The field, on a line of its own.
PanelWidget A card with a title.
FieldsetWidget A group with a caption, inside a card.
RowWidget, or a tuple Fields side by side, and one under the other on a phone.
A list Fields one under the other, such as a column inside a row.
TabsWidget One tab at a time.

A field the layout leaves out comes last.

An order and its lines belong together, so edit them on one page. Name the relationship in inlines:

from adminsite import Inline


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

The lines show as a table under the order's fields, where rows can be added and removed. Removing a row deletes that line. The order and its lines are saved in one transaction, so a line that fails keeps the whole order unsaved.

Inline lists the other options. get_inlines(request, record) picks a different set per request, and Permissions covers the fields of the rows.

Turn off the record page or the export

class ApiKeyView(ModelView[ApiKey]):
    can_view_detail = False
    can_export = False

Without a record page, a row opens the form instead. Both go through allows, so they can be decided per user too.

The sidebar

icon sets the view's icon in the sidebar, as inline SVG markup or the address of a picture. The markup goes into the page as it is, so use only icons you trust. in_sidebar = False leaves a view out of the sidebar, for records people only open from others, such as an invoice's payments:

class OrderView(ModelView[Order]):
    icon = '<svg viewBox="0 0 16 16"><path d="M2 4h12v9H2z" fill="currentColor"/></svg>'


class PaymentView(ModelView[Payment]):
    in_sidebar = False

The pages of a view left out of the sidebar still open, and links from other records still lead to them.

From a laptop up, the button beside the admin's name folds the sidebar to its icons, which gives the page 176px more room, and each person's choice is kept. Give each view its own icon, so the folded sidebar tells them apart.

Answer per request

Every get_ method receives the request, so the answer can depend on the user, and can_access_field decides who sees which field on which page:

from starlette.requests import Request

from adminsite import BaseField, ColumnReference, ModelView, RequestAction


class OrderView(ModelView[Order]):
    fields = [Order.id, Order.customer, Order.status, Order.total]

    def can_access_field(
        self, request: Request, field: BaseField, action: RequestAction
    ) -> bool:
        return field.name != "total" or request.state.user.is_manager

    def get_readonly_fields(
        self, request: Request, record: Order | None
    ) -> list[ColumnReference]:
        if record is not None and record.status == "shipped":
            return [Order.customer, Order.status]
        return []

The methods are get_searchable_fields, get_list_filters, get_fields_default_sort, get_deferred_fields, get_readonly_fields, get_inlines and get_actions. Each answers with what its setting takes. Permissions says where a refused field is left out.

Several views of one model

A model can have as many views as you like, each with its own name:

class ShippedOrders(ModelView[Order]):
    name = "shipped_orders"
    label_plural = "Shipped orders"

    def scope_query(self, statement: Statement, *, request: Request) -> Statement:
        return statement.where(Order.status == OrderStatus.SHIPPED)

scope_query narrows every read the view makes, as Permissions shows.

A link to a customer opens the first view registered for Customer. Name another with view:

class WalletView(ModelView[Wallet]):
    fields = [Wallet.id, RelationField(Wallet.owner, view=BuyerView), Wallet.balance]

The owner then opens in BuyerView, and the picker lists buyers. view also takes the name in a view's URL, such as "buyers".