Bulk actions

The bar that appears once rows are ticked — declaring one, the transaction it runs in, and how to offer none.

Available since v0.1.0

Tick a row and a bar appears above the table. delete is built in and enabled by default; everything else is a method on the ModelAdmin.

@admin.register(Product, key="catalog.product")
class ProductAdmin(admin.ModelAdmin):
    actions = ("delete", "activate", "mark_discontinued")

    @admin.action("Activate", icon="check-circle")
    async def activate(self, adapter, objects):
        for product in objects:
            await adapter.update(product, {"is_active": True})
        return f"{len(objects)} products activated."

The decorator carries the label and the icon, so they cannot drift from the code that implements them — which is what naming methods in a list instead would allow.

@admin.action(label, *, icon=None, danger=False, confirm=None)

labelWhat the button says
iconA name from fastfort.ui.icons, checked at declaration time
dangerDrawn in the danger colour, and asks before running
confirmWhat the question says. {count} is substituted
@admin.action(
    "Mark discontinued",
    icon="trash",
    danger=True,
    confirm="Mark {count} products discontinued? They will stop appearing in the shop.",
)
async def mark_discontinued(self, adapter, objects):
    for product in objects:
        await adapter.update(
            product, {"availability": Availability.DISCONTINUED, "is_active": False}
        )
    return f"{len(objects)} products discontinued."

The signature

async def handler(self, adapter, objects) -> str: ...

adapter is the ModelAdapter for this model, scoped to this request’s unit of work. objects are the rows that were ticked, already loaded. The return value is the message shown afterwards.

It never commits

This is the part worth internalising. The adapter flushes; the request’s UnitOfWork commits on a clean exit and rolls back on an exception. So an action that fails on its fortieth row leaves the first thirty-nine unwritten too — which is almost always what you want from a bulk operation, and is not what a loop of individual saves gives you.

@admin.action("Recalculate totals")
async def recalculate(self, adapter, objects):
    for invoice in objects:
        # If this raises on invoice 40, invoices 1–39 are rolled back with it.
        await adapter.update(invoice, {"total": compute_total(invoice)})
    return f"{len(objects)} invoices recalculated."

Do not call commit() yourself. Doing so splits the request into two transactions and breaks that guarantee for everything after it.

Offering none

actions = ()

The bar never appears. This is how a model whose rows must never be removed in bulk says so — the per-row delete button is unaffected.

@admin.register(ApiToken, key="accounts.token")
class ApiTokenAdmin(admin.ModelAdmin):
    actions = ()

Reachability

A method that carries @admin.action but is left out of actions is not reachable by posting its name. The check is against the declared list, not against getattr — so commenting a name out of actions disables it rather than merely hiding the button.

The built-in delete

"delete" runs the same deletion plan the confirmation page shows, and checks it again immediately before the write — because a page may have been open for an hour and the graph may have changed under it.

If any selected row is protected, nothing is deleted and the page says which.

Errors

Raise, and the whole thing rolls back with the message shown to the operator:

from fastfort import ValidationError

@admin.action("Ship", icon="truck")
async def ship(self, adapter, objects):
    unpaid = [s for s in objects if s.customer and not s.customer.is_active]
    if unpaid:
        raise ValidationError(
            f"{len(unpaid)} shipments belong to inactive customers.",
            hint="Reactivate the customer, or deselect those rows.",
        )
    ...

ValidationError is rendered as a message on the page. Anything else, in production, is a generic failure with the traceback in your logs rather than on somebody’s screen.