Give each sync connection a page to pair its items with records by hand, and show each pairing on its record (#327) #1174

Merged
tiagoagueda merged 1 commit from 327-pairing-page into main 2026-10-31 09:52:08 +00:00
Owner

What a sync plugin could not pair by UID had nowhere to be paired: a card
could not be said to be that company, an event the second interview.
SyncLink appeared only in the Django admin, no record page showed a pairing,
and a plugin may add no page of its own (#228).

  • SyncPlugin gains two optional methods. remote_items(config, search, kind,
    key) lists the items a person may pair, or the one item a (collection,
    uid) key names. fill_record(connection, config, item, kind, into=None)
    makes or fills a record through the surface's services, as the plugin.
    RemoteItem and RECORD_KINDS (contact, company, department, you,
    interview, reminder, application) are on postulo.plugins.api. A plugin
    without remote_items gets no page.
  • Settings -> Connections -> Pairing: opens on what is paired and what was
    removed on the other side, read from SyncLink alone. Search and Show
    unpaired ask the plugin live, under a new per-account allowance
    (POSTULO_CONNECTION_PAIRING_RATE, 120/h) and a 15-second deadline, and
    draw a captioned table in a live region. An item's page offers the
    person's own records of a kind: Pair, Fill and pair, or Fill once without
    pairing. Unpair keeps both sides (#326); for a twin gone, Pair again, or
    let the plugin make a new one. The plugin is asked outside any
    transaction through the helper Test uses.
  • A pairing names the item by its key only; the plugin is asked for it
    again and the link's address comes from that answer, never the form.
    Written with no etag, so the next run makes the first comparison. Only
    the owner's own connections, records and links are reached; everything
    else is 404. Labels from the server are escaped text, never links.
  • One cotton component, c-pairing-line, says "Paired through ..., last
    synced ..." or "Removed on the other side", with Unpair and Pair with an
    item..., on the company page (and its departments), the application
    page, the contact, interview and reminder edit pages and Your details,
    from SyncLink only.
  • postulo.plugins.testing gains pair_by_hand, unpair_by_hand and
    create_by_hand, doing what the page does.

No migration and no archive change. Tests: tests/test_pairing.py (pair,
unpair, pair again and create each seen by a fake plugin's next run), the
isolation sweep, page coverage and the browser walk, the plugin surface,
and an accessibility test of the page after Show unpaired.

Closes #327

Notes for review

  • The whole issue landed, create included: Closes #327.
  • The plugin's half, both optional on SyncPlugin:
    • remote_items(config, search="", kind="", key=None) -> list[RemoteItem]; key is (collection, uid).
    • fill_record(connection, config, item, kind, into=None) returns the record; it writes through the surface's services with the plugin as via/actor.
    • A plugin without remote_items gets no page (404, no button on the list) and no Pair with an item… link.
  • On the surface: RemoteItem (collection, uid, label, collection_label, href, detail, kinds, slot; .key) and RECORD_KINDS (contact, company, department, you, interview, reminder, application).
  • Pages, all under connections/<pk>/pairing/:
    • pairing (GET): paired and gone rows from SyncLink alone; Search / Show unpaired asks the plugin and swaps the results into an aria-live region (htmx hx-select, a plain GET without scripts). A ?for=kind-pk names the record being paired, from a record's line.
    • pairing_item (GET): one item, asked again by key, with a table of the person's own records of a kind (radios) and Pair / Fill … and pair / Fill once, without pairing.
    • pairing_apply, pairing_unpair, pairing_renew (POST).
  • Decisions taken:
    • The item is always asked again by key before a pair or a fill, and the link's remote_href, collection, uid and slot come from that answer; the form carries only the key. Written with no etag, no local hash, no last-synced time.
    • "Let the plugin make a new twin" for a gone item deletes the link, so a UID-pairing plugin's next run pushes the record as new. Unpair keeps the row (unpaired_at), as #326.
    • Records are the person's own only (owner=user), not a group's or a share's; a shared record's page shows no line.
    • you is the User; it can be paired, never filled (nothing on the surface writes a person).
    • A filled record must be an instance of the kind's model, the connection owner's own, and into itself when given; otherwise refused and not paired. Fill and pair is refused up front if another record holds the item.
    • Deadline: every wait the plugin makes through Postulo's client is held to 15 seconds (http.until). At most 100 items drawn, 50 records offered.
    • _too_often gained pairing=True, spending POSTULO_CONNECTION_PAIRING_RATE (120/h, new, in settings and .env.example) under the scope connection-pairing, so Test is not spent by pairing.
    • Unpair/renew without a link redirect with a message (so the isolation sweep's owner check reaches the view); a link not the person's on that connection is 404.
  • The record line is one cotton component, cotton/pairing_line.html, fed by a new tag library plugins/templatetags/pairings.py (pairings_of). It is on the company page (plus each department, quiet), the application page, the contact, interview and reminder edit pages, and Your details.
  • Testing helper: pair_by_hand, unpair_by_hand, create_by_hand in postulo.plugins.testing, tested in tests/test_pairing.py.
  • Browser walk: furnished now registers a pairable stand-in plugin (and is a yield fixture) so the walk visits the page, the page after Show unpaired, and an item page; plus an axe test of the page after Show unpaired.
  • No migration and no format bump. Pairings stay out of the archive.
  • Wiki: scratchpad/batch/wiki-327.md (Writing-a-plugin, Configuration, Tracking-applications). Until it is applied, tests/test_wiki_surface.py fails on RemoteItem, RECORD_KINDS and POSTULO_CONNECTION_PAIRING_RATE.

🤖 Generated with Claude Code

What a sync plugin could not pair by UID had nowhere to be paired: a card could not be said to be that company, an event the second interview. SyncLink appeared only in the Django admin, no record page showed a pairing, and a plugin may add no page of its own (#228). - SyncPlugin gains two optional methods. remote_items(config, search, kind, key) lists the items a person may pair, or the one item a (collection, uid) key names. fill_record(connection, config, item, kind, into=None) makes or fills a record through the surface's services, as the plugin. RemoteItem and RECORD_KINDS (contact, company, department, you, interview, reminder, application) are on postulo.plugins.api. A plugin without remote_items gets no page. - Settings -> Connections -> Pairing: opens on what is paired and what was removed on the other side, read from SyncLink alone. Search and Show unpaired ask the plugin live, under a new per-account allowance (POSTULO_CONNECTION_PAIRING_RATE, 120/h) and a 15-second deadline, and draw a captioned table in a live region. An item's page offers the person's own records of a kind: Pair, Fill and pair, or Fill once without pairing. Unpair keeps both sides (#326); for a twin gone, Pair again, or let the plugin make a new one. The plugin is asked outside any transaction through the helper Test uses. - A pairing names the item by its key only; the plugin is asked for it again and the link's address comes from that answer, never the form. Written with no etag, so the next run makes the first comparison. Only the owner's own connections, records and links are reached; everything else is 404. Labels from the server are escaped text, never links. - One cotton component, c-pairing-line, says "Paired through ..., last synced ..." or "Removed on the other side", with Unpair and Pair with an item..., on the company page (and its departments), the application page, the contact, interview and reminder edit pages and Your details, from SyncLink only. - postulo.plugins.testing gains pair_by_hand, unpair_by_hand and create_by_hand, doing what the page does. No migration and no archive change. Tests: tests/test_pairing.py (pair, unpair, pair again and create each seen by a fake plugin's next run), the isolation sweep, page coverage and the browser walk, the plugin surface, and an accessibility test of the page after Show unpaired. Closes #327 ## Notes for review - **The whole issue landed**, create included: `Closes #327`. - **The plugin's half**, both optional on `SyncPlugin`: - `remote_items(config, search="", kind="", key=None) -> list[RemoteItem]`; `key` is `(collection, uid)`. - `fill_record(connection, config, item, kind, into=None)` returns the record; it writes through the surface's services with the plugin as `via`/`actor`. - A plugin without `remote_items` gets no page (404, no button on the list) and no *Pair with an item…* link. - **On the surface:** `RemoteItem` (collection, uid, label, collection_label, href, detail, kinds, slot; `.key`) and `RECORD_KINDS` (contact, company, department, you, interview, reminder, application). - **Pages**, all under `connections/<pk>/pairing/`: - `pairing` (GET): paired and gone rows from `SyncLink` alone; *Search* / *Show unpaired* asks the plugin and swaps the results into an `aria-live` region (htmx `hx-select`, a plain GET without scripts). A `?for=kind-pk` names the record being paired, from a record's line. - `pairing_item` (GET): one item, asked again by key, with a table of the person's own records of a kind (radios) and *Pair* / *Fill … and pair* / *Fill once, without pairing*. - `pairing_apply`, `pairing_unpair`, `pairing_renew` (POST). - **Decisions taken:** - The item is always asked again by key before a pair or a fill, and the link's `remote_href`, `collection`, `uid` and `slot` come from that answer; the form carries only the key. Written with no etag, no local hash, no last-synced time. - "Let the plugin make a new twin" for a gone item **deletes** the link, so a UID-pairing plugin's next run pushes the record as new. Unpair keeps the row (`unpaired_at`), as #326. - Records are the person's **own** only (`owner=user`), not a group's or a share's; a shared record's page shows no line. - `you` is the `User`; it can be paired, never filled (nothing on the surface writes a person). - A filled record must be an instance of the kind's model, the connection owner's own, and `into` itself when given; otherwise refused and not paired. *Fill and pair* is refused up front if another record holds the item. - Deadline: every wait the plugin makes through Postulo's client is held to 15 seconds (`http.until`). At most 100 items drawn, 50 records offered. - `_too_often` gained `pairing=True`, spending `POSTULO_CONNECTION_PAIRING_RATE` (120/h, new, in settings and `.env.example`) under the scope `connection-pairing`, so *Test* is not spent by pairing. - Unpair/renew without a `link` redirect with a message (so the isolation sweep's owner check reaches the view); a link not the person's on that connection is 404. - **The record line** is one cotton component, `cotton/pairing_line.html`, fed by a new tag library `plugins/templatetags/pairings.py` (`pairings_of`). It is on the company page (plus each department, `quiet`), the application page, the contact, interview and reminder edit pages, and *Your details*. - **Testing helper:** `pair_by_hand`, `unpair_by_hand`, `create_by_hand` in `postulo.plugins.testing`, tested in `tests/test_pairing.py`. - **Browser walk:** `furnished` now registers a pairable stand-in plugin (and is a yield fixture) so the walk visits the page, the page after *Show unpaired*, and an item page; plus an axe test of the page after *Show unpaired*. - **No migration and no format bump.** Pairings stay out of the archive. - **Wiki:** `scratchpad/batch/wiki-327.md` (Writing-a-plugin, Configuration, Tracking-applications). Until it is applied, `tests/test_wiki_surface.py` fails on `RemoteItem`, `RECORD_KINDS` and `POSTULO_CONNECTION_PAIRING_RATE`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Give each sync connection a page to pair its items with records by hand, and show each pairing on its record (#327)
Some checks failed
CI / Unit tests and coverage (Python 3.14) (pull_request) Failing after 14m51s
CI / Checks: lint, migrations, catalogues, settings, built files (pull_request) Successful in 1m43s
CI / Security audit (pull_request) Has been cancelled
CI / Database tests on PostgreSQL (pull_request) Has been cancelled
CI / Browser tests (Chromium) (pull_request) Has been cancelled
aff43c6903
What a sync plugin could not pair by UID had nowhere to be paired: a card
could not be said to be that company, an event the second interview.
SyncLink appeared only in the Django admin, no record page showed a pairing,
and a plugin may add no page of its own (#228).

- SyncPlugin gains two optional methods. remote_items(config, search, kind,
  key) lists the items a person may pair, or the one item a (collection,
  uid) key names. fill_record(connection, config, item, kind, into=None)
  makes or fills a record through the surface's services, as the plugin.
  RemoteItem and RECORD_KINDS (contact, company, department, you,
  interview, reminder, application) are on postulo.plugins.api. A plugin
  without remote_items gets no page.
- Settings -> Connections -> Pairing: opens on what is paired and what was
  removed on the other side, read from SyncLink alone. Search and Show
  unpaired ask the plugin live, under a new per-account allowance
  (POSTULO_CONNECTION_PAIRING_RATE, 120/h) and a 15-second deadline, and
  draw a captioned table in a live region. An item's page offers the
  person's own records of a kind: Pair, Fill and pair, or Fill once without
  pairing. Unpair keeps both sides (#326); for a twin gone, Pair again, or
  let the plugin make a new one. The plugin is asked outside any
  transaction through the helper Test uses.
- A pairing names the item by its key only; the plugin is asked for it
  again and the link's address comes from that answer, never the form.
  Written with no etag, so the next run makes the first comparison. Only
  the owner's own connections, records and links are reached; everything
  else is 404. Labels from the server are escaped text, never links.
- One cotton component, c-pairing-line, says "Paired through ..., last
  synced ..." or "Removed on the other side", with Unpair and Pair with an
  item..., on the company page (and its departments), the application
  page, the contact, interview and reminder edit pages and Your details,
  from SyncLink only.
- postulo.plugins.testing gains pair_by_hand, unpair_by_hand and
  create_by_hand, doing what the page does.

No migration and no archive change. Tests: tests/test_pairing.py (pair,
unpair, pair again and create each seen by a fake plugin's next run), the
isolation sweep, page coverage and the browser walk, the plugin surface,
and an accessibility test of the page after Show unpaired.

Closes #327

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tiagoagueda force-pushed 327-pairing-page from aff43c6903
Some checks failed
CI / Unit tests and coverage (Python 3.14) (pull_request) Failing after 14m51s
CI / Checks: lint, migrations, catalogues, settings, built files (pull_request) Successful in 1m43s
CI / Security audit (pull_request) Has been cancelled
CI / Database tests on PostgreSQL (pull_request) Has been cancelled
CI / Browser tests (Chromium) (pull_request) Has been cancelled
to e1949b4a3c
Some checks failed
CI / Unit tests and coverage (Python 3.14) (pull_request) Successful in 15m12s
CI / Checks: lint, migrations, catalogues, settings, built files (pull_request) Successful in 1m46s
CI / Database tests on PostgreSQL (pull_request) Successful in 3m49s
CI / Security audit (pull_request) Successful in 43s
CI / Browser tests (Chromium) (pull_request) Failing after 38m14s
2026-10-31 07:45:20 +00:00
Compare
tiagoagueda force-pushed 327-pairing-page from e1949b4a3c
Some checks failed
CI / Unit tests and coverage (Python 3.14) (pull_request) Successful in 15m12s
CI / Checks: lint, migrations, catalogues, settings, built files (pull_request) Successful in 1m46s
CI / Database tests on PostgreSQL (pull_request) Successful in 3m49s
CI / Security audit (pull_request) Successful in 43s
CI / Browser tests (Chromium) (pull_request) Failing after 38m14s
to 4fc8db0a73
All checks were successful
CI / Unit tests and coverage (Python 3.14) (pull_request) Successful in 17m32s
CI / Checks: lint, migrations, catalogues, settings, built files (pull_request) Successful in 3m4s
CI / Database tests on PostgreSQL (pull_request) Successful in 4m42s
CI / Security audit (pull_request) Successful in 1m21s
CI / Browser tests (Chromium) (pull_request) Successful in 40m33s
2026-10-31 08:55:35 +00:00
Compare
tiagoagueda deleted branch 327-pairing-page 2026-10-31 09:52:10 +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!1174
No description provided.