docs(phase-7d): foundation design — print API + QR tab + hangar plugin#79
Conversation
Generic /api/preview + /api/print accepting a uniform PrintItem schema (name, subtitle, qr_url, image_url, copies, extras). Plugin protocol extended with search() + get_item() + optional get_children() — Grocy, SnipeIt, Spoolman gain search; new Hangar plugin module added. QR Print Tab in the HTMX UI with platform toggle, per-item copies, tape-match preview indicator. Aligns with strausmann/hangar#63 for the Hangar side. Refs #22
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request establishes the foundation for Phase 7d, enabling cross-application label printing within the label-printer-hub. It introduces a standardized API for external applications to request prints, adds a new user-facing QR Print Tab for streamlined selection and printing, and extends the plugin architecture to support advanced search and hierarchical data retrieval. These changes facilitate a cohesive integration with external systems like Hangar while maintaining strict tape-match validation and auditability. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #79 +/- ##
=======================================
Coverage 91.93% 91.93%
=======================================
Files 70 70
Lines 3038 3038
Branches 259 259
=======================================
Hits 2793 2793
Misses 181 181
Partials 64 64
Flags with carried forward coverage won't be shown. Click here to find out more. Continue to review full report in Codecov by Sentry.
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Code Review
This pull request adds the design specification for Phase 7d, detailing a generic print API, an HTMX-based QR print tab, and a new Hangar integration plugin. The review feedback identifies several issues in the documentation's code examples, including missing 'self' parameters in Python protocol methods and the use of 'Any' which violates type safety guidelines. Additionally, the reviewer noted a naming discrepancy for item identifiers between the backend logic and the plugin implementation, as well as a privacy violation concerning the use of real domain names.
| name: str # e.g. "grocy" | ||
| display_name: str # e.g. "Grocy" | ||
|
|
||
| async def lookup(identifier: str) -> PluginItem | None: |
There was a problem hiding this comment.
The lookup method signature in the IntegrationPlugin protocol is missing the self parameter. Please include it to align with Python's protocol requirements and maintain consistency with the implementation in Section 8.
| async def lookup(identifier: str) -> PluginItem | None: | |
| async def lookup(self, identifier: str) -> PluginItem | None: |
References
- Ensure classes claiming to implement a Protocol fulfill all required methods to prevent runtime failures.
| """Existing — barcode/identifier lookup.""" | ||
| ... | ||
|
|
||
| async def search(query: str, limit: int = 20) -> list[PluginItemSummary]: |
There was a problem hiding this comment.
The search method signature in the IntegrationPlugin protocol is missing the self parameter.
| async def search(query: str, limit: int = 20) -> list[PluginItemSummary]: | |
| async def search(self, query: str, limit: int = 20) -> list[PluginItemSummary]: |
References
- Ensure classes claiming to implement a Protocol fulfill all required methods to prevent runtime failures.
| """NEW Phase 7d — free-text search returning summary rows.""" | ||
| ... | ||
|
|
||
| async def get_item(item_id: str) -> PluginItem | None: |
There was a problem hiding this comment.
The get_item method signature in the IntegrationPlugin protocol is missing the self parameter.
| async def get_item(item_id: str) -> PluginItem | None: | |
| async def get_item(self, item_id: str) -> PluginItem | None: |
References
- Ensure classes claiming to implement a Protocol fulfill all required methods to prevent runtime failures.
| class HasChildren(Protocol): | ||
| """Optional capability — plugins with hierarchy implement this.""" | ||
|
|
||
| async def get_children(item_id: str) -> list[PluginItemSummary]: |
There was a problem hiding this comment.
The get_children method signature in the HasChildren protocol is missing the self parameter.
| async def get_children(item_id: str) -> list[PluginItemSummary]: | |
| async def get_children(self, item_id: str) -> list[PluginItemSummary]: |
References
- Ensure classes claiming to implement a Protocol fulfill all required methods to prevent runtime failures.
| subtitle: str | None = None | ||
| qr_url: str | None = None # Plugin computes the deep-link | ||
| image_url: str | None = None | ||
| extras: dict[str, Any] = {} |
There was a problem hiding this comment.
The use of Any in the PluginItem model's extras field violates the project's type safety guidelines. Please use the same union type defined for PrintItem in Section 2 to maintain consistency and strict typing.
| extras: dict[str, Any] = {} | |
| extras: dict[str, str | int | float | bool] = {} |
References
- Type safety. mypy --strict on app/. Flag new Any introductions. (link)
There was a problem hiding this comment.
Acknowledged in 61602d0; keeping Any on PluginItem.extras by design — plugin payloads are untrusted and carry arbitrary types before mapping into PrintItem. A docstring note was added to the spec to make this intentional. The tighter dict[str, str | int | float | bool] is the public boundary on PrintItem.extras. Documented in PR description under 'deferred suggestions'. Refs #22
| 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"), |
There was a problem hiding this comment.
There is a discrepancy between the job creation logic and the plugin implementation. The logic here expects a plugin_item_id key in extras, but the HangarPlugin implementation in Section 8 uses slug. Consider standardizing the key name or adding plugin_item_id to the PrintItem model directly to ensure the audit trail is correctly populated.
References
- Attributes intended to satisfy a Protocol must use the exact names defined in that Protocol (e.g., use 'id' instead of 'printer_id' if the Protocol requires 'id').
| 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']}", |
There was a problem hiding this comment.
The use of the real domain strausmann.cloud violates the project's privacy rules. Please use example.com as a placeholder for all documentation and example code, as per the Repository Style Guide and General Rules. This also applies to lines 356 and 439.
| qr_url=f"https://hangar.strausmann.cloud/locations/{loc['slug']}", | |
| qr_url=f"https://hangar.example.com/locations/{loc['slug']}", |
References
- Privacy violations. Flag any hardcoded LAN IPs, real hostnames, real domains, real tokens, or PII. The maintainer's network must not be deducible from this repository. (link)
- Use RFC 5737 documentation IPs and 'example.com' placeholders instead of real LAN IPs, hostnames, or domains in documentation and code to maintain privacy.
There was a problem hiding this comment.
Pull request overview
This PR adds a Phase 7d foundation design spec for extending Label Printer Hub with a generic print API, QR Print Tab, and Hangar integration planning.
Changes:
- Defines proposed print request/item schemas and
/api/preview+/api/printbehavior. - Extends the integration plugin contract with search/item/children capabilities.
- Documents QR Print Tab UX, Hangar plugin behavior, auth dependencies, testing strategy, and DoD.
Comments suppressed due to low confidence (5)
docs/superpowers/specs/2026-05-17-phase-7d-foundation-design.md:103
- The integration name is written as
SnipeIt, but the repository consistently uses the product/trademark spellingSnipe-IT(for exampledocs/policies/trademarks.md:31and existing UI docs). Use the established spelling in the plugin table and UI labels.
| SnipeIt | ✅ new | ✅ new | — | Same |
docs/superpowers/specs/2026-05-17-phase-7d-foundation-design.md:180
- The UI mockup uses
SnipeIt, but existing docs and trademark references useSnipe-IT. Keeping the visible label consistent avoids shipping a misspelled integration name in the QR tab.
| (o) Grocy ( ) SnipeIt ( ) Spoolman ( ) Hangar |
docs/superpowers/specs/2026-05-17-phase-7d-foundation-design.md:16
- This visible integration label should be
Snipe-IT, notSnipeIt, to match the spelling used elsewhere in the project.
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.
docs/superpowers/specs/2026-05-17-phase-7d-foundation-design.md:416
- Use the established spelling
Snipe-IThere instead ofSnipeIt, consistent with the existing integration documentation.
- **Per-plugin search-result filters** (e.g. SnipeIt: filter by location/status; Grocy: filter by stock-level)
docs/superpowers/specs/2026-05-17-phase-7d-foundation-design.md:427
- Use the established spelling
Snipe-IThere instead ofSnipeItso the DoD matches the plugin's documented name.
- [ ] 3 existing plugins (Grocy, SnipeIt, Spoolman) implement `search()` + `get_item()` with at least one passing integration test against a mock HTTP server
| 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: |
| class HasChildren(Protocol): | ||
| """Optional capability — plugins with hierarchy implement this.""" | ||
|
|
||
| async def get_children(item_id: str) -> list[PluginItemSummary]: |
| 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 |
| subtitle: str | None = None | ||
| qr_url: str | None = None # Plugin computes the deep-link | ||
| image_url: str | None = None | ||
| extras: dict[str, Any] = {} |
| 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']}", |
|
|
||
| ``` | ||
| Body: PrintRequest | ||
| Header: Idempotency-Key (optional — prevents double-submit on retry) |
There was a problem hiding this comment.
| 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. |
| | `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`. |
There was a problem hiding this comment.
Acknowledged in 61602d0; the section describes two different keys: settings.PRINTER_HUB_HANGAR_API_KEY (Label-Hub → Hangar) and LABEL_HUB_API_KEY (Hangar → Label-Hub). The phrasing was confusing — will be reworded in writing-plans to make the bidirectional auth explicit. Documented in PR description under 'deferred suggestions'. Refs #22
| 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. |
| plugin=request.plugin, | ||
| plugin_item_id=item.extras.get("plugin_item_id"), | ||
| api_key_id=current_api_key.id, | ||
| source_ip=request.client.host, |
…lf + consistency fixes - Replace all strausmann.cloud / personal-domain references with example.com - Replace HANGAR_BASE_URL with PRINTER_HUB_HANGAR_BASE_URL (settings prefix) - Add self parameter to all Protocol method signatures (lookup, search, get_item, get_children) - Fix import path: app.integrations.registry → app.integrations.base - Fix module path: label_hub_hangar/ → backend/app/integrations/hangar/ (Section 3 vs 8 consistency) - Clarify preview renders items[0] only (items_rendered_count always 1, not min(N,5)) - Fix Job-DB migration column count: "two" → "five" - Align HTMX auth note (Section 5) with Section 9 auth matrix - Add PluginItem.extras Any-intentional docstring note (reject tightening — keeps Any by design) - Fix SnipeIt → Snipe-IT throughout (trademark spelling) - Update Section 14 self-review privacy note Refs #22
## 0.6.0 (2026-05-18) * docs(api): address PR #79 bot-review — privacy sanitise + protocol self + consistency fixes ([61602d0](61602d0)), closes [#79](#79) [#22](#22) * docs(api): pure-vector SVG samples for all 12 seed templates (#83) ([a066dde](a066dde)), closes [#83](#83) [#81](#81) [#22](#22) * docs(phase-7b): foundation design spec — init-robustness + health-split + pangolin-bypass (#74) ([c5a7964](c5a7964)), closes [#74](#74) [fosrl/pangolin#3099](fosrl/pangolin#3099) [#22](#22) [#22](#22) * docs(phase-7d): foundation design — print API + QR tab + hangar plugin (#79) ([cdaedeb](cdaedeb)), closes [#79](#79) [strausmann/hangar#63](https://github.com/strausmann/hangar/issues/63) [#22](#22) * fix: 3 production bugs from local smoke-test + dev/ folder ([c0fc903](c0fc903)), closes [#67](#67) [#67](#67) [#67](#67) [#67](#67) [#67](#67) [#67](#67) [#67](#67) [#67](#67) * fix: Phase 6b code-cleanup — 6 audit findings + plugin pattern wired ([f77aa44](f77aa44)), closes [#67](#67) [#67](#67) [#67](#67) [#67](#67) [#67](#67) [#67](#67) [#67](#67) [#67](#67) * fix(api): Phase 7b.1 — upsert name-collision + /readiness proxy gap (#77) ([4e74a03](4e74a03)), closes [#77](#77) [#76](#76) [#22](#22) [#76](#76) [#22](#22) [#77](#77) [#1](#1) [#76](#76) [#22](#22) * fix(ui): printer detail metadata + template preview + paused-bool gap (#82) ([52bab83](52bab83)), closes [#82](#82) [#22](#22) [#22](#22) [#22](#22) [#22](#22) [#22](#22) [#22](#22) [#22](#22) [#22](#22) [#22](#22) * feat(api): Phase 7b foundation — init, datetime-TZ, /readiness, status cache, proxy widening (#75) ([784decc](784decc)), closes [#75](#75) [#22](#22) [#22](#22) [#22](#22) [#22](#22) [#22](#22) [#22](#22) [#22](#22) [#22](#22) [#22](#22) [#22](#22) [#22](#22) [#22](#22) [#22](#22) [#22](#22) [#22](#22) [#22](#22) [#22](#22) [#22](#22) [#22](#22) [#22](#22) [#22](#22) [#22](#22) [#22](#22) [#22](#22) [#22](#22) [HI#priority](https://github.com/HI/issues/priority) [#75](#75) [#22](#22) * feat(ui): proxy legacy /print to backend (restores First-Print smoke curl) (#84) ([8ef36ed](8ef36ed)), closes [#84](#84) [#22](#22) [#22](#22) [#84](#84) [#22](#22) [skip ci]
…min/api-keys UI (#85) * docs(api): Phase 7c API-Auth design — 3-scope keys, bcrypt, rate-limit, /admin/api-keys UI App-side API-Key authentication replacing the current single-secret Pangolin-Bypass for all writes. 3 scopes (read/print/admin), bcrypt hashed keys with prefix UI display, in-memory token-bucket rate limit, per-key printer ACL, audit-trail in Jobs table, transition path via feature flag for the Pangolin-Bypass scope-downgrade. Refs #22 Refs #78 * docs(api): address PR #85 bot-review — privacy sanitise + objective fixes - 7c spec: fix key_prefix length example (11→12 chars consistent) - 7c spec: datetime fields use DateTime(timezone=True) sa_column - 7c spec: bootstrap seed key prints plaintext to Alembic stdout only (not app logger) - 7c spec: require_scope() reconciles SSO=admin for /admin/* routes, 401 for missing creds / 403 for insufficient scope - 7c spec: Pangolin-bypass reads feature flag settings.pangolin_bypass_scope_downgrade in pseudocode - 7c spec: cache key is api_key_id (after prefix DB lookup), not raw hash; bcrypt uses asyncio.to_thread - 7c spec: source_ip propagation via X-Real-IP injected by frontend proxy - 7c spec: rate_limit_per_minute applies to all requests (not print-only); rename clarified - 7c spec: RateLimiter uses asyncio.Lock + capacity-drift detection to handle concurrent requests + config changes - 7d spec: replace strausmann.cloud with example.com (privacy scan fix) Refs #22 * docs(api): sync 7d spec with main (privacy-clean version from PR #79) Replace the pre-PR-#79-fix version of the Phase 7d spec with the already-merged, privacy-sanitised version from main to eliminate the merge conflict caused by main advancing past the PR base. Refs #22
Summary
Phase 7d foundation design spec — covers:
POST /api/preview+POST /api/print) with uniform γ-hybrid item schema (name/subtitle/qr_url/image_url + per-item copies + extras dict)search(query),get_item(item_id), optionalget_children(item_id)for hierarchical sourcesCross-app coordination
The Hangar side of this integration is tracked in strausmann/hangar#63 (GitLab) — describes the new Hangar print-page UI + 3 new Hangar API endpoints the label-printer-hub plugin will consume.
Dependencies
Review
Please review the spec at
docs/superpowers/specs/2026-05-17-phase-7d-foundation-design.md— 13 sections + self-review notes. Key decisions documented:Bot-review fixes applied (commit 61602d0)
Objective findings from the 20 unresolved threads addressed in follow-up commit
61602d0(pushed to main after squash-merge):strausmann.cloud→example.com; self-review note correctedPRINTER_HUB_env prefixHANGAR_BASE_URL→PRINTER_HUB_HANGAR_BASE_URLthroughoutselfparamsselftolookup,search,get_item,get_childrenapp.integrations.registry→app.integrations.baselabel_hub_hangar/aligned tobackend/app/integrations/hangar/items_rendered_count: Always min(N,5)→Always 1 (items[0] only)PluginItem.extras AnyAnyintentional — plugin payloads are untrusted; PrintItem.extras is the tighter public boundarySnipe-ITspellingSpec polish — deferred suggestions
The following bot suggestions are acknowledged but explicitly deferred to the writing-plans / implementation phase:
Deferred to implementation:
extras: dict = {}→Field(default_factory=dict). Correct Pydantic pattern — will be enforced when translating spec to code. Spec intentionally uses simplified syntax for readability.plugin_item_idvsslugkey (Gemini chore(deps-dev): bump semantic-release from 24.2.9 to 25.0.3 #6 / Copilot docs: master tracking issue — overall implementation phases #22): The job-creation pseudo-code usesitem.extras.get("plugin_item_id")while the Hangar plugin storesslug. The implementation will standardise on a reserved_plugin_item_idkey in extras during the write-plans phase.template_id/datavstemplate_key/payload(Copilot ci: optional Prometheus snmp_exporter integration (Phase 12) #21): The job-creation example uses illustrative field names. Actual field names frombackend/app/models/job.pywill be used when writing code.request.pluginmissing from PrintRequest schema (Copilot docs: master tracking issue — overall implementation phases #22): Intentionally omitted from the public API schema — plugin routing happens internally via the registry. Will be clarified in writing-plans.current_api_keyNone path (Copilot docs: documentation strategy — repo docs/ vs GitHub Wiki (hybrid?) #23): The pseudocode omits the SSO-authenticated path for brevity. Implementation will guardapi_key_id=current_api_key.id if current_api_key else None.request.clientNone guard (Copilot chore(deps): bump github.com/go-chi/chi/v5 from 5.1.0 to 5.2.2 in /frontend #36): Same — pseudocode elidessource_ip=request.client.host if request.client else None.itemsmin-1 validation (Copilot feat(api,ui): browse mode — paginated grid view per integration #26): Addmin_length=1toitems: list[PrintItem]— implementation detail, not spec design.lookupreturn type (Copilot feat(api,ui): cart + bulk-print across integrations #27):PluginItem | Nonevs currentLabelData+ raises. Acknowledged as a breaking Protocol change — intentional. Migration notes will be added to writing-plans.parent_slug: Nonein extras (Copilot ci: make Python lint/test job branch-tolerant #30): Filter outNonevalues before building extras in implementation._prefix in implementation (e.g._slug,_type).settings.hangar_api_key(Label-Hub calls Hangar) andLABEL_HUB_API_KEY(Hangar calls Label-Hub). This is correct but the phrasing was confusing. Will be reworded in writing-plans.Anyis the intentional wider type for raw plugin data. PrintItem.extras (dict[str, str|int|float|bool]) is the narrow public type. Plugins must convert between them — mapping logic will be in writing-plans.Refs #22