webhook.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  1. import logging
  2. from fastapi import APIRouter, Depends, HTTPException
  3. from pydantic import BaseModel
  4. from sqlalchemy import or_, select
  5. from sqlalchemy.ext.asyncio import AsyncSession
  6. from backend.app.api.routes.print_queue import _assert_can_queue_archive, saved_slicer_ams_mapping
  7. from backend.app.core.auth import (
  8. ApiKeyActor,
  9. api_key_printer_scope,
  10. check_webhook_permission,
  11. ensure_api_key_printer_access,
  12. get_api_key,
  13. is_auth_enabled,
  14. queue_review_required_for,
  15. resolve_apikey_owner,
  16. )
  17. from backend.app.core.database import get_db
  18. from backend.app.core.permissions import Permission
  19. from backend.app.core.row_visibility import may_reach_row
  20. from backend.app.models.api_key import APIKey
  21. from backend.app.models.archive import PrintArchive
  22. from backend.app.models.print_queue import PrintQueueItem
  23. from backend.app.models.printer import Printer
  24. from backend.app.schemas.printer import HMSErrorResponse, hms_error_responses
  25. from backend.app.services.print_confirmation import confirm_outcome_for_new_queue_item
  26. from backend.app.services.print_control import stop_print_by_user
  27. from backend.app.services.print_defaults import default_print_options
  28. from backend.app.services.printer_manager import printer_manager
  29. from backend.app.services.queue_announcements import announce_queue_jobs_added
  30. from backend.app.services.queue_position import next_queue_position
  31. from backend.app.utils.local_time import to_naive_utc
  32. logger = logging.getLogger(__name__)
  33. router = APIRouter(prefix="/webhook", tags=["webhook"])
  34. # Request schemas
  35. class QueueAddRequest(BaseModel):
  36. archive_id: int
  37. printer_id: int
  38. project_id: int | None = None
  39. scheduled_time: str | None = None # ISO format datetime
  40. require_previous_success: bool = False
  41. auto_off_after: bool = False
  42. class QueueAddResponse(BaseModel):
  43. id: int
  44. archive_id: int
  45. printer_id: int
  46. position: int
  47. status: str
  48. message: str
  49. class PrinterStatusResponse(BaseModel):
  50. id: int
  51. name: str
  52. # The printer's own serial, so a client can tell printers apart by what
  53. # they report rather than by Bambuddy's row id (#2919).
  54. serial_number: str
  55. connected: bool
  56. state: str | None
  57. current_print: str | None
  58. progress: float | None
  59. # Minutes, as the printer reports it. Kept for existing clients;
  60. # remaining_seconds is the same estimate in seconds, the unit the
  61. # notification pipeline uses (#2919).
  62. remaining_time: int | None
  63. remaining_seconds: int | None = None
  64. layer_num: int | None = None
  65. total_layers: int | None = None
  66. # Bambu's id for the running job. A new value marks a new print, even when
  67. # two prints of the same file run back to back between two polls. None
  68. # when the job has no id: Bambu reports "0" or "" for local prints (for
  69. # example one started on the printer), the same reading main.py uses.
  70. subtask_id: str | None = None
  71. # Live HMS faults, in the same shape as GET /printers/{id}/status. They
  72. # tell a filament runout apart from someone pressing pause.
  73. hms_errors: list[HMSErrorResponse] = []
  74. class QueueStatusResponse(BaseModel):
  75. printer_id: int
  76. printer_name: str
  77. pending: int
  78. printing: int
  79. items: list[dict]
  80. def _job_id(subtask_id) -> str | None:
  81. """The printer's job id as text, or None when the job has none.
  82. Stored as the printer sent it, so coerce: a numeric id would fail
  83. validation and turn a status poll into a 500.
  84. """
  85. if subtask_id is None:
  86. return None
  87. value = str(subtask_id).strip()
  88. return None if value in ("", "0") else value
  89. # Webhook endpoints
  90. async def _key_actor(db: AsyncSession, api_key: APIKey) -> ApiKeyActor | None:
  91. """The key acting for its owner; None with auth off, where there is no owner
  92. to act for (as on POST /queue/) and the key's printers still apply."""
  93. return ApiKeyActor(api_key, await resolve_apikey_owner(db, api_key)) if await is_auth_enabled(db) else None
  94. @router.post("/queue/add", response_model=QueueAddResponse)
  95. async def webhook_add_to_queue(
  96. data: QueueAddRequest,
  97. api_key: APIKey = Depends(get_api_key),
  98. db: AsyncSession = Depends(get_db, scope="function"),
  99. ):
  100. """Add a print to the queue via webhook.
  101. Requires 'can_queue' permission.
  102. """
  103. await check_webhook_permission(db, api_key, "queue")
  104. await ensure_api_key_printer_access(db, api_key, data.printer_id)
  105. # Verify archive exists and passes the same source gate as POST /queue/,
  106. # with the key acting for its owner within its printers.
  107. result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
  108. archive = result.scalar_one_or_none()
  109. if not archive:
  110. raise HTTPException(status_code=404, detail="Archive not found")
  111. actor = await _key_actor(db, api_key)
  112. _assert_can_queue_archive(archive, actor, await api_key_printer_scope(db, api_key))
  113. # Verify printer exists
  114. result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
  115. printer = result.scalar_one_or_none()
  116. if not printer:
  117. raise HTTPException(status_code=404, detail="Printer not found")
  118. # Append to the end of the queue: positions are one sequence across all
  119. # pending items, not one per printer (#3200).
  120. next_position = await next_queue_position(db)
  121. # Parse scheduled time if provided
  122. scheduled_time = None
  123. if data.scheduled_time:
  124. from datetime import datetime
  125. try:
  126. # Stored as naive UTC: an offset is converted, not dropped.
  127. scheduled_time = to_naive_utc(datetime.fromisoformat(data.scheduled_time.replace("Z", "+00:00")))
  128. except (ValueError, OverflowError):
  129. raise HTTPException(status_code=400, detail="Invalid scheduled_time format")
  130. # Create queue item
  131. queue_item = PrintQueueItem(
  132. printer_id=data.printer_id,
  133. archive_id=data.archive_id,
  134. project_id=data.project_id,
  135. position=next_position,
  136. scheduled_time=scheduled_time,
  137. require_previous_success=data.require_previous_success,
  138. auto_off_after=data.auto_off_after,
  139. # No dialog to pick this per job, so the install-wide default decides
  140. # whether the finished print asks for a verdict (#1898).
  141. confirm_outcome=await confirm_outcome_for_new_queue_item(db),
  142. # Attribute to the key's owner so the item shows up under `queue:read_own`
  143. # for the person whose key it is. Legacy keys predating per-user ownership
  144. # have no `user_id`, and those rows stay ownerless.
  145. created_by_id=api_key.user_id,
  146. # Waits for someone to start it unless the owner may print without review (#1620)
  147. manual_start=await is_auth_enabled(db) and queue_review_required_for(await resolve_apikey_owner(db, api_key)),
  148. # No print options in the request: the install's defaults.
  149. **await default_print_options(db),
  150. # The slicer's slots, as a reprint from the UI reuses them
  151. ams_mapping=saved_slicer_ams_mapping(archive, data.printer_id),
  152. )
  153. db.add(queue_item)
  154. await db.commit()
  155. await db.refresh(queue_item)
  156. await announce_queue_jobs_added(db, [queue_item.id])
  157. return QueueAddResponse(
  158. id=queue_item.id,
  159. archive_id=queue_item.archive_id,
  160. printer_id=queue_item.printer_id,
  161. position=queue_item.position,
  162. status=queue_item.status,
  163. message=f"Added to queue at position {queue_item.position}",
  164. )
  165. @router.post("/printer/{printer_id}/start")
  166. async def webhook_start_print(
  167. printer_id: int,
  168. api_key: APIKey = Depends(get_api_key),
  169. db: AsyncSession = Depends(get_db, scope="function"),
  170. ):
  171. """Trigger the next manual-start queue item on a printer.
  172. Mirrors `POST /print-queue/{item_id}/start`: clears `manual_start` on
  173. the next pending item so the scheduler picks it up — which handles
  174. FTP upload, AMS mapping, and all print options (timelapse,
  175. bed_levelling, etc.) correctly via the queue's stored fields. The
  176. previous implementation called `printer_manager.start_print()`
  177. directly with `archive_id` as the filename arg and no print options,
  178. bypassing the upload step entirely and discarding the user's
  179. workflow choices — it 500'd before ever reaching the printer.
  180. Requires 'can_control_printer' permission.
  181. """
  182. await check_webhook_permission(db, api_key, "control_printer")
  183. await ensure_api_key_printer_access(db, api_key, printer_id)
  184. # Get printer
  185. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  186. printer = result.scalar_one_or_none()
  187. if not printer:
  188. raise HTTPException(status_code=404, detail="Printer not found")
  189. # The next pending queue item the key's owner could start from the queue:
  190. # theirs, or one nobody owns yet (a virtual-printer upload, claimed by
  191. # starting it, #1670), unless they may start everyone's.
  192. actor = await _key_actor(db, api_key)
  193. owner = actor.owner if actor is not None else None
  194. startable: list | None = []
  195. if owner is not None and not owner.has_permission(Permission.QUEUE_UPDATE_ALL.value):
  196. startable = (
  197. [or_(PrintQueueItem.created_by_id == owner.id, PrintQueueItem.created_by_id.is_(None))]
  198. if owner.has_permission(Permission.QUEUE_UPDATE_OWN.value)
  199. else None
  200. )
  201. queue_item = None
  202. if startable is not None:
  203. result = await db.execute(
  204. select(PrintQueueItem)
  205. .where(
  206. PrintQueueItem.printer_id == printer_id,
  207. PrintQueueItem.status == "pending",
  208. *startable,
  209. )
  210. .order_by(PrintQueueItem.position)
  211. .limit(1)
  212. )
  213. queue_item = result.scalar_one_or_none()
  214. if not queue_item:
  215. raise HTTPException(status_code=404, detail="No pending prints in queue")
  216. # Starting a waiting job is a review decision (#1620): a key whose owner
  217. # needs review for their own jobs can't make one for anybody's
  218. if (
  219. queue_item.manual_start
  220. and await is_auth_enabled(db)
  221. and queue_review_required_for(await resolve_apikey_owner(db, api_key))
  222. ):
  223. raise HTTPException(
  224. status_code=403,
  225. detail="The next job waits for review: someone who can manage all queue jobs has to start it",
  226. )
  227. # Clear manual_start so the scheduler will dispatch. If the item was
  228. # already auto-dispatchable this is a no-op; the scheduler will still
  229. # pick it up on its next tick.
  230. queue_item.manual_start = False
  231. if owner is not None and queue_item.created_by_id is None:
  232. queue_item.created_by_id = owner.id
  233. await db.commit()
  234. await db.refresh(queue_item)
  235. logger.info("Webhook started queue item %s on printer %s", queue_item.id, printer_id)
  236. return {"message": "Print started", "queue_item_id": queue_item.id}
  237. # States in which a print can be stopped, as with the Stop button on the
  238. # printer card: printing or paused.
  239. _STOPPABLE_STATES = ("RUNNING", "PAUSE")
  240. def _stop_current_print(printer_id: int) -> None:
  241. """Stop the print on the user's behalf (cancel and stop are the same command)."""
  242. try:
  243. sent = stop_print_by_user(printer_id)
  244. except Exception as e:
  245. logger.error("Failed to stop print on printer %s: %s", printer_id, e)
  246. raise HTTPException(status_code=500, detail=str(e))
  247. if not sent:
  248. raise HTTPException(status_code=503, detail="Printer not connected")
  249. @router.post("/printer/{printer_id}/stop")
  250. async def webhook_stop_print(
  251. printer_id: int,
  252. api_key: APIKey = Depends(get_api_key),
  253. db: AsyncSession = Depends(get_db, scope="function"),
  254. ):
  255. """Stop the current print on a printer.
  256. Requires 'can_control_printer' permission.
  257. """
  258. await check_webhook_permission(db, api_key, "control_printer")
  259. await ensure_api_key_printer_access(db, api_key, printer_id)
  260. status = printer_manager.get_status(printer_id)
  261. # `printer_manager.get_status(...)` returns a ``PrinterState`` dataclass
  262. # (see backend/app/services/bambu_mqtt.py), not a dict — `.get(...)` on it
  263. # raises AttributeError and surfaces as a generic 500 (#1584).
  264. if not status or not status.connected:
  265. raise HTTPException(status_code=503, detail="Printer not connected")
  266. if status.state not in _STOPPABLE_STATES:
  267. raise HTTPException(status_code=409, detail="No print in progress")
  268. _stop_current_print(printer_id)
  269. return {"message": "Print stopped"}
  270. @router.post("/printer/{printer_id}/cancel")
  271. async def webhook_cancel_print(
  272. printer_id: int,
  273. api_key: APIKey = Depends(get_api_key),
  274. db: AsyncSession = Depends(get_db, scope="function"),
  275. ):
  276. """Cancel the current print on a printer.
  277. Requires 'can_control_printer' permission.
  278. """
  279. await check_webhook_permission(db, api_key, "control_printer")
  280. await ensure_api_key_printer_access(db, api_key, printer_id)
  281. status = printer_manager.get_status(printer_id)
  282. # Same dataclass-not-dict shape as stop_print above (#1584).
  283. if not status or not status.connected:
  284. raise HTTPException(status_code=503, detail="Printer not connected")
  285. if status.state not in _STOPPABLE_STATES:
  286. raise HTTPException(status_code=409, detail="No print to cancel")
  287. _stop_current_print(printer_id)
  288. return {"message": "Print cancelled"}
  289. @router.get("/printer/{printer_id}/status", response_model=PrinterStatusResponse)
  290. async def webhook_get_printer_status(
  291. printer_id: int,
  292. api_key: APIKey = Depends(get_api_key),
  293. db: AsyncSession = Depends(get_db, scope="function"),
  294. ):
  295. """Get status of a printer.
  296. Requires 'can_read_status' permission.
  297. """
  298. await check_webhook_permission(db, api_key, "read_status")
  299. await ensure_api_key_printer_access(db, api_key, printer_id)
  300. # Get printer
  301. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  302. printer = result.scalar_one_or_none()
  303. if not printer:
  304. raise HTTPException(status_code=404, detail="Printer not found")
  305. status = printer_manager.get_status(printer_id)
  306. # `printer_manager.get_status(...)` returns a ``PrinterState`` dataclass —
  307. # attribute access, not dict lookup. The previous `.get(...)` calls raised
  308. # AttributeError and surfaced as a generic 500 for any printer that
  309. # actually had a status row (#1584).
  310. if status is None:
  311. return PrinterStatusResponse(
  312. id=printer.id,
  313. name=printer.name,
  314. serial_number=printer.serial_number,
  315. connected=False,
  316. state=None,
  317. current_print=None,
  318. progress=None,
  319. remaining_time=None,
  320. )
  321. return PrinterStatusResponse(
  322. id=printer.id,
  323. name=printer.name,
  324. serial_number=printer.serial_number,
  325. connected=status.connected,
  326. state=status.state,
  327. current_print=status.current_print,
  328. progress=status.progress,
  329. remaining_time=status.remaining_time,
  330. remaining_seconds=status.remaining_time * 60 if status.remaining_time is not None else None,
  331. layer_num=status.layer_num,
  332. total_layers=status.total_layers,
  333. subtask_id=_job_id(status.subtask_id),
  334. hms_errors=hms_error_responses(status.hms_errors),
  335. )
  336. @router.get("/queue", response_model=list[QueueStatusResponse])
  337. async def webhook_get_queue_status(
  338. printer_id: int | None = None,
  339. api_key: APIKey = Depends(get_api_key),
  340. db: AsyncSession = Depends(get_db, scope="function"),
  341. ):
  342. """Get queue status for all printers or a specific printer.
  343. Requires 'can_read_status' permission. The counts cover every job; the
  344. items listed are those the key's owner sees in the queue.
  345. """
  346. await check_webhook_permission(db, api_key, "read_status")
  347. actor = await _key_actor(db, api_key)
  348. # Get printers
  349. if printer_id:
  350. await ensure_api_key_printer_access(db, api_key, printer_id)
  351. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  352. printers = result.scalars().all()
  353. else:
  354. result = await db.execute(select(Printer))
  355. printers = result.scalars().all()
  356. # Only the printers the key (and its owner) may reach
  357. scope = await api_key_printer_scope(db, api_key)
  358. printers = [p for p in printers if scope.allows(p.id)]
  359. response = []
  360. for printer in printers:
  361. # Get queue items
  362. result = await db.execute(
  363. select(PrintQueueItem)
  364. .where(
  365. PrintQueueItem.printer_id == printer.id,
  366. PrintQueueItem.status.in_(["pending", "printing"]),
  367. )
  368. .order_by(PrintQueueItem.position)
  369. )
  370. items = result.scalars().all()
  371. pending_count = sum(1 for i in items if i.status == "pending")
  372. printing_count = sum(1 for i in items if i.status == "printing")
  373. response.append(
  374. QueueStatusResponse(
  375. printer_id=printer.id,
  376. printer_name=printer.name,
  377. pending=pending_count,
  378. printing=printing_count,
  379. items=[
  380. {
  381. "id": item.id,
  382. "archive_id": item.archive_id,
  383. "position": item.position,
  384. "status": item.status,
  385. }
  386. for item in items
  387. if may_reach_row(item.created_by_id, actor, Permission.QUEUE_READ_ALL, Permission.QUEUE_READ_OWN)
  388. ],
  389. )
  390. )
  391. return response