| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240 |
- """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 <location>" 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)
|