"""Printer CRUD API — POST /printers, DELETE /printers/{id}, PATCH /printers/{id}."""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from peewee import JOIN
from imptune.api.pages import group_printers_by_client
from imptune.db.models import Client, Driver, Printer
router = APIRouter(prefix="/printers")
templates = Jinja2Templates(
directory=str(Path(__file__).parent.parent / "templates")
)
_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"
",
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."""
import json
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 = list(Driver.select().order_by(Driver.uploaded_at.desc()))
driver_data = [
{"driver": d, "names": json.loads(d.driver_desc) if d.driver_desc else []}
for d in 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)