fix(backup): collect cloud profiles from every connected account (#2717)

Enabling Cloud Profiles for a Git backup produced nothing, and said it had
    worked. Two independent faults, either one sufficient.

    The collector looked for a "setting" list. The Bambu Cloud listing endpoint
    is keyed by preset type instead, each key holding private and public arrays,
    so the loop body never executed once — and the entries carry no type of
    their own either, which routes/cloud.py already knew: it takes the type from
    the outer key and maps Bambu's "print" to process. Two bugs on one line.

    It also asked build_authenticated_cloud for the credential store used when
    authentication is disabled. With auth on, tokens live on User rows, so the
    collector returned at "Cloud not authenticated" before ever reaching the bad
    key. Every multi-user install was collecting from zero accounts.

    Neither failure surfaced. backup_metadata.json recorded the configured flag
    rather than the outcome, so it claimed cloud_profiles: true on runs that
    wrote nothing, and the log read "Collected cloud profiles: 0 filament, 0
    printer, 0 process" at INFO — which is exactly what a successful backup of
    an empty account looks like.

    Cloud profiles now come from every connected account across both clouds. The
    toggle predates Orca Cloud entirely, and Orca has the same three preset
    types, so both are collected and grouped the same way:

        cloud_profiles/bambu/user-3/{filament,printer,process}.json
        cloud_profiles/orca/user-3/{filament,printer,process}.json

    Accounts are keyed by Bambuddy user id, "global" when auth is off. Never by
    email: a backup repository can be public, and the Bambu listing's user_id is
    dropped for the same reason. Both credential stores are read on every run,
    because a Settings row survives someone enabling auth later and dropping it
    would silently stop backing that account up.

    Bambu costs one get_setting_detail per private preset. The listing is
    metadata only, and without base_id and setting the backup is a list of names
    that create_setting cannot rebuild from. Public presets are skipped — Bambu's
    bundled catalogue is the same hundreds of entries for everyone, always
    re-downloadable, not recreatable under your account, and would rewrite the
    repository on every run. Orca needs no second call; its sync-pull carries
    each profile's content inline. Where the Orca route drops a profile whose
    content.type it cannot map, the backup writes it to other.json instead:
    silently omitting a profile because Orca added a type is the same class of
    bug as this one.

    Failures are contained per account and per preset, and counted rather than
    swallowed. A partial backup that looks complete is how this stayed invisible.

    The metadata now reports what was collected, per cloud and per account, and a
    run that collects nothing while the category is enabled warns with the reason
    instead of an INFO line that reads like success.

    The checkbox gated on the viewer's own Bambu sign-in, which is not the same
    question as whether there is anything to back up — with auth enabled the
    accounts belong to individual users, and an administrator who never signed
    in personally saw the category disabled with plenty in scope. It now gates
    on the total across both clouds and shows the counts. That comes from its
    own endpoint rather than a field on /config, since /config answers null
    until the first save and would disable the toggle during the very setup it
    belongs to. Counts only, never identities.

    One deliberate restraint. _build_authenticated_service clears stored
    credentials when a refresh is rejected, which is right for a route — the
    user is on the page and can pair again — and wrong for a scheduled job.
    Orca reports every rejection with one composite reason ("unknown, expired,
    revoked, or already used"), so a genuine revocation cannot be told apart
    from a lost token-rotation race, and acting destructively on a signal that
    cannot be disambiguated is the #2562 mistake in a different cloud. It also
    gains nothing: the Profiles route hits the same failure and clears it then,
    with the user present. Background callers now pass clear_on_auth_failure=
    False and skip the account. A successful refresh is still persisted either
    way — by that point the old token is consumed, so dropping the new pair
    would break a working pairing for real.

    Restore is not part of this. Nothing reads cloud_profiles/* yet; the format
    carries base_id/setting for Bambu and content for Orca so that it can.
This commit is contained in:
maziggy
2026-08-02 09:52:12 +02:00
24 changed files with 1130 additions and 93 deletions
+3
View File
@@ -10,6 +10,9 @@ All notable changes to Bambuddy will be documented in this file.
## [1.2.5.1] - 2026-07-27
### Fixed
- **Git backup now actually writes cloud profiles (#2717, reporter @jmoore-skild)** — Enabling **Cloud Profiles** for a Git backup produced nothing. The collector looked for a `setting` list in the Bambu Cloud response, which is keyed by preset type instead, so the loop never ran once — and it asked for the credential store used when authentication is *disabled*, so on any install with authentication on it found no account to collect from in the first place. Neither failure was visible: `backup_metadata.json` still recorded `cloud_profiles: true`, and the log line read `Collected cloud profiles: 0 filament, 0 printer, 0 process`, which looks like a successful backup of an empty account. Cloud profiles are now collected from **every connected account across both Bambu Cloud and Orca Cloud**, one directory per cloud per account, keyed by user ID so no email address is written into a backup repository. Bambu presets are stored with the payload needed to recreate them rather than just their names, and Bambu's bundled public catalogue is skipped — it is identical for everyone, re-downloadable, and would rewrite the repository on every run. The metadata now records what was actually collected, per cloud and per account, and a run that collects nothing while the category is enabled says so as a warning instead of an INFO line that reads like success. The Cloud Profiles checkbox no longer keys off your own Bambu sign-in — it enables when *any* account is connected and shows how many are in scope, which matters on a multi-user install where the presets being backed up are other people's. A backup also no longer disconnects an Orca Cloud account whose session it can't refresh: Orca reports every rejection with one composite reason, so a genuine revocation is indistinguishable from a lost token-rotation race, and an unattended job should not be the thing that guesses. The account is skipped with a warning, and the dead credentials are cleared the next time you open Orca Cloud Profiles — where you can pair again on the spot. Translated in all locales; wiki updated. Covered by backend tests.
### Added
- **Keep the AMS slots the slicer picked (#2700, contributor @Striker72rus)** — Bambu Studio and OrcaSlicer resolve which physical AMS tray feeds each filament themselves, right before sending. Bambuddy threw that away: a queue-mode virtual printer worked the mapping out again at dispatch time, from the filament type and colour baked into the 3MF. That is usually the better answer — it is computed against the printer's live trays and it respects **Prefer lowest filament** and the AMS-backup gate that goes with it — but it has nothing to go on when the match isn't unique. Two spools of the same red PLA, and the slot you deliberately chose in the slicer is a coin toss. A new per-virtual-printer **Save AMS mapping** toggle keeps the slicer's pick instead: the print dispatches to exactly those trays, and the mapping is stored on the archive so a reprint can reuse the same physical spools — a **Mapping** button in the print modal selects every slot from it in one click, and the archive card and queue row say so. Because a tray number only means something on the AMS it was resolved against, the mapping records its printer and is only ever offered on that same printer; a model-based ("Any [model]") virtual printer has no fixed printer and is unaffected. Off by default, so nothing changes for existing virtual printers until you turn it on, and **Force color match** still wins for the print being dispatched when both are on. Translated in all locales; wiki updated. Covered by backend and frontend tests.
- **P2S/X2D accessory fans: left auxiliary cooling and chamber exhaust (#2691, contributor @gzimbric, requested in #2660)** — The P2S and X2D have two fans Bambuddy could not show or drive. The **left auxiliary part cooling fan** had no tile and no control at all, because the printer only reports it inside its air-duct data and never in the ordinary fan fields Bambuddy was reading. The **chamber exhaust fan** had the opposite problem: its tile appeared on every P2S whether or not the fan was fitted, so owners of a base machine had a control that did nothing. Both are add-on kits on the P2S and fitted at the factory on the X2D. Both tiles now appear only when the printer itself reports the hardware, so a base P2S looks exactly as it does today and a kitted one gains the fans it actually has. The left auxiliary fan is set from the same speed popover as the others, and the enclosure fan is labelled **Exhaust** on the P2S and X2D — matching the printer's own screen and Bambu Studio — while every other enclosed model keeps **Chamber Fan**. The confirmation message after changing a speed uses the same name as the tile that was clicked. The four tiles are ordered part cooling, left auxiliary, auxiliary, exhaust, so they read left to right in the same order as the physical fans. Both fields are also published through the status endpoint, the WebSocket feed and the MQTT relay, so external automations can read them. Translated in all locales; wiki updated. Covered by backend and frontend tests, including the case where the printer sends a partial fan report — a tile must not disappear mid-print just because one update didn't mention it.
+34
View File
@@ -12,6 +12,7 @@ from backend.app.core.permissions import Permission
from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
from backend.app.models.user import User
from backend.app.schemas.github_backup import (
CloudAccountCounts,
GitHubBackupConfigCreate,
GitHubBackupConfigResponse,
GitHubBackupConfigUpdate,
@@ -75,6 +76,39 @@ async def _enforce_private_repo(repo_url: str, token: str, provider: str) -> Non
raise HTTPException(status_code=400, detail=_PUBLIC_REPO_ERROR)
async def _count_cloud_accounts(db: AsyncSession) -> tuple[int, int]:
"""How many Bambu / Orca accounts a backup would collect from.
Asks the collector itself rather than re-deriving the rule, so the number
the UI gates on can't drift from the number the backup actually uses
(#2717). Counts only — never who.
"""
try:
bambu, orca = await github_backup_service.cloud_accounts(db)
return len(bambu), len(orca)
except Exception:
# A settings page must still render when a credential store is
# unreadable; the toggle simply shows as unavailable.
logger.warning("Failed to count connected cloud accounts", exc_info=True)
return 0, 0
@router.get("/cloud-accounts", response_model=CloudAccountCounts)
async def get_cloud_accounts(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
):
"""How many cloud accounts the Cloud Profiles category would collect from.
Its own endpoint rather than a field on ``/config``, because the settings
form needs this before any config exists — ``/config`` answers ``null``
until the first save, which would leave the toggle disabled during the
very setup it's part of.
"""
bambu, orca = await _count_cloud_accounts(db)
return CloudAccountCounts(bambu=bambu, orca=orca)
def _config_to_response(config: GitHubBackupConfig) -> dict:
"""Convert config model to response dict."""
return {
+24 -3
View File
@@ -431,6 +431,7 @@ async def _upsert_settings(db: AsyncSession, values: dict[str, str | None]) -> N
async def _build_authenticated_service(
db: AsyncSession,
user: User | None,
clear_on_auth_failure: bool = True,
) -> OrcaCloudService:
"""Construct an :class:`OrcaCloudService` pre-populated with stored
credentials. If the access token is within the refresh-leeway of expiry,
@@ -440,7 +441,24 @@ async def _build_authenticated_service(
We don't lock around the refresh: Orca tolerates concurrent refreshes for
~60s (each racer gets its own valid pair on the same connection rather than
a revoke), so a lost race here is harmless — last-write-wins on the stored
pair, and whichever pair we keep is valid."""
pair, and whichever pair we keep is valid.
``clear_on_auth_failure`` controls what happens when the refresh is
rejected. Routes leave it on: the caller is a person looking at the UI, and
wiping the dead credentials flips the page to disconnected in front of them
so they can pair again. Background jobs pass ``False`` — see the caveat
below.
Why background callers must not clear: Orca reports every rejection with
one composite reason (``unknown, expired, revoked, or already used``), so
a genuine revocation is indistinguishable from a lost refresh-rotation
race. Acting destructively on a signal that can't be disambiguated is the
#2562 mistake in a different cloud. It also gains nothing — a route call
hits the same failure and clears then, at a moment the user can respond to.
A successful refresh is still persisted either way: by that point the old
refresh token is consumed, so dropping the new pair would break a working
pairing for real.
"""
creds = await _load_credentials(db, user)
if not creds.token:
raise HTTPException(status_code=401, detail="Orca Cloud is not connected — sign in first.")
@@ -457,8 +475,11 @@ async def _build_authenticated_service(
await svc.refresh()
except OrcaCloudAuthError as e:
# Refresh token was revoked or rotated out from under us. Clear
# the stale credentials so the UI flips to disconnected.
await _clear_credentials(db, user)
# the stale credentials so the UI flips to disconnected — unless
# the caller is a background job, which must not change sign-in
# state on its own.
if clear_on_auth_failure:
await _clear_credentials(db, user)
raise HTTPException(status_code=401, detail=f"Orca Cloud session refresh failed: {e}") from e
except OrcaCloudError as e:
raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
+13
View File
@@ -157,6 +157,19 @@ class GitHubBackupLogResponse(BaseModel):
from_attributes = True
class CloudAccountCounts(BaseModel):
"""How many connected cloud accounts a backup would collect presets from.
Counts only, never identities: with auth enabled these are other users'
accounts, and whoever administers the backup has no business learning who
signed in to what. The number is enough to answer the only question the UI
asks — is the Cloud Profiles category worth offering at all (#2717).
"""
bambu: int = Field(default=0, description="Connected Bambu Cloud accounts")
orca: int = Field(default=0, description="Connected Orca Cloud accounts")
class GitHubBackupStatus(BaseModel):
"""Schema for current backup status."""
+334 -57
View File
@@ -8,7 +8,7 @@ import logging
from datetime import datetime, timedelta, timezone
import httpx
from sqlalchemy import desc, select
from sqlalchemy import desc, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.database import async_session
@@ -18,11 +18,61 @@ from backend.app.models.printer import Printer
from backend.app.models.settings import Settings
from backend.app.models.spool import Spool
from backend.app.models.spool_usage_history import SpoolUsageHistory
from backend.app.models.user import User
from backend.app.services.git_providers.factory import get_provider_backend
from backend.app.services.printer_manager import printer_manager
logger = logging.getLogger(__name__)
# Bambu's listing endpoint is keyed by preset type and calls process presets
# "print". Same mapping as `routes/cloud.py` — kept in step with it, since a
# divergence here silently drops a whole preset type from every backup.
_BAMBU_PRESET_TYPES = {
"filament": "filament",
"printer": "printer",
"print": "process",
}
def _bambu_preset_record(setting_id, our_type: str, entry: dict, detail: dict) -> dict:
"""One Bambu preset as stored in the backup: metadata plus the payload.
``base_id`` and ``setting`` are the two fields ``BambuCloudService.
create_setting`` needs, so a restore can rebuild the preset rather than
just list it.
``user_id`` from the listing is deliberately dropped. It identifies the
account and adds nothing to a rebuild, and backup repositories can be
public.
"""
return {
"setting_id": str(setting_id),
"name": detail.get("name") or entry.get("name") or "Unknown",
"type": our_type,
"version": detail.get("version") or entry.get("version"),
"updated_time": entry.get("updated_time"),
"base_id": detail.get("base_id"),
"filament_id": detail.get("filament_id"),
"setting": detail.get("setting") or {},
}
def _orca_profile_record(entry: dict) -> dict:
"""One Orca profile as stored in the backup.
``content`` is kept whole rather than picked apart: it is the profile, the
sync API hands it over inline, and Orca owns its shape. Narrowing it here
would mean guessing which keys a future restore needs.
"""
return {
"id": str(entry.get("id")) if entry.get("id") is not None else None,
"name": entry.get("name"),
"updated_time": entry.get("updated_time"),
"created_time": entry.get("created_time"),
"content": entry.get("content"),
}
# Schedule intervals in seconds
SCHEDULE_INTERVALS = {
"hourly": 3600,
@@ -279,11 +329,13 @@ class GitHubBackupService:
{
"backup_metadata.json": {...},
"kprofiles/{serial}/{nozzle}.json": {...},
"cloud_profiles/filament.json": [...],
"cloud_profiles/printer.json": [...],
"cloud_profiles/process.json": [...],
"cloud_profiles/bambu/{account}/{filament,printer,process}.json": {...},
"cloud_profiles/orca/{account}/{filament,printer,process}.json": {...},
"settings/app_settings.json": {...},
}
``{account}`` is ``global`` when auth is disabled, otherwise
``user-{id}`` — one directory per connected cloud account (#2717).
"""
files: dict[str, dict | list] = {}
@@ -306,10 +358,20 @@ class GitHubBackupService:
self._backup_progress = "Collecting K-profiles from printers..."
await self._collect_kprofiles(db, files)
# Collect cloud profiles
# Collect cloud profiles. `contents.cloud_profiles` is corrected below
# from what was configured to what was actually written — it claimed
# `true` on every backup, including the ones that collected nothing
# (#2717), which is exactly the signal a restore needs to be able to
# trust.
if config.backup_cloud_profiles:
self._backup_progress = "Collecting cloud profiles from Bambu Cloud..."
await self._collect_cloud_profiles(db, files)
self._backup_progress = "Collecting cloud profiles from Bambu Cloud and Orca Cloud..."
cloud_summary = await self._collect_cloud_profiles(db, files)
collected = bool(cloud_summary.get("bambu") or cloud_summary.get("orca"))
metadata["contents"]["cloud_profiles"] = collected
if collected:
# Per-cloud, per-account counts, so a restore can tell an empty
# account from one that failed to collect.
metadata["cloud_profiles"] = cloud_summary
# Collect app settings
if config.backup_settings:
@@ -374,69 +436,284 @@ class GitHubBackupService:
if printer_profiles:
logger.info("Collected K-profiles for %s: %s", serial, printer_profiles)
async def _collect_cloud_profiles(self, db: AsyncSession, files: dict):
"""Collect Bambu Cloud profiles if authenticated."""
# Backup runs without a user context, so fall back to the auth-disabled
# Settings storage. ``build_authenticated_cloud`` honours the stored
# region so China-region tokens are validated against api.bambulab.cn.
async def _collect_cloud_profiles(self, db: AsyncSession, files: dict) -> dict:
"""Collect slicer presets from every connected cloud account.
Two clouds, and on an auth-enabled install any number of accounts in
each: Bambu Cloud tokens live on ``User.cloud_token`` and Orca Cloud
tokens on ``User.orca_cloud_token``, falling back to the global
``Settings`` table only when auth is disabled. The previous version
asked for the auth-disabled store unconditionally, so it collected
nothing at all on any install with auth on (#2717).
Layout is one directory per cloud per account, both clouds grouped the
same way so a restore reads them identically::
cloud_profiles/bambu/user-3/{filament,printer,process}.json
cloud_profiles/orca/user-3/{filament,printer,process}.json
Accounts are keyed by Bambuddy user id (``global`` when auth is off),
never by email — a backup repository can be public.
Returns a per-cloud summary for ``backup_metadata.json`` so the
metadata records what was actually collected rather than what was
merely enabled.
"""
summary: dict = {"bambu": {}, "orca": {}}
bambu_accounts, orca_accounts = await self.cloud_accounts(db)
if not bambu_accounts and not orca_accounts:
# Enabled but nothing to collect. Deliberately a warning: the INFO
# line this replaces read as a successful collection of nothing,
# which is how #2717 went unnoticed through every backup.
logger.warning(
"Cloud profiles are enabled for backup, but no Bambu Cloud or Orca Cloud "
"account is connected — nothing to collect."
)
return summary
for account_key, user in bambu_accounts:
try:
counts = await self._collect_bambu_profiles(db, files, account_key, user)
except Exception:
logger.warning("Failed to collect Bambu Cloud profiles for %s", account_key, exc_info=True)
continue
if counts:
summary["bambu"][account_key] = counts
for account_key, user in orca_accounts:
try:
counts = await self._collect_orca_profiles(db, files, account_key, user)
except Exception:
logger.warning("Failed to collect Orca Cloud profiles for %s", account_key, exc_info=True)
continue
if counts:
summary["orca"][account_key] = counts
if not summary["bambu"] and not summary["orca"]:
logger.warning(
"Cloud profiles are enabled and %d Bambu / %d Orca account(s) are connected, "
"but no presets were collected — see the per-account warnings above.",
len(bambu_accounts),
len(orca_accounts),
)
else:
logger.info("Collected cloud profiles: %s", summary)
return summary
async def cloud_accounts(self, db: AsyncSession) -> tuple[list, list]:
"""Enumerate connected accounts as ``(account_key, user_or_None)`` per cloud.
With auth enabled every user holds their own credentials, so a backup
that only looked at the global store saw none of them. With auth
disabled there is a single global row and no ``User`` at all, which is
what ``user=None`` means to both clouds' credential loaders.
Both stores are read regardless: a ``Settings`` row survives enabling
auth later, and dropping it silently would lose that account's presets.
"""
from backend.app.api.routes.cloud import get_stored_token
from backend.app.api.routes.orca_cloud import _load_credentials
bambu: list = []
orca: list = []
global_token, _email, _region = await get_stored_token(db, None)
if global_token:
bambu.append(("global", None))
global_orca = await _load_credentials(db, None)
if global_orca.token:
orca.append(("global", None))
result = await db.execute(
select(User).where(or_(User.cloud_token.isnot(None), User.orca_cloud_token.isnot(None)))
)
for user in result.scalars().all():
if user.cloud_token:
bambu.append((f"user-{user.id}", user))
if user.orca_cloud_token:
orca.append((f"user-{user.id}", user))
return bambu, orca
async def _collect_bambu_profiles(self, db: AsyncSession, files: dict, account_key: str, user) -> dict:
"""Collect one Bambu Cloud account's custom presets, with their payloads.
The listing endpoint is keyed by preset type, each holding ``private``
and ``public`` lists — there is no flat ``setting`` array, and the
entries carry no ``type`` of their own, which is why the type comes
from the outer key here exactly as it does in ``routes/cloud.py``.
Bambu calls process presets ``print``.
``public`` is skipped: those are Bambu's own bundled catalogue, the
same hundreds of entries for every user, re-downloadable at any time
and not recreatable under your account anyway. Backing them up would
churn the repository on every run for nothing.
Each private preset then costs one ``get_setting_detail`` call, because
the listing carries only metadata. Without ``base_id`` and ``setting``
the backup is a list of names, not something a restore can rebuild
from. Bounded by the number of *custom* presets, and the backup already
makes a round-trip per printer for K-profiles.
"""
from backend.app.api.routes.cloud import build_authenticated_cloud
cloud = await build_authenticated_cloud(db, user=None)
cloud = await build_authenticated_cloud(db, user=user)
if cloud is None or not cloud.is_authenticated:
if cloud is not None:
await cloud.close()
logger.info("Cloud not authenticated, skipping cloud profiles")
return
logger.info("Bambu Cloud not authenticated for %s, skipping", account_key)
return {}
counts: dict = {}
try:
settings = await cloud.get_slicer_settings()
if not settings:
return
if not isinstance(settings, dict) or not settings:
logger.warning("Bambu Cloud returned no slicer settings for %s", account_key)
return {}
# Separate by type
filament_settings = []
printer_settings = []
process_settings = []
failed = 0
for api_key, our_type in _BAMBU_PRESET_TYPES.items():
type_data = settings.get(api_key)
if not isinstance(type_data, dict):
continue
private = type_data.get("private")
if not isinstance(private, list) or not private:
continue
for setting in settings.get("setting", []) if isinstance(settings.get("setting"), list) else []:
setting_type = setting.get("type", "")
if setting_type == "filament":
filament_settings.append(setting)
elif setting_type == "printer":
printer_settings.append(setting)
elif setting_type == "process":
process_settings.append(setting)
profiles = []
for entry in private:
setting_id = entry.get("setting_id") or entry.get("id")
if not setting_id:
continue
try:
detail = await cloud.get_setting_detail(str(setting_id))
except Exception as e:
# One unreadable preset must not cost the rest of the
# account, but it must not vanish quietly either.
failed += 1
logger.warning(
"Failed to fetch Bambu Cloud preset %s (%s) for %s: %s",
setting_id,
entry.get("name", "unnamed"),
account_key,
e,
)
continue
profiles.append(_bambu_preset_record(setting_id, our_type, entry, detail))
if filament_settings:
files["cloud_profiles/filament.json"] = {
"version": "1.0",
"profiles": filament_settings,
}
if profiles:
files[f"cloud_profiles/bambu/{account_key}/{our_type}.json"] = {
"version": "2.0",
"cloud": "bambu",
"type": our_type,
"profiles": profiles,
}
counts[our_type] = len(profiles)
if printer_settings:
files["cloud_profiles/printer.json"] = {
"version": "1.0",
"profiles": printer_settings,
}
if process_settings:
files["cloud_profiles/process.json"] = {
"version": "1.0",
"profiles": process_settings,
}
logger.info(
"Collected cloud profiles: %d filament, %d printer, %d process",
len(filament_settings),
len(printer_settings),
len(process_settings),
)
except Exception:
logger.warning("Failed to collect cloud profiles", exc_info=True)
if failed:
counts["failed"] = failed
return counts
finally:
await cloud.close()
async def _collect_orca_profiles(self, db: AsyncSession, files: dict, account_key: str, user) -> dict:
"""Collect one Orca Cloud account's profiles, grouped the same three ways.
Cheaper than Bambu: the sync-pull listing already carries each
profile's full ``content``, so there is no per-profile fetch.
The type lives at ``content.type`` and is mapped through the same
``_ORCA_TYPE_TO_BAMBU`` table the Orca tab uses, so the backup groups
exactly as the UI does. Where that route *drops* a profile whose type
it can't map, this writes it to ``other.json`` instead — a backup that
silently omits a profile because Orca added a type is the same class of
bug as #2717 itself.
Uses the route layer's ``_build_authenticated_service`` rather than
re-implementing the refresh: the Orca refresh token is single-use and
rotating, and that helper already persists the new pair atomically
before returning.
Passes ``clear_on_auth_failure=False``, so a rejected refresh skips the
account instead of disconnecting it. A backup is an observer; it should
not change anyone's sign-in state on a schedule, least of all on a
rejection reason Orca does not disambiguate. The next time the user
opens the Orca Profiles page that route clears the dead pairing anyway,
with the user present to pair again.
"""
from fastapi import HTTPException
from backend.app.api.routes.orca_cloud import (
_ORCA_TYPE_TO_BAMBU,
_build_authenticated_service,
)
try:
svc = await _build_authenticated_service(db, user, clear_on_auth_failure=False)
except HTTPException as e:
# Either way the stored credentials are untouched and this account
# is skipped, not disconnected — but the two need different advice.
# A rejected refresh will not fix itself and needs the user to pair
# again; an unreachable Orca is very likely gone by the next run.
if e.status_code == 401:
logger.warning(
"Orca Cloud rejected the stored session for %s, so its profiles are not in this "
"backup. Later runs will skip it too until the account is paired again under "
"Profiles > Orca Cloud Profiles — which is also where the dead credentials get "
"cleared. Cause: %s",
account_key,
e.detail,
)
else:
logger.warning(
"Orca Cloud unreachable for %s, skipping its profiles this run: %s",
account_key,
e.detail,
)
return {}
except Exception as e:
logger.warning("Orca Cloud not usable for %s: %s", account_key, e, exc_info=True)
return {}
counts: dict = {}
try:
raw_profiles = await svc.list_profiles()
grouped: dict[str, list] = {}
unknown_types: dict[str, int] = {}
for entry in raw_profiles:
if not isinstance(entry, dict):
continue
content = entry.get("content")
raw_type = content.get("type") if isinstance(content, dict) else None
our_type = _ORCA_TYPE_TO_BAMBU.get(str(raw_type)) if raw_type is not None else None
if our_type is None:
unknown_types[str(raw_type) if raw_type is not None else "<missing>"] = (
unknown_types.get(str(raw_type) if raw_type is not None else "<missing>", 0) + 1
)
our_type = "other"
grouped.setdefault(our_type, []).append(_orca_profile_record(entry))
for our_type, profiles in grouped.items():
files[f"cloud_profiles/orca/{account_key}/{our_type}.json"] = {
"version": "2.0",
"cloud": "orca",
"type": our_type,
"profiles": profiles,
}
counts[our_type] = len(profiles)
if unknown_types:
logger.warning(
"Orca Cloud sent %d profile(s) for %s with unmapped content.type values %s — "
"backed up to other.json rather than dropped.",
sum(unknown_types.values()),
account_key,
unknown_types,
)
return counts
finally:
await svc.close()
async def _collect_settings(self, db: AsyncSession, files: dict):
"""Collect app settings."""
result = await db.execute(select(Settings))
@@ -0,0 +1,509 @@
"""Cloud-profile collection for Git backup (#2717).
The collector used to read a ``setting`` key the Bambu Cloud API never returns,
so ``cloud_profiles/*`` was never written while ``backup_metadata.json`` claimed
it was. It also asked for the auth-disabled credential store unconditionally,
which meant it saw no accounts at all once auth was on. These tests pin the
response shape it actually has to parse, the account enumeration, and the
metadata now telling the truth.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app.models.settings import Settings
from backend.app.models.user import User
from backend.app.services.github_backup import GitHubBackupService
# The real listing body: keyed by preset type, each holding private/public
# lists. There is no top-level "setting" array, and the entries carry no "type"
# of their own — the type is the outer key, and Bambu calls process "print".
BAMBU_LISTING = {
"filament": {
"private": [
{"setting_id": "PFUS1", "name": "My PLA", "version": "1.0", "user_id": "u-123"},
],
"public": [
{"setting_id": "GFSA00", "name": "Bambu PLA Basic", "version": "1.0"},
],
},
"printer": {
"private": [{"setting_id": "PMUS1", "name": "My X1C", "version": "1.0"}],
"public": [],
},
"print": {
"private": [{"setting_id": "PSUS1", "name": "My 0.2mm", "version": "1.0"}],
"public": [],
},
}
def _detail(setting_id: str, name: str, base: str) -> dict:
return {
"setting_id": setting_id,
"name": name,
"type": "filament",
"version": "1.0",
"base_id": base,
"filament_id": "P1234",
"setting": {"filament_flow_ratio": ["0.98"]},
}
def _bambu_cloud(listing=None, detail_side_effect=None):
cloud = MagicMock()
cloud.is_authenticated = True
cloud.get_slicer_settings = AsyncMock(return_value=listing if listing is not None else BAMBU_LISTING)
cloud.get_setting_detail = AsyncMock(
side_effect=detail_side_effect or (lambda sid: _detail(sid, f"detail-{sid}", "GFSA00")),
)
cloud.close = AsyncMock()
return cloud
def _orca_service(profiles):
svc = MagicMock()
svc.list_profiles = AsyncMock(return_value=profiles)
svc.close = AsyncMock()
return svc
@pytest.fixture
def service():
return GitHubBackupService()
class TestCloudAccountEnumeration:
"""Which accounts a backup collects from."""
@pytest.mark.asyncio
async def test_auth_disabled_uses_the_global_store(self, service, db_session):
"""With auth off there is no User row at all — credentials live in the
Settings table and the account is keyed ``global``."""
with (
patch(
"backend.app.api.routes.cloud.get_stored_token",
new_callable=AsyncMock,
return_value=("bambu-token", "a@b.c", "global"),
),
patch(
"backend.app.api.routes.orca_cloud._load_credentials",
new_callable=AsyncMock,
return_value=MagicMock(token=None),
),
):
bambu, orca = await service.cloud_accounts(db_session)
assert bambu == [("global", None)]
assert orca == []
@pytest.mark.asyncio
async def test_auth_enabled_finds_every_user_holding_a_token(self, service, db_session):
"""The bug that made this invisible: with auth on, tokens live on User
rows, and the collector only ever looked at the global store. Each cloud
is enumerated separately so a user connected to one shows up only there.
"""
both = User(username="both", cloud_token="t1", orca_cloud_token="o1")
bambu_only = User(username="bambu-only", cloud_token="t2")
orca_only = User(username="orca-only", orca_cloud_token="o2")
neither = User(username="neither")
db_session.add_all([both, bambu_only, orca_only, neither])
await db_session.commit()
with (
patch(
"backend.app.api.routes.cloud.get_stored_token",
new_callable=AsyncMock,
return_value=(None, None, "global"),
),
patch(
"backend.app.api.routes.orca_cloud._load_credentials",
new_callable=AsyncMock,
return_value=MagicMock(token=None),
),
):
bambu, orca = await service.cloud_accounts(db_session)
assert sorted(key for key, _ in bambu) == [f"user-{both.id}", f"user-{bambu_only.id}"]
assert sorted(key for key, _ in orca) == [f"user-{both.id}", f"user-{orca_only.id}"]
@pytest.mark.asyncio
async def test_global_and_per_user_accounts_coexist(self, service, db_session):
"""A Settings row survives someone enabling auth later. Dropping it
would silently stop backing up that account's presets."""
db_session.add(User(username="u", cloud_token="t1"))
await db_session.commit()
with (
patch(
"backend.app.api.routes.cloud.get_stored_token",
new_callable=AsyncMock,
return_value=("legacy-global", None, "global"),
),
patch(
"backend.app.api.routes.orca_cloud._load_credentials",
new_callable=AsyncMock,
return_value=MagicMock(token=None),
),
):
bambu, _orca = await service.cloud_accounts(db_session)
assert "global" in [key for key, _ in bambu]
assert len(bambu) == 2
class TestBambuCollection:
@pytest.mark.asyncio
async def test_reads_the_shape_the_api_actually_returns(self, service, db_session):
"""The whole bug in one assertion: presets come out of
``data[type]["private"]``, not a flat ``setting`` list, and ``print``
maps to ``process``."""
files: dict = {}
with patch(
"backend.app.api.routes.cloud.build_authenticated_cloud",
new_callable=AsyncMock,
return_value=_bambu_cloud(),
):
counts = await service._collect_bambu_profiles(db_session, files, "global", None)
assert counts == {"filament": 1, "printer": 1, "process": 1}
assert set(files) == {
"cloud_profiles/bambu/global/filament.json",
"cloud_profiles/bambu/global/printer.json",
"cloud_profiles/bambu/global/process.json",
}
@pytest.mark.asyncio
async def test_public_presets_are_not_backed_up(self, service, db_session):
"""Bambu's bundled catalogue is identical for everyone, re-downloadable,
and not recreatable under your account — backing it up would churn the
repository on every run for no recovery value."""
files: dict = {}
with patch(
"backend.app.api.routes.cloud.build_authenticated_cloud",
new_callable=AsyncMock,
return_value=_bambu_cloud(),
):
await service._collect_bambu_profiles(db_session, files, "global", None)
filament = files["cloud_profiles/bambu/global/filament.json"]["profiles"]
assert [p["setting_id"] for p in filament] == ["PFUS1"]
@pytest.mark.asyncio
async def test_stores_the_payload_a_restore_needs(self, service, db_session):
"""The listing is metadata only. Without ``base_id`` and ``setting``
the backup is a list of names — ``create_setting`` cannot rebuild from
it."""
files: dict = {}
with patch(
"backend.app.api.routes.cloud.build_authenticated_cloud",
new_callable=AsyncMock,
return_value=_bambu_cloud(),
):
await service._collect_bambu_profiles(db_session, files, "global", None)
preset = files["cloud_profiles/bambu/global/filament.json"]["profiles"][0]
assert preset["base_id"] == "GFSA00"
assert preset["setting"] == {"filament_flow_ratio": ["0.98"]}
assert preset["type"] == "filament"
@pytest.mark.asyncio
async def test_account_identity_is_not_written_to_the_repo(self, service, db_session):
"""Backup repositories can be public, and ``user_id`` adds nothing to a
rebuild."""
files: dict = {}
with patch(
"backend.app.api.routes.cloud.build_authenticated_cloud",
new_callable=AsyncMock,
return_value=_bambu_cloud(),
):
await service._collect_bambu_profiles(db_session, files, "global", None)
for payload in files.values():
for preset in payload["profiles"]:
assert "user_id" not in preset
@pytest.mark.asyncio
async def test_one_unreadable_preset_does_not_lose_the_others(self, service, db_session):
"""And it is counted, not swallowed — a partial backup that looks
complete is how #2717 stayed invisible."""
def detail(setting_id):
if setting_id == "PFUS1":
raise RuntimeError("boom")
return _detail(setting_id, "ok", "GFSA00")
files: dict = {}
with patch(
"backend.app.api.routes.cloud.build_authenticated_cloud",
new_callable=AsyncMock,
return_value=_bambu_cloud(detail_side_effect=detail),
):
counts = await service._collect_bambu_profiles(db_session, files, "global", None)
assert "cloud_profiles/bambu/global/filament.json" not in files
assert counts["printer"] == 1
assert counts["process"] == 1
assert counts["failed"] == 1
@pytest.mark.asyncio
async def test_unauthenticated_account_writes_nothing(self, service, db_session):
files: dict = {}
with patch(
"backend.app.api.routes.cloud.build_authenticated_cloud",
new_callable=AsyncMock,
return_value=None,
):
counts = await service._collect_bambu_profiles(db_session, files, "user-1", MagicMock())
assert counts == {}
assert files == {}
class TestOrcaCollection:
@pytest.mark.asyncio
async def test_groups_by_content_type_including_aliases(self, service, db_session):
"""Orca carries the type at ``content.type`` and uses BambuStudio-style
aliases — ``machine`` is a printer, ``process`` and ``print`` are both
process. Same map the Orca tab groups by."""
profiles = [
{"id": 1, "name": "f", "content": {"type": "filament"}},
{"id": 2, "name": "m", "content": {"type": "machine"}},
{"id": 3, "name": "p", "content": {"type": "print"}},
{"id": 4, "name": "p2", "content": {"type": "process"}},
]
files: dict = {}
with patch(
"backend.app.api.routes.orca_cloud._build_authenticated_service",
new_callable=AsyncMock,
return_value=_orca_service(profiles),
):
counts = await service._collect_orca_profiles(db_session, files, "user-3", MagicMock())
assert counts == {"filament": 1, "printer": 1, "process": 2}
assert "cloud_profiles/orca/user-3/printer.json" in files
@pytest.mark.asyncio
async def test_content_is_stored_inline_without_a_second_fetch(self, service, db_session):
"""The sync-pull listing already carries each profile's content, so
unlike Bambu there is no per-profile round trip."""
svc = _orca_service([{"id": 7, "name": "f", "content": {"type": "filament", "flow": 0.98}}])
files: dict = {}
with patch(
"backend.app.api.routes.orca_cloud._build_authenticated_service",
new_callable=AsyncMock,
return_value=svc,
):
await service._collect_orca_profiles(db_session, files, "global", None)
stored = files["cloud_profiles/orca/global/filament.json"]["profiles"][0]
assert stored["content"] == {"type": "filament", "flow": 0.98}
assert svc.list_profiles.await_count == 1
@pytest.mark.asyncio
async def test_unmapped_types_are_kept_not_dropped(self, service, db_session):
"""The Orca *route* drops profiles whose type it can't render, which is
right for a list and wrong for a backup: silently omitting a profile
because Orca added a type is the same class of bug as #2717."""
profiles = [
{"id": 1, "name": "f", "content": {"type": "filament"}},
{"id": 2, "name": "x", "content": {"type": "something_new"}},
{"id": 3, "name": "y", "content": {}},
]
files: dict = {}
with patch(
"backend.app.api.routes.orca_cloud._build_authenticated_service",
new_callable=AsyncMock,
return_value=_orca_service(profiles),
):
counts = await service._collect_orca_profiles(db_session, files, "global", None)
assert counts["other"] == 2
assert len(files["cloud_profiles/orca/global/other.json"]["profiles"]) == 2
@pytest.mark.asyncio
async def test_dead_pairing_writes_nothing_and_does_not_raise(self, service, db_session):
"""An unexpected failure building the Orca client must not abort the
rest of the backup — the other accounts and the other cloud still have
profiles worth collecting."""
files: dict = {}
with patch(
"backend.app.api.routes.orca_cloud._build_authenticated_service",
new_callable=AsyncMock,
side_effect=RuntimeError("session expired"),
):
counts = await service._collect_orca_profiles(db_session, files, "user-2", MagicMock())
assert counts == {}
assert files == {}
@pytest.mark.asyncio
async def test_the_backup_never_disconnects_an_account(self, service, db_session):
"""A backup is an observer. It must not change anyone's sign-in state
on a schedule — least of all on Orca's composite rejection reason,
which cannot tell a real revocation from a lost refresh-rotation race.
The Profiles route clears the dead pairing instead, with the user
present to act on it.
"""
from fastapi import HTTPException
build = AsyncMock(side_effect=HTTPException(status_code=401, detail="grant already used"))
files: dict = {}
with patch("backend.app.api.routes.orca_cloud._build_authenticated_service", build):
counts = await service._collect_orca_profiles(db_session, files, "global", None)
assert counts == {}
assert build.await_args.kwargs["clear_on_auth_failure"] is False
@pytest.mark.asyncio
async def test_a_rejected_session_says_it_will_keep_being_skipped(self, service, db_session, caplog):
"""Not clearing means the warning recurs every run, so the one line the
operator sees has to say how to stop it."""
from fastapi import HTTPException
files: dict = {}
with (
caplog.at_level("WARNING"),
patch(
"backend.app.api.routes.orca_cloud._build_authenticated_service",
new_callable=AsyncMock,
side_effect=HTTPException(status_code=401, detail="refresh rejected: grant already used"),
),
):
counts = await service._collect_orca_profiles(db_session, files, "global", None)
assert counts == {}
assert "paired again" in caplog.text
assert "Later runs will skip it too" in caplog.text
@pytest.mark.asyncio
async def test_an_unreachable_orca_is_a_transient_skip(self, service, db_session, caplog):
"""502 is very likely gone by the next run, so it must not carry the
"go and re-pair" advice a rejected session does."""
from fastapi import HTTPException
files: dict = {}
with (
caplog.at_level("WARNING"),
patch(
"backend.app.api.routes.orca_cloud._build_authenticated_service",
new_callable=AsyncMock,
side_effect=HTTPException(status_code=502, detail="Orca Cloud unreachable: timeout"),
),
):
counts = await service._collect_orca_profiles(db_session, files, "user-9", None)
assert counts == {}
assert "unreachable" in caplog.text
assert "paired again" not in caplog.text
class TestCollectorAndMetadata:
@pytest.mark.asyncio
async def test_no_connected_account_collects_nothing(self, service, db_session):
files: dict = {}
with (
patch(
"backend.app.api.routes.cloud.get_stored_token",
new_callable=AsyncMock,
return_value=(None, None, "global"),
),
patch(
"backend.app.api.routes.orca_cloud._load_credentials",
new_callable=AsyncMock,
return_value=MagicMock(token=None),
),
):
summary = await service._collect_cloud_profiles(db_session, files)
assert summary == {"bambu": {}, "orca": {}}
assert files == {}
@pytest.mark.asyncio
async def test_one_failing_account_does_not_stop_the_others(self, service, db_session):
a = User(username="a", cloud_token="t1")
b = User(username="b", cloud_token="t2")
db_session.add_all([a, b])
await db_session.commit()
def build(db, user=None):
if user is not None and user.username == "a":
raise RuntimeError("cloud down for this account")
return _bambu_cloud()
files: dict = {}
with (
patch(
"backend.app.api.routes.cloud.get_stored_token",
new_callable=AsyncMock,
return_value=(None, None, "global"),
),
patch(
"backend.app.api.routes.orca_cloud._load_credentials",
new_callable=AsyncMock,
return_value=MagicMock(token=None),
),
patch(
"backend.app.api.routes.cloud.build_authenticated_cloud",
new_callable=AsyncMock,
side_effect=build,
),
):
summary = await service._collect_cloud_profiles(db_session, files)
assert f"user-{a.id}" not in summary["bambu"]
assert summary["bambu"][f"user-{b.id}"] == {"filament": 1, "printer": 1, "process": 1}
@pytest.mark.asyncio
async def test_metadata_reports_collection_not_configuration(self, service, db_session):
"""``contents.cloud_profiles`` said ``true`` on every backup, including
the ones that wrote nothing. A restore has to be able to trust it."""
config = MagicMock(
backup_kprofiles=False,
backup_cloud_profiles=True,
backup_settings=False,
backup_spools=False,
backup_archives=False,
)
with patch.object(
service, "_collect_cloud_profiles", new_callable=AsyncMock, return_value={"bambu": {}, "orca": {}}
):
files = await service._collect_backup_data(db_session, config)
assert files["backup_metadata.json"]["contents"]["cloud_profiles"] is False
assert "cloud_profiles" not in files["backup_metadata.json"]
@pytest.mark.asyncio
async def test_metadata_records_per_account_counts_when_collected(self, service, db_session):
config = MagicMock(
backup_kprofiles=False,
backup_cloud_profiles=True,
backup_settings=False,
backup_spools=False,
backup_archives=False,
)
summary = {"bambu": {"user-3": {"filament": 2}}, "orca": {}}
with patch.object(service, "_collect_cloud_profiles", new_callable=AsyncMock, return_value=summary):
files = await service._collect_backup_data(db_session, config)
assert files["backup_metadata.json"]["contents"]["cloud_profiles"] is True
assert files["backup_metadata.json"]["cloud_profiles"] == summary
class TestSettingsFallbackIsStillHonoured:
@pytest.mark.asyncio
async def test_global_orca_row_is_discovered(self, service, db_session):
"""Orca's auth-disabled fallback lives in the same Settings table as
Bambu's; both stores are read on every run."""
db_session.add(Settings(key="orca_cloud_token", value="oc_ext_x"))
await db_session.commit()
with patch(
"backend.app.api.routes.cloud.get_stored_token",
new_callable=AsyncMock,
return_value=(None, None, "global"),
):
_bambu, orca = await service.cloud_accounts(db_session)
assert orca == [("global", None)]
@@ -0,0 +1,125 @@
"""What a rejected Orca Cloud refresh is allowed to do to stored credentials.
The refresh token is single-use and rotating, and Orca reports every rejection
with one composite reason (``unknown, expired, revoked, or already used``), so
Bambuddy cannot tell a genuine revocation from a lost rotation race. Routes may
still clear on that signal — a person is looking at the page and can pair again
— but a background job must not, or an unattended run can destroy a working
pairing (#2717).
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from sqlalchemy import select
from backend.app.api.routes.orca_cloud import _SETTINGS_KEYS, _build_authenticated_service
from backend.app.models.settings import Settings
from backend.app.services.orca_cloud import OrcaCloudAuthError, OrcaCloudError
async def _store_global_credentials(db):
"""An auth-disabled install's Orca credentials, expired so the helper
refreshes rather than returning straight away."""
db.add_all(
[
Settings(key=_SETTINGS_KEYS["token"], value="oc_ext_old"),
Settings(key=_SETTINGS_KEYS["refresh_token"], value="oc_ext_rt_old"),
Settings(key=_SETTINGS_KEYS["expires_at"], value="2000-01-01T00:00:00+00:00"),
Settings(key=_SETTINGS_KEYS["email"], value="a@b.c"),
]
)
await db.commit()
async def _stored_keys(db) -> set[str]:
result = await db.execute(select(Settings).where(Settings.key.in_(list(_SETTINGS_KEYS.values()))))
return {s.key for s in result.scalars().all()}
def _expired_service(refresh_side_effect=None):
"""A service that reports its access token as expired, so the helper takes
the refresh branch."""
svc = MagicMock()
svc.is_authenticated = False
svc.refresh_token = "oc_ext_rt_old"
svc.set_tokens = MagicMock()
svc.refresh = AsyncMock(side_effect=refresh_side_effect)
svc.access_token = "oc_ext_new"
svc.token_expiry = None
return svc
class TestRejectedRefresh:
@pytest.mark.asyncio
async def test_routes_clear_the_dead_pairing_by_default(self, db_session):
"""Unchanged behaviour for interactive callers: the page flips to
disconnected while the user is there to pair again."""
await _store_global_credentials(db_session)
svc = _expired_service(OrcaCloudAuthError("grant already used"))
with (
patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc),
pytest.raises(HTTPException) as exc,
):
await _build_authenticated_service(db_session, None)
assert exc.value.status_code == 401
assert await _stored_keys(db_session) == set()
@pytest.mark.asyncio
async def test_background_callers_leave_the_credentials_alone(self, db_session):
"""The whole point of the flag. A scheduled backup that guesses wrong
here destroys a pairing nobody asked it to touch, and the user finds
out when their profiles stop being backed up."""
await _store_global_credentials(db_session)
svc = _expired_service(OrcaCloudAuthError("grant already used"))
with (
patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc),
pytest.raises(HTTPException) as exc,
):
await _build_authenticated_service(db_session, None, clear_on_auth_failure=False)
# Still reported as a hard auth failure — the caller has to skip the
# account — but nothing was destroyed on the way out.
assert exc.value.status_code == 401
assert _SETTINGS_KEYS["token"] in await _stored_keys(db_session)
assert _SETTINGS_KEYS["refresh_token"] in await _stored_keys(db_session)
@pytest.mark.asyncio
async def test_an_unreachable_orca_never_clears_either_way(self, db_session):
"""A transport failure says nothing about the credentials' validity."""
await _store_global_credentials(db_session)
svc = _expired_service(OrcaCloudError("connection reset"))
with (
patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc),
pytest.raises(HTTPException) as exc,
):
await _build_authenticated_service(db_session, None)
assert exc.value.status_code == 502
assert _SETTINGS_KEYS["token"] in await _stored_keys(db_session)
class TestSuccessfulRefresh:
@pytest.mark.asyncio
async def test_the_rotated_pair_is_persisted_even_for_background_callers(self, db_session):
"""Not optional: by the time the refresh succeeds the old token is
consumed, so failing to store the new pair would break a live pairing
for real. The flag suppresses destruction, never persistence.
"""
await _store_global_credentials(db_session)
svc = _expired_service()
svc.refresh_token = "oc_ext_rt_new"
with patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc):
returned = await _build_authenticated_service(db_session, None, clear_on_auth_failure=False)
assert returned is svc
result = await db_session.execute(select(Settings).where(Settings.key == _SETTINGS_KEYS["token"]))
assert result.scalar_one().value == "oc_ext_new"
result = await db_session.execute(select(Settings).where(Settings.key == _SETTINGS_KEYS["refresh_token"]))
assert result.scalar_one().value == "oc_ext_rt_new"
+11
View File
@@ -2652,6 +2652,14 @@ export interface NotificationProviderUpdate {
export type ScheduleType = 'hourly' | 'daily' | 'weekly';
export type GitProviderType = 'github' | 'gitea' | 'forgejo' | 'gitlab';
/** How many cloud accounts a backup would collect presets from. Counts only —
* with auth enabled the accounts belong to individual users, so the backup
* administrator sees how many, never whose. */
export interface CloudAccountCounts {
bambu: number;
orca: number;
}
export interface GitHubBackupConfig {
id: number;
repository_url: string;
@@ -6464,6 +6472,9 @@ export const api = {
getGitHubBackupConfig: () =>
request<GitHubBackupConfig | null>('/github-backup/config'),
getGitHubBackupCloudAccounts: () =>
request<CloudAccountCounts>('/github-backup/cloud-accounts'),
saveGitHubBackupConfig: (config: GitHubBackupConfigCreate) =>
request<GitHubBackupConfig>('/github-backup/config', {
method: 'POST',
@@ -32,6 +32,7 @@ import type {
LocalBackupPathCheck,
LocalBackupStatus,
ScheduleType,
CloudAccountCounts,
CloudAuthStatus,
Printer,
} from '../api/client';
@@ -324,6 +325,24 @@ export function GitHubBackupSettings() {
queryFn: api.getCloudStatus,
});
// How many cloud accounts the backup would actually collect from, across
// both Bambu Cloud and Orca Cloud. Not the same question as `cloudStatus`,
// which is only *this viewer's* Bambu sign-in: with auth enabled every user
// holds their own credentials and the backup collects from all of them, so
// an admin who never signed in to Bambu Cloud personally would otherwise see
// the category disabled while there is plenty to back up (#2717).
const { data: cloudAccounts } = useQuery<CloudAccountCounts>({
queryKey: ['github-backup-cloud-accounts'],
queryFn: api.getGitHubBackupCloudAccounts,
staleTime: 60_000,
});
const connectedCloudAccounts = (cloudAccounts?.bambu ?? 0) + (cloudAccounts?.orca ?? 0);
// Until the count arrives, fall back to the viewer's own Bambu status rather
// than rendering the toggle as unavailable and making it flicker enabled.
const anyCloudConnected = cloudAccounts
? connectedCloudAccounts > 0
: !!cloudStatus?.is_authenticated;
// Fetch printers and their statuses for K-profile availability
const { data: printers } = useQuery<Printer[]>({
queryKey: ['printers'],
@@ -751,18 +770,18 @@ export function GitHubBackupSettings() {
<p className="text-xs text-bambu-gray">{t('backup.kProfilesDescription')}</p>
</div>
</label>
<label className={`flex items-start gap-2 ${!cloudStatus?.is_authenticated ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>
<label className={`flex items-start gap-2 ${!anyCloudConnected ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>
<input
type="checkbox"
checked={backupCloudProfiles}
onChange={(e) => setBackupCloudProfiles(e.target.checked)}
className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
disabled={!cloudStatus?.is_authenticated}
disabled={!anyCloudConnected}
/>
<div>
<div className="flex items-center gap-2">
<span className={`text-sm ${cloudStatus?.is_authenticated ? 'text-white' : 'text-bambu-gray'}`}>{t('backup.cloudProfiles')}</span>
{!cloudStatus?.is_authenticated && (
<span className={`text-sm ${anyCloudConnected ? 'text-white' : 'text-bambu-gray'}`}>{t('backup.cloudProfiles')}</span>
{!anyCloudConnected && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400">
<AlertTriangle className="w-3 h-3" />
{t('backup.cloudLoginRequiredShort')}
@@ -770,6 +789,18 @@ export function GitHubBackupSettings() {
)}
</div>
<p className="text-xs text-bambu-gray">{t('backup.cloudProfilesDescription')}</p>
{/* Say how many accounts are in scope. On a multi-user
install the presets being backed up are other people's,
and the count is the only honest way to show that
without naming them. */}
{connectedCloudAccounts > 0 && (
<p className="text-xs text-bambu-gray mt-0.5">
{t('backup.cloudProfilesAccounts', {
bambu: cloudAccounts?.bambu ?? 0,
orca: cloudAccounts?.orca ?? 0,
})}
</p>
)}
</div>
</label>
<label className="flex items-start gap-2 cursor-pointer">
+2 -1
View File
@@ -4746,7 +4746,8 @@ export default {
noPrintersConnected: 'Keine Drucker verbunden',
printersConnected: '{{connected}}/{{total}} verbunden',
cloudProfiles: 'Cloud-Profile',
cloudProfilesDescription: 'Filament-, Drucker- und Prozessprofile aus der Bambu Cloud',
cloudProfilesDescription: 'Filament-, Drucker- und Prozessprofile aus Bambu Cloud und Orca Cloud',
cloudProfilesAccounts: 'Verbundene Konten — Bambu Cloud: {{bambu}}, Orca Cloud: {{orca}}',
appSettings: 'App-Einstellungen',
appSettingsDescription: 'Bambuddy-Konfiguration (komplette Datenbank)',
spoolInventory: 'Spulenbestand',
+2 -1
View File
@@ -4789,7 +4789,8 @@ export default {
noPrintersConnected: 'No printers connected',
printersConnected: '{{connected}}/{{total}} connected',
cloudProfiles: 'Cloud Profiles',
cloudProfilesDescription: 'Filament, printer, and process presets from Bambu Cloud',
cloudProfilesDescription: 'Filament, printer, and process presets from Bambu Cloud and Orca Cloud',
cloudProfilesAccounts: 'Connected accounts — Bambu Cloud: {{bambu}}, Orca Cloud: {{orca}}',
appSettings: 'App Settings',
appSettingsDescription: 'Bambuddy configuration (complete database)',
spoolInventory: 'Spool Inventory',
+2 -1
View File
@@ -4754,7 +4754,8 @@ export default {
noPrintersConnected: 'No hay impresoras conectadas',
printersConnected: '{{connected}}/{{total}} conectadas',
cloudProfiles: 'Perfiles en la nube',
cloudProfilesDescription: 'Preajustes de filamento, impresora y proceso de Bambu Cloud',
cloudProfilesDescription: 'Preajustes de filamento, impresora y proceso de Bambu Cloud y Orca Cloud',
cloudProfilesAccounts: 'Cuentas conectadas — Bambu Cloud: {{bambu}}, Orca Cloud: {{orca}}',
appSettings: 'Ajustes de la aplicación',
appSettingsDescription: 'Configuración de Bambuddy (base de datos completa)',
spoolInventory: 'Inventario de bobinas',
+2 -1
View File
@@ -4735,7 +4735,8 @@ export default {
noPrintersConnected: 'Aucune imprimante connectée',
printersConnected: '{{connected}}/{{total}} connectées',
cloudProfiles: 'Profils Cloud',
cloudProfilesDescription: 'Préréglages de filament, imprimante et processus depuis Bambu Cloud',
cloudProfilesDescription: 'Préréglages de filament, imprimante et processus depuis Bambu Cloud et Orca Cloud',
cloudProfilesAccounts: 'Comptes connectés — Bambu Cloud : {{bambu}}, Orca Cloud : {{orca}}',
appSettings: 'Paramètres de l\'application',
appSettingsDescription: 'Configuration Bambuddy (base de données complète)',
spoolInventory: 'Inventaire des bobines',
+2 -1
View File
@@ -4734,7 +4734,8 @@ export default {
noPrintersConnected: 'Nessuna stampante connessa',
printersConnected: '{{connected}}/{{total}} connesse',
cloudProfiles: 'Profili Cloud',
cloudProfilesDescription: 'Preset di filamento, stampante e processo da Bambu Cloud',
cloudProfilesDescription: 'Preset di filamento, stampante e processo da Bambu Cloud e Orca Cloud',
cloudProfilesAccounts: 'Account collegati — Bambu Cloud: {{bambu}}, Orca Cloud: {{orca}}',
appSettings: 'Impostazioni App',
appSettingsDescription: 'Configurazione Bambuddy (database completo)',
spoolInventory: 'Inventario bobine',
+2 -1
View File
@@ -4746,7 +4746,8 @@ export default {
noPrintersConnected: 'プリンターが接続されていません',
printersConnected: '{{connected}}/{{total}} 接続済み',
cloudProfiles: 'クラウドプロファイル',
cloudProfilesDescription: 'Bambu Cloudからのフィラメント、プリンター、プロセスプリセット',
cloudProfilesDescription: 'Bambu CloudとOrca Cloudからのフィラメント、プリンター、プロセスプリセット',
cloudProfilesAccounts: '接続済みアカウント — Bambu Cloud: {{bambu}}、Orca Cloud: {{orca}}',
appSettings: 'アプリ設定',
appSettingsDescription: 'Bambuddy設定(データベース全体)',
spoolInventory: 'スプール在庫',
+2 -1
View File
@@ -4511,7 +4511,8 @@ export default {
noPrintersConnected: '연결된 프린터 없음',
printersConnected: '{{total}}개 중 {{connected}}개 연결됨',
cloudProfiles: '클라우드 프로필',
cloudProfilesDescription: 'Bambu 클라우드의 필라멘트, 프린터 및 프로세스 프리셋',
cloudProfilesDescription: 'Bambu 클라우드와 Orca 클라우드의 필라멘트, 프린터 및 프로세스 프리셋',
cloudProfilesAccounts: '연결된 계정 — Bambu 클라우드: {{bambu}}, Orca 클라우드: {{orca}}',
appSettings: '앱 설정',
appSettingsDescription: 'Bambuddy 구성 (전체 데이터베이스)',
spoolInventory: '스풀 재고',
+2 -1
View File
@@ -4734,7 +4734,8 @@ export default {
noPrintersConnected: 'Nenhuma impressora conectada',
printersConnected: '{{connected}}/{{total}} conectadas',
cloudProfiles: 'Perfis Cloud',
cloudProfilesDescription: 'Predefinições de filamento, impressora e processo do Bambu Cloud',
cloudProfilesDescription: 'Predefinições de filamento, impressora e processo do Bambu Cloud e do Orca Cloud',
cloudProfilesAccounts: 'Contas conectadas — Bambu Cloud: {{bambu}}, Orca Cloud: {{orca}}',
appSettings: 'Configurações do App',
appSettingsDescription: 'Configuração do Bambuddy (banco de dados completo)',
spoolInventory: 'Inventário de bobinas',
+2 -1
View File
@@ -4503,7 +4503,8 @@ export default {
noPrintersConnected: "Нет подключённых принтеров",
printersConnected: "Подключено {{connected}} из {{total}}",
cloudProfiles: "Облачные профили",
cloudProfilesDescription: "Предустановки филамента, принтера и процесса из Bambu Cloud",
cloudProfilesDescription: "Предустановки филамента, принтера и процесса из Bambu Cloud и Orca Cloud",
cloudProfilesAccounts: "Подключённые аккаунты — Bambu Cloud: {{bambu}}, Orca Cloud: {{orca}}",
appSettings: "Настройки приложения",
appSettingsDescription: "Конфигурация Bambuddy (полная база данных)",
spoolInventory: "Учёт катушек",
+2 -1
View File
@@ -4724,7 +4724,8 @@ export default {
noPrintersConnected: 'Bağlı yazıcı yok',
printersConnected: '{{connected}}/{{total}} bağlı',
cloudProfiles: 'Bulut Profilleri',
cloudProfilesDescription: 'Bambu Cloud\'dan filament, yazıcı ve işlem ön ayarları',
cloudProfilesDescription: 'Bambu Cloud ve Orca Cloud\'dan filament, yazıcı ve işlem ön ayarları',
cloudProfilesAccounts: 'Bağlı hesaplar — Bambu Cloud: {{bambu}}, Orca Cloud: {{orca}}',
appSettings: 'Uygulama Ayarları',
appSettingsDescription: 'Bambuddy yapılandırması (tam veritabanı)',
spoolInventory: 'Makara Envanteri',
+2 -1
View File
@@ -4789,7 +4789,8 @@ export default {
noPrintersConnected: "Немає підключених принтерів",
printersConnected: "{{connected}}/{{total}} підключено",
cloudProfiles: "Хмарні профілі",
cloudProfilesDescription: "Попередні налаштування філаменту, принтера та процесу від Bambu Cloud",
cloudProfilesDescription: "Попередні налаштування філаменту, принтера та процесу від Bambu Cloud і Orca Cloud",
cloudProfilesAccounts: "Підключені акаунти — Bambu Cloud: {{bambu}}, Orca Cloud: {{orca}}",
appSettings: "Налаштування програми",
appSettingsDescription: "Конфігурація Bambuddy (повна база даних)",
spoolInventory: "Облік котушок",
+2 -1
View File
@@ -4734,7 +4734,8 @@ export default {
noPrintersConnected: '没有打印机连接',
printersConnected: '{{connected}}/{{total}} 已连接',
cloudProfiles: '云配置文件',
cloudProfilesDescription: '来自 Bambu Cloud 的耗材、打印机和工艺预设',
cloudProfilesDescription: '来自 Bambu Cloud 和 Orca Cloud 的耗材、打印机和工艺预设',
cloudProfilesAccounts: '已连接账户 — Bambu Cloud:{{bambu}},Orca Cloud:{{orca}}',
appSettings: '应用设置',
appSettingsDescription: 'Bambuddy 配置(完整数据库)',
spoolInventory: '耗材库存',
+2 -1
View File
@@ -4734,7 +4734,8 @@ export default {
noPrintersConnected: '沒有印表機連線',
printersConnected: '{{connected}}/{{total}} 已連線',
cloudProfiles: '雲設定檔案',
cloudProfilesDescription: '來自 Bambu Cloud 的耗材、印表機和工藝預設',
cloudProfilesDescription: '來自 Bambu Cloud 和 Orca Cloud 的耗材、印表機和工藝預設',
cloudProfilesAccounts: '已連線帳戶 — Bambu Cloud:{{bambu}},Orca Cloud:{{orca}}',
appSettings: '應用程式設定',
appSettingsDescription: 'Bambuddy 設定(完整資料庫)',
spoolInventory: '耗材庫存',
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-Css9_XII.js"></script>
<script type="module" crossorigin src="/assets/index-CxAiFpme.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-oReXTzKG.css">
</head>
<body>