Skip to content

Pages and plugins

Not everything in an admin is a table. A sales report, a settings form or a button that clears a cache are pages of your own. They live next to the model views, in the same layout, behind the same sign in.

A page of your own

from adminsite import AdminPage
from sqlalchemy import func, select


class SalesReport(AdminPage):
    label = "Sales report"
    group = "Reports"
    template = "reports/sales.html"

    async def get_context(self, request):
        async with self.admin.database.session() as session:
            total = await session.scalar(select(func.sum(Order.total)))
        return {"total": total}


admin = Admin(
    engine, views=[OrderView], pages=[SalesReport], template_dirs=["templates"]
)

The page is served at /admin/-/sales_report, named after the class, and shows up in the sidebar under its group and in the command palette. The template sits in one of your template_dirs and extends the admin's layout:

{% extends "adminsite/base.html" %}

{% block header %}<h1 class="text-base font-semibold">{{ page.label }}</h1>{% endblock %}

{% block content %}
  <div class="p-6">
    <div class="stat">
      <div class="stat-title">Sold so far</div>
      <div class="stat-value">{{ total }}</div>
    </div>
  </div>
{% endblock %}

The admin's styles are daisyUI components, so a page can use the same cards, stats and tables as the rest of the admin. page is the page object; urls, user and csrf_input are there as on every page.

Setting What it does
name The URL, /-/ and the name. Defaults to the class name in snake case, without Page.
label The sidebar text. Defaults to the name, spaced out.
group The sidebar section. Pages share groups with views.
template The template to render.

Who may open it

class SalesReport(AdminPage):
    async def allows(self, request):
        return request.state.user.is_manager

A refused page is left out of the sidebar and answers with a 403.

Forms

A page that takes a form overrides post. The form's token is already checked, so a form from another site never reaches it:

from starlette.responses import RedirectResponse

from adminsite.http.templating import add_message


class Settings(AdminPage):
    template = "settings.html"

    async def post(self, request, form):
        await save_settings(form)
        add_message(request, "Settings saved.")
        return RedirectResponse(request.url, status_code=303)

Put {{ csrf_input | safe }} inside the <form method="post"> in the template.

To answer with something other than a template, such as a file or JSON, override get and return any Starlette response.

Plugins

A plugin packages views, pages, routes, templates and static files so several projects can share them. It gets the admin once and adds what it brings:

from pathlib import Path

from adminsite import Plugin

HERE = Path(__file__).parent


class Reports(Plugin):
    name = "reports"

    def setup(self, admin):
        admin.add_template_dir(HERE / "templates")
        admin.add_static("reports", HERE / "static")
        admin.add_stylesheet("-/static/reports/reports.css")
        admin.add_page(SalesReport)
        admin.add_view(ReportScheduleView)
        admin.add_route("/-/reports/export.csv", export_sales)


admin = Admin(engine, plugins=[Reports()])
Method What it adds
add_view(view) A model view.
add_page(page) A page of your own.
add_route(path, endpoint, methods=("GET",), guarded=True) Any endpoint, called as endpoint(admin, request). The path starts with /-/, so it never meets a model's URL. guarded=False leaves it outside the sign in, for a health check or a webhook.
add_template_dir(path) A folder of templates, searched before the admin's own, so a plugin can also replace one of them.
add_static(name, path) A folder served at /-/static/ and the name.
add_stylesheet(href), add_script(src) A stylesheet or script on every page. A relative path starts at the admin, a full URL is used as it is.

The same methods work on the admin directly, without a plugin. Routes and static files have to be added before the admin answers its first request, since the routes are fixed from then on.