preset_resolver.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. """Resolve a `PresetRef` (source + id) to the JSON-string content the
  2. slicer-api sidecar's `/slice` endpoint expects.
  3. Three sources, three paths:
  4. - **local** — read ``LocalPreset.setting`` from the DB. Existing pre-PR
  5. behaviour for the slicer integration; preserved verbatim
  6. so clients still sending bare integer ids see no change.
  7. - **cloud** — fetch ``BambuCloudService.get_setting_detail(id)`` for the
  8. caller's stored cloud token. Result is the full slicer-shape
  9. preset JSON the sidecar can ingest directly.
  10. - **standard** — emit a stub ``{inherits: <name>, from: "system"}``. The
  11. sidecar's `bambuddy/profile-resolver` branch already walks
  12. ``inherits:`` against ``BUNDLED_PROFILES_PATH/<category>/<name>.json``
  13. during ``materializeProfile`` and merges parent-then-child,
  14. so the stub flattens out to the bundled content with no
  15. round-trip needed for the JSON itself.
  16. All three return the JSON as a *string* because that's what
  17. ``SlicerApiService.slice_with_profiles`` accepts as
  18. ``printer_profile_json`` etc. — the sidecar parses it once.
  19. """
  20. from __future__ import annotations
  21. import json
  22. import logging
  23. from fastapi import HTTPException
  24. from sqlalchemy.ext.asyncio import AsyncSession
  25. from backend.app.api.routes.cloud import get_stored_token
  26. from backend.app.core.permissions import Permission
  27. from backend.app.models.local_preset import LocalPreset
  28. from backend.app.models.user import User
  29. from backend.app.schemas.slicer import PresetRef
  30. from backend.app.services.bambu_cloud import (
  31. BambuCloudAuthError,
  32. BambuCloudError,
  33. BambuCloudService,
  34. )
  35. logger = logging.getLogger(__name__)
  36. _SLOT_TO_BUNDLED_CATEGORY = {
  37. "printer": "machine",
  38. "process": "process",
  39. "filament": "filament",
  40. }
  41. async def resolve_preset_ref(
  42. db: AsyncSession,
  43. user: User | None,
  44. ref: PresetRef,
  45. slot: str,
  46. ) -> str:
  47. """Return the JSON-string content for `ref` so the sidecar can ingest it.
  48. `slot` is one of ``"printer"`` / ``"process"`` / ``"filament"``; it's
  49. only used to generate friendly error messages and to pick the bundled
  50. category for the standard tier.
  51. Raises ``HTTPException`` for any caller-facing error (invalid id, wrong
  52. preset type, cloud auth failure, network error fetching cloud detail).
  53. """
  54. if ref.source == "local":
  55. return await _resolve_local(db, ref, slot)
  56. if ref.source == "cloud":
  57. return await _resolve_cloud(db, user, ref, slot)
  58. if ref.source == "standard":
  59. return _resolve_standard(ref, slot)
  60. raise HTTPException(
  61. status_code=400,
  62. detail=f"Unknown preset source for {slot}: {ref.source!r}",
  63. )
  64. async def _resolve_local(db: AsyncSession, ref: PresetRef, slot: str) -> str:
  65. try:
  66. local_id = int(ref.id)
  67. except (ValueError, TypeError):
  68. raise HTTPException(status_code=400, detail=f"Invalid local preset id for {slot}: {ref.id!r}") from None
  69. preset = await db.get(LocalPreset, local_id)
  70. if preset is None or preset.preset_type != slot:
  71. raise HTTPException(
  72. status_code=400,
  73. detail=f"Invalid {slot} preset id (expected preset_type='{slot}')",
  74. )
  75. return preset.setting
  76. async def _resolve_cloud(db: AsyncSession, user: User | None, ref: PresetRef, slot: str) -> str:
  77. """Fetch a single cloud preset detail. Permission gate matches the
  78. rest of the cloud surface (`CLOUD_AUTH`) so a user with `LIBRARY_UPLOAD`
  79. but no `CLOUD_AUTH` can't slice using cloud presets even if their
  80. ``User.cloud_token`` survived a permission revocation."""
  81. if user is not None and not user.has_permission(Permission.CLOUD_AUTH.value):
  82. raise HTTPException(
  83. status_code=403,
  84. detail=f"Cloud presets require the cloud:auth permission ({slot})",
  85. )
  86. token, _email, region = await get_stored_token(db, user)
  87. if not token:
  88. raise HTTPException(
  89. status_code=400,
  90. detail=(
  91. f"Cloud preset selected for {slot}, but no Bambu Cloud session is "
  92. "stored. Sign in to Bambu Cloud and retry."
  93. ),
  94. )
  95. cloud = BambuCloudService(region=region)
  96. cloud.set_token(token)
  97. try:
  98. detail = await cloud.get_setting_detail(ref.id)
  99. except BambuCloudAuthError:
  100. raise HTTPException(
  101. status_code=401,
  102. detail=(f"Bambu Cloud session expired while fetching {slot} preset. Sign in again and retry."),
  103. ) from None
  104. except BambuCloudError as e:
  105. raise HTTPException(
  106. status_code=502,
  107. detail=f"Bambu Cloud unreachable while fetching {slot} preset: {e}",
  108. ) from e
  109. finally:
  110. await cloud.close()
  111. # `get_setting_detail` returns the wrapper envelope; the actual preset
  112. # JSON lives under `.setting`. The sidecar wants the preset content, not
  113. # the envelope.
  114. payload = detail.get("setting") if isinstance(detail, dict) else None
  115. if not isinstance(payload, dict):
  116. # Some endpoints return the preset at the top level instead of
  117. # nested under `setting`. Fall back to the whole response in that
  118. # case rather than failing — the sidecar will reject it cleanly if
  119. # the shape is genuinely wrong, and we log the unusual response.
  120. logger.info(
  121. "Cloud preset %r for %s returned unexpected shape, forwarding raw payload",
  122. ref.id,
  123. slot,
  124. )
  125. payload = detail
  126. return json.dumps(payload)
  127. def _resolve_standard(ref: PresetRef, slot: str) -> str:
  128. """Build a minimal `{inherits: <name>}` stub. The sidecar's resolver
  129. walks `BUNDLED_PROFILES_PATH/<category>/<name>.json` and merges,
  130. yielding the full bundled preset without us round-tripping the content
  131. through Bambuddy."""
  132. if slot not in _SLOT_TO_BUNDLED_CATEGORY:
  133. raise HTTPException(status_code=400, detail=f"Unknown slot for standard preset: {slot!r}")
  134. return json.dumps(
  135. {
  136. # `name` must be set so the sidecar's compatibility checks see a
  137. # populated value. Reusing the bundled name keeps the resolved
  138. # profile's identity consistent with what the user picked.
  139. "name": ref.id,
  140. "inherits": ref.id,
  141. # `from: "system"` skips the User/system compatibility rejection
  142. # the resolver was designed to fix for OrcaSlicer GUI exports —
  143. # we never want a bundled preset to be treated as User-authored.
  144. "from": "system",
  145. }
  146. )