| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670 |
- """
- Orca Cloud API Routes
- Device-pairing (RFC 8628) connect/disconnect + profile sync endpoints for the
- Orca Cloud external-app surface.
- Auth shape (see :mod:`backend.app.services.orca_cloud` for the deep dive):
- POST /orca-cloud/device/start
- Request a device code, persist it server-side (TTL 10 min), return the
- user_code + verification URIs + poll interval.
- POST /orca-cloud/device/poll
- One poll of the token endpoint. Returns an in-progress status while the
- user approves; on approval, persists the token pair and reports
- connected. The frontend calls this every ``interval`` seconds.
- GET /orca-cloud/status
- Connected/disconnected + user_id.
- POST /orca-cloud/logout
- Clear stored tokens (Bambuddy then has no token to use; the user can
- also disconnect from Orca Cloud's own settings to revoke server-side).
- GET /orca-cloud/profiles
- List of the user's Orca Cloud profiles, grouped by type. JIT-refreshes
- the access token if it's within the refresh leeway of expiry.
- GET /orca-cloud/profiles/{id}
- Single profile's full content.
- Storage shape mirrors the Bambu Cloud surface: per-user columns on ``users``
- when auth is enabled, fallback to global ``settings`` keys when auth is
- disabled. The transient pending device-code state (device_code, interval,
- started_at) reuses the ``orca_cloud_pending_*`` columns — same dual-mode
- pattern; no schema change from the previous PKCE flow.
- """
- from __future__ import annotations
- import logging
- from datetime import datetime, timezone
- from fastapi import APIRouter, Depends, HTTPException, Request
- from sqlalchemy import select, update
- from sqlalchemy.ext.asyncio import AsyncSession
- from backend.app.api.routes.cloud import _cloud_api_key_gate, cloud_caller
- from backend.app.core.database import get_db
- from backend.app.core.permissions import Permission
- from backend.app.models.settings import Settings
- from backend.app.models.user import User
- from backend.app.schemas.orca_cloud import (
- OrcaAuthStatusResponse,
- OrcaDevicePollResponse,
- OrcaDeviceStartResponse,
- OrcaProfileDetail,
- OrcaProfileListResponse,
- OrcaProfileMeta,
- )
- from backend.app.services.orca_cloud import (
- DEVICE_CODE_TTL,
- DevicePoll,
- OrcaCloudAuthError,
- OrcaCloudError,
- OrcaCloudService,
- )
- logger = logging.getLogger(__name__)
- # Router-level dependency: enforce the same API-key cloud-access fence as the
- # Bambu Cloud router (rejects ownerless legacy keys, requires the
- # ``can_access_cloud`` scope, stashes the owner on ``request.state`` so
- # per-route deps can resolve it as the effective ``current_user``).
- # Without this gate the kiosk's API-keyed requests sail past with
- # ``current_user=None`` → ``_build_authenticated_service`` falls back to
- # the global Settings table → no Orca token → 401, no presets surfaced.
- # Bambu Cloud works in the same kiosk because its router has this gate.
- router = APIRouter(prefix="/orca-cloud", tags=["orca-cloud"], dependencies=[Depends(_cloud_api_key_gate)])
- # Orca ``content.type`` values map onto Bambu Cloud's preset type vocabulary.
- # Empirically (confirmed against a live account on 2026-06-04): Orca uses
- # ``"printer"`` / ``"print"`` / ``"filament"`` — NOT the BambuStudio
- # ``"machine"`` / ``"process"`` / ``"filament"`` triplet that lives elsewhere
- # in the OrcaSlicer source. The aliases keep us forward-compatible if Orca
- # ever flips back to the older naming.
- _ORCA_TYPE_TO_BAMBU = {
- "filament": "filament",
- "printer": "printer",
- "machine": "printer", # alias for the BambuStudio-style naming
- "print": "process",
- "process": "process", # alias for the BambuStudio-style naming
- }
- def _orca_to_setting(orca_profile: dict) -> OrcaProfileMeta | None:
- """Normalize one Orca profile (``{id, name, content, ...}``) into a
- ``SlicerSetting``-shaped row. Returns ``None`` if the content isn't a dict
- or the type isn't one we render."""
- content = orca_profile.get("content") or {}
- if not isinstance(content, dict):
- return None
- bambu_type = _ORCA_TYPE_TO_BAMBU.get(str(content.get("type", "")))
- if bambu_type is None:
- return None
- pid = orca_profile.get("id")
- if pid is None:
- return None
- updated = orca_profile.get("updated_time")
- return OrcaProfileMeta(
- setting_id=str(pid),
- name=str(orca_profile.get("name") or pid),
- type=bambu_type,
- version=_str_or_none(content.get("version")),
- # ``from`` distinguishes ``system`` (bundled) from ``User`` (custom),
- # same field the Bambu source-of-truth uses for that distinction.
- user_id=_str_or_none(content.get("user_id") or content.get("from")),
- updated_time=str(updated) if updated is not None else None,
- # Every profile that lives in the user's Orca Cloud account is by
- # definition user-authored; bundled defaults aren't synced.
- is_custom=True,
- )
- def _str_or_none(value: object) -> str | None:
- """Cast non-empty scalars to ``str``; pass ``None`` and empty values
- through unchanged. Used to keep the response shape consistent when
- Orca's source data has heterogenous typing for the same field."""
- if value is None:
- return None
- s = str(value)
- return s if s else None
- # Settings table keys for the auth-disabled fallback. Mirrors the Bambu Cloud
- # pattern (``bambu_cloud_token`` etc.) so administrators inspecting the
- # settings table see a consistent prefix. The ``pending_*`` keys hold the
- # transient device-code state (device_code / interval / started_at).
- _SETTINGS_KEYS = {
- "token": "orca_cloud_token",
- "refresh_token": "orca_cloud_refresh_token",
- "expires_at": "orca_cloud_expires_at", # ISO 8601 UTC string
- "email": "orca_cloud_email",
- "user_id": "orca_cloud_user_id",
- "pending_device_code": "orca_cloud_pending_verifier", # reused column
- "pending_interval": "orca_cloud_pending_state", # reused column
- "pending_at": "orca_cloud_pending_at", # ISO 8601 UTC string
- }
- # ---------------------------------------------------------------------------
- # Storage helpers — bridge User-row vs Settings-table fallback transparently
- # ---------------------------------------------------------------------------
- def _iso(dt: datetime | None) -> str | None:
- """Serialize a datetime to ISO 8601 UTC. ``None`` passes through."""
- if dt is None:
- return None
- if dt.tzinfo is None:
- dt = dt.replace(tzinfo=timezone.utc)
- return dt.astimezone(timezone.utc).isoformat()
- def _as_utc(dt: datetime | None) -> datetime | None:
- """Attach ``tzinfo=UTC`` to a naive datetime that we know was stored as
- UTC. ``None`` passes through. Already-aware datetimes are converted to
- UTC to normalize."""
- if dt is None:
- return None
- if dt.tzinfo is None:
- return dt.replace(tzinfo=timezone.utc)
- return dt.astimezone(timezone.utc)
- def _parse_iso(value: str | None) -> datetime | None:
- """Parse an ISO 8601 string back to a UTC datetime. ``None`` passes through."""
- if not value:
- return None
- try:
- dt = datetime.fromisoformat(value)
- except (TypeError, ValueError):
- return None
- if dt.tzinfo is None:
- dt = dt.replace(tzinfo=timezone.utc)
- return dt
- class _OrcaCredentials:
- """Lightweight bag for stored Orca Cloud credentials. We use a class
- rather than a dataclass so the helpers can mutate it as needed during
- JIT-refresh without rebuilding the whole object.
- ``pending_device_code`` / ``pending_interval`` / ``pending_at`` hold the
- in-flight device-code pairing state (reusing the ``orca_cloud_pending_*``
- columns that the old PKCE flow used for its verifier/state)."""
- __slots__ = (
- "token",
- "refresh_token",
- "expires_at",
- "email",
- "user_id",
- "pending_device_code",
- "pending_interval",
- "pending_at",
- )
- def __init__(self) -> None:
- self.token: str | None = None
- self.refresh_token: str | None = None
- self.expires_at: datetime | None = None
- self.email: str | None = None
- self.user_id: str | None = None
- self.pending_device_code: str | None = None
- self.pending_interval: str | None = None
- self.pending_at: datetime | None = None
- async def _load_credentials(db: AsyncSession, user: User | None) -> _OrcaCredentials:
- """Load stored Orca Cloud credentials for the caller (user-row when auth
- is enabled, Settings fallback when auth is disabled).
- Datetimes coming back from the User row are NAIVE on the Postgres side
- (asyncpg strips tzinfo for ``TIMESTAMP WITHOUT TIME ZONE`` columns) but
- represent UTC moments because that's what we stored. We attach
- ``tzinfo=UTC`` here so downstream comparisons against
- ``datetime.now(timezone.utc)`` don't get shifted by the host's local
- offset — ``naive_dt.astimezone(UTC)`` would assume local time, which on
- a UTC+2 host turns a 1-minute-old pending state into a 2h1m one and
- fires the 10-minute TTL guard immediately."""
- creds = _OrcaCredentials()
- if user is not None:
- creds.token = user.orca_cloud_token
- creds.refresh_token = user.orca_cloud_refresh_token
- creds.expires_at = _as_utc(user.orca_cloud_expires_at)
- creds.email = user.orca_cloud_email
- creds.user_id = user.orca_cloud_user_id
- creds.pending_device_code = user.orca_cloud_pending_verifier
- creds.pending_interval = user.orca_cloud_pending_state
- creds.pending_at = _as_utc(user.orca_cloud_pending_at)
- return creds
- result = await db.execute(select(Settings).where(Settings.key.in_(list(_SETTINGS_KEYS.values()))))
- raw = {s.key: s.value for s in result.scalars().all()}
- creds.token = raw.get(_SETTINGS_KEYS["token"])
- creds.refresh_token = raw.get(_SETTINGS_KEYS["refresh_token"])
- creds.expires_at = _parse_iso(raw.get(_SETTINGS_KEYS["expires_at"]))
- creds.email = raw.get(_SETTINGS_KEYS["email"])
- creds.user_id = raw.get(_SETTINGS_KEYS["user_id"])
- creds.pending_device_code = raw.get(_SETTINGS_KEYS["pending_device_code"])
- creds.pending_interval = raw.get(_SETTINGS_KEYS["pending_interval"])
- creds.pending_at = _parse_iso(raw.get(_SETTINGS_KEYS["pending_at"]))
- return creds
- async def _persist_pending_device(
- db: AsyncSession,
- user: User | None,
- device_code: str,
- interval: int,
- when: datetime,
- ) -> None:
- """Store the transient device-code state used by ``/device/start`` ->
- ``/device/poll``. The device_code is a secret kept server-side."""
- if user is not None:
- await db.execute(
- update(User)
- .where(User.id == user.id)
- .values(
- orca_cloud_pending_verifier=device_code,
- orca_cloud_pending_state=str(interval),
- orca_cloud_pending_at=when,
- )
- )
- await db.commit()
- return
- await _upsert_settings(
- db,
- {
- _SETTINGS_KEYS["pending_device_code"]: device_code,
- _SETTINGS_KEYS["pending_interval"]: str(interval),
- _SETTINGS_KEYS["pending_at"]: _iso(when),
- },
- )
- async def _clear_pending_device(db: AsyncSession, user: User | None) -> None:
- """Wipe just the pending device-code state (on terminal poll outcomes),
- leaving any existing tokens untouched."""
- if user is not None:
- await db.execute(
- update(User)
- .where(User.id == user.id)
- .values(
- orca_cloud_pending_verifier=None,
- orca_cloud_pending_state=None,
- orca_cloud_pending_at=None,
- )
- )
- await db.commit()
- return
- await _upsert_settings(
- db,
- {
- _SETTINGS_KEYS["pending_device_code"]: None,
- _SETTINGS_KEYS["pending_interval"]: None,
- _SETTINGS_KEYS["pending_at"]: None,
- },
- )
- async def _persist_tokens(
- db: AsyncSession,
- user: User | None,
- access_token: str,
- refresh_token: str | None,
- expires_at: datetime | None,
- email: str | None,
- user_id: str | None,
- ) -> None:
- """Atomically write the new access/refresh pair to whichever backing store
- the deployment uses. Also clears the pending device-code state on the same
- write, since by this point the pairing is complete."""
- if user is not None:
- await db.execute(
- update(User)
- .where(User.id == user.id)
- .values(
- orca_cloud_token=access_token,
- orca_cloud_refresh_token=refresh_token,
- orca_cloud_expires_at=expires_at,
- orca_cloud_email=email,
- orca_cloud_user_id=user_id,
- orca_cloud_pending_verifier=None,
- orca_cloud_pending_state=None,
- orca_cloud_pending_at=None,
- )
- )
- await db.commit()
- return
- await _upsert_settings(
- db,
- {
- _SETTINGS_KEYS["token"]: access_token,
- _SETTINGS_KEYS["refresh_token"]: refresh_token,
- _SETTINGS_KEYS["expires_at"]: _iso(expires_at),
- _SETTINGS_KEYS["email"]: email,
- _SETTINGS_KEYS["user_id"]: user_id,
- _SETTINGS_KEYS["pending_device_code"]: None,
- _SETTINGS_KEYS["pending_interval"]: None,
- _SETTINGS_KEYS["pending_at"]: None,
- },
- )
- async def _persist_rotated_tokens(
- db: AsyncSession,
- user: User | None,
- access_token: str,
- refresh_token: str | None,
- expires_at: datetime | None,
- ) -> None:
- """Persist tokens after a refresh — does NOT touch email/user_id and does
- NOT touch the pending state (refresh happens long after pairing)."""
- if user is not None:
- await db.execute(
- update(User)
- .where(User.id == user.id)
- .values(
- orca_cloud_token=access_token,
- orca_cloud_refresh_token=refresh_token,
- orca_cloud_expires_at=expires_at,
- )
- )
- await db.commit()
- return
- await _upsert_settings(
- db,
- {
- _SETTINGS_KEYS["token"]: access_token,
- _SETTINGS_KEYS["refresh_token"]: refresh_token,
- _SETTINGS_KEYS["expires_at"]: _iso(expires_at),
- },
- )
- async def _clear_credentials(db: AsyncSession, user: User | None) -> None:
- """Wipe everything Orca-related (tokens, identity, pending state)."""
- if user is not None:
- await db.execute(
- update(User)
- .where(User.id == user.id)
- .values(
- orca_cloud_token=None,
- orca_cloud_refresh_token=None,
- orca_cloud_expires_at=None,
- orca_cloud_email=None,
- orca_cloud_user_id=None,
- orca_cloud_pending_verifier=None,
- orca_cloud_pending_state=None,
- orca_cloud_pending_at=None,
- )
- )
- await db.commit()
- return
- result = await db.execute(select(Settings).where(Settings.key.in_(list(_SETTINGS_KEYS.values()))))
- for setting in result.scalars().all():
- await db.delete(setting)
- await db.commit()
- async def _upsert_settings(db: AsyncSession, values: dict[str, str | None]) -> None:
- """Idempotent upsert into the Settings table. ``None`` values delete the row."""
- keys = [k for k, _ in values.items()]
- result = await db.execute(select(Settings).where(Settings.key.in_(keys)))
- existing = {s.key: s for s in result.scalars().all()}
- for key, value in values.items():
- row = existing.get(key)
- if value is None:
- if row is not None:
- await db.delete(row)
- continue
- if row is not None:
- row.value = value
- else:
- db.add(Settings(key=key, value=value))
- await db.commit()
- # ---------------------------------------------------------------------------
- # Authenticated service builder with JIT refresh
- # ---------------------------------------------------------------------------
- async def _build_authenticated_service(
- db: AsyncSession,
- user: User | None,
- ) -> OrcaCloudService:
- """Construct an :class:`OrcaCloudService` pre-populated with stored
- credentials. If the access token is within the refresh-leeway of expiry,
- proactively refresh and persist the new pair BEFORE returning, so the
- next API call doesn't time out mid-flight on an expired token.
- We don't lock around the refresh: Orca tolerates concurrent refreshes for
- ~60s (each racer gets its own valid pair on the same connection rather than
- a revoke), so a lost race here is harmless — last-write-wins on the stored
- pair, and whichever pair we keep is valid."""
- creds = await _load_credentials(db, user)
- if not creds.token:
- raise HTTPException(status_code=401, detail="Orca Cloud is not connected — sign in first.")
- svc = OrcaCloudService()
- svc.set_tokens(creds.token, creds.refresh_token, creds.expires_at)
- if not svc.is_authenticated:
- if not svc.refresh_token:
- raise HTTPException(
- status_code=401,
- detail="Orca Cloud session expired and no refresh token is stored — sign in again.",
- )
- try:
- await svc.refresh()
- except OrcaCloudAuthError as e:
- # Refresh token was revoked or rotated out from under us. Clear
- # the stale credentials so the UI flips to disconnected.
- await _clear_credentials(db, user)
- raise HTTPException(status_code=401, detail=f"Orca Cloud session refresh failed: {e}") from e
- except OrcaCloudError as e:
- raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
- # Persist new pair BEFORE returning. A crash between here and the
- # downstream API call would still leave the user with valid stored
- # tokens for the next request.
- await _persist_rotated_tokens(db, user, svc.access_token, svc.refresh_token, svc.token_expiry)
- return svc
- # ---------------------------------------------------------------------------
- # Route handlers
- # ---------------------------------------------------------------------------
- @router.post("/device/start", response_model=OrcaDeviceStartResponse)
- async def device_start(
- request: Request,
- db: AsyncSession = Depends(get_db),
- current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
- ):
- """Begin device pairing. Requests a device code from Orca, stores it
- server-side (the device_code is a secret and never leaves the backend),
- and returns the user_code + verification URIs + poll interval for the
- frontend to display and poll against."""
- svc = OrcaCloudService()
- # instance_url/label are display-only anti-phishing context on the approval
- # card. base_url may be off behind a reverse proxy, but it's harmless if so.
- instance_url = str(request.base_url).rstrip("/") or None
- try:
- data = await svc.request_device_code(instance_url=instance_url, instance_label="Bambuddy")
- except OrcaCloudAuthError as e:
- # invalid_client etc. — an operator misconfiguration, not user error.
- raise HTTPException(status_code=502, detail=f"Orca Cloud pairing is misconfigured: {e}") from e
- except OrcaCloudError as e:
- raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
- device_code = data.get("device_code")
- user_code = data.get("user_code")
- if not device_code or not user_code:
- raise HTTPException(status_code=502, detail="Orca Cloud returned an incomplete device-code response.")
- interval = int(data.get("interval") or 5)
- expires_in = int(data.get("expires_in") or DEVICE_CODE_TTL.total_seconds())
- await _persist_pending_device(db, current_user, device_code, interval, datetime.now(timezone.utc))
- return OrcaDeviceStartResponse(
- user_code=user_code,
- verification_uri=str(data.get("verification_uri") or ""),
- verification_uri_complete=str(data.get("verification_uri_complete") or ""),
- interval=interval,
- expires_in=expires_in,
- )
- @router.post("/device/poll", response_model=OrcaDevicePollResponse)
- async def device_poll(
- db: AsyncSession = Depends(get_db),
- current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
- ):
- """Poll the token endpoint once for the in-flight pairing. Returns an
- in-progress status while the user approves; on approval persists the token
- pair (clearing the pending state) and reports connected."""
- creds = await _load_credentials(db, current_user)
- if not creds.pending_device_code or not creds.pending_at:
- raise HTTPException(
- status_code=400,
- detail="No pending Orca Cloud pairing. Click Connect first to start the flow.",
- )
- # creds.pending_at is already tz-aware UTC after _load_credentials' _as_utc
- # normalization. Subtracting two aware UTC datetimes gives a real delta.
- age = datetime.now(timezone.utc) - creds.pending_at
- if age > DEVICE_CODE_TTL:
- await _clear_pending_device(db, current_user)
- return OrcaDevicePollResponse(status=DevicePoll.EXPIRED, connected=False)
- svc = OrcaCloudService()
- try:
- status, token_data = await svc.poll_token(creds.pending_device_code)
- except OrcaCloudError as e:
- raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
- if status in DevicePoll.ONGOING:
- return OrcaDevicePollResponse(status=status, connected=False)
- if status in DevicePoll.TERMINAL:
- # access_denied / expired_token — the attempt is dead; clear it so the
- # user starts fresh next time.
- await _clear_pending_device(db, current_user)
- return OrcaDevicePollResponse(status=status, connected=False)
- # COMPLETE — tokens issued and applied to svc. Introspect for the user_id
- # (the external API's /me doesn't return an email, so email stays None).
- user_id: str | None = None
- try:
- info = await svc.introspect()
- if isinstance(info, dict):
- user_id = _str_or_none(info.get("user_id"))
- except OrcaCloudError as e:
- # Don't fail the whole pairing over the side introspection call — we
- # have valid tokens, which is the load-bearing part.
- logger.warning("Orca Cloud introspection failed after successful pairing: %s", e)
- await _persist_tokens(db, current_user, svc.access_token, svc.refresh_token, svc.token_expiry, None, user_id)
- return OrcaDevicePollResponse(status=DevicePoll.COMPLETE, connected=True, email=None, user_id=user_id)
- @router.get("/status", response_model=OrcaAuthStatusResponse)
- async def get_status(
- db: AsyncSession = Depends(get_db),
- current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
- ):
- """Return whether the caller has an Orca Cloud session stored, plus
- identifier details for display. Does NOT make a live API call."""
- creds = await _load_credentials(db, current_user)
- return OrcaAuthStatusResponse(
- connected=bool(creds.token),
- email=creds.email,
- user_id=creds.user_id,
- )
- @router.post("/logout")
- async def logout(
- db: AsyncSession = Depends(get_db),
- current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
- ):
- """Clear stored Orca Cloud credentials. Does not call Orca's disconnect
- endpoint (the user can revoke server-side from Orca Cloud's own settings;
- Bambuddy will no longer have the token to use either way)."""
- await _clear_credentials(db, current_user)
- return {"success": True}
- @router.get("/profiles", response_model=OrcaProfileListResponse)
- async def list_profiles(
- db: AsyncSession = Depends(get_db),
- current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
- ):
- """Return profile metadata grouped by type (``filament`` / ``printer``
- / ``process``), matching the ``SlicerSettingsResponse`` shape the
- Bambu Cloud tab consumes. This lets the frontend render Orca profiles
- with the same visual components — same cards, same filter bar, same
- grouping — without separate UI code paths."""
- svc = await _build_authenticated_service(db, current_user)
- try:
- raw_profiles = await svc.list_profiles()
- except OrcaCloudAuthError as e:
- raise HTTPException(status_code=401, detail=str(e)) from e
- except OrcaCloudError as e:
- raise HTTPException(status_code=502, detail=str(e)) from e
- grouped: dict[str, list[OrcaProfileMeta]] = {"filament": [], "printer": [], "process": []}
- # Log any unknown content.type values we silently drop, so a future
- # change in Orca's type vocabulary surfaces in the logs rather than
- # quietly losing profiles.
- unknown_types: dict[str, int] = {}
- for entry in raw_profiles:
- setting = _orca_to_setting(entry)
- if setting is None:
- content = entry.get("content") if isinstance(entry, dict) else None
- raw_type = (content.get("type") if isinstance(content, dict) else None) or "<missing>"
- unknown_types[str(raw_type)] = unknown_types.get(str(raw_type), 0) + 1
- continue
- grouped[setting.type].append(setting)
- if unknown_types:
- logger.warning(
- "Orca Cloud profile list dropped %d profiles with unmapped content.type values: %s",
- sum(unknown_types.values()),
- unknown_types,
- )
- return OrcaProfileListResponse(**grouped)
- @router.get("/profiles/{profile_id}", response_model=OrcaProfileDetail)
- async def get_profile(
- profile_id: str,
- db: AsyncSession = Depends(get_db),
- current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
- ):
- """Fetch a single profile's full content, shaped like
- ``SlicerSettingDetail`` so the Bambu Cloud detail modal can render it
- unchanged. The inner ``setting`` field is the raw slicer-format JSON
- Orca stores — same shape Bambu Cloud uses since OrcaSlicer is a
- BambuStudio fork."""
- svc = await _build_authenticated_service(db, current_user)
- try:
- profile = await svc.get_profile(profile_id)
- except OrcaCloudAuthError as e:
- raise HTTPException(status_code=401, detail=str(e)) from e
- except OrcaCloudError as e:
- if "not found" in str(e).lower():
- raise HTTPException(status_code=404, detail=str(e)) from e
- raise HTTPException(status_code=502, detail=str(e)) from e
- content = profile.get("content") if isinstance(profile, dict) else None
- if not isinstance(content, dict):
- content = {}
- orca_type = str(content.get("type", ""))
- bambu_type = _ORCA_TYPE_TO_BAMBU.get(orca_type, orca_type)
- update_time = profile.get("updated_time") if isinstance(profile, dict) else None
- return OrcaProfileDetail(
- setting_id=str(profile_id),
- name=str(profile.get("name") if isinstance(profile, dict) else "") or str(profile_id),
- type=bambu_type,
- version=_str_or_none(content.get("version")),
- base_id=_str_or_none(content.get("inherits") or content.get("base_id")),
- update_time=str(update_time) if update_time is not None else None,
- setting=content,
- )
|