diff --git a/docs/superpowers/specs/2026-05-17-phase-7d-foundation-design.md b/docs/superpowers/specs/2026-05-17-phase-7d-foundation-design.md new file mode 100644 index 0000000..ef41198 --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-phase-7d-foundation-design.md @@ -0,0 +1,443 @@ +# Phase 7d Foundation Design — Generic Print API + QR Print Tab + Hangar Integration + +**Date:** 2026-05-17 +**Status:** Draft — awaiting user review +**Tracking:** strausmann/label-printer-hub#22 (master), `#NN` (Phase 7d issue, TBD) +**Dependencies:** +- Phase 7c API-Auth (#78) — MUST land before Phase 7d goes to production +- Hangar feature-request strausmann/hangar#63 — parallel work in Hangar repo for the cross-app integration + +## 1. Executive Summary + +Phase 7d delivers the **End-to-End MVP** for cross-application label-printing in the label-printer-hub project. After Phase 7b stabilised the foundation (lifespan, datetime, /readiness, status cache), Phase 7d turns the app outward by introducing two integration surfaces: + +1. **Generic Print API** (`POST /api/preview` + `POST /api/print`) that any external app can call with a uniform item payload. Hangar will be the first consumer (see strausmann/hangar#63), but the API is plugin-agnostic — Grocy/SnipeIt/Spoolman could also push from their own UIs if desired. + +2. **QR Print Tab** (`/qr-print`) inside the label-printer-hub HTMX UI. Users can search across multiple external sources (Grocy, SnipeIt, Spoolman, Hangar) through a single search field with a platform toggle, select an item, choose a template, see a live preview with tape-match indicator, and print. + +The plugin system from Phase 3.5 (`IntegrationRegistry` with `lookup(identifier)`) is extended with `search(query)`, `get_item(item_id)`, and an optional `get_children(item_id)` for hierarchical sources like Hangar. + +## 2. Item Datamodel — γ Hybrid + +The single item shape that flows through every endpoint and every plugin: + +```python +class PrintItem(BaseModel): + name: str # Required main line + subtitle: str | None = None # Optional secondary line + qr_url: str | None = None # QR-code payload (deep-link to source app) + image_url: str | None = None # Optional thumbnail + copies: int = Field(default=1, ge=1, le=99) # NEW (Δ from initial design): per-item copies + extras: dict[str, str | int | float | bool] = {} # Template-specific fields (Jinja-accessible) + + +class PrintRequest(BaseModel): + template_id: UUID + printer_id: UUID + items: list[PrintItem] # 1..N items + force_tape_mismatch: bool = False # Optional override +``` + +Rationale for γ Hybrid (chosen over flat-α and template-aware-β): +- Templates have access to a fixed set of generic fields (`name`, `subtitle`, `qr_url`, `image_url`) that cover 80% of label layouts. +- `extras` dict carries plugin-specific or template-specific values (`fach_nr`, `regal_color`, `expiry_date`, etc.) without coupling the caller to specific template field-names. +- Templates use Jinja: `{{ name }}` or `{{ extras.fach_nr }}` — both work. +- Caller (Hangar, etc.) builds the `subtitle` string from its own hierarchy data: `"Vorratskeller > Kallax 02 > Fach C"`. + +### Per-item copies + +Total labels produced = `sum(item.copies for item in items)`. Each copy creates its own `Job` row for tracking. Example payload: 2 Samla-Boxes × 3 copies + 1 Regalfach × 1 copy = 7 print jobs. + +## 3. Plugin Interface Extension + +Building on the Phase 3.5 `IntegrationPlugin` Protocol: + +```python +class IntegrationPlugin(Protocol): + name: str # e.g. "grocy" + display_name: str # e.g. "Grocy" + + async def lookup(identifier: str) -> PluginItem | None: + """Existing — barcode/identifier lookup.""" + ... + + async def search(query: str, limit: int = 20) -> list[PluginItemSummary]: + """NEW Phase 7d — free-text search returning summary rows.""" + ... + + async def get_item(item_id: str) -> PluginItem | None: + """NEW Phase 7d — full item data after user selection.""" + ... + + +class HasChildren(Protocol): + """Optional capability — plugins with hierarchy implement this.""" + + async def get_children(item_id: str) -> list[PluginItemSummary]: + """Returns direct children of a container item (e.g. shelf -> compartments).""" + ... + + +class PluginItemSummary(BaseModel): + id: str # Plugin-internal identifier + name: str + subtitle: str | None = None + image_url: str | None = None + has_children: bool = False # For UI: show '+ alle Kinder' toggle + + +class PluginItem(BaseModel): + id: str + name: str + subtitle: str | None = None + qr_url: str | None = None # Plugin computes the deep-link + image_url: str | None = None + extras: dict[str, Any] = {} +``` + +### Plugins in MVP + +| Plugin | search() | get_item() | get_children() | Notes | +|---|---|---|---|---| +| Grocy | ✅ new | ✅ new | — | Existing Phase-3.5 plugin; extend with the 2 new methods | +| SnipeIt | ✅ new | ✅ new | — | Same | +| Spoolman | ✅ new | ✅ new | — | Same | +| **Hangar (NEW)** | ✅ | ✅ | ✅ | New plugin module `label_hub_hangar/` — see Section 8 | + +`HasChildren` is only implemented by Hangar in MVP. Other plugins simply don't expose the protocol — UI hides the "+ alle Kinder" toggle when `summary.has_children == False`. + +## 4. Backend Endpoints + +### POST /api/preview + +``` +Body: PrintRequest (max 5 items rendered, rest counted but not drawn) +Response: PreviewResponse { + image_data_url: str # "data:image/png;base64,..." for inline embed + items_count: int # Total items in the request + items_rendered_count: int # Always min(items_count, 5) + total_labels: int # sum(item.copies for item in items) + template_name: str + current_tape: { + width_mm: int | None, + color_name: str | None, + hr_status: str | None + } + required_tape: { width_mm: int } + tape_match: bool + tape_match_reason: str | None # If false: human-readable why +} +``` + +Preview renders only `items[0]` as PNG to keep the response payload small. UI shows "Vorschau Item 1 von N" caption. + +Authorisation: `print` scope OR `read` scope sufficient (preview is read-only side-effect-free). + +Idempotency: not required (read-only). + +### POST /api/print + +``` +Body: PrintRequest +Header: Idempotency-Key (optional — prevents double-submit on retry) +Response: PrintResponse { + job_ids: list[UUID] # One per (item × copy) = sum of all copies + accepted_count: int + refused_count: int + refused_reason: str | None # When some items refused (e.g. tape mismatch + no force) +} +``` + +Job creation: `items=[A×2, B×3]` → 5 Jobs in DB, all enqueued with the same template+printer, sequential order. + +Tape-mismatch behaviour: +- Default `strict_tape_match=true` on the server (refused with HTTP 422 + `refused_reason`). +- `force_tape_mismatch=true` in body bypasses the check. Each created Job carries `tape_match_override=true` in DB for audit. + +Authorisation: `print` scope required. + +### Job-DB extensions (small Alembic migration) + +`Job` table gets two new optional columns: +```python +api_key_id: UUID | None # Set by Phase 7c auth middleware +source_ip: str | None +tape_match_override: bool = False # Whether user forced through tape mismatch +plugin: str | None # When the job came via a plugin's get_item flow +plugin_item_id: str | None # The plugin-internal identifier +``` + +These power the audit trail (Section 9). All optional, all backwards-compatible. + +## 5. QR Print Tab UI + +New HTMX route `/qr-print` (server-rendered Go-templates in the frontend, no SPA framework). + +### Layout + +``` ++-- Plugin-Toggle (radio): -------------------------------------+ +| (o) Grocy ( ) SnipeIt ( ) Spoolman ( ) Hangar | ++---------------------------------------------------------------+ +| Search: [ schraubendreher ] [Suchen] | ++-- Results list (HTMX-swapped, 250ms debounce) ---------------+ +| [img] Schraubendreher Set Bosch | +| Werkstatt > Regal A > Box 3 [Auswählen] | +| | +| [img] Schraubendreher Phillips PH2 | +| Werkstatt > Regal A > Box 3 [Auswählen] | ++-- After user clicks Auswählen on a Hangar shelf item: -------+ +| Item: Kallax 02 (Regal, Werkstatt) | +| [x] Auch alle 4 Fächer drucken (Children-Toggle, Hangar) | +| | +| Printer: [ PT-P750W ▼ ] | +| Template: [ ikea-kallax-fach-12mm ▼ ] | +| | +| Copies pro Item: | +| Kallax 02 [1] copies | +| Fach A [1] copies | +| Fach B [1] copies | +| Fach C [3] copies <- Samla-Box-Use-Case | +| Fach D [1] copies | +| | ++-- Preview ---------------------------------------------------+ +| Tape: 12mm white/black ◤ | +| +--------------------------+ | +| | ░░ Kallax 02 ░░ | <- grüner Rahmen wenn tape ok | +| +--------------------------+ | +| "Vorschau Item 1 von 5 (Total 7 Labels)" | ++--------------------------------------------------------------+ +[ Drucken ] +``` + +### HTMX endpoints + +| Path | What it returns | +|---|---| +| `GET /qr-print/search?plugin=X&q=...` | `
` fragment with result-cards (200 OK, swap into results-list) | +| `GET /qr-print/select?plugin=X&item=...` | Print-form block (with optional children-toggle if `has_children=true`) | +| `GET /qr-print/children?plugin=X&item=...` | Children-list fragment (HTMX-merged into the items list when toggle activated) | +| `POST /qr-print/preview` | Preview block fragment (image + tape-match border + caption) | +| `POST /qr-print/print` | Toast fragment: "5 Jobs angelegt (#abc, #def, ...)" | + +All HTMX endpoints accept Pangolin-SSO (browser cookie) OR an API-Key header — see Section 9. + +## 6. Tape-Match Indicator + +The backend computes the match in the `/api/preview` response: + +```python +def compute_tape_match(template, printer_status_cache_row): + loaded_mm = parsed_cache.get("loaded_tape_mm") if parsed_cache else None + required_mm = template.tape_width_mm + if loaded_mm is None: + return False, "Drucker meldet kein Tape (kein SNMP-Probe oder Probe veraltet)" + if loaded_mm != required_mm: + return False, f"Tape ist {loaded_mm}mm, Template benötigt {required_mm}mm" + return True, None +``` + +Frontend renders: +- **Green border** on the preview image when `tape_match=true` +- **Red border** + tooltip with `tape_match_reason` when `tape_match=false` +- Top-right badge inside the preview frame: `Tape: 12mm white/black` (built from `current_tape.width_mm`, `current_tape.color_name`) + +The badge is small, monospace, semitransparent — not blocking the preview content. + +### Mismatch + Print + +When user clicks [Drucken] and `tape_match=false`, the HTMX print-button opens a modal: + +``` +Tape passt nicht zu Template +- Eingelegt: 12mm white/black +- Benötigt: 24mm yellow + +[Abbrechen] [Trotzdem drucken] +``` + +[Trotzdem drucken] sends `force_tape_mismatch=true` in the body. Backend creates the Jobs with `tape_match_override=true` for audit. + +## 7. Multi-Item Print + Copies + +The print-flow turns `(items × copies)` into N Job rows: + +```python +total_jobs = sum(item.copies for item in request.items) +for item in request.items: + for _ in range(item.copies): + session.add(Job( + template_id=request.template_id, + printer_id=request.printer_id, + data=item.model_dump(), + state="queued", + tape_match_override=request.force_tape_mismatch and not tape_match, + plugin=request.plugin, + plugin_item_id=item.extras.get("plugin_item_id"), + api_key_id=current_api_key.id, + source_ip=request.client.host, + )) +``` + +Mid-batch failure handling: +- If one job fails to enqueue (e.g. DB constraint), the response reports `accepted_count: 4, refused_count: 1, refused_reason: "Job for item B copy 2 conflicted"`. +- Already-enqueued jobs are NOT rolled back. The UI shows which ones succeeded. + +## 8. Hangar Plugin + +New plugin module: `backend/app/integrations/hangar/` + +```python +# backend/app/integrations/hangar/__init__.py +from app.integrations.registry import IntegrationPlugin +from app.integrations.hangar.client import HangarClient + + +class HangarPlugin: + name = "hangar" + display_name = "Hangar" + + def __init__(self): + self._client = HangarClient() # reads HANGAR_BASE_URL + HANGAR_API_KEY from settings + + async def lookup(self, identifier: str) -> PluginItem | None: + """Slug lookup, e.g. 'kallax-02-fach-c'.""" + return await self._client.get_location(identifier) + + async def search(self, query: str, limit: int = 20) -> list[PluginItemSummary]: + """GET /api/locations/search?q={query}&limit={limit} against Hangar.""" + rows = await self._client.search(query, limit) + return [ + PluginItemSummary( + id=row["slug"], + name=row["name"], + subtitle=row.get("subtitle"), + image_url=row.get("image_url"), + has_children=row["type"] in {"room", "cabinet", "shelf", "box"}, + ) + for row in rows + ] + + async def get_item(self, item_id: str) -> PluginItem | None: + """GET /api/locations/{slug} against Hangar — returns full data ready for printing.""" + loc = await self._client.get_location(item_id) + if loc is None: + return None + return PluginItem( + id=loc["slug"], + name=loc["name"], + subtitle=" > ".join(loc["path"][:-1]) if len(loc["path"]) > 1 else None, + qr_url=f"https://hangar.strausmann.cloud/locations/{loc['slug']}", + image_url=loc.get("image_url"), + extras={ + "slug": loc["slug"], + "type": loc["type"], + "parent_slug": loc.get("parent_slug"), + **(loc.get("extras") or {}), + }, + ) + + async def get_children(self, item_id: str) -> list[PluginItemSummary]: + """GET /api/locations/{slug}/children against Hangar.""" + rows = await self._client.get_children(item_id) + return [ + PluginItemSummary( + id=row["slug"], + name=row["name"], + subtitle=row.get("subtitle"), + image_url=row.get("image_url"), + has_children=row["type"] in {"room", "cabinet", "shelf", "box"}, + ) + for row in rows + ] +``` + +`HangarClient` is a thin httpx wrapper: +- Base URL from `settings.hangar_base_url` (e.g. `https://hangar.strausmann.cloud`) +- API key from `settings.hangar_api_key` — sent as `X-Hangar-API-Key` header +- 5 s connect timeout, 10 s read timeout +- 404 → returns None; 401/403 → raises with clear log message; 5xx → exponential backoff retry once + +The 3 Hangar endpoints the plugin calls are documented in **strausmann/hangar#63** which must ship before this plugin is deployable in production. + +For local dev / tests without a running Hangar instance, the plugin auto-disables itself when `settings.hangar_base_url` is empty. + +## 9. Auth + +Phase 7d depends on Phase 7c (#78) for API-key authentication: + +| Endpoint | Required Auth | Scope | +|---|---|---| +| `GET /api/printers`, `GET /api/templates` | API-Key OR Pangolin-SSO | `read` | +| `POST /api/preview` | API-Key OR Pangolin-SSO | `read` | +| `POST /api/print` | API-Key OR Pangolin-SSO | `print` (or `admin`) | +| `GET /qr-print/*` (HTMX) | Pangolin-SSO required (browser) | — | +| `POST /qr-print/{preview,print}` | Pangolin-SSO OR API-Key | `read` / `print` | + +When Phase 7c lands, the Hangar plugin needs an API-Key issued by Label-Hub admin UI. The key is stored in Hangar's env as `LABEL_HUB_API_KEY`. + +Transition from `claude-automation` Basic-Auth-Bypass (Phase 7b state) → API-Key (Phase 7c): +- During the transition window, both work. +- After Phase 7c rolls out, `claude-automation` is downgraded to `read` scope only (recovery path). +- Hangar must have its API-Key configured before Hangar's Print-Page rolls out. + +## 10. Testing Strategy + +| Layer | Test type | Coverage target | +|---|---|---| +| Plugin search | Unit per plugin: `search("schrauben")` with mocked HTTP → asserts list of `PluginItemSummary` with expected fields | each plugin >= 90% | +| Plugin get_item | Unit per plugin: `get_item("known-id")` mocked → asserts `PluginItem` shape | each plugin >= 90% | +| HangarPlugin.get_children | Unit: mocked Hangar `/children` response → asserts hierarchy mapping | 100% | +| /api/preview | Integration: items + template + printer → PNG bytes valid, tape_match correct | full path | +| /api/print | Integration: 2 items × 3 copies + 1 item × 1 copy → 7 Job rows, all queued | full path | +| Tape-mismatch | Integration: template-tape 24mm + cache-tape 12mm → preview returns `tape_match=false` with reason | edge case | +| Tape-override | Integration: print with `force_tape_mismatch=true` on mismatch → Jobs with `tape_match_override=true` | edge case | +| QR-Tab UI | E2E Playwright: search → select → toggle children → set copies → preview → print → toast | golden path | +| Hangar smoke | Hardware-skip-able test against a real Hangar staging URL (if available) | optional | + +Coverage threshold stays at 80% (`pyproject.toml` `fail_under`). + +## 11. Hangar Cross-App Coordination + +The Hangar side of this integration is tracked in **strausmann/hangar#63**. That issue describes: + +- New `/print` page in Hangar UI (multi-select Locations, printer + template dropdowns sourced from Label-Hub, preview, print) +- 3 new Hangar API endpoints: `/api/locations/search`, `/api/locations/{slug}`, `/api/locations/{slug}/children` +- Hangar API-key generation for Label-Hub plugin authentication +- Acceptance criteria + dependency ordering + +Both projects can proceed in parallel once the API contract (this spec, Sections 2-4) is locked. + +## 12. Out-of-Scope for Phase 7d + +These are real future-work items but explicitly out of the MVP PR: + +- **Aggregations templates** ("one big label for an entire shelf showing all 4 compartment numbers") — needs template metadata `aggregate_children: bool` and a different renderer path. Phase 7e or later. +- **Per-plugin search-result filters** (e.g. SnipeIt: filter by location/status; Grocy: filter by stock-level) +- **Saved searches / favourites in QR-Tab** +- **Plugin configuration via UI** — for now plugins read all settings from env vars +- **Multi-printer-fan-out** ("send half to PT-P750W, half to QL-820NWB") — single-printer per print request only +- **Job-rerun-from-history** ("re-print last 5 Hangar jobs") — Phase 7e +- **Authenticated webhook from Hangar** ("Hangar item just got created, auto-print a label") — Phase 7e +- **Image rendering on labels** (Phase 7d preview ignores `image_url`; renderer falls back to QR-only) + +## 13. Definition of Done for Phase 7d + +- [ ] Item schema + Plugin protocol extensions land with full unit-test coverage +- [ ] 3 existing plugins (Grocy, SnipeIt, Spoolman) implement `search()` + `get_item()` with at least one passing integration test against a mock HTTP server +- [ ] Hangar plugin module implemented (search + get_item + get_children) — disabled when `HANGAR_BASE_URL` empty (for tests/local dev) +- [ ] `/api/preview` returns PNG + tape_match — integration test green +- [ ] `/api/print` creates the right number of Jobs (items × copies) — integration test green +- [ ] Tape mismatch refuses without override, accepts with override + audit flag +- [ ] `/qr-print` HTMX page renders end-to-end; children-toggle works for Hangar items; per-item copies input works +- [ ] Full test suite at ≥80% coverage, no failures +- [ ] Doku: README section on the new endpoints + screenshot of the QR-Tab UI +- [ ] strausmann/hangar#63 acknowledged + linked from this spec + +## 14. Self-review notes + +- **Privacy:** spec uses RFC-5737 doc IPs and `*.strausmann.cloud` (already user-public via the merged Phase 7b spec). +- **Internal consistency:** the per-item `copies` field is referenced consistently — `items × copies` everywhere. +- **Scope:** 4 plugins + new endpoints + UI page = larger than a typical phase, but within MVP scope per Phase-7d-decomposition decision. Splitting into 7d-foundation (endpoints) + 7d-UI (QR-Tab) is possible at writing-plans time if the PR feels too heavy. +- **Dependency declared:** Phase 7c (#78) hard-required for production; Phase 7b.1 (#77) currently merging in parallel. +- **External coordination:** strausmann/hangar#63 captures the Hangar side; cross-team alignment achieved before code starts.