Files
bambuddy/backend/app/services/preset_resolver.py
T
maziggy 61c15aac03 feat(slicer): unified Cloud/local/standard presets + harden 3MF profile path
UNIFIED PRESET LISTING (the main feature)

  The initial slicer integration only saw DB-backed local imports — users
  without imported profiles got an empty Slice modal even when their
  Bambu Cloud account or the slicer sidecar carried perfectly usable
  presets. The Slice modal now pulls from three tiers in priority order:

    - cloud:    user's own Bambu Cloud presets, fetched live.
    - local:    DB-backed imports.
    - standard: slicer-bundled stock profiles via the sidecar's new
                GET /profiles/bundled endpoint.

  Listing endpoint: GET /api/v1/slicer/presets

    - Name-based dedup, cloud > local > standard, within-tier order
      preserved exactly. A preset that exists in multiple tiers only
      renders in the highest-priority one.
    - cloud_status (ok / not_authenticated / expired / unreachable)
      drives a precise modal banner instead of an unexplained empty
      list.
    - Cloud branch: per-user cache, 5 min TTL, key
      (user_id, sha256(token)[:16]) so logout/login or token rotation
      auto-invalidates without callback wiring from the cloud-auth
      routes.
    - Bundled branch: global cache, 1 h TTL.
    - Bundled URL respects preferred_slicer (bambu_studio vs orcaslicer)
      so BambuStudio installs see the bambu sidecar's bundled list, not
      OrcaSlicer's.

  Slicing endpoint: POST /library/files/{id}/slice + /archives/{id}/slice

    - Body now accepts source-aware {source, id} triplets per slot:
        printer_preset:  PresetRef
        process_preset:  PresetRef
        filament_preset: PresetRef
    - Legacy *_preset_id integer fields kept for backwards-compat. The
      schema validator normalises bare ints into
      PresetRef(source='local', id=str(int)) so the route handler only
      deals with one shape.

  New preset_resolver service fetches the JSON content per source:

    - cloud:    BambuCloudService.get_setting_detail(id), unwraps the
                `setting` envelope (falls back to top-level for minor
                shape variants).
    - local:    DB read with preset_type slot validation (existing path,
                factored into the new helper).
    - standard: minimal {name, inherits, from: "system"} stub — the
                sidecar's profile-resolver flattens it against
                BUNDLED_PROFILES_PATH/<category>/<name>.json with no
                preset-content round-trip from Bambuddy.

  PERMISSIONS

    - Listing route gate: LIBRARY_UPLOAD (matches the slice action — any
      user who can slice can populate the dropdowns).
    - Cloud branch in BOTH the listing helper and the resolver checks
      CLOUD_AUTH independently — a user with LIBRARY_UPLOAD but not
      CLOUD_AUTH doesn't see the cloud tier (returns 403 if they try
      to slice with a cloud preset) even if a leftover User.cloud_token
      survived a permission revocation. Cloud listing path
      short-circuits the token lookup entirely on the gate-fail branch.

  FRONTEND — SliceModal

    - Calls api.getSlicerPresets() instead of api.getLocalPresets().
    - Dropdowns render <optgroup> per tier with localised section
      labels (Cloud / Imported / Standard).
    - Default selection follows cloud > local > standard priority on
      first load (auto-pick fires once when the data arrives, manual
      choices stick after that).
    - Cloud-status banner renders three variants
      (sign-in / expired / unreachable) only when status != 'ok'.
    - Slice button submits source-aware refs; legacy integer payload
      is preserved server-side for older clients.

  3MF PROFILE-PATH HARDENING (shipped together because they touch the
  same code paths)

  (1) Strip widened. _strip_3mf_embedded_settings only removed
      Metadata/project_settings.config. Real-world Bambu Studio /
      OrcaSlicer 3MFs also carry model_settings.config, slice_info.config,
      and cut_information.xml — any single leftover trips the CLI's
      input validation and the slice falls back to embedded settings,
      making the SliceModal's profile picker theatrical for 3MF inputs.
      Now removes all four configs via a centralised
      _STRIPPABLE_3MF_CONFIGS frozenset with per-file rationale;
      geometry (3D/3dmodel.model), thumbnails, multi-part data
      preserved.

  (2) Sidecar 5xx error capture. slicer_api.py was reading only
      `message` from sidecar 5xx responses and dropping `details`, so
      every CLI failure surfaced as the unhelpful generic
      "Failed to slice the model". New _format_sidecar_error helper
      combines both fields, falls back to plain-text body for
      non-JSON 5xx (nginx 502s, gateway timeouts), replaces the four
      duplicated extraction blocks. Pairs with the orca-slicer-api
      fork's bambuddy/profile-resolver branch which now emits
      `details` on AppError responses (d9c6121) and captures CLI
      stderr in the failure path (fb928c8).

  CARE TAKEN — additive on existing surfaces

    - main.py:               +1 import, +1 router register
    - slicer_api.py:         +list_bundled_profiles, +_format_sidecar_error
                             (dedupes the 4 message-extraction blocks);
                             no existing method behaviour changed
    - library.py:            resolver swap inside _run_slicer_with_fallback,
                             user_id threaded through two callers,
                             strip widened
    - schemas/slicer.py:     PresetRef added, *_preset fields added,
                             legacy *_preset_id kept; validator normalises
    - 4 new files:           schema, route, resolver, tests
    - No existing route URL changed, no existing field removed, no
      behaviour change for clients still sending bare integer ids.

  TESTS

    - 17 unit tests for the listing endpoint helpers
    - 11 unit tests for the source-aware resolver
    - 6 schema tests for SliceRequest legacy + new shapes
    - 3 unit tests for the new sidecar error-detail capture
    - Strip integration test extended to assert all 4 configs go and
      geometry stays
    - 12 frontend tests for SliceModal covering tier-priority
      auto-selection, <optgroup> grouping, fallback paths, source-aware
      payload on submit, manual override across tiers, archive vs
      library routing, error display, all three banner variants

  Verified: 3394 backend + 1531 frontend tests pass, ruff clean,
  frontend production build clean.

  Pairs with three already-pushed commits on the orca-slicer-api fork's
  bambuddy/profile-resolver branch:

    - 5fd6bc6  feat(profiles): add GET /profiles/bundled
    - d9c6121  fix(error): include causeMessage in JSON response as `details`
    - fb928c8  fix(slicing): include CLI stdout/stderr in failure causeMessage
2026-04-27 19:15:31 +02:00

170 lines
6.5 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.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,
)
logger = logging.getLogger(__name__)
_SLOT_TO_BUNDLED_CATEGORY = {
"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 == "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)
def _resolve_standard(ref: PresetRef, slot: str) -> str:
"""Build a minimal `{inherits: <name>}` 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",
}
)