diff --git a/backend/alembic/versions/20260623_phase1k3_preset_content_type_tape_mm.py b/backend/alembic/versions/20260623_phase1k3_preset_content_type_tape_mm.py new file mode 100644 index 0000000..b77b670 --- /dev/null +++ b/backend/alembic/versions/20260623_phase1k3_preset_content_type_tape_mm.py @@ -0,0 +1,48 @@ +"""Phase 1k.3: content_type + tape_mm auf presets-Tabelle (Refs #104) + +Revision ID: 20260623_phase1k3_preset_layout +Revises: 42fbd015698d +Create Date: 2026-06-23 + +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "20260623_phase1k3_preset_layout" +down_revision: str | Sequence[str] | None = "42fbd015698d" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Zwei neue Spalten auf presets: content_type (Default qr_three_lines) und tape_mm (Default 12).""" + op.add_column( + "presets", + sa.Column( + "content_type", + sa.String(), + nullable=False, + server_default="qr_three_lines", + ), + ) + op.add_column( + "presets", + sa.Column( + "tape_mm", + sa.Integer(), + nullable=False, + server_default="12", + ), + ) + + +def downgrade() -> None: + """Spalten content_type und tape_mm von presets entfernen.""" + op.drop_column("presets", "tape_mm") + op.drop_column("presets", "content_type") diff --git a/backend/app/api/routes/presets_api.py b/backend/app/api/routes/presets_api.py new file mode 100644 index 0000000..55e715b --- /dev/null +++ b/backend/app/api/routes/presets_api.py @@ -0,0 +1,169 @@ +"""JSON-CRUD-API für Layout-Presets (Phase 1k.3, Refs #104). + +Routes +------ +GET /api/v1/presets — Liste (require_read) +POST /api/v1/presets — Anlegen 201 (require_print) +GET /api/v1/presets/{id} — Einzeln 200/404 (require_read) +PUT /api/v1/presets/{id} — Update 200/404/409/422 (require_print) +DELETE /api/v1/presets/{id} — Löschen 204/404 (require_print) + +Preview (preview.png) folgt in einem separaten Modul-Abschnitt. +""" + +from __future__ import annotations + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Response, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.dependencies import AuthContext +from app.auth.scope_deps import require_print, require_read +from app.db.session import get_session +from app.printer_backends.exceptions import ( + ContentTypeDataMismatchError, + UnsupportedTapeError, +) +from app.schemas.preset import ( + PresetCreatePayload, + PresetResponse, + PresetUpdatePayload, +) +from app.services.preset_service import ( + DuplicatePresetNameError, + PresetNotFoundError, + PresetService, + UnsupportedContentTypeError, +) + +router = APIRouter(prefix="/api/v1/presets", tags=["presets"]) + +SessionDep = Annotated[AsyncSession, Depends(get_session)] +ReadAuthDep = Annotated[AuthContext, Depends(require_read)] +WriteAuthDep = Annotated[AuthContext, Depends(require_print)] + + +def _map_validation_error(exc: Exception) -> HTTPException: + """Domain-Fehler auf HTTP-Statuscodes mappen. + + Feste, sichere Fehlermeldungen — kein str(exc) (CWE-209: interne Details + nicht an Clients weitergeben). + """ + if isinstance(exc, UnsupportedTapeError): + return HTTPException( + status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="Tape-Breite nicht unterstützt", + ) + if isinstance(exc, ContentTypeDataMismatchError): + return HTTPException( + status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="field_values deckt content_type nicht ab", + ) + if isinstance(exc, UnsupportedContentTypeError): + return HTTPException( + status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="content_type wird in Presets noch nicht unterstützt", + ) + if isinstance(exc, DuplicatePresetNameError): + return HTTPException(status.HTTP_409_CONFLICT, detail="Preset-Name bereits vergeben") + raise exc # pragma: no cover — unerwarteter Typ + + +@router.get("", response_model=list[PresetResponse]) +async def list_presets(session: SessionDep, _auth: ReadAuthDep) -> list[PresetResponse]: + """Alle gespeicherten Presets zurückgeben.""" + presets = await PresetService(session).list_all() + return [PresetResponse.model_validate(p) for p in presets] + + +@router.post("", response_model=PresetResponse, status_code=status.HTTP_201_CREATED) +async def create_preset( + payload: PresetCreatePayload, session: SessionDep, _auth: WriteAuthDep +) -> PresetResponse: + """Neues Preset anlegen — validiert Tape + ContentType-Pflichtfelder.""" + try: + preset = await PresetService(session).create(payload) + except ( + UnsupportedTapeError, + ContentTypeDataMismatchError, + UnsupportedContentTypeError, + DuplicatePresetNameError, + ) as exc: + raise _map_validation_error(exc) from exc + return PresetResponse.model_validate(preset) + + +@router.get("/{preset_id}", response_model=PresetResponse) +async def get_preset(preset_id: UUID, session: SessionDep, _auth: ReadAuthDep) -> PresetResponse: + """Einzelnes Preset per ID abrufen — 404 wenn nicht gefunden.""" + try: + preset = await PresetService(session).get(preset_id) + except PresetNotFoundError as exc: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Preset nicht gefunden") from exc + return PresetResponse.model_validate(preset) + + +@router.put("/{preset_id}", response_model=PresetResponse) +async def update_preset( + preset_id: UUID, + payload: PresetUpdatePayload, + session: SessionDep, + _auth: WriteAuthDep, +) -> PresetResponse: + """Preset aktualisieren (PUT mit optionalen Feldern — nur gesetzte Felder werden übernommen).""" + try: + preset = await PresetService(session).update(preset_id, payload) + except PresetNotFoundError as exc: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Preset nicht gefunden") from exc + except ( + UnsupportedTapeError, + ContentTypeDataMismatchError, + UnsupportedContentTypeError, + DuplicatePresetNameError, + ) as exc: + raise _map_validation_error(exc) from exc + return PresetResponse.model_validate(preset) + + +@router.delete("/{preset_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_preset(preset_id: UUID, session: SessionDep, _auth: WriteAuthDep) -> Response: + """Preset löschen — 204 bei Erfolg, 404 wenn nicht gefunden.""" + try: + await PresetService(session).delete(preset_id) + except PresetNotFoundError as exc: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Preset nicht gefunden") from exc + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get( + "/{preset_id}/preview.png", + response_class=Response, + responses={ + 200: {"content": {"image/png": {}}, "description": "PNG-Vorschau des Presets"}, + 404: {"description": "Preset nicht gefunden"}, + 409: {"description": "Tape-Breite nicht unterstützt"}, + 422: {"description": "field_values deckt content_type nicht ab"}, + }, +) +async def preview_preset_png(preset_id: UUID, session: SessionDep, _auth: ReadAuthDep) -> Response: + """Preset als PNG-Vorschau rendern. + + 200 image/png, 404 nicht gefunden, 409 Band nicht unterstützt, 422 fehlende Felder. + """ + try: + png = await PresetService(session).render_preview_png(preset_id) + except PresetNotFoundError as exc: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Preset nicht gefunden") from exc + except UnsupportedTapeError as exc: + raise HTTPException( + status.HTTP_409_CONFLICT, + detail="Tape-Breite nicht unterstützt", + ) from exc + except ContentTypeDataMismatchError as exc: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="field_values deckt content_type nicht ab", + ) from exc + return Response(content=png, media_type="image/png") diff --git a/backend/app/main.py b/backend/app/main.py index ccadc76..1a3d78c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -85,6 +85,7 @@ from app.api.routes import webhooks as webhooks_routes from app.api.routes.admin_api_keys import router as admin_api_keys_router from app.api.routes.admin_printers_api import router as admin_printers_api_router +from app.api.routes.presets_api import router as presets_api_router from app.api.routes.print import render_router from app.api.routes.print import router as print_router from app.auth.dependencies import AuthContext @@ -713,6 +714,7 @@ async def readiness( app.include_router(qr_routes.router) app.include_router(admin_api_keys_router) app.include_router(admin_printers_api_router) + app.include_router(presets_api_router) _static_dir = Path(__file__).parent / "static" if _static_dir.exists(): diff --git a/backend/app/models/preset.py b/backend/app/models/preset.py index fc9db42..d5f4481 100644 --- a/backend/app/models/preset.py +++ b/backend/app/models/preset.py @@ -21,6 +21,14 @@ class Preset(SQLModel, table=True): id: UUID = Field(default_factory=uuid4, primary_key=True) name: str + content_type: str = Field( + default="qr_three_lines", + description="Semantischer ContentType (siehe app.schemas.content_type.ContentType).", + ) + tape_mm: int = Field( + default=12, + description="Ziel-Bandbreite in mm (muss in TAPE_GEOMETRY existieren).", + ) printer_id: UUID | None = Field(default=None, foreign_key="printers.id") field_values: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) created_at: datetime = Field( diff --git a/backend/app/repositories/presets.py b/backend/app/repositories/presets.py index c15ef50..a410a29 100644 --- a/backend/app/repositories/presets.py +++ b/backend/app/repositories/presets.py @@ -6,13 +6,21 @@ from typing import Any from uuid import UUID -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import col from app.models.preset import Preset +async def get_by_name(session: AsyncSession, name: str) -> Preset | None: + """Case-insensitiver Lookup über den Namen (für Duplikat-Prüfung).""" + result = await session.execute( + select(Preset).where(func.lower(Preset.name) == name.lower()) + ) + return result.scalars().first() + + async def list_all(session: AsyncSession) -> list[Preset]: result = await session.execute( select(Preset).order_by(col(Preset.created_at)) # col() gives proper Column typing diff --git a/backend/app/schemas/preset.py b/backend/app/schemas/preset.py new file mode 100644 index 0000000..7ef73d4 --- /dev/null +++ b/backend/app/schemas/preset.py @@ -0,0 +1,50 @@ +"""Pydantic-Schemas für die Preset-CRUD-API (Phase 1k.3, Refs #104).""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.schemas.content_type import ContentType + + +class PresetCreatePayload(BaseModel): + """Body für POST /api/v1/presets.""" + + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=255) + content_type: ContentType + tape_mm: int = Field(ge=1) + field_values: dict[str, Any] = Field(default_factory=dict) + printer_id: UUID | None = None + + +class PresetUpdatePayload(BaseModel): + """Body für PUT /api/v1/presets/{id} — PATCH-Semantik, alle Felder optional.""" + + model_config = ConfigDict(extra="forbid") + + name: str | None = Field(default=None, min_length=1, max_length=255) + content_type: ContentType | None = None + tape_mm: int | None = Field(default=None, ge=1) + field_values: dict[str, Any] | None = None + printer_id: UUID | None = None + + +class PresetResponse(BaseModel): + """Response-Darstellung eines Presets.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID + name: str + content_type: ContentType + tape_mm: int + field_values: dict[str, Any] + printer_id: UUID | None + created_at: datetime + updated_at: datetime diff --git a/backend/app/services/layout_engine.py b/backend/app/services/layout_engine.py index 961ed55..040a778 100644 --- a/backend/app/services/layout_engine.py +++ b/backend/app/services/layout_engine.py @@ -48,6 +48,11 @@ class LayoutEngine: complete missing-fields list (one 422 instead of multiple round-trips). """ + @classmethod + def required_fields(cls, content_type: ContentType) -> tuple[str, ...]: + """Öffentlicher Accessor auf die Pflichtfelder eines ContentType.""" + return cls._REQUIRED_FIELDS[content_type] + def render( self, tape_mm: int, diff --git a/backend/app/services/preset_service.py b/backend/app/services/preset_service.py new file mode 100644 index 0000000..eda6453 --- /dev/null +++ b/backend/app/services/preset_service.py @@ -0,0 +1,161 @@ +"""PresetService — Validierung + CRUD für Layout-Presets (Phase 1k.3, Refs #104). + +Presets speichern ein Layout (ContentType + Ziel-Band + Default-Feldwerte). +Der Service validiert gegen TAPE_GEOMETRY und die ContentType-Pflichtfelder, +bevor er ans Repository delegiert. Domain-Errors werden im Router auf +HTTP-Statuscodes gemappt. +""" + +from __future__ import annotations + +import asyncio +import io +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.preset import Preset +from app.printer_backends.exceptions import ( + ContentTypeDataMismatchError, + UnsupportedTapeError, +) +from app.repositories import presets as preset_repo +from app.schemas.content_type import ContentType +from app.schemas.label_data import LabelData +from app.schemas.preset import PresetCreatePayload, PresetUpdatePayload +from app.schemas.tape_geometry import TAPE_GEOMETRY +from app.services.layout_engine import LayoutEngine + + +class PresetNotFoundError(Exception): + def __init__(self, preset_id: UUID) -> None: + self.preset_id = preset_id + super().__init__(f"Preset {preset_id} nicht gefunden") + + +class DuplicatePresetNameError(Exception): + def __init__(self, name: str) -> None: + self.name = name + super().__init__(f"Preset-Name {name!r} bereits vergeben") + + +class UnsupportedContentTypeError(Exception): + def __init__(self, content_type: str) -> None: + self.content_type = content_type + super().__init__(f"content_type {content_type!r} wird in Presets noch nicht unterstützt") + + +def _validate_layout( + content_type: ContentType, tape_mm: int, field_values: dict[str, object] +) -> None: + """Tape + ContentType-Pflichtfelder prüfen. Wirft Domain-Errors bei Verstoß.""" + if content_type == ContentType.QR_WITH_LISTING: + raise UnsupportedContentTypeError(str(content_type)) + if tape_mm not in TAPE_GEOMETRY: + raise UnsupportedTapeError(tape_mm=tape_mm) + required = LayoutEngine.required_fields(content_type) + missing = [ + f + for f in required + if (v := field_values.get(f)) is None or (hasattr(v, "__len__") and len(v) == 0) + ] + if missing: + raise ContentTypeDataMismatchError( + content_type=str(content_type), missing_fields=tuple(missing) + ) + + +class PresetService: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def list_all(self) -> list[Preset]: + return await preset_repo.list_all(self._session) + + async def get(self, preset_id: UUID) -> Preset: + preset = await preset_repo.get(self._session, preset_id) + if preset is None: + raise PresetNotFoundError(preset_id) + return preset + + async def create(self, payload: PresetCreatePayload) -> Preset: + _validate_layout(payload.content_type, payload.tape_mm, payload.field_values) + if await preset_repo.get_by_name(self._session, payload.name) is not None: + raise DuplicatePresetNameError(payload.name) + preset = Preset( + name=payload.name, + content_type=str(payload.content_type), + tape_mm=payload.tape_mm, + field_values=dict(payload.field_values), + printer_id=payload.printer_id, + ) + return await preset_repo.create(self._session, preset) + + async def update(self, preset_id: UUID, payload: PresetUpdatePayload) -> Preset: + existing = await self.get(preset_id) + merged_ct = ( + payload.content_type + if payload.content_type is not None + else ContentType(existing.content_type) + ) + merged_tape = payload.tape_mm if payload.tape_mm is not None else existing.tape_mm + merged_fields = ( + payload.field_values if payload.field_values is not None else existing.field_values + ) + _validate_layout(merged_ct, merged_tape, merged_fields) + if payload.name is not None and payload.name.lower() != existing.name.lower(): + clash = await preset_repo.get_by_name(self._session, payload.name) + if clash is not None: + raise DuplicatePresetNameError(payload.name) + changes: dict[str, object] = {} + if payload.name is not None: + changes["name"] = payload.name + if payload.content_type is not None: + changes["content_type"] = str(payload.content_type) + if payload.tape_mm is not None: + changes["tape_mm"] = payload.tape_mm + if payload.field_values is not None: + changes["field_values"] = dict(payload.field_values) + if "printer_id" in payload.model_fields_set: + changes["printer_id"] = payload.printer_id + updated = await preset_repo.update(self._session, preset_id, **changes) + if updated is None: # pragma: no cover — get() oben garantiert Existenz + raise PresetNotFoundError(preset_id) + return updated + + async def delete(self, preset_id: UUID) -> None: + ok = await preset_repo.delete(self._session, preset_id) + if not ok: + raise PresetNotFoundError(preset_id) + + async def render_preview_png(self, preset_id: UUID) -> bytes: + """Rendert ein Preset als PNG-Bytes. Nutzt die bestehende LayoutEngine. + + Wirft PresetNotFoundError wenn das Preset nicht existiert. + Propagiert UnsupportedTapeError und ContentTypeDataMismatchError + aus dem Render-Pfad unverändert an den Aufrufer. + """ + preset = await self.get(preset_id) + fv = preset.field_values + label = LabelData( + primary_id=fv.get("primary_id"), + title=fv.get("title"), + qr_payload=fv.get("qr_payload"), + source_app="preview", + secondary=tuple(fv.get("secondary", ()) or ()), + items=tuple(fv.get("items", ()) or ()), + ) + engine = LayoutEngine() + tape_mm = preset.tape_mm + content_type = ContentType(preset.content_type) + + def _render() -> bytes: + image = engine.render(tape_mm, content_type, label) + buf = io.BytesIO() + image.save(buf, format="PNG") + return buf.getvalue() + + # asyncio.to_thread: render() + image.save() sind CPU-gebunden (QR-Generierung, + # Font-Rendering, PNG-Enkodierung). Auslagerung in den Thread-Pool verhindert + # das Blockieren des Event-Loops bei aufwändigem Rendering. + return await asyncio.to_thread(_render) diff --git a/backend/tests/api/test_openapi_completeness.py b/backend/tests/api/test_openapi_completeness.py index a9a68f9..5aa896b 100644 --- a/backend/tests/api/test_openapi_completeness.py +++ b/backend/tests/api/test_openapi_completeness.py @@ -140,29 +140,31 @@ def test_json_responses_have_schemas(openapi_schema: dict[str, Any]) -> None: def test_endpoint_count_in_range(openapi_schema: dict[str, Any]) -> None: - """Operation count must be between 28 and 45. + """Operation count must be between 28 und 55. - Expected breakdown: + Erwartete Aufschlüsselung: printers (7) + templates (1) + jobs (6) + lookup (1) + webhooks (2) - + qr-landing (4) = 21 new Phase-6a endpoints - + existing /print (1) + /jobs/{id} (1) + /printer/resume (1) - + /jobs/{id}/resume (1) = 4 legacy print.py endpoints - + /healthz (1) = 1 meta endpoint - + /api/events (1) = 1 Phase-6b SSE endpoint + + qr-landing (4) = 21 neue Phase-6a-Endpunkte + + bestehend /print (1) + /jobs/{id} (1) + /printer/resume (1) + + /jobs/{id}/resume (1) = 4 Legacy-Endpunkte aus print.py + + /healthz (1) = 1 Meta-Endpunkt + + /api/events (1) = 1 Phase-6b SSE-Endpunkt + /api/templates/{key}/preview-png (1) = Phase-1i A + /api/templates/{key}/preview-svg (1) = Phase-1i D - Total = 29+ - - The range 28-45 is intentionally wide to tolerate minor additions - (e.g. a future ``/healthz/db`` probe) without requiring this test to be - updated. It will still catch the case where an entire router is - accidentally unregistered (count drops below 28) or a rogue batch of - undocumented endpoints lands (count exceeds 45). + + /api/v1/presets (2: GET list + POST create) = Phase-1k.3 + + /api/v1/presets/{id} (3: GET + PUT + DELETE) = Phase-1k.3 + + /api/v1/presets/{id}/preview.png (1: GET) = Phase-1k.3 + Gesamt = 35+ + + Die Spanne 28-55 ist bewusst weit, um kleine Erweiterungen (z.B. einen + künftigen ``/healthz/db``-Endpunkt) ohne Test-Update zu tolerieren. + Sie erkennt aber einen versehentlich entfernten Router (< 28) oder + einen unerwarteten Massen-Commit nicht dokumentierter Endpunkte (> 55). """ count = sum(1 for _ in _iter_operations(openapi_schema)) - assert 28 <= count <= 45, ( - f"Operation count {count} is outside the expected 28-45 range. " - "If you intentionally added or removed endpoints, update this test." + assert 28 <= count <= 55, ( + f"Endpoint-Anzahl {count} außerhalb des erwarteten Bereichs 28-55. " + "Bei bewussten Additions/Deletionen diesen Test anpassen." ) @@ -202,10 +204,11 @@ def test_path_segments_are_lowercase(openapi_schema: dict[str, Any]) -> None: non-standard characters in static segments indicate a naming convention violation. - Allowed pattern: ``[a-z][a-z0-9_-]*`` — starts with a letter, followed - by lowercase letters, digits, underscores, or hyphens. + Allowed pattern: ``[a-z][a-z0-9_.-]*`` — starts with a letter, followed + by lowercase letters, digits, underscores, hyphens, or dots. + Dots are permitted for file-extension segments like ``preview.png``. """ - segment_re = re.compile(r"^[a-z][a-z0-9_-]*$") + segment_re = re.compile(r"^[a-z][a-z0-9_.\-]*$") violations: list[str] = [] for path in openapi_schema["paths"]: for segment in path.split("/"): diff --git a/backend/tests/db/test_presets_repo.py b/backend/tests/db/test_presets_repo.py new file mode 100644 index 0000000..29aeb04 --- /dev/null +++ b/backend/tests/db/test_presets_repo.py @@ -0,0 +1,35 @@ +"""Repo-Tests für Preset-Aggregat (Phase 1k.3, Refs #104).""" + +from __future__ import annotations + +import pytest +from app.models.preset import Preset +from app.repositories import presets as preset_repo + + +@pytest.mark.asyncio +async def test_create_and_get_roundtrip_with_layout_fields(session): + created = await preset_repo.create( + session, + Preset(name="Schublade A", content_type="qr_three_lines", tape_mm=12), + ) + fetched = await preset_repo.get(session, created.id) + assert fetched is not None + assert fetched.name == "Schublade A" + assert fetched.content_type == "qr_three_lines" + assert fetched.tape_mm == 12 + + +@pytest.mark.asyncio +async def test_defaults_applied(session): + created = await preset_repo.create(session, Preset(name="Default-Preset")) + assert created.content_type == "qr_three_lines" + assert created.tape_mm == 12 + + +@pytest.mark.asyncio +async def test_get_by_name_case_insensitive(session): + await preset_repo.create(session, Preset(name="Schublade A")) + assert await preset_repo.get_by_name(session, "schublade a") is not None + assert await preset_repo.get_by_name(session, "SCHUBLADE A") is not None + assert await preset_repo.get_by_name(session, "anderer") is None diff --git a/backend/tests/unit/api/test_presets_api.py b/backend/tests/unit/api/test_presets_api.py new file mode 100644 index 0000000..5ed949a --- /dev/null +++ b/backend/tests/unit/api/test_presets_api.py @@ -0,0 +1,226 @@ +"""Unit-Tests für /api/v1/presets CRUD (Phase 1k.3, Refs #104). + +Auth über dependency_overrides — analog test_admin_printers_api.py. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from uuid import uuid4 + +import app.models # noqa: F401 +import pytest +from app.api.routes.presets_api import router as presets_router +from app.auth.dependencies import AuthContext +from app.auth.scope_deps import require_print, require_read +from app.db.engine import _apply_pragmas +from app.db.session import get_session +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy import event +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlmodel import SQLModel + + +def _make_engine(): + eng = create_async_engine("sqlite+aiosqlite:///:memory:") + event.listen(eng.sync_engine, "connect", _apply_pragmas) + return eng + + +@pytest.fixture +async def session(): + eng = _make_engine() + async with eng.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + factory = async_sessionmaker(eng, expire_on_commit=False) + async with factory() as s: + yield s + await eng.dispose() + + +def _build_app(session: AsyncSession, *, with_write: bool = True) -> FastAPI: + app = FastAPI() + app.include_router(presets_router) + + async def _override_session() -> AsyncIterator[AsyncSession]: + yield session + + app.dependency_overrides[get_session] = _override_session + read_ctx = AuthContext(source="api-key", scope="read", api_key_id=uuid4(), ip="192.0.2.1") + app.dependency_overrides[require_read] = lambda: read_ctx + if with_write: + print_ctx = AuthContext(source="api-key", scope="print", api_key_id=uuid4(), ip="192.0.2.1") + app.dependency_overrides[require_print] = lambda: print_ctx + return app + + +def _payload(name: str = "Schublade A") -> dict: + return { + "name": name, + "content_type": "qr_three_lines", + "tape_mm": 12, + "field_values": { + "primary_id": "A1", + "title": "Schrauben", + "qr_payload": "https://x", + "secondary": ["M3"], + }, + } + + +@pytest.mark.asyncio +async def test_create_then_get(session): + app = _build_app(session) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://t") as ac: + r = await ac.post("/api/v1/presets", json=_payload()) + assert r.status_code == 201 + pid = r.json()["id"] + g = await ac.get(f"/api/v1/presets/{pid}") + assert g.status_code == 200 + assert g.json()["content_type"] == "qr_three_lines" + + +@pytest.mark.asyncio +async def test_list_returns_created(session): + app = _build_app(session) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as ac: + await ac.post("/api/v1/presets", json=_payload()) + r = await ac.get("/api/v1/presets") + assert r.status_code == 200 + assert len(r.json()) == 1 + + +@pytest.mark.asyncio +async def test_create_duplicate_name_returns_409(session): + app = _build_app(session) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as ac: + await ac.post("/api/v1/presets", json=_payload()) + r = await ac.post("/api/v1/presets", json=_payload(name="schublade a")) + assert r.status_code == 409 + + +@pytest.mark.asyncio +async def test_create_unsupported_tape_returns_422(session): + app = _build_app(session) + bad = _payload() + bad["tape_mm"] = 999 + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as ac: + r = await ac.post("/api/v1/presets", json=bad) + assert r.status_code == 422 + + +@pytest.mark.asyncio +async def test_create_missing_fields_returns_422(session): + app = _build_app(session) + bad = _payload() + bad["field_values"] = {"primary_id": "A1"} + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as ac: + r = await ac.post("/api/v1/presets", json=bad) + assert r.status_code == 422 + + +@pytest.mark.asyncio +async def test_get_missing_returns_404(session): + app = _build_app(session) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as ac: + r = await ac.get(f"/api/v1/presets/{uuid4()}") + assert r.status_code == 404 + + +@pytest.mark.asyncio +async def test_update_patches(session): + app = _build_app(session) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as ac: + pid = (await ac.post("/api/v1/presets", json=_payload())).json()["id"] + r = await ac.put(f"/api/v1/presets/{pid}", json={"name": "Neu"}) + assert r.status_code == 200 + assert r.json()["name"] == "Neu" + + +@pytest.mark.asyncio +async def test_delete_returns_204_then_404(session): + app = _build_app(session) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as ac: + pid = (await ac.post("/api/v1/presets", json=_payload())).json()["id"] + d = await ac.delete(f"/api/v1/presets/{pid}") + assert d.status_code == 204 + assert (await ac.delete(f"/api/v1/presets/{pid}")).status_code == 404 + + +@pytest.mark.asyncio +async def test_write_requires_print_scope(session): + # Ohne require_print-Override greift die echte Scope-Dependency → 401/403. + app = _build_app(session, with_write=False) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as ac: + r = await ac.post("/api/v1/presets", json=_payload()) + assert r.status_code in (401, 403) + + +@pytest.mark.asyncio +async def test_update_unsupported_tape_returns_422(session): + app = _build_app(session) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as ac: + pid = (await ac.post("/api/v1/presets", json=_payload())).json()["id"] + r = await ac.put(f"/api/v1/presets/{pid}", json={"tape_mm": 999}) + assert r.status_code == 422 + + +@pytest.mark.asyncio +async def test_update_duplicate_name_returns_409(session): + app = _build_app(session) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as ac: + await ac.post("/api/v1/presets", json=_payload(name="Erstes")) + pid2 = (await ac.post("/api/v1/presets", json=_payload(name="Zweites"))).json()["id"] + r = await ac.put(f"/api/v1/presets/{pid2}", json={"name": "erstes"}) + assert r.status_code == 409 + + +@pytest.mark.asyncio +async def test_preview_png_ok(session): + app = _build_app(session) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as ac: + pid = (await ac.post("/api/v1/presets", json=_payload())).json()["id"] + r = await ac.get(f"/api/v1/presets/{pid}/preview.png") + assert r.status_code == 200 + assert r.headers["content-type"] == "image/png" + assert r.content[:8] == b"\x89PNG\r\n\x1a\n" + + +@pytest.mark.asyncio +async def test_preview_png_missing_returns_404(session): + app = _build_app(session) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as ac: + r = await ac.get(f"/api/v1/presets/{uuid4()}/preview.png") + assert r.status_code == 404 + + +@pytest.mark.asyncio +async def test_create_qr_with_listing_returns_422(session): + """POST mit qr_with_listing muss 422 liefern — nicht 500 (Fix 1).""" + app = _build_app(session) + payload = { + "name": "Listing Preset", + "content_type": "qr_with_listing", + "tape_mm": 12, + "field_values": {"qr_payload": "x", "primary_id": "A1", "items": ["item1"]}, + } + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as ac: + r = await ac.post("/api/v1/presets", json=payload) + assert r.status_code == 422 + + +def test_presets_router_registered_in_app(): + """Smoke-Test: presets-Router ist in der echten App registriert. + + create_app() liefert einen _LifespanManager-Wrapper; die FastAPI-Instanz + liegt in ._app (Unwrap-Muster aus tests/api/test_openapi_completeness.py). + """ + from app.main import create_app + + # _LifespanManager-Wrapper aufschalten → innere FastAPI-Instanz + inner_app = create_app()._app # type: ignore[attr-defined] + paths = {r.path for r in inner_app.routes} + assert "/api/v1/presets" in paths + assert "/api/v1/presets/{preset_id}/preview.png" in paths diff --git a/backend/tests/unit/services/test_preset_service.py b/backend/tests/unit/services/test_preset_service.py new file mode 100644 index 0000000..842a16b --- /dev/null +++ b/backend/tests/unit/services/test_preset_service.py @@ -0,0 +1,209 @@ +"""Tests für PresetService + Preset-Schemas (Phase 1k.3, Refs #104).""" + +from __future__ import annotations + +from uuid import uuid4 + +import app.models # noqa: F401 — registriert alle Models +import pytest +import pytest_asyncio +from app.db.engine import _apply_pragmas +from app.printer_backends.exceptions import ( + ContentTypeDataMismatchError, + UnsupportedTapeError, +) +from app.schemas.content_type import ContentType +from app.schemas.preset import PresetCreatePayload, PresetUpdatePayload +from app.services.preset_service import ( + DuplicatePresetNameError, + PresetNotFoundError, + PresetService, + UnsupportedContentTypeError, +) +from pydantic import ValidationError +from sqlalchemy import event +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlmodel import SQLModel + + +@pytest_asyncio.fixture +async def session(): + eng = create_async_engine("sqlite+aiosqlite:///:memory:") + event.listen(eng.sync_engine, "connect", _apply_pragmas) + async with eng.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + factory = async_sessionmaker(eng, expire_on_commit=False) + async with factory() as s: + yield s + await eng.dispose() + + +# --------------------------------------------------------------------------- +# Schema-Tests (aus Task 4) +# --------------------------------------------------------------------------- + + +def test_create_payload_accepts_content_type_enum(): + payload = PresetCreatePayload( + name="Schublade A", + content_type=ContentType.QR_THREE_LINES, + tape_mm=12, + field_values={"primary_id": "A1", "title": "Schrauben", + "qr_payload": "x", "secondary": ["M3"]}, + ) + assert payload.content_type == ContentType.QR_THREE_LINES + assert payload.tape_mm == 12 + + +def test_create_payload_rejects_empty_name(): + with pytest.raises(ValidationError): + PresetCreatePayload(name="", content_type=ContentType.QR_ONLY, tape_mm=12) + + +# --------------------------------------------------------------------------- +# Service-Tests (TDD Phase 1k.3, Step 1 — Refs #104) +# --------------------------------------------------------------------------- + + +def _valid_three_line_fields() -> dict: + return {"primary_id": "A1", "title": "Schrauben", + "qr_payload": "https://x", "secondary": ["M3"]} + + +@pytest.mark.asyncio +async def test_create_persists_and_returns(session): + svc = PresetService(session) + preset = await svc.create(PresetCreatePayload( + name="Schublade A", content_type=ContentType.QR_THREE_LINES, + tape_mm=12, field_values=_valid_three_line_fields())) + assert preset.id is not None + assert preset.content_type == "qr_three_lines" + + +@pytest.mark.asyncio +async def test_create_rejects_unsupported_tape(session): + svc = PresetService(session) + with pytest.raises(UnsupportedTapeError): + await svc.create(PresetCreatePayload( + name="Bad Tape", content_type=ContentType.QR_ONLY, + tape_mm=999, field_values={"qr_payload": "x"})) + + +@pytest.mark.asyncio +async def test_create_rejects_missing_required_fields(session): + svc = PresetService(session) + with pytest.raises(ContentTypeDataMismatchError): + await svc.create(PresetCreatePayload( + name="Missing", content_type=ContentType.QR_THREE_LINES, + tape_mm=12, field_values={"primary_id": "A1"})) # title/qr/secondary fehlen + + +@pytest.mark.asyncio +async def test_create_rejects_duplicate_name_case_insensitive(session): + svc = PresetService(session) + await svc.create(PresetCreatePayload( + name="Schublade A", content_type=ContentType.QR_ONLY, + tape_mm=12, field_values={"qr_payload": "x"})) + with pytest.raises(DuplicatePresetNameError): + await svc.create(PresetCreatePayload( + name="schublade a", content_type=ContentType.QR_ONLY, + tape_mm=12, field_values={"qr_payload": "x"})) + + +@pytest.mark.asyncio +async def test_update_patches_name(session): + svc = PresetService(session) + created = await svc.create(PresetCreatePayload( + name="Alt", content_type=ContentType.QR_ONLY, + tape_mm=12, field_values={"qr_payload": "x"})) + updated = await svc.update(created.id, PresetUpdatePayload(name="Neu")) + assert updated.name == "Neu" + + +@pytest.mark.asyncio +async def test_update_missing_raises_not_found(session): + svc = PresetService(session) + with pytest.raises(PresetNotFoundError): + await svc.update(uuid4(), PresetUpdatePayload(name="X")) + + +@pytest.mark.asyncio +async def test_delete_missing_raises_not_found(session): + svc = PresetService(session) + with pytest.raises(PresetNotFoundError): + await svc.delete(uuid4()) + + +@pytest.mark.asyncio +async def test_delete_removes(session): + svc = PresetService(session) + created = await svc.create(PresetCreatePayload( + name="Weg", content_type=ContentType.QR_ONLY, + tape_mm=12, field_values={"qr_payload": "x"})) + await svc.delete(created.id) + with pytest.raises(PresetNotFoundError): + await svc.get(created.id) + + +@pytest.mark.asyncio +async def test_update_rejects_incompatible_content_type_change(session): + svc = PresetService(session) + created = await svc.create(PresetCreatePayload( + name="Only QR", content_type=ContentType.QR_ONLY, + tape_mm=12, field_values={"qr_payload": "x"})) + with pytest.raises(ContentTypeDataMismatchError): + await svc.update(created.id, PresetUpdatePayload( + content_type=ContentType.QR_THREE_LINES)) + + +@pytest.mark.asyncio +async def test_update_rejects_duplicate_name_case_insensitive(session): + svc = PresetService(session) + await svc.create(PresetCreatePayload( + name="Bestehend", content_type=ContentType.QR_ONLY, + tape_mm=12, field_values={"qr_payload": "x"})) + other = await svc.create(PresetCreatePayload( + name="Anderer", content_type=ContentType.QR_ONLY, + tape_mm=12, field_values={"qr_payload": "x"})) + with pytest.raises(DuplicatePresetNameError): + await svc.update(other.id, PresetUpdatePayload(name="bestehend")) + + +# --------------------------------------------------------------------------- +# Preview-Rendering-Tests (TDD Phase 1k.3, Task 6 — Refs #104) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_render_preview_png_returns_png_bytes(session): + svc = PresetService(session) + created = await svc.create(PresetCreatePayload( + name="Preview", content_type=ContentType.QR_THREE_LINES, + tape_mm=12, field_values=_valid_three_line_fields())) + png = await svc.render_preview_png(created.id) + assert png[:8] == b"\x89PNG\r\n\x1a\n" # PNG-Magic + + +@pytest.mark.asyncio +async def test_render_preview_missing_raises_not_found(session): + svc = PresetService(session) + with pytest.raises(PresetNotFoundError): + await svc.render_preview_png(uuid4()) + + +# --------------------------------------------------------------------------- +# Fix 1: qr_with_listing wird abgelehnt (Refs #104) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_rejects_qr_with_listing(session): + """qr_with_listing-Presets werden mit UnsupportedContentTypeError abgelehnt (Fix 1).""" + svc = PresetService(session) + with pytest.raises(UnsupportedContentTypeError): + await svc.create(PresetCreatePayload( + name="Listing Preset", + content_type=ContentType.QR_WITH_LISTING, + tape_mm=12, + field_values={"qr_payload": "x", "primary_id": "A1", "items": ["item1"]}, + )) diff --git a/docs/superpowers/plans/2026-06-23-phase-1k3-user-presets.md b/docs/superpowers/plans/2026-06-23-phase-1k3-user-presets.md new file mode 100644 index 0000000..325c1ce --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-phase-1k3-user-presets.md @@ -0,0 +1,1288 @@ +# Phase 1k.3 — User-Presets (Hub-Backend) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Die ungenutzte `presets`-Tabelle zum Layout-Preset-Store ausbauen und eine CRUD-API + PNG-Preview bereitstellen, ohne den Druck-Pfad zu berühren. + +**Architecture:** `presets`-Tabelle wird um `content_type` + `tape_mm` erweitert. Ein neuer `PresetService` validiert gegen `ContentType` / `TAPE_GEOMETRY` und kapselt Domain-Errors; ein neuer Router `presets_api.py` mappt CRUD + `preview.png`. Der Preview-Endpoint ruft die bestehende `LayoutEngine` auf — kein neuer Render-Pfad. Der Druck-Pfad (`POST /api/print`) bleibt unverändert. + +**Tech Stack:** FastAPI, SQLModel/SQLAlchemy (async, SQLite + aiosqlite), Alembic, Pydantic v2, pytest + pytest-asyncio + httpx ASGITransport. + +## Global Constraints + +- Spec: `docs/superpowers/specs/2026-06-23-user-presets-design.md` — verbindlich. +- TDD strict: Test zuerst schreiben, fehlschlagen sehen, dann minimal implementieren. +- Mutation-Logic Coverage ≥ 85% (`scripts/coverage-gate-strict.sh`). +- mypy strict + ruff: kein `Any` in öffentlichen Signaturen außer wo bestehend (`field_values: dict[str, Any]` ist erlaubt — bestehendes Schema). +- Deutsche Kommentare mit echten Umlauten (ä/ö/ü/ß). +- Test-IPs aus RFC-5737 (`192.0.2.x`) — Repo-Konvention (hier kaum relevant, Presets sind druckerlos). +- `content_type`-Default ist **`qr_three_lines`** (User-Vorgabe). +- Writes erfordern `require_print`, Reads/Preview `require_read`. +- Branch: `feat/phase-1k3-user-presets` (existiert bereits, Spec ist committed). +- Jeder Commit referenziert `Refs #104` (oder `#101`). +- Alle Pfade relativ zu Repo-Root `/opt/repos/label-printer-hub`. Tests laufen aus `backend/` (`cd backend && pytest ...`). + +--- + +## File Structure + +| Datei | Aktion | Verantwortung | +|-------|--------|---------------| +| `backend/app/models/preset.py` | Modify | Spalten `content_type` + `tape_mm` ergänzen | +| `backend/alembic/versions/_add_preset_layout_columns.py` | Create | Migration: 2 Spalten auf `presets` | +| `backend/app/repositories/presets.py` | Modify | `get_by_name` ergänzen (case-insensitive) | +| `backend/app/schemas/preset.py` | Create | Create/Update-Payloads + Response-Schema | +| `backend/app/services/preset_service.py` | Create | Domain-Errors + Validierung + CRUD-Orchestrierung + Preview-Render | +| `backend/app/api/routes/presets_api.py` | Create | HTTP-Router CRUD + `preview.png`, Auth-Deps | +| `backend/app/main.py` | Modify | Router registrieren | +| `backend/tests/db/test_presets_repo.py` | Create | Repo-Roundtrip + `get_by_name` | +| `backend/tests/db/test_preset_layout_migration.py` | Create | Migration fügt Spalten hinzu | +| `backend/tests/unit/services/test_preset_service.py` | Create | Validierung + Mutationen + Preview | +| `backend/tests/unit/api/test_presets_api.py` | Create | CRUD-Routes + Auth-Enforcement + Preview | + +--- + +## Task 1: Preset-Model um `content_type` + `tape_mm` erweitern + +**Files:** +- Modify: `backend/app/models/preset.py` +- Test: `backend/tests/db/test_presets_repo.py` + +**Interfaces:** +- Produces: `Preset` SQLModel mit neuen Feldern `content_type: str` (Default `"qr_three_lines"`), `tape_mm: int` (Default `12`). + +- [ ] **Step 1: Failing test — Preset mit neuen Feldern round-trippt** + +Erstelle `backend/tests/db/test_presets_repo.py`: + +```python +"""Repo-Tests für Preset-Aggregat (Phase 1k.3, Refs #104).""" + +from __future__ import annotations + +import pytest +from app.models.preset import Preset +from app.repositories import presets as preset_repo + + +@pytest.mark.asyncio +async def test_create_and_get_roundtrip_with_layout_fields(session): + created = await preset_repo.create( + session, + Preset(name="Schublade A", content_type="qr_three_lines", tape_mm=12), + ) + fetched = await preset_repo.get(session, created.id) + assert fetched is not None + assert fetched.name == "Schublade A" + assert fetched.content_type == "qr_three_lines" + assert fetched.tape_mm == 12 + + +@pytest.mark.asyncio +async def test_defaults_applied(session): + created = await preset_repo.create(session, Preset(name="Default-Preset")) + assert created.content_type == "qr_three_lines" + assert created.tape_mm == 12 +``` + +Die `session`-Fixture kommt aus `backend/tests/db/conftest.py` (in-memory SQLite, `SQLModel.metadata.create_all`). + +- [ ] **Step 2: Run test — erwartet FAIL** + +Run: `cd backend && python -m pytest tests/db/test_presets_repo.py -v` +Expected: FAIL — `TypeError: 'content_type' is an invalid keyword argument for Preset` (Feld existiert noch nicht). + +- [ ] **Step 3: Model erweitern** + +In `backend/app/models/preset.py` innerhalb `class Preset`, nach dem `name`-Feld einfügen: + +```python + content_type: str = Field( + default="qr_three_lines", + description="Semantischer ContentType (siehe app.schemas.content_type.ContentType).", + ) + tape_mm: int = Field( + default=12, + description="Ziel-Bandbreite in mm (muss in TAPE_GEOMETRY existieren).", + ) +``` + +- [ ] **Step 4: Run test — erwartet PASS** + +Run: `cd backend && python -m pytest tests/db/test_presets_repo.py -v` +Expected: PASS (2 passed). + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/models/preset.py backend/tests/db/test_presets_repo.py +git commit -m "feat(presets): content_type + tape_mm auf Preset-Model (Refs #104)" +``` + +--- + +## Task 2: Alembic-Migration für die zwei Spalten + +**Files:** +- Create: `backend/alembic/versions/a1b2c3d4e5f6_add_preset_layout_columns.py` +- Test: `backend/tests/db/test_preset_layout_migration.py` + +**Interfaces:** +- Consumes: aktueller Migrations-Head `42fbd015698d` (als `down_revision`). +- Produces: Migration-Modul mit `upgrade()`/`downgrade()`, `revision = "a1b2c3d4e5f6"`. + +- [ ] **Step 1: Failing test — Migration fügt Spalten zu bestehender presets-Tabelle hinzu** + +Erstelle `backend/tests/db/test_preset_layout_migration.py`: + +```python +"""Migrationstest: presets bekommt content_type + tape_mm (Phase 1k.3, Refs #104).""" + +from __future__ import annotations + +import importlib + +import pytest +import sqlalchemy as sa +from sqlalchemy.ext.asyncio import create_async_engine + +MIGRATION = "alembic.versions.a1b2c3d4e5f6_add_preset_layout_columns" + + +@pytest.mark.asyncio +async def test_upgrade_adds_columns_to_existing_presets_table(): + eng = create_async_engine("sqlite+aiosqlite:///:memory:") + # Minimal-Vorzustand: presets-Tabelle ohne die neuen Spalten. + async with eng.begin() as conn: + await conn.execute( + sa.text( + "CREATE TABLE presets (" + "id CHAR(32) PRIMARY KEY, name VARCHAR, printer_id CHAR(32), " + "field_values JSON, created_at DATETIME, updated_at DATETIME)" + ) + ) + mig = importlib.import_module(MIGRATION) + await conn.run_sync(lambda sync_conn: _run_upgrade(sync_conn, mig)) + cols = await conn.run_sync( + lambda c: {row[1] for row in c.execute(sa.text("PRAGMA table_info(presets)"))} + ) + await eng.dispose() + assert "content_type" in cols + assert "tape_mm" in cols + + +def _run_upgrade(sync_conn, mig) -> None: + from alembic.migration import MigrationContext + from alembic.operations import Operations + + ctx = MigrationContext.configure(sync_conn) + with Operations.context(ctx): + mig.upgrade() +``` + +- [ ] **Step 2: Run test — erwartet FAIL** + +Run: `cd backend && python -m pytest tests/db/test_preset_layout_migration.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'alembic.versions.a1b2c3d4e5f6_add_preset_layout_columns'`. + +- [ ] **Step 3: Migration schreiben** + +Erstelle `backend/alembic/versions/a1b2c3d4e5f6_add_preset_layout_columns.py`: + +```python +"""add_preset_layout_columns + +Phase 1k.3 (Refs #104): presets-Tabelle bekommt content_type + tape_mm, damit +Presets ein Layout (ContentType + Ziel-Band) speichern. Tabelle ist in +Produktion leer — server_default deckt eventuelle Bestandszeilen sauber ab. + +Revision ID: a1b2c3d4e5f6 +Revises: 42fbd015698d +Create Date: 2026-06-23 + +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "a1b2c3d4e5f6" +down_revision: str | Sequence[str] | None = "42fbd015698d" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + with op.batch_alter_table("presets", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "content_type", + sa.String(length=32), + nullable=False, + server_default="qr_three_lines", + ) + ) + batch_op.add_column( + sa.Column( + "tape_mm", + sa.Integer(), + nullable=False, + server_default="12", + ) + ) + + +def downgrade() -> None: + with op.batch_alter_table("presets", schema=None) as batch_op: + batch_op.drop_column("tape_mm") + batch_op.drop_column("content_type") +``` + +- [ ] **Step 4: Run test — erwartet PASS** + +Run: `cd backend && python -m pytest tests/db/test_preset_layout_migration.py -v` +Expected: PASS. + +- [ ] **Step 5: Migrations-Kette prüfen** + +Run: `cd backend && python -m alembic heads` +Expected: genau ein Head `a1b2c3d4e5f6 (head)`. Falls mehrere Heads erscheinen, ist `down_revision` falsch — auf den vorher einzigen Head zeigen lassen. + +- [ ] **Step 6: Commit** + +```bash +git add backend/alembic/versions/a1b2c3d4e5f6_add_preset_layout_columns.py backend/tests/db/test_preset_layout_migration.py +git commit -m "feat(presets): Alembic-Migration content_type + tape_mm (Refs #104)" +``` + +--- + +## Task 3: Repository `get_by_name` (case-insensitive) + +**Files:** +- Modify: `backend/app/repositories/presets.py` +- Test: `backend/tests/db/test_presets_repo.py` + +**Interfaces:** +- Produces: `async def get_by_name(session, name: str) -> Preset | None` — case-insensitiver Vergleich auf `name`. + +- [ ] **Step 1: Failing test — get_by_name findet case-insensitiv** + +Ans Ende von `backend/tests/db/test_presets_repo.py` anhängen: + +```python +@pytest.mark.asyncio +async def test_get_by_name_case_insensitive(session): + await preset_repo.create(session, Preset(name="Schublade A")) + assert await preset_repo.get_by_name(session, "schublade a") is not None + assert await preset_repo.get_by_name(session, "SCHUBLADE A") is not None + assert await preset_repo.get_by_name(session, "anderer") is None +``` + +- [ ] **Step 2: Run test — erwartet FAIL** + +Run: `cd backend && python -m pytest tests/db/test_presets_repo.py::test_get_by_name_case_insensitive -v` +Expected: FAIL — `AttributeError: module 'app.repositories.presets' has no attribute 'get_by_name'`. + +- [ ] **Step 3: `get_by_name` implementieren** + +In `backend/app/repositories/presets.py`, oberhalb von `create`, einfügen (und `func` importieren): + +```python +from sqlalchemy import func, select # ersetzt den bestehenden `from sqlalchemy import select` + + +async def get_by_name(session: AsyncSession, name: str) -> Preset | None: + """Case-insensitiver Lookup über den Namen (für Duplikat-Prüfung).""" + result = await session.execute( + select(Preset).where(func.lower(Preset.name) == name.lower()) + ) + return result.scalars().first() +``` + +- [ ] **Step 4: Run test — erwartet PASS** + +Run: `cd backend && python -m pytest tests/db/test_presets_repo.py -v` +Expected: PASS (alle Tests in der Datei). + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/repositories/presets.py backend/tests/db/test_presets_repo.py +git commit -m "feat(presets): get_by_name case-insensitive im Repository (Refs #104)" +``` + +--- + +## Task 4: Pydantic-Schemas (Create/Update/Response) + +**Files:** +- Create: `backend/app/schemas/preset.py` +- Test: `backend/tests/unit/services/test_preset_service.py` (Schema-Teil — Datei wird hier angelegt) + +**Interfaces:** +- Produces: + - `PresetCreatePayload(name: str, content_type: ContentType, tape_mm: int, field_values: dict[str, Any] = {}, printer_id: UUID | None = None)` + - `PresetUpdatePayload(name | content_type | tape_mm | field_values | printer_id, alle optional)` + - `PresetResponse(id, name, content_type, tape_mm, field_values, printer_id, created_at, updated_at)` + +- [ ] **Step 1: Failing test — Payload akzeptiert ContentType-Enum, lehnt leeren Namen ab** + +Erstelle `backend/tests/unit/services/test_preset_service.py`: + +```python +"""Tests für PresetService + Preset-Schemas (Phase 1k.3, Refs #104).""" + +from __future__ import annotations + +import pytest +from app.schemas.content_type import ContentType +from app.schemas.preset import PresetCreatePayload +from pydantic import ValidationError + + +def test_create_payload_accepts_content_type_enum(): + payload = PresetCreatePayload( + name="Schublade A", + content_type=ContentType.QR_THREE_LINES, + tape_mm=12, + field_values={"primary_id": "A1", "title": "Schrauben", + "qr_payload": "x", "secondary": ["M3"]}, + ) + assert payload.content_type == ContentType.QR_THREE_LINES + assert payload.tape_mm == 12 + + +def test_create_payload_rejects_empty_name(): + with pytest.raises(ValidationError): + PresetCreatePayload(name="", content_type=ContentType.QR_ONLY, tape_mm=12) +``` + +- [ ] **Step 2: Run test — erwartet FAIL** + +Run: `cd backend && python -m pytest tests/unit/services/test_preset_service.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'app.schemas.preset'`. + +- [ ] **Step 3: Schemas schreiben** + +Erstelle `backend/app/schemas/preset.py`: + +```python +"""Pydantic-Schemas für die Preset-CRUD-API (Phase 1k.3, Refs #104).""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.schemas.content_type import ContentType + + +class PresetCreatePayload(BaseModel): + """Body für POST /api/v1/presets.""" + + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=255) + content_type: ContentType + tape_mm: int = Field(ge=1) + field_values: dict[str, Any] = Field(default_factory=dict) + printer_id: UUID | None = None + + +class PresetUpdatePayload(BaseModel): + """Body für PUT /api/v1/presets/{id} — PATCH-Semantik, alle Felder optional.""" + + model_config = ConfigDict(extra="forbid") + + name: str | None = Field(default=None, min_length=1, max_length=255) + content_type: ContentType | None = None + tape_mm: int | None = Field(default=None, ge=1) + field_values: dict[str, Any] | None = None + printer_id: UUID | None = None + + +class PresetResponse(BaseModel): + """Response-Darstellung eines Presets.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID + name: str + content_type: ContentType + tape_mm: int + field_values: dict[str, Any] + printer_id: UUID | None + created_at: datetime + updated_at: datetime +``` + +- [ ] **Step 4: Run test — erwartet PASS** + +Run: `cd backend && python -m pytest tests/unit/services/test_preset_service.py -v` +Expected: PASS (2 passed). + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/schemas/preset.py backend/tests/unit/services/test_preset_service.py +git commit -m "feat(presets): Create/Update/Response-Schemas (Refs #104)" +``` + +--- + +## Task 5: PresetService — Domain-Errors + Validierung + CRUD + +**Files:** +- Create: `backend/app/services/preset_service.py` +- Modify: `backend/app/services/layout_engine.py` (kleiner öffentlicher Accessor `required_fields`) +- Test: `backend/tests/unit/services/test_preset_service.py` + +**Interfaces:** +- Consumes: `PresetCreatePayload`, `PresetUpdatePayload`, `app.repositories.presets`, `TAPE_GEOMETRY`, `ContentType`, `LayoutEngine.required_fields`. +- Produces: + - Errors: `PresetNotFoundError(preset_id: UUID)`, `DuplicatePresetNameError(name: str)`, `UnsupportedTapeError` (wiederverwendet aus `app.printer_backends.exceptions`), `ContentTypeDataMismatchError` (wiederverwendet). + - `class PresetService(session)` mit `async create(payload) -> Preset`, `async get(preset_id) -> Preset`, `async list_all() -> list[Preset]`, `async update(preset_id, payload) -> Preset`, `async delete(preset_id) -> None`. + - Klassenmethode `LayoutEngine.required_fields(content_type) -> tuple[str, ...]`. + +- [ ] **Step 1: Failing test — Service validiert tape + required-fields + Name-Duplikat, und CRUD** + +Ans Ende von `backend/tests/unit/services/test_preset_service.py` anhängen: + +```python +import pytest_asyncio +from app.printer_backends.exceptions import ( + ContentTypeDataMismatchError, + UnsupportedTapeError, +) +from app.schemas.preset import PresetUpdatePayload +from app.services.preset_service import ( + DuplicatePresetNameError, + PresetNotFoundError, + PresetService, +) +from uuid import uuid4 + + +def _valid_three_line_fields() -> dict: + return {"primary_id": "A1", "title": "Schrauben", + "qr_payload": "https://x", "secondary": ["M3"]} + + +@pytest.mark.asyncio +async def test_create_persists_and_returns(session): + svc = PresetService(session) + preset = await svc.create(PresetCreatePayload( + name="Schublade A", content_type=ContentType.QR_THREE_LINES, + tape_mm=12, field_values=_valid_three_line_fields())) + assert preset.id is not None + assert preset.content_type == "qr_three_lines" + + +@pytest.mark.asyncio +async def test_create_rejects_unsupported_tape(session): + svc = PresetService(session) + with pytest.raises(UnsupportedTapeError): + await svc.create(PresetCreatePayload( + name="Bad Tape", content_type=ContentType.QR_ONLY, + tape_mm=999, field_values={"qr_payload": "x"})) + + +@pytest.mark.asyncio +async def test_create_rejects_missing_required_fields(session): + svc = PresetService(session) + with pytest.raises(ContentTypeDataMismatchError): + await svc.create(PresetCreatePayload( + name="Missing", content_type=ContentType.QR_THREE_LINES, + tape_mm=12, field_values={"primary_id": "A1"})) # title/qr/secondary fehlen + + +@pytest.mark.asyncio +async def test_create_rejects_duplicate_name_case_insensitive(session): + svc = PresetService(session) + await svc.create(PresetCreatePayload( + name="Schublade A", content_type=ContentType.QR_ONLY, + tape_mm=12, field_values={"qr_payload": "x"})) + with pytest.raises(DuplicatePresetNameError): + await svc.create(PresetCreatePayload( + name="schublade a", content_type=ContentType.QR_ONLY, + tape_mm=12, field_values={"qr_payload": "x"})) + + +@pytest.mark.asyncio +async def test_update_patches_name(session): + svc = PresetService(session) + created = await svc.create(PresetCreatePayload( + name="Alt", content_type=ContentType.QR_ONLY, + tape_mm=12, field_values={"qr_payload": "x"})) + updated = await svc.update(created.id, PresetUpdatePayload(name="Neu")) + assert updated.name == "Neu" + + +@pytest.mark.asyncio +async def test_update_missing_raises_not_found(session): + svc = PresetService(session) + with pytest.raises(PresetNotFoundError): + await svc.update(uuid4(), PresetUpdatePayload(name="X")) + + +@pytest.mark.asyncio +async def test_delete_missing_raises_not_found(session): + svc = PresetService(session) + with pytest.raises(PresetNotFoundError): + await svc.delete(uuid4()) + + +@pytest.mark.asyncio +async def test_delete_removes(session): + svc = PresetService(session) + created = await svc.create(PresetCreatePayload( + name="Weg", content_type=ContentType.QR_ONLY, + tape_mm=12, field_values={"qr_payload": "x"})) + await svc.delete(created.id) + with pytest.raises(PresetNotFoundError): + await svc.get(created.id) +``` + +Die `session`-Fixture: lege eine lokale Fixture am Anfang der Datei an (analog `tests/db/conftest.py`), da diese Datei unter `tests/unit/services/` liegt: + +```python +import app.models # noqa: F401 — registriert alle Models +import pytest_asyncio +from app.db.engine import _apply_pragmas +from sqlalchemy import event +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlmodel import SQLModel + + +@pytest_asyncio.fixture +async def session(): + eng = create_async_engine("sqlite+aiosqlite:///:memory:") + event.listen(eng.sync_engine, "connect", _apply_pragmas) + async with eng.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + factory = async_sessionmaker(eng, expire_on_commit=False) + async with factory() as s: + yield s + await eng.dispose() +``` + +- [ ] **Step 2: Run test — erwartet FAIL** + +Run: `cd backend && python -m pytest tests/unit/services/test_preset_service.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'app.services.preset_service'`. + +- [ ] **Step 3: `required_fields`-Accessor auf LayoutEngine ergänzen** + +In `backend/app/services/layout_engine.py`, als Methode der Klasse `LayoutEngine` (nach `render`), einfügen: + +```python + @classmethod + def required_fields(cls, content_type: ContentType) -> tuple[str, ...]: + """Öffentlicher Accessor auf die Pflichtfelder eines ContentType.""" + return cls._REQUIRED_FIELDS[content_type] +``` + +- [ ] **Step 4: PresetService schreiben** + +Erstelle `backend/app/services/preset_service.py`: + +```python +"""PresetService — Validierung + CRUD für Layout-Presets (Phase 1k.3, Refs #104). + +Presets speichern ein Layout (ContentType + Ziel-Band + Default-Feldwerte). +Der Service validiert gegen TAPE_GEOMETRY und die ContentType-Pflichtfelder, +bevor er ans Repository delegiert. Domain-Errors werden im Router auf +HTTP-Statuscodes gemappt. +""" + +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.preset import Preset +from app.printer_backends.exceptions import ( + ContentTypeDataMismatchError, + UnsupportedTapeError, +) +from app.repositories import presets as preset_repo +from app.schemas.content_type import ContentType +from app.schemas.preset import PresetCreatePayload, PresetUpdatePayload +from app.schemas.tape_geometry import TAPE_GEOMETRY +from app.services.layout_engine import LayoutEngine + + +class PresetNotFoundError(Exception): + def __init__(self, preset_id: UUID) -> None: + self.preset_id = preset_id + super().__init__(f"Preset {preset_id} nicht gefunden") + + +class DuplicatePresetNameError(Exception): + def __init__(self, name: str) -> None: + self.name = name + super().__init__(f"Preset-Name {name!r} bereits vergeben") + + +def _validate_layout( + content_type: ContentType, tape_mm: int, field_values: dict[str, object] +) -> None: + """Tape + ContentType-Pflichtfelder prüfen. Wirft Domain-Errors bei Verstoß.""" + if tape_mm not in TAPE_GEOMETRY: + raise UnsupportedTapeError(tape_mm=tape_mm) + required = LayoutEngine.required_fields(content_type) + missing = [ + f + for f in required + if (v := field_values.get(f)) is None + or (hasattr(v, "__len__") and len(v) == 0) + ] + if missing: + raise ContentTypeDataMismatchError( + content_type=str(content_type), missing_fields=tuple(missing) + ) + + +class PresetService: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def list_all(self) -> list[Preset]: + return await preset_repo.list_all(self._session) + + async def get(self, preset_id: UUID) -> Preset: + preset = await preset_repo.get(self._session, preset_id) + if preset is None: + raise PresetNotFoundError(preset_id) + return preset + + async def create(self, payload: PresetCreatePayload) -> Preset: + _validate_layout(payload.content_type, payload.tape_mm, payload.field_values) + if await preset_repo.get_by_name(self._session, payload.name) is not None: + raise DuplicatePresetNameError(payload.name) + preset = Preset( + name=payload.name, + content_type=str(payload.content_type), + tape_mm=payload.tape_mm, + field_values=dict(payload.field_values), + printer_id=payload.printer_id, + ) + return await preset_repo.create(self._session, preset) + + async def update(self, preset_id: UUID, payload: PresetUpdatePayload) -> Preset: + existing = await self.get(preset_id) + merged_ct = payload.content_type or ContentType(existing.content_type) + merged_tape = payload.tape_mm if payload.tape_mm is not None else existing.tape_mm + merged_fields = ( + payload.field_values + if payload.field_values is not None + else existing.field_values + ) + _validate_layout(merged_ct, merged_tape, merged_fields) + if payload.name is not None and payload.name.lower() != existing.name.lower(): + clash = await preset_repo.get_by_name(self._session, payload.name) + if clash is not None: + raise DuplicatePresetNameError(payload.name) + changes: dict[str, object] = {} + if payload.name is not None: + changes["name"] = payload.name + if payload.content_type is not None: + changes["content_type"] = str(payload.content_type) + if payload.tape_mm is not None: + changes["tape_mm"] = payload.tape_mm + if payload.field_values is not None: + changes["field_values"] = dict(payload.field_values) + if "printer_id" in payload.model_fields_set: + changes["printer_id"] = payload.printer_id + updated = await preset_repo.update(self._session, preset_id, **changes) + if updated is None: # pragma: no cover — get() oben garantiert Existenz + raise PresetNotFoundError(preset_id) + return updated + + async def delete(self, preset_id: UUID) -> None: + ok = await preset_repo.delete(self._session, preset_id) + if not ok: + raise PresetNotFoundError(preset_id) +``` + +- [ ] **Step 5: Run test — erwartet PASS** + +Run: `cd backend && python -m pytest tests/unit/services/test_preset_service.py -v` +Expected: PASS (alle Service-Tests). + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/services/preset_service.py backend/app/services/layout_engine.py backend/tests/unit/services/test_preset_service.py +git commit -m "feat(presets): PresetService mit Validierung + CRUD (Refs #104)" +``` + +--- + +## Task 6: Preview-Rendering im Service (Preset → PNG) + +**Files:** +- Modify: `backend/app/services/preset_service.py` +- Test: `backend/tests/unit/services/test_preset_service.py` + +**Interfaces:** +- Produces: `async PresetService.render_preview_png(preset_id: UUID) -> bytes` — lädt Preset, mappt `field_values` → `LabelData`, rendert via `LayoutEngine` zu PNG-Bytes. Wirft `PresetNotFoundError` (404), `UnsupportedTapeError` (409), `ContentTypeDataMismatchError` (422). + +- [ ] **Step 1: Failing test — Preview liefert PNG-Bytes** + +Ans Ende von `backend/tests/unit/services/test_preset_service.py` anhängen: + +```python +@pytest.mark.asyncio +async def test_render_preview_png_returns_png_bytes(session): + svc = PresetService(session) + created = await svc.create(PresetCreatePayload( + name="Preview", content_type=ContentType.QR_THREE_LINES, + tape_mm=12, field_values=_valid_three_line_fields())) + png = await svc.render_preview_png(created.id) + assert png[:8] == b"\x89PNG\r\n\x1a\n" # PNG-Magic + + +@pytest.mark.asyncio +async def test_render_preview_missing_raises_not_found(session): + svc = PresetService(session) + with pytest.raises(PresetNotFoundError): + await svc.render_preview_png(uuid4()) +``` + +- [ ] **Step 2: Run test — erwartet FAIL** + +Run: `cd backend && python -m pytest tests/unit/services/test_preset_service.py -k preview -v` +Expected: FAIL — `AttributeError: 'PresetService' object has no attribute 'render_preview_png'`. + +- [ ] **Step 3: Methode implementieren** + +In `backend/app/services/preset_service.py` die Imports ergänzen: + +```python +import io + +from app.schemas.label_data import LabelData +``` + +Und in `class PresetService` die Methode ergänzen: + +```python + async def render_preview_png(self, preset_id: UUID) -> bytes: + """Rendert ein Preset als PNG. Nutzt die bestehende LayoutEngine.""" + preset = await self.get(preset_id) + fv = preset.field_values + label = LabelData( + primary_id=fv.get("primary_id"), + title=fv.get("title"), + qr_payload=fv.get("qr_payload"), + source_app="preview", + secondary=tuple(fv.get("secondary", ()) or ()), + items=tuple(fv.get("items", ()) or ()), + ) + engine = LayoutEngine() + image = engine.render(preset.tape_mm, ContentType(preset.content_type), label) + buf = io.BytesIO() + image.save(buf, format="PNG") + return buf.getvalue() +``` + +Hinweis: Falls `LabelData.items` typisierte `LabelDataItem`-Objekte erwartet, im ersten Wurf nur die ContentTypes ohne `items` (alle außer `qr_with_listing`) durch die Preview unterstützen — `items` bleibt leer. `qr_with_listing`-Preview ist Folge-Arbeit (im Out-of-Scope der Spec implizit, hier explizit als `# pragma`-frei dokumentiert). + +- [ ] **Step 4: Run test — erwartet PASS** + +Run: `cd backend && python -m pytest tests/unit/services/test_preset_service.py -v` +Expected: PASS (alle). + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/preset_service.py backend/tests/unit/services/test_preset_service.py +git commit -m "feat(presets): PNG-Preview-Rendering im Service (Refs #104)" +``` + +--- + +## Task 7: CRUD-Router `presets_api.py` (ohne Preview) + +**Files:** +- Create: `backend/app/api/routes/presets_api.py` +- Test: `backend/tests/unit/api/test_presets_api.py` + +**Interfaces:** +- Consumes: `PresetService`, `PresetCreatePayload`, `PresetUpdatePayload`, `PresetResponse`, `require_read`, `require_print`, `get_session`. +- Produces: `router = APIRouter(prefix="/api/v1/presets", tags=["presets"])` mit GET(list), POST, GET/{id}, PUT/{id}, DELETE/{id}. + +- [ ] **Step 1: Failing test — CRUD-Happy-Paths + Fehlercodes + Auth** + +Erstelle `backend/tests/unit/api/test_presets_api.py`: + +```python +"""Unit-Tests für /api/v1/presets CRUD (Phase 1k.3, Refs #104). + +Auth über dependency_overrides — analog test_admin_printers_api.py. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from uuid import uuid4 + +import app.models # noqa: F401 +import pytest +from app.api.routes.presets_api import router as presets_router +from app.auth.dependencies import AuthContext +from app.auth.scope_deps import require_print, require_read +from app.db.engine import _apply_pragmas +from app.db.session import get_session +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy import event +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlmodel import SQLModel + + +def _make_engine(): + eng = create_async_engine("sqlite+aiosqlite:///:memory:") + event.listen(eng.sync_engine, "connect", _apply_pragmas) + return eng + + +@pytest.fixture +async def session(): + eng = _make_engine() + async with eng.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + factory = async_sessionmaker(eng, expire_on_commit=False) + async with factory() as s: + yield s + await eng.dispose() + + +def _build_app(session: AsyncSession, *, with_write: bool = True) -> FastAPI: + app = FastAPI() + app.include_router(presets_router) + + async def _override_session() -> AsyncIterator[AsyncSession]: + yield session + + app.dependency_overrides[get_session] = _override_session + read_ctx = AuthContext(source="api-key", scope="read", api_key_id=uuid4(), ip="192.0.2.1") + app.dependency_overrides[require_read] = lambda: read_ctx + if with_write: + print_ctx = AuthContext(source="api-key", scope="print", api_key_id=uuid4(), ip="192.0.2.1") + app.dependency_overrides[require_print] = lambda: print_ctx + return app + + +def _payload(name: str = "Schublade A") -> dict: + return { + "name": name, + "content_type": "qr_three_lines", + "tape_mm": 12, + "field_values": {"primary_id": "A1", "title": "Schrauben", + "qr_payload": "https://x", "secondary": ["M3"]}, + } + + +@pytest.mark.asyncio +async def test_create_then_get(session): + app = _build_app(session) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://t") as ac: + r = await ac.post("/api/v1/presets", json=_payload()) + assert r.status_code == 201 + pid = r.json()["id"] + g = await ac.get(f"/api/v1/presets/{pid}") + assert g.status_code == 200 + assert g.json()["content_type"] == "qr_three_lines" + + +@pytest.mark.asyncio +async def test_list_returns_created(session): + app = _build_app(session) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as ac: + await ac.post("/api/v1/presets", json=_payload()) + r = await ac.get("/api/v1/presets") + assert r.status_code == 200 + assert len(r.json()) == 1 + + +@pytest.mark.asyncio +async def test_create_duplicate_name_returns_409(session): + app = _build_app(session) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as ac: + await ac.post("/api/v1/presets", json=_payload()) + r = await ac.post("/api/v1/presets", json=_payload(name="schublade a")) + assert r.status_code == 409 + + +@pytest.mark.asyncio +async def test_create_unsupported_tape_returns_422(session): + app = _build_app(session) + bad = _payload() + bad["tape_mm"] = 999 + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as ac: + r = await ac.post("/api/v1/presets", json=bad) + assert r.status_code == 422 + + +@pytest.mark.asyncio +async def test_create_missing_fields_returns_422(session): + app = _build_app(session) + bad = _payload() + bad["field_values"] = {"primary_id": "A1"} + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as ac: + r = await ac.post("/api/v1/presets", json=bad) + assert r.status_code == 422 + + +@pytest.mark.asyncio +async def test_get_missing_returns_404(session): + app = _build_app(session) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as ac: + r = await ac.get(f"/api/v1/presets/{uuid4()}") + assert r.status_code == 404 + + +@pytest.mark.asyncio +async def test_update_patches(session): + app = _build_app(session) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as ac: + pid = (await ac.post("/api/v1/presets", json=_payload())).json()["id"] + r = await ac.put(f"/api/v1/presets/{pid}", json={"name": "Neu"}) + assert r.status_code == 200 + assert r.json()["name"] == "Neu" + + +@pytest.mark.asyncio +async def test_delete_returns_204_then_404(session): + app = _build_app(session) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as ac: + pid = (await ac.post("/api/v1/presets", json=_payload())).json()["id"] + d = await ac.delete(f"/api/v1/presets/{pid}") + assert d.status_code == 204 + assert (await ac.delete(f"/api/v1/presets/{pid}")).status_code == 404 + + +@pytest.mark.asyncio +async def test_write_requires_print_scope(session): + # Ohne require_print-Override greift die echte Scope-Dependency → 401/403. + app = _build_app(session, with_write=False) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as ac: + r = await ac.post("/api/v1/presets", json=_payload()) + assert r.status_code in (401, 403) +``` + +- [ ] **Step 2: Run test — erwartet FAIL** + +Run: `cd backend && python -m pytest tests/unit/api/test_presets_api.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'app.api.routes.presets_api'`. + +- [ ] **Step 3: Router schreiben** + +Erstelle `backend/app/api/routes/presets_api.py`: + +```python +"""JSON-CRUD-API für Layout-Presets (Phase 1k.3, Refs #104). + +Routes +------ +GET /api/v1/presets — Liste (require_read) +POST /api/v1/presets — Anlegen 201 (require_print) +GET /api/v1/presets/{id} — Einzeln 200/404 (require_read) +PUT /api/v1/presets/{id} — Update 200/404/409/422 (require_print) +DELETE /api/v1/presets/{id} — Löschen 204/404 (require_print) + +Preview (preview.png) folgt in einem separaten Modul-Abschnitt. +""" + +from __future__ import annotations + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Response, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.dependencies import AuthContext +from app.auth.scope_deps import require_print, require_read +from app.db.session import get_session +from app.printer_backends.exceptions import ( + ContentTypeDataMismatchError, + UnsupportedTapeError, +) +from app.schemas.preset import ( + PresetCreatePayload, + PresetResponse, + PresetUpdatePayload, +) +from app.services.preset_service import ( + DuplicatePresetNameError, + PresetNotFoundError, + PresetService, +) + +router = APIRouter(prefix="/api/v1/presets", tags=["presets"]) + +SessionDep = Annotated[AsyncSession, Depends(get_session)] +ReadAuthDep = Annotated[AuthContext, Depends(require_read)] +WriteAuthDep = Annotated[AuthContext, Depends(require_print)] + + +def _map_validation_error(exc: Exception) -> HTTPException: + if isinstance(exc, UnsupportedTapeError): + return HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) + if isinstance(exc, ContentTypeDataMismatchError): + return HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) + if isinstance(exc, DuplicatePresetNameError): + return HTTPException(status.HTTP_409_CONFLICT, detail=str(exc)) + raise exc # pragma: no cover — unerwarteter Typ + + +@router.get("", response_model=list[PresetResponse]) +async def list_presets(session: SessionDep, _auth: ReadAuthDep) -> list[PresetResponse]: + presets = await PresetService(session).list_all() + return [PresetResponse.model_validate(p) for p in presets] + + +@router.post("", response_model=PresetResponse, status_code=status.HTTP_201_CREATED) +async def create_preset( + payload: PresetCreatePayload, session: SessionDep, _auth: WriteAuthDep +) -> PresetResponse: + try: + preset = await PresetService(session).create(payload) + except (UnsupportedTapeError, ContentTypeDataMismatchError, DuplicatePresetNameError) as exc: + raise _map_validation_error(exc) from exc + return PresetResponse.model_validate(preset) + + +@router.get("/{preset_id}", response_model=PresetResponse) +async def get_preset( + preset_id: UUID, session: SessionDep, _auth: ReadAuthDep +) -> PresetResponse: + try: + preset = await PresetService(session).get(preset_id) + except PresetNotFoundError as exc: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + return PresetResponse.model_validate(preset) + + +@router.put("/{preset_id}", response_model=PresetResponse) +async def update_preset( + preset_id: UUID, + payload: PresetUpdatePayload, + session: SessionDep, + _auth: WriteAuthDep, +) -> PresetResponse: + try: + preset = await PresetService(session).update(preset_id, payload) + except PresetNotFoundError as exc: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except (UnsupportedTapeError, ContentTypeDataMismatchError, DuplicatePresetNameError) as exc: + raise _map_validation_error(exc) from exc + return PresetResponse.model_validate(preset) + + +@router.delete("/{preset_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_preset( + preset_id: UUID, session: SessionDep, _auth: WriteAuthDep +) -> Response: + try: + await PresetService(session).delete(preset_id) + except PresetNotFoundError as exc: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + return Response(status_code=status.HTTP_204_NO_CONTENT) +``` + +- [ ] **Step 4: Run test — erwartet PASS** + +Run: `cd backend && python -m pytest tests/unit/api/test_presets_api.py -v` +Expected: PASS (alle CRUD-Tests). + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api/routes/presets_api.py backend/tests/unit/api/test_presets_api.py +git commit -m "feat(presets): CRUD-Router /api/v1/presets (Refs #104)" +``` + +--- + +## Task 8: Preview-Endpoint `GET /{id}/preview.png` + +**Files:** +- Modify: `backend/app/api/routes/presets_api.py` +- Test: `backend/tests/unit/api/test_presets_api.py` + +**Interfaces:** +- Consumes: `PresetService.render_preview_png`. +- Produces: Route `GET /api/v1/presets/{id}/preview.png` → `image/png` (200), 404, 409 (bad tape), 422 (fehlende Felder). + +- [ ] **Step 1: Failing test — Preview liefert PNG / 404** + +Ans Ende von `backend/tests/unit/api/test_presets_api.py` anhängen: + +```python +@pytest.mark.asyncio +async def test_preview_png_ok(session): + app = _build_app(session) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as ac: + pid = (await ac.post("/api/v1/presets", json=_payload())).json()["id"] + r = await ac.get(f"/api/v1/presets/{pid}/preview.png") + assert r.status_code == 200 + assert r.headers["content-type"] == "image/png" + assert r.content[:8] == b"\x89PNG\r\n\x1a\n" + + +@pytest.mark.asyncio +async def test_preview_png_missing_returns_404(session): + app = _build_app(session) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as ac: + r = await ac.get(f"/api/v1/presets/{uuid4()}/preview.png") + assert r.status_code == 404 +``` + +- [ ] **Step 2: Run test — erwartet FAIL** + +Run: `cd backend && python -m pytest tests/unit/api/test_presets_api.py -k preview -v` +Expected: FAIL — 404 erwartet PNG bzw. Route fehlt (404 für ok-Test wegen fehlender Route). + +- [ ] **Step 3: Preview-Route ergänzen** + +In `backend/app/api/routes/presets_api.py` die Route ergänzen (keine neuen Imports nötig — `render_preview_png` ist async und wird direkt awaited; die CPU-Arbeit ist klein genug für den Request-Pfad, eine spätere `to_thread`-Optimierung ist Folge-Arbeit): + +```python +@router.get( + "/{preset_id}/preview.png", + responses={ + 200: {"content": {"image/png": {}}}, + 404: {"description": "Preset nicht gefunden"}, + 409: {"description": "Tape-Breite nicht unterstützt"}, + 422: {"description": "field_values deckt content_type nicht ab"}, + }, +) +async def preview_preset_png( + preset_id: UUID, session: SessionDep, _auth: ReadAuthDep +) -> Response: + try: + png = await PresetService(session).render_preview_png(preset_id) + except PresetNotFoundError as exc: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except UnsupportedTapeError as exc: + raise HTTPException(status.HTTP_409_CONFLICT, detail=str(exc)) from exc + except ContentTypeDataMismatchError as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + return Response(content=png, media_type="image/png") +``` + +- [ ] **Step 4: Run test — erwartet PASS** + +Run: `cd backend && python -m pytest tests/unit/api/test_presets_api.py -v` +Expected: PASS (alle, inkl. Preview). + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api/routes/presets_api.py backend/tests/unit/api/test_presets_api.py +git commit -m "feat(presets): preview.png-Endpoint (Refs #104)" +``` + +--- + +## Task 9: Router in `main.py` registrieren + Smoke-Test + +**Files:** +- Modify: `backend/app/main.py` +- Test: `backend/tests/unit/api/test_presets_api.py` + +**Interfaces:** +- Consumes: `app.api.routes.presets_api.router`. +- Produces: registrierte Routen in der echten App. + +- [ ] **Step 1: Failing test — Route ist in der echten App registriert** + +Ans Ende von `backend/tests/unit/api/test_presets_api.py` anhängen: + +```python +def test_presets_router_registered_in_app(): + from app.main import create_app + + # create_app() liefert einen _LifespanManager-Wrapper; die FastAPI-Instanz + # liegt in ._app (Unwrap-Muster aus tests/api/test_openapi_completeness.py). + inner_app = create_app()._app # type: ignore[attr-defined] + paths = {r.path for r in inner_app.routes} + assert "/api/v1/presets" in paths + assert "/api/v1/presets/{preset_id}/preview.png" in paths +``` + +- [ ] **Step 2: Run test — erwartet FAIL** + +Run: `cd backend && python -m pytest tests/unit/api/test_presets_api.py::test_presets_router_registered_in_app -v` +Expected: FAIL — Pfad nicht in `app.routes`. + +- [ ] **Step 3: Router registrieren** + +In `backend/app/main.py`: +- Bei den Router-Imports (nach Zeile 87, `admin_printers_api_router`) ergänzen: + +```python +from app.api.routes.presets_api import router as presets_api_router +``` + +- Bei den `include_router`-Aufrufen (nach Zeile 715, `admin_printers_api_router`) ergänzen: + +```python + app.include_router(presets_api_router) +``` + +- [ ] **Step 4: Run test — erwartet PASS** + +Run: `cd backend && python -m pytest tests/unit/api/test_presets_api.py -v` +Expected: PASS (alle). + +- [ ] **Step 5: Volle Test-Suite + Lint + Coverage-Gate** + +```bash +cd backend && python -m pytest tests/ -q +cd backend && python -m ruff check app/ tests/ +cd backend && python -m mypy app/ +``` +Expected: alle grün; keine ruff/mypy-Fehler in den neuen Dateien. + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/main.py backend/tests/unit/api/test_presets_api.py +git commit -m "feat(presets): Router in App registrieren + Smoke-Test (Refs #104)" +``` + +--- + +## Self-Review (gegen Spec) + +**Spec-Coverage:** +- presets-Tabelle erweitern (content_type/tape_mm, Default qr_three_lines/12) → Task 1 + 2 ✓ +- CRUD /api/v1/presets + preview.png → Task 7 + 8 ✓ +- Validierung content_type ∈ ContentType / tape ∈ TAPE_GEOMETRY / required fields → Task 5 ✓ +- Auth: Writes require_print, Reads require_read → Task 7 (Test `test_write_requires_print_scope`) ✓ +- Preview nutzt LayoutEngine, kein neuer Render-Pfad → Task 6 ✓ +- Tests Mutation-Pfade Happy+Error → Task 5/7 ✓ +- Out of scope (Overrides, Editor-UI, PrintRequest) → nicht berührt ✓ + +**Offen / bewusst nicht im Plan:** +- `qr_with_listing`-Preview mit `items` → Task 6 Hinweis: Folge-Arbeit. +- Doku-Pipeline (MkDocs-Seite + Blog) → nach Merge via Doku-Workflow (separat, nicht Code-Plan). +- #104 Re-Scope + Label `superpowers:brainstorming` → PM-Team (separat). + +**Platzhalter-Scan:** keine TBD/TODO; jeder Code-Step enthält vollständigen Code. + +**Typ-Konsistenz:** `PresetService`, `PresetCreatePayload/UpdatePayload/Response`, `render_preview_png`, `get_by_name`, `required_fields` durchgängig gleich benannt in Tasks 4–9. diff --git a/docs/superpowers/specs/2026-06-23-user-presets-design.md b/docs/superpowers/specs/2026-06-23-user-presets-design.md new file mode 100644 index 0000000..0c5e23d --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-user-presets-design.md @@ -0,0 +1,230 @@ +# Phase 1k.3 — User-Presets (Hub-Seite) — Design + +**Datum:** 2026-06-23 +**Status:** Entwurf +**Issue:** #104 (Phase 1k.3) — Prämisse veraltet, muss neu zugeschnitten werden +**Parent:** #101 (Phase 1k Umbrella) +**Ziel-Repo:** `label-printer-hub` (Backend), Companion-UI später in Hangar + +--- + +## Ausgangslage / Warum dieser Spec von #104 abweicht + +Issue #104 wurde geschrieben, **bevor** die Layout-Engine (#103 / Phase 1k.1a) +gelandet ist. Der Refactor war radikaler als #104 annimmt. Eine Code-Untersuchung +(2026-06-23, HEAD `eedb323`) ergab: + +| #104 nimmt an | Realität im Code | +|---------------|------------------| +| `templates`-Tabelle mit `source='seed'\|'user'` | **Gelöscht** (Phase 1k.1a, `seed_templates()` ist No-op-Stub, `lifespan.py:117`) | +| `TemplateLoader` lädt YAML, soll user-source ergänzen | **Komplett entfernt** (`print_service.py:3`) | +| 21 YAML-Templates + `hub-layouts.yaml` | **Alle gelöscht** | +| `PrintRequest` referenziert `template_id` | **Entfernt** — heute `content_type` + geladenes Band (`print_request.py:3`) | +| `GET /api/templates/{key}/preview-svg` | Obsolet — es gibt bereits `POST /api/render/preview` (PNG, `print.py:298`) | +| Auth-Scope `templates:write` | Existiert nicht — Scopes sind 3-stufig `read ⊂ print ⊂ admin` (`scope_deps.py:41-43`) | + +Das **Ziel** von #104 bleibt gültig: User sollen eigene benannte Layout-Presets +speichern können (pro Möbeltyp/Fach/Schublade). Der Weg dahin ändert sich. + +### Schlüssel-Fund: Es gibt bereits eine `presets`-Tabelle + +`models/preset.py` + `repositories/presets.py` definieren ein vollständiges +SQLModel-Aggregat mit CRUD-Repository — **das aber an keinen Route/Service +angeschlossen ist** (ungenutzt). Docstring (`preset.py:3`): + +> Phase 1k.1a (Task 25): template_id foreign key removed — the templates table +> and Template model were deleted. Presets are now independent of templates. + +Aktuelles Schema: + +```python +class Preset(SQLModel, table=True): + __tablename__ = "presets" + id: UUID # PK + name: str + printer_id: UUID | None # FK printers.id (optional) + field_values: dict[str, Any] # JSON + created_at: datetime + updated_at: datetime +``` + +--- + +## Entscheidung + +| Frage | Entscheidung | Begründung | +|-------|--------------|------------| +| Integration mit Druck-Pfad | **B — Hub als Store + Preview, Druck-Pfad bleibt template-agnostisch** | Variante A (`template_key` in `PrintRequest`) würde die `template_id`-Kopplung wieder einführen, die 1k.1a gerade bewusst entfernt hat. Gegen die Richtung des Codebestands. | +| Datenmodell | **B1 — bestehende `presets`-Tabelle erweitern** | Model + Repository existieren bereits; kein neues Aggregat. Bewusst akzeptierter Semantik-Blur (Preset = gespeicherte Feldwerte ⇒ jetzt auch Layout-Preset). | +| Geometrie-Overrides (qr_position/font_size pro Template) | **Defern (YAGNI)** | Die `LayoutEngine` rendert aus frozen `TAPE_GEOMETRY`; Overrides würden 7 Render-Methoden aufbohren. Erster Wurf ist auch ohne nützlich. Eigenes Folge-Issue. | + +--- + +## Architektur + +```mermaid +graph LR + subgraph Hangar [Hangar Companion-UI - späteres Issue] + Editor[Template-Editor UI] + PrintForm[Druck-Formular] + end + subgraph Hub [Label-Printer-Hub Backend - DIESER Spec] + API["/api/v1/presets CRUD + preview.png"] + Service[PresetService] + Repo[repositories/presets.py] + DB[(presets-Tabelle)] + Engine[LayoutEngine.render] + end + Editor -->|CRUD| API + PrintForm -.->|liest Preset, füllt Formular vor| API + PrintForm -->|POST /api/print content_type inline| Hub + API --> Service --> Repo --> DB + API -->|preview.png| Engine +``` + +**Kern:** Der Druck-Pfad (`POST /api/print`) bleibt **unverändert** und +template-agnostisch. Presets sind ein **getrennter** Store, den die UI nutzt, um +das Druck-Formular vorzubefüllen. Der Druck sendet `content_type` weiterhin inline. + +--- + +## Datenmodell + +`presets`-Tabelle erweitern (Alembic-Migration; Tabelle ist in Produktion leer, +daher konfliktfrei): + +| Spalte | Status | Typ / Default | +|--------|--------|---------------| +| `id` | bestehend | UUID PK | +| `name` | bestehend | str | +| `printer_id` | bestehend | UUID \| None (FK printers.id) | +| `field_values` | bestehend | JSON — Default-/Beispieldaten für Preview | +| `content_type` | **neu** | String (ContentType-Enum), `server_default='qr_three_lines'`, NOT NULL | +| `tape_mm` | **neu** | Integer, `server_default='12'`, NOT NULL | +| `created_at` / `updated_at` | bestehend | DateTime(tz) | + +`field_values` hält die label-data-Felder (`primary_id`, `title`, `qr_payload`, +`secondary`, `items`) als Default-Werte. Für die Preview werden sie in `LabelData` +gemappt (analog `_PreviewRequest`-Handling in `print.py:298`). + +**Default-`content_type` = `qr_three_lines`** (User-Vorgabe). Erfordert Felder +`qr_payload + primary_id + title + secondary[0]` (`_REQUIRED_FIELDS`, +`layout_engine.py:40`). + +### Alembic-Migration + +Neue Revision (Vorlage: bestehende Migrationen unter `backend/alembic/versions/`): +- `add_column presets.content_type String NOT NULL server_default 'qr_three_lines'` +- `add_column presets.tape_mm Integer NOT NULL server_default '12'` +- Downgrade: beide Spalten droppen. + +--- + +## API + +Neuer Router `backend/app/api/routes/presets_api.py` (Struktur-Vorlage: +`admin_printers_api.py` — Router + `SessionDep`/`AuthDep` + Service-Layer mit +typisierten Domain-Errors → HTTPException). + +| Methode | Pfad | Auth | Erfolg | Fehler | +|---------|------|------|--------|--------| +| GET | `/api/v1/presets` | `require_read` | 200 (Liste) | — | +| POST | `/api/v1/presets` | `require_print` | 201 | 409 (Name-Duplikat), 422 (invalid content_type/tape/fields) | +| GET | `/api/v1/presets/{id}` | `require_read` | 200 | 404 | +| PUT | `/api/v1/presets/{id}` | `require_print` | 200 | 404, 409, 422 | +| DELETE | `/api/v1/presets/{id}` | `require_print` | 204 | 404 | +| GET | `/api/v1/presets/{id}/preview.png` | `require_read` | 200 (image/png) | 404, 409 (unsupported tape), 422 (fehlende Felder) | + +### Validierung (im PresetService, vor Persistenz) + +1. `content_type ∈ ContentType` (Pydantic-Enum, automatisch). +2. `tape_mm ∈ TAPE_GEOMETRY` → sonst 422 (`unsupported_tape` analog `UnsupportedTapeError`). +3. `field_values` deckt die `_REQUIRED_FIELDS[content_type]` ab → sonst 422 + (`content_type_data_mismatch`, wiederverwendet aus LayoutEngine-Validierung). +4. `name` eindeutig (case-insensitive) → sonst 409 (`DuplicateNameError`). + +### Preview-Endpoint + +`GET /api/v1/presets/{id}/preview.png`: +1. Preset aus DB laden (404 wenn fehlt). +2. `field_values` → `LabelData` mappen (`source_app='preview'`). +3. `LayoutEngine().render(preset.tape_mm, preset.content_type, label_data)` → + PNG (identische Logik wie `render_preview` in `print.py`, in CPU-Thread). +4. **Kein neuer Render-Pfad** — LayoutEngine wird unverändert wiederverwendet. + +### Auth-Begründung (Review-Punkt) + +Writes = `require_print`, **nicht** `require_admin`. Grund: Der Companion ist ein +Hangar-Editor; Hangars API-Key hat Scope `print`. Mit `admin`-Pflicht könnte der +Editor keine Presets verwalten. Browser-User (SSO) sind nach ADR 0014 ohnehin für +alle Scopes trusted. **Falls Presets als Infra-Admin-Ressource gelten sollen → +auf `require_admin` ändern.** Dieser Punkt ist bewusst markiert. + +--- + +## Komponenten (Isolation) + +| Unit | Datei | Verantwortung | Abhängigkeiten | +|------|-------|---------------|----------------| +| `Preset` Model | `models/preset.py` (erweitert) | Tabellen-Schema | SQLModel | +| Preset-Repository | `repositories/presets.py` (erweitert) | DB-CRUD | AsyncSession | +| `PresetService` | `services/preset_service.py` (**neu**) | Validierung + Domain-Errors + Orchestrierung | Repository, ContentType, TAPE_GEOMETRY | +| Preset-Schemas | `schemas/preset.py` (**neu**) | Create/Update-Payloads + Response | Pydantic | +| Preset-Router | `api/routes/presets_api.py` (**neu**) | HTTP-Mapping, Auth-Deps | PresetService, LayoutEngine | +| Migration | `alembic/versions/*` (**neu**) | Schema-Erweiterung | Alembic | + +--- + +## Fehlerbehandlung + +Domain-Errors im Service (Muster wie `printer_admin_service.py`): +`PresetNotFoundError` → 404, `DuplicateNameError` → 409, +`UnsupportedTapeError` (wiederverwendet) → 409/422, `ContentTypeDataMismatchError` +(wiederverwendet) → 422. Router mappt Domain-Errors → `HTTPException` mit +`ProblemDetail`-Body (`schemas/problem.py`). + +--- + +## Tests (Test-Coverage-Pflicht — VERBINDLICH) + +TDD strict: Test zuerst. Schwelle Mutation-Logic 85% (`coverage-gate-strict.sh`). + +| Ebene | Test | Pfade | +|-------|------|-------| +| Repository | create/get/list/update/delete Roundtrip | Happy + not-found | +| PresetService | Validierung | invalid content_type, unsupported tape, fehlende Required-Fields, Name-Duplikat | +| PresetService | Mutationen | create/update/delete Happy + Error | +| Router | CRUD-Endpoints | 200/201/204 + 404/409/422 je Endpoint | +| Router | Auth | `require_print`-Enforcement auf Writes (403 ohne Scope), `require_read` auf Reads | +| Preview | preview.png | 200 image/png Happy, 404, 409 (bad tape), 422 (fehlende Felder) | + +--- + +## Out of Scope + +- **Geometrie-Overrides** (qr_position/font_size pro Preset) → eigenes Folge-Issue (Phase 1k.3.x / 1l). +- **Editor-UI** → Hangar-Companion-Issue. +- **Versionierung / Sharing / Marketplace** von Presets. +- **Bereinigung der `presets`-Semantik** (Feldwert-Preset vs. Layout-Preset) — bewusst akzeptierter Blur. +- **`PrintRequest`-Änderungen** — Druck-Pfad bleibt unangetastet. + +--- + +## Risiken / Offene Punkte + +1. **Auth-Scope für Writes** (`print` vs `admin`) — siehe Begründung oben. User-Review. +2. **Semantik-Blur `presets`** — die Tabelle vermischt künftig zwei Konzepte. Akzeptiert; falls später Reibung → Trennung in Folge-Issue. +3. **`printer_id`-Feld** bleibt optional und ungenutzt im ersten Wurf — Preset ist druckerunabhängig (tape_mm zählt). Nicht entfernen (Migration-Aufwand), aber dokumentieren dass es vorerst informell ist. +4. **#104 muss vom PM neu zugeschnitten werden** (veraltete Prämisse) + Label `superpowers:brainstorming` setzen. + +--- + +## Referenzen + +- Layout-Engine Spec: `docs/superpowers/specs/2026-06-05-phase-1k1-layout-engine-design.md` +- LayoutEngine: `backend/app/services/layout_engine.py` +- Preview-Endpoint: `backend/app/api/routes/print.py:298` (`POST /api/render/preview`) +- CRUD-Vorlage: `backend/app/api/routes/admin_printers_api.py` (#124) +- Bestehendes Preset-Aggregat: `backend/app/models/preset.py`, `backend/app/repositories/presets.py` +- Scopes: `backend/app/auth/scope_deps.py` +- Issue #104: https://github.com/strausmann/Label-Printer-Hub/issues/104 diff --git a/frontend/internal/handlers/admin_api_keys.go b/frontend/internal/handlers/admin_api_keys.go index 4cf19ab..86d3e2f 100644 --- a/frontend/internal/handlers/admin_api_keys.go +++ b/frontend/internal/handlers/admin_api_keys.go @@ -55,7 +55,7 @@ func (h *PageHandler) AdminAPIKeysList(w http.ResponseWriter, r *http.Request) { return } h.renderPage(w, r, "admin_api_keys", AdminAPIKeyListData{ - TemplateData: h.baseData(r, "admin"), + TemplateData: h.baseData(r, "admin-api-keys"), Keys: keys, }) } @@ -63,7 +63,7 @@ func (h *PageHandler) AdminAPIKeysList(w http.ResponseWriter, r *http.Request) { // AdminAPIKeysNew handles GET /admin/api-keys/new — Erstell-Formular anzeigen. func (h *PageHandler) AdminAPIKeysNew(w http.ResponseWriter, r *http.Request) { h.renderPage(w, r, "admin_api_keys_create", AdminAPIKeyCreateData{ - TemplateData: h.baseData(r, "admin"), + TemplateData: h.baseData(r, "admin-api-keys"), }) } @@ -101,14 +101,14 @@ func (h *PageHandler) AdminAPIKeysCreate(w http.ResponseWriter, r *http.Request) plaintext, prefix, apiErr := h.createAPIKey(r, payload) if apiErr != nil { h.renderPage(w, r, "admin_api_keys_create", AdminAPIKeyCreateData{ - TemplateData: h.baseData(r, "admin"), + TemplateData: h.baseData(r, "admin-api-keys"), Error: apiErr.Error(), }) return } h.renderPage(w, r, "admin_api_keys_create", AdminAPIKeyCreateData{ - TemplateData: h.baseData(r, "admin"), + TemplateData: h.baseData(r, "admin-api-keys"), Plaintext: plaintext, Prefix: prefix, }) @@ -123,7 +123,7 @@ func (h *PageHandler) AdminAPIKeyDetail(w http.ResponseWriter, r *http.Request) return } h.renderPage(w, r, "admin_api_keys_detail", AdminAPIKeyDetailData{ - TemplateData: h.baseData(r, "admin"), + TemplateData: h.baseData(r, "admin-api-keys"), Key: *key, }) } diff --git a/frontend/internal/handlers/admin_printers.go b/frontend/internal/handlers/admin_printers.go index 780c946..50b20e6 100644 --- a/frontend/internal/handlers/admin_printers.go +++ b/frontend/internal/handlers/admin_printers.go @@ -84,7 +84,7 @@ func (h *PageHandler) ListPrintersPage(w http.ResponseWriter, r *http.Request) { return } h.renderPage(w, r, "admin_printers", AdminPrinterListData{ - TemplateData: h.baseData(r, "admin"), + TemplateData: h.baseData(r, "admin-printers"), Printers: printers, IncludeDisabled: includeDisabled, }) @@ -97,7 +97,7 @@ func (h *PageHandler) ListPrintersPage(w http.ResponseWriter, r *http.Request) { // NewPrinterPage behandelt GET /admin/printers/new — leeres Erstell-Formular. func (h *PageHandler) NewPrinterPage(w http.ResponseWriter, r *http.Request) { h.renderPage(w, r, "admin_printers_form", AdminPrinterFormData{ - TemplateData: h.baseData(r, "admin"), + TemplateData: h.baseData(r, "admin-printers"), IsEdit: false, }) } @@ -123,7 +123,7 @@ func (h *PageHandler) CreatePrinter(w http.ResponseWriter, r *http.Request) { // Formular-Daten für Rerender bei Fehler formData := AdminPrinterFormData{ - TemplateData: h.baseData(r, "admin"), + TemplateData: h.baseData(r, "admin-printers"), IsEdit: false, FormName: name, FormSlug: slug, @@ -190,7 +190,7 @@ func (h *PageHandler) PrinterDetailPageWithSlug(w http.ResponseWriter, r *http.R return } h.renderPage(w, r, "admin_printers_detail", AdminPrinterDetailData{ - TemplateData: h.baseData(r, "admin"), + TemplateData: h.baseData(r, "admin-printers"), Printer: *printer, }) } @@ -256,7 +256,7 @@ func (h *PageHandler) EditPrinterPageWithSlug(w http.ResponseWriter, r *http.Req } h.renderPage(w, r, "admin_printers_form", AdminPrinterFormData{ - TemplateData: h.baseData(r, "admin"), + TemplateData: h.baseData(r, "admin-printers"), Printer: printer, IsEdit: true, Slug: slug, @@ -294,7 +294,7 @@ func (h *PageHandler) UpdatePrinterWithSlug(w http.ResponseWriter, r *http.Reque snmpCommunity := r.FormValue("snmp_community") formData := AdminPrinterFormData{ - TemplateData: h.baseData(r, "admin"), + TemplateData: h.baseData(r, "admin-printers"), IsEdit: true, Slug: slug, FormName: name, @@ -353,7 +353,7 @@ func (h *PageHandler) DisablePrinterConfirmPageWithSlug(w http.ResponseWriter, r return } h.renderPage(w, r, "admin_printers_confirm_disable", AdminPrinterConfirmData{ - TemplateData: h.baseData(r, "admin"), + TemplateData: h.baseData(r, "admin-printers"), Printer: *printer, }) } diff --git a/frontend/internal/handlers/base.go b/frontend/internal/handlers/base.go index a3196ce..ff7acfb 100644 --- a/frontend/internal/handlers/base.go +++ b/frontend/internal/handlers/base.go @@ -46,7 +46,7 @@ import ( // Every page template receives at minimum these fields. type TemplateData struct { Version string // Build-Version aus Env (z.B. "1.2.3") - ActiveNav string // "dashboard" | "jobs" | "templates" | "" + ActiveNav string // "dashboard" | "jobs" | "templates" | "admin-printers" | "admin-api-keys" | "" Error string // Nicht-leer bei Fehlerseiten CSRFField template.HTML // gorilla/csrf Hidden-Input für POST-Forms; leer auf GET-only-Seiten } diff --git a/frontend/web/templates/layout.html b/frontend/web/templates/layout.html index 467f76e..3f6f039 100644 --- a/frontend/web/templates/layout.html +++ b/frontend/web/templates/layout.html @@ -14,10 +14,11 @@