diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e00ce3c8..427257085 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.4b1] - Unreleased ### Added +- **Slicer presets now span Cloud, imported, and slicer-bundled tiers, end-to-end** — Initial slicer integration only saw DB-backed local imports, so a user 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** (the user's own Bambu Cloud presets), **local** (DB-backed imports), **standard** (slicer-bundled stock profiles) — with name-based dedup so a preset that exists in multiple tiers only renders in the highest-priority one (cloud > local > standard) and within-tier order is preserved exactly. **Listing** (`GET /api/v1/slicer/presets`): cloud branch is per-user with a 5-minute cache keyed on `(user_id, sha256(token)[:16])` so a logout/login or token rotation auto-invalidates without callback wiring from the cloud-auth routes. Bundled branch is global with a 1-hour cache (sidecar's read-only filesystem only changes across image rebuilds). `cloud_status` (`ok` / `not_authenticated` / `expired` / `unreachable`) drives a precise modal banner instead of an unexplained empty list. **Slicing** (`POST /library/files/{id}/slice`, `POST /archives/{id}/slice`): request body now accepts source-aware `{source, id}` triplets per slot (cloud / local / standard) alongside the legacy `*_preset_id` fields for full backwards-compatibility — the schema validator normalises bare integer ids into `PresetRef(source='local', id=str(int))` so the dispatcher only deals with one shape. New `preset_resolver` service fetches the preset content per source: cloud via `BambuCloudService.get_setting_detail` (unwraps the `setting` envelope, falls back to top-level on minor shape variants), local from the DB (existing path), standard via a minimal `{inherits: , from: "system"}` stub that the sidecar's `bambuddy/profile-resolver` branch flattens against `BUNDLED_PROFILES_PATH//.json` — no preset-content round-trip needed for the standard tier. **Permissions**: the listing route gate matches the slice action itself (`LIBRARY_UPLOAD`) so any user who can slice can populate the dropdowns; the cloud branch has an independent `CLOUD_AUTH` check inside the fetch helper — a user holding `LIBRARY_UPLOAD` but not `CLOUD_AUTH` doesn't see the cloud tier (and can't slice with a cloud preset, returns 403) even if a leftover `User.cloud_token` survived a permission revocation. **SliceModal** (frontend): grouped `` per tier with localised section headers, default-selection follows the cloud > local > standard priority on first load, cloud-status banner with three variants (sign-in / expired / unreachable) only when the status isn't `ok`. **Sidecar** (orca-slicer-api fork, `bambuddy/profile-resolver` branch): new `GET /profiles/bundled` walks `BUNDLED_PROFILES_PATH/{machine,process,filament}` and returns instantiable presets only (`instantiation: "true"`), filtering out abstract bases like `fdm_filament_pla` so the dropdowns only offer things a user can actually pick. **Tests**: 17 unit tests for the listing endpoint helpers (dedup priority + per-slot scoping + order preservation, all four `cloud_status` states, `CLOUD_AUTH` defence-in-depth with token lookup short-circuit, per-user cache isolation, token-change cache invalidation, sidecar-unreachable fallback), 11 unit tests for the source-aware resolver (standard inherits-stub shape, local DB lookup with `preset_type` validation, cloud envelope unwrapping with both standard and top-level shapes, cloud auth-error → 401, cloud `CLOUD_AUTH` defence, slot dispatch routing), 6 schema tests for `SliceRequest` covering legacy bare-int normalisation and new source-aware refs and explicit-ref-wins-over-legacy precedence, 12 frontend tests for SliceModal covering tier-priority auto-selection, `` grouping, fallback when higher tiers are empty, source-aware payload on submit, manual override across tiers, archive-vs-library routing, error display, and all three banner variants. All 3391 backend + 1531 frontend tests pass. + - **Server-side slicing via OrcaSlicer / Bambu Studio sidecar** — Bambuddy can now slice models without a desktop slicer installed. New optional `slicer-api/` Compose stack runs HTTP wrappers around the OrcaSlicer and/or Bambu Studio CLI; Bambuddy's File Manager and Archives pages get a **Slice** button that picks a printer / process / filament preset and dispatches a background slice job whose result lands as a new `.gcode.3mf` in the same library folder (or as a new archive when the source was an archive). Settings → Workflow gets a new **Slicer** card: pick the preferred slicer, toggle "Use Slicer API" on, and paste the sidecar URL — Slice buttons across File Manager, Archives, and MakerWorld then route through the API instead of the OS slicer URI scheme. Status updates come from a global `SliceJobTrackerProvider` that polls `/api/v1/slice-jobs/{id}` and surfaces a single toast per job (queued → running → completed / failed) plus auto-refreshes the file or archive list on success — slicing one file no longer pins the modal. Server side, a fresh in-memory dispatcher (`backend/app/services/slice_dispatch.py`) runs jobs as `asyncio.create_task`s with a 30-minute retention sweep, and the routes (`POST /library/files/{id}/slice`, `POST /archives/{id}/slice`) return 202 immediately with `{job_id, status, status_url}` instead of holding the request open through a multi-minute slice. The CLI bridge (`backend/app/services/slicer_api.py`) distinguishes 4xx (`SlicerInputError`), 5xx (`SlicerApiServerError`), and connection failures (`SlicerApiUnavailableError`) so 3MF inputs can transparently retry with embedded settings when the sidecar's `--load-settings` path segfaults on the input — empirically required for OrcaSlicer 2.3.x + H2D and signalled to the UI via `used_embedded_settings: true`. Sliced output is forced to `.gcode.3mf` so File Manager picks up the embedded thumbnail, the `print_name` is dropped from saved metadata so the displayed filename matches what the user picked, and `file_type="gcode"` paints the badge blue. The polling endpoint `GET /api/v1/slice-jobs/{id}` is gated on `LIBRARY_READ` since job IDs are sequential and the body leaks source filenames + resulting library/archive IDs. The sidecar itself builds from a fork of [AFKFelix/orca-slicer-api](https://github.com/AFKFelix/orca-slicer-api) (`maziggy/orca-slicer-api@bambuddy/profile-resolver`) which adds the `inherits:` chain resolver, `from: "User"` → `"system"` rewrite, `# ` clone-prefix strip, and sentinel-value strip empirically required to slice real OrcaSlicer GUI exports without segfaulting the CLI; the Compose file uses Docker's git-build-context so users don't clone it manually. Default ports are 3003 (orca) and 3001 (bambu-studio) — 3000/3002 are skipped because Bambuddy's virtual-printer feature owns them. 10 backend integration tests cover sync validation (404/400), happy-path enqueue, preset-error → failed job, sidecar unreachable, the 3MF embedded-settings fallback, STL no-fallback, and the strip-before-forward path; 5 new frontend tests for the SliceModal cover preset gating, library + archive enqueue paths, error display, and preset-load failure. New i18n keys under `slicer.*` and `settings.slicer.*` across all 8 locales (English fully translated; the seven other locales seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features). Slicer integration is opt-in: if "Use Slicer API" stays off, the existing "open in desktop slicer via URI" flow is the default and unchanged. - **Per-spool category + low-stock threshold override** ([#729](https://github.com/maziggy/bambuddy/issues/729) — minimal version) — Two new fields on the spool form: a free-text **Category** (with autocomplete from categories already in use, so users naturally re-use "Production" instead of accidentally typing "production" / "prod") and a per-spool **Low-stock threshold (%)** override that defaults to the global setting if left blank. Powers the "I want to differentiate critical spools from prototype spools and alert at different thresholds" use case from the issue without taking on the full multi-tag taxonomy + auto-apply-rules + per-tag alert system the ticket originally proposed (which would have been ~5x the work for the same underlying value). Inventory page gains a Category filter chip — only renders once at least one spool carries a category, otherwise hidden so the chip row stays uncluttered. Low-stock counts in the stat-card and the "Low Stock" filter both honour the per-spool override (so a "Production" spool with override = 90% will count as low-stock at 80% remaining even when the global threshold is 20%). 50-char cap on category, 1-99% range on threshold (0 and 100 are both rejected as footguns). 9 new backend schema-validation tests covering the field defaults, partial-update behaviour, range/length rejection; 2 new frontend tests confirming the per-spool threshold pulls in spools the global threshold misses, and that the category filter chip stays hidden until at least one spool has a category. Localised across all 8 UI languages with full translations. The full multi-tag taxonomy from the original issue isn't going forward; if demand for it grows past the current 3 thumbs-up the design can layer on top of these fields without breakage. @@ -37,6 +39,8 @@ All notable changes to Bambuddy will be documented in this file. - **Per-request trace ID column on every log line, plumbed through HTTP access log + application logs + response headers** — Builds on the new uvicorn-access-log-into-bambuddy.log change below: the access line tells you *who* called an endpoint, but until now there was no way to tie that line to the application records emitted on the server side while handling that request. A new FastAPI middleware (`trace_id_middleware` in `main.py`, sourced from `backend.app.core.trace`) stamps each request with a fresh 8-char hex ID (or honours a sane inbound `X-Trace-Id` header for cross-system correlation), stores it in a `ContextVar` so any code in the request's call stack can read it, echoes it on the response as `X-Trace-Id`, and a new `TraceIDFilter` injects it into every `LogRecord` so the format string `[%(trace_id)s]` resolves to the right ID for the right request. ContextVars (rather than `request.state`) are the right plumbing here because asyncio copies the current context into every `asyncio.create_task`, so background work spawned from inside a request inherits the trace ID without explicit threading; the logging filter has no access to the FastAPI request object regardless. Records emitted outside any request scope (startup, MQTT callbacks, scheduler) get a stable `-` placeholder so the column stays visually aligned and missing values are obvious in `grep`. Inbound `X-Trace-Id` is hard-validated against a strict whitelist (`[A-Za-z0-9_-]+`, max 64 chars) before being honoured — a hostile or buggy caller cannot smuggle log-injection payloads (newlines, control chars, megabyte blobs) into `bambuddy.log` via the trace-ID column; values that fail the gate silently trigger a freshly minted server-side ID rather than failing the request. Middleware is decorated AFTER `auth_middleware` on purpose: Starlette stacks `@app.middleware` decorators LIFO so the last-decorated runs first inbound, making trace stamp the OUTERMOST layer — auth log lines and every record emitted on the way down to and back from the route handler all carry the same ID. Output now looks like `2026-04-26 09:51:39,152 INFO [uvicorn.access] [a4f3b1e7] 192.168.1.42:54812 - "POST /api/v1/printers/1/print/stop HTTP/1.1" 200` paired with the route handler's `2026-04-26 09:51:39,158 INFO [bambu_mqtt] [a4f3b1e7] [SERIAL] Sent stop print command` — one `grep a4f3b1e7` away from the full causality chain. 30 new tests across `tests/unit/test_trace.py` (placeholder when no request scope, filter copies ContextVar value onto records, ID propagates into spawned tasks via asyncio context copy, concurrent requests don't leak IDs into each other, generator produces unique hex IDs, hostile payloads rejected by validator, max-length boundary, dash/underscore variants accepted) plus `tests/integration/test_trace_middleware.py` (X-Trace-Id header echoed on response, body and header IDs match, each request gets a unique ID, generator format stays short hex, safe inbound IDs honoured, hostile inbound IDs replaced, overlong inbound IDs replaced, ContextVar reset cleanly after request). ### Fixed +- **3MF profile-driven slicing always fell back to embedded settings, plus sidecar errors were generic** — `_strip_3mf_embedded_settings` only removed `Metadata/project_settings.config` before forwarding the model to the slicer sidecar; real-world Bambu Studio / OrcaSlicer 3MFs also carry `Metadata/model_settings.config`, `Metadata/slice_info.config`, and `Metadata/cut_information.xml`, all of which reference the original slice's printer / filament IDs. Any single leftover triggered the CLI's input validation and the slice fell back to embedded settings — making the new SliceModal's profile picker theatrical for 3MF inputs. Strip widened to remove all four configs (centralised in `_STRIPPABLE_3MF_CONFIGS` with per-file rationale); geometry (`3D/3dmodel.model`), thumbnails, and multi-part data preserved verbatim. Existing strip integration test extended to assert all four files go and `3D/3dmodel.model` stays. Separately, `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 (preferring `message: details`, falling back to whichever is set, falling back to `response.text` for non-JSON 5xx like nginx 502s) and replaces the four duplicated `response.json().get("message", "")` blocks. After both changes, a CLI rejection log line goes from `Slicer CLI failed (500): Failed to slice the model` to `Slicer CLI failed (500): Failed to slice the model: `. 3 new unit tests in `test_slicer_api.py`: `details` field surfaced alongside `message`, `details`-only response still produces a useful error, plain-text 5xx (gateway timeouts) doesn't crash the JSON decode and falls back to the body. Pairs with the orca-slicer-api fork's `bambuddy/profile-resolver` branch which now emits `details` on its `AppError` responses (`d9c6121`) and captures CLI stdout / stderr in the failure path (`fb928c8`). + - **Moving a file to an external folder updated the DB row but never wrote the bytes to the mount** ([#1112](https://github.com/maziggy/bambuddy/issues/1112) follow-up — confirmed by @Carter3DP after testing 0.2.4b1) — Carter's report read "the file appears in Bambuddy but not physically on the external folder", which traced to `move_files` only updating `file.folder_id` in the DB while leaving the bytes in the internal `library_files_dir`. Direct upload to a writable external folder was already fixed in 0.2.4b1; the move path was not. Cross-boundary moves now physically relocate the bytes through a new `_move_file_bytes` helper. Same-boundary moves (managed → managed) keep the existing DB-only fast path because the file's on-disk location doesn't depend on which managed folder owns it. The helper handles four flows: managed → external (copy bytes to `/`, flip `is_external=True`, store the absolute path, unlink the managed source), external → managed (copy bytes into internal storage with a fresh UUID name, flip `is_external=False`, store the relative path, unlink the external source, recompute `file_hash` since scan-tracked rows historically carry `file_hash=None`), external → external (same as managed → external), and managed → managed (DB-only). Copy-then-unlink ordering means a partial copy followed by a failed unlink leaves both copies on disk rather than losing the source if the target write fails halfway through on a flaky NAS mount. Failed `shutil.copy2` cleans up partial dest before raising. Defence-in-depth checks block: source on a read-only external mount (move = delete-on-source which a RO mount can't fulfil — would copy-then-fail-to-unlink and silently duplicate the file), filename collisions on the target mount (won't silently overwrite a file the user already has on the NAS), traversal-style filenames after `Path.resolve()`, missing source on disk, and `os.access(W_OK)` on the target mount. Each skip carries a structured `{file_id, code, reason}` entry in a new `skipped_reasons` field on the response so the UI can surface "5 of 10 files skipped: 3 had filename collisions on the NAS, 2 are no longer on disk" instead of a blank "skipped: 5". The original `{moved, skipped}` numeric counters are preserved so existing frontend code that only reads those keeps working unchanged. Six new integration tests in `test_external_folders_api.py::TestCrossBoundaryMove` covering: managed → external relocates bytes (the actual #1112 fix — bytes land on mount, internal source removed, DB row matches reality), external → managed relocates bytes (symmetric path including hash recompute), name collision on target external mount skips with `code: "name_collision"` and leaves the pre-existing target file intact, source on read-only external mount skips with `code: "source_readonly"`, managed → managed stays DB-only (file_path doesn't change, no shutil.copy), and `skipped_reasons` is always present (empty list when nothing skipped) so frontend code can treat it as the source of truth without optional-chaining. - **`bambuddy.log` filling with `Exception terminating connection ... CancelledError` + `database is locked` cascades on long uploads** ([#1112](https://github.com/maziggy/bambuddy/issues/1112) follow-up, surfaced by @Carter3DP's support package) — Two-part fix to a single root cause: Starlette's `BaseHTTPMiddleware` (which FastAPI's `@app.middleware("http")` decorator uses under the hood) cancels the inner task scope when a client disconnects mid-request — common on long multipart uploads where the client times out before the server's response. Pre-fix `get_db` only caught `Exception`, but `CancelledError` is a `BaseException`, so cancellation skipped the rollback path entirely; the SQLite write lock stayed held until the connection was eventually GC'd, producing the `(sqlite3.OperationalError) database is locked` cascade against `runtime_seconds` updates and other tight-loop writers in @Carter3DP's log. Postgres users would see pool exhaustion / "QueuePool limit overflow" instead of file-level lock contention, but the leak shape is identical. **(1)** `get_db` now catches `BaseException` so `CancelledError` triggers rollback, and wraps both `rollback()` and `close()` in `asyncio.shield` so the cleanup completes even when the await itself is being cancelled by the same cancel scope. The SQLite write lock is released promptly; the connection returns to the pool instead of leaking until GC. **(2)** A `CancelledPoolNoiseFilter` (new `logging_filters.py` filter, attached to `sqlalchemy.pool`) drops the residual log noise that pre-existing pools still emit during their own cleanup — both the `Exception terminating connection ... CancelledError` records (matched on prefix + cancellation-driven `exc_info`, including chained `__cause__`/`__context__`) and the symptomatic `garbage collector is trying to clean up non-checked-in connection` records. Real pool problems — broken connections, network hiccups, exhaustion — keep flowing because they carry a different exception chain or a different message prefix; verified by `test_keeps_terminate_with_real_oserror` and `test_keeps_unrelated_pool_message`. 13 new regression tests across `test_get_db_cancel_safety.py` (commit on clean exit, rollback on regular `Exception`, rollback on `CancelledError` — the actual #1112 fix, close runs even if rollback raises, close failure on clean exit doesn't propagate, both rollback + close go through `asyncio.shield`) and `test_cancelled_pool_filter.py` (drops cancellation-driven terminate, drops GC-cleanup, keeps real `OSError` terminate, keeps terminate without `exc_info`, keeps unrelated pool messages, drops chained-cause `CancelledError`, defensive guard against self-referential cause chains). Applies to SQLite and PostgreSQL — `get_db` is dialect-agnostic and the filtered messages come from base `sqlalchemy.pool` not from any specific dialect. diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index ffe323dbb..080f2e4ae 100644 --- a/backend/app/api/routes/library.py +++ b/backend/app/api/routes/library.py @@ -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] diff --git a/backend/app/api/routes/slicer_presets.py b/backend/app/api/routes/slicer_presets.py new file mode 100644 index 000000000..a674a0a7b --- /dev/null +++ b/backend/app/api/routes/slicer_presets.py @@ -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, + ) diff --git a/backend/app/main.py b/backend/app/main.py index 0fc8e64fe..ce800557c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/schemas/slicer.py b/backend/app/schemas/slicer.py index 8003baf9a..e51f86c3c 100644 --- a/backend/app/schemas/slicer.py +++ b/backend/app/schemas/slicer.py @@ -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 diff --git a/backend/app/schemas/slicer_presets.py b/backend/app/schemas/slicer_presets.py new file mode 100644 index 000000000..3f1f6f59c --- /dev/null +++ b/backend/app/schemas/slicer_presets.py @@ -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" diff --git a/backend/app/services/preset_resolver.py b/backend/app/services/preset_resolver.py new file mode 100644 index 000000000..879a0821f --- /dev/null +++ b/backend/app/services/preset_resolver.py @@ -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: , from: "system"}``. The + sidecar's `bambuddy/profile-resolver` branch already walks + ``inherits:`` against ``BUNDLED_PROFILES_PATH//.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: }` stub. The sidecar's resolver + walks `BUNDLED_PROFILES_PATH//.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", + } + ) diff --git a/backend/app/services/slicer_api.py b/backend/app/services/slicer_api.py index 997e43b67..7428ae024 100644 --- a/backend/app/services/slicer_api.py +++ b/backend/app/services/slicer_api.py @@ -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, diff --git a/backend/tests/integration/test_library_slice_api.py b/backend/tests/integration/test_library_slice_api.py index 81e1299c9..848f7d178 100644 --- a/backend/tests/integration/test_library_slice_api.py +++ b/backend/tests/integration/test_library_slice_api.py @@ -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", "") @@ -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", "") + zf.writestr( + "Metadata/slice_info.config", + "", + ) + zf.writestr("Metadata/cut_information.xml", "") 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 diff --git a/backend/tests/unit/services/test_preset_resolver.py b/backend/tests/unit/services/test_preset_resolver.py new file mode 100644 index 000000000..67d4df5f7 --- /dev/null +++ b/backend/tests/unit/services/test_preset_resolver.py @@ -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//.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" diff --git a/backend/tests/unit/services/test_slicer_api.py b/backend/tests/unit/services/test_slicer_api.py index d8190ee12..2c50443eb 100644 --- a/backend/tests/unit/services/test_slicer_api.py +++ b/backend/tests/unit/services/test_slicer_api.py @@ -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: diff --git a/backend/tests/unit/test_slice_request_schema.py b/backend/tests/unit/test_slice_request_schema.py new file mode 100644 index 000000000..ac2c5b180 --- /dev/null +++ b/backend/tests/unit/test_slice_request_schema.py @@ -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") diff --git a/backend/tests/unit/test_slicer_presets.py b/backend/tests/unit/test_slicer_presets.py new file mode 100644 index 000000000..c74a8e1d7 --- /dev/null +++ b/backend/tests/unit/test_slicer_presets.py @@ -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 diff --git a/frontend/src/__tests__/components/SliceModal.test.tsx b/frontend/src/__tests__/components/SliceModal.test.tsx index 4580251cd..3831e18ea 100644 --- a/frontend/src/__tests__/components/SliceModal.test.tsx +++ b/frontend/src/__tests__/components/SliceModal.test.tsx @@ -1,23 +1,24 @@ /** * Tests for SliceModal. * - * The modal handles preset selection + enqueueing a slice job. After - * enqueue success it hands the job_id off to SliceJobTrackerProvider - * (which lives at app level) and calls onClose. Polling, toasts, and - * query invalidation all happen in the tracker — not here. + * The modal handles preset selection across three tiers (cloud / local / + * standard) + enqueueing a slice job. After enqueue success it hands the + * job_id off to SliceJobTrackerProvider (which lives at app level) and + * calls onClose. Polling, toasts, and query invalidation all happen in + * the tracker — not here. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { screen, waitFor } from '@testing-library/react'; +import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { render } from '../utils'; import { SliceModal } from '../../components/SliceModal'; import { SliceJobTrackerProvider } from '../../contexts/SliceJobTrackerContext'; -import { api } from '../../api/client'; +import { api, type UnifiedPresetsResponse } from '../../api/client'; vi.mock('../../api/client', () => ({ api: { - getLocalPresets: vi.fn(), + getSlicerPresets: vi.fn(), sliceLibraryFile: vi.fn(), sliceArchive: vi.fn(), getSliceJob: vi.fn(), @@ -27,17 +28,39 @@ vi.mock('../../api/client', () => ({ })); const mockApi = api as unknown as { - getLocalPresets: ReturnType; + getSlicerPresets: ReturnType; sliceLibraryFile: ReturnType; sliceArchive: ReturnType; getSliceJob: ReturnType; }; -const samplePresets = { - printer: [{ id: 1, name: 'X1C 0.4', preset_type: 'printer' }], - process: [{ id: 2, name: '0.20mm Standard', preset_type: 'process' }], - filament: [{ id: 3, name: 'Bambu PLA Basic', preset_type: 'filament' }], -}; +function makeUnified(overrides: Partial = {}): UnifiedPresetsResponse { + return { + cloud: { printer: [], process: [], filament: [] }, + local: { printer: [], process: [], filament: [] }, + standard: { printer: [], process: [], filament: [] }, + cloud_status: 'ok', + ...overrides, + }; +} + +const fullThreeTier: UnifiedPresetsResponse = makeUnified({ + cloud: { + printer: [{ id: 'PFUcloud-printer', name: 'My Custom X1C', source: 'cloud' }], + process: [{ id: 'PFUcloud-process', name: 'My 0.16mm Tweaked', source: 'cloud' }], + filament: [{ id: 'PFUcloud-filament', name: 'My PLA Black', source: 'cloud' }], + }, + local: { + printer: [{ id: '1', name: 'Imported X1C 0.4', source: 'local' }], + process: [{ id: '2', name: 'Imported 0.20mm', source: 'local' }], + filament: [{ id: '3', name: 'Imported PLA Basic', source: 'local' }], + }, + standard: { + printer: [{ id: 'Bambu Lab X1 Carbon 0.4 nozzle', name: 'Bambu Lab X1 Carbon 0.4 nozzle', source: 'standard' }], + process: [{ id: '0.20mm Standard', name: '0.20mm Standard', source: 'standard' }], + filament: [{ id: 'Bambu PLA Basic', name: 'Bambu PLA Basic', source: 'standard' }], + }, +}); function renderWithTracker(props: Parameters[0]) { return render( @@ -50,9 +73,7 @@ function renderWithTracker(props: Parameters[0]) { describe('SliceModal', () => { beforeEach(() => { vi.clearAllMocks(); - mockApi.getLocalPresets.mockResolvedValue(samplePresets); - // Tracker polls — return a still-running job so the test doesn't - // race against terminal-state side effects (toasts, invalidation). + mockApi.getSlicerPresets.mockResolvedValue(fullThreeTier); mockApi.getSliceJob.mockResolvedValue({ job_id: 42, status: 'running', @@ -65,28 +86,86 @@ describe('SliceModal', () => { }); }); - it('disables Slice button until all three presets are picked', async () => { + it('auto-selects the highest-priority tier per slot on first load', async () => { renderWithTracker({ source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' }, onClose: vi.fn(), }); - await waitFor(() => expect(screen.getByText('X1C 0.4')).toBeDefined()); - - const sliceBtn = screen.getByRole('button', { name: /^Slice$/ }); - expect((sliceBtn as HTMLButtonElement).disabled).toBe(true); - - const user = userEvent.setup(); - const selects = screen.getAllByRole('combobox'); + // The cloud tier wins — printer dropdown should land on the cloud entry. + await waitFor(() => { + expect(screen.getByText('My Custom X1C')).toBeDefined(); + }); + const selects = screen.getAllByRole('combobox') as HTMLSelectElement[]; expect(selects).toHaveLength(3); - await user.selectOptions(selects[0], '1'); - await user.selectOptions(selects[1], '2'); - expect((sliceBtn as HTMLButtonElement).disabled).toBe(true); - await user.selectOptions(selects[2], '3'); + expect(selects[0].value).toBe('cloud:PFUcloud-printer'); + expect(selects[1].value).toBe('cloud:PFUcloud-process'); + expect(selects[2].value).toBe('cloud:PFUcloud-filament'); + + // Slice button is enabled because all three slots auto-defaulted. + const sliceBtn = screen.getByRole('button', { name: /^Slice$/ }); expect((sliceBtn as HTMLButtonElement).disabled).toBe(false); }); - it('enqueues a library-file slice job and closes the modal on success', async () => { + it('renders Cloud / Imported / Standard sections via ', async () => { + renderWithTracker({ + source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' }, + onClose: vi.fn(), + }); + + await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined()); + + const printerSelect = screen.getAllByRole('combobox')[0]; + const groups = printerSelect.querySelectorAll('optgroup'); + expect(Array.from(groups).map((g) => g.label)).toEqual([ + 'Cloud', + 'Imported', + 'Standard', + ]); + + // The cloud entry sits inside the Cloud group, the local entry inside + // Imported, the standard entry inside Standard — pin the assignment so + // a future render-shape change can't quietly mix them. + const cloudGroup = groups[0]; + expect(within(cloudGroup as HTMLElement).getByText('My Custom X1C')).toBeDefined(); + const localGroup = groups[1]; + expect(within(localGroup as HTMLElement).getByText('Imported X1C 0.4')).toBeDefined(); + const standardGroup = groups[2]; + expect(within(standardGroup as HTMLElement).getByText('Bambu Lab X1 Carbon 0.4 nozzle')).toBeDefined(); + }); + + it('falls back to local when cloud is empty (auto-pick respects priority)', async () => { + mockApi.getSlicerPresets.mockResolvedValue( + makeUnified({ + local: fullThreeTier.local, + standard: fullThreeTier.standard, + }), + ); + renderWithTracker({ + source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' }, + onClose: vi.fn(), + }); + + await waitFor(() => expect(screen.getByText('Imported X1C 0.4')).toBeDefined()); + const selects = screen.getAllByRole('combobox') as HTMLSelectElement[]; + expect(selects[0].value).toBe('local:1'); + }); + + it('falls back to standard when both cloud and local are empty', async () => { + mockApi.getSlicerPresets.mockResolvedValue( + makeUnified({ standard: fullThreeTier.standard }), + ); + renderWithTracker({ + source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' }, + onClose: vi.fn(), + }); + + await waitFor(() => expect(screen.getByText('Bambu Lab X1 Carbon 0.4 nozzle')).toBeDefined()); + const selects = screen.getAllByRole('combobox') as HTMLSelectElement[]; + expect(selects[0].value).toBe('standard:Bambu Lab X1 Carbon 0.4 nozzle'); + }); + + it('sends source-aware refs (not legacy bare ints) on submit', async () => { const onClose = vi.fn(); mockApi.sliceLibraryFile.mockResolvedValue({ job_id: 42, @@ -99,25 +178,51 @@ describe('SliceModal', () => { onClose, }); - await waitFor(() => expect(screen.getByText('X1C 0.4')).toBeDefined()); + await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined()); const user = userEvent.setup(); - const selects = screen.getAllByRole('combobox'); - await user.selectOptions(selects[0], '1'); - await user.selectOptions(selects[1], '2'); - await user.selectOptions(selects[2], '3'); await user.click(screen.getByRole('button', { name: /^Slice$/ })); await waitFor(() => { expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(100, { - printer_preset_id: 1, - process_preset_id: 2, - filament_preset_id: 3, + printer_preset: { source: 'cloud', id: 'PFUcloud-printer' }, + process_preset: { source: 'cloud', id: 'PFUcloud-process' }, + filament_preset: { source: 'cloud', id: 'PFUcloud-filament' }, }); }); await waitFor(() => expect(onClose).toHaveBeenCalled()); }); + it('lets the user override the default and pick a Standard preset', async () => { + const onClose = vi.fn(); + mockApi.sliceLibraryFile.mockResolvedValue({ + job_id: 42, + status: 'pending', + status_url: '/api/v1/slice-jobs/42', + }); + + renderWithTracker({ + source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' }, + onClose, + }); + + await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined()); + + const user = userEvent.setup(); + const selects = screen.getAllByRole('combobox'); + await user.selectOptions(selects[0], 'standard:Bambu Lab X1 Carbon 0.4 nozzle'); + await user.click(screen.getByRole('button', { name: /^Slice$/ })); + + await waitFor(() => { + expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith( + 100, + expect.objectContaining({ + printer_preset: { source: 'standard', id: 'Bambu Lab X1 Carbon 0.4 nozzle' }, + }), + ); + }); + }); + it('routes archive sources to sliceArchive instead of sliceLibraryFile', async () => { const onClose = vi.fn(); mockApi.sliceArchive.mockResolvedValue({ @@ -131,13 +236,9 @@ describe('SliceModal', () => { onClose, }); - await waitFor(() => expect(screen.getByText('X1C 0.4')).toBeDefined()); + await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined()); const user = userEvent.setup(); - const selects = screen.getAllByRole('combobox'); - await user.selectOptions(selects[0], '1'); - await user.selectOptions(selects[1], '2'); - await user.selectOptions(selects[2], '3'); await user.click(screen.getByRole('button', { name: /^Slice$/ })); await waitFor(() => { @@ -155,13 +256,9 @@ describe('SliceModal', () => { onClose, }); - await waitFor(() => expect(screen.getByText('X1C 0.4')).toBeDefined()); + await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined()); const user = userEvent.setup(); - const selects = screen.getAllByRole('combobox'); - await user.selectOptions(selects[0], '1'); - await user.selectOptions(selects[1], '2'); - await user.selectOptions(selects[2], '3'); await user.click(screen.getByRole('button', { name: /^Slice$/ })); await waitFor(() => { @@ -170,8 +267,8 @@ describe('SliceModal', () => { expect(onClose).not.toHaveBeenCalled(); }); - it('shows a friendly notice when getLocalPresets fails', async () => { - mockApi.getLocalPresets.mockRejectedValue(new Error('500')); + it('shows a friendly notice when getSlicerPresets fails', async () => { + mockApi.getSlicerPresets.mockRejectedValue(new Error('500')); renderWithTracker({ source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' }, @@ -182,4 +279,49 @@ describe('SliceModal', () => { expect(screen.getByRole('alert')).toHaveTextContent(/Failed to load presets/i); }); }); + + it('renders a "sign in" banner when cloud_status is not_authenticated', async () => { + mockApi.getSlicerPresets.mockResolvedValue( + makeUnified({ + cloud_status: 'not_authenticated', + local: fullThreeTier.local, + standard: fullThreeTier.standard, + }), + ); + renderWithTracker({ + source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' }, + onClose: vi.fn(), + }); + + await waitFor(() => { + expect(screen.getByRole('status')).toHaveTextContent(/Sign in to Bambu Cloud/i); + }); + }); + + it('renders an "expired" banner when cloud_status is expired', async () => { + mockApi.getSlicerPresets.mockResolvedValue( + makeUnified({ + cloud_status: 'expired', + local: fullThreeTier.local, + }), + ); + renderWithTracker({ + source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' }, + onClose: vi.fn(), + }); + + await waitFor(() => { + expect(screen.getByRole('status')).toHaveTextContent(/expired/i); + }); + }); + + it('omits the banner entirely when cloud_status is ok', async () => { + renderWithTracker({ + source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' }, + onClose: vi.fn(), + }); + await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined()); + // No status-role banner should be rendered on the happy path. + expect(screen.queryByRole('status')).toBeNull(); + }); }); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index ccdb52ef9..59242b516 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1111,14 +1111,47 @@ export interface BuiltinFilament { } // Slice request/response — POST /library/files/{id}/slice and /archives/{id}/slice +// +// Two preset shapes are accepted per slot: +// - Legacy bare integer ids (`*_preset_id`) — pre-cloud-tier clients. +// - Source-aware refs (`*_preset: PresetRef`) — new SliceModal that picks +// across cloud / local / standard tiers. Source-aware refs win when both +// are present in the same payload. +export type PresetSource = 'cloud' | 'local' | 'standard'; +export interface PresetRef { + source: PresetSource; + id: string; +} export interface SliceRequest { - printer_preset_id: number; - process_preset_id: number; - filament_preset_id: number; + printer_preset_id?: number; + process_preset_id?: number; + filament_preset_id?: number; + printer_preset?: PresetRef; + process_preset?: PresetRef; + filament_preset?: PresetRef; plate?: number; export_3mf?: boolean; } +// GET /api/v1/slicer/presets — unified listing across cloud / local / standard. +export type SlicerCloudStatus = 'ok' | 'not_authenticated' | 'expired' | 'unreachable'; +export interface UnifiedPreset { + id: string; + name: string; + source: PresetSource; +} +export interface UnifiedPresetsBySlot { + printer: UnifiedPreset[]; + process: UnifiedPreset[]; + filament: UnifiedPreset[]; +} +export interface UnifiedPresetsResponse { + cloud: UnifiedPresetsBySlot; + local: UnifiedPresetsBySlot; + standard: UnifiedPresetsBySlot; + cloud_status: SlicerCloudStatus; +} + export interface SliceResponse { library_file_id: number; name: string; @@ -4976,6 +5009,12 @@ export const api = { getSliceJob: (jobId: number) => request(`/slice-jobs/${jobId}`), + // Unified slicer-preset listing — cloud + local + standard, deduped by name. + // Used by the SliceModal; see UnifiedPresetsResponse for the shape and + // backend/app/api/routes/slicer_presets.py for the priority rules. + getSlicerPresets: () => + request('/slicer/presets'), + // Local Presets (OrcaSlicer imports) getLocalPresets: () => request('/local-presets/'), diff --git a/frontend/src/components/SliceModal.tsx b/frontend/src/components/SliceModal.tsx index 7beea878f..e026209a9 100644 --- a/frontend/src/components/SliceModal.tsx +++ b/frontend/src/components/SliceModal.tsx @@ -1,8 +1,16 @@ -import { Cog, Loader2, X } from 'lucide-react'; -import { useState } from 'react'; +import { Cloud, CloudOff, Cog, Loader2, X } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useMutation, useQuery } from '@tanstack/react-query'; -import { api, type LocalPreset } from '../api/client'; +import { + api, + type PresetRef, + type PresetSource, + type SlicerCloudStatus, + type UnifiedPreset, + type UnifiedPresetsBySlot, + type UnifiedPresetsResponse, +} from '../api/client'; import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext'; export type SliceSource = @@ -14,30 +22,72 @@ interface SliceModalProps { onClose: () => void; } +type Slot = 'printer' | 'process' | 'filament'; + +function pickDefault(by: UnifiedPresetsResponse, slot: Slot): PresetRef | null { + // Cloud > local > standard. The endpoint already deduplicates by name, so + // no name-collision handling needed here — first non-empty tier wins. + for (const tier of ['cloud', 'local', 'standard'] as const) { + const list = by[tier][slot]; + if (list.length > 0) { + return { source: list[0].source, id: list[0].id }; + } + } + return null; +} + +function toRefValue(ref: PresetRef | null): string { + // The HTML ` onChange(e.target.value ? Number(e.target.value) : null)} - disabled={disabled} + value={toRefValue(value)} + onChange={(e) => onChange(fromRefValue(e.target.value))} + disabled={disabled || totalEntries === 0} className="w-full px-3 py-2 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm focus:outline-none focus:border-bambu-gray disabled:opacity-50" > - - {presets.map((p) => ( - + + {sections.map((section) => ( + + {section.entries.map((p) => ( + + ))} + ))}