Let a plugin add a widget to the dashboard #1236

Closed
tiagoagueda wants to merge 2 commits from f-1102 into main
Owner

Closes #1102: a plugin can add a widget to the dashboard.

  • DashboardWidget(key, label, blurb, template, context, width) on the plugin surface (postulo.plugins.api); a plugin of any kind declares its widgets in widgets. context(user, connection) is handed the person and their connection, never a queryset (the issue's decision), once per enabled connection the policy allows, or once with None.
  • Offered on the arrange page through the existing "new to this account" offer, so nothing appears on anyone's dashboard unasked; shown only where the plugin policy lets the person use the plugin, and refused by POST otherwise.
  • Postulo draws the card and heading; the plugin's body is autoescaped and then held to an nh3 allowlist (text, lists, tables, h3-h6, safe links, a few attributes), whatever |safe let through. A widget that raises or returns something wrong is left out and logged.
  • Caching as for the built-in widgets (the issue's decision). With no plugin widget the dashboard makes the same queries as before; render time measured unchanged after a declaration cache keyed on the registry's generation.
  • postulo.plugins.testing gains assert_declares_usable_widgets and draw_widget.

Tested: 22 new tests fail without the change; 10 browser tests with a plugin widget for two connections (axe light and dark, read and arrange, right to left, 320 px, target size, keyboard arranging, dragging); the dashboard's own browser tests; plugin surface and the usual guards. No migration, no new strings.

🤖 Generated with Claude Code

Closes #1102: a plugin can add a widget to the dashboard. - `DashboardWidget(key, label, blurb, template, context, width)` on the plugin surface (`postulo.plugins.api`); a plugin of any kind declares its widgets in `widgets`. `context(user, connection)` is handed the person and their connection, never a queryset (the issue's decision), once per enabled connection the policy allows, or once with `None`. - Offered on the arrange page through the existing "new to this account" offer, so nothing appears on anyone's dashboard unasked; shown only where the plugin policy lets the person use the plugin, and refused by POST otherwise. - Postulo draws the card and heading; the plugin's body is autoescaped and then held to an nh3 allowlist (text, lists, tables, h3-h6, safe links, a few attributes), whatever `|safe` let through. A widget that raises or returns something wrong is left out and logged. - Caching as for the built-in widgets (the issue's decision). With no plugin widget the dashboard makes the same queries as before; render time measured unchanged after a declaration cache keyed on the registry's generation. - `postulo.plugins.testing` gains `assert_declares_usable_widgets` and `draw_widget`. Tested: 22 new tests fail without the change; 10 browser tests with a plugin widget for two connections (axe light and dark, read and arrange, right to left, 320 px, target size, keyboard arranging, dragging); the dashboard's own browser tests; plugin surface and the usual guards. No migration, no new strings. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The dashboard is a grid of widgets and a plugin could not add one: the
plugin surface said what a widget is handed was not settled. It is now.
A plugin declares `DashboardWidget`s in a `widgets` attribute: a key, a
label, a sentence for the arrange page, a width, a template from the
plugin's own templates/ directory, and a function handed the person and
their connection to the plugin, never a queryset, returning the
template's context.

A plugin's widget joins Postulo's under `plugin:key` and is arranged like
any other: it waits on the arrange page under the plugin's name until the
person adds it. It is offered and drawn only where the instance's plugin
policy lets that person use the plugin; for anyone else it is a plugin
that is not installed. A person with several connections sees its body
once for each, under the connection's name; one with none, once, handed
None. It is computed on every view, as the built-in widgets are, and
nothing is kept between views.

Postulo draws the card and heading. What the plugin's template writes is
escaped as any template is and then held to an allowlist (text, lists,
tables, h3 to h6, links, a few attributes), so no script, style, form,
image, id or data- attribute reaches the page whatever |safe let through.
A widget that raises, or answers with something other than a dict, is
left off the page and logged; while arranging its cell stays so it can be
taken off.

The plugin registry now has a generation number, so the dashboard reads
the declarations once and again only when the plugins change, instead of
asking for every kind of plugin for every widget and every arrow. The
testing module gains `assert_declares_usable_widgets` and `draw_widget`
for a plugin's own tests.

Closes #1102

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Record #1102 in the changelog
All checks were successful
CI / Unit tests and coverage (Python 3.14) (pull_request) Successful in 14m15s
CI / Checks: lint, migrations, catalogues, settings, built files (pull_request) Successful in 1m49s
CI / Database tests on PostgreSQL (pull_request) Successful in 4m8s
CI / Security audit (pull_request) Successful in 21s
CI / Browser tests (Chromium) (pull_request) Successful in 34m40s
59531330bc
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tiagoagueda force-pushed f-1102 from 59531330bc
All checks were successful
CI / Unit tests and coverage (Python 3.14) (pull_request) Successful in 14m15s
CI / Checks: lint, migrations, catalogues, settings, built files (pull_request) Successful in 1m49s
CI / Database tests on PostgreSQL (pull_request) Successful in 4m8s
CI / Security audit (pull_request) Successful in 21s
CI / Browser tests (Chromium) (pull_request) Successful in 34m40s
to 435872066d
All checks were successful
CI / Unit tests and coverage (Python 3.14) (pull_request) Successful in 22m16s
CI / Checks: lint, migrations, catalogues, settings, built files (pull_request) Successful in 2m4s
CI / Database tests on PostgreSQL (pull_request) Successful in 4m23s
CI / Security audit (pull_request) Successful in 22s
CI / Browser tests (Chromium) (pull_request) Successful in 42m52s
2026-11-12 01:49:34 +00:00
Compare
Author
Owner

Superseded by #1237, which stacks this branch with two other features so they share one CI run (each merge here is fast-forward-only, so landing them one by one would mean a CI run each after every rebase). The commits are the same.

🤖 Generated with Claude Code

Superseded by #1237, which stacks this branch with two other features so they share one CI run (each merge here is fast-forward-only, so landing them one by one would mean a CI run each after every rebase). The commits are the same. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
tiagoagueda closed this pull request 2026-11-12 02:11:23 +00:00
All checks were successful
CI / Unit tests and coverage (Python 3.14) (pull_request) Successful in 22m16s
CI / Checks: lint, migrations, catalogues, settings, built files (pull_request) Successful in 2m4s
CI / Database tests on PostgreSQL (pull_request) Successful in 4m23s
CI / Security audit (pull_request) Successful in 22s
CI / Browser tests (Chromium) (pull_request) Successful in 42m52s

Pull request closed

Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo!1236
No description provided.