Skip to content

Actions

An action is a button that runs over the rows the user ticked. Mark a method with @action:

from adminsite.actions import Selection, action


class OrderView(ModelView, model=Order):
    @action("Mark as shipped", confirm="Mark the chosen orders as shipped?")
    async def ship(self, selection: Selection) -> str:
        changed = await selection.update(status=OrderStatus.SHIPPED)
        return f"{changed} orders marked as shipped."

Ticking rows shows a bar with the view's actions. What the method returns is shown to the user once it has run.

Every row that matches

Ticking every row on the page offers "Select all 12,408 matching". The selection then covers every row the current search and filters match, not only the page.

A Selection is a query, not a list of records, so an action over twelve thousand rows is still one statement. It gives you:

Method What it does
await selection.update(**values) Changes every covered row in one UPDATE, and returns how many.
await selection.delete() Deletes every covered row in one DELETE, and returns how many.
await selection.count() How many rows it covers.
await selection.records() Loads the records, for work that needs each one.
selection.statement() A select() of the covered primary keys, to use in your own queries.

update and delete never load the records, so they skip the save hooks, and a delete relies on the database for cascades. Give a child table's foreign key ondelete="CASCADE" where children should go with their parent. Use records() when the hooks matter.

Asking for values first

An action can ask for values before it runs. They appear in a dialog, are checked like form fields, and reach the method by name:

from adminsite.fields import ChoiceField, StringField

CARRIERS = (("dhl", "DHL Express"), ("ups", "UPS"), ("postnl", "PostNL"))


class OrderView(ModelView, model=Order):
    @action(
        "Mark as shipped",
        confirm="Mark the chosen orders as shipped?",
        inputs=[
            ChoiceField("carrier", choices=CARRIERS, required=True),
            StringField("tracking", label="Tracking number", max_length=40),
        ],
    )
    async def ship(
        self, selection: Selection, carrier: str, tracking: str | None
    ) -> str:
        changed = await selection.update(
            status=OrderStatus.SHIPPED, carrier=carrier, tracking=tracking
        )
        return f"{changed} orders sent with {carrier}."

A missing or invalid value stops the action and tells the user which field and why. The names keys, everything and _csrf are taken by the action form itself and cannot be used.

Where the dialog starts

default says what an input holds when the dialog opens, so switches that are usually on open on and nobody has to set them every time. multiple=True on a ChoiceField lets one input hold several options, and the method receives a list:

class OrderView(ModelView, model=Order):
    @action(
        "Download",
        inputs=[
            ChoiceField("kinds", choices=KINDS, multiple=True, default=("paper",)),
            BooleanField("with_totals", label="With totals", default=True),
        ],
    )
    async def download(
        self, selection: Selection, kinds: list[str], with_totals: bool
    ) -> Response: ...

default works on any field, and a form for a new record starts from it too. A stored value always wins over it, so it never overwrites anything.

With the audit log on, the values an action was run with are written down with it. A secret is kept as ***: an input named like password or api_key, or one given secret=True.

Choices worked out per request

The inputs are read when the page is drawn, so get_actions can hand back an action carrying whatever this user may pick:

import dataclasses


class OrderView(ModelView, model=Order):
    @action("Move", inputs=[ChoiceField("warehouse", choices=())])
    async def move(self, selection: Selection, warehouse: str) -> str: ...

    def get_actions(self, request=None):
        choices = warehouses_for(request.user)
        return tuple(
            dataclasses.replace(
                item, inputs=(ChoiceField("warehouse", choices=choices),)
            )
            if item.name == "move"
            else item
            for item in super().get_actions(request)
        )

The run goes through get_actions as well, so a value that was never on offer is refused rather than accepted because the class said so.

Options

Option What it does
label The button text. Defaults to the method name, mark_paid reading "Mark paid".
confirm A question asked in a dialog before it runs.
inputs Fields to ask for, each with an optional default. See above.
dangerous Draws the button in red.
permission What the user needs to run it. Permission.EDIT unless you say otherwise.
name The name in the URL, if the method name will not do.

Refusing

Raise RefusedError to stop an action with a message. Everything it did is rolled back:

from adminsite import RefusedError


@action("Refund")
async def refund(self, selection: Selection) -> str:
    if await selection.count() > 50:
        raise RefusedError("Refund at most 50 orders at a time.")
    ...

An action that breaks a database constraint, for example deleting customers that orders still point at, is rolled back and reported the same way.

Exporting

Every list has an "Export CSV" button. It exports every row the search and filters match, not just the page, using the values the list shows, and streams them in batches, so a large export never has to fit in memory.

What an action acts on

on says what an action is about, and what its method is given:

on Where it appears The method gets
"selection", the default above the list, once rows are ticked a Selection
"record" on each row, and on the record's page (record, session)
"view" above the list, with nothing ticked (session)

On one record

class InvoiceView(ModelView, model=Invoice):
    @action("Confirm", on="record", confirm="Confirm this invoice?")
    async def confirm(self, record: Invoice, session: SessionAdapter) -> str:
        record.status = "confirmed"
        return f"Invoice {record.number} confirmed."

The button sits on the row and on the record's page, and the record's own permission decides whether it is offered: allows(action, record=record) refusing a paid invoice hides Confirm for that row and refuses the request if someone posts it anyway. A view with several record actions collects them into a menu on the row. Afterwards the person lands back on the record.

Everything a selection action has is here too: a confirmation, inputs asked in a dialog, a dangerous style, and an entry in the record's history.

On the whole view

Some work is about the table, not about any row: fetching from another system, importing from an API, sending a summary. Those need nothing ticked:

class OrderView(ModelView, model=Order):
    @action("Sync from the provider", on="view", permission=Permission.VIEW)
    async def sync(self, session: SessionAdapter) -> str:
        return f"{await fetch_new_orders(session)} orders fetched."

Answering with a file

An action can return a response instead of a message, which is how a download works:

@action("Download as CSV", on="record", permission=Permission.EXPORT)
async def download(self, record: Order, session: SessionAdapter) -> Response:
    return Response(
        render_csv(record),
        media_type="text/csv",
        headers={
            "content-disposition": f'attachment; filename="order-{record.id}.csv"'
        },
    )

Anything Starlette can answer with works: a file, JSON, or a redirect to somewhere the result waits. The transaction is committed first, so the answer describes work that really happened.