mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 15:47:04 +02:00
feat(slicer): unified Cloud/local/standard presets + harden 3MF profile path
UNIFIED PRESET LISTING (the main feature)
The initial slicer integration only saw DB-backed local imports — users
without imported profiles got an empty Slice modal even when their
Bambu Cloud account or the slicer sidecar carried perfectly usable
presets. The Slice modal now pulls from three tiers in priority order:
- cloud: user's own Bambu Cloud presets, fetched live.
- local: DB-backed imports.
- standard: slicer-bundled stock profiles via the sidecar's new
GET /profiles/bundled endpoint.
Listing endpoint: GET /api/v1/slicer/presets
- Name-based dedup, cloud > local > standard, within-tier order
preserved exactly. A preset that exists in multiple tiers only
renders in the highest-priority one.
- cloud_status (ok / not_authenticated / expired / unreachable)
drives a precise modal banner instead of an unexplained empty
list.
- Cloud branch: per-user cache, 5 min TTL, key
(user_id, sha256(token)[:16]) so logout/login or token rotation
auto-invalidates without callback wiring from the cloud-auth
routes.
- Bundled branch: global cache, 1 h TTL.
- Bundled URL respects preferred_slicer (bambu_studio vs orcaslicer)
so BambuStudio installs see the bambu sidecar's bundled list, not
OrcaSlicer's.
Slicing endpoint: POST /library/files/{id}/slice + /archives/{id}/slice
- Body now accepts source-aware {source, id} triplets per slot:
printer_preset: PresetRef
process_preset: PresetRef
filament_preset: PresetRef
- Legacy *_preset_id integer fields kept for backwards-compat. The
schema validator normalises bare ints into
PresetRef(source='local', id=str(int)) so the route handler only
deals with one shape.
New preset_resolver service fetches the JSON content per source:
- cloud: BambuCloudService.get_setting_detail(id), unwraps the
`setting` envelope (falls back to top-level for minor
shape variants).
- local: DB read with preset_type slot validation (existing path,
factored into the new helper).
- standard: minimal {name, inherits, from: "system"} stub — the
sidecar's profile-resolver flattens it against
BUNDLED_PROFILES_PATH/<category>/<name>.json with no
preset-content round-trip from Bambuddy.
PERMISSIONS
- Listing route gate: LIBRARY_UPLOAD (matches the slice action — any
user who can slice can populate the dropdowns).
- Cloud branch in BOTH the listing helper and the resolver checks
CLOUD_AUTH independently — a user with LIBRARY_UPLOAD but not
CLOUD_AUTH doesn't see the cloud tier (returns 403 if they try
to slice with a cloud preset) even if a leftover User.cloud_token
survived a permission revocation. Cloud listing path
short-circuits the token lookup entirely on the gate-fail branch.
FRONTEND — SliceModal
- Calls api.getSlicerPresets() instead of api.getLocalPresets().
- Dropdowns render <optgroup> per tier with localised section
labels (Cloud / Imported / Standard).
- Default selection follows cloud > local > standard priority on
first load (auto-pick fires once when the data arrives, manual
choices stick after that).
- Cloud-status banner renders three variants
(sign-in / expired / unreachable) only when status != 'ok'.
- Slice button submits source-aware refs; legacy integer payload
is preserved server-side for older clients.
3MF PROFILE-PATH HARDENING (shipped together because they touch the
same code paths)
(1) Strip widened. _strip_3mf_embedded_settings only removed
Metadata/project_settings.config. Real-world Bambu Studio /
OrcaSlicer 3MFs also carry model_settings.config, slice_info.config,
and cut_information.xml — any single leftover trips the CLI's
input validation and the slice falls back to embedded settings,
making the SliceModal's profile picker theatrical for 3MF inputs.
Now removes all four configs via a centralised
_STRIPPABLE_3MF_CONFIGS frozenset with per-file rationale;
geometry (3D/3dmodel.model), thumbnails, multi-part data
preserved.
(2) Sidecar 5xx error capture. slicer_api.py was reading only
`message` from sidecar 5xx responses and dropping `details`, so
every CLI failure surfaced as the unhelpful generic
"Failed to slice the model". New _format_sidecar_error helper
combines both fields, falls back to plain-text body for
non-JSON 5xx (nginx 502s, gateway timeouts), replaces the four
duplicated extraction blocks. Pairs with the orca-slicer-api
fork's bambuddy/profile-resolver branch which now emits
`details` on AppError responses (d9c6121) and captures CLI
stderr in the failure path (fb928c8).
CARE TAKEN — additive on existing surfaces
- main.py: +1 import, +1 router register
- slicer_api.py: +list_bundled_profiles, +_format_sidecar_error
(dedupes the 4 message-extraction blocks);
no existing method behaviour changed
- library.py: resolver swap inside _run_slicer_with_fallback,
user_id threaded through two callers,
strip widened
- schemas/slicer.py: PresetRef added, *_preset fields added,
legacy *_preset_id kept; validator normalises
- 4 new files: schema, route, resolver, tests
- No existing route URL changed, no existing field removed, no
behaviour change for clients still sending bare integer ids.
TESTS
- 17 unit tests for the listing endpoint helpers
- 11 unit tests for the source-aware resolver
- 6 schema tests for SliceRequest legacy + new shapes
- 3 unit tests for the new sidecar error-detail capture
- Strip integration test extended to assert all 4 configs go and
geometry stays
- 12 frontend tests for SliceModal covering tier-priority
auto-selection, <optgroup> grouping, fallback paths, source-aware
payload on submit, manual override across tiers, archive vs
library routing, error display, all three banner variants
Verified: 3394 backend + 1531 frontend tests pass, ruff clean,
frontend production build clean.
Pairs with three already-pushed commits on the orca-slicer-api fork's
bambuddy/profile-resolver branch:
- 5fd6bc6 feat(profiles): add GET /profiles/bundled
- d9c6121 fix(error): include causeMessage in JSON response as `details`
- fb928c8 fix(slicing): include CLI stdout/stderr in failure causeMessage
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user