Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions backend/app/api/routes/printers.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,41 @@ async def list_printers(session: SessionDep) -> list[PrinterRead]:
return result


# ---------------------------------------------------------------------------
# GET /api/printers/{id}
# ---------------------------------------------------------------------------


@router.get(
"/{printer_id}",
response_model=PrinterRead,
summary="Get printer detail",
description=(
"Returns full metadata for a single printer, including the ``paused`` "
"flag joined from ``printer_state``. Returns 404 when the printer is "
"not registered."
),
)
async def get_printer(
printer_id: UUID,
session: SessionDep,
) -> PrinterRead:
"""Return full printer metadata for a single printer."""
printer = await _get_printer_or_404(session, printer_id)
state = await printer_state_repo.get(session, printer_id)
return PrinterRead(
id=printer.id,
name=printer.name,
model=printer.model,
backend=printer.backend,
connection=dict(printer.connection),
enabled=printer.enabled,
paused=state.paused if state is not None else False,
created_at=printer.created_at,
updated_at=printer.updated_at,
)


# ---------------------------------------------------------------------------
# GET /api/printers/{id}/status
# ---------------------------------------------------------------------------
Expand Down
140 changes: 133 additions & 7 deletions backend/app/api/routes/templates.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
"""REST endpoint for the Templates aggregate (Phase 6a Task 2).

Single read-only endpoint — template CRUD is out of scope for Phase 6a.
"""REST endpoints for the Templates aggregate (Phase 6a Task 2 + Bug-3 fix).

Routes
------
GET /api/templates?app=<optional> — list all templates, optionally filtered
by integration app (snipeit, grocy, spoolman, …)
GET /api/templates?app=<optional> — list all templates, optionally
filtered by integration app (snipeit, grocy, spoolman, …)
POST /api/render/preview?key=<template_key> — render a sample label as PNG

The preview endpoint is used by the frontend template-detail page to show a
rendered preview image. Sample values are sourced from the template's own
``preview_sample`` block in its definition — the route does NOT fabricate
sample data. Templates without ``preview_sample`` return HTTP 422.

References:
docs/superpowers/specs/2026-05-16-phase6a-rest-api-design.md — Templates section
Expand All @@ -14,21 +18,143 @@

from __future__ import annotations

from typing import Annotated
import io
import logging
from typing import Annotated, Any

from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession

from app.db.session import get_session
from app.repositories import templates as templates_repo
from app.schemas.label_data import LabelData
from app.schemas.template import TemplateSchema
from app.schemas.template_read import TemplateRead
from app.services.label_renderer import LabelRenderer

_log = logging.getLogger(__name__)

router = APIRouter(prefix="/api/templates", tags=["templates"])

# Separate router for /api/render so the preview endpoint can live here while
# the prefix keeps it at /api/render/preview (not /api/templates/render/preview).
render_router = APIRouter(prefix="/api/render", tags=["templates"])

# Type alias for the session dependency
SessionDep = Annotated[AsyncSession, Depends(get_session)]


def _build_label_data(
template_key: str,
template_app: str | None,
preview_sample: dict[str, Any],
) -> LabelData:
"""Build a LabelData from a template's preview_sample dict.

The template is responsible for declaring values for every ``field``
and ``data_field`` its elements reference. Missing values raise
HTTPException 422.
"""
try:
# source_app is filled from the template's own ``app`` field — falls
# back to "generic" for templates without an integration.
return LabelData(
primary_id=str(preview_sample.get("primary_id", "")),
title=str(preview_sample.get("title", "")),
qr_payload=str(preview_sample.get("qr_payload", "")),
source_app=template_app or "generic",
secondary=tuple(preview_sample.get("secondary", ()) or ()),
Comment thread
strausmann marked this conversation as resolved.
)
except Exception as exc: # ValidationError or coercion error
raise HTTPException(
status_code=422,
detail=(f"Template {template_key!r} has an invalid preview_sample block: {exc}"),
) from exc


@render_router.post(
"/preview",
response_class=Response,
responses={
200: {
"content": {"image/png": {"schema": {"type": "string", "format": "binary"}}},
"description": "PNG image of the rendered sample label",
}
},
summary="Render a template preview as PNG",
description=(
"Renders the named template with the sample values declared in the "
"template's own ``preview_sample`` block and returns a PNG image. "
"Returns 404 if the template key is not registered. "
"Returns 422 if the template has no ``preview_sample`` block."
),
)
async def render_preview(
session: SessionDep,
key: str = Query(description="Template key, e.g. 'snipeit-12mm'"),
) -> Response:
"""Render a sample preview PNG for the given template key.

Sample values are taken from the template's own ``preview_sample`` block
(in ``template.definition``). Templates that do not declare one return
HTTP 422 with a clear error message — the route does NOT fabricate
fallback sample data.
"""
template_row = await templates_repo.get_by_key(session, key)
if template_row is None:
raise HTTPException(status_code=404, detail=f"template {key!r} not found")

definition = dict(template_row.definition)

# The preview_sample block lives in the template definition. Without it
# the template cannot be previewed — we refuse to guess on its behalf.
preview_sample = definition.get("preview_sample")
if not preview_sample or not isinstance(preview_sample, dict):
raise HTTPException(
status_code=422,
detail=(
f"Template {template_row.key!r} has no preview_sample in its "
"definition. Add a 'preview_sample' block to the template YAML "
"to enable previews."
),
)

# Reconstruct TemplateSchema from the DB row — the definition column stores
# the TemplateSchema field values. Supplement missing fields from the row's
# top-level columns (id→key, tape_mm→tape_width_mm, etc.) so that rows
# created before the definition was normalised can still render.
# ``preview_sample`` is not a TemplateSchema field — strip it before
# passing to the schema constructor.
schema_dict = {k: v for k, v in definition.items() if k != "preview_sample"}
Comment thread
strausmann marked this conversation as resolved.
Outdated
Comment thread
strausmann marked this conversation as resolved.
Outdated
schema_dict.setdefault("id", template_row.key)
schema_dict.setdefault("name", template_row.name)
schema_dict.setdefault("app", template_row.app)
schema_dict.setdefault("tape_mm", template_row.tape_width_mm)
schema_dict.setdefault("schema_version", template_row.schema_version)
schema_dict.setdefault("elements", [])

try:
template_schema = TemplateSchema(**schema_dict)
except Exception as exc:
_log.warning("render_preview: invalid definition for key=%r: %s", key, exc)

Check warning

Code scanning / CodeQL

Log Injection Medium

This log entry depends on a
user-provided value
.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
raise HTTPException(status_code=422, detail=f"invalid template definition: {exc}") from exc

sample_data = _build_label_data(template_row.key, template_row.app, preview_sample)

renderer = LabelRenderer()
Comment thread
strausmann marked this conversation as resolved.
Outdated
try:
img = renderer.render(template_schema, sample_data)
except ValueError as exc:
_log.warning("render_preview: render failed for key=%r: %s", key, exc)

Check warning

Code scanning / CodeQL

Log Injection Medium

This log entry depends on a
user-provided value
.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
raise HTTPException(status_code=422, detail=str(exc)) from exc

# Convert PIL image to PNG bytes
buf = io.BytesIO()
img.save(buf, format="PNG")
Comment thread
strausmann marked this conversation as resolved.
Outdated
return Response(content=buf.getvalue(), media_type="image/png")


@router.get(
"",
response_model=list[TemplateRead],
Expand Down
1 change: 1 addition & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,7 @@ async def readiness(
app.include_router(events_routes.router)
app.include_router(printers_routes.router)
app.include_router(templates_routes.router)
app.include_router(templates_routes.render_router)
app.include_router(jobs_routes.router)
app.include_router(lookup_routes.router)
app.include_router(webhooks_routes.router)
Expand Down
2 changes: 1 addition & 1 deletion backend/app/schemas/printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class PrinterRead(BaseModel):
backend: str
connection: dict[str, object]
enabled: bool
paused: bool = False # joined from printer_state
paused: bool # joined from printer_state — always set explicitly by callers
created_at: datetime
updated_at: datetime

Expand Down
9 changes: 9 additions & 0 deletions backend/app/schemas/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ class TemplateSchema(BaseModel):
validated at load time against ``IntegrationRegistry``; the schema
itself accepts any string so plugins can be added without a schema
migration.

``preview_sample`` is an optional mapping of field name → sample value
used by the preview-render endpoint (``POST /api/render/preview``).
Each template declares its own preview values so the route never has
to fabricate sample data per-app. Keys must match the ``field`` /
``data_field`` names referenced by ``elements``; supported keys are
``primary_id``, ``title``, ``qr_payload``, and optionally ``secondary``
(list/tuple of additional lines).
"""

model_config = ConfigDict(frozen=True)
Expand All @@ -71,3 +79,4 @@ class TemplateSchema(BaseModel):
app: str | None
tape_mm: int
elements: tuple[LayoutElement, ...]
preview_sample: dict[str, str | int | float | bool | list[str] | tuple[str, ...]] | None = None
Comment thread
strausmann marked this conversation as resolved.
Outdated
4 changes: 4 additions & 0 deletions backend/app/seed/templates/grocy-12mm.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ elements:
- { type: qr, x: 8, y: 13, size: 80, data_field: qr_payload }
- { type: text, x: 100, y: 18, field: primary_id, font_size: 22 }
- { type: text, x: 100, y: 60, field: title, font_size: 14 }
preview_sample:
primary_id: "Erdbeermarmelade"
title: "Lager > Vorrat"
qr_payload: "https://grocy.example.com/stock/products/47"
5 changes: 5 additions & 0 deletions backend/app/seed/templates/grocy-18mm.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@ elements:
- { type: text, x: 170, y: 20, field: primary_id, font_size: 32 }
- { type: text, x: 170, y: 70, field: title, font_size: 20 }
- { type: text, x: 170, y: 110, field: secondary, font_size: 14 }
preview_sample:
primary_id: "Erdbeermarmelade"
title: "Lager > Vorrat"
qr_payload: "https://grocy.example.com/stock/products/47"
secondary: ["MHD 2027-04-30", "3 Glaeser"]
5 changes: 5 additions & 0 deletions backend/app/seed/templates/grocy-24mm.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@ elements:
- { type: text, x: 260, y: 20, field: primary_id, font_size: 48 }
- { type: text, x: 260, y: 85, field: title, font_size: 28 }
- { type: text, x: 260, y: 130, field: secondary, font_size: 18 }
preview_sample:
primary_id: "Erdbeermarmelade"
title: "Lager > Vorrat"
qr_payload: "https://grocy.example.com/stock/products/47"
secondary: ["MHD 2027-04-30", "3 Glaeser"]
4 changes: 4 additions & 0 deletions backend/app/seed/templates/qr-only-12mm.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@ app: null
tape_mm: 12
elements:
- { type: qr, x: 260, y: 13, size: 80, data_field: qr_payload }
preview_sample:
primary_id: "Sample"
title: "Preview"
qr_payload: "https://example.com/preview"
4 changes: 4 additions & 0 deletions backend/app/seed/templates/qr-only-18mm.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@ app: null
tape_mm: 18
elements:
- { type: qr, x: 230, y: 13, size: 140, data_field: qr_payload }
preview_sample:
primary_id: "Sample"
title: "Preview"
qr_payload: "https://example.com/preview"
4 changes: 4 additions & 0 deletions backend/app/seed/templates/qr-only-24mm.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@ app: null
tape_mm: 24
elements:
- { type: qr, x: 185, y: 13, size: 230, data_field: qr_payload }
preview_sample:
primary_id: "Sample"
title: "Preview"
qr_payload: "https://example.com/preview"
4 changes: 4 additions & 0 deletions backend/app/seed/templates/snipeit-12mm.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ elements:
- { type: qr, x: 8, y: 13, size: 80, data_field: qr_payload }
- { type: text, x: 100, y: 18, field: primary_id, font_size: 22 }
- { type: text, x: 100, y: 60, field: title, font_size: 14 }
preview_sample:
primary_id: "ASSET-2024-001"
title: "Dell Latitude 7430"
qr_payload: "https://snipeit.example.com/hardware/123"
5 changes: 5 additions & 0 deletions backend/app/seed/templates/snipeit-18mm.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@ elements:
- { type: text, x: 170, y: 20, field: primary_id, font_size: 32 }
- { type: text, x: 170, y: 70, field: title, font_size: 20 }
- { type: text, x: 170, y: 110, field: secondary, font_size: 14 }
preview_sample:
primary_id: "ASSET-2024-001"
title: "Dell Latitude 7430"
qr_payload: "https://snipeit.example.com/hardware/123"
secondary: ["IT Office", "Bjoern Strausmann"]
5 changes: 5 additions & 0 deletions backend/app/seed/templates/snipeit-24mm.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@ elements:
- { type: text, x: 260, y: 20, field: primary_id, font_size: 48 }
- { type: text, x: 260, y: 85, field: title, font_size: 28 }
- { type: text, x: 260, y: 130, field: secondary, font_size: 18 }
preview_sample:
primary_id: "ASSET-2024-001"
title: "Dell Latitude 7430"
qr_payload: "https://snipeit.example.com/hardware/123"
secondary: ["IT Office", "Bjoern Strausmann"]
4 changes: 4 additions & 0 deletions backend/app/seed/templates/spoolman-12mm.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@ elements:
- { type: qr, x: 8, y: 13, size: 80, data_field: qr_payload }
- { type: text, x: 100, y: 18, field: primary_id, font_size: 22 }
- { type: text, x: 100, y: 60, field: title, font_size: 14 }
preview_sample:
primary_id: "PLA-Black-1kg"
title: "Spool #7"
qr_payload: "https://spoolman.example.com/spool/7"
5 changes: 5 additions & 0 deletions backend/app/seed/templates/spoolman-18mm.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@ elements:
- { type: text, x: 170, y: 20, field: primary_id, font_size: 32 }
- { type: text, x: 170, y: 70, field: title, font_size: 20 }
- { type: text, x: 170, y: 110, field: secondary, font_size: 14 }
preview_sample:
primary_id: "PLA-Black-1kg"
title: "Spool #7"
qr_payload: "https://spoolman.example.com/spool/7"
secondary: ["780g left", "Prusament"]
5 changes: 5 additions & 0 deletions backend/app/seed/templates/spoolman-24mm.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@ elements:
- { type: text, x: 260, y: 20, field: primary_id, font_size: 48 }
- { type: text, x: 260, y: 85, field: title, font_size: 28 }
- { type: text, x: 260, y: 130, field: secondary, font_size: 18 }
preview_sample:
primary_id: "PLA-Black-1kg"
title: "Spool #7"
qr_payload: "https://spoolman.example.com/spool/7"
secondary: ["780g left", "Prusament"]
Loading
Loading