webhook.py 15 KB

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