Skip to content
Merged
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
61fc7b8
spec: Phase 1k.1 Layout-Engine + TapeGeometry + ContentTypes
strausmann Jun 5, 2026
2545467
spec(round-1): adressiert PR #108 Review-Findings (15 Aenderungen)
strausmann Jun 5, 2026
9c877cb
spec(round-2): Copilot R2-Findings + Initial-Seed-Designentscheidung
strausmann Jun 5, 2026
b10552a
spec(round-3): Copilot R3-Findings (template_key behalten, 4 Klarstel…
strausmann Jun 5, 2026
c37d494
spec(round-4): Copilot R4 Klarstellungen (4 Findings)
strausmann Jun 5, 2026
6a1b88d
spec(round-5): Copilot R5 finishing touches (4 Findings)
strausmann Jun 5, 2026
b08c4c2
spec(round-6): Copilot R6 finishing touches (3 Findings)
strausmann Jun 5, 2026
6de07cd
spec(round-7): R7 finishing touches + Spec konvergiert
strausmann Jun 5, 2026
1098473
plan: Phase 1k.1a Hub Layout-Engine — 25 TDD-Tasks
strausmann Jun 5, 2026
2231bd5
feat(schemas): add TapeGeometry + TAPE_GEOMETRY constants table
strausmann Jun 5, 2026
18ed3ff
test(schemas): tighten test_frozen_immutable zu ValidationError + hap…
strausmann Jun 5, 2026
297258a
feat(schemas): add ContentType enum (7 semantic types)
strausmann Jun 5, 2026
7915f2f
feat(schemas): add LabelDataItem (qr_with_listing child)
strausmann Jun 5, 2026
0448586
feat(schemas): LabelData fields optional + items extension
strausmann Jun 5, 2026
9212505
feat(exceptions): UnsupportedTapeError + NoTapeLoadedError + ContentT…
strausmann Jun 5, 2026
12fe7bd
feat(services): LayoutEngine skeleton — dispatch + validation
strausmann Jun 5, 2026
05826e9
feat(layout-engine): implement _render_qr_only
strausmann Jun 5, 2026
bbd77cc
feat(layout-engine): implement _render_qr_one_line
strausmann Jun 5, 2026
93b9420
feat(layout-engine): implement _render_qr_two_lines (V4 baseline)
strausmann Jun 5, 2026
b3b2a6c
feat(layout-engine): implement _render_qr_three_lines
strausmann Jun 5, 2026
2f7f82f
feat(layout-engine): implement _render_text_one_line
strausmann Jun 5, 2026
6eef85b
feat(layout-engine): implement _render_text_two_lines
strausmann Jun 5, 2026
45f0cb0
feat(layout-engine): implement _render_qr_with_listing
strausmann Jun 5, 2026
3fb0bfd
test(layout-engine): fix skeleton test + Pillow deprecation cleanup
strausmann Jun 5, 2026
cf95786
feat(schemas): PrintRequest content_type-based (no template_id)
strausmann Jun 5, 2026
8e086a7
refactor(print-service): integrate LayoutEngine + remove PAUSED path
strausmann Jun 5, 2026
8c28945
style(models): job.py line length fix
strausmann Jun 5, 2026
7587ac8
refactor(print-queue): _rerender_from_db via LayoutEngine; drop PAUSED
strausmann Jun 5, 2026
dd277c1
refactor(batch-dispatch): drop MixedTapeSizesError + tape consistency…
strausmann Jun 5, 2026
b394c56
chore: delete obsolete template-related files (Phase 1k.1a)
strausmann Jun 5, 2026
94fcc25
chore: main.py + lifespan.py — drop TemplateLoader/LabelRenderer wiring
strausmann Jun 5, 2026
ad1faf1
feat(phase-1k1a): Task 25 — final cleanup, /render/preview endpoint, …
strausmann Jun 5, 2026
11e9b08
fix(api): NoTapeLoadedError 409 mapping in print route
strausmann Jun 5, 2026
182c4ed
fix(api): CodeQL security + asyncio.to_thread + lru_cache (Round-1 §1-3)
strausmann Jun 6, 2026
19c4e3b
fix(api): Exception-Mapping vollständig (Round-1 §4)
strausmann Jun 6, 2026
fb24a44
fix(print-queue): robuste Recovery + Webhook-Payload-Skip (Round-1 §5-6)
strausmann Jun 6, 2026
73cdaca
chore(smoke): Pixel-Hash SHA-256 für Rendering-Regression-Detection (…
strausmann Jun 6, 2026
9ba7c50
fix(api): Round-2 Konsistenz-Fixes (4 Copilot-Findings)
strausmann Jun 6, 2026
30f32e8
fix(api): Round-3 Konsistenz + Sicherheit (4 Copilot-Findings)
strausmann Jun 6, 2026
3312531
fix(api): Round-4 Copilot-Findings (4 valide Befunde)
strausmann Jun 6, 2026
63a72b0
style: ruff import-sort fix in main.py (R4-fallout)
strausmann Jun 6, 2026
1fb9e44
fix: 3 mypy strict errors (pre-existing, blocking CI)
strausmann Jun 6, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Phase 1k.1a: drop presets.template_id column and templates table

The Template model and TemplateLoader were removed in Phase 1k.1a (layout
engine replaces template-based rendering). This migration:
1. Drops the presets.template_id column (includes its FK to templates.id)
2. Drops the templates table (no longer managed by any SQLModel class)

SQLite note: SQLite does not support named FK constraints. Alembic batch
mode handles FK removal implicitly when the column is dropped — no explicit
drop_constraint call is needed.

Revision ID: 20260605a1b2c3d4
Revises: a0516c04278c
Create Date: 2026-06-05 00:00:00.000000
"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "20260605a1b2c3d4"
down_revision: str | Sequence[str] | None = "a0516c04278c"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Drop template_id from presets, then drop templates table."""
# Batch mode recreates the table without the dropped column, which
# also removes the FK constraint in SQLite (no explicit drop_constraint).
with op.batch_alter_table("presets", schema=None) as batch_op:
batch_op.drop_column("template_id")

# Drop the templates table — no Python model references it any more.
op.drop_table("templates")


def downgrade() -> None:
"""Recreate templates table and restore template_id column on presets."""
# Recreate the minimal templates table schema from migration 54f963fdb994.
op.create_table(
"templates",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("key", sa.String(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("app", sa.String(), nullable=True),
sa.Column("printer_model", sa.String(), nullable=True),
sa.Column("tape_width_mm", sa.Integer(), nullable=False),
sa.Column("schema_version", sa.Integer(), nullable=False, server_default="1"),
sa.Column("definition", sa.JSON(), nullable=True),
sa.Column("source", sa.String(), nullable=False, server_default="seed"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("key"),
)
with op.batch_alter_table("presets", schema=None) as batch_op:
batch_op.add_column(sa.Column("template_id", sa.Uuid(), nullable=True))
# SQLite does not support named FK constraints and alembic batch mode
# requires a name for create_foreign_key. FK is omitted in downgrade
# (column is re-added without FK constraint — acceptable for rollback).
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Phase 1k.1a: make jobs.template_key nullable to match SQLModel definition

The Job model defines template_key as Optional[str] (nullable=True) but the
initial jobs migration (e0d573b37f5b) created it as NOT NULL. This migration
corrects the nullable mismatch so alembic check passes.

Revision ID: 20260605b2c3d4e5
Revises: 20260605a1b2c3d4
Create Date: 2026-06-05 00:01:00.000000
"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "20260605b2c3d4e5"
down_revision: str | Sequence[str] | None = "20260605a1b2c3d4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Make jobs.template_key nullable (SQLite batch mode)."""
with op.batch_alter_table("jobs", schema=None) as batch_op:
batch_op.alter_column(
"template_key",
existing_type=sa.String(),
nullable=True,
)


def downgrade() -> None:
"""Restore jobs.template_key to NOT NULL."""
with op.batch_alter_table("jobs", schema=None) as batch_op:
batch_op.alter_column(
"template_key",
existing_type=sa.String(),
nullable=False,
)
3 changes: 0 additions & 3 deletions backend/app/api/error_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
docs/superpowers/specs/2026-05-16-phase6a-rest-api-design.md
app/services/errors.py
app/printer_backends/exceptions.py
app/services/template_loader.py
"""

from __future__ import annotations
Expand All @@ -34,15 +33,13 @@
)
from app.schemas.problem import ProblemDetail
from app.services.errors import AppLookupNotFoundError
from app.services.template_loader import TemplateNotFoundError

# Mapping: exception class → (HTTP status code, problem-type slug)
_MAPPING: dict[type[Exception], tuple[int, str]] = {
PrinterOfflineError: (503, "printer-offline"),
TapeMismatchError: (409, "tape-mismatch"),
TapeEmptyError: (409, "tape-empty"),
PrinterCoverOpenError: (409, "printer-cover-open"),
TemplateNotFoundError: (404, "template-not-found"),
AppLookupNotFoundError: (404, "app-lookup-not-found"),
}

Expand Down
34 changes: 14 additions & 20 deletions backend/app/api/routes/batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
from app.auth.scope_deps import require_print
from app.db.session import get_session
from app.models.print_batch import PrintBatch
from app.printer_backends.base import PrinterBackend # noqa: F401 — used by dispatch_batch typing
from app.printer_backends.exceptions import (
PrinterCoverOpenError,
PrinterOfflineError,
Expand All @@ -22,7 +21,7 @@
from app.repositories import print_batches as batches_repo
from app.repositories import printers as printers_repo
from app.schemas.print_batch import BatchRequest, BatchResponse
from app.services.batch_dispatch import MixedTapeSizesError, dispatch_batch
from app.services.batch_dispatch import dispatch_batch

# SessionDep locally — Hub has no central app/api/deps.py module.
SessionDep = Annotated[AsyncSession, Depends(get_session)]
Expand Down Expand Up @@ -106,13 +105,18 @@ async def create_batch(
},
) from err

# 6. Best-effort dispatch
# 6. Dispatch (half_cut: override takes precedence over backend capability)
backend_supports_half_cut: bool = getattr(backend, "half_cut_supported", False)
if body.half_cut_override is not None:
use_half_cut = body.half_cut_override and backend_supports_half_cut
else:
use_half_cut = backend_supports_half_cut

try:
job_ids, errors = await dispatch_batch(
service,
body.items,
half_cut_override=body.half_cut_override,
backend=backend,
job_ids = await dispatch_batch(
service=service,
items=body.items,
half_cut=use_half_cut,
Comment on lines 121 to +125
)
Comment on lines 121 to 126
except (PrinterOfflineError, PrinterCoverOpenError, SnmpQueryError, TapeMismatchError) as exc:
raise HTTPException(
Expand All @@ -122,22 +126,13 @@ async def create_batch(
"error_message": str(exc),
},
) from exc
except MixedTapeSizesError as exc:
raise HTTPException(
400,
detail={
"error_code": "mixed_tape_sizes",
"error_message": str(exc),
"tape_mm_values": sorted(set(exc.tape_mm_values)),
},
) from exc

# 7. Persist tracking row
# auth.subject_id does NOT exist — use api_key_id or source
created_by = str(auth.api_key_id) if auth.api_key_id else auth.source
batch_row = PrintBatch(
printer_id=printer.id,
job_ids=job_ids,
job_ids=[str(jid) for jid in job_ids],
created_by=created_by,
)
await batches_repo.create(session, batch_row)
Expand All @@ -146,6 +141,5 @@ async def create_batch(
batch_id=batch_row.id,
printer_id=printer.id,
queued_at=datetime.now(UTC).isoformat().replace("+00:00", "Z"),
job_ids=job_ids,
errors=errors,
job_ids=[str(jid) for jid in job_ids],
)
Comment on lines 189 to 193
86 changes: 81 additions & 5 deletions backend/app/api/routes/print.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,37 @@
"""POST /print + GET /jobs/{job_id} + POST /jobs/{job_id}/resume."""
"""POST /print + GET /jobs/{job_id} + POST /jobs/{job_id}/resume + POST /render/preview."""

from __future__ import annotations

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

from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel

from app.auth.dependencies import AuthContext
from app.auth.scope_deps import require_print, require_read
from app.printer_backends.exceptions import (
ContentTypeDataMismatchError,
NoTapeLoadedError,
PrinterCoverOpenError,
PrinterOfflineError,
SnmpQueryError,
TapeEmptyError,
TapeMismatchError,
UnsupportedTapeError,
)
from app.printer_backends.snmp_helper import LiveStatus, query_live_status
from app.schemas.print_request import PrintRequest
from app.schemas.content_type import ContentType
from app.schemas.label_data import LabelData
from app.schemas.print_request import PrintRequest, RawLabelData
from app.schemas.print_response import PrintJobResponse, PrintJobStatusResponse
from app.services.job_lifecycle import JobState
from app.services.layout_engine import LayoutEngine
from app.services.lookup_service import LookupFailedError
from app.services.print_queue import PrinterAlreadyActiveError
from app.services.template_loader import TemplateNotFoundError

_log = logging.getLogger(__name__)

Expand All @@ -40,10 +46,10 @@


_SYNC_ERROR_MAP: dict[type[Exception], tuple[int, str]] = {
TemplateNotFoundError: (404, "template_not_found"),
LookupFailedError: (502, "integration_lookup_failed"),
TapeMismatchError: (409, "tape_mismatch"),
TapeEmptyError: (409, "tape_empty"),
NoTapeLoadedError: (409, "no_tape_loaded"),
Comment on lines 54 to +58
PrinterCoverOpenError: (409, "printer_cover_open"),
PrinterOfflineError: (503, "printer_offline"),
}
Expand Down Expand Up @@ -244,3 +250,73 @@
finished_at=getattr(job, "finished_at", None),
live=None,
)


class _PreviewRequest(BaseModel):
"""Request body for POST /render/preview.

Phase 1k.1a (Task 25): render-only endpoint — no printer, no queue, no DB.
"""

content_type: ContentType
data: RawLabelData
tape_mm: int = 12


@router.post(
"/render/preview",
tags=["print"],
summary="Render a label preview as PNG",
description=(
"Render a label using the LayoutEngine and return the result as a "
"PNG image (no printer interaction, no job created). "
"Useful for UI preview and debugging. "
"Returns 422 when ``data`` is missing fields required by ``content_type``. "
"Returns 409 when ``tape_mm`` is not a supported tape width."
),
response_class=Response,
responses={
200: {"content": {"image/png": {}}, "description": "PNG label bitmap"},
409: {"description": "Unsupported tape width"},
422: {"description": "Data missing required fields for content_type"},
},
)
async def render_preview(
body: _PreviewRequest,
_auth: Annotated[AuthContext, Depends(require_read)],
) -> Response:
"""Render a label preview without touching the printer or DB.

Uses a fresh LayoutEngine instance so that this endpoint works even
when no printer is configured (dev / CI mode).

Returns 200 with Content-Type: image/png on success.
Returns 409 (unsupported_tape) when tape_mm is not in TAPE_GEOMETRY.
Returns 422 (data_mismatch) when data is missing fields for content_type.
"""
engine = LayoutEngine()
label_data = LabelData(
primary_id=body.data.primary_id,
title=body.data.title,
qr_payload=body.data.qr_payload,
source_app="preview",
secondary=body.data.secondary,
items=body.data.items,
)
try:
image = engine.render(body.tape_mm, body.content_type, label_data)
except UnsupportedTapeError as exc:
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
content={"error_code": "unsupported_tape", "error_message": str(exc)},

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
)
except ContentTypeDataMismatchError as exc:
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={"error_code": "data_mismatch", "error_message": str(exc)},

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
)
Comment on lines +340 to +352

buf = io.BytesIO()
image.save(buf, format="PNG")
buf.seek(0)
return Response(content=buf.read(), media_type="image/png")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The engine.render and image.save operations are CPU-bound (generating QR codes, rendering text, and compressing PNG bytes). Running them directly in the async render_preview endpoint blocks the event loop, which can severely degrade API performance under concurrent load.

Please offload these operations to a worker thread using asyncio.to_thread.

    import asyncio

    def _render() -> bytes:
        image = engine.render(body.tape_mm, body.content_type, label_data)
        buf = io.BytesIO()
        image.save(buf, format="PNG")
        return buf.getvalue()

    try:
        png_bytes = await asyncio.to_thread(_render)
    except UnsupportedTapeError as exc:
        return JSONResponse(
            status_code=status.HTTP_409_CONFLICT,
            content={"error_code": "unsupported_tape", "error_message": str(exc)},
        )
    except ContentTypeDataMismatchError as exc:
        return JSONResponse(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            content={"error_code": "data_mismatch", "error_message": str(exc)},
        )

    return Response(content=png_bytes, media_type="image/png")
References
  1. Wrap CPU-bound or synchronous blocking operations (e.g., image processing with PIL) in asyncio.to_thread when calling them from asynchronous FastAPI route handlers to prevent blocking the event loop.

Loading
Loading