webhook.py 14 KB

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