|
| 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 | + ) |
0 commit comments