fix(slicer-presets): #1581 SliceModal refresh + invalidate on local-profile delete/import

Two-part fix for the reporter's "removed profiles still show on the slice
  menu" symptom.

  Local half (real bug). LocalProfilesView's import and delete mutations
  invalidated ['localPresets'] (the management view's own query) but not
  ['slicerPresets'] (the SliceModal's unified preset query, staleTime 60s).
  A freshly-deleted preset kept rendering in the slice dropdown until that
  staleTime elapsed plus a refocus/remount. Both mutations now also call
  queryClient.invalidateQueries({queryKey: ['slicerPresets']}).

  Cloud half (opt-in cache bypass). _fetch_cloud_presets keeps a 5-minute
  per-(user, token) in-process cache (slicer_presets.py:69, balances
  "users see freshly-saved presets quickly" against "busy install doesn't
  hit Bambu Cloud once per modal open"). Users delete cloud presets in
  Bambu Studio / Bambu Handy, not in Bambuddy, so there's no event hook
  to invalidate on. Rather than shorten the TTL globally, the listing
  endpoint gains an opt-in ?refresh=true query param that bypasses both
  the cloud cache AND the 1-hour bundled-preset cache for that one call;
  the fresh result is still written back so subsequent normal callers
  keep hitting the cache.

  New SliceModal "Refresh" button. Lives in the preset section header
  next to the cloud-status banner. Calls getSlicerPresets({refresh: true})
  and writes the fresh slots into the ['slicerPresets'] cache via
  queryClient.setQueryData so the spinner stops immediately rather than
  triggering a second refetch. RefreshCw icon spins while in-flight;
  disabled during slice enqueue to prevent double-fire.
This commit is contained in:
maziggy
2026-06-02 12:42:16 +02:00
parent 396e9aa09e
commit 171848a0fc
19 changed files with 495 additions and 142 deletions
+32 -10
View File
@@ -16,7 +16,7 @@ import json
import logging
import time
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -88,7 +88,9 @@ 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]:
async def _fetch_cloud_presets(
db: AsyncSession, user: User | None, *, refresh: bool = False
) -> 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
@@ -96,6 +98,12 @@ async def _fetch_cloud_presets(db: AsyncSession, user: User | None) -> tuple[dic
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.
``refresh=True`` skips the in-process cache for this call (used by the
SliceModal's manual Refresh button so a user who just deleted a preset
in Bambu Studio / Handy can pick up the change without waiting for the
5-minute TTL to expire). The fresh result is still written back to the
cache so subsequent non-refresh callers benefit.
"""
if user is not None and not user.has_permission(Permission.CLOUD_AUTH.value):
return _empty_slots(), "not_authenticated"
@@ -107,9 +115,10 @@ async def _fetch_cloud_presets(db: AsyncSession, user: User | None) -> tuple[dic
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"
if not refresh:
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)
@@ -227,11 +236,15 @@ def _first_scalar(value: object) -> str | None:
return None
async def _fetch_bundled_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset]]:
"""Standard slicer-bundled profiles via the sidecar's /profiles/bundled."""
async def _fetch_bundled_presets(db: AsyncSession, *, refresh: bool = False) -> dict[str, list[UnifiedPreset]]:
"""Standard slicer-bundled profiles via the sidecar's /profiles/bundled.
``refresh=True`` skips the in-process cache; see _fetch_cloud_presets for
the same shape and rationale.
"""
global _bundled_cache
now = time.monotonic()
if _bundled_cache and now - _bundled_cache[0] < _BUNDLED_TTL_S:
if not refresh and _bundled_cache and now - _bundled_cache[0] < _BUNDLED_TTL_S:
return _bundled_cache[1]
api_url = await _resolve_slicer_api_url(db)
@@ -383,6 +396,15 @@ async def list_unified_presets(
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
refresh: bool = Query(
False,
description=(
"Bypass the in-process cloud and bundled-preset caches for this "
"request. The SliceModal's Refresh button sets this so users who "
"deleted a preset in Bambu Studio or Bambu Handy don't have to "
"wait for the 5-minute cloud-cache TTL to expire."
),
),
) -> UnifiedPresetsResponse:
"""List slicer presets across cloud / local / standard tiers, deduped by name.
@@ -399,9 +421,9 @@ async def list_unified_presets(
too — matching the slice route (#1182 follow-up).
"""
cloud_token_user = current_user or api_key_cloud_owner
cloud, cloud_status = await _fetch_cloud_presets(db, cloud_token_user)
cloud, cloud_status = await _fetch_cloud_presets(db, cloud_token_user, refresh=refresh)
local = await _fetch_local_presets(db)
standard = await _fetch_bundled_presets(db)
standard = await _fetch_bundled_presets(db, refresh=refresh)
cloud, local, standard = _dedupe_by_name(cloud, local, standard)