preset_resolver.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. """Resolve a `PresetRef` (source + id) to the JSON-string content the
  2. slicer-api sidecar's `/slice` endpoint expects.
  3. Three sources, three paths:
  4. - **local** — read ``LocalPreset.setting`` from the DB. Existing pre-PR
  5. behaviour for the slicer integration; preserved verbatim
  6. so clients still sending bare integer ids see no change.
  7. - **cloud** — fetch ``BambuCloudService.get_setting_detail(id)`` for the
  8. caller's stored cloud token. Result is the full slicer-shape
  9. preset JSON the sidecar can ingest directly.
  10. - **standard** — emit a stub ``{inherits: <name>, from: "system"}``. The
  11. sidecar's `bambuddy/profile-resolver` branch already walks
  12. ``inherits:`` against ``BUNDLED_PROFILES_PATH/<category>/<name>.json``
  13. during ``materializeProfile`` and merges parent-then-child,
  14. so the stub flattens out to the bundled content with no
  15. round-trip needed for the JSON itself.
  16. All three return the JSON as a *string* because that's what
  17. ``SlicerApiService.slice_with_profiles`` accepts as
  18. ``printer_profile_json`` etc. — the sidecar parses it once.
  19. """
  20. from __future__ import annotations
  21. import json
  22. import logging
  23. from fastapi import HTTPException
  24. from sqlalchemy.ext.asyncio import AsyncSession
  25. from backend.app.api.routes.orca_cloud import _build_authenticated_service as _build_orca_service
  26. from backend.app.core.auth import is_auth_enabled
  27. from backend.app.core.permissions import Permission
  28. from backend.app.models.local_preset import LocalPreset
  29. from backend.app.models.user import User
  30. from backend.app.schemas.slicer import PresetRef
  31. from backend.app.services.bambu_cloud import (
  32. BambuCloudAuthError,
  33. BambuCloudError,
  34. BambuCloudService,
  35. )
  36. from backend.app.services.bambu_cloud_credentials import get_stored_token
  37. from backend.app.services.orca_cloud import OrcaCloudAuthError, OrcaCloudError
  38. from backend.app.utils.slicer_strings import unescape_strings_cstyle
  39. logger = logging.getLogger(__name__)
  40. _SLOT_TO_BUNDLED_CATEGORY = {
  41. "printer": "machine",
  42. "process": "process",
  43. "filament": "filament",
  44. }
  45. # The CLI's --load-settings parser uses the JSON's `type` field to decide
  46. # how to interpret each file (machine/process/filament). Without it the
  47. # CLI logs `operator(): unknown config type ... in load-settings`,
  48. # writes `error_string: "The input preset file is invalid and can not be
  49. # parsed.", return_code: -5` to result.json, and exits 0 — which the
  50. # Node sidecar's child_process treats as silent success producing no
  51. # output, then bubbles up as a generic "Failed to slice the model" 5xx.
  52. # Bambuddy then falls back to the embedded-settings path for every 3MF
  53. # slice, silently using whatever printer the source file was originally
  54. # bound to. Setting `type` correctly per slot fixes the silent fallback.
  55. _SLOT_TO_PROFILE_TYPE = {
  56. "printer": "machine",
  57. "process": "process",
  58. "filament": "filament",
  59. }
  60. # String-list options Orca Cloud can return INI-encoded (#3380). The sidecar
  61. # only fixes up `compatible_printers` when it is an array (#3250), so a
  62. # process made for a user printer preset passes the Slice dialog's check and
  63. # is then refused by the CLI.
  64. _ORCA_STRING_LIST_FIELDS = ("compatible_printers", "filament_type", "default_filament_colour")
  65. async def resolve_preset_ref(
  66. db: AsyncSession,
  67. user: User | None,
  68. ref: PresetRef,
  69. slot: str,
  70. ) -> str:
  71. """Return the JSON-string content for `ref` so the sidecar can ingest it.
  72. `slot` is one of ``"printer"`` / ``"process"`` / ``"filament"``; it's
  73. only used to generate friendly error messages and to pick the bundled
  74. category for the standard tier.
  75. Raises ``HTTPException`` for any caller-facing error (invalid id, wrong
  76. preset type, cloud auth failure, network error fetching cloud detail).
  77. """
  78. if ref.source == "local":
  79. return await _resolve_local(db, ref, slot)
  80. if ref.source == "cloud":
  81. return await _resolve_cloud(db, user, ref, slot)
  82. if ref.source == "orca_cloud":
  83. return await _resolve_orca_cloud(db, user, ref, slot)
  84. if ref.source == "standard":
  85. return _resolve_standard(ref, slot)
  86. raise HTTPException(
  87. status_code=400,
  88. detail=f"Unknown preset source for {slot}: {ref.source!r}",
  89. )
  90. async def _resolve_local(db: AsyncSession, ref: PresetRef, slot: str) -> str:
  91. try:
  92. local_id = int(ref.id)
  93. except (ValueError, TypeError):
  94. raise HTTPException(status_code=400, detail=f"Invalid local preset id for {slot}: {ref.id!r}") from None
  95. preset = await db.get(LocalPreset, local_id)
  96. if preset is None or preset.preset_type != slot:
  97. raise HTTPException(
  98. status_code=400,
  99. detail=f"Invalid {slot} preset id (expected preset_type='{slot}')",
  100. )
  101. return preset.setting
  102. # The sign-in stored without a user belongs to an install with auth off. With
  103. # auth on, a caller without a user (an API key without Allow Cloud Access)
  104. # has no cloud sign-in of its own and must not borrow that one.
  105. _NO_CLOUD_IDENTITY = "{cloud} presets need a signed-in user or an API key with Allow Cloud Access ({slot})"
  106. async def _resolve_cloud(db: AsyncSession, user: User | None, ref: PresetRef, slot: str) -> str:
  107. """Fetch a single cloud preset detail. Permission gate matches the
  108. rest of the cloud surface (`CLOUD_AUTH`) so a user with `LIBRARY_UPLOAD`
  109. but no `CLOUD_AUTH` can't slice using cloud presets even if their
  110. ``User.cloud_token`` survived a permission revocation."""
  111. if user is not None and not user.has_permission(Permission.CLOUD_AUTH.value):
  112. raise HTTPException(
  113. status_code=403,
  114. detail=f"Cloud presets require the cloud:auth permission ({slot})",
  115. )
  116. if user is None and await is_auth_enabled(db):
  117. raise HTTPException(status_code=403, detail=_NO_CLOUD_IDENTITY.format(cloud="Bambu Cloud", slot=slot))
  118. token, _email, region = await get_stored_token(db, user)
  119. if not token:
  120. raise HTTPException(
  121. status_code=400,
  122. detail=(
  123. f"Cloud preset selected for {slot}, but no Bambu Cloud session is "
  124. "stored. Sign in to Bambu Cloud and retry."
  125. ),
  126. )
  127. cloud = BambuCloudService(region=region)
  128. cloud.set_token(token)
  129. try:
  130. detail = await cloud.get_setting_detail(ref.id)
  131. except BambuCloudAuthError:
  132. raise HTTPException(
  133. status_code=401,
  134. detail=(f"Bambu Cloud session expired while fetching {slot} preset. Sign in again and retry."),
  135. ) from None
  136. except BambuCloudError as e:
  137. raise HTTPException(
  138. status_code=502,
  139. detail=f"Bambu Cloud unreachable while fetching {slot} preset: {e}",
  140. ) from e
  141. finally:
  142. await cloud.close()
  143. # `get_setting_detail` returns the wrapper envelope; the actual preset
  144. # JSON lives under `.setting`. The sidecar wants the preset content, not
  145. # the envelope.
  146. payload = detail.get("setting") if isinstance(detail, dict) else None
  147. if not isinstance(payload, dict):
  148. # Some endpoints return the preset at the top level instead of
  149. # nested under `setting`. Fall back to the whole response in that
  150. # case rather than failing — the sidecar will reject it cleanly if
  151. # the shape is genuinely wrong, and we log the unusual response.
  152. logger.info(
  153. "Cloud preset %r for %s returned unexpected shape, forwarding raw payload",
  154. ref.id,
  155. slot,
  156. )
  157. payload = detail
  158. if isinstance(payload, dict):
  159. # Bambu Cloud labels presets with `type: "printer"` / `"print"` /
  160. # `"filament"`, but the BS / Orca CLI's `--load-settings` parser only
  161. # accepts `"machine"` / `"process"` / `"filament"`. Without this
  162. # rewrite the CLI exits -5 with `operator(): unknown config type`
  163. # and the sidecar surfaces a generic "The input preset file is
  164. # invalid and can not be parsed" — see preset_resolver header
  165. # comment for the silent-fail history. `from` gets the same
  166. # treatment: Bambu Cloud's filament details routinely arrive with
  167. # `from: ""` (or no `from` at all) and the CLI rejects either with
  168. # `operator(): ... from unsupported` (same -5 exit). The standard
  169. # tier already pins `from: "system"` for exactly this reason; the
  170. # cloud tier needs the same pin because it lands at the same `--load-
  171. # settings` parser. The sidecar's `normalizeFromField` only rewrites
  172. # the `"User"` / `"System"` casings, not empty / missing values.
  173. payload = {**payload, "type": _SLOT_TO_PROFILE_TYPE[slot], "from": "system"}
  174. return json.dumps(payload)
  175. async def _resolve_orca_cloud(db: AsyncSession, user: User | None, ref: PresetRef, slot: str) -> str:
  176. """Fetch a single profile from Orca Cloud and return its content JSON.
  177. The route-layer service builder handles JIT token refresh and stale-credential
  178. cleanup, so any exception here means a genuine fetch / network / not-found
  179. problem — never a "stale token" situation the caller could retry through.
  180. Permission gate matches the rest of the Orca Cloud surface so a user with
  181. ``LIBRARY_UPLOAD`` but no ``ORCA_CLOUD_AUTH`` cannot slice using cloud
  182. profiles even if their stored token survived a permission revocation.
  183. """
  184. if user is not None and not user.has_permission(Permission.ORCA_CLOUD_AUTH.value):
  185. raise HTTPException(
  186. status_code=403,
  187. detail=f"Orca Cloud presets require the orca_cloud:auth permission ({slot})",
  188. )
  189. if user is None and await is_auth_enabled(db):
  190. raise HTTPException(status_code=403, detail=_NO_CLOUD_IDENTITY.format(cloud="Orca Cloud", slot=slot))
  191. try:
  192. svc = await _build_orca_service(db, user)
  193. except HTTPException:
  194. # Builder already produces the right user-facing error (401 not
  195. # connected, 401 session refresh failed, 502 unreachable).
  196. raise
  197. try:
  198. profile = await svc.get_profile(ref.id)
  199. except OrcaCloudAuthError as e:
  200. raise HTTPException(
  201. status_code=401,
  202. detail=f"Orca Cloud session expired while fetching {slot} preset. Sign in again and retry.",
  203. ) from e
  204. except OrcaCloudError as e:
  205. if "not found" in str(e).lower():
  206. raise HTTPException(
  207. status_code=400,
  208. detail=f"Orca Cloud {slot} preset {ref.id!r} not found.",
  209. ) from e
  210. raise HTTPException(
  211. status_code=502,
  212. detail=f"Orca Cloud unreachable while fetching {slot} preset: {e}",
  213. ) from e
  214. finally:
  215. await svc.close()
  216. # ``profile`` is the ProfileUpsert shape — the inner ``content`` is the
  217. # actual slicer-format JSON. Fall back to forwarding the wrapper if the
  218. # shape doesn't match what we expect (defensive, in case Orca evolves
  219. # the wire format).
  220. content = profile.get("content") if isinstance(profile, dict) else None
  221. if not isinstance(content, dict):
  222. logger.info(
  223. "Orca Cloud preset %r for %s returned unexpected shape, forwarding raw payload",
  224. ref.id,
  225. slot,
  226. )
  227. content = profile
  228. if isinstance(content, dict):
  229. # Orca natively uses `machine` / `process` / `filament` for `type`,
  230. # which is what the CLI wants — but Bambu-imported profiles synced
  231. # through Orca Cloud can carry `printer` / `print` instead, and the
  232. # CLI's `--load-settings` parser rejects those the same way it does
  233. # for the Bambu Cloud tier. Force the slot-appropriate value so the
  234. # source tier doesn't decide whether slicing works. `from` gets the
  235. # same forced pin to `"system"` for the same reason — see the
  236. # Bambu Cloud branch above.
  237. content = {**content, "type": _SLOT_TO_PROFILE_TYPE[slot], "from": "system"}
  238. for key in _ORCA_STRING_LIST_FIELDS:
  239. raw = content.get(key)
  240. # Parsed the way the slicer would, empty items included, so
  241. # per-extruder positions survive. A string it can't parse is
  242. # forwarded as is.
  243. items = unescape_strings_cstyle(raw) if isinstance(raw, str) else None
  244. if items is not None:
  245. content[key] = items
  246. return json.dumps(content)
  247. def _resolve_standard(ref: PresetRef, slot: str) -> str:
  248. """Build a minimal `{name, inherits, from, type}` stub. The sidecar's
  249. resolver walks `BUNDLED_PROFILES_PATH/<category>/<name>.json` and merges,
  250. yielding the full bundled preset without us round-tripping the content
  251. through Bambuddy."""
  252. if slot not in _SLOT_TO_BUNDLED_CATEGORY:
  253. raise HTTPException(status_code=400, detail=f"Unknown slot for standard preset: {slot!r}")
  254. return json.dumps(
  255. {
  256. # `name` must be set so the sidecar's compatibility checks see a
  257. # populated value. Reusing the bundled name keeps the resolved
  258. # profile's identity consistent with what the user picked.
  259. "name": ref.id,
  260. "inherits": ref.id,
  261. # `from: "system"` skips the User/system compatibility rejection
  262. # the resolver was designed to fix for OrcaSlicer GUI exports —
  263. # we never want a bundled preset to be treated as User-authored.
  264. "from": "system",
  265. # `type` is required by the CLI's --load-settings parser — see
  266. # _SLOT_TO_PROFILE_TYPE above for the silent-failure mode.
  267. "type": _SLOT_TO_PROFILE_TYPE[slot],
  268. }
  269. )