slicer_presets.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. """Unified slicer-preset listing for the SliceModal (#wiki / Cloud-aware presets).
  2. Returns the printer/process/filament options grouped by source tier in
  3. priority order — cloud (per-user, live-fetched) > local (DB-backed
  4. imports) > standard (slicer-bundled stock fallback). Name-based dedup is
  5. applied so a preset that exists in multiple tiers only appears in the
  6. highest-priority one. Cloud failure modes (signed out / expired / network)
  7. are surfaced via a status field so the modal can render a precise banner
  8. without faking an "ok with empty list" response.
  9. """
  10. from __future__ import annotations
  11. import hashlib
  12. import logging
  13. import time
  14. from fastapi import APIRouter, Depends
  15. from sqlalchemy import select
  16. from sqlalchemy.ext.asyncio import AsyncSession
  17. from backend.app.api.routes.cloud import get_stored_token
  18. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  19. from backend.app.core.config import settings as app_settings
  20. from backend.app.core.database import get_db
  21. from backend.app.core.permissions import Permission
  22. from backend.app.models.local_preset import LocalPreset
  23. from backend.app.models.user import User
  24. from backend.app.schemas.slicer_presets import (
  25. UnifiedPreset,
  26. UnifiedPresetsBySlot,
  27. UnifiedPresetsResponse,
  28. )
  29. from backend.app.services.bambu_cloud import (
  30. BambuCloudAuthError,
  31. BambuCloudError,
  32. BambuCloudService,
  33. )
  34. from backend.app.services.slicer_api import SlicerApiError, SlicerApiService
  35. logger = logging.getLogger(__name__)
  36. router = APIRouter(prefix="/slicer", tags=["Slicer Presets"])
  37. # In-process cache for the bundled-profile list. The slicer sidecar walks a
  38. # read-only filesystem inside its own container, so the list only changes
  39. # across sidecar rebuilds — a long TTL is safe and avoids a sidecar round-trip
  40. # on every modal open. Per-user cache is unnecessary because bundled profiles
  41. # are global.
  42. _BUNDLED_TTL_S = 3600.0
  43. _bundled_cache: tuple[float, dict[str, list[UnifiedPreset]]] | None = None
  44. # Per-user cache for the cloud preset list. Cache key is (user_id, token_hash):
  45. # keying on the token hash means a logout/login or token-change automatically
  46. # invalidates the entry without needing the cloud-auth route handlers to call
  47. # back into this module. 5 minutes balances "users see their freshly-saved
  48. # presets quickly" against "a busy install doesn't hit the cloud once per
  49. # modal open per user".
  50. _CLOUD_TTL_S = 300.0
  51. _cloud_cache: dict[tuple[int, str], tuple[float, dict[str, list[UnifiedPreset]]]] = {}
  52. def _token_fingerprint(token: str) -> str:
  53. """Short stable hash of the cloud token for use as a cache-key component.
  54. Storing only the hash means we can safely keep multiple per-(user, token)
  55. entries without leaking the token via the in-process dict."""
  56. return hashlib.sha256(token.encode("utf-8")).hexdigest()[:16]
  57. _CLOUD_TYPE_TO_SLOT = {
  58. "filament": "filament",
  59. "printer": "printer",
  60. "print": "process", # Bambu Cloud calls process presets "print"
  61. }
  62. def _empty_slots() -> dict[str, list[UnifiedPreset]]:
  63. return {"printer": [], "process": [], "filament": []}
  64. async def _fetch_cloud_presets(db: AsyncSession, user: User | None) -> tuple[dict[str, list[UnifiedPreset]], str]:
  65. """Return (slots, cloud_status). Slots are empty when cloud_status != 'ok'.
  66. Defence-in-depth: even if a stored cloud_token survived a permission
  67. revocation (admin reset, legacy state), users without ``CLOUD_AUTH`` are
  68. treated as not-authenticated for this endpoint — the cloud tier never
  69. surfaces for them. This keeps the per-tier visibility consistent with the
  70. /cloud/* endpoint suite that already gates on CLOUD_AUTH.
  71. """
  72. if user is not None and not user.has_permission(Permission.CLOUD_AUTH.value):
  73. return _empty_slots(), "not_authenticated"
  74. token, _email, region = await get_stored_token(db, user)
  75. if not token:
  76. return _empty_slots(), "not_authenticated"
  77. user_key = user.id if user is not None else 0
  78. cache_key = (user_key, _token_fingerprint(token))
  79. now = time.monotonic()
  80. cached = _cloud_cache.get(cache_key)
  81. if cached and now - cached[0] < _CLOUD_TTL_S:
  82. return cached[1], "ok"
  83. cloud = BambuCloudService(region=region)
  84. cloud.set_token(token)
  85. try:
  86. raw = await cloud.get_slicer_settings()
  87. except BambuCloudAuthError:
  88. # Don't clear the token here — the cloud-status endpoint owns that
  89. # lifecycle. Just report expired so the UI can prompt re-auth.
  90. return _empty_slots(), "expired"
  91. except BambuCloudError as e:
  92. logger.warning("Cloud preset fetch failed for user %s: %s", user_key, e)
  93. return _empty_slots(), "unreachable"
  94. except Exception as e: # noqa: BLE001 — defensive: never crash the modal
  95. logger.warning("Cloud preset fetch unexpected error for user %s: %s", user_key, e)
  96. return _empty_slots(), "unreachable"
  97. finally:
  98. await cloud.close()
  99. slots = _empty_slots()
  100. for cloud_type, slot in _CLOUD_TYPE_TO_SLOT.items():
  101. type_data = raw.get(cloud_type, {})
  102. # The cloud splits presets into "private" (the user's own) and "public"
  103. # (Bambu's stock cloud presets). Both are valid choices — surface them
  104. # in the natural order private → public so a user's customisations
  105. # appear above the stock entries with the same names. Stock entries
  106. # that share names with private ones get deduped out within the cloud
  107. # tier itself.
  108. seen_names: set[str] = set()
  109. for entry in type_data.get("private", []) + type_data.get("public", []):
  110. name = entry.get("name")
  111. setting_id = entry.get("setting_id") or entry.get("id")
  112. if not name or not setting_id or name in seen_names:
  113. continue
  114. seen_names.add(name)
  115. slots[slot].append(UnifiedPreset(id=setting_id, name=name, source="cloud"))
  116. _cloud_cache[cache_key] = (now, slots)
  117. return slots, "ok"
  118. async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset]]:
  119. """Local imports — no caching needed, single indexed DB read."""
  120. result = await db.execute(select(LocalPreset).order_by(LocalPreset.name))
  121. presets = result.scalars().all()
  122. slots = _empty_slots()
  123. type_to_slot = {"filament": "filament", "printer": "printer", "process": "process"}
  124. for p in presets:
  125. slot = type_to_slot.get(p.preset_type)
  126. if slot is None:
  127. continue
  128. slots[slot].append(UnifiedPreset(id=str(p.id), name=p.name, source="local"))
  129. return slots
  130. async def _fetch_bundled_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset]]:
  131. """Standard slicer-bundled profiles via the sidecar's /profiles/bundled."""
  132. global _bundled_cache
  133. now = time.monotonic()
  134. if _bundled_cache and now - _bundled_cache[0] < _BUNDLED_TTL_S:
  135. return _bundled_cache[1]
  136. api_url = await _resolve_slicer_api_url(db)
  137. if not api_url:
  138. # No sidecar configured at all — return empty rather than caching, so
  139. # users who configure one mid-session see results on next open.
  140. return _empty_slots()
  141. try:
  142. async with SlicerApiService(base_url=api_url) as svc:
  143. raw = await svc.list_bundled_profiles()
  144. except SlicerApiError as e:
  145. logger.info("Bundled preset fetch from sidecar at %s failed: %s", api_url, e)
  146. return _empty_slots()
  147. except Exception as e: # noqa: BLE001 — never break the modal on sidecar issues
  148. logger.warning("Bundled preset fetch unexpected error: %s", e)
  149. return _empty_slots()
  150. slots = _empty_slots()
  151. for slot in ("printer", "process", "filament"):
  152. for entry in raw.get(slot, []) or []:
  153. name = entry.get("name")
  154. if not name:
  155. continue
  156. # Bundled presets are addressed by name (the slicer resolves them
  157. # by name during the `inherits:` walk), so name doubles as id.
  158. slots[slot].append(UnifiedPreset(id=name, name=name, source="standard"))
  159. _bundled_cache = (now, slots)
  160. return slots
  161. async def _resolve_slicer_api_url(db: AsyncSession) -> str | None:
  162. """Pick the sidecar URL the bundled-listing fetch should hit.
  163. Mirrors the slice route's resolution at ``library.py:_run_slicer_with_fallback``:
  164. the user's ``preferred_slicer`` setting decides which sidecar Bambuddy
  165. talks to, and the per-install URL setting overrides the env default.
  166. A user who prefers Bambu Studio gets the *bambu-studio-api* sidecar's
  167. bundled list; a user who prefers OrcaSlicer gets the *orca-slicer-api*
  168. sidecar's bundled list. Without this branch the listing would always
  169. hit OrcaSlicer (port 3003) even for BambuStudio installs (port 3001),
  170. leaving the Standard tier permanently empty for them.
  171. """
  172. from backend.app.api.routes.settings import get_setting
  173. preferred = (await get_setting(db, "preferred_slicer")) or "bambu_studio"
  174. if preferred == "orcaslicer":
  175. configured = await get_setting(db, "orcaslicer_api_url")
  176. url = (configured or app_settings.slicer_api_url).strip()
  177. elif preferred == "bambu_studio":
  178. configured = await get_setting(db, "bambu_studio_api_url")
  179. url = (configured or app_settings.bambu_studio_api_url).strip()
  180. else:
  181. # Unknown preference — return None so the bundled tier is empty
  182. # rather than crashing the modal. The slice route raises 400 here;
  183. # we degrade silently because the modal's listing is informational.
  184. logger.warning("Unknown preferred_slicer setting: %r — bundled tier disabled", preferred)
  185. return None
  186. return url or None
  187. def _dedupe_by_name(
  188. cloud: dict[str, list[UnifiedPreset]],
  189. local: dict[str, list[UnifiedPreset]],
  190. standard: dict[str, list[UnifiedPreset]],
  191. ) -> tuple[
  192. dict[str, list[UnifiedPreset]],
  193. dict[str, list[UnifiedPreset]],
  194. dict[str, list[UnifiedPreset]],
  195. ]:
  196. """Filter so each preset name appears in exactly one tier (cloud > local > standard).
  197. Order within each tier is preserved as-is — only "lower-priority duplicates"
  198. are dropped. A preset shared across tiers (e.g. "Bambu PLA Basic" in cloud
  199. public AND standard bundled) only renders once, in the cloud tier.
  200. """
  201. deduped_local = _empty_slots()
  202. deduped_standard = _empty_slots()
  203. for slot in ("printer", "process", "filament"):
  204. seen = {p.name for p in cloud[slot]}
  205. for p in local[slot]:
  206. if p.name in seen:
  207. continue
  208. deduped_local[slot].append(p)
  209. seen.add(p.name)
  210. for p in standard[slot]:
  211. if p.name in seen:
  212. continue
  213. deduped_standard[slot].append(p)
  214. seen.add(p.name)
  215. return cloud, deduped_local, deduped_standard
  216. @router.get("/presets", response_model=UnifiedPresetsResponse)
  217. async def list_unified_presets(
  218. db: AsyncSession = Depends(get_db),
  219. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
  220. ) -> UnifiedPresetsResponse:
  221. """List slicer presets across cloud / local / standard tiers, deduped by name.
  222. Drives the SliceModal preset dropdowns. Permission gate matches the
  223. slice action itself (``LIBRARY_UPLOAD``) so any user who can slice can
  224. see the preset options for the dialog. The cloud branch is independently
  225. gated on ``CLOUD_AUTH`` inside ``_fetch_cloud_presets`` so a user with
  226. only ``LIBRARY_UPLOAD`` doesn't see cloud presets they shouldn't have
  227. access to.
  228. """
  229. cloud, cloud_status = await _fetch_cloud_presets(db, current_user)
  230. local = await _fetch_local_presets(db)
  231. standard = await _fetch_bundled_presets(db)
  232. cloud, local, standard = _dedupe_by_name(cloud, local, standard)
  233. return UnifiedPresetsResponse(
  234. cloud=UnifiedPresetsBySlot(**cloud),
  235. local=UnifiedPresetsBySlot(**local),
  236. standard=UnifiedPresetsBySlot(**standard),
  237. cloud_status=cloud_status,
  238. )