-
Notifications
You must be signed in to change notification settings - Fork 0
feat(api): Hub batch print endpoint + slug + hangar templates for Hangar integration #92
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 17 commits
0a2a9f3
c9ae9f8
e8de35b
f99137e
a0cbd3d
a3ae386
e6f112f
b27b876
7d6c4ac
d6cd621
44534d0
85e1de8
c7fdc80
baca4c4
d54e468
a093056
702f7e2
562ef62
bca88c6
1540fd7
84232ce
2dbf21c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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") |
| 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 | ||||||||||||||||||||||
| 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"}) | ||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The batch print endpoint does not enforce printer-specific access controls. If a user or API key has the
Suggested change
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| # 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, | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| 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), | ||
| ) |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The 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 |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Import
check_printer_accessto enforce printer-specific authorization checks in the batch endpoint.