Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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")
169 changes: 169 additions & 0 deletions backend/app/api/routes/presets_api.py
Original file line number Diff line number Diff line change
@@ -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")
2 changes: 2 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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():
Expand Down
8 changes: 8 additions & 0 deletions backend/app/models/preset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
10 changes: 9 additions & 1 deletion backend/app/repositories/presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions backend/app/schemas/preset.py
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions backend/app/services/layout_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading