Skip to content

Commit 280f46d

Browse files
authored
Merge pull request #92 from strausmann/feat/hangar-integration-phase-1
feat(api): Hub batch print endpoint + slug + hangar templates for Hangar integration
2 parents 69b133a + 2dbf21c commit 280f46d

36 files changed

Lines changed: 1759 additions & 15 deletions
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""add print_batches table
2+
3+
Revision ID: a0516c04278c
4+
Revises: da865401716d
5+
Create Date: 2026-05-30 15:18:58.348408
6+
7+
"""
8+
9+
from collections.abc import Sequence
10+
11+
import sqlalchemy as sa
12+
import sqlmodel
13+
from alembic import op
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = "a0516c04278c"
17+
down_revision: str | Sequence[str] | None = "da865401716d"
18+
branch_labels: str | Sequence[str] | None = None
19+
depends_on: str | Sequence[str] | None = None
20+
21+
22+
def upgrade() -> None:
23+
"""Upgrade schema."""
24+
# ### commands auto generated by Alembic - please adjust! ###
25+
op.create_table(
26+
"print_batches",
27+
sa.Column("id", sa.Uuid(), nullable=False),
28+
sa.Column("printer_id", sa.Uuid(), nullable=False),
29+
sa.Column("job_ids", sa.JSON(), nullable=True),
30+
sa.Column("created_by", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
31+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
32+
sa.ForeignKeyConstraint(
33+
["printer_id"],
34+
["printers.id"],
35+
),
36+
sa.PrimaryKeyConstraint("id"),
37+
)
38+
op.create_index(
39+
op.f("ix_print_batches_created_at"),
40+
"print_batches",
41+
["created_at"],
42+
unique=False,
43+
)
44+
op.create_index(
45+
op.f("ix_print_batches_created_by"),
46+
"print_batches",
47+
["created_by"],
48+
unique=False,
49+
)
50+
op.create_index(
51+
op.f("ix_print_batches_printer_id"),
52+
"print_batches",
53+
["printer_id"],
54+
unique=False,
55+
)
56+
# ### end Alembic commands ###
57+
58+
59+
def downgrade() -> None:
60+
"""Downgrade schema."""
61+
# ### commands auto generated by Alembic - please adjust! ###
62+
op.drop_index(op.f("ix_print_batches_printer_id"), table_name="print_batches")
63+
op.drop_index(op.f("ix_print_batches_created_by"), table_name="print_batches")
64+
op.drop_index(op.f("ix_print_batches_created_at"), table_name="print_batches")
65+
op.drop_table("print_batches")
66+
# ### end Alembic commands ###
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""add printer slug column
2+
3+
Revision ID: da865401716d
4+
Revises: 20260518_phase7c_pat_prefix
5+
Create Date: 2026-05-30 15:03:06.420359
6+
7+
"""
8+
9+
from collections.abc import Sequence
10+
11+
import sqlalchemy as sa
12+
import sqlmodel
13+
from alembic import op
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = "da865401716d"
17+
down_revision: str | Sequence[str] | None = "20260518_phase7c_pat_prefix"
18+
branch_labels: str | Sequence[str] | None = None
19+
depends_on: str | Sequence[str] | None = None
20+
21+
22+
def upgrade() -> None:
23+
"""Upgrade schema."""
24+
op.add_column(
25+
"printers",
26+
sa.Column(
27+
"slug",
28+
sqlmodel.sql.sqltypes.AutoString(),
29+
nullable=False,
30+
server_default="",
31+
),
32+
)
33+
# Backfill aus name: existierende Drucker erhalten einen slug
34+
op.execute("UPDATE printers SET slug = LOWER(REPLACE(name, ' ', '-')) WHERE slug = ''")
35+
op.create_index(op.f("ix_printers_slug"), "printers", ["slug"], unique=True)
36+
37+
38+
def downgrade() -> None:
39+
"""Downgrade schema."""
40+
op.drop_index(op.f("ix_printers_slug"), table_name="printers")
41+
op.drop_column("printers", "slug")

backend/app/api/routes/batch.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""POST /api/print/{printer_key}/batch — best-effort batch print."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import UTC, datetime
6+
from typing import Annotated
7+
8+
from fastapi import APIRouter, Depends, HTTPException, Path, Request, status
9+
from sqlalchemy.ext.asyncio import AsyncSession
10+
11+
from app.auth.dependencies import AuthContext, check_printer_access
12+
from app.auth.scope_deps import require_print
13+
from app.db.session import get_session
14+
from app.models.print_batch import PrintBatch
15+
from app.printer_backends.exceptions import (
16+
PrinterCoverOpenError,
17+
PrinterOfflineError,
18+
SnmpQueryError,
19+
)
20+
from app.repositories import print_batches as batches_repo
21+
from app.repositories import printers as printers_repo
22+
from app.schemas.print_batch import BatchRequest, BatchResponse
23+
from app.services.batch_dispatch import dispatch_batch
24+
25+
# SessionDep locally — Hub has no central app/api/deps.py module.
26+
SessionDep = Annotated[AsyncSession, Depends(get_session)]
27+
28+
# prefix=/api → POST /api/print/{...}/batch. print.py has no prefix
29+
# (POST /print), so this is a clean separation.
30+
router = APIRouter(prefix="/api")
31+
32+
_SYNC_ERROR_MAP: dict[type[Exception], str] = {
33+
PrinterOfflineError: "printer_offline",
34+
PrinterCoverOpenError: "printer_cover_open",
35+
SnmpQueryError: "snmp_error",
36+
}
37+
38+
39+
@router.post(
40+
"/print/{printer_key}/batch",
41+
status_code=status.HTTP_202_ACCEPTED,
42+
response_model=BatchResponse,
43+
tags=["print"],
44+
summary="Submit a batch of print jobs",
45+
description=(
46+
"Best-effort batch print. Validates each item individually and "
47+
"returns per-item errors. Hardware preconditions (printer_offline, "
48+
"cover_open) reject the entire batch with 409."
49+
),
50+
)
51+
async def create_batch(
52+
printer_key: Annotated[str, Path(description="Printer slug or UUID")],
53+
body: BatchRequest,
54+
http: Request,
55+
session: SessionDep,
56+
auth: Annotated[AuthContext, Depends(require_print)],
57+
) -> BatchResponse:
58+
# 1. Resolve printer (404 if unknown slug/uuid)
59+
printer = await printers_repo.resolve_by_slug_or_uuid(session, printer_key)
60+
if printer is None:
61+
raise HTTPException(404, detail={"error_code": "printer_not_found"})
62+
63+
# 2. ACL: api-key may be restricted to a subset of printer_ids
64+
check_printer_access(auth, printer.id)
65+
66+
# 3. Verify the resolved printer matches the singleton wired into
67+
# app.state.print_service. The Hub is currently single-printer at
68+
# startup (main.py wires PrintService to app.state.printer_id).
69+
# If the URL slug points to a different printer row, the dispatch
70+
# would silently route to the wrong device. Reject explicitly.
71+
seeded_printer_id = getattr(http.app.state, "printer_id", None)
72+
if seeded_printer_id is not None and printer.id != seeded_printer_id:
73+
raise HTTPException(
74+
404,
75+
detail={
76+
"error_code": "printer_not_active",
77+
"error_message": (
78+
"Resolved printer is not the currently-seeded device. "
79+
"Hub is single-printer at startup; multi-printer routing "
80+
"is a future enhancement."
81+
),
82+
},
83+
)
84+
85+
# 4. Best-effort dispatch
86+
service = http.app.state.print_service
87+
try:
88+
job_ids, errors = await dispatch_batch(service, body.items)
89+
except (PrinterOfflineError, PrinterCoverOpenError, SnmpQueryError) as exc:
90+
raise HTTPException(
91+
409,
92+
detail={
93+
"error_code": _SYNC_ERROR_MAP[type(exc)],
94+
"error_message": str(exc),
95+
},
96+
) from exc
97+
98+
# 5. Persist tracking row
99+
# auth.subject_id does NOT exist — use api_key_id or source
100+
created_by = str(auth.api_key_id) if auth.api_key_id else auth.source
101+
batch_row = PrintBatch(
102+
printer_id=printer.id,
103+
job_ids=job_ids,
104+
created_by=created_by,
105+
)
106+
await batches_repo.create(session, batch_row)
107+
108+
return BatchResponse(
109+
batch_id=batch_row.id,
110+
printer_id=printer.id,
111+
queued_at=datetime.now(UTC).isoformat().replace("+00:00", "Z"),
112+
job_ids=job_ids,
113+
errors=errors,
114+
)

backend/app/api/routes/printers.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from typing import Annotated, Any
2929
from uuid import UUID
3030

31-
from fastapi import APIRouter, Depends, HTTPException, status
31+
from fastapi import APIRouter, Depends, HTTPException, Query, status
3232
from sqlalchemy.ext.asyncio import AsyncSession
3333

3434
from app.auth.dependencies import AuthContext, check_printer_access
@@ -80,12 +80,30 @@ async def _get_printer_or_404(session: AsyncSession, printer_id: UUID) -> Any:
8080
summary="List all printers",
8181
description=(
8282
"Returns every registered printer. The ``paused`` flag is joined "
83-
"from ``printer_state``; it is ``false`` when no state row exists yet."
83+
"from ``printer_state``; it is ``false`` when no state row exists yet. "
84+
"Pass ``?slug=<slug>`` to filter to a single printer by exact slug match "
85+
"(returns 404 when no printer with that slug exists)."
8486
),
8587
)
86-
async def list_printers(session: SessionDep, _auth: ReadAuthDep) -> list[PrinterRead]:
87-
"""List all printers with their pause state."""
88-
printers = await printers_repo.list_all(session)
88+
async def list_printers(
89+
session: SessionDep,
90+
_auth: ReadAuthDep,
91+
slug: Annotated[str | None, Query(description="Filter by exact slug")] = None,
92+
) -> list[PrinterRead]:
93+
"""List all printers with their pause state, optionally filtered by slug."""
94+
if slug is not None:
95+
printer = await printers_repo.get_by_slug(session, slug)
96+
if printer is None:
97+
raise HTTPException(
98+
status_code=status.HTTP_404_NOT_FOUND,
99+
detail={
100+
"error_code": "printer_not_found",
101+
"error_message": f"slug={slug!r} not found",
102+
},
103+
)
104+
printers = [printer]
105+
else:
106+
printers = await printers_repo.list_all(session)
89107
result: list[PrinterRead] = []
90108
for p in printers:
91109
state = await printer_state_repo.get(session, p.id)
@@ -94,6 +112,7 @@ async def list_printers(session: SessionDep, _auth: ReadAuthDep) -> list[Printer
94112
PrinterRead(
95113
id=p.id,
96114
name=p.name,
115+
slug=p.slug,
97116
model=p.model,
98117
backend=p.backend,
99118
connection=dict(p.connection),

backend/app/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
import app.integrations as _integrations_init # triggers integration plugin discovery
7676
from app import __version__
7777
from app.api.error_handlers import register_error_handlers
78+
from app.api.routes import batch as batch_routes
7879
from app.api.routes import events as events_routes
7980
from app.api.routes import jobs as jobs_routes
8081
from app.api.routes import lookup as lookup_routes
@@ -595,6 +596,7 @@ async def readiness(
595596

596597
register_error_handlers(app)
597598
app.include_router(print_router)
599+
app.include_router(batch_routes.router)
598600
app.include_router(events_routes.router)
599601
app.include_router(printers_routes.router)
600602
app.include_router(templates_routes.router)

backend/app/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from app.models.api_key import ApiKey
88
from app.models.job import Job, JobState
99
from app.models.preset import Preset
10+
from app.models.print_batch import PrintBatch
1011
from app.models.printer import Printer
1112
from app.models.printer_state import PrinterState
1213
from app.models.printer_status_cache import PrinterStatusCache
@@ -17,6 +18,7 @@
1718
"Job",
1819
"JobState",
1920
"Preset",
21+
"PrintBatch",
2022
"Printer",
2123
"PrinterState",
2224
"PrinterStatusCache",

backend/app/models/print_batch.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""SQLModel table für print_batches — Tracking-Aggregat für Batch-Druckaufträge."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import UTC, datetime
6+
from uuid import UUID, uuid4
7+
8+
from sqlalchemy import JSON, DateTime
9+
from sqlmodel import Column, Field, SQLModel
10+
11+
12+
class PrintBatch(SQLModel, table=True):
13+
__tablename__ = "print_batches"
14+
15+
id: UUID = Field(default_factory=uuid4, primary_key=True)
16+
printer_id: UUID = Field(index=True, foreign_key="printers.id")
17+
job_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON))
18+
created_by: str = Field(index=True, description="SSO-Email oder API-Key-ID")
19+
created_at: datetime = Field(
20+
default_factory=lambda: datetime.now(UTC),
21+
sa_column=Column(DateTime(timezone=True), nullable=False, index=True),
22+
)

backend/app/models/printer.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ class Printer(SQLModel, table=True):
1515

1616
id: UUID = Field(default_factory=uuid4, primary_key=True)
1717
name: str = Field(index=True, unique=True)
18+
slug: str = Field(
19+
default="",
20+
index=True,
21+
unique=True,
22+
description="Stable URL-safe identifier (e.g., 'brother-p750w'). "
23+
"Defaults to slugified name on init.",
24+
)
1825
model: str
1926
backend: str
2027
connection: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""CRUD für PrintBatch-Aggregat."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import UTC, datetime, timedelta
6+
from uuid import UUID
7+
8+
from sqlalchemy import select
9+
from sqlalchemy.ext.asyncio import AsyncSession
10+
from sqlmodel import col
11+
12+
from app.models.print_batch import PrintBatch
13+
14+
15+
async def create(session: AsyncSession, batch: PrintBatch) -> PrintBatch:
16+
session.add(batch)
17+
await session.commit()
18+
await session.refresh(batch)
19+
return batch
20+
21+
22+
async def get(session: AsyncSession, batch_id: UUID) -> PrintBatch | None:
23+
return await session.get(PrintBatch, batch_id)
24+
25+
26+
async def list_recent(session: AsyncSession, hours: int = 24) -> list[PrintBatch]:
27+
since = datetime.now(UTC) - timedelta(hours=hours)
28+
result = await session.execute(
29+
select(PrintBatch)
30+
.where(col(PrintBatch.created_at) >= since)
31+
.order_by(col(PrintBatch.created_at).desc())
32+
)
33+
return list(result.scalars())
34+
35+
36+
async def prune_older_than(session: AsyncSession, hours: int = 24) -> int:
37+
cutoff = datetime.now(UTC) - timedelta(hours=hours)
38+
result = await session.execute(select(PrintBatch).where(col(PrintBatch.created_at) < cutoff))
39+
rows = list(result.scalars())
40+
for row in rows:
41+
await session.delete(row)
42+
await session.commit()
43+
return len(rows)

0 commit comments

Comments
 (0)