Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
200 changes: 193 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,203 @@

from __future__ import annotations

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

from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Request
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


# LabelData fields that preview_sample values map to (via _build_label_data).
# Elements reference these by name via `field` / `data_field` on LayoutElement.
_LABEL_DATA_FIELDS: frozenset[str] = frozenset({"primary_id", "title", "qr_payload", "secondary"})


def _validate_preview_sample_fields(
template_key: str,
elements: tuple[Any, ...],
preview_sample: dict[str, Any],
) -> None:
"""Raise HTTP 422 if preview_sample is missing any field required by elements.

Each LayoutElement of type ``text`` references a ``field`` on LabelData;
type ``qr`` references a ``data_field``. Both must be present in
``preview_sample`` so the renderer does not silently produce empty output.
"""
element_fields = set()
for el in elements:
if el.type == "text" and el.field:
element_fields.add(el.field)
elif el.type == "qr" and el.data_field:
element_fields.add(el.data_field)

# Restrict check to known LabelData fields — unknown names will resolve to
# empty strings via _resolve_field (getattr fallback) which is acceptable.
missing = (element_fields & _LABEL_DATA_FIELDS) - set(preview_sample.keys())
if missing:
raise HTTPException(
status_code=422,
detail=(
f"Template {template_key!r} preview_sample is missing fields "
f"required by its elements: {sorted(missing)}. "
"Add these keys to the template's 'preview_sample' block."
),
)


@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(
request: Request,
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.

The LabelRenderer is reused from ``app.state.label_renderer`` (wired by
the lifespan) to avoid per-request font-loading overhead. The CPU-bound
render + PNG encode is offloaded to ``asyncio.to_thread`` so it does not
block the event loop.
"""
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.
schema_dict = dict(definition)
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 the sanitised key from the DB row (trusted), NOT the raw query
# parameter, to prevent log injection via crafted key values.
_log.warning("render_preview: invalid definition for key=%r: %s", template_row.key, exc)
raise HTTPException(status_code=422, detail=f"invalid template definition: {exc}") from exc

# Validate that preview_sample provides all fields referenced by elements.
_validate_preview_sample_fields(template_row.key, template_schema.elements, preview_sample)

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

# Reuse the shared renderer from app.state (avoids per-request font-loading).
# Fall back to a fresh instance when running outside a full lifespan
# (e.g. unit tests that don't wire app.state).
renderer: LabelRenderer = getattr(request.app.state, "label_renderer", None) or LabelRenderer()

def _render_and_encode() -> bytes:
"""CPU-bound render + PNG encode — runs in a thread pool."""
try:
img = renderer.render(template_schema, sample_data)
except ValueError as exc:
# Log the sanitised key from the DB row (trusted), NOT the raw query
# parameter, to prevent log injection via crafted key values.
_log.warning("render_preview: render failed for key=%r: %s", template_row.key, exc)
raise
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()

try:
png_bytes = await asyncio.to_thread(_render_and_encode)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc

return Response(content=png_bytes, media_type="image/png")


@router.get(
"",
response_model=list[TemplateRead],
Expand Down
8 changes: 7 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,9 +325,14 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app.state.printer_id = printer.id
app.state.printer_host = discovery_host
app.state.printer_snmp_community = settings.printer_snmp_community
# Shared LabelRenderer reused by both PrintService and the preview endpoint.
# Constructing it once avoids repeated font-loading overhead on every
# POST /api/render/preview request.
shared_renderer = LabelRenderer()
app.state.label_renderer = shared_renderer
app.state.print_service = PrintService(
template_loader=TemplateLoader,
renderer=LabelRenderer(),
renderer=shared_renderer,
print_queue=queue,
lookup_service=AppLookupService(),
printer_id=printer.id,
Expand Down Expand Up @@ -589,6 +594,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
17 changes: 17 additions & 0 deletions backend/app/schemas/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@

Templates are frozen at construction so they can be safely seeded as
module-level constants (see app/seed/templates.py in PR D2).

Immutability note: Pydantic `frozen=True` prevents attribute re-assignment
but does NOT deep-freeze container values. The ``preview_sample`` field
therefore uses ``tuple[str, ...]`` for its sequence type (instead of
``list[str]``) so the entire schema is truly immutable after construction.
"""

from __future__ import annotations
Expand Down Expand Up @@ -61,6 +66,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 +84,7 @@ class TemplateSchema(BaseModel):
app: str | None
tape_mm: int
elements: tuple[LayoutElement, ...]
# Values use tuple (not list) so the entire schema is deeply immutable —
# Pydantic frozen=True only prevents attribute re-assignment, not mutation
# of mutable containers stored in those attributes.
preview_sample: dict[str, str | int | float | bool | tuple[str, ...]] | None = None
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"
Loading
Loading