printer_locations.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. """Printer locations (groups): list, create, rename, restyle, delete, assign (#2962).
  2. A printer's location is the free-text ``printers.location`` column, which the
  3. scheduler's model-based targeting (``print_queue.target_location``) and the
  4. printers filter match exactly. ``printer_locations`` adds the rest: a location
  5. with no printers yet, and its icon and colour. The list is the union of both,
  6. so a location typed into the printer dialog shows up here without a row.
  7. Every write is one transaction on the server. The page used to send one PATCH
  8. per printer from its cached printer list: a failure halfway left a location
  9. split under two names, and a printer another user had moved in the meantime
  10. was moved back.
  11. Locations are also an access setting (#1727): a restricted group can be given
  12. a location, and then reaches every printer in it. So these routes follow the
  13. same rules as editing a printer: moving printers into or out of a granted
  14. location is for admins only, a rename carries the grants along to the new
  15. name, and a caller limited to some printers sees and changes only locations
  16. made of their own printers.
  17. """
  18. import logging
  19. from fastapi import APIRouter, Depends, HTTPException
  20. from sqlalchemy import delete, func, insert, select, update
  21. from sqlalchemy.exc import IntegrityError
  22. from sqlalchemy.ext.asyncio import AsyncSession
  23. from backend.app.core.auth import RequestPrinterScope, RequirePermissionIfAuthEnabled, is_auth_enabled
  24. from backend.app.core.database import get_db
  25. from backend.app.core.permissions import Permission
  26. from backend.app.core.printer_scope import PrinterScope, location_grantees
  27. from backend.app.core.websocket import ws_manager
  28. from backend.app.models.group import group_locations
  29. from backend.app.models.print_queue import PrintQueueItem
  30. from backend.app.models.printer import Printer
  31. from backend.app.models.printer_location import PrinterLocation
  32. from backend.app.models.user import User
  33. from backend.app.schemas.printer_location import (
  34. PrinterLocationAssign,
  35. PrinterLocationAssignResult,
  36. PrinterLocationCreate,
  37. PrinterLocationDelete,
  38. PrinterLocationDeleteResult,
  39. PrinterLocationResponse,
  40. PrinterLocationUpdate,
  41. )
  42. from backend.app.utils.natural_sort import natural_sort_key
  43. logger = logging.getLogger(__name__)
  44. router = APIRouter(prefix="/printer-locations", tags=["printer-locations"])
  45. DUPLICATE_NAME = "A location with this name already exists"
  46. def _blank_location():
  47. """Printers with no location: NULL, or "" from before blanks were folded."""
  48. return (Printer.location.is_(None)) | (func.trim(Printer.location) == "")
  49. async def _printer_counts(db: AsyncSession, scope: PrinterScope | None = None) -> dict[str, int]:
  50. """Printers per location; with ``scope``, only the printers in it."""
  51. query = select(Printer.location, func.count(Printer.id)).where(~_blank_location())
  52. limit = scope.where_strict(Printer.id) if scope is not None else None
  53. if limit is not None:
  54. query = query.where(limit)
  55. result = await db.execute(query.group_by(Printer.location))
  56. return dict(result.all())
  57. async def _ensure_all_in_scope(db: AsyncSession, scope: PrinterScope, names: set[str]) -> None:
  58. """Refuse a limited caller a location that also holds printers they can't see.
  59. Renaming or deleting it would change those printers too.
  60. """
  61. if scope.is_unrestricted or not names:
  62. return
  63. ids = (await db.execute(select(Printer.id).where(Printer.location.in_(names)))).scalars().all()
  64. if any(not scope.allows(pid) for pid in ids):
  65. raise HTTPException(
  66. status_code=403,
  67. detail="This location also holds printers you can't access, so only someone who can see them all may change it.",
  68. )
  69. async def _ensure_admin_for_access_change(db: AsyncSession, user: User | None, grantees: list[str]) -> None:
  70. """Moving printers into or out of a granted location changes who reaches them (#1727)."""
  71. if grantees and await is_auth_enabled(db) and not (user is not None and user.is_admin):
  72. raise HTTPException(
  73. status_code=403,
  74. detail=(
  75. "This changes which printers the groups "
  76. + ", ".join(grantees)
  77. + " can access through their locations. Only an admin can do that."
  78. ),
  79. )
  80. async def _row(db: AsyncSession, name: str) -> PrinterLocation | None:
  81. result = await db.execute(select(PrinterLocation).where(PrinterLocation.name == name))
  82. return result.scalar_one_or_none()
  83. async def _name_taken(db: AsyncSession, name: str, *, ignore: str | None = None) -> bool:
  84. """Whether ``name`` is a location already, ignoring case.
  85. Matching elsewhere is exact, so "Workshop" and "workshop" would be two
  86. locations that look like one. Refusing the second keeps them apart.
  87. """
  88. key = name.casefold()
  89. rows = (await db.execute(select(PrinterLocation.name))).scalars().all()
  90. used = (await db.execute(select(Printer.location).where(~_blank_location()).distinct())).scalars().all()
  91. return any(n.casefold() == key and n != ignore for n in (*rows, *used))
  92. async def _conflicts(db: AsyncSession, name: str) -> bool:
  93. """Whether using ``name`` would add a case variant of a location.
  94. A name that already exists exactly is never a conflict, even when an older
  95. install also holds a case variant of it: refusing would leave no way to
  96. move a printer into, or style, a location that is plainly there.
  97. """
  98. if await _row(db, name) is not None:
  99. return False
  100. if name in await _printer_counts(db):
  101. return False
  102. return await _name_taken(db, name)
  103. async def _move_grants(db: AsyncSession, old: str, new: str) -> None:
  104. """Re-point the groups' grants of ``old`` to ``new`` (#1727).
  105. A group already granted ``new`` keeps one grant: the pair is the primary key.
  106. """
  107. holders = (
  108. (await db.execute(select(group_locations.c.group_id).where(group_locations.c.location == old))).scalars().all()
  109. )
  110. if not holders:
  111. return
  112. already = set(
  113. (
  114. await db.execute(
  115. select(group_locations.c.group_id).where(
  116. group_locations.c.location == new, group_locations.c.group_id.in_(holders)
  117. )
  118. )
  119. )
  120. .scalars()
  121. .all()
  122. )
  123. await db.execute(delete(group_locations).where(group_locations.c.location == old))
  124. rows = [{"group_id": gid, "location": new} for gid in holders if gid not in already]
  125. if rows:
  126. await db.execute(insert(group_locations), rows)
  127. async def _broadcast() -> None:
  128. await ws_manager.broadcast({"type": "printer_locations_changed"})
  129. @router.get("/", response_model=list[PrinterLocationResponse])
  130. async def list_printer_locations(
  131. db: AsyncSession = Depends(get_db),
  132. _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  133. printer_scope: PrinterScope = RequestPrinterScope,
  134. ):
  135. """Every location: those with a row, and those only printers carry.
  136. A caller limited to some printers (#1727) sees only the locations their
  137. printers are in, counted over those printers.
  138. """
  139. counts = await _printer_counts(db, printer_scope)
  140. rows = (await db.execute(select(PrinterLocation))).scalars().all()
  141. if not printer_scope.is_unrestricted:
  142. rows = [row for row in rows if row.name in counts]
  143. out = {
  144. row.name: PrinterLocationResponse(
  145. id=row.id, name=row.name, icon=row.icon, color=row.color, printer_count=counts.get(row.name, 0)
  146. )
  147. for row in rows
  148. }
  149. for name, count in counts.items():
  150. if name not in out:
  151. out[name] = PrinterLocationResponse(name=name, printer_count=count)
  152. return sorted(out.values(), key=lambda loc: natural_sort_key(loc.name))
  153. @router.post("/", response_model=PrinterLocationResponse, status_code=201)
  154. async def create_printer_location(
  155. data: PrinterLocationCreate,
  156. db: AsyncSession = Depends(get_db),
  157. _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
  158. ):
  159. """Create a location, with or without printers in it yet."""
  160. if await _row(db, data.name) is not None or await _conflicts(db, data.name):
  161. raise HTTPException(status_code=409, detail=DUPLICATE_NAME)
  162. # A location printers already use but that has no row yet gets one, which is
  163. # how it gets an icon and colour.
  164. row = PrinterLocation(name=data.name, icon=data.icon, color=data.color)
  165. db.add(row)
  166. try:
  167. await db.commit()
  168. except IntegrityError as exc:
  169. await db.rollback()
  170. raise HTTPException(status_code=409, detail=DUPLICATE_NAME) from exc
  171. await db.refresh(row)
  172. await _broadcast()
  173. counts = await _printer_counts(db)
  174. return PrinterLocationResponse(
  175. id=row.id, name=row.name, icon=row.icon, color=row.color, printer_count=counts.get(row.name, 0)
  176. )
  177. @router.patch("/", response_model=PrinterLocationResponse)
  178. async def update_printer_location(
  179. data: PrinterLocationUpdate,
  180. db: AsyncSession = Depends(get_db),
  181. user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
  182. printer_scope: PrinterScope = RequestPrinterScope,
  183. ):
  184. """Rename a location and/or change its icon and colour.
  185. A rename moves its printers and the queue items that target it in the same
  186. transaction, so an "any printer in <location>" job, and the next run of a
  187. batch, keep finding their printers.
  188. """
  189. row = await _row(db, data.name)
  190. counts = await _printer_counts(db)
  191. if row is None and data.name not in counts:
  192. raise HTTPException(status_code=404, detail="Location not found")
  193. if not printer_scope.is_unrestricted and data.name not in await _printer_counts(db, printer_scope):
  194. raise HTTPException(status_code=404, detail="Location not found")
  195. await _ensure_all_in_scope(db, printer_scope, {data.name})
  196. name = data.name
  197. new_name = data.new_name if "new_name" in data.model_fields_set else None
  198. if new_name is not None and new_name != name:
  199. if await _name_taken(db, new_name, ignore=name):
  200. raise HTTPException(status_code=409, detail=DUPLICATE_NAME)
  201. # A grant left on the new name (#1727) would hand these printers to
  202. # another group: that is an access change.
  203. await _ensure_admin_for_access_change(db, user, await location_grantees(db, [new_name]))
  204. await db.execute(update(Printer).where(Printer.location == name).values(location=new_name))
  205. # The groups given this location keep it under its new name, so the
  206. # rename changes no one's access.
  207. await _move_grants(db, name, new_name)
  208. # Every row, not only pending ones: a batch clones its next run from
  209. # its newest row whatever that row's status, so a finished run still
  210. # pointing at the old name would send future runs nowhere. A rename is
  211. # the same place under a new name, so history follows it too.
  212. await db.execute(
  213. update(PrintQueueItem).where(PrintQueueItem.target_location == name).values(target_location=new_name)
  214. )
  215. name = new_name
  216. if row is None:
  217. row = PrinterLocation(name=name)
  218. db.add(row)
  219. else:
  220. row.name = name
  221. if "icon" in data.model_fields_set:
  222. row.icon = data.icon
  223. if "color" in data.model_fields_set:
  224. row.color = data.color
  225. try:
  226. await db.commit()
  227. except IntegrityError as exc:
  228. await db.rollback()
  229. raise HTTPException(status_code=409, detail=DUPLICATE_NAME) from exc
  230. await db.refresh(row)
  231. await _broadcast()
  232. counts = await _printer_counts(db)
  233. return PrinterLocationResponse(
  234. id=row.id, name=row.name, icon=row.icon, color=row.color, printer_count=counts.get(row.name, 0)
  235. )
  236. @router.post("/delete", response_model=PrinterLocationDeleteResult)
  237. async def delete_printer_locations(
  238. data: PrinterLocationDelete,
  239. db: AsyncSession = Depends(get_db),
  240. user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
  241. printer_scope: PrinterScope = RequestPrinterScope,
  242. ):
  243. """Delete locations; their printers end up with no location.
  244. Pending queue items that target a deleted location are left alone: changing
  245. them to "any location" would let them start on printers they were meant to
  246. stay off.
  247. A location given to a group (#1727) can only be deleted by an admin, and
  248. its grants go with it: left behind, they would hand a later location of
  249. the same name to that group.
  250. """
  251. names = set(data.names)
  252. if not printer_scope.is_unrestricted:
  253. # A limited caller can only reach locations made of their own printers.
  254. names &= set(await _printer_counts(db, printer_scope))
  255. await _ensure_all_in_scope(db, printer_scope, names)
  256. grantees = await location_grantees(db, names)
  257. await _ensure_admin_for_access_change(db, user, grantees)
  258. existing = set((await db.execute(select(PrinterLocation.name).where(PrinterLocation.name.in_(names)))).scalars())
  259. existing |= set(await _printer_counts(db)) & names
  260. await db.execute(delete(PrinterLocation).where(PrinterLocation.name.in_(names)))
  261. moved = await db.execute(update(Printer).where(Printer.location.in_(names)).values(location=None))
  262. if names:
  263. await db.execute(delete(group_locations).where(group_locations.c.location.in_(names)))
  264. await db.commit()
  265. if grantees:
  266. await ws_manager.refresh_printer_scopes()
  267. await _broadcast()
  268. return PrinterLocationDeleteResult(deleted=len(existing), printers_ungrouped=moved.rowcount or 0)
  269. @router.post("/assign", response_model=PrinterLocationAssignResult)
  270. async def assign_printer_location(
  271. data: PrinterLocationAssign,
  272. db: AsyncSession = Depends(get_db),
  273. user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
  274. printer_scope: PrinterScope = RequestPrinterScope,
  275. ):
  276. """Move printers into a location, or out of any with null.
  277. By printer id, on the server: a printer someone else moved meanwhile is
  278. moved again only if it is in this request. A printer outside the caller's
  279. scope (#1727) is "not found", and moving printers into or out of a
  280. location given to a group is for admins only, as when editing a printer.
  281. """
  282. # Moving into a new case variant of an existing location would split it.
  283. if data.location is not None and await _conflicts(db, data.location):
  284. raise HTTPException(status_code=409, detail=DUPLICATE_NAME)
  285. ids = list(dict.fromkeys(data.printer_ids))
  286. rows = (await db.execute(select(Printer.id, Printer.location).where(Printer.id.in_(ids)))).all()
  287. found = {pid for pid, _ in rows if printer_scope.allows(pid)}
  288. missing = sorted(set(ids) - found)
  289. if missing:
  290. raise HTTPException(status_code=404, detail=f"Printer not found: {', '.join(map(str, missing))}")
  291. leaving = {location for _, location in rows if location != data.location}
  292. grantees = await location_grantees(db, [*leaving, data.location]) if leaving else []
  293. await _ensure_admin_for_access_change(db, user, grantees)
  294. result = await db.execute(update(Printer).where(Printer.id.in_(ids)).values(location=data.location))
  295. await db.commit()
  296. if grantees:
  297. await ws_manager.refresh_printer_scopes()
  298. await _broadcast()
  299. return PrinterLocationAssignResult(moved=result.rowcount or 0)