slicer_plugin.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. """API for the Bambuddy slicer plugin (OrcaSlicer).
  2. The plugin runs inside the slicer on the user's machine and sends a sliced
  3. file here together with a manifest: what the user wants done with it (library
  4. only, or queue plate N x copies on a printer) and where it came from (slicer,
  5. presets, a fingerprint of the model). The file's own contents -- per-plate
  6. grams, time, the model it was sliced for -- are read from the file by the same
  7. parser every library upload goes through, not taken from the manifest, so the
  8. two can never disagree.
  9. The work is delegated: storing the file is ``ingest_library_upload`` and
  10. queueing it is ``POST /queue/`` itself, so a file sent from the slicer passes
  11. exactly the checks one added through the UI does.
  12. """
  13. import asyncio
  14. import io
  15. import json
  16. import logging
  17. import zipfile
  18. from datetime import datetime, timedelta, timezone
  19. from typing import Annotated
  20. from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, UploadFile
  21. from fastapi.security import HTTPAuthorizationCredentials
  22. from pydantic import ValidationError
  23. from sqlalchemy import delete, select
  24. from sqlalchemy.exc import IntegrityError
  25. from sqlalchemy.ext.asyncio import AsyncSession
  26. from backend.app.api.routes.library import ingest_library_upload
  27. from backend.app.api.routes.print_queue import add_to_queue
  28. from backend.app.core.auth import _validate_api_key, require_permission_if_auth_enabled, security
  29. from backend.app.core.config import APP_VERSION
  30. from backend.app.core.database import get_db
  31. from backend.app.core.permissions import Permission
  32. from backend.app.models.print_queue import PrintQueueItem
  33. from backend.app.models.printer import Printer
  34. from backend.app.models.slicer_plugin_upload import SlicerPluginUpload
  35. from backend.app.models.user import User
  36. from backend.app.schemas.print_queue import PrintQueueItemCreate
  37. from backend.app.schemas.slicer_plugin import (
  38. SlicerPluginInfoResponse,
  39. SlicerPluginLibraryFile,
  40. SlicerPluginManifest,
  41. SlicerPluginQueueResult,
  42. SlicerPluginUploadResponse,
  43. )
  44. from backend.app.utils.printer_models import is_gcode_compatible
  45. logger = logging.getLogger(__name__)
  46. router = APIRouter(prefix="/slicer-plugin", tags=["slicer-plugin"])
  47. # Manifest versions this server understands. A plugin reads them from /info and
  48. # sends the newest one both sides know.
  49. MANIFEST_VERSIONS = (1,)
  50. # A claim still "processing" after this long belongs to a request that died
  51. # without releasing it (a killed worker, a cancelled task). Past it, a retry
  52. # with the same upload_id may take the claim over instead of being refused
  53. # forever. Far longer than any upload plus queue insert takes.
  54. _STALE_CLAIM = timedelta(minutes=10)
  55. _MAX_MANIFEST_CHARS = 64_000
  56. def _utcnow() -> datetime:
  57. return datetime.now(timezone.utc).replace(tzinfo=None)
  58. async def _may_queue(credentials: HTTPAuthorizationCredentials | None, x_api_key: str | None) -> bool:
  59. """Whether the caller may create queue items, decided by the queue's own gate.
  60. Asks the dependency ``POST /queue/`` is guarded by rather than restating
  61. its rules, so a key or account that could not queue from the UI cannot
  62. queue from the slicer either.
  63. """
  64. try:
  65. await require_permission_if_auth_enabled(Permission.QUEUE_CREATE)(credentials=credentials, x_api_key=x_api_key)
  66. except HTTPException as e:
  67. if e.status_code == 403:
  68. return False
  69. raise
  70. return True
  71. async def _caller_id(
  72. db: AsyncSession,
  73. user: User | None,
  74. credentials: HTTPAuthorizationCredentials | None,
  75. x_api_key: str | None,
  76. ) -> str:
  77. """Name the caller, so an upload_id replays only to whoever used it first."""
  78. if user is not None:
  79. return f"user:{user.id}"
  80. key_value = x_api_key
  81. if key_value is None and credentials is not None and credentials.credentials.startswith("bb_"):
  82. key_value = credentials.credentials
  83. if key_value:
  84. api_key = await _validate_api_key(db, key_value)
  85. if api_key is not None:
  86. return f"apikey:{api_key.id}"
  87. return "anonymous"
  88. def _check_plate(content: bytes, plate_id: int) -> str | None:
  89. """Return why ``plate_id`` cannot be printed from this file, or None if it can."""
  90. try:
  91. with zipfile.ZipFile(io.BytesIO(content)) as zf:
  92. names = set(zf.namelist())
  93. except zipfile.BadZipFile:
  94. return "plate_id needs a sliced 3MF; this file is not one"
  95. if f"Metadata/plate_{plate_id}.gcode" not in names:
  96. return f"This file has no sliced plate {plate_id}"
  97. return None
  98. def _replay(existing: SlicerPluginUpload, caller: str) -> SlicerPluginUploadResponse:
  99. """Answer a repeated upload_id with what the first request returned."""
  100. # Anyone else presenting the id gets nothing about it: a 409 that is the
  101. # same whether or not the stored upload is still in progress.
  102. if existing.caller != caller:
  103. raise HTTPException(409, "upload_id has already been used")
  104. if existing.status != "done" or not existing.response:
  105. raise HTTPException(409, "An upload with this upload_id is still being processed")
  106. data = json.loads(existing.response)
  107. data["replayed"] = True
  108. return SlicerPluginUploadResponse.model_validate(data)
  109. @router.get("/info", response_model=SlicerPluginInfoResponse)
  110. async def plugin_info(
  111. current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
  112. credentials: HTTPAuthorizationCredentials | None = Depends(security),
  113. x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
  114. ):
  115. """Handshake for the plugin's connect dialog.
  116. Needs the same permission an upload does, so "connected" means the plugin
  117. can actually send. ``can_queue`` tells it whether to offer queueing at all.
  118. """
  119. return SlicerPluginInfoResponse(
  120. bambuddy_version=APP_VERSION,
  121. manifest_versions=list(MANIFEST_VERSIONS),
  122. can_upload=True,
  123. can_queue=await _may_queue(credentials, x_api_key),
  124. )
  125. @router.post("/uploads", response_model=SlicerPluginUploadResponse)
  126. async def upload_from_slicer(
  127. file: UploadFile = File(...),
  128. manifest: str = Form(..., max_length=_MAX_MANIFEST_CHARS),
  129. db: AsyncSession = Depends(get_db),
  130. current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
  131. credentials: HTTPAuthorizationCredentials | None = Depends(security),
  132. x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
  133. ):
  134. """Store a sliced file from the slicer plugin, and queue it if asked.
  135. Idempotent on ``manifest.upload_id``: a retry of a send whose response was
  136. lost gets the first response back (``replayed: true``) instead of a second
  137. upload -- and, with action "queue", instead of a second print.
  138. """
  139. try:
  140. m = SlicerPluginManifest.model_validate_json(manifest)
  141. except ValidationError as e:
  142. raise HTTPException(422, detail=e.errors(include_url=False, include_context=False, include_input=False))
  143. if m.schema_version not in MANIFEST_VERSIONS:
  144. raise HTTPException(
  145. 400,
  146. f"Manifest version {m.schema_version} is not supported; this server reads {list(MANIFEST_VERSIONS)}",
  147. )
  148. if not file.filename:
  149. raise HTTPException(400, "Filename is required")
  150. intent = m.intent
  151. caller = await _caller_id(db, current_user, credentials, x_api_key)
  152. # A repeat is answered before anything is checked or read, so a retry of an
  153. # upload that succeeded returns its answer even if the caller has since
  154. # lost a permission it had then.
  155. existing = (
  156. await db.execute(select(SlicerPluginUpload).where(SlicerPluginUpload.upload_id == m.upload_id))
  157. ).scalar_one_or_none()
  158. if existing is not None:
  159. abandoned = (
  160. existing.status != "done" and existing.caller == caller and existing.created_at < _utcnow() - _STALE_CLAIM
  161. )
  162. if not abandoned:
  163. return _replay(existing, caller)
  164. await db.delete(existing)
  165. await db.commit()
  166. # Refused before anything is written: a plugin told "uploaded, but you may
  167. # not queue" would leave a file in the library the user never asked to keep.
  168. if intent.action == "queue" and not await _may_queue(credentials, x_api_key):
  169. raise HTTPException(403, "This account or API key may upload files but not queue them")
  170. content = await file.read()
  171. if intent.action == "queue" and intent.plate_id is not None:
  172. plate_problem = await asyncio.to_thread(_check_plate, content, intent.plate_id)
  173. if plate_problem:
  174. raise HTTPException(400, plate_problem)
  175. # Claim the id. The unique constraint decides between two requests that
  176. # both got past the lookup above -- the plugin retrying a send that timed
  177. # out while the first attempt was still working.
  178. source = m.source
  179. claim = SlicerPluginUpload(
  180. upload_id=m.upload_id,
  181. caller=caller,
  182. status="processing",
  183. slicer=source.slicer,
  184. slicer_version=source.slicer_version,
  185. plugin_version=source.plugin_version,
  186. project_name=source.project_name,
  187. model_fingerprint=source.model_fingerprint,
  188. presets=source.presets.model_dump_json() if source.presets else None,
  189. created_at=_utcnow(),
  190. )
  191. db.add(claim)
  192. try:
  193. await db.commit()
  194. except IntegrityError:
  195. await db.rollback()
  196. winner = (
  197. await db.execute(select(SlicerPluginUpload).where(SlicerPluginUpload.upload_id == m.upload_id))
  198. ).scalar_one()
  199. return _replay(winner, caller)
  200. claim_id = claim.id
  201. try:
  202. response = await _store_and_queue(db, m, file.filename, content, current_user)
  203. except Exception:
  204. # Release the claim, so the plugin's retry runs again rather than
  205. # being told the failed attempt is still in progress.
  206. await db.rollback()
  207. await db.execute(delete(SlicerPluginUpload).where(SlicerPluginUpload.id == claim_id))
  208. await db.commit()
  209. raise
  210. claim = await db.get(SlicerPluginUpload, claim_id)
  211. claim.status = "done"
  212. claim.library_file_id = response.library_file.id
  213. claim.response = response.model_dump_json()
  214. await db.commit()
  215. return response
  216. async def _store_and_queue(
  217. db: AsyncSession,
  218. m: SlicerPluginManifest,
  219. filename: str,
  220. content: bytes,
  221. current_user: User | None,
  222. ) -> SlicerPluginUploadResponse:
  223. intent = m.intent
  224. library_file, duplicate_of = await ingest_library_upload(
  225. db,
  226. filename=filename,
  227. content=content,
  228. folder_id=intent.folder_id,
  229. created_by_id=current_user.id if current_user else None,
  230. )
  231. stored = SlicerPluginLibraryFile(
  232. id=library_file.id,
  233. filename=library_file.filename,
  234. file_type=library_file.file_type,
  235. file_size=library_file.file_size,
  236. thumbnail_path=library_file.thumbnail_path,
  237. duplicate_of=duplicate_of,
  238. )
  239. sliced_for = (library_file.file_metadata or {}).get("sliced_for_model")
  240. warnings: list[str] = []
  241. if duplicate_of is not None:
  242. warnings.append(f"The library already holds this exact file (#{duplicate_of}).")
  243. if intent.action != "queue":
  244. return SlicerPluginUploadResponse(upload_id=m.upload_id, library_file=stored, warnings=warnings)
  245. # POST /queue/ refuses a target_model the file was not sliced for, but a
  246. # named printer is the user's own pick and is only flagged -- the same
  247. # latitude the print dialog gives.
  248. if intent.printer_id is not None:
  249. printer = await db.get(Printer, intent.printer_id)
  250. if printer is not None and not is_gcode_compatible(sliced_for, printer.model):
  251. warnings.append(f"Sliced for {sliced_for}, but {printer.name} is a {printer.model}.")
  252. try:
  253. item = await add_to_queue(
  254. PrintQueueItemCreate(
  255. library_file_id=library_file.id,
  256. printer_id=intent.printer_id,
  257. target_model=intent.target_model,
  258. plate_id=intent.plate_id,
  259. quantity=intent.copies,
  260. manual_start=intent.manual_start,
  261. cost_center_id=intent.cost_center_id,
  262. ),
  263. db=db,
  264. current_user=current_user,
  265. )
  266. except HTTPException as e:
  267. # The queue route may have flushed a batch row before refusing (the
  268. # budget check runs after it); none of that may outlive the refusal.
  269. await db.rollback()
  270. queue_error = e.detail if isinstance(e.detail, str) else json.dumps(e.detail)
  271. return SlicerPluginUploadResponse(
  272. upload_id=m.upload_id, library_file=stored, queue_error=queue_error, warnings=warnings
  273. )
  274. if item.batch_id is None:
  275. item_ids = [item.id]
  276. else:
  277. item_ids = list(
  278. (
  279. await db.execute(
  280. select(PrintQueueItem.id)
  281. .where(PrintQueueItem.batch_id == item.batch_id)
  282. .order_by(PrintQueueItem.position)
  283. )
  284. ).scalars()
  285. )
  286. return SlicerPluginUploadResponse(
  287. upload_id=m.upload_id,
  288. library_file=stored,
  289. queue=SlicerPluginQueueResult(queue_item_ids=item_ids, batch_id=item.batch_id),
  290. warnings=warnings,
  291. )