-
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
Merged
Merged
Changes from all 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 c9ae9f8
fix(sse): ergänze data-job-id + data-state in job_state.html-Fragment
strausmann e8de35b
feat(printer): add slug column with backfill from name
strausmann f99137e
feat(printers): add slug lookup + unified slug/uuid resolver
strausmann a0cbd3d
feat(schemas): expose slug field on PrinterRead
strausmann a3ae386
fix(tests): seed Printer-Helper mit explizitem slug
strausmann e6f112f
feat(api): GET /api/printers?slug=... filter
strausmann b27b876
feat(persistence): add print_batches aggregate
strausmann 7d6c4ac
feat(schemas): add BatchRequest/Response/Error Pydantic models
strausmann d6cd621
feat(seeds): add hangar-furniture templates (12mm/18mm/24mm)
strausmann 44534d0
feat(services): best-effort batch dispatcher
strausmann 85e1de8
feat(api): POST /api/print/{slug_or_uuid}/batch endpoint
strausmann c7fdc80
test(api): batch endpoint happy path
strausmann baca4c4
test(api): batch endpoint partial failure with template_not_found
strausmann d54e468
test(api): batch endpoint rejects with 409 when printer offline
strausmann a093056
test(api): batch endpoint tape_mismatch + auth happy path
strausmann 702f7e2
test(sse): phase 6b stream contains all batch job events
strausmann 562ef62
fix(ci): adressiere PR #92 review-findings (ruff + mypy + privacy + c…
strausmann bca88c6
chore(ci): trigger fresh CodeQL aggregate after alert-dismissal
strausmann 1540fd7
revert(events): entferne nutzlose codeql-Inline-Suppressions
strausmann 84232ce
test(api): align batch endpoint tests with single-printer-binding
strausmann 2dbf21c
fix(api): adressiere Copilot+Gemini Review-Findings auf PR #92
strausmann File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
66 changes: 66 additions & 0 deletions
66
backend/alembic/versions/a0516c04278c_add_print_batches_table.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| """add print_batches table | ||
|
|
||
| Revision ID: a0516c04278c | ||
| Revises: da865401716d | ||
| Create Date: 2026-05-30 15:18:58.348408 | ||
|
|
||
| """ | ||
|
|
||
| from collections.abc import Sequence | ||
|
|
||
| import sqlalchemy as sa | ||
| import sqlmodel | ||
| from alembic import op | ||
|
|
||
| # revision identifiers, used by Alembic. | ||
| revision: str = "a0516c04278c" | ||
| down_revision: str | Sequence[str] | None = "da865401716d" | ||
| branch_labels: str | Sequence[str] | None = None | ||
| depends_on: 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 ### |
41 changes: 41 additions & 0 deletions
41
backend/alembic/versions/da865401716d_add_printer_slug_column.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| """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") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| """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, 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 (404 if unknown slug/uuid) | ||
| 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"}) | ||
|
|
||
| # 2. ACL: api-key may be restricted to a subset of printer_ids | ||
| check_printer_access(auth, printer.id) | ||
|
|
||
| # 3. Verify the resolved printer matches the singleton wired into | ||
| # app.state.print_service. The Hub is currently single-printer at | ||
| # startup (main.py wires PrintService to app.state.printer_id). | ||
| # If the URL slug points to a different printer row, the dispatch | ||
| # would silently route to the wrong device. Reject explicitly. | ||
| seeded_printer_id = getattr(http.app.state, "printer_id", None) | ||
| if seeded_printer_id is not None and printer.id != seeded_printer_id: | ||
| raise HTTPException( | ||
| 404, | ||
| detail={ | ||
| "error_code": "printer_not_active", | ||
| "error_message": ( | ||
| "Resolved printer is not the currently-seeded device. " | ||
| "Hub is single-printer at startup; multi-printer routing " | ||
| "is a future enhancement." | ||
| ), | ||
| }, | ||
| ) | ||
|
|
||
| # 4. 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), | ||
| }, | ||
| ) from exc | ||
|
|
||
| # 5. 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, | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| """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), | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| """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) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
The
prune_older_thanfunction fetches all matchingPrintBatchrows 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 bulkdeletestatement instead.