bulk.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. """Bulk reads for pages that show many printers (or archives) at once.
  2. The Printers page asked for each printer's status, slot presets, AMS labels,
  3. plugs, sensor readings, firmware and queue with one request per printer, and
  4. the Archives page for each card's folders with one per archive. On a print
  5. farm that was hundreds of requests per page load, queued one behind the other
  6. on the server. The frontend (api/batch.ts) coalesces those per-id calls into
  7. these.
  8. Each route answers exactly what its single-id route answers, through the same
  9. helper. A printer outside the caller's scope, or one that doesn't exist, is
  10. left out; the client then asks the single route for it, which gives its 404.
  11. They live under their own prefix so no ``/{id}`` route of another router can
  12. take them for an id.
  13. """
  14. from fastapi import APIRouter, Depends, Query
  15. from sqlalchemy import and_, func, or_, select
  16. from sqlalchemy.ext.asyncio import AsyncSession
  17. from backend.app.api.routes.firmware import FirmwareUpdateInfo, _checks_enabled, _not_checked, _printer_update_info
  18. from backend.app.api.routes.ha_sensors import _card_sensors, _reading
  19. from backend.app.api.routes.library import _folders_by_archive
  20. from backend.app.api.routes.print_queue import _QUEUE_ITEM_LOADS, _enrich_response
  21. from backend.app.api.routes.printers import _ams_labels, _printer_status, _slot_presets_by_key
  22. from backend.app.api.routes.smart_plugs import (
  23. CallerEditsPlugs,
  24. PrinterCardPlugs,
  25. _card_script_plugs,
  26. _pick_main_plug,
  27. _serialize_plug,
  28. )
  29. from backend.app.core.auth import (
  30. ApiKeyActor,
  31. RequestPrinterScope,
  32. RequirePermissionIfAuthEnabled,
  33. require_ownership_permission,
  34. )
  35. from backend.app.core.database import get_db
  36. from backend.app.core.permissions import Permission
  37. from backend.app.core.printer_scope import PrinterScope
  38. from backend.app.models.print_queue import PrintQueueItem
  39. from backend.app.models.printer import Printer
  40. from backend.app.models.printer_ha_sensor import PrinterHASensor
  41. from backend.app.models.slot_preset import SlotPresetMapping
  42. from backend.app.models.smart_plug import SmartPlug
  43. from backend.app.models.user import User
  44. from backend.app.schemas.library import FolderResponse
  45. from backend.app.schemas.print_queue import PrintQueueItemResponse
  46. from backend.app.schemas.printer import PrinterStatus
  47. from backend.app.schemas.printer_ha_sensor import PrinterHASensorReading
  48. from backend.app.schemas.smart_plug import SmartPlugResponse
  49. from backend.app.services.firmware_check import get_firmware_service
  50. from backend.app.utils.id_list import parse_id_list
  51. router = APIRouter(prefix="/bulk", tags=["bulk"])
  52. @router.get("/printer-statuses", response_model=list[PrinterStatus])
  53. async def get_printer_statuses(
  54. ids: str = Query(..., description="Comma-separated printer ids"),
  55. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  56. printer_scope: PrinterScope = RequestPrinterScope,
  57. db: AsyncSession = Depends(get_db),
  58. ):
  59. """``GET /printers/{printer_id}/status`` for several printers. Unknown ids are left out."""
  60. wanted = [i for i in parse_id_list(ids) if printer_scope.allows(i)]
  61. if not wanted:
  62. return []
  63. result = await db.execute(select(Printer).where(Printer.id.in_(wanted)))
  64. printers = {p.id: p for p in result.scalars().all()}
  65. return [await _printer_status(db, printers[i]) for i in wanted if i in printers]
  66. @router.get("/slot-presets")
  67. async def get_slot_presets_bulk(
  68. ids: str = Query(..., description="Comma-separated printer ids"),
  69. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  70. printer_scope: PrinterScope = RequestPrinterScope,
  71. db: AsyncSession = Depends(get_db),
  72. ):
  73. """``GET /printers/{printer_id}/slot-presets`` for several printers, keyed by printer id."""
  74. wanted = [i for i in parse_id_list(ids) if printer_scope.allows(i)]
  75. by_printer: dict[int, list] = {i: [] for i in wanted}
  76. if wanted:
  77. result = await db.execute(select(SlotPresetMapping).where(SlotPresetMapping.printer_id.in_(wanted)))
  78. for mapping in result.scalars().all():
  79. by_printer[mapping.printer_id].append(mapping)
  80. return {i: _slot_presets_by_key(mappings) for i, mappings in by_printer.items()}
  81. @router.get("/ams-labels")
  82. async def get_ams_labels_bulk(
  83. ids: str = Query(..., description="Comma-separated printer ids"),
  84. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  85. printer_scope: PrinterScope = RequestPrinterScope,
  86. db: AsyncSession = Depends(get_db),
  87. ):
  88. """``GET /printers/{printer_id}/ams-labels`` for several printers, keyed by printer id."""
  89. wanted = [i for i in parse_id_list(ids) if printer_scope.allows(i)]
  90. return {i: await _ams_labels(db, i) for i in wanted}
  91. @router.get("/card-plugs", response_model=None, responses={200: {"model": dict[int, PrinterCardPlugs]}})
  92. async def get_smart_plugs_by_printers(
  93. ids: str = Query(..., description="Comma-separated printer ids"),
  94. db: AsyncSession = Depends(get_db),
  95. _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
  96. printer_scope: PrinterScope = RequestPrinterScope,
  97. can_edit: bool = CallerEditsPlugs,
  98. ):
  99. """``GET /smart-plugs/by-printer/{printer_id}`` and its ``/scripts``, for several printers.
  100. The Printers page asked both once per printer card. A printer outside the
  101. caller's scope is left out, which the client reports as the single routes'
  102. 404.
  103. """
  104. wanted = [i for i in parse_id_list(ids) if printer_scope.allows(i)]
  105. by_printer: dict[int, list[SmartPlug]] = {i: [] for i in wanted}
  106. if wanted:
  107. result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id.in_(wanted)).order_by(SmartPlug.id))
  108. for plug in result.scalars().all():
  109. if plug.printer_id is not None:
  110. by_printer[plug.printer_id].append(plug)
  111. # Shaped as the single routes shape them: the main plug in the editor's
  112. # form for a caller who may edit plugs (see _serialize_plug), so no
  113. # response_model here either.
  114. cards = {}
  115. for i, plugs in by_printer.items():
  116. plug = _pick_main_plug(plugs)
  117. cards[i] = {
  118. "plug": _serialize_plug(plug, can_edit=can_edit) if plug else None,
  119. "scripts": [SmartPlugResponse.model_validate(p) for p in _card_script_plugs(plugs)],
  120. }
  121. return cards
  122. @router.get("/ha-sensor-readings", response_model=dict[int, list[PrinterHASensorReading]])
  123. async def get_sensor_readings_by_printers(
  124. ids: str = Query(..., description="Comma-separated printer ids"),
  125. db: AsyncSession = Depends(get_db),
  126. _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
  127. printer_scope: PrinterScope = RequestPrinterScope,
  128. ):
  129. """``GET /ha-sensors/by-printer/{printer_id}/readings`` for several printers, keyed by printer id.
  130. The Printers page asked once per card every 15 seconds. A printer outside
  131. the caller's scope is left out, which the client reports as the single
  132. route's 404.
  133. """
  134. wanted = [i for i in parse_id_list(ids) if printer_scope.allows(i)]
  135. readings: dict[int, list[PrinterHASensorReading]] = {i: [] for i in wanted}
  136. if wanted:
  137. for sensor in await _card_sensors(db, PrinterHASensor.printer_id.in_(wanted)):
  138. readings[sensor.printer_id].append(_reading(sensor))
  139. return readings
  140. @router.get("/firmware-updates", response_model=list[FirmwareUpdateInfo])
  141. async def check_printers_firmware(
  142. ids: str = Query(..., description="Comma-separated printer ids"),
  143. db: AsyncSession = Depends(get_db),
  144. _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
  145. printer_scope: PrinterScope = RequestPrinterScope,
  146. ):
  147. """``GET /firmware/updates/{printer_id}`` for several printers.
  148. The Printers page asked once per card. Unknown printers, and printers
  149. outside the caller's scope, are left out, which the client reports as the
  150. single route's 404.
  151. """
  152. wanted = [i for i in parse_id_list(ids) if printer_scope.allows(i)]
  153. if not wanted:
  154. return []
  155. result = await db.execute(select(Printer).where(Printer.id.in_(wanted)))
  156. printers = {p.id: p for p in result.scalars().all()}
  157. ordered = [printers[i] for i in wanted if i in printers]
  158. if not await _checks_enabled(db):
  159. return [_not_checked(p) for p in ordered]
  160. firmware_service = get_firmware_service()
  161. return [await _printer_update_info(firmware_service, p) for p in ordered]
  162. @router.get("/printer-queues", response_model=dict[int, list[PrintQueueItemResponse]])
  163. async def list_queue_by_printers(
  164. ids: str = Query(..., description="Comma-separated printer ids"),
  165. status: str | None = Query(None, description="Filter by status; several may be given comma-separated"),
  166. db: AsyncSession = Depends(get_db),
  167. auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
  168. require_ownership_permission(
  169. Permission.QUEUE_READ_ALL,
  170. Permission.QUEUE_READ_OWN,
  171. )
  172. ),
  173. printer_scope: PrinterScope = RequestPrinterScope,
  174. ):
  175. """``GET /queue/?printer_id=N&status=...`` for several printers, keyed by printer id.
  176. Every printer card asked for its own queue, twice (the card and its queue
  177. widget). Each list is what the single call returns: the printer's own jobs
  178. plus the "Any <model>" jobs for its model, in dispatch order. A job for a
  179. model shows up under every printer of that model.
  180. """
  181. user, can_read_all = auth_result
  182. wanted = parse_id_list(ids)
  183. if not wanted:
  184. return {}
  185. models = {
  186. printer_id: model.lower()
  187. for printer_id, model in (
  188. await db.execute(select(Printer.id, Printer.model).where(Printer.id.in_(wanted)))
  189. ).all()
  190. if model
  191. }
  192. query = (
  193. select(PrintQueueItem)
  194. .options(*_QUEUE_ITEM_LOADS)
  195. .where(
  196. or_(
  197. PrintQueueItem.printer_id.in_(wanted),
  198. and_(
  199. PrintQueueItem.printer_id.is_(None),
  200. func.lower(PrintQueueItem.target_model).in_(set(models.values())),
  201. ),
  202. )
  203. )
  204. # The order the scheduler dispatches in (#3200), as GET /queue/ returns it
  205. .order_by(PrintQueueItem.position, PrintQueueItem.id)
  206. )
  207. if user is not None and not can_read_all:
  208. query = query.where(PrintQueueItem.created_by_id == user.id)
  209. # Items bound to printers the caller can't see stay out (#1727)
  210. if (clause := printer_scope.where(PrintQueueItem.printer_id)) is not None:
  211. query = query.where(clause)
  212. if status:
  213. query = query.where(PrintQueueItem.status.in_([s.strip() for s in status.split(",") if s.strip()]))
  214. items = (await db.execute(query)).scalars().all()
  215. responses = {item.id: _enrich_response(item) for item in items}
  216. def belongs(item: PrintQueueItem, printer_id: int) -> bool:
  217. if item.printer_id is not None:
  218. return item.printer_id == printer_id
  219. model = models.get(printer_id)
  220. return bool(model and item.target_model and item.target_model.lower() == model)
  221. return {printer_id: [responses[item.id] for item in items if belongs(item, printer_id)] for printer_id in wanted}
  222. @router.get("/archive-folders", response_model=dict[int, list[FolderResponse]])
  223. async def get_folders_by_archives(
  224. ids: str = Query(..., description="Comma-separated archive ids"),
  225. db: AsyncSession = Depends(get_db),
  226. auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
  227. require_ownership_permission(
  228. Permission.LIBRARY_READ_ALL,
  229. Permission.LIBRARY_READ_OWN,
  230. )
  231. ),
  232. printer_scope: PrinterScope = RequestPrinterScope,
  233. ):
  234. """``GET /library/folders/by-archive/{archive_id}`` for several archives, keyed by archive id.
  235. The Archives page asked once per archive card, each time loading the whole
  236. folder index to work out what the user may see.
  237. """
  238. user, _ = auth_result
  239. archive_ids = parse_id_list(ids)
  240. if not archive_ids:
  241. return {}
  242. return await _folders_by_archive(db, user, archive_ids, printer_scope)