Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0a2a9f3
test(fixtures): add client/db_session/auth fixtures (Phase 1 integrat…
strausmann May 30, 2026
c9ae9f8
fix(sse): ergänze data-job-id + data-state in job_state.html-Fragment
strausmann May 30, 2026
e8de35b
feat(printer): add slug column with backfill from name
strausmann May 30, 2026
f99137e
feat(printers): add slug lookup + unified slug/uuid resolver
strausmann May 30, 2026
a0cbd3d
feat(schemas): expose slug field on PrinterRead
strausmann May 30, 2026
a3ae386
fix(tests): seed Printer-Helper mit explizitem slug
strausmann May 30, 2026
e6f112f
feat(api): GET /api/printers?slug=... filter
strausmann May 30, 2026
b27b876
feat(persistence): add print_batches aggregate
strausmann May 30, 2026
7d6c4ac
feat(schemas): add BatchRequest/Response/Error Pydantic models
strausmann May 30, 2026
d6cd621
feat(seeds): add hangar-furniture templates (12mm/18mm/24mm)
strausmann May 30, 2026
44534d0
feat(services): best-effort batch dispatcher
strausmann May 30, 2026
85e1de8
feat(api): POST /api/print/{slug_or_uuid}/batch endpoint
strausmann May 30, 2026
c7fdc80
test(api): batch endpoint happy path
strausmann May 30, 2026
baca4c4
test(api): batch endpoint partial failure with template_not_found
strausmann May 30, 2026
d54e468
test(api): batch endpoint rejects with 409 when printer offline
strausmann May 30, 2026
a093056
test(api): batch endpoint tape_mismatch + auth happy path
strausmann May 30, 2026
702f7e2
test(sse): phase 6b stream contains all batch job events
strausmann May 30, 2026
562ef62
fix(ci): adressiere PR #92 review-findings (ruff + mypy + privacy + c…
strausmann May 30, 2026
bca88c6
chore(ci): trigger fresh CodeQL aggregate after alert-dismissal
strausmann May 30, 2026
1540fd7
revert(events): entferne nutzlose codeql-Inline-Suppressions
strausmann May 30, 2026
84232ce
test(api): align batch endpoint tests with single-printer-binding
strausmann May 30, 2026
2dbf21c
fix(api): adressiere Copilot+Gemini Review-Findings auf PR #92
strausmann May 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""add print_batches table

Revision ID: a0516c04278c
Revises: da865401716d
Create Date: 2026-05-30 15:18:58.348408

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
import sqlmodel


# revision identifiers, used by Alembic.
revision: str = 'a0516c04278c'
down_revision: Union[str, Sequence[str], None] = 'da865401716d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('print_batches',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('printer_id', sa.Uuid(), nullable=False),
sa.Column('job_ids', sa.JSON(), nullable=True),
sa.Column('created_by', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['printer_id'], ['printers.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_print_batches_created_at'), 'print_batches', ['created_at'], unique=False)
op.create_index(op.f('ix_print_batches_created_by'), 'print_batches', ['created_by'], unique=False)
op.create_index(op.f('ix_print_batches_printer_id'), 'print_batches', ['printer_id'], unique=False)
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_print_batches_printer_id'), table_name='print_batches')
op.drop_index(op.f('ix_print_batches_created_by'), table_name='print_batches')
op.drop_index(op.f('ix_print_batches_created_at'), table_name='print_batches')
op.drop_table('print_batches')
# ### end Alembic commands ###
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""add printer slug column

Revision ID: da865401716d
Revises: 20260518_phase7c_pat_prefix
Create Date: 2026-05-30 15:03:06.420359

"""
from collections.abc import Sequence

import sqlalchemy as sa
import sqlmodel
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "da865401716d"
down_revision: str | Sequence[str] | None = "20260518_phase7c_pat_prefix"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Upgrade schema."""
op.add_column(
"printers",
sa.Column(
"slug",
sqlmodel.sql.sqltypes.AutoString(),
nullable=False,
server_default="",
),
)
# Backfill aus name: existierende Drucker erhalten einen slug
op.execute(
"UPDATE printers SET slug = LOWER(REPLACE(name, ' ', '-')) WHERE slug = ''"
)
op.create_index(op.f("ix_printers_slug"), "printers", ["slug"], unique=True)


def downgrade() -> None:
"""Downgrade schema."""
op.drop_index(op.f("ix_printers_slug"), table_name="printers")
op.drop_column("printers", "slug")
88 changes: 88 additions & 0 deletions backend/app/api/routes/batch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""POST /api/print/{printer_key}/batch — best-effort batch print."""
from __future__ import annotations

from datetime import UTC, datetime
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, Path, Request, status
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth.dependencies import AuthContext

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Import check_printer_access to enforce printer-specific authorization checks in the batch endpoint.

Suggested change
from app.auth.dependencies import AuthContext
from app.auth.dependencies import AuthContext, check_printer_access

from app.auth.scope_deps import require_print
from app.db.session import get_session
from app.models.print_batch import PrintBatch
from app.printer_backends.exceptions import (
PrinterCoverOpenError,
PrinterOfflineError,
SnmpQueryError,
)
from app.repositories import print_batches as batches_repo
from app.repositories import printers as printers_repo
from app.schemas.print_batch import BatchRequest, BatchResponse
from app.services.batch_dispatch import dispatch_batch

# SessionDep locally — Hub has no central app/api/deps.py module.
SessionDep = Annotated[AsyncSession, Depends(get_session)]

# prefix=/api → POST /api/print/{...}/batch. print.py has no prefix
# (POST /print), so this is a clean separation.
router = APIRouter(prefix="/api")

_SYNC_ERROR_MAP: dict[type[Exception], str] = {
PrinterOfflineError: "printer_offline",
PrinterCoverOpenError: "printer_cover_open",
SnmpQueryError: "snmp_error",
}


@router.post(
"/print/{printer_key}/batch",
status_code=status.HTTP_202_ACCEPTED,
response_model=BatchResponse,
tags=["print"],
summary="Submit a batch of print jobs",
description=(
"Best-effort batch print. Validates each item individually and "
"returns per-item errors. Hardware preconditions (printer_offline, "
"cover_open) reject the entire batch with 409."
),
)
async def create_batch(
printer_key: Annotated[str, Path(description="Printer slug or UUID")],
body: BatchRequest,
http: Request,
session: SessionDep,
auth: Annotated[AuthContext, Depends(require_print)],
) -> BatchResponse:
# 1. Resolve printer
printer = await printers_repo.resolve_by_slug_or_uuid(session, printer_key)
if printer is None:
raise HTTPException(404, detail={"error_code": "printer_not_found"})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

security-critical critical

The batch print endpoint does not enforce printer-specific access controls. If a user or API key has the print scope but is restricted to specific printers, they can bypass this restriction and print to any printer via this endpoint. Please call check_printer_access(auth, printer.id) after resolving the printer to ensure proper authorization.

Suggested change
# 1. Resolve printer
printer = await printers_repo.resolve_by_slug_or_uuid(session, printer_key)
if printer is None:
raise HTTPException(404, detail={"error_code": "printer_not_found"})
# 1. Resolve printer
printer = await printers_repo.resolve_by_slug_or_uuid(session, printer_key)
if printer is None:
raise HTTPException(404, detail={"error_code": "printer_not_found"})
check_printer_access(auth, printer.id)


# 2. Best-effort dispatch
service = http.app.state.print_service
try:
job_ids, errors = await dispatch_batch(service, body.items)
except (PrinterOfflineError, PrinterCoverOpenError, SnmpQueryError) as exc:
raise HTTPException(409, detail={
"error_code": _SYNC_ERROR_MAP[type(exc)],
"error_message": str(exc),
})

# 3. Persist tracking row
# auth.subject_id does NOT exist — use api_key_id or source
created_by = str(auth.api_key_id) if auth.api_key_id else auth.source
batch_row = PrintBatch(
printer_id=printer.id,
job_ids=job_ids,
created_by=created_by,
)
await batches_repo.create(session, batch_row)

return BatchResponse(
batch_id=batch_row.id,
printer_id=printer.id,
queued_at=datetime.now(UTC).isoformat().replace("+00:00", "Z"),
job_ids=job_ids,
errors=errors,
)
27 changes: 22 additions & 5 deletions backend/app/api/routes/printers.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
from typing import Annotated, Any
from uuid import UUID

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

from app.auth.dependencies import AuthContext, check_printer_access
Expand Down Expand Up @@ -80,12 +80,28 @@ async def _get_printer_or_404(session: AsyncSession, printer_id: UUID) -> Any:
summary="List all printers",
description=(
"Returns every registered printer. The ``paused`` flag is joined "
"from ``printer_state``; it is ``false`` when no state row exists yet."
"from ``printer_state``; it is ``false`` when no state row exists yet. "
"Pass ``?slug=<slug>`` to filter to a single printer by exact slug match "
"(returns 404 when no printer with that slug exists)."
),
)
async def list_printers(session: SessionDep, _auth: ReadAuthDep) -> list[PrinterRead]:
"""List all printers with their pause state."""
printers = await printers_repo.list_all(session)
async def list_printers(
session: SessionDep,
_auth: ReadAuthDep,
slug: Annotated[str | None, Query(description="Filter by exact slug")] = None,
) -> list[PrinterRead]:
"""List all printers with their pause state, optionally filtered by slug."""
if slug is not None:
printer = await printers_repo.get_by_slug(session, slug)
if printer is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error_code": "printer_not_found",
"error_message": f"slug={slug!r} not found"},
)
printers = [printer]
else:
printers = await printers_repo.list_all(session)
result: list[PrinterRead] = []
for p in printers:
state = await printer_state_repo.get(session, p.id)
Expand All @@ -94,6 +110,7 @@ async def list_printers(session: SessionDep, _auth: ReadAuthDep) -> list[Printer
PrinterRead(
id=p.id,
name=p.name,
slug=p.slug,
model=p.model,
backend=p.backend,
connection=dict(p.connection),
Expand Down
2 changes: 2 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
import app.integrations as _integrations_init # triggers integration plugin discovery
from app import __version__
from app.api.error_handlers import register_error_handlers
from app.api.routes import batch as batch_routes
from app.api.routes import events as events_routes
from app.api.routes import jobs as jobs_routes
from app.api.routes import lookup as lookup_routes
Expand Down Expand Up @@ -595,6 +596,7 @@ async def readiness(

register_error_handlers(app)
app.include_router(print_router)
app.include_router(batch_routes.router)
app.include_router(events_routes.router)
app.include_router(printers_routes.router)
app.include_router(templates_routes.router)
Expand Down
2 changes: 2 additions & 0 deletions backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from app.models.api_key import ApiKey
from app.models.job import Job, JobState
from app.models.preset import Preset
from app.models.print_batch import PrintBatch
from app.models.printer import Printer
from app.models.printer_state import PrinterState
from app.models.printer_status_cache import PrinterStatusCache
Expand All @@ -17,6 +18,7 @@
"Job",
"JobState",
"Preset",
"PrintBatch",
"Printer",
"PrinterState",
"PrinterStatusCache",
Expand Down
21 changes: 21 additions & 0 deletions backend/app/models/print_batch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""SQLModel table für print_batches — Tracking-Aggregat für Batch-Druckaufträge."""
from __future__ import annotations

from datetime import UTC, datetime
from uuid import UUID, uuid4

from sqlalchemy import JSON, DateTime
from sqlmodel import Column, Field, SQLModel


class PrintBatch(SQLModel, table=True):
__tablename__ = "print_batches"

id: UUID = Field(default_factory=uuid4, primary_key=True)
printer_id: UUID = Field(index=True, foreign_key="printers.id")
job_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON))
created_by: str = Field(index=True, description="SSO-Email oder API-Key-ID")
created_at: datetime = Field(
default_factory=lambda: datetime.now(UTC),
sa_column=Column(DateTime(timezone=True), nullable=False, index=True),
)
3 changes: 3 additions & 0 deletions backend/app/models/printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ class Printer(SQLModel, table=True):

id: UUID = Field(default_factory=uuid4, primary_key=True)
name: str = Field(index=True, unique=True)
slug: str = Field(default="", index=True, unique=True,
description="Stable URL-safe identifier (e.g., 'brother-p750w'). "
"Defaults to slugified name on init.")
model: str
backend: str
connection: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
Expand Down
44 changes: 44 additions & 0 deletions backend/app/repositories/print_batches.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""CRUD für PrintBatch-Aggregat."""
from __future__ import annotations

from datetime import UTC, datetime, timedelta
from uuid import UUID

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import col

from app.models.print_batch import PrintBatch


async def create(session: AsyncSession, batch: PrintBatch) -> PrintBatch:
session.add(batch)
await session.commit()
await session.refresh(batch)
return batch


async def get(session: AsyncSession, batch_id: UUID) -> PrintBatch | None:
return await session.get(PrintBatch, batch_id)


async def list_recent(session: AsyncSession, hours: int = 24) -> list[PrintBatch]:
since = datetime.now(UTC) - timedelta(hours=hours)
result = await session.execute(
select(PrintBatch)
.where(col(PrintBatch.created_at) >= since)
.order_by(col(PrintBatch.created_at).desc())
)
return list(result.scalars())


async def prune_older_than(session: AsyncSession, hours: int = 24) -> int:
cutoff = datetime.now(UTC) - timedelta(hours=hours)
result = await session.execute(
select(PrintBatch).where(col(PrintBatch.created_at) < cutoff)
)
rows = list(result.scalars())
for row in rows:
await session.delete(row)
await session.commit()
return len(rows)
Comment on lines +36 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The prune_older_than function fetches all matching PrintBatch rows into memory and deletes them one by one in a loop. This is highly inefficient and can cause performance bottlenecks or high memory usage if there are many batches to prune. Please use a single bulk delete statement instead.

async def prune_older_than(session: AsyncSession, hours: int = 24) -> int:
    cutoff = datetime.now(UTC) - timedelta(hours=hours)
    from sqlalchemy import delete
    statement = delete(PrintBatch).where(col(PrintBatch.created_at) < cutoff)
    result = await session.execute(statement)
    await session.commit()
    return result.rowcount

20 changes: 20 additions & 0 deletions backend/app/repositories/printers.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,23 @@ async def create(session: AsyncSession, printer: Printer) -> Printer:
await session.commit()
await session.refresh(printer)
return printer


async def get_by_slug(session: AsyncSession, slug: str) -> Printer | None:
"""Lookup nach slug. None wenn nicht vorhanden."""
result = await session.execute(
select(Printer).where(col(Printer.slug) == slug)
)
return result.scalar_one_or_none()


async def resolve_by_slug_or_uuid(session: AsyncSession, key: str) -> Printer | None:
"""Akzeptiert Slug-String ODER UUID-String. UUID hat Vorrang (Performance)."""
try:
uuid_obj = UUID(key)
printer = await get(session, uuid_obj)
if printer is not None:
return printer
except ValueError:
pass
return await get_by_slug(session, key)
Loading
Loading