Skip to content

Hooks

Hooks run your code around a save or a delete. Most run inside the same transaction, so a business rule can read and write other tables, and refuse the change. Two run once the change is committed, for work such as an email that must only go out for a change that was kept.

The examples on this page use the example models.

from adminsite import DeleteContext, ModelView, RefusedError, SaveContext


class OrderView(ModelView[Order]):
    async def before_save(self, context: SaveContext[Order]) -> None:
        if context.created:
            customer = context.values[Order.customer].get()
            if not customer.is_active:
                raise RefusedError(
                    "This customer's account is closed.", field=Order.customer
                )

    async def after_save(self, context: SaveContext[Order]) -> None:
        await context.session.add(Notification(order_id=context.record.id))

    async def after_save_committed(self, context: SaveContext[Order]) -> None:
        if context.created:
            await send_confirmation(context.record)

    async def before_delete(self, context: DeleteContext[Order]) -> None:
        if context.record.status == "shipped":
            raise RefusedError("A shipped order cannot be deleted.")
Hook When it runs
before_save Before the submitted values are written onto the record.
save_record Writes the values onto the record and stores it. Override it to store the record your own way.
after_save After the flush, so the record has its key and the values the database set, and before the commit.
after_save_committed Once the save has committed.
before_delete Before the record is deleted.
delete_record Removes the record. Override it to delete another way.
after_delete After the delete is flushed, before the commit.
after_delete_committed Once the delete has committed.

What a hook gets

SaveContext[Order] holds the session, the record, the values the save stores, whether the save created a new record, and the request, as SaveContext says. The record is an Order, and each value has its column's type. DeleteContext[Order] holds the session, the record and the request.

Read and change a value

context.values[Order.status] is one column's value in the save. get() reads it, typed as the column is, and set() stores another value in its place:

class ProductView(ModelView[Product]):
    async def before_save(self, context: SaveContext[Product]) -> None:
        name = context.values[Product.name].get()
        context.values[Product.slug].set(slugify(name))

get() gives the value the save stores, or the record's own when the save leaves the column as it is. On a new record, a column the database fills in is None until the insert, so read it in after_save. Product.name in context.values tells whether the save stores that column at all.

Change values in before_save, before they are written onto the record. Setting an attribute on context.record there would be overwritten by the form's value a moment later.

A string names a column too, context.values["slug"], and so does the name of a form-only input, such as context.values["password"]. A name that is neither is refused, so a typo never reads as an empty value.

Refuse a change

Raise RefusedError with a message, and the whole change is rolled back, including anything the hook wrote. The message shows on the form, or above the list for a delete. Name a field, and the message shows next to its input instead, with everything else that was typed kept:

class CheckView(ModelView[Check]):
    async def before_save(self, context: SaveContext[Check]) -> None:
        delay = context.values[Check.validation_delay].get()
        if delay is not None and delay < context.values[Check.check_delay].get():
            raise RefusedError(
                "Keep this above the check delay.", field=Check.validation_delay
            )

Any other exception is a fault: the change is rolled back and reported as an error, so a bug in a hook is never mistaken for a business rule.

Save or delete another way

save_record and delete_record do the write itself. Override one to write the record your own way, and everything around it still runs: the permission check, the other hooks and the audit log.

A delete that keeps the row:

from datetime import UTC, datetime

from adminsite import DeleteContext, ModelView


class SupplierView(ModelView[Supplier]):
    async def delete_record(self, context: DeleteContext[Supplier]) -> None:
        context.record.closed_at = datetime.now(UTC)

Every Delete runs it: on the record's page, for the rows ticked in the list, and through the API. list_query keeps such records off the list.

save_record writes the values onto the record and stores it, on a create and on an edit. Call super().save_record(context) to keep that, or leave it out to store the record your own way:

from adminsite import ModelView, SaveContext


class OrderView(ModelView[Order]):
    async def save_record(self, context: SaveContext[Order]) -> None:
        if not context.created:
            await super().save_record(context)
            return
        # New orders go through the shop's own code, which reserves the stock.
        order = context.record
        order.customer = context.values[Order.customer].get()
        order.total = context.values[Order.total].get()
        await place_order(context.session, order)

The rows of the record's inline tables are on context.record already. A new record has to end up in the session, as place_order does here, since the page opens it next.

After the commit

after_save_committed and after_delete_committed run once the change is committed, so they only see a change that was kept. They are the place for anything outside the database, such as an email, a webhook or a cache to clear.

They cannot undo the change. An error in one is written to the server's log, under adminsite, and the save still succeeds. The transaction is over too, so write through a session of your own.

The session in a hook

The session is adminsite's wrapper, the same for async and sync engines, so a hook is written once:

class OrderView(ModelView[Order]):
    async def after_save(self, context: SaveContext[Order]) -> None:
        order = context.record
        customer = await context.session.get(Customer, order.customer_id)
        orders = await context.session.scalar(
            select(func.count()).where(Order.customer_id == order.customer_id)
        )
        if customer is not None and orders == 1:
            await context.session.add(Notification(order_id=order.id))

For work that needs a plain SQLAlchemy Session, such as lazy loading, use run:

from sqlalchemy.orm import Session


class OrderView(ModelView[Order]):
    async def before_delete(self, context: DeleteContext[Order]) -> None:
        def count_lines(session: Session) -> int:
            order = session.get(Order, context.record.id)
            return 0 if order is None else len(order.items)

        if await context.session.run(count_lines) > 0:
            raise RefusedError("Remove the order's lines first.")