webhook.py 10 KB

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