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
This commit is contained in:
maziggy
2026-04-27 19:15:31 +02:00
parent 20b21f992b
commit 61c15aac03
16 changed files with 1912 additions and 136 deletions
+64 -19
View File
@@ -2471,16 +2471,51 @@ async def get_library_file_filament_requirements(
}
_STRIPPABLE_3MF_CONFIGS = frozenset(
{
# Settings dump used by --load-settings validation; the CLI tries to
# match its sentinel values (`prime_tower_brim_width: -1`, empty
# arrays) against the supplied profile and rejects out-of-range.
"Metadata/project_settings.config",
# Per-object settings overrides referencing the source plate's
# filament IDs / printer IDs. When the user picks a different
# printer / filament triplet, the IDs no longer resolve and the
# CLI exits non-zero on input validation.
"Metadata/model_settings.config",
# Slicer-version + plate-config + filament-mapping snapshot from
# the original slice. Includes the original printer model and
# filament references; mismatches against `--load-settings`
# consistently surfaced as `Slicer CLI failed (500)` for every
# 3MF in production. Removing it lets the CLI build a fresh slice
# plan from the supplied profile triplet.
"Metadata/slice_info.config",
# Multi-part / split-mesh metadata referencing object IDs from the
# original slice. Strip for the same reason — preserves the geometry
# in `3D/3dmodel.model` while dropping the orphan references.
"Metadata/cut_information.xml",
}
)
def _strip_3mf_embedded_settings(zip_bytes: bytes) -> bytes:
"""Remove ``Metadata/project_settings.config`` from a 3MF.
"""Remove embedded slicer-config metadata from a 3MF.
Bambuddy supplies the slicer profile triplet via the sidecar's
``--load-settings`` path; the 3MF's embedded settings would otherwise be
validated by the CLI first and can fail with sentinel-value range
checks (`prime_tower_brim_width: -1 not in range`, etc.) regardless of
what we pass via ``--load-settings``. Stripping the embedded config
forces the CLI to use the supplied profiles only. Geometry, color, and
multi-part data inside the 3MF are preserved.
what we pass via ``--load-settings``. Stripping the embedded configs
forces the CLI to use the supplied profiles only. Geometry
(``3D/3dmodel.model``), thumbnails, color, and multi-part data inside
the 3MF are preserved.
The set of strippable filenames is centralised in
``_STRIPPABLE_3MF_CONFIGS`` — see that constant for the per-file
rationale. Project-settings alone wasn't enough: real-world Bambu
Studio 3MFs cross-reference printer / filament IDs from the other
metadata configs, and any single leftover triggered the validation
failure that made every profile-driven slice fall back to embedded
settings.
"""
from io import BytesIO
@@ -2488,7 +2523,7 @@ def _strip_3mf_embedded_settings(zip_bytes: bytes) -> bytes:
dst = BytesIO()
with zipfile.ZipFile(src, "r") as zin, zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
if item.filename == "Metadata/project_settings.config":
if item.filename in _STRIPPABLE_3MF_CONFIGS:
continue
zout.writestr(item, zin.read(item.filename))
return dst.getvalue()
@@ -2500,14 +2535,19 @@ async def _run_slicer_with_fallback(
model_bytes: bytes,
model_filename: str,
request: SliceRequest,
current_user_id: int | None = None,
):
"""Validate presets, dispatch to the right sidecar, run the slicer with
the auto-fallback for 3MF inputs whose `--load-settings` path crashes the
CLI. Returns ``(SliceResult, used_embedded_settings: bool)``. Raises
``HTTPException`` for any caller-facing error.
`current_user_id` is needed to resolve **cloud** presets — the cloud token
is per-user when auth is enabled. For the legacy / local-only path it can
be left ``None``.
"""
from backend.app.api.routes.settings import get_setting
from backend.app.models.local_preset import LocalPreset
from backend.app.services.preset_resolver import resolve_preset_ref
from backend.app.services.slicer_api import (
SlicerApiServerError,
SlicerApiService,
@@ -2515,20 +2555,23 @@ async def _run_slicer_with_fallback(
SlicerInputError,
)
# Profile triplet — every slot must match the expected preset_type
# Resolve each slot via the source-aware resolver. The schema validator
# has already normalised legacy `*_preset_id: int` fields into
# `PresetRef(source='local', id=str(int))`, so all three are guaranteed
# non-None here.
user: User | None = None
if current_user_id is not None:
user = await db.get(User, current_user_id)
presets: dict[str, str] = {}
for pid, expected_type, key in (
(request.printer_preset_id, "printer", "printer"),
(request.process_preset_id, "process", "process"),
(request.filament_preset_id, "filament", "filament"),
):
preset = await db.get(LocalPreset, pid)
if preset is None or preset.preset_type != expected_type:
raise HTTPException(
status_code=400,
detail=f"Invalid {key} preset id (expected preset_type='{expected_type}')",
)
presets[key] = preset.setting
refs = {
"printer": request.printer_preset,
"process": request.process_preset,
"filament": request.filament_preset,
}
for slot, ref in refs.items():
assert ref is not None, "schema validator guarantees PresetRef is set"
presets[slot] = await resolve_preset_ref(db, user, ref, slot)
# Slicer routing — pick the sidecar URL by preferred_slicer.
# The per-install URL setting (Settings UI → Slicer card) wins; an
@@ -2621,6 +2664,7 @@ async def slice_and_persist(
model_bytes=model_bytes,
model_filename=model_filename,
request=library_request,
current_user_id=current_user_id,
)
base_name = model_filename.rsplit(".", 1)[0]
@@ -2728,6 +2772,7 @@ async def slice_and_persist_as_archive(
model_bytes=model_bytes,
model_filename=model_filename,
request=archive_request,
current_user_id=current_user_id,
)
base_name = model_filename.rsplit(".", 1)[0]
+282
View File
@@ -0,0 +1,282 @@
"""Unified slicer-preset listing for the SliceModal (#wiki / Cloud-aware presets).
Returns the printer/process/filament options grouped by source tier in
priority order — cloud (per-user, live-fetched) > local (DB-backed
imports) > standard (slicer-bundled stock fallback). Name-based dedup is
applied so a preset that exists in multiple tiers only appears in the
highest-priority one. Cloud failure modes (signed out / expired / network)
are surfaced via a status field so the modal can render a precise banner
without faking an "ok with empty list" response.
"""
from __future__ import annotations
import hashlib
import logging
import time
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.api.routes.cloud import get_stored_token
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.config import settings as app_settings
from backend.app.core.database import get_db
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_presets import (
UnifiedPreset,
UnifiedPresetsBySlot,
UnifiedPresetsResponse,
)
from backend.app.services.bambu_cloud import (
BambuCloudAuthError,
BambuCloudError,
BambuCloudService,
)
from backend.app.services.slicer_api import SlicerApiError, SlicerApiService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/slicer", tags=["Slicer Presets"])
# In-process cache for the bundled-profile list. The slicer sidecar walks a
# read-only filesystem inside its own container, so the list only changes
# across sidecar rebuilds — a long TTL is safe and avoids a sidecar round-trip
# on every modal open. Per-user cache is unnecessary because bundled profiles
# are global.
_BUNDLED_TTL_S = 3600.0
_bundled_cache: tuple[float, dict[str, list[UnifiedPreset]]] | None = None
# Per-user cache for the cloud preset list. Cache key is (user_id, token_hash):
# keying on the token hash means a logout/login or token-change automatically
# invalidates the entry without needing the cloud-auth route handlers to call
# back into this module. 5 minutes balances "users see their freshly-saved
# presets quickly" against "a busy install doesn't hit the cloud once per
# modal open per user".
_CLOUD_TTL_S = 300.0
_cloud_cache: dict[tuple[int, str], tuple[float, dict[str, list[UnifiedPreset]]]] = {}
def _token_fingerprint(token: str) -> str:
"""Short stable hash of the cloud token for use as a cache-key component.
Storing only the hash means we can safely keep multiple per-(user, token)
entries without leaking the token via the in-process dict."""
return hashlib.sha256(token.encode("utf-8")).hexdigest()[:16]
_CLOUD_TYPE_TO_SLOT = {
"filament": "filament",
"printer": "printer",
"print": "process", # Bambu Cloud calls process presets "print"
}
def _empty_slots() -> dict[str, list[UnifiedPreset]]:
return {"printer": [], "process": [], "filament": []}
async def _fetch_cloud_presets(db: AsyncSession, user: User | None) -> tuple[dict[str, list[UnifiedPreset]], str]:
"""Return (slots, cloud_status). Slots are empty when cloud_status != 'ok'.
Defence-in-depth: even if a stored cloud_token survived a permission
revocation (admin reset, legacy state), users without ``CLOUD_AUTH`` are
treated as not-authenticated for this endpoint — the cloud tier never
surfaces for them. This keeps the per-tier visibility consistent with the
/cloud/* endpoint suite that already gates on CLOUD_AUTH.
"""
if user is not None and not user.has_permission(Permission.CLOUD_AUTH.value):
return _empty_slots(), "not_authenticated"
token, _email, region = await get_stored_token(db, user)
if not token:
return _empty_slots(), "not_authenticated"
user_key = user.id if user is not None else 0
cache_key = (user_key, _token_fingerprint(token))
now = time.monotonic()
cached = _cloud_cache.get(cache_key)
if cached and now - cached[0] < _CLOUD_TTL_S:
return cached[1], "ok"
cloud = BambuCloudService(region=region)
cloud.set_token(token)
try:
raw = await cloud.get_slicer_settings()
except BambuCloudAuthError:
# Don't clear the token here — the cloud-status endpoint owns that
# lifecycle. Just report expired so the UI can prompt re-auth.
return _empty_slots(), "expired"
except BambuCloudError as e:
logger.warning("Cloud preset fetch failed for user %s: %s", user_key, e)
return _empty_slots(), "unreachable"
except Exception as e: # noqa: BLE001 — defensive: never crash the modal
logger.warning("Cloud preset fetch unexpected error for user %s: %s", user_key, e)
return _empty_slots(), "unreachable"
finally:
await cloud.close()
slots = _empty_slots()
for cloud_type, slot in _CLOUD_TYPE_TO_SLOT.items():
type_data = raw.get(cloud_type, {})
# The cloud splits presets into "private" (the user's own) and "public"
# (Bambu's stock cloud presets). Both are valid choices — surface them
# in the natural order private → public so a user's customisations
# appear above the stock entries with the same names. Stock entries
# that share names with private ones get deduped out within the cloud
# tier itself.
seen_names: set[str] = set()
for entry in type_data.get("private", []) + type_data.get("public", []):
name = entry.get("name")
setting_id = entry.get("setting_id") or entry.get("id")
if not name or not setting_id or name in seen_names:
continue
seen_names.add(name)
slots[slot].append(UnifiedPreset(id=setting_id, name=name, source="cloud"))
_cloud_cache[cache_key] = (now, slots)
return slots, "ok"
async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset]]:
"""Local imports — no caching needed, single indexed DB read."""
result = await db.execute(select(LocalPreset).order_by(LocalPreset.name))
presets = result.scalars().all()
slots = _empty_slots()
type_to_slot = {"filament": "filament", "printer": "printer", "process": "process"}
for p in presets:
slot = type_to_slot.get(p.preset_type)
if slot is None:
continue
slots[slot].append(UnifiedPreset(id=str(p.id), name=p.name, source="local"))
return slots
async def _fetch_bundled_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset]]:
"""Standard slicer-bundled profiles via the sidecar's /profiles/bundled."""
global _bundled_cache
now = time.monotonic()
if _bundled_cache and now - _bundled_cache[0] < _BUNDLED_TTL_S:
return _bundled_cache[1]
api_url = await _resolve_slicer_api_url(db)
if not api_url:
# No sidecar configured at all — return empty rather than caching, so
# users who configure one mid-session see results on next open.
return _empty_slots()
try:
async with SlicerApiService(base_url=api_url) as svc:
raw = await svc.list_bundled_profiles()
except SlicerApiError as e:
logger.info("Bundled preset fetch from sidecar at %s failed: %s", api_url, e)
return _empty_slots()
except Exception as e: # noqa: BLE001 — never break the modal on sidecar issues
logger.warning("Bundled preset fetch unexpected error: %s", e)
return _empty_slots()
slots = _empty_slots()
for slot in ("printer", "process", "filament"):
for entry in raw.get(slot, []) or []:
name = entry.get("name")
if not name:
continue
# Bundled presets are addressed by name (the slicer resolves them
# by name during the `inherits:` walk), so name doubles as id.
slots[slot].append(UnifiedPreset(id=name, name=name, source="standard"))
_bundled_cache = (now, slots)
return slots
async def _resolve_slicer_api_url(db: AsyncSession) -> str | None:
"""Pick the sidecar URL the bundled-listing fetch should hit.
Mirrors the slice route's resolution at ``library.py:_run_slicer_with_fallback``:
the user's ``preferred_slicer`` setting decides which sidecar Bambuddy
talks to, and the per-install URL setting overrides the env default.
A user who prefers Bambu Studio gets the *bambu-studio-api* sidecar's
bundled list; a user who prefers OrcaSlicer gets the *orca-slicer-api*
sidecar's bundled list. Without this branch the listing would always
hit OrcaSlicer (port 3003) even for BambuStudio installs (port 3001),
leaving the Standard tier permanently empty for them.
"""
from backend.app.api.routes.settings import get_setting
preferred = (await get_setting(db, "preferred_slicer")) or "bambu_studio"
if preferred == "orcaslicer":
configured = await get_setting(db, "orcaslicer_api_url")
url = (configured or app_settings.slicer_api_url).strip()
elif preferred == "bambu_studio":
configured = await get_setting(db, "bambu_studio_api_url")
url = (configured or app_settings.bambu_studio_api_url).strip()
else:
# Unknown preference — return None so the bundled tier is empty
# rather than crashing the modal. The slice route raises 400 here;
# we degrade silently because the modal's listing is informational.
logger.warning("Unknown preferred_slicer setting: %r — bundled tier disabled", preferred)
return None
return url or None
def _dedupe_by_name(
cloud: dict[str, list[UnifiedPreset]],
local: dict[str, list[UnifiedPreset]],
standard: dict[str, list[UnifiedPreset]],
) -> tuple[
dict[str, list[UnifiedPreset]],
dict[str, list[UnifiedPreset]],
dict[str, list[UnifiedPreset]],
]:
"""Filter so each preset name appears in exactly one tier (cloud > local > standard).
Order within each tier is preserved as-is — only "lower-priority duplicates"
are dropped. A preset shared across tiers (e.g. "Bambu PLA Basic" in cloud
public AND standard bundled) only renders once, in the cloud tier.
"""
deduped_local = _empty_slots()
deduped_standard = _empty_slots()
for slot in ("printer", "process", "filament"):
seen = {p.name for p in cloud[slot]}
for p in local[slot]:
if p.name in seen:
continue
deduped_local[slot].append(p)
seen.add(p.name)
for p in standard[slot]:
if p.name in seen:
continue
deduped_standard[slot].append(p)
seen.add(p.name)
return cloud, deduped_local, deduped_standard
@router.get("/presets", response_model=UnifiedPresetsResponse)
async def list_unified_presets(
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
) -> UnifiedPresetsResponse:
"""List slicer presets across cloud / local / standard tiers, deduped by name.
Drives the SliceModal preset dropdowns. Permission gate matches the
slice action itself (``LIBRARY_UPLOAD``) so any user who can slice can
see the preset options for the dialog. The cloud branch is independently
gated on ``CLOUD_AUTH`` inside ``_fetch_cloud_presets`` so a user with
only ``LIBRARY_UPLOAD`` doesn't see cloud presets they shouldn't have
access to.
"""
cloud, cloud_status = await _fetch_cloud_presets(db, current_user)
local = await _fetch_local_presets(db)
standard = await _fetch_bundled_presets(db)
cloud, local, standard = _dedupe_by_name(cloud, local, standard)
return UnifiedPresetsResponse(
cloud=UnifiedPresetsBySlot(**cloud),
local=UnifiedPresetsBySlot(**local),
standard=UnifiedPresetsBySlot(**standard),
cloud_status=cloud_status,
)
+2
View File
@@ -48,6 +48,7 @@ from backend.app.api.routes import (
projects,
settings as settings_routes,
slice_jobs,
slicer_presets,
smart_plugs,
spoolbuddy,
spoolman,
@@ -4799,6 +4800,7 @@ app.include_router(projects.router, prefix=app_settings.api_prefix)
app.include_router(library.router, prefix=app_settings.api_prefix)
app.include_router(library_trash.router, prefix=app_settings.api_prefix)
app.include_router(slice_jobs.router, prefix=app_settings.api_prefix)
app.include_router(slicer_presets.router, prefix=app_settings.api_prefix)
app.include_router(archive_purge.router, prefix=app_settings.api_prefix)
app.include_router(makerworld.router, prefix=app_settings.api_prefix)
app.include_router(api_keys.router, prefix=app_settings.api_prefix)
+69 -5
View File
@@ -1,14 +1,58 @@
"""Pydantic schemas for slice requests."""
from pydantic import BaseModel, Field
from typing import Literal
from pydantic import BaseModel, Field, model_validator
class PresetRef(BaseModel):
"""A source-aware reference to a printer / process / filament preset.
The SliceModal pulls dropdown options from three tiers (cloud / local /
standard). At submit time the client sends one of these per slot so the
backend knows where to fetch the preset content from at slice time.
"""
source: Literal["cloud", "local", "standard"]
id: str = Field(..., description=("Cloud setting_id, local DB row id (stringified), or standard preset name."))
class SliceRequest(BaseModel):
"""Body for `POST /library/files/{file_id}/slice`."""
"""Body for `POST /library/files/{file_id}/slice`.
Two preset shapes are accepted per slot for backwards-compatibility:
- **Legacy** — bare integer ``*_preset_id`` fields point into the
``local_presets`` table. Existing clients (and stale browser tabs after
a Bambuddy upgrade) keep working unchanged.
- **Source-aware** — ``*_preset`` carries an explicit
``{source, id}``. Required for cloud / standard tiers; also accepted
(and equivalent) for local presets when the client is on the new modal.
Exactly one of each pair must be set; the validator normalises legacy
integer ids into a ``PresetRef(source='local', id=str(id))`` so the
downstream resolver only deals with one shape.
"""
# Legacy fields — kept optional so older clients continue to work.
printer_preset_id: int | None = Field(
default=None,
description="DEPRECATED: prefer printer_preset. LocalPreset id with preset_type='printer'.",
)
process_preset_id: int | None = Field(
default=None,
description="DEPRECATED: prefer process_preset. LocalPreset id with preset_type='process'.",
)
filament_preset_id: int | None = Field(
default=None,
description="DEPRECATED: prefer filament_preset. LocalPreset id with preset_type='filament'.",
)
# Source-aware fields — set by the new SliceModal.
printer_preset: PresetRef | None = None
process_preset: PresetRef | None = None
filament_preset: PresetRef | None = None
printer_preset_id: int = Field(..., description="LocalPreset id with preset_type='printer'")
process_preset_id: int = Field(..., description="LocalPreset id with preset_type='process'")
filament_preset_id: int = Field(..., description="LocalPreset id with preset_type='filament'")
plate: int | None = Field(
default=None,
ge=1,
@@ -19,6 +63,26 @@ class SliceRequest(BaseModel):
description="If true, request a 3MF response with embedded G-code instead of raw G-code.",
)
@model_validator(mode="after")
def normalise_preset_refs(self) -> "SliceRequest":
"""Each slot must end up with a `PresetRef` set. Legacy integer ids
become `(source='local', id=str(int))` so the route handler only
deals with the canonical shape."""
for slot, ref_attr, legacy_attr in (
("printer", "printer_preset", "printer_preset_id"),
("process", "process_preset", "process_preset_id"),
("filament", "filament_preset", "filament_preset_id"),
):
ref = getattr(self, ref_attr)
legacy_id = getattr(self, legacy_attr)
if ref is None and legacy_id is None:
raise ValueError(
f"{slot} preset is required: provide '{ref_attr}' (preferred) or legacy '{legacy_attr}'"
)
if ref is None:
setattr(self, ref_attr, PresetRef(source="local", id=str(legacy_id)))
return self
class SliceResponse(BaseModel):
"""Response from `POST /library/files/{file_id}/slice`. The result lands
+59
View File
@@ -0,0 +1,59 @@
"""Pydantic schemas for the unified slicer-presets endpoint.
The SliceModal pulls printer/process/filament options from three sources, in
priority order: cloud (the user's Bambu Cloud account), local (DB-backed
imported profiles), and standard (slicer-bundled stock profiles). The endpoint
returns all three lists with name-based dedup applied so each preset appears
exactly once across the response.
"""
from typing import Literal
from pydantic import BaseModel
CloudStatus = Literal["ok", "not_authenticated", "expired", "unreachable"]
class UnifiedPreset(BaseModel):
"""A single printer/process/filament preset with its source.
The ``id`` shape varies by source:
- cloud → Bambu Cloud setting_id (e.g. ``"PFUS9ac902733670a9"``)
- local → stringified DB row id from ``local_presets``
- standard → preset name as written in the bundled JSON (the slicer
resolves bundled profiles by name during inheritance walk)
The frontend treats ``id`` as opaque; the slice dispatch path uses
``(source, id)`` to fetch / pass the preset content to the sidecar.
"""
id: str
name: str
source: Literal["cloud", "local", "standard"]
class UnifiedPresetsBySlot(BaseModel):
"""Three slots in the order Bambu Studio / OrcaSlicer use."""
printer: list[UnifiedPreset] = []
process: list[UnifiedPreset] = []
filament: list[UnifiedPreset] = []
class UnifiedPresetsResponse(BaseModel):
"""Each tier carries only the names that didn't appear in a higher tier.
Cloud is the highest priority (user's personal customisations win), then
the local imports the user explicitly curated, then the slicer's stock
fallback. A name that appears in cloud is filtered out of local and
standard; a name that appears in local is filtered out of standard.
``cloud_status`` lets the frontend show a banner explaining why the cloud
tier is empty when the user expected to see it (signed out / token
expired / network down).
"""
cloud: UnifiedPresetsBySlot = UnifiedPresetsBySlot()
local: UnifiedPresetsBySlot = UnifiedPresetsBySlot()
standard: UnifiedPresetsBySlot = UnifiedPresetsBySlot()
cloud_status: CloudStatus = "ok"
+169
View File
@@ -0,0 +1,169 @@
"""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",
}
)
+49 -20
View File
@@ -48,6 +48,30 @@ class SliceResult(NamedTuple):
_shared_http_client: httpx.AsyncClient | None = None
def _format_sidecar_error(response: httpx.Response) -> str:
"""Build a human-readable error string from a sidecar 4xx/5xx response.
The sidecar's `AppError` middleware emits a JSON body of the shape
``{"message": "...", "details": "..."}``. Earlier versions of this
client only read ``message``, which left every CLI failure surfaced
as the generic ``Failed to slice the model`` because the *actual*
CLI stderr / `error_string` lives in ``details``. Including both
means ``bambuddy.log`` carries the real reason a slice rejected
the supplied profiles instead of an unhelpful generic line.
"""
try:
payload = response.json()
except Exception:
return response.text[:500]
if not isinstance(payload, dict):
return str(payload)[:500]
message = payload.get("message") or ""
details = payload.get("details") or ""
if message and details:
return f"{message}: {details}"[:500]
return (message or details or response.text)[:500]
def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
"""Register an app-scoped client so per-request services can pool transport."""
global _shared_http_client
@@ -108,6 +132,27 @@ class SlicerApiService:
raise SlicerApiUnavailableError(f"Slicer sidecar /health returned {response.status_code}")
return response.json()
async def list_bundled_profiles(self) -> dict:
"""GET /profiles/bundled — return the slicer's stock profiles by slot.
Powers the "Standard" tier of Bambuddy's SliceModal preset dropdowns.
The sidecar walks the slicer's read-only `resources/profiles/BBL/`
tree and returns ``{printer, process, filament}`` arrays of
``{name, base_id}`` (alphabetised, instantiable presets only — abstract
bases like `fdm_filament_pla` are filtered out by the sidecar).
Returns an empty-shaped dict when the sidecar is unreachable so the
unified-presets endpoint can degrade to "no standard tier" without
crashing the modal — cloud + local-imported profiles still render.
"""
try:
response = await self._client.get(f"{self.base_url}/profiles/bundled", timeout=10.0)
except httpx.RequestError as exc:
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
if response.status_code >= 400:
raise SlicerApiUnavailableError(f"Slicer sidecar /profiles/bundled returned {response.status_code}")
return response.json()
async def slice_with_profiles(
self,
*,
@@ -148,17 +193,9 @@ class SlicerApiService:
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
if response.status_code >= 500:
try:
msg = response.json().get("message", "")
except Exception:
msg = response.text
raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {msg[:500]}")
raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {_format_sidecar_error(response)}")
if response.status_code >= 400:
try:
msg = response.json().get("message", "")
except Exception:
msg = response.text
raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {msg[:500]}")
raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {_format_sidecar_error(response)}")
return SliceResult(
content=response.content,
@@ -203,17 +240,9 @@ class SlicerApiService:
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
if response.status_code >= 500:
try:
msg = response.json().get("message", "")
except Exception:
msg = response.text
raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {msg[:500]}")
raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {_format_sidecar_error(response)}")
if response.status_code >= 400:
try:
msg = response.json().get("message", "")
except Exception:
msg = response.text
raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {msg[:500]}")
raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {_format_sidecar_error(response)}")
return SliceResult(
content=response.content,
@@ -35,8 +35,14 @@ from backend.app.services.slice_dispatch import slice_dispatch
def _make_3mf_with_settings(settings_payload: dict | None = None) -> bytes:
"""Build a tiny in-memory 3MF zip that has a `Metadata/project_settings.config`
entry. Used to verify the strip-before-forwarding behavior."""
"""Build a tiny in-memory 3MF zip with all the embedded-config files
that real-world Bambu Studio / OrcaSlicer 3MFs ship with.
The strip-before-forwarding helper has to remove ALL of these (not
just `project_settings.config`) — leftover entries reference printer
/ filament IDs from the original slice and trip the CLI's input
validation when a different `--load-settings` triplet is supplied.
"""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("3D/3dmodel.model", "<model/>")
@@ -44,6 +50,12 @@ def _make_3mf_with_settings(settings_payload: dict | None = None) -> bytes:
"Metadata/project_settings.config",
json.dumps(settings_payload or {"prime_tower_brim_width": "-1"}),
)
zf.writestr("Metadata/model_settings.config", "<config><object id='1'/></config>")
zf.writestr(
"Metadata/slice_info.config",
"<config><plate><metadata key='filament' value='GFL00'/></plate></config>",
)
zf.writestr("Metadata/cut_information.xml", "<cut><part id='1'/></cut>")
return buf.getvalue()
@@ -420,13 +432,19 @@ class TestSliceLibraryFile:
assert final["status"] == "completed", final
# Recover the embedded zip from the multipart body — the strip
# removed Metadata/project_settings.config but kept geometry.
# must remove every config that references the original slice's
# printer / filament IDs (otherwise the CLI's input validation
# rejects the new --load-settings triplet, the slice fails, and
# we drop into the embedded-settings fallback). Geometry stays.
body = captured["body"]
pk = body.find(b"PK\x03\x04")
assert pk >= 0, "3MF body not found in multipart payload"
with zipfile.ZipFile(io.BytesIO(body[pk:]), "r") as zin:
names = set(zin.namelist())
assert "Metadata/project_settings.config" not in names
assert "Metadata/model_settings.config" not in names
assert "Metadata/slice_info.config" not in names
assert "Metadata/cut_information.xml" not in names
assert "3D/3dmodel.model" in names
@@ -0,0 +1,217 @@
"""Tests for the source-aware preset resolver used by the slice route."""
from __future__ import annotations
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from backend.app.schemas.slicer import PresetRef
from backend.app.services import preset_resolver
# --- standard tier --------------------------------------------------------
def test_standard_emits_inherits_stub():
"""Standard tier returns a JSON stub the sidecar's resolver can flatten
against `BUNDLED_PROFILES_PATH/<category>/<name>.json`. No content
round-trip needed — the sidecar reads the bundled JSON itself."""
out = preset_resolver._resolve_standard(
PresetRef(source="standard", id="Bambu Lab X1 Carbon 0.4 nozzle"),
slot="printer",
)
payload = json.loads(out)
assert payload == {
"name": "Bambu Lab X1 Carbon 0.4 nozzle",
"inherits": "Bambu Lab X1 Carbon 0.4 nozzle",
# `from: "system"` so the sidecar's compatibility check doesn't
# treat this as a User-authored profile and reject it against
# system filament/process pairs.
"from": "system",
}
def test_standard_rejects_unknown_slot():
with pytest.raises(HTTPException) as exc:
preset_resolver._resolve_standard(PresetRef(source="standard", id="anything"), slot="bogus")
assert exc.value.status_code == 400
# --- local tier -----------------------------------------------------------
@pytest.mark.asyncio
async def test_local_returns_setting_blob():
db = MagicMock()
preset = MagicMock()
preset.preset_type = "filament"
preset.setting = '{"name": "PLA Basic"}'
db.get = AsyncMock(return_value=preset)
out = await preset_resolver._resolve_local(db, PresetRef(source="local", id="42"), slot="filament")
assert out == '{"name": "PLA Basic"}'
db.get.assert_awaited_once()
@pytest.mark.asyncio
async def test_local_rejects_non_integer_id():
db = MagicMock()
db.get = AsyncMock()
with pytest.raises(HTTPException) as exc:
await preset_resolver._resolve_local(db, PresetRef(source="local", id="not-a-number"), slot="filament")
assert exc.value.status_code == 400
db.get.assert_not_awaited()
@pytest.mark.asyncio
async def test_local_rejects_wrong_preset_type():
"""A `local` ref pointing at a process preset for the filament slot
must fail — same guard the legacy slice path had."""
db = MagicMock()
preset = MagicMock()
preset.preset_type = "process"
db.get = AsyncMock(return_value=preset)
with pytest.raises(HTTPException) as exc:
await preset_resolver._resolve_local(db, PresetRef(source="local", id="1"), slot="filament")
assert exc.value.status_code == 400
assert "preset_type='filament'" in exc.value.detail
# --- cloud tier -----------------------------------------------------------
@pytest.mark.asyncio
async def test_cloud_blocks_user_without_cloud_auth():
"""Defence-in-depth: a user holding LIBRARY_UPLOAD but not CLOUD_AUTH
cannot slice with cloud presets even if their User row carries a
leftover cloud_token from a previous permission state."""
db = MagicMock()
user = MagicMock()
user.has_permission = MagicMock(return_value=False)
with pytest.raises(HTTPException) as exc:
await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
assert exc.value.status_code == 403
@pytest.mark.asyncio
async def test_cloud_400_when_no_token_stored():
db = MagicMock()
user = MagicMock()
user.has_permission = MagicMock(return_value=True)
with (
patch.object(
preset_resolver,
"get_stored_token",
AsyncMock(return_value=(None, None, None)),
),
pytest.raises(HTTPException) as exc,
):
await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
assert exc.value.status_code == 400
assert "Sign in" in exc.value.detail
@pytest.mark.asyncio
async def test_cloud_unwraps_setting_envelope():
"""Bambu Cloud's `get_setting_detail` returns the preset wrapped under
`.setting`; the sidecar wants the inner content, not the envelope."""
db = MagicMock()
user = MagicMock()
user.has_permission = MagicMock(return_value=True)
cloud_mock = MagicMock()
cloud_mock.set_token = MagicMock()
cloud_mock.get_setting_detail = AsyncMock(
return_value={
"setting_id": "PFU123",
"name": "X1C Custom",
"setting": {"name": "X1C Custom", "nozzle_diameter": [0.4]},
}
)
cloud_mock.close = AsyncMock()
with (
patch.object(
preset_resolver,
"get_stored_token",
AsyncMock(return_value=("tok", "e@x", "global")),
),
patch.object(preset_resolver, "BambuCloudService", return_value=cloud_mock),
):
out = await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
payload = json.loads(out)
assert payload == {"name": "X1C Custom", "nozzle_diameter": [0.4]}
cloud_mock.close.assert_awaited_once()
@pytest.mark.asyncio
async def test_cloud_falls_back_to_top_level_when_no_envelope():
"""If a cloud response doesn't nest under `.setting` (rare but seen on
some endpoints), forward the whole payload rather than failing — the
sidecar will reject malformed content cleanly."""
db = MagicMock()
user = MagicMock()
user.has_permission = MagicMock(return_value=True)
cloud_mock = MagicMock()
cloud_mock.set_token = MagicMock()
cloud_mock.get_setting_detail = AsyncMock(return_value={"name": "X1C Custom", "nozzle_diameter": [0.4]})
cloud_mock.close = AsyncMock()
with (
patch.object(
preset_resolver,
"get_stored_token",
AsyncMock(return_value=("tok", None, "global")),
),
patch.object(preset_resolver, "BambuCloudService", return_value=cloud_mock),
):
out = await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
payload = json.loads(out)
assert "name" in payload
@pytest.mark.asyncio
async def test_cloud_auth_error_returns_401():
db = MagicMock()
user = MagicMock()
user.has_permission = MagicMock(return_value=True)
cloud_mock = MagicMock()
cloud_mock.set_token = MagicMock()
cloud_mock.get_setting_detail = AsyncMock(side_effect=preset_resolver.BambuCloudAuthError("expired"))
cloud_mock.close = AsyncMock()
with (
patch.object(
preset_resolver,
"get_stored_token",
AsyncMock(return_value=("tok", None, "global")),
),
patch.object(preset_resolver, "BambuCloudService", return_value=cloud_mock),
pytest.raises(HTTPException) as exc,
):
await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
assert exc.value.status_code == 401
# --- top-level dispatcher -------------------------------------------------
@pytest.mark.asyncio
async def test_resolve_preset_ref_dispatches_by_source():
"""The public entrypoint just routes to the right tier-specific
helper. Verify each branch is selected correctly."""
db = MagicMock()
user = MagicMock()
user.has_permission = MagicMock(return_value=True)
preset = MagicMock()
preset.preset_type = "printer"
preset.setting = '{"local": true}'
db.get = AsyncMock(return_value=preset)
# local
out = await preset_resolver.resolve_preset_ref(db, user, PresetRef(source="local", id="1"), slot="printer")
assert out == '{"local": true}'
# standard
out = await preset_resolver.resolve_preset_ref(
db, user, PresetRef(source="standard", id="Some Bundled Name"), slot="printer"
)
assert json.loads(out)["inherits"] == "Some Bundled Name"
@@ -129,6 +129,79 @@ class TestSliceWithProfiles:
)
assert "Failed to slice the model" in str(exc_info.value)
@pytest.mark.asyncio
async def test_5xx_includes_sidecar_details_field(self):
"""Sidecar's AppError emits ``{message, details}`` — both must end up
in the raised error so ``bambuddy.log`` carries the actual CLI
rejection reason instead of just the generic outer message.
Pinned to fix the regression where every 3MF slice surfaced as
the unhelpful ``Failed to slice the model`` line in production."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
status_code=500,
json={
"message": "Failed to slice the model",
"details": "prime_tower_brim_width: -1 not in range [0, 100]",
},
)
service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
with pytest.raises(SlicerApiServerError) as exc_info:
await service.slice_with_profiles(
model_bytes=b"x",
model_filename="Cube.stl",
printer_profile_json="{}",
process_profile_json="{}",
filament_profile_json="{}",
)
msg = str(exc_info.value)
assert "Failed to slice the model" in msg
assert "prime_tower_brim_width: -1" in msg
@pytest.mark.asyncio
async def test_5xx_with_only_details_still_surfaces(self):
"""If a future sidecar version emits ``details`` without
``message``, fall back to the details string so we don't end up
with an empty error."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
status_code=500,
json={"details": "Slicer killed by SIGSEGV"},
)
service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
with pytest.raises(SlicerApiServerError) as exc_info:
await service.slice_with_profiles(
model_bytes=b"x",
model_filename="Cube.stl",
printer_profile_json="{}",
process_profile_json="{}",
filament_profile_json="{}",
)
assert "SIGSEGV" in str(exc_info.value)
@pytest.mark.asyncio
async def test_5xx_with_non_json_body_falls_back_to_text(self):
"""Some failure paths (gateway timeouts, bare nginx 502s) return
plain text rather than the JSON envelope. Don't crash trying to
decode it — fall back to the text body."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(status_code=502, content=b"Bad Gateway")
service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
with pytest.raises(SlicerApiServerError) as exc_info:
await service.slice_with_profiles(
model_bytes=b"x",
model_filename="Cube.stl",
printer_profile_json="{}",
process_profile_json="{}",
filament_profile_json="{}",
)
assert "Bad Gateway" in str(exc_info.value)
@pytest.mark.asyncio
async def test_connection_error_raises_unavailable(self):
def handler(request: httpx.Request) -> httpx.Response:
@@ -0,0 +1,86 @@
"""Tests for `SliceRequest` validator — covers both the legacy bare-int
shape and the new source-aware shape, plus the backwards-compat
normalisation that lets the route handler ignore the difference.
"""
import pytest
from pydantic import ValidationError
from backend.app.schemas.slicer import PresetRef, SliceRequest
class TestLegacyBareIntegerShape:
"""Existing clients (and stale browser tabs after upgrade) keep
sending bare integer ids. They must continue working unchanged."""
def test_bare_int_ids_normalise_to_local_preset_ref(self):
req = SliceRequest(printer_preset_id=1, process_preset_id=2, filament_preset_id=3)
assert req.printer_preset == PresetRef(source="local", id="1")
assert req.process_preset == PresetRef(source="local", id="2")
assert req.filament_preset == PresetRef(source="local", id="3")
def test_legacy_ids_unchanged_in_payload(self):
"""The legacy fields stay populated — no behaviour change for
clients that read them back from the model."""
req = SliceRequest(printer_preset_id=10, process_preset_id=20, filament_preset_id=30)
assert req.printer_preset_id == 10
assert req.process_preset_id == 20
assert req.filament_preset_id == 30
class TestNewSourceAwareShape:
"""The new modal sends source-aware refs explicitly."""
def test_cloud_refs_pass_through(self):
req = SliceRequest(
printer_preset=PresetRef(source="cloud", id="PFUprinter"),
process_preset=PresetRef(source="cloud", id="PFUprocess"),
filament_preset=PresetRef(source="cloud", id="PFUfilament"),
)
assert req.printer_preset.source == "cloud"
assert req.printer_preset.id == "PFUprinter"
def test_mixed_sources_per_slot(self):
"""A user may pick cloud for printer, local for process, standard
for filament — the modal is per-slot."""
req = SliceRequest(
printer_preset=PresetRef(source="cloud", id="PFU123"),
process_preset=PresetRef(source="local", id="42"),
filament_preset=PresetRef(source="standard", id="Bambu PLA Basic"),
)
assert req.printer_preset.source == "cloud"
assert req.process_preset.source == "local"
assert req.filament_preset.source == "standard"
class TestValidationErrors:
def test_missing_printer_slot_raises(self):
with pytest.raises(ValidationError) as exc:
SliceRequest(process_preset_id=2, filament_preset_id=3)
assert "printer" in str(exc.value)
def test_invalid_source_rejected(self):
with pytest.raises(ValidationError):
SliceRequest(
printer_preset={"source": "made_up", "id": "x"},
process_preset_id=2,
filament_preset_id=3,
)
class TestPriorityWhenBothSet:
"""If a client sends BOTH the legacy id AND the new ref for the same
slot (unlikely in practice, but ambiguous), the new ref wins. Tests
pin the resolution order so a future schema change can't silently
flip it."""
def test_explicit_ref_wins_over_legacy_id(self):
req = SliceRequest(
printer_preset_id=999, # would resolve to local:999
printer_preset=PresetRef(source="cloud", id="PFU"),
process_preset_id=2,
filament_preset_id=3,
)
# Validator only fills the ref when it's None — the explicit cloud
# ref stays untouched.
assert req.printer_preset == PresetRef(source="cloud", id="PFU")
+433
View File
@@ -0,0 +1,433 @@
"""Tests for the unified slicer-presets endpoint helpers.
The endpoint stitches together three preset sources (cloud / local /
standard) with name-based dedup. These tests pin the dedup logic, the
cloud-status mapping, and the per-user / sidecar caches at the
helper level — full HTTP integration is covered by the routes test.
"""
from __future__ import annotations
import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app.api.routes import slicer_presets as sp
from backend.app.schemas.slicer_presets import UnifiedPreset
def _slot(items: list[tuple[str, str, str]]) -> dict[str, list[UnifiedPreset]]:
"""Helper: build a single-slot dict from (id, name, source) tuples placed
on the printer slot. Process / filament default to empty so each test
only exercises the slot it cares about."""
return {
"printer": [UnifiedPreset(id=i, name=n, source=s) for i, n, s in items],
"process": [],
"filament": [],
}
class TestDedupeByName:
"""Cloud > local > standard, by ``name``, order preserved within tier."""
def test_cloud_wins_over_local_and_standard(self):
cloud = _slot([("cid1", "Bambu PLA Basic", "cloud")])
local = _slot([("lid1", "Bambu PLA Basic", "local")])
standard = _slot([("Bambu PLA Basic", "Bambu PLA Basic", "standard")])
c, l_, s = sp._dedupe_by_name(cloud, local, standard)
assert [p.source for p in c["printer"]] == ["cloud"]
assert l_["printer"] == []
assert s["printer"] == []
def test_local_filtered_only_when_present_in_cloud(self):
cloud = _slot([("cid1", "Custom PLA", "cloud")])
local = _slot(
[
("lid1", "Custom PLA", "local"), # filtered (in cloud)
("lid2", "My Workhorse PLA", "local"), # kept
]
)
standard = _slot([])
_c, l_, _s = sp._dedupe_by_name(cloud, local, standard)
assert [p.name for p in l_["printer"]] == ["My Workhorse PLA"]
def test_standard_filtered_against_both_higher_tiers(self):
cloud = _slot([("c1", "A", "cloud")])
local = _slot([("l1", "B", "local")])
standard = _slot(
[
("A", "A", "standard"), # filtered (in cloud)
("B", "B", "standard"), # filtered (in local)
("C", "C", "standard"), # kept
]
)
_c, _l, s = sp._dedupe_by_name(cloud, local, standard)
assert [p.name for p in s["printer"]] == ["C"]
def test_preserves_order_within_tier(self):
"""A tier's input order must be preserved in its output — nothing in
the dedupe pass should sort, reverse, or otherwise reorder entries."""
cloud = _slot(
[
("c1", "Z-First", "cloud"),
("c2", "A-Second", "cloud"),
("c3", "M-Third", "cloud"),
]
)
c, _l, _s = sp._dedupe_by_name(cloud, _slot([]), _slot([]))
assert [p.name for p in c["printer"]] == ["Z-First", "A-Second", "M-Third"]
def test_dedupe_is_per_slot(self):
"""A name colliding across DIFFERENT slots must NOT cross-filter —
a "Custom" filament shouldn't hide a "Custom" printer."""
cloud = {
"printer": [],
"process": [],
"filament": [UnifiedPreset(id="cf1", name="Custom", source="cloud")],
}
local = {
"printer": [UnifiedPreset(id="lp1", name="Custom", source="local")],
"process": [],
"filament": [],
}
_c, l_, _s = sp._dedupe_by_name(cloud, local, _slot([]))
# The filament-tier collision must NOT remove the printer-tier "Custom".
assert [p.name for p in l_["printer"]] == ["Custom"]
def _user_with_cloud_auth(user_id: int = 1) -> MagicMock:
"""Construct a mock User that passes the CLOUD_AUTH permission check.
`MagicMock` defaults `.has_permission(...)` to a truthy MagicMock object,
which would coincidentally pass the gate — but explicit is better than
accidental. Setting `.return_value = True` documents the intent."""
user = MagicMock(id=user_id)
user.has_permission = MagicMock(return_value=True)
return user
class TestFetchCloudPresets:
"""`_fetch_cloud_presets` translates token state and cloud errors into
the four ``cloud_status`` values the SliceModal banner consumes."""
@pytest.mark.asyncio
async def test_no_token_returns_not_authenticated(self):
sp._cloud_cache.clear()
with patch.object(sp, "get_stored_token", AsyncMock(return_value=(None, None, None))):
slots, status = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth())
assert status == "not_authenticated"
assert slots == {"printer": [], "process": [], "filament": []}
@pytest.mark.asyncio
async def test_user_without_cloud_auth_returns_not_authenticated(self):
"""Defence-in-depth: a user lacking CLOUD_AUTH must NOT see cloud
presets even if their User row carries a stale cloud_token from a
previous permission state. Token lookup is skipped entirely."""
sp._cloud_cache.clear()
user = MagicMock(id=1)
user.has_permission = MagicMock(return_value=False)
with patch.object(sp, "get_stored_token", AsyncMock(return_value=("leftover-token", None, None))) as get_tok:
slots, status = await sp._fetch_cloud_presets(MagicMock(), user)
assert status == "not_authenticated"
assert slots["printer"] == []
# Token was never read — the perm check short-circuits ahead of it.
get_tok.assert_not_called()
@pytest.mark.asyncio
async def test_auth_error_returns_expired(self):
sp._cloud_cache.clear()
cloud_mock = MagicMock()
cloud_mock.set_token = MagicMock()
cloud_mock.get_slicer_settings = AsyncMock(side_effect=sp.BambuCloudAuthError("expired"))
cloud_mock.close = AsyncMock()
with (
patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", "e@x", None))),
patch.object(sp, "BambuCloudService", return_value=cloud_mock),
):
slots, status = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth())
assert status == "expired"
assert slots["printer"] == []
cloud_mock.close.assert_awaited_once()
@pytest.mark.asyncio
async def test_cloud_error_returns_unreachable(self):
sp._cloud_cache.clear()
cloud_mock = MagicMock()
cloud_mock.set_token = MagicMock()
cloud_mock.get_slicer_settings = AsyncMock(side_effect=sp.BambuCloudError("net down"))
cloud_mock.close = AsyncMock()
with (
patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
patch.object(sp, "BambuCloudService", return_value=cloud_mock),
):
_slots, status = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth())
assert status == "unreachable"
@pytest.mark.asyncio
async def test_happy_path_shapes_private_then_public(self):
"""Cloud presets split into private (user-custom) + public (Bambu's
stock cloud presets). Private should sort before public so a user's
own customisations sit at the top of the dropdown."""
sp._cloud_cache.clear()
cloud_mock = MagicMock()
cloud_mock.set_token = MagicMock()
cloud_mock.get_slicer_settings = AsyncMock(
return_value={
"printer": {
"private": [{"setting_id": "PFUprivate1", "name": "My X1C"}],
"public": [{"setting_id": "PFUpublic1", "name": "Bambu X1C Stock"}],
},
"print": {"private": [], "public": []},
"filament": {"private": [], "public": []},
}
)
cloud_mock.close = AsyncMock()
with (
patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
patch.object(sp, "BambuCloudService", return_value=cloud_mock),
):
slots, status = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth())
assert status == "ok"
names = [p.name for p in slots["printer"]]
assert names == ["My X1C", "Bambu X1C Stock"]
@pytest.mark.asyncio
async def test_cache_hit_skips_cloud_call(self):
"""A second call within TTL must reuse the cached slots and NOT
hit Bambu Cloud again."""
sp._cloud_cache.clear()
cloud_mock = MagicMock()
cloud_mock.set_token = MagicMock()
cloud_mock.get_slicer_settings = AsyncMock(
return_value={
"printer": {"private": [{"setting_id": "id1", "name": "X1C"}], "public": []},
"print": {"private": [], "public": []},
"filament": {"private": [], "public": []},
}
)
cloud_mock.close = AsyncMock()
user = _user_with_cloud_auth(user_id=42)
with (
patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
patch.object(sp, "BambuCloudService", return_value=cloud_mock),
):
await sp._fetch_cloud_presets(MagicMock(), user)
await sp._fetch_cloud_presets(MagicMock(), user)
cloud_mock.get_slicer_settings.assert_awaited_once()
@pytest.mark.asyncio
async def test_cache_is_per_user(self):
"""User A's cached cloud presets must not surface for user B."""
sp._cloud_cache.clear()
def make_mock(name: str):
m = MagicMock()
m.set_token = MagicMock()
m.get_slicer_settings = AsyncMock(
return_value={
"printer": {"private": [{"setting_id": f"id-{name}", "name": name}], "public": []},
"print": {"private": [], "public": []},
"filament": {"private": [], "public": []},
}
)
m.close = AsyncMock()
return m
sequence = [make_mock("AliceX1C"), make_mock("BobX1C")]
with (
patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
patch.object(sp, "BambuCloudService", side_effect=sequence),
):
alice_slots, _ = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth(1))
bob_slots, _ = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth(2))
assert alice_slots["printer"][0].name == "AliceX1C"
assert bob_slots["printer"][0].name == "BobX1C"
@pytest.mark.asyncio
async def test_cache_invalidates_on_token_change(self):
"""A token change (logout + login, admin reset, region switch) must
bypass the cache for that user — pinning a real-world auth bug
where user re-login + cache-stuck-on-old-cloud-account would
silently serve a different account's preset list for ~5 minutes."""
sp._cloud_cache.clear()
def make_mock(name: str):
m = MagicMock()
m.set_token = MagicMock()
m.get_slicer_settings = AsyncMock(
return_value={
"printer": {"private": [{"setting_id": f"id-{name}", "name": name}], "public": []},
"print": {"private": [], "public": []},
"filament": {"private": [], "public": []},
}
)
m.close = AsyncMock()
return m
# Same user_id, different token between calls — the second call must
# NOT serve the first call's cached slots.
services = [make_mock("OldAccountX1C"), make_mock("NewAccountX1C")]
token_sequence = [("tok-old", None, None), ("tok-new", None, None)]
user = _user_with_cloud_auth(user_id=7)
with (
patch.object(sp, "get_stored_token", AsyncMock(side_effect=token_sequence)),
patch.object(sp, "BambuCloudService", side_effect=services),
):
first, _ = await sp._fetch_cloud_presets(MagicMock(), user)
second, _ = await sp._fetch_cloud_presets(MagicMock(), user)
assert first["printer"][0].name == "OldAccountX1C"
assert second["printer"][0].name == "NewAccountX1C"
class TestFetchBundledPresets:
"""Standard tier reaches out to the slicer-api sidecar; tolerate the
sidecar being absent / unreachable so the modal still works."""
@pytest.mark.asyncio
async def test_no_sidecar_url_returns_empty(self):
sp._bundled_cache = None
with patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value=None)):
slots = await sp._fetch_bundled_presets(MagicMock())
assert slots == {"printer": [], "process": [], "filament": []}
# No URL means no useful cache result either — second call should
# try again (so users who configure a URL mid-session see results).
assert sp._bundled_cache is None
@pytest.mark.asyncio
async def test_sidecar_error_returns_empty(self):
sp._bundled_cache = None
svc_mock = MagicMock()
svc_mock.list_bundled_profiles = AsyncMock(side_effect=sp.SlicerApiError("boom"))
svc_mock.__aenter__ = AsyncMock(return_value=svc_mock)
svc_mock.__aexit__ = AsyncMock(return_value=False)
with (
patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://nope")),
patch.object(sp, "SlicerApiService", return_value=svc_mock),
):
slots = await sp._fetch_bundled_presets(MagicMock())
assert slots == {"printer": [], "process": [], "filament": []}
@pytest.mark.asyncio
async def test_happy_path_shapes_response(self):
sp._bundled_cache = None
svc_mock = MagicMock()
svc_mock.list_bundled_profiles = AsyncMock(
return_value={
"printer": [{"name": "Bambu X1C 0.4", "base_id": None}],
"process": [{"name": "0.20mm Standard", "base_id": "fdm_process_common"}],
"filament": [{"name": "Bambu PLA Basic", "base_id": "fdm_filament_pla"}],
}
)
svc_mock.__aenter__ = AsyncMock(return_value=svc_mock)
svc_mock.__aexit__ = AsyncMock(return_value=False)
with (
patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
patch.object(sp, "SlicerApiService", return_value=svc_mock),
):
slots = await sp._fetch_bundled_presets(MagicMock())
assert slots["printer"][0].name == "Bambu X1C 0.4"
assert slots["printer"][0].source == "standard"
# Bundled presets are addressed by name (the slicer's inheritance
# walker resolves them by name), so id == name.
assert slots["printer"][0].id == "Bambu X1C 0.4"
@pytest.mark.asyncio
async def test_cache_hit_skips_sidecar(self):
"""A second call within TTL must serve from the cached entry and not
re-hit the sidecar HTTP."""
sp._bundled_cache = (
time.monotonic(),
{
"printer": [UnifiedPreset(id="Cached", name="Cached", source="standard")],
"process": [],
"filament": [],
},
)
# If `SlicerApiService` is constructed at all we've missed the cache.
with patch.object(sp, "SlicerApiService", side_effect=AssertionError("cache miss!")):
slots = await sp._fetch_bundled_presets(MagicMock())
assert slots["printer"][0].name == "Cached"
class TestResolveSlicerApiUrl:
"""`_resolve_slicer_api_url` must respect the user's `preferred_slicer`
setting just like the slice route does. The bundled-listing fetch
used to be hardcoded to OrcaSlicer's URL, which left the Standard
tier permanently empty for BambuStudio installs."""
@pytest.mark.asyncio
async def test_bambu_studio_preference_uses_bambu_url(self):
"""When the user prefers Bambu Studio, the listing fetch must hit
the bambu-studio-api sidecar (port 3001 by default), not orca's
port 3003."""
async def fake_get_setting(_db, key):
return {
"preferred_slicer": "bambu_studio",
"bambu_studio_api_url": "http://bambu-studio-api:3000",
}.get(key)
with patch(
"backend.app.api.routes.settings.get_setting",
new=fake_get_setting,
):
url = await sp._resolve_slicer_api_url(MagicMock())
assert url == "http://bambu-studio-api:3000"
@pytest.mark.asyncio
async def test_orcaslicer_preference_uses_orca_url(self):
async def fake_get_setting(_db, key):
return {
"preferred_slicer": "orcaslicer",
"orcaslicer_api_url": "http://orca-slicer-api:3000",
}.get(key)
with patch(
"backend.app.api.routes.settings.get_setting",
new=fake_get_setting,
):
url = await sp._resolve_slicer_api_url(MagicMock())
assert url == "http://orca-slicer-api:3000"
@pytest.mark.asyncio
async def test_default_preference_is_bambu_studio(self):
"""Empty preferred_slicer → bambu_studio (matches the slice route's
default at library.py:_run_slicer_with_fallback)."""
async def fake_get_setting(_db, key):
return {
# preferred_slicer not set
"bambu_studio_api_url": "http://bambu-default:3000",
}.get(key)
with patch(
"backend.app.api.routes.settings.get_setting",
new=fake_get_setting,
):
url = await sp._resolve_slicer_api_url(MagicMock())
assert url == "http://bambu-default:3000"
@pytest.mark.asyncio
async def test_unknown_preference_returns_none(self):
"""An unrecognised preferred_slicer value (e.g. set out-of-band by
a stale migration) returns None so the modal degrades to "no
Standard tier" rather than crashing — the slice route raises 400
in this case but the listing is informational, so be lenient."""
async def fake_get_setting(_db, key):
return {"preferred_slicer": "prusaslicer"}.get(key)
with patch(
"backend.app.api.routes.settings.get_setting",
new=fake_get_setting,
):
url = await sp._resolve_slicer_api_url(MagicMock())
assert url is None