Offers in the API, CV export formats, and plugin dashboard widgets #1237

Merged
tiagoagueda merged 8 commits from f-features into main 2026-11-12 04:52:03 +00:00
Owner

Three 0.6.0 features stacked on one branch, so they share one CI run; each keeps its own commits. Merge fast-forward-only. They were reviewed as #1232, #1235 and #1236, which this replaces.

  • Closes #1101: offers recorded, changed and deleted through the API (POST, PATCH, DELETE /api/v1/offers), through the page's own form and functions, under the write scope.
  • Closes #1100: a CV downloaded as Europass (SkillsPassport JSON 3.3.0, read back by Postulo's own reader) and as JSON Resume (validated against schema 1.2.1), each its own switch under Extend file formats; the CV page says what each leaves out.
  • Closes #1102: a plugin can add a dashboard widget (DashboardWidget on the plugin surface), offered on the arrange page, shown only where the plugin policy allows, its markup held to an allowlist, left out and logged if it fails.

Tested locally on the stack: lint, migrations, catalogues (extract writes nothing), and the full unit suite: 16,421 passed. The two failures are tests/test_wiki_surface.py, which CI skips: the wiki's API table and plugin-surface page gain the offer calls and DashboardWidget after the merge.

🤖 Generated with Claude Code

Three 0.6.0 features stacked on one branch, so they share one CI run; each keeps its own commits. Merge fast-forward-only. They were reviewed as #1232, #1235 and #1236, which this replaces. - Closes #1101: offers recorded, changed and deleted through the API (`POST`, `PATCH`, `DELETE /api/v1/offers`), through the page's own form and functions, under the `write` scope. - Closes #1100: a CV downloaded as Europass (SkillsPassport JSON 3.3.0, read back by Postulo's own reader) and as JSON Resume (validated against schema 1.2.1), each its own switch under *Extend file formats*; the CV page says what each leaves out. - Closes #1102: a plugin can add a dashboard widget (`DashboardWidget` on the plugin surface), offered on the arrange page, shown only where the plugin policy allows, its markup held to an allowlist, left out and logged if it fails. Tested locally on the stack: lint, migrations, catalogues (extract writes nothing), and the full unit suite: 16,421 passed. The two failures are `tests/test_wiki_surface.py`, which CI skips: the wiki's API table and plugin-surface page gain the offer calls and `DashboardWidget` after the merge. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Offers could be read through the API and not written: the router said
recording one was the interface's "for now (#237)", and #237 is closed.
An agent that moved an application to Offer could not write down what
was offered.

POST /offers, PATCH /offers/{id} and DELETE /offers/{id} now take the
write scope, as interviews and reminders do. Each hands the body to the
interface's own OfferForm, so the page and the API refuse alike, and the
form's refusal is answered as a validation-failed problem document with
the form's messages in `errors`. Recording goes through record_offer
(timeline entry signed by the token, answer-by reminder, the move to
Offer), a change through revise_offer with the form's changed fields (a
revision only when a term changed; the reminder follows a moved date),
and a delete through withdraw_offer, which ticks the reminder off. A
PATCH reads only the fields sent, and a null clears one.

The isolation sweep covers the two new addresses and the application_id
in the body; the unscoped-queries list names the application lookup.
postulo-mcp is untouched and keeps offering no delete tool.

Closes #1101

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The API's offer calls reuse sentences the catalogues already hold, so
their source references gained the router's file; without extracting,
the catalogue checks found the files out of step with the code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run CI again: data.forgejo.org answered 502 to the browser job
All checks were successful
CI / Unit tests and coverage (Python 3.14) (pull_request) Successful in 15m13s
CI / Checks: lint, migrations, catalogues, settings, built files (pull_request) Successful in 2m15s
CI / Database tests on PostgreSQL (pull_request) Successful in 4m11s
CI / Security audit (pull_request) Successful in 21s
CI / Browser tests (Chromium) (pull_request) Successful in 36m11s
bb8e386489
The browser job stopped before its first step, fetching actions/cache
from data.forgejo.org, which answered 502. Nothing in the change was
tested by it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A CV could be read in from Europass but never written back to it, and
portals across Europe take Europass while many tools read JSON Resume:
a CV that left only as a PDF or a Word file was retyped into each.

Both are now formats of a CV beside text, OpenDocument and Word, each a
sub-module of Extend file formats ("Europass export", "JSON Resume").
A format may now be handed the CV as values (documents.structured)
instead of its outline: the variant's chosen entries in its order and
language, and the personal details it prints, nobody where its contact
block is off. Such a format is never offered for a letter.

The Europass writer sits beside the reader in the europass plugin and
writes the SkillsPassport JSON (schema 3.3.0) with the reader's own
mapping, so a CV written as Europass reads back through Postulo's
importer as what was written; a test holds that. Long fields are HTML,
as the format's are, with what was typed escaped. JSON Resume (schema
1.2.1) is written in its own fields.

What a format has no place for is left out of the file and named under
its download on the CV's page. The JSON Resume schema (MIT) is vendored
under tests/data and the file is checked against it; the Europass
schema states no licence, so the test writes out the constraints of it
the writer relies on. fastjsonschema (BSD-3-Clause, pure Python, no
dependencies, no advisories) is a new test-only dependency.

Closes #1100

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 / Database tests on PostgreSQL (pull_request) Successful in 4m15s
CI / Browser tests (Chromium) (pull_request) Successful in 35m32s
CI / Unit tests and coverage (Python 3.14) (pull_request) Successful in 14m59s
CI / Checks: lint, migrations, catalogues, settings, built files (pull_request) Successful in 2m34s
CI / Security audit (pull_request) Successful in 23s
CI / Checks: lint, migrations, catalogues, settings, built files (push) Successful in 2m27s
CI / Database tests on PostgreSQL (push) Successful in 4m19s
CI / Unit tests and coverage (Python 3.14) (push) Successful in 15m3s
CI / Security audit (push) Successful in 20s
CI / Browser tests (Chromium) (push) Successful in 36m51s
Every Python / Unit tests (Python 3.12) (push) Successful in 16m14s
README / README language tables (push) Successful in 23s
Every Python / Unit tests (Python 3.13) (push) Successful in 16m12s
Dev image / Dev image: build, scan, push (push) Successful in 1h19m51s
2b8d3ab777
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tiagoagueda deleted branch f-features 2026-11-12 04:52:06 +00:00
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!1237
No description provided.