Files
bambuddy/backend/app/services/preset_resolver.py
T
maziggy 18d534c945 feat(orca-cloud): integrate Orca Cloud profile sync across UI, slicer and SpoolBuddy
Reads, lists, and slices with profiles from a user's Orca Cloud account
  (OrcaSlicer 2.4.0-alpha's Supabase-backed sync) alongside the existing
  Bambu Cloud integration. Four sign-in providers (Google / Apple / GitHub /
  email+password); password defaults. Paste-flow PKCE because Orca's
  Supabase project only allowlists localhost redirect_to — open feature
  request at OrcaSlicer/OrcaSlicer#14028.

  Surfaces:
  - Profiles tab: new "Orca Cloud" tab next to "Bambu Cloud" with the same
    rich layout (search + 5 filter dropdowns + 3-column grouped grid +
    read-only detail modal)
  - SliceModal: 4-tier preset picker (orca_cloud > local > bambu cloud >
    standard); separate status banner per cloud; metadata-aware pre-pick
    scores Orca filaments above local (Orca's sync_pull returns full
    content inline so filament_type / filament_colour come for free, no
    per-setting fetch rate-limit dance)
  - ConfigureAmsSlotModal: orca_cloud as a new preset source (prefixed
    orca_<UUID> to match local_/builtin_); generic Bambu filament-ID
    derivation from parsed material (printer firmware can't grok Orca
    UUIDs); slot mapping persists preset_source='orca_cloud'
  - SpoolForm / SpoolBuddyWriteTagPage: Orca filaments merge into the
    cloud preset list via Promise.allSettled (OrcaProfileMeta is
    structurally identical to SlicerSetting)

  Backend:
  - services/orca_cloud.py: OrcaCloudService with PKCE / token exchange /
    single-use refresh rotation / get_user_info / list_profiles via the
    bare /sync/pull bootstrap path
  - routes/orca_cloud.py: 7 endpoints (auth/start, auth/finish,
    auth/password, status, logout, profiles, profiles/{id}); router-level
    _cloud_api_key_gate + per-route cloud_caller() so API-keyed callers
    (SpoolBuddy kiosk) properly resolve their owner User; just-in-time
    refresh with atomic persist-before-API-call
  - routes/slicer_presets.py: _fetch_orca_cloud_presets mirrors the Bambu
    Cloud fetcher (status vocabulary, 5min cache, permission shortcut);
    _dedupe_by_name extended to 4 tiers; UnifiedPresetsResponse gains
    orca_cloud + orca_cloud_status
  - services/preset_resolver.py: PresetRef.source extended with
    "orca_cloud"; _resolve_orca_cloud walks list + filters
  - 8 columns on users table for tokens (5 persistent) + transient PKCE
    handshake state with 10-min TTL (3); dialect-branched DATETIME /
    TIMESTAMP; auth-disabled mode falls back to Settings table
  - orca_cloud:auth permission folded into can_access_cloud API-key scope
    (same trust dimension)
2026-06-04 15:50:44 +02:00

251 lines
10 KiB
Python

"""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
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
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],
}
)