webhook.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  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 check_printer_access, check_webhook_permission, get_api_key
  7. from backend.app.core.database import get_db
  8. from backend.app.models.api_key import APIKey
  9. from backend.app.models.archive import PrintArchive
  10. from backend.app.models.print_queue import PrintQueueItem
  11. from backend.app.models.printer import Printer
  12. from backend.app.services.print_confirmation import confirm_outcome_for_new_queue_item
  13. from backend.app.services.printer_manager import printer_manager
  14. logger = logging.getLogger(__name__)
  15. router = APIRouter(prefix="/webhook", tags=["webhook"])
  16. # Request schemas
  17. class QueueAddRequest(BaseModel):
  18. archive_id: int
  19. printer_id: int
  20. project_id: int | None = None
  21. scheduled_time: str | None = None # ISO format datetime
  22. require_previous_success: bool = False
  23. auto_off_after: bool = False
  24. class QueueAddResponse(BaseModel):
  25. id: int
  26. archive_id: int
  27. printer_id: int
  28. position: int
  29. status: str
  30. message: str
  31. class PrinterStatusResponse(BaseModel):
  32. id: int
  33. name: str
  34. connected: bool
  35. state: str | None
  36. current_print: str | None
  37. progress: float | None
  38. remaining_time: int | None
  39. class QueueStatusResponse(BaseModel):
  40. printer_id: int
  41. printer_name: str
  42. pending: int
  43. printing: int
  44. items: list[dict]
  45. # Webhook endpoints
  46. @router.post("/queue/add", response_model=QueueAddResponse)
  47. async def webhook_add_to_queue(
  48. data: QueueAddRequest,
  49. api_key: APIKey = Depends(get_api_key),
  50. db: AsyncSession = Depends(get_db),
  51. ):
  52. """Add a print to the queue via webhook.
  53. Requires 'can_queue' permission.
  54. """
  55. await check_webhook_permission(db, api_key, "queue")
  56. check_printer_access(api_key, data.printer_id)
  57. # Verify archive exists
  58. result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
  59. archive = result.scalar_one_or_none()
  60. if not archive:
  61. raise HTTPException(status_code=404, detail="Archive not found")
  62. # Verify printer exists
  63. result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
  64. printer = result.scalar_one_or_none()
  65. if not printer:
  66. raise HTTPException(status_code=404, detail="Printer not found")
  67. # Get next position
  68. result = await db.execute(
  69. select(PrintQueueItem.position)
  70. .where(
  71. PrintQueueItem.printer_id == data.printer_id,
  72. PrintQueueItem.status == "pending",
  73. )
  74. .order_by(PrintQueueItem.position.desc())
  75. .limit(1)
  76. )
  77. max_position = result.scalar()
  78. next_position = (max_position or 0) + 1
  79. # Parse scheduled time if provided
  80. scheduled_time = None
  81. if data.scheduled_time:
  82. from datetime import datetime
  83. try:
  84. scheduled_time = datetime.fromisoformat(data.scheduled_time.replace("Z", "+00:00"))
  85. except ValueError:
  86. raise HTTPException(status_code=400, detail="Invalid scheduled_time format")
  87. # Create queue item
  88. queue_item = PrintQueueItem(
  89. printer_id=data.printer_id,
  90. archive_id=data.archive_id,
  91. project_id=data.project_id,
  92. position=next_position,
  93. scheduled_time=scheduled_time,
  94. require_previous_success=data.require_previous_success,
  95. auto_off_after=data.auto_off_after,
  96. # No dialog to pick this per job, so the install-wide default decides
  97. # whether the finished print asks for a verdict (#1898).
  98. confirm_outcome=await confirm_outcome_for_new_queue_item(db),
  99. # Attribute to the key's owner so the item shows up under `queue:read_own`
  100. # for the person whose key it is. Legacy keys predating per-user ownership
  101. # have no `user_id`, and those rows stay ownerless.
  102. created_by_id=api_key.user_id,
  103. )
  104. db.add(queue_item)
  105. await db.flush()
  106. await db.refresh(queue_item)
  107. return QueueAddResponse(
  108. id=queue_item.id,
  109. archive_id=queue_item.archive_id,
  110. printer_id=queue_item.printer_id,
  111. position=queue_item.position,
  112. status=queue_item.status,
  113. message=f"Added to queue at position {queue_item.position}",
  114. )
  115. @router.post("/printer/{printer_id}/start")
  116. async def webhook_start_print(
  117. printer_id: int,
  118. api_key: APIKey = Depends(get_api_key),
  119. db: AsyncSession = Depends(get_db),
  120. ):
  121. """Trigger the next manual-start queue item on a printer.
  122. Mirrors `POST /print-queue/{item_id}/start`: clears `manual_start` on
  123. the next pending item so the scheduler picks it up — which handles
  124. FTP upload, AMS mapping, and all print options (timelapse,
  125. bed_levelling, etc.) correctly via the queue's stored fields. The
  126. previous implementation called `printer_manager.start_print()`
  127. directly with `archive_id` as the filename arg and no print options,
  128. bypassing the upload step entirely and discarding the user's
  129. workflow choices — it 500'd before ever reaching the printer.
  130. Requires 'can_control_printer' permission.
  131. """
  132. await check_webhook_permission(db, api_key, "control_printer")
  133. check_printer_access(api_key, printer_id)
  134. # Get printer
  135. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  136. printer = result.scalar_one_or_none()
  137. if not printer:
  138. raise HTTPException(status_code=404, detail="Printer not found")
  139. # Get next pending queue item
  140. result = await db.execute(
  141. select(PrintQueueItem)
  142. .where(
  143. PrintQueueItem.printer_id == printer_id,
  144. PrintQueueItem.status == "pending",
  145. )
  146. .order_by(PrintQueueItem.position)
  147. .limit(1)
  148. )
  149. queue_item = result.scalar_one_or_none()
  150. if not queue_item:
  151. raise HTTPException(status_code=404, detail="No pending prints in queue")
  152. # Clear manual_start so the scheduler will dispatch. If the item was
  153. # already auto-dispatchable this is a no-op; the scheduler will still
  154. # pick it up on its next tick.
  155. queue_item.manual_start = False
  156. await db.commit()
  157. await db.refresh(queue_item)
  158. logger.info("Webhook started queue item %s on printer %s", queue_item.id, printer_id)
  159. return {"message": "Print started", "queue_item_id": queue_item.id}
  160. @router.post("/printer/{printer_id}/stop")
  161. async def webhook_stop_print(
  162. printer_id: int,
  163. api_key: APIKey = Depends(get_api_key),
  164. db: AsyncSession = Depends(get_db),
  165. ):
  166. """Stop the current print on a printer.
  167. Requires 'can_control_printer' permission.
  168. """
  169. await check_webhook_permission(db, api_key, "control_printer")
  170. check_printer_access(api_key, printer_id)
  171. status = printer_manager.get_status(printer_id)
  172. # `printer_manager.get_status(...)` returns a ``PrinterState`` dataclass
  173. # (see backend/app/services/bambu_mqtt.py), not a dict — `.get(...)` on it
  174. # raises AttributeError and surfaces as a generic 500 (#1584).
  175. if not status or not status.connected:
  176. raise HTTPException(status_code=503, detail="Printer not connected")
  177. if status.state != "RUNNING":
  178. raise HTTPException(status_code=409, detail="No print in progress")
  179. try:
  180. await printer_manager.stop_print(printer_id)
  181. except Exception as e:
  182. logger.error("Failed to stop print: %s", e)
  183. raise HTTPException(status_code=500, detail=str(e))
  184. return {"message": "Print stopped"}
  185. @router.post("/printer/{printer_id}/cancel")
  186. async def webhook_cancel_print(
  187. printer_id: int,
  188. api_key: APIKey = Depends(get_api_key),
  189. db: AsyncSession = Depends(get_db),
  190. ):
  191. """Cancel the current print on a printer.
  192. Requires 'can_control_printer' permission.
  193. """
  194. await check_webhook_permission(db, api_key, "control_printer")
  195. check_printer_access(api_key, printer_id)
  196. status = printer_manager.get_status(printer_id)
  197. # Same dataclass-not-dict shape as stop_print above (#1584).
  198. if not status or not status.connected:
  199. raise HTTPException(status_code=503, detail="Printer not connected")
  200. if status.state not in ["RUNNING", "PAUSE"]:
  201. raise HTTPException(status_code=409, detail="No print to cancel")
  202. try:
  203. await printer_manager.cancel_print(printer_id)
  204. except Exception as e:
  205. logger.error("Failed to cancel print: %s", e)
  206. raise HTTPException(status_code=500, detail=str(e))
  207. return {"message": "Print cancelled"}
  208. @router.get("/printer/{printer_id}/status", response_model=PrinterStatusResponse)
  209. async def webhook_get_printer_status(
  210. printer_id: int,
  211. api_key: APIKey = Depends(get_api_key),
  212. db: AsyncSession = Depends(get_db),
  213. ):
  214. """Get status of a printer.
  215. Requires 'can_read_status' permission.
  216. """
  217. await check_webhook_permission(db, api_key, "read_status")
  218. check_printer_access(api_key, printer_id)
  219. # Get printer
  220. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  221. printer = result.scalar_one_or_none()
  222. if not printer:
  223. raise HTTPException(status_code=404, detail="Printer not found")
  224. status = printer_manager.get_status(printer_id)
  225. # `printer_manager.get_status(...)` returns a ``PrinterState`` dataclass —
  226. # attribute access, not dict lookup. The previous `.get(...)` calls raised
  227. # AttributeError and surfaced as a generic 500 for any printer that
  228. # actually had a status row (#1584).
  229. return PrinterStatusResponse(
  230. id=printer.id,
  231. name=printer.name,
  232. connected=status.connected if status else False,
  233. state=status.state if status else None,
  234. current_print=status.current_print if status else None,
  235. progress=status.progress if status else None,
  236. remaining_time=status.remaining_time if status else None,
  237. )
  238. @router.get("/queue", response_model=list[QueueStatusResponse])
  239. async def webhook_get_queue_status(
  240. printer_id: int | None = None,
  241. api_key: APIKey = Depends(get_api_key),
  242. db: AsyncSession = Depends(get_db),
  243. ):
  244. """Get queue status for all printers or a specific printer.
  245. Requires 'can_read_status' permission.
  246. """
  247. await check_webhook_permission(db, api_key, "read_status")
  248. # Get printers
  249. if printer_id:
  250. check_printer_access(api_key, printer_id)
  251. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  252. printers = result.scalars().all()
  253. else:
  254. result = await db.execute(select(Printer))
  255. printers = result.scalars().all()
  256. # Filter by allowed printers if limited
  257. if api_key.printer_ids is not None:
  258. printers = [p for p in printers if p.id in api_key.printer_ids]
  259. response = []
  260. for printer in printers:
  261. # Get queue items
  262. result = await db.execute(
  263. select(PrintQueueItem)
  264. .where(
  265. PrintQueueItem.printer_id == printer.id,
  266. PrintQueueItem.status.in_(["pending", "printing"]),
  267. )
  268. .order_by(PrintQueueItem.position)
  269. )
  270. items = result.scalars().all()
  271. pending_count = sum(1 for i in items if i.status == "pending")
  272. printing_count = sum(1 for i in items if i.status == "printing")
  273. response.append(
  274. QueueStatusResponse(
  275. printer_id=printer.id,
  276. printer_name=printer.name,
  277. pending=pending_count,
  278. printing=printing_count,
  279. items=[
  280. {
  281. "id": item.id,
  282. "archive_id": item.archive_id,
  283. "position": item.position,
  284. "status": item.status,
  285. }
  286. for item in items
  287. ],
  288. )
  289. )
  290. return response