Skip to content

Hooks

Hooks run around a save or a delete, inside the same transaction. They receive the session, so a business rule can read or write other tables, and they can refuse the change.

from adminsite import RefusedError
from adminsite.views.writing import DeleteContext, SaveContext


class OrderView(ModelView, model=Order):
    async def before_save(self, context: SaveContext) -> None:
        if context.created:
            stock = await context.session.get(Stock, context.record.product_id)
            if stock is None or stock.quantity < 1:
                raise RefusedError("That product is out of stock.")

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

    async def before_delete(self, context: DeleteContext) -> 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.
after_save After the flush, so the record has its primary key, and before the commit.
before_delete Before the record is deleted.
after_delete After the delete is flushed, before the commit.

What a hook receives

SaveContext has:

Attribute What it holds
session The session, for reading and writing other tables.
record The record being saved. On create, a new instance.
values The values that were submitted, already converted.
created Whether this is a new record.
request The request, to see who is asking.

DeleteContext has session, record and request.

Refusing

Raise RefusedError with a message and the whole change is rolled back, including anything the hook already wrote. The message is shown on the form, or above the list for a delete.

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

The session in a hook

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

await context.session.add(record)
await context.session.get(Model, key)
await context.session.scalar(select(func.count()).select_from(Model))

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

def count_lines(session):
    order = session.get(Order, key)
    return len(order.items)


total = await context.session.run(count_lines)

Changing a value before it is stored

before_save runs before the submitted values are written onto the record, so that is where to change them. Change context.values, or use context.set:

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

Whatever the hook leaves in context.values is what is stored, so the database never sees the untransformed value and a unique check fires against the value you meant. Setting an attribute on context.record here would be overwritten a moment later by the value from the form; set the attribute in after_save only for things the form does not send.

Refusing one field

A refusal with a field name appears next to that input, like any other problem with what was typed, and everything else the person wrote stays in place:

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

Without a field the message sits above the form, as before. The JSON API answers 422 with {"errors": {"validation_delay": "..."}} for a refusal about a field, and 409 for the rest.