"""Printer CRUD API — POST /printers, DELETE /printers/{id}, PATCH /printers/{id}.""" from __future__ import annotations from fastapi import APIRouter, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse, Response from peewee import JOIN from imptune.api.pages import build_driver_data, group_printers_by_client from imptune.db.models import Client, Driver, Printer from imptune.templating import templates router = APIRouter(prefix="/printers") _VALID_DUPLEX = {"OneSided", "LongEdge", "ShortEdge"} _VALID_PAPER = {"A4", "Letter", "Legal"} def _error_response(message: str, status_code: int = 400) -> HTMLResponse: """Return an HTMX-friendly error fragment swapped into #printer-list.""" return HTMLResponse( content=f"

{message}

", status_code=status_code, ) def _validate_fields( name: str, ip_address: str, port_name: str, duplex_mode: str, paper_size: str ) -> HTMLResponse | None: """Shared field validation for create and update — None when everything is valid.""" if not name: return _error_response("Printer name is required.") if not ip_address: return _error_response("IP address is required.") if not port_name: return _error_response("Port name is required.") if duplex_mode not in _VALID_DUPLEX: return _error_response(f"Invalid duplex mode: {duplex_mode}.") if paper_size not in _VALID_PAPER: return _error_response(f"Invalid paper size: {paper_size}.") return None def _resolve_client(raw: str, owner) -> tuple[int | None, HTMLResponse | None]: """Resolve the optional client_id form field to an owned Client id. The form value is attacker-controlled text: a non-numeric value used to raise ValueError (HTTP 500) instead of the 400 the HTMX form can render. """ raw = raw.strip() if not raw: return None, None try: client_fk = int(raw) except ValueError: return None, _error_response(f"Invalid client id: {raw}.") if Client.get_or_none((Client.id == client_fk) & (Client.owner == owner)) is None: return None, _error_response(f"Client {client_fk} not found.", status_code=404) return client_fk, None def _resolve_driver(raw: str) -> tuple[int | None, HTMLResponse | None]: """Resolve the optional driver_id form field. Drivers are global/shared. Existence is checked here because an unknown id otherwise reaches SQLite as a FOREIGN KEY violation — an IntegrityError (HTTP 500) rather than a 404. """ raw = raw.strip() if not raw: return None, None try: driver_fk = int(raw) except ValueError: return None, _error_response(f"Invalid driver id: {raw}.") if Driver.get_or_none(Driver.id == driver_fk) is None: return None, _error_response(f"Driver {driver_fk} not found.", status_code=404) return driver_fk, None def _render_printer_list(request: Request) -> HTMLResponse: """Query printers with LEFT JOIN on client and render grouped partial.""" owner = request.state.owner query = ( Printer.select(Printer, Client) .join(Client, JOIN.LEFT_OUTER) .where(Printer.owner == owner) .order_by(Client.name, Printer.name) ) grouped = group_printers_by_client(query) clients = list(Client.select().where(Client.owner == owner).order_by(Client.name)) all_drivers = Driver.select().order_by(Driver.uploaded_at.desc()) driver_data = build_driver_data(all_drivers) return templates.TemplateResponse( request=request, name="partials/printer_list.html", context={"grouped": grouped, "clients": clients, "driver_data": driver_data}, ) @router.post("", response_class=HTMLResponse) def create_printer( request: Request, name: str = Form(...), ip_address: str = Form(...), port_name: str = Form(...), duplex_mode: str = Form("OneSided"), color_mode: str = Form(""), paper_size: str = Form("A4"), collate: str = Form(""), client_id: str = Form(""), driver_id: str = Form(""), ) -> Response: """Create a new printer configuration. Redirects to /printers on success; returns an inline HTMX error fragment (400/404) on validation failure. """ name = name.strip() ip_address = ip_address.strip() port_name = port_name.strip() invalid = _validate_fields(name, ip_address, port_name, duplex_mode, paper_size) if invalid is not None: return invalid owner = request.state.owner # Resolve optional FK IDs — client must belong to this owner client_fk, error = _resolve_client(client_id, owner) if error is not None: return error driver_fk, error = _resolve_driver(driver_id) if error is not None: return error Printer.create( name=name, ip_address=ip_address, port_name=port_name, duplex_mode=duplex_mode, # HTML checkbox convention: "on" = True, absent/empty = False color_mode=color_mode == "on", paper_size=paper_size, collate=collate == "on", owner=owner, client=client_fk, driver=driver_fk, ) return RedirectResponse(url="/printers", status_code=303) @router.delete("/{printer_id}", response_class=HTMLResponse) def delete_printer(request: Request, printer_id: int) -> HTMLResponse: """Delete a printer by ID. Returns updated printer list partial.""" deleted = ( Printer.delete() .where((Printer.id == printer_id) & (Printer.owner == request.state.owner)) .execute() ) if not deleted: return _error_response(f"Printer {printer_id} not found.", status_code=404) return _render_printer_list(request) @router.patch("/{printer_id}", response_class=HTMLResponse) def update_printer( request: Request, printer_id: int, name: str = Form(...), ip_address: str = Form(""), port_name: str = Form(""), duplex_mode: str = Form("OneSided"), color_mode: str = Form(""), paper_size: str = Form("A4"), collate: str = Form(""), client_id: str = Form(""), driver_id: str = Form(""), ) -> HTMLResponse: """Update an existing printer configuration in-place.""" from datetime import UTC, datetime owner = request.state.owner printer = Printer.get_or_none((Printer.id == printer_id) & (Printer.owner == owner)) if printer is None: return _error_response(f"Printer {printer_id} not found.", status_code=404) name = name.strip() ip_address = ip_address.strip() or printer.ip_address port_name = port_name.strip() or printer.port_name invalid = _validate_fields(name, ip_address, port_name, duplex_mode, paper_size) if invalid is not None: return invalid client_fk, error = _resolve_client(client_id, owner) if error is not None: return error driver_fk, error = _resolve_driver(driver_id) if error is not None: return error printer.name = name printer.ip_address = ip_address printer.port_name = port_name printer.duplex_mode = duplex_mode printer.color_mode = color_mode == "on" printer.paper_size = paper_size printer.collate = collate == "on" printer.client = client_fk printer.driver = driver_fk printer.updated_at = datetime.now(UTC).replace(tzinfo=None) printer.save() return _render_printer_list(request)