webhook.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  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. def _stop_current_print(printer_id: int) -> None:
  201. """Send the stop command and mark the printer as stopped by the user.
  202. Cancel and stop are the same MQTT command on Bambu printers. The mark makes
  203. the print end as stopped rather than failed, as a stop from the printer
  204. card or the queue does (mark_printer_stopped_by_user).
  205. """
  206. try:
  207. sent = printer_manager.stop_print(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. from backend.app.main import mark_printer_stopped_by_user
  214. mark_printer_stopped_by_user(printer_id)
  215. @router.post("/printer/{printer_id}/stop")
  216. async def webhook_stop_print(
  217. printer_id: int,
  218. api_key: APIKey = Depends(get_api_key),
  219. db: AsyncSession = Depends(get_db),
  220. ):
  221. """Stop the current print on a printer.
  222. Requires 'can_control_printer' permission.
  223. """
  224. await check_webhook_permission(db, api_key, "control_printer")
  225. await ensure_api_key_printer_access(db, api_key, printer_id)
  226. status = printer_manager.get_status(printer_id)
  227. # `printer_manager.get_status(...)` returns a ``PrinterState`` dataclass
  228. # (see backend/app/services/bambu_mqtt.py), not a dict — `.get(...)` on it
  229. # raises AttributeError and surfaces as a generic 500 (#1584).
  230. if not status or not status.connected:
  231. raise HTTPException(status_code=503, detail="Printer not connected")
  232. if status.state != "RUNNING":
  233. raise HTTPException(status_code=409, detail="No print in progress")
  234. _stop_current_print(printer_id)
  235. return {"message": "Print stopped"}
  236. @router.post("/printer/{printer_id}/cancel")
  237. async def webhook_cancel_print(
  238. printer_id: int,
  239. api_key: APIKey = Depends(get_api_key),
  240. db: AsyncSession = Depends(get_db),
  241. ):
  242. """Cancel the current print on a printer.
  243. Requires 'can_control_printer' permission.
  244. """
  245. await check_webhook_permission(db, api_key, "control_printer")
  246. await ensure_api_key_printer_access(db, api_key, printer_id)
  247. status = printer_manager.get_status(printer_id)
  248. # Same dataclass-not-dict shape as stop_print above (#1584).
  249. if not status or not status.connected:
  250. raise HTTPException(status_code=503, detail="Printer not connected")
  251. if status.state not in ["RUNNING", "PAUSE"]:
  252. raise HTTPException(status_code=409, detail="No print to cancel")
  253. _stop_current_print(printer_id)
  254. return {"message": "Print cancelled"}
  255. @router.get("/printer/{printer_id}/status", response_model=PrinterStatusResponse)
  256. async def webhook_get_printer_status(
  257. printer_id: int,
  258. api_key: APIKey = Depends(get_api_key),
  259. db: AsyncSession = Depends(get_db),
  260. ):
  261. """Get status of a printer.
  262. Requires 'can_read_status' permission.
  263. """
  264. await check_webhook_permission(db, api_key, "read_status")
  265. await ensure_api_key_printer_access(db, api_key, printer_id)
  266. # Get printer
  267. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  268. printer = result.scalar_one_or_none()
  269. if not printer:
  270. raise HTTPException(status_code=404, detail="Printer not found")
  271. status = printer_manager.get_status(printer_id)
  272. # `printer_manager.get_status(...)` returns a ``PrinterState`` dataclass —
  273. # attribute access, not dict lookup. The previous `.get(...)` calls raised
  274. # AttributeError and surfaced as a generic 500 for any printer that
  275. # actually had a status row (#1584).
  276. if status is None:
  277. return PrinterStatusResponse(
  278. id=printer.id,
  279. name=printer.name,
  280. serial_number=printer.serial_number,
  281. connected=False,
  282. state=None,
  283. current_print=None,
  284. progress=None,
  285. remaining_time=None,
  286. )
  287. return PrinterStatusResponse(
  288. id=printer.id,
  289. name=printer.name,
  290. serial_number=printer.serial_number,
  291. connected=status.connected,
  292. state=status.state,
  293. current_print=status.current_print,
  294. progress=status.progress,
  295. remaining_time=status.remaining_time,
  296. remaining_seconds=status.remaining_time * 60 if status.remaining_time is not None else None,
  297. layer_num=status.layer_num,
  298. total_layers=status.total_layers,
  299. subtask_id=_job_id(status.subtask_id),
  300. hms_errors=hms_error_responses(status.hms_errors),
  301. )
  302. @router.get("/queue", response_model=list[QueueStatusResponse])
  303. async def webhook_get_queue_status(
  304. printer_id: int | None = None,
  305. api_key: APIKey = Depends(get_api_key),
  306. db: AsyncSession = Depends(get_db),
  307. ):
  308. """Get queue status for all printers or a specific printer.
  309. Requires 'can_read_status' permission.
  310. """
  311. await check_webhook_permission(db, api_key, "read_status")
  312. # Get printers
  313. if printer_id:
  314. await ensure_api_key_printer_access(db, api_key, printer_id)
  315. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  316. printers = result.scalars().all()
  317. else:
  318. result = await db.execute(select(Printer))
  319. printers = result.scalars().all()
  320. # Only the printers the key (and its owner) may reach
  321. scope = await api_key_printer_scope(db, api_key)
  322. printers = [p for p in printers if scope.allows(p.id)]
  323. response = []
  324. for printer in printers:
  325. # Get queue items
  326. result = await db.execute(
  327. select(PrintQueueItem)
  328. .where(
  329. PrintQueueItem.printer_id == printer.id,
  330. PrintQueueItem.status.in_(["pending", "printing"]),
  331. )
  332. .order_by(PrintQueueItem.position)
  333. )
  334. items = result.scalars().all()
  335. pending_count = sum(1 for i in items if i.status == "pending")
  336. printing_count = sum(1 for i in items if i.status == "printing")
  337. response.append(
  338. QueueStatusResponse(
  339. printer_id=printer.id,
  340. printer_name=printer.name,
  341. pending=pending_count,
  342. printing=printing_count,
  343. items=[
  344. {
  345. "id": item.id,
  346. "archive_id": item.archive_id,
  347. "position": item.position,
  348. "status": item.status,
  349. }
  350. for item in items
  351. ],
  352. )
  353. )
  354. return response