| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276 |
- """Resolve a `PresetRef` (source + id) to the JSON-string content the
- slicer-api sidecar's `/slice` endpoint expects.
- Three sources, three paths:
- - **local** — read ``LocalPreset.setting`` from the DB. Existing pre-PR
- behaviour for the slicer integration; preserved verbatim
- so clients still sending bare integer ids see no change.
- - **cloud** — fetch ``BambuCloudService.get_setting_detail(id)`` for the
- caller's stored cloud token. Result is the full slicer-shape
- preset JSON the sidecar can ingest directly.
- - **standard** — emit a stub ``{inherits: <name>, from: "system"}``. The
- sidecar's `bambuddy/profile-resolver` branch already walks
- ``inherits:`` against ``BUNDLED_PROFILES_PATH/<category>/<name>.json``
- during ``materializeProfile`` and merges parent-then-child,
- so the stub flattens out to the bundled content with no
- round-trip needed for the JSON itself.
- All three return the JSON as a *string* because that's what
- ``SlicerApiService.slice_with_profiles`` accepts as
- ``printer_profile_json`` etc. — the sidecar parses it once.
- """
- from __future__ import annotations
- import json
- import logging
- from fastapi import HTTPException
- from sqlalchemy.ext.asyncio import AsyncSession
- from backend.app.api.routes.cloud import get_stored_token
- from backend.app.api.routes.orca_cloud import _build_authenticated_service as _build_orca_service
- from backend.app.core.permissions import Permission
- from backend.app.models.local_preset import LocalPreset
- from backend.app.models.user import User
- from backend.app.schemas.slicer import PresetRef
- from backend.app.services.bambu_cloud import (
- BambuCloudAuthError,
- BambuCloudError,
- BambuCloudService,
- )
- from backend.app.services.orca_cloud import OrcaCloudAuthError, OrcaCloudError
- logger = logging.getLogger(__name__)
- _SLOT_TO_BUNDLED_CATEGORY = {
- "printer": "machine",
- "process": "process",
- "filament": "filament",
- }
- # The CLI's --load-settings parser uses the JSON's `type` field to decide
- # how to interpret each file (machine/process/filament). Without it the
- # CLI logs `operator(): unknown config type ... in load-settings`,
- # writes `error_string: "The input preset file is invalid and can not be
- # parsed.", return_code: -5` to result.json, and exits 0 — which the
- # Node sidecar's child_process treats as silent success producing no
- # output, then bubbles up as a generic "Failed to slice the model" 5xx.
- # Bambuddy then falls back to the embedded-settings path for every 3MF
- # slice, silently using whatever printer the source file was originally
- # bound to. Setting `type` correctly per slot fixes the silent fallback.
- _SLOT_TO_PROFILE_TYPE = {
- "printer": "machine",
- "process": "process",
- "filament": "filament",
- }
- async def resolve_preset_ref(
- db: AsyncSession,
- user: User | None,
- ref: PresetRef,
- slot: str,
- ) -> str:
- """Return the JSON-string content for `ref` so the sidecar can ingest it.
- `slot` is one of ``"printer"`` / ``"process"`` / ``"filament"``; it's
- only used to generate friendly error messages and to pick the bundled
- category for the standard tier.
- Raises ``HTTPException`` for any caller-facing error (invalid id, wrong
- preset type, cloud auth failure, network error fetching cloud detail).
- """
- if ref.source == "local":
- return await _resolve_local(db, ref, slot)
- if ref.source == "cloud":
- return await _resolve_cloud(db, user, ref, slot)
- if ref.source == "orca_cloud":
- return await _resolve_orca_cloud(db, user, ref, slot)
- if ref.source == "standard":
- return _resolve_standard(ref, slot)
- raise HTTPException(
- status_code=400,
- detail=f"Unknown preset source for {slot}: {ref.source!r}",
- )
- async def _resolve_local(db: AsyncSession, ref: PresetRef, slot: str) -> str:
- try:
- local_id = int(ref.id)
- except (ValueError, TypeError):
- raise HTTPException(status_code=400, detail=f"Invalid local preset id for {slot}: {ref.id!r}") from None
- preset = await db.get(LocalPreset, local_id)
- if preset is None or preset.preset_type != slot:
- raise HTTPException(
- status_code=400,
- detail=f"Invalid {slot} preset id (expected preset_type='{slot}')",
- )
- return preset.setting
- async def _resolve_cloud(db: AsyncSession, user: User | None, ref: PresetRef, slot: str) -> str:
- """Fetch a single cloud preset detail. Permission gate matches the
- rest of the cloud surface (`CLOUD_AUTH`) so a user with `LIBRARY_UPLOAD`
- but no `CLOUD_AUTH` can't slice using cloud presets even if their
- ``User.cloud_token`` survived a permission revocation."""
- if user is not None and not user.has_permission(Permission.CLOUD_AUTH.value):
- raise HTTPException(
- status_code=403,
- detail=f"Cloud presets require the cloud:auth permission ({slot})",
- )
- token, _email, region = await get_stored_token(db, user)
- if not token:
- raise HTTPException(
- status_code=400,
- detail=(
- f"Cloud preset selected for {slot}, but no Bambu Cloud session is "
- "stored. Sign in to Bambu Cloud and retry."
- ),
- )
- cloud = BambuCloudService(region=region)
- cloud.set_token(token)
- try:
- detail = await cloud.get_setting_detail(ref.id)
- except BambuCloudAuthError:
- raise HTTPException(
- status_code=401,
- detail=(f"Bambu Cloud session expired while fetching {slot} preset. Sign in again and retry."),
- ) from None
- except BambuCloudError as e:
- raise HTTPException(
- status_code=502,
- detail=f"Bambu Cloud unreachable while fetching {slot} preset: {e}",
- ) from e
- finally:
- await cloud.close()
- # `get_setting_detail` returns the wrapper envelope; the actual preset
- # JSON lives under `.setting`. The sidecar wants the preset content, not
- # the envelope.
- payload = detail.get("setting") if isinstance(detail, dict) else None
- if not isinstance(payload, dict):
- # Some endpoints return the preset at the top level instead of
- # nested under `setting`. Fall back to the whole response in that
- # case rather than failing — the sidecar will reject it cleanly if
- # the shape is genuinely wrong, and we log the unusual response.
- logger.info(
- "Cloud preset %r for %s returned unexpected shape, forwarding raw payload",
- ref.id,
- slot,
- )
- payload = detail
- if isinstance(payload, dict):
- # Bambu Cloud labels presets with `type: "printer"` / `"print"` /
- # `"filament"`, but the BS / Orca CLI's `--load-settings` parser only
- # accepts `"machine"` / `"process"` / `"filament"`. Without this
- # rewrite the CLI exits -5 with `operator(): unknown config type`
- # and the sidecar surfaces a generic "The input preset file is
- # invalid and can not be parsed" — see preset_resolver header
- # comment for the silent-fail history. `from` gets the same
- # treatment: Bambu Cloud's filament details routinely arrive with
- # `from: ""` (or no `from` at all) and the CLI rejects either with
- # `operator(): ... from unsupported` (same -5 exit). The standard
- # tier already pins `from: "system"` for exactly this reason; the
- # cloud tier needs the same pin because it lands at the same `--load-
- # settings` parser. The sidecar's `normalizeFromField` only rewrites
- # the `"User"` / `"System"` casings, not empty / missing values.
- payload = {**payload, "type": _SLOT_TO_PROFILE_TYPE[slot], "from": "system"}
- return json.dumps(payload)
- async def _resolve_orca_cloud(db: AsyncSession, user: User | None, ref: PresetRef, slot: str) -> str:
- """Fetch a single profile from Orca Cloud and return its content JSON.
- The route-layer service builder handles JIT token refresh and stale-credential
- cleanup, so any exception here means a genuine fetch / network / not-found
- problem — never a "stale token" situation the caller could retry through.
- Permission gate matches the rest of the Orca Cloud surface so a user with
- ``LIBRARY_UPLOAD`` but no ``ORCA_CLOUD_AUTH`` cannot slice using cloud
- profiles even if their stored token survived a permission revocation.
- """
- if user is not None and not user.has_permission(Permission.ORCA_CLOUD_AUTH.value):
- raise HTTPException(
- status_code=403,
- detail=f"Orca Cloud presets require the orca_cloud:auth permission ({slot})",
- )
- try:
- svc = await _build_orca_service(db, user)
- except HTTPException:
- # Builder already produces the right user-facing error (401 not
- # connected, 401 session refresh failed, 502 unreachable).
- raise
- try:
- profile = await svc.get_profile(ref.id)
- except OrcaCloudAuthError as e:
- raise HTTPException(
- status_code=401,
- detail=f"Orca Cloud session expired while fetching {slot} preset. Sign in again and retry.",
- ) from e
- except OrcaCloudError as e:
- if "not found" in str(e).lower():
- raise HTTPException(
- status_code=400,
- detail=f"Orca Cloud {slot} preset {ref.id!r} not found.",
- ) from e
- raise HTTPException(
- status_code=502,
- detail=f"Orca Cloud unreachable while fetching {slot} preset: {e}",
- ) from e
- finally:
- await svc.close()
- # ``profile`` is the ProfileUpsert shape — the inner ``content`` is the
- # actual slicer-format JSON. Fall back to forwarding the wrapper if the
- # shape doesn't match what we expect (defensive, in case Orca evolves
- # the wire format).
- content = profile.get("content") if isinstance(profile, dict) else None
- if not isinstance(content, dict):
- logger.info(
- "Orca Cloud preset %r for %s returned unexpected shape, forwarding raw payload",
- ref.id,
- slot,
- )
- content = profile
- if isinstance(content, dict):
- # Orca natively uses `machine` / `process` / `filament` for `type`,
- # which is what the CLI wants — but Bambu-imported profiles synced
- # through Orca Cloud can carry `printer` / `print` instead, and the
- # CLI's `--load-settings` parser rejects those the same way it does
- # for the Bambu Cloud tier. Force the slot-appropriate value so the
- # source tier doesn't decide whether slicing works. `from` gets the
- # same forced pin to `"system"` for the same reason — see the
- # Bambu Cloud branch above.
- content = {**content, "type": _SLOT_TO_PROFILE_TYPE[slot], "from": "system"}
- return json.dumps(content)
- def _resolve_standard(ref: PresetRef, slot: str) -> str:
- """Build a minimal `{name, inherits, from, type}` stub. The sidecar's
- resolver walks `BUNDLED_PROFILES_PATH/<category>/<name>.json` and merges,
- yielding the full bundled preset without us round-tripping the content
- through Bambuddy."""
- if slot not in _SLOT_TO_BUNDLED_CATEGORY:
- raise HTTPException(status_code=400, detail=f"Unknown slot for standard preset: {slot!r}")
- return json.dumps(
- {
- # `name` must be set so the sidecar's compatibility checks see a
- # populated value. Reusing the bundled name keeps the resolved
- # profile's identity consistent with what the user picked.
- "name": ref.id,
- "inherits": ref.id,
- # `from: "system"` skips the User/system compatibility rejection
- # the resolver was designed to fix for OrcaSlicer GUI exports —
- # we never want a bundled preset to be treated as User-authored.
- "from": "system",
- # `type` is required by the CLI's --load-settings parser — see
- # _SLOT_TO_PROFILE_TYPE above for the silent-failure mode.
- "type": _SLOT_TO_PROFILE_TYPE[slot],
- }
- )
|