printer_locations.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  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. """
  12. import logging
  13. from fastapi import APIRouter, Depends, HTTPException
  14. from sqlalchemy import delete, func, select, update
  15. from sqlalchemy.exc import IntegrityError
  16. from sqlalchemy.ext.asyncio import AsyncSession
  17. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  18. from backend.app.core.database import get_db
  19. from backend.app.core.permissions import Permission
  20. from backend.app.core.websocket import ws_manager
  21. from backend.app.models.print_queue import PrintQueueItem
  22. from backend.app.models.printer import Printer
  23. from backend.app.models.printer_location import PrinterLocation
  24. from backend.app.models.user import User
  25. from backend.app.schemas.printer_location import (
  26. PrinterLocationAssign,
  27. PrinterLocationAssignResult,
  28. PrinterLocationCreate,
  29. PrinterLocationDelete,
  30. PrinterLocationDeleteResult,
  31. PrinterLocationResponse,
  32. PrinterLocationUpdate,
  33. )
  34. from backend.app.utils.natural_sort import natural_sort_key
  35. logger = logging.getLogger(__name__)
  36. router = APIRouter(prefix="/printer-locations", tags=["printer-locations"])
  37. DUPLICATE_NAME = "A location with this name already exists"
  38. def _blank_location():
  39. """Printers with no location: NULL, or "" from before blanks were folded."""
  40. return (Printer.location.is_(None)) | (func.trim(Printer.location) == "")
  41. async def _printer_counts(db: AsyncSession) -> dict[str, int]:
  42. result = await db.execute(
  43. select(Printer.location, func.count(Printer.id)).where(~_blank_location()).group_by(Printer.location)
  44. )
  45. return dict(result.all())
  46. async def _row(db: AsyncSession, name: str) -> PrinterLocation | None:
  47. result = await db.execute(select(PrinterLocation).where(PrinterLocation.name == name))
  48. return result.scalar_one_or_none()
  49. async def _name_taken(db: AsyncSession, name: str, *, ignore: str | None = None) -> bool:
  50. """Whether ``name`` is a location already, ignoring case.
  51. Matching elsewhere is exact, so "Workshop" and "workshop" would be two
  52. locations that look like one. Refusing the second keeps them apart.
  53. """
  54. key = name.casefold()
  55. rows = (await db.execute(select(PrinterLocation.name))).scalars().all()
  56. used = (await db.execute(select(Printer.location).where(~_blank_location()).distinct())).scalars().all()
  57. return any(n.casefold() == key and n != ignore for n in (*rows, *used))
  58. async def _conflicts(db: AsyncSession, name: str) -> bool:
  59. """Whether using ``name`` would add a case variant of a location.
  60. A name that already exists exactly is never a conflict, even when an older
  61. install also holds a case variant of it: refusing would leave no way to
  62. move a printer into, or style, a location that is plainly there.
  63. """
  64. if await _row(db, name) is not None:
  65. return False
  66. if name in await _printer_counts(db):
  67. return False
  68. return await _name_taken(db, name)
  69. async def _broadcast() -> None:
  70. await ws_manager.broadcast({"type": "printer_locations_changed"})
  71. @router.get("/", response_model=list[PrinterLocationResponse])
  72. async def list_printer_locations(
  73. db: AsyncSession = Depends(get_db),
  74. _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  75. ):
  76. """Every location: those with a row, and those only printers carry."""
  77. counts = await _printer_counts(db)
  78. rows = (await db.execute(select(PrinterLocation))).scalars().all()
  79. out = {
  80. row.name: PrinterLocationResponse(
  81. id=row.id, name=row.name, icon=row.icon, color=row.color, printer_count=counts.get(row.name, 0)
  82. )
  83. for row in rows
  84. }
  85. for name, count in counts.items():
  86. if name not in out:
  87. out[name] = PrinterLocationResponse(name=name, printer_count=count)
  88. return sorted(out.values(), key=lambda loc: natural_sort_key(loc.name))
  89. @router.post("/", response_model=PrinterLocationResponse, status_code=201)
  90. async def create_printer_location(
  91. data: PrinterLocationCreate,
  92. db: AsyncSession = Depends(get_db),
  93. _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
  94. ):
  95. """Create a location, with or without printers in it yet."""
  96. if await _row(db, data.name) is not None or await _conflicts(db, data.name):
  97. raise HTTPException(status_code=409, detail=DUPLICATE_NAME)
  98. # A location printers already use but that has no row yet gets one, which is
  99. # how it gets an icon and colour.
  100. row = PrinterLocation(name=data.name, icon=data.icon, color=data.color)
  101. db.add(row)
  102. try:
  103. await db.commit()
  104. except IntegrityError as exc:
  105. await db.rollback()
  106. raise HTTPException(status_code=409, detail=DUPLICATE_NAME) from exc
  107. await db.refresh(row)
  108. await _broadcast()
  109. counts = await _printer_counts(db)
  110. return PrinterLocationResponse(
  111. id=row.id, name=row.name, icon=row.icon, color=row.color, printer_count=counts.get(row.name, 0)
  112. )
  113. @router.patch("/", response_model=PrinterLocationResponse)
  114. async def update_printer_location(
  115. data: PrinterLocationUpdate,
  116. db: AsyncSession = Depends(get_db),
  117. _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
  118. ):
  119. """Rename a location and/or change its icon and colour.
  120. A rename moves its printers and the queue items that target it in the same
  121. transaction, so an "any printer in <location>" job, and the next run of a
  122. batch, keep finding their printers.
  123. """
  124. row = await _row(db, data.name)
  125. counts = await _printer_counts(db)
  126. if row is None and data.name not in counts:
  127. raise HTTPException(status_code=404, detail="Location not found")
  128. name = data.name
  129. new_name = data.new_name if "new_name" in data.model_fields_set else None
  130. if new_name is not None and new_name != name:
  131. if await _name_taken(db, new_name, ignore=name):
  132. raise HTTPException(status_code=409, detail=DUPLICATE_NAME)
  133. await db.execute(update(Printer).where(Printer.location == name).values(location=new_name))
  134. # Every row, not only pending ones: a batch clones its next run from
  135. # its newest row whatever that row's status, so a finished run still
  136. # pointing at the old name would send future runs nowhere. A rename is
  137. # the same place under a new name, so history follows it too.
  138. await db.execute(
  139. update(PrintQueueItem).where(PrintQueueItem.target_location == name).values(target_location=new_name)
  140. )
  141. name = new_name
  142. if row is None:
  143. row = PrinterLocation(name=name)
  144. db.add(row)
  145. else:
  146. row.name = name
  147. if "icon" in data.model_fields_set:
  148. row.icon = data.icon
  149. if "color" in data.model_fields_set:
  150. row.color = data.color
  151. try:
  152. await db.commit()
  153. except IntegrityError as exc:
  154. await db.rollback()
  155. raise HTTPException(status_code=409, detail=DUPLICATE_NAME) from exc
  156. await db.refresh(row)
  157. await _broadcast()
  158. counts = await _printer_counts(db)
  159. return PrinterLocationResponse(
  160. id=row.id, name=row.name, icon=row.icon, color=row.color, printer_count=counts.get(row.name, 0)
  161. )
  162. @router.post("/delete", response_model=PrinterLocationDeleteResult)
  163. async def delete_printer_locations(
  164. data: PrinterLocationDelete,
  165. db: AsyncSession = Depends(get_db),
  166. _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
  167. ):
  168. """Delete locations; their printers end up with no location.
  169. Pending queue items that target a deleted location are left alone: changing
  170. them to "any location" would let them start on printers they were meant to
  171. stay off.
  172. """
  173. names = set(data.names)
  174. existing = set((await db.execute(select(PrinterLocation.name).where(PrinterLocation.name.in_(names)))).scalars())
  175. existing |= set(await _printer_counts(db)) & names
  176. await db.execute(delete(PrinterLocation).where(PrinterLocation.name.in_(names)))
  177. moved = await db.execute(update(Printer).where(Printer.location.in_(names)).values(location=None))
  178. await db.commit()
  179. await _broadcast()
  180. return PrinterLocationDeleteResult(deleted=len(existing), printers_ungrouped=moved.rowcount or 0)
  181. @router.post("/assign", response_model=PrinterLocationAssignResult)
  182. async def assign_printer_location(
  183. data: PrinterLocationAssign,
  184. db: AsyncSession = Depends(get_db),
  185. _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
  186. ):
  187. """Move printers into a location, or out of any with null.
  188. By printer id, on the server: a printer someone else moved meanwhile is
  189. moved again only if it is in this request.
  190. """
  191. # Moving into a new case variant of an existing location would split it.
  192. if data.location is not None and await _conflicts(db, data.location):
  193. raise HTTPException(status_code=409, detail=DUPLICATE_NAME)
  194. ids = list(dict.fromkeys(data.printer_ids))
  195. found = (await db.execute(select(Printer.id).where(Printer.id.in_(ids)))).scalars().all()
  196. missing = sorted(set(ids) - set(found))
  197. if missing:
  198. raise HTTPException(status_code=404, detail=f"Printer not found: {', '.join(map(str, missing))}")
  199. result = await db.execute(update(Printer).where(Printer.id.in_(ids)).values(location=data.location))
  200. await db.commit()
  201. await _broadcast()
  202. return PrinterLocationAssignResult(moved=result.rowcount or 0)