"""Printer locations (groups): list, create, rename, restyle, delete, assign (#2962). A printer's location is the free-text ``printers.location`` column, which the scheduler's model-based targeting (``print_queue.target_location``) and the printers filter match exactly. ``printer_locations`` adds the rest: a location with no printers yet, and its icon and colour. The list is the union of both, so a location typed into the printer dialog shows up here without a row. Every write is one transaction on the server. The page used to send one PATCH per printer from its cached printer list: a failure halfway left a location split under two names, and a printer another user had moved in the meantime was moved back. """ import logging from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import delete, func, select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from backend.app.core.auth import RequirePermissionIfAuthEnabled from backend.app.core.database import get_db from backend.app.core.permissions import Permission from backend.app.core.websocket import ws_manager from backend.app.models.print_queue import PrintQueueItem from backend.app.models.printer import Printer from backend.app.models.printer_location import PrinterLocation from backend.app.models.user import User from backend.app.schemas.printer_location import ( PrinterLocationAssign, PrinterLocationAssignResult, PrinterLocationCreate, PrinterLocationDelete, PrinterLocationDeleteResult, PrinterLocationResponse, PrinterLocationUpdate, ) from backend.app.utils.natural_sort import natural_sort_key logger = logging.getLogger(__name__) router = APIRouter(prefix="/printer-locations", tags=["printer-locations"]) DUPLICATE_NAME = "A location with this name already exists" def _blank_location(): """Printers with no location: NULL, or "" from before blanks were folded.""" return (Printer.location.is_(None)) | (func.trim(Printer.location) == "") async def _printer_counts(db: AsyncSession) -> dict[str, int]: result = await db.execute( select(Printer.location, func.count(Printer.id)).where(~_blank_location()).group_by(Printer.location) ) return dict(result.all()) async def _row(db: AsyncSession, name: str) -> PrinterLocation | None: result = await db.execute(select(PrinterLocation).where(PrinterLocation.name == name)) return result.scalar_one_or_none() async def _name_taken(db: AsyncSession, name: str, *, ignore: str | None = None) -> bool: """Whether ``name`` is a location already, ignoring case. Matching elsewhere is exact, so "Workshop" and "workshop" would be two locations that look like one. Refusing the second keeps them apart. """ key = name.casefold() rows = (await db.execute(select(PrinterLocation.name))).scalars().all() used = (await db.execute(select(Printer.location).where(~_blank_location()).distinct())).scalars().all() return any(n.casefold() == key and n != ignore for n in (*rows, *used)) async def _conflicts(db: AsyncSession, name: str) -> bool: """Whether using ``name`` would add a case variant of a location. A name that already exists exactly is never a conflict, even when an older install also holds a case variant of it: refusing would leave no way to move a printer into, or style, a location that is plainly there. """ if await _row(db, name) is not None: return False if name in await _printer_counts(db): return False return await _name_taken(db, name) async def _broadcast() -> None: await ws_manager.broadcast({"type": "printer_locations_changed"}) @router.get("/", response_model=list[PrinterLocationResponse]) async def list_printer_locations( db: AsyncSession = Depends(get_db), _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), ): """Every location: those with a row, and those only printers carry.""" counts = await _printer_counts(db) rows = (await db.execute(select(PrinterLocation))).scalars().all() out = { row.name: PrinterLocationResponse( id=row.id, name=row.name, icon=row.icon, color=row.color, printer_count=counts.get(row.name, 0) ) for row in rows } for name, count in counts.items(): if name not in out: out[name] = PrinterLocationResponse(name=name, printer_count=count) return sorted(out.values(), key=lambda loc: natural_sort_key(loc.name)) @router.post("/", response_model=PrinterLocationResponse, status_code=201) async def create_printer_location( data: PrinterLocationCreate, db: AsyncSession = Depends(get_db), _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE), ): """Create a location, with or without printers in it yet.""" if await _row(db, data.name) is not None or await _conflicts(db, data.name): raise HTTPException(status_code=409, detail=DUPLICATE_NAME) # A location printers already use but that has no row yet gets one, which is # how it gets an icon and colour. row = PrinterLocation(name=data.name, icon=data.icon, color=data.color) db.add(row) try: await db.commit() except IntegrityError as exc: await db.rollback() raise HTTPException(status_code=409, detail=DUPLICATE_NAME) from exc await db.refresh(row) await _broadcast() counts = await _printer_counts(db) return PrinterLocationResponse( id=row.id, name=row.name, icon=row.icon, color=row.color, printer_count=counts.get(row.name, 0) ) @router.patch("/", response_model=PrinterLocationResponse) async def update_printer_location( data: PrinterLocationUpdate, db: AsyncSession = Depends(get_db), _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE), ): """Rename a location and/or change its icon and colour. A rename moves its printers and the queue items that target it in the same transaction, so an "any printer in " job, and the next run of a batch, keep finding their printers. """ row = await _row(db, data.name) counts = await _printer_counts(db) if row is None and data.name not in counts: raise HTTPException(status_code=404, detail="Location not found") name = data.name new_name = data.new_name if "new_name" in data.model_fields_set else None if new_name is not None and new_name != name: if await _name_taken(db, new_name, ignore=name): raise HTTPException(status_code=409, detail=DUPLICATE_NAME) await db.execute(update(Printer).where(Printer.location == name).values(location=new_name)) # Every row, not only pending ones: a batch clones its next run from # its newest row whatever that row's status, so a finished run still # pointing at the old name would send future runs nowhere. A rename is # the same place under a new name, so history follows it too. await db.execute( update(PrintQueueItem).where(PrintQueueItem.target_location == name).values(target_location=new_name) ) name = new_name if row is None: row = PrinterLocation(name=name) db.add(row) else: row.name = name if "icon" in data.model_fields_set: row.icon = data.icon if "color" in data.model_fields_set: row.color = data.color try: await db.commit() except IntegrityError as exc: await db.rollback() raise HTTPException(status_code=409, detail=DUPLICATE_NAME) from exc await db.refresh(row) await _broadcast() counts = await _printer_counts(db) return PrinterLocationResponse( id=row.id, name=row.name, icon=row.icon, color=row.color, printer_count=counts.get(row.name, 0) ) @router.post("/delete", response_model=PrinterLocationDeleteResult) async def delete_printer_locations( data: PrinterLocationDelete, db: AsyncSession = Depends(get_db), _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE), ): """Delete locations; their printers end up with no location. Pending queue items that target a deleted location are left alone: changing them to "any location" would let them start on printers they were meant to stay off. """ names = set(data.names) existing = set((await db.execute(select(PrinterLocation.name).where(PrinterLocation.name.in_(names)))).scalars()) existing |= set(await _printer_counts(db)) & names await db.execute(delete(PrinterLocation).where(PrinterLocation.name.in_(names))) moved = await db.execute(update(Printer).where(Printer.location.in_(names)).values(location=None)) await db.commit() await _broadcast() return PrinterLocationDeleteResult(deleted=len(existing), printers_ungrouped=moved.rowcount or 0) @router.post("/assign", response_model=PrinterLocationAssignResult) async def assign_printer_location( data: PrinterLocationAssign, db: AsyncSession = Depends(get_db), _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE), ): """Move printers into a location, or out of any with null. By printer id, on the server: a printer someone else moved meanwhile is moved again only if it is in this request. """ # Moving into a new case variant of an existing location would split it. if data.location is not None and await _conflicts(db, data.location): raise HTTPException(status_code=409, detail=DUPLICATE_NAME) ids = list(dict.fromkeys(data.printer_ids)) found = (await db.execute(select(Printer.id).where(Printer.id.in_(ids)))).scalars().all() missing = sorted(set(ids) - set(found)) if missing: raise HTTPException(status_code=404, detail=f"Printer not found: {', '.join(map(str, missing))}") result = await db.execute(update(Printer).where(Printer.id.in_(ids)).values(location=data.location)) await db.commit() await _broadcast() return PrinterLocationAssignResult(moved=result.rowcount or 0)