mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
feat(backup): restore selected categories from a Git backup commit (#2656)
The Git backup feature was push-only: there was no equivalent of the local
backup's Restore button, so recovering meant hand-downloading JSON files from
the repository. This adds the read side.
Providers gain list_commits / list_tree / fetch_files on the GitProviderBackend
ABC. GitHub implements them against the Git Data API and Gitea/Forgejo inherit
that unchanged; GitLab overrides for its own REST shape, including tree
pagination and subgroup path encoding. fetch_files is batched so the path ->
blob SHA lookup happens once per restore rather than once per file, and uses the
blobs API rather than contents because contents silently inlines only the first
1 MB.
The new GitHubRestoreService resolves HEAD to a concrete SHA up front, so a
preview and the restore that follows act on the same commit even if a scheduled
backup lands in between. Categories are applied archives -> spools -> settings
-> kprofiles: archives first because spool usage history references archive_id,
K-profiles last because they leave the database and publish over MQTT.
Restores never reuse the backup's primary keys. spool.id and print_archives.id
are bare autoincrement columns, so ids from an old backup very likely belong to
unrelated rows today; rows are matched on natural keys (tag_uid, then
tray_uuid, then a descriptive composite for spools; content_hash or filename
plus started_at for archives), inserted without an explicit id, and an
old_id -> new_id map rewrites the foreign keys in spool usage history.
created_at is carried across on insert so restoring the same backup twice
matches instead of duplicating. Dangling printer/project links are cleared and
reported rather than failing the row.
Settings restore re-applies the collector's credential denylist on the read
side, plus a pattern guard, because a backup taken before that denylist existed
can still contain secrets. Restored archives are metadata-only: the 3MF and
thumbnail bytes are not in a Git backup and print_archives.file_path is NOT
NULL, so inserted rows get an empty path and the UI says so.
Backup and restore take a mutex against each other; both write the same tables
and talk to the same printers. Restores are logged as GitHubBackupLog rows with
trigger="restore", which needs no migration and surfaces them in the existing
History card.
Cloud profiles are deliberately not a restore category. The collector never
actually writes cloud_profiles/*.json - it reads a "setting" list key the Bambu
Cloud API does not return - and the preset list it would write carries no
setting payload. Filed separately.
Permission github:restore already existed and is granted to Administrators, so
no permission changes were needed.
Tests: 125 new backend tests (provider reads across all four providers, the
per-category appliers, the API endpoints) and 13 frontend tests. Full suites
pass with no regressions; the 35 backend failures on Windows are byte-identical
with and without this branch.
This commit is contained in:
@@ -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 (
|
||||
REF_PATTERN,
|
||||
CloudAccountCounts,
|
||||
GitHubBackupConfigCreate,
|
||||
GitHubBackupConfigResponse,
|
||||
@@ -19,10 +20,15 @@ from backend.app.schemas.github_backup import (
|
||||
GitHubBackupLogResponse,
|
||||
GitHubBackupStatus,
|
||||
GitHubBackupTriggerResponse,
|
||||
GitHubCommitListResponse,
|
||||
GitHubRestorePreview,
|
||||
GitHubRestoreRequest,
|
||||
GitHubRestoreResponse,
|
||||
GitHubTestConnectionResponse,
|
||||
ProviderType,
|
||||
)
|
||||
from backend.app.services.github_backup import github_backup_service
|
||||
from backend.app.services.github_restore import github_restore_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -388,13 +394,75 @@ async def get_status(
|
||||
configured=True,
|
||||
enabled=config.enabled,
|
||||
is_running=github_backup_service.is_running,
|
||||
progress=github_backup_service.progress,
|
||||
restore_running=github_restore_service.is_running,
|
||||
progress=github_backup_service.progress or github_restore_service.progress,
|
||||
last_backup_at=config.last_backup_at,
|
||||
last_backup_status=config.last_backup_status,
|
||||
next_scheduled_run=config.next_scheduled_run,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/commits", response_model=GitHubCommitListResponse)
|
||||
async def list_commits(
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
|
||||
):
|
||||
"""List recent backup commits so the user can pick one to restore from."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
|
||||
|
||||
commit_result = await github_restore_service.list_commits(config, limit=limit)
|
||||
return GitHubCommitListResponse(**commit_result)
|
||||
|
||||
|
||||
@router.get("/restore/preview", response_model=GitHubRestorePreview)
|
||||
async def preview_restore(
|
||||
ref: str = Query(default="HEAD", pattern=REF_PATTERN),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
|
||||
):
|
||||
"""Report which categories a given backup commit contains."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
|
||||
|
||||
preview = await github_restore_service.preview(config, ref=ref)
|
||||
return GitHubRestorePreview(**preview)
|
||||
|
||||
|
||||
@router.post("/restore", response_model=GitHubRestoreResponse)
|
||||
async def restore_backup(
|
||||
request: GitHubRestoreRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
|
||||
):
|
||||
"""Restore selected categories from one backup commit.
|
||||
|
||||
Note there is no private-repo gate here, unlike the config endpoints: that
|
||||
check exists to stop credentials leaving the instance, and this path only
|
||||
reads. A config can only be saved against a private repo anyway.
|
||||
"""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
|
||||
|
||||
restore_result = await github_restore_service.run_restore(
|
||||
config.id,
|
||||
ref=request.ref,
|
||||
categories=request.categories,
|
||||
overwrite_existing=request.overwrite_existing,
|
||||
)
|
||||
return GitHubRestoreResponse(**restore_result)
|
||||
|
||||
|
||||
@router.get("/logs", response_model=list[GitHubBackupLogResponse])
|
||||
async def get_logs(
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
|
||||
@@ -176,6 +176,7 @@ class GitHubBackupStatus(BaseModel):
|
||||
configured: bool = Field(description="Whether backup is configured")
|
||||
enabled: bool = Field(description="Whether backup is enabled")
|
||||
is_running: bool = Field(description="Whether a backup is currently running")
|
||||
restore_running: bool = Field(default=False, description="Whether a restore is currently running")
|
||||
progress: str | None = Field(default=None, description="Current backup progress message")
|
||||
last_backup_at: datetime | None
|
||||
last_backup_status: str | None
|
||||
@@ -204,3 +205,104 @@ class GitHubBackupTriggerResponse(BaseModel):
|
||||
log_id: int | None = None
|
||||
commit_sha: str | None = None
|
||||
files_changed: int = 0
|
||||
|
||||
|
||||
# --- Restore (issue #2656) --------------------------------------------------
|
||||
|
||||
# "HEAD" means "whatever the branch tip is right now"; the service resolves it
|
||||
# to a concrete SHA before reading anything so preview and apply can't straddle
|
||||
# two different commits. Anything else must look like a git object name.
|
||||
REF_PATTERN = r"^(?:HEAD|[0-9a-fA-F]{7,40})$"
|
||||
|
||||
|
||||
class RestoreCategory(StrEnum):
|
||||
"""Backup categories that can be restored.
|
||||
|
||||
Cloud profiles are deliberately absent: the backup collector never actually
|
||||
writes ``cloud_profiles/*.json`` (it reads a "setting" list key the Bambu
|
||||
Cloud API does not return), and the preset list it would collect carries no
|
||||
setting payload to restore from. Tracked separately from #2656.
|
||||
"""
|
||||
|
||||
KPROFILES = "kprofiles"
|
||||
SETTINGS = "settings"
|
||||
SPOOLS = "spools"
|
||||
ARCHIVES = "archives"
|
||||
|
||||
|
||||
class GitHubCommitInfo(BaseModel):
|
||||
"""One commit in the backup repository."""
|
||||
|
||||
sha: str
|
||||
message: str
|
||||
author: str
|
||||
date: str
|
||||
|
||||
|
||||
class GitHubCommitListResponse(BaseModel):
|
||||
"""Schema for the commit picker."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
branch: str
|
||||
commits: list[GitHubCommitInfo] = Field(default_factory=list)
|
||||
|
||||
|
||||
class GitHubRestorePreviewCategory(BaseModel):
|
||||
"""What a single category looks like inside one backup commit."""
|
||||
|
||||
category: RestoreCategory
|
||||
available: bool = Field(description="Whether this category is present in the commit")
|
||||
item_count: int = Field(default=0, description="Rows/profiles found, 0 when unavailable")
|
||||
detail: str | None = Field(default=None, description="Why unavailable, or extra context")
|
||||
|
||||
|
||||
class GitHubRestorePreview(BaseModel):
|
||||
"""Schema for inspecting a commit before restoring from it."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
ref: str = Field(description="The concrete commit SHA that was inspected")
|
||||
commit: GitHubCommitInfo | None = None
|
||||
metadata_version: str | None = Field(default=None, description="version field from backup_metadata.json")
|
||||
categories: list[GitHubRestorePreviewCategory] = Field(default_factory=list)
|
||||
|
||||
|
||||
class GitHubRestoreRequest(BaseModel):
|
||||
"""Schema for triggering a restore."""
|
||||
|
||||
ref: str = Field(default="HEAD", pattern=REF_PATTERN, description="Commit SHA to restore from, or HEAD")
|
||||
categories: list[RestoreCategory] = Field(..., min_length=1, description="Categories to restore")
|
||||
overwrite_existing: bool = Field(
|
||||
default=False,
|
||||
description="Update rows that already exist locally. When false, only missing rows are inserted.",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def deduplicate_categories(self) -> "GitHubRestoreRequest":
|
||||
# Same category twice would double-count the result totals.
|
||||
seen: list[RestoreCategory] = []
|
||||
for category in self.categories:
|
||||
if category not in seen:
|
||||
seen.append(category)
|
||||
self.categories = seen
|
||||
return self
|
||||
|
||||
|
||||
class GitHubRestoreCategoryResult(BaseModel):
|
||||
"""Per-category outcome of a restore."""
|
||||
|
||||
restored: int = 0
|
||||
skipped: int = 0
|
||||
failed: int = 0
|
||||
notes: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class GitHubRestoreResponse(BaseModel):
|
||||
"""Schema for the restore result."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
log_id: int | None = None
|
||||
ref: str | None = Field(default=None, description="The concrete commit SHA restored from")
|
||||
results: dict[str, GitHubRestoreCategoryResult] = Field(default_factory=dict)
|
||||
|
||||
@@ -76,3 +76,61 @@ class GitProviderBackend(ABC):
|
||||
client: httpx.AsyncClient,
|
||||
) -> dict:
|
||||
"""Push files to the repository. Returns status/message/commit_sha/files_changed."""
|
||||
|
||||
# --- Read side (restore, issue #2656) ---------------------------------
|
||||
# The backup path only ever writes. Restore needs to walk history, list a
|
||||
# snapshot and read individual blobs back, so these three mirror the
|
||||
# ``{"success": bool, "message": str, ...}`` convention ``test_connection``
|
||||
# already uses rather than raising.
|
||||
|
||||
@abstractmethod
|
||||
async def list_commits(
|
||||
self,
|
||||
repo_url: str,
|
||||
token: str,
|
||||
branch: str,
|
||||
client: httpx.AsyncClient,
|
||||
limit: int = 20,
|
||||
) -> dict:
|
||||
"""List recent commits on ``branch``, newest first.
|
||||
|
||||
Returns ``{"success", "message", "commits": [{"sha", "message", "author", "date"}]}``.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def list_tree(
|
||||
self,
|
||||
repo_url: str,
|
||||
token: str,
|
||||
ref: str,
|
||||
client: httpx.AsyncClient,
|
||||
) -> dict:
|
||||
"""List every blob path present at ``ref``.
|
||||
|
||||
``ref`` is a concrete commit SHA — the caller resolves "latest" to a SHA
|
||||
via :meth:`list_commits` first, so the snapshot being previewed and the
|
||||
one being restored are provably the same commit even if a scheduled
|
||||
backup lands in between.
|
||||
|
||||
Returns ``{"success", "message", "paths": [str]}``.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def fetch_files(
|
||||
self,
|
||||
repo_url: str,
|
||||
token: str,
|
||||
ref: str,
|
||||
paths: list[str],
|
||||
client: httpx.AsyncClient,
|
||||
) -> dict:
|
||||
"""Read several files' decoded UTF-8 text at ``ref``.
|
||||
|
||||
Batched rather than one-file-at-a-time so providers that need a tree
|
||||
listing to map path -> blob SHA can do that lookup once for the whole
|
||||
restore instead of per file.
|
||||
|
||||
Returns ``{"success", "message", "files": {path: text}}``. Paths absent
|
||||
from the commit are simply missing from ``files`` — that is not an error,
|
||||
since which categories a given backup contains varies by config.
|
||||
"""
|
||||
|
||||
@@ -115,6 +115,214 @@ class GitHubBackend(GitProviderBackend):
|
||||
"is_private": None,
|
||||
}
|
||||
|
||||
async def list_commits(
|
||||
self,
|
||||
repo_url: str,
|
||||
token: str,
|
||||
branch: str,
|
||||
client: httpx.AsyncClient,
|
||||
limit: int = 20,
|
||||
) -> dict:
|
||||
"""List recent commits on ``branch`` via the repo commits API."""
|
||||
try:
|
||||
owner, repo = self.parse_repo_url(repo_url)
|
||||
api_base = self.get_api_base(repo_url)
|
||||
headers = self.get_headers(token)
|
||||
|
||||
# GitHub pages with ``per_page`` and ignores ``limit``; Gitea/Forgejo
|
||||
# do the reverse. Sending both lets GiteaBackend inherit this method
|
||||
# unchanged instead of duplicating it for one query parameter.
|
||||
response = await client.get(
|
||||
f"{api_base}/repos/{owner}/{repo}/commits",
|
||||
headers=headers,
|
||||
params={"sha": branch, "per_page": limit, "limit": limit},
|
||||
)
|
||||
|
||||
if response.status_code == 404:
|
||||
return {
|
||||
"success": False,
|
||||
"message": (
|
||||
f"Branch '{branch}' not found, or the repository has no commits yet. "
|
||||
"Run a backup before restoring."
|
||||
),
|
||||
"commits": [],
|
||||
}
|
||||
if response.status_code != 200:
|
||||
msg = f"Failed to list commits (HTTP {response.status_code}): {self._truncated_response_text(response)}"
|
||||
logger.warning("list_commits %s/%s: %s", owner, repo, msg)
|
||||
return {"success": False, "message": msg, "commits": []}
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
return {"success": False, "message": "Non-JSON response listing commits", "commits": []}
|
||||
if not isinstance(data, list):
|
||||
return {"success": False, "message": "Unexpected shape listing commits", "commits": []}
|
||||
|
||||
return {"success": True, "message": "OK", "commits": self._parse_commit_entries(data, limit)}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("list_commits failed for %s branch=%s", repo_url, branch)
|
||||
return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "commits": []}
|
||||
|
||||
@staticmethod
|
||||
def _parse_commit_entries(data: list, limit: int) -> list[dict]:
|
||||
"""Normalise GitHub/Gitea commit list entries to our flat shape."""
|
||||
commits = []
|
||||
for entry in data[:limit]:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
sha = entry.get("sha")
|
||||
if not isinstance(sha, str) or not sha:
|
||||
continue
|
||||
commit = entry.get("commit") if isinstance(entry.get("commit"), dict) else {}
|
||||
author = commit.get("author") if isinstance(commit.get("author"), dict) else {}
|
||||
commits.append(
|
||||
{
|
||||
"sha": sha,
|
||||
"message": commit.get("message") or "",
|
||||
"author": author.get("name") or "",
|
||||
"date": author.get("date") or "",
|
||||
}
|
||||
)
|
||||
return commits
|
||||
|
||||
async def _blob_shas_at(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
headers: dict,
|
||||
api_base: str,
|
||||
owner: str,
|
||||
repo: str,
|
||||
ref: str,
|
||||
) -> tuple[dict[str, str] | None, str]:
|
||||
"""Return ``({path: blob_sha}, "")`` at ``ref``, or ``(None, error_message)``.
|
||||
|
||||
A commit SHA is a valid tree-ish for the trees API, so this resolves the
|
||||
commit's tree in one request rather than commit -> tree -> list.
|
||||
"""
|
||||
response = await client.get(
|
||||
f"{api_base}/repos/{owner}/{repo}/git/trees/{ref}?recursive=1",
|
||||
headers=headers,
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return None, f"Commit or tree '{ref}' not found in the repository"
|
||||
if response.status_code != 200:
|
||||
return None, f"Failed to list tree (HTTP {response.status_code}): {self._truncated_response_text(response)}"
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
return None, "Non-JSON response listing tree"
|
||||
# Same limit the push path guards against: a truncated listing would make
|
||||
# a restore silently skip categories that are actually in the backup.
|
||||
if data.get("truncated"):
|
||||
return None, (
|
||||
"Repository tree exceeds the API listing limit (truncated=true), so the backup "
|
||||
"contents cannot be enumerated reliably. Rotate the backup repository."
|
||||
)
|
||||
blobs: dict[str, str] = {}
|
||||
for item in data.get("tree", []):
|
||||
if not isinstance(item, dict) or item.get("type") != "blob":
|
||||
continue
|
||||
path, sha = item.get("path"), item.get("sha")
|
||||
if isinstance(path, str) and isinstance(sha, str) and path and sha:
|
||||
blobs[path] = sha
|
||||
return blobs, ""
|
||||
|
||||
async def list_tree(
|
||||
self,
|
||||
repo_url: str,
|
||||
token: str,
|
||||
ref: str,
|
||||
client: httpx.AsyncClient,
|
||||
) -> dict:
|
||||
"""List blob paths present at ``ref`` via the Git Data trees API."""
|
||||
try:
|
||||
owner, repo = self.parse_repo_url(repo_url)
|
||||
api_base = self.get_api_base(repo_url)
|
||||
headers = self.get_headers(token)
|
||||
|
||||
blobs, error = await self._blob_shas_at(client, headers, api_base, owner, repo, ref)
|
||||
if blobs is None:
|
||||
logger.warning("list_tree %s/%s ref=%s: %s", owner, repo, ref, error)
|
||||
return {"success": False, "message": error, "paths": []}
|
||||
|
||||
return {"success": True, "message": "OK", "paths": sorted(blobs)}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("list_tree failed for %s ref=%s", repo_url, ref)
|
||||
return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "paths": []}
|
||||
|
||||
async def fetch_files(
|
||||
self,
|
||||
repo_url: str,
|
||||
token: str,
|
||||
ref: str,
|
||||
paths: list[str],
|
||||
client: httpx.AsyncClient,
|
||||
) -> dict:
|
||||
"""Read ``paths`` at ``ref`` via the Git Data blobs API.
|
||||
|
||||
The blobs API is used rather than the contents API because contents
|
||||
inlines only files up to 1 MB — an archive-heavy ``print_history.json``
|
||||
can exceed that, and it would come back with an empty body instead of an
|
||||
error.
|
||||
"""
|
||||
try:
|
||||
owner, repo = self.parse_repo_url(repo_url)
|
||||
api_base = self.get_api_base(repo_url)
|
||||
headers = self.get_headers(token)
|
||||
|
||||
blobs, error = await self._blob_shas_at(client, headers, api_base, owner, repo, ref)
|
||||
if blobs is None:
|
||||
logger.warning("fetch_files %s/%s ref=%s: %s", owner, repo, ref, error)
|
||||
return {"success": False, "message": error, "files": {}}
|
||||
|
||||
files: dict[str, str] = {}
|
||||
for path in paths:
|
||||
sha = blobs.get(path)
|
||||
if sha is None:
|
||||
continue
|
||||
response = await client.get(f"{api_base}/repos/{owner}/{repo}/git/blobs/{sha}", headers=headers)
|
||||
if response.status_code != 200:
|
||||
msg = f"Failed to read {path} (HTTP {response.status_code}): {self._truncated_response_text(response)}"
|
||||
logger.warning("fetch_files %s/%s: %s", owner, repo, msg)
|
||||
return {"success": False, "message": msg, "files": {}}
|
||||
text, error = self._decode_blob(response, path)
|
||||
if text is None:
|
||||
logger.warning("fetch_files %s/%s: %s", owner, repo, error)
|
||||
return {"success": False, "message": error, "files": {}}
|
||||
files[path] = text
|
||||
|
||||
return {"success": True, "message": "OK", "files": files}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("fetch_files failed for %s ref=%s", repo_url, ref)
|
||||
return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "files": {}}
|
||||
|
||||
def _decode_blob(self, response: httpx.Response, path: str) -> tuple[str | None, str]:
|
||||
"""Decode a blob API response body to text, or return an error message."""
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
return None, f"Non-JSON response reading {path}"
|
||||
if not isinstance(data, dict):
|
||||
return None, f"Unexpected shape reading {path}"
|
||||
content = data.get("content")
|
||||
if not isinstance(content, str):
|
||||
return None, f"Missing content reading {path}"
|
||||
encoding = data.get("encoding", "base64")
|
||||
try:
|
||||
if encoding == "base64":
|
||||
# Both providers wrap base64 payloads at 60 chars; b64decode
|
||||
# tolerates the newlines, but be explicit about it.
|
||||
return base64.b64decode(content).decode("utf-8"), ""
|
||||
if encoding in ("utf-8", "text", "plain"):
|
||||
return content, ""
|
||||
except (ValueError, UnicodeDecodeError) as e:
|
||||
return None, f"Could not decode {path}: {type(e).__name__}"
|
||||
return None, f"Unsupported blob encoding {encoding!r} reading {path}"
|
||||
|
||||
async def push_files(
|
||||
self,
|
||||
repo_url: str,
|
||||
|
||||
@@ -115,6 +115,201 @@ class GitLabBackend(GitProviderBackend):
|
||||
"is_private": None,
|
||||
}
|
||||
|
||||
def _encoded_project(self, repo_url: str) -> str:
|
||||
"""Return the URL-encoded ``namespace/project`` path for /api/v4/projects/."""
|
||||
owner, repo = self.parse_repo_url(repo_url)
|
||||
return urllib.parse.quote(f"{owner}/{repo}", safe="")
|
||||
|
||||
async def list_commits(
|
||||
self,
|
||||
repo_url: str,
|
||||
token: str,
|
||||
branch: str,
|
||||
client: httpx.AsyncClient,
|
||||
limit: int = 20,
|
||||
) -> dict:
|
||||
"""List recent commits on ``branch`` via /repository/commits."""
|
||||
try:
|
||||
api_base = self.get_api_base(repo_url)
|
||||
headers = self.get_headers(token)
|
||||
encoded_path = self._encoded_project(repo_url)
|
||||
|
||||
response = await client.get(
|
||||
f"{api_base}/projects/{encoded_path}/repository/commits",
|
||||
headers=headers,
|
||||
params={"ref_name": branch, "per_page": limit},
|
||||
)
|
||||
|
||||
if response.status_code == 404:
|
||||
return {
|
||||
"success": False,
|
||||
"message": (
|
||||
f"Branch '{branch}' not found, or the repository has no commits yet. "
|
||||
"Run a backup before restoring."
|
||||
),
|
||||
"commits": [],
|
||||
}
|
||||
if response.status_code != 200:
|
||||
msg = f"Failed to list commits (HTTP {response.status_code}): {self._truncated_response_text(response)}"
|
||||
logger.warning("list_commits %s: %s", repo_url, msg)
|
||||
return {"success": False, "message": msg, "commits": []}
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
return {"success": False, "message": "Non-JSON response listing commits", "commits": []}
|
||||
if not isinstance(data, list):
|
||||
return {"success": False, "message": "Unexpected shape listing commits", "commits": []}
|
||||
|
||||
commits = []
|
||||
for entry in data[:limit]:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
sha = entry.get("id")
|
||||
if not isinstance(sha, str) or not sha:
|
||||
continue
|
||||
# GitLab flattens author/date onto the commit itself rather than
|
||||
# nesting them under "commit" the way GitHub does.
|
||||
commits.append(
|
||||
{
|
||||
"sha": sha,
|
||||
"message": entry.get("message") or "",
|
||||
"author": entry.get("author_name") or "",
|
||||
"date": entry.get("committed_date") or entry.get("created_at") or "",
|
||||
}
|
||||
)
|
||||
|
||||
return {"success": True, "message": "OK", "commits": commits}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("list_commits failed for %s branch=%s", repo_url, branch)
|
||||
return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "commits": []}
|
||||
|
||||
async def list_tree(
|
||||
self,
|
||||
repo_url: str,
|
||||
token: str,
|
||||
ref: str,
|
||||
client: httpx.AsyncClient,
|
||||
) -> dict:
|
||||
"""List blob paths at ``ref`` via /repository/tree, following pagination."""
|
||||
try:
|
||||
api_base = self.get_api_base(repo_url)
|
||||
headers = self.get_headers(token)
|
||||
encoded_path = self._encoded_project(repo_url)
|
||||
|
||||
paths: list[str] = []
|
||||
page = 1
|
||||
# GitLab's tree endpoint paginates instead of exposing a "truncated"
|
||||
# flag, so walk pages until one comes back short. The page cap stops
|
||||
# a malformed X-Next-Page loop from spinning forever.
|
||||
while page <= 50:
|
||||
response = await client.get(
|
||||
f"{api_base}/projects/{encoded_path}/repository/tree",
|
||||
headers=headers,
|
||||
params={"ref": ref, "recursive": "true", "per_page": 100, "page": page},
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Commit or tree '{ref}' not found in the repository",
|
||||
"paths": [],
|
||||
}
|
||||
if response.status_code != 200:
|
||||
msg = (
|
||||
f"Failed to list tree (HTTP {response.status_code}): {self._truncated_response_text(response)}"
|
||||
)
|
||||
logger.warning("list_tree %s ref=%s: %s", repo_url, ref, msg)
|
||||
return {"success": False, "message": msg, "paths": []}
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
return {"success": False, "message": "Non-JSON response listing tree", "paths": []}
|
||||
if not isinstance(data, list):
|
||||
return {"success": False, "message": "Unexpected shape listing tree", "paths": []}
|
||||
|
||||
for item in data:
|
||||
if isinstance(item, dict) and item.get("type") == "blob":
|
||||
path = item.get("path")
|
||||
if isinstance(path, str) and path:
|
||||
paths.append(path)
|
||||
|
||||
if len(data) < 100:
|
||||
break
|
||||
page += 1
|
||||
|
||||
return {"success": True, "message": "OK", "paths": sorted(paths)}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("list_tree failed for %s ref=%s", repo_url, ref)
|
||||
return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "paths": []}
|
||||
|
||||
async def fetch_files(
|
||||
self,
|
||||
repo_url: str,
|
||||
token: str,
|
||||
ref: str,
|
||||
paths: list[str],
|
||||
client: httpx.AsyncClient,
|
||||
) -> dict:
|
||||
"""Read ``paths`` at ``ref`` via /repository/files/{path}."""
|
||||
try:
|
||||
api_base = self.get_api_base(repo_url)
|
||||
headers = self.get_headers(token)
|
||||
encoded_path = self._encoded_project(repo_url)
|
||||
|
||||
files: dict[str, str] = {}
|
||||
for path in paths:
|
||||
encoded_file = urllib.parse.quote(path, safe="")
|
||||
response = await client.get(
|
||||
f"{api_base}/projects/{encoded_path}/repository/files/{encoded_file}",
|
||||
headers=headers,
|
||||
params={"ref": ref},
|
||||
)
|
||||
# A path absent from this commit is expected — which categories a
|
||||
# backup contains varies by config — so skip rather than fail.
|
||||
if response.status_code == 404:
|
||||
continue
|
||||
if response.status_code != 200:
|
||||
msg = (
|
||||
f"Failed to read {path} (HTTP {response.status_code}): "
|
||||
f"{self._truncated_response_text(response)}"
|
||||
)
|
||||
logger.warning("fetch_files %s: %s", repo_url, msg)
|
||||
return {"success": False, "message": msg, "files": {}}
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
return {"success": False, "message": f"Non-JSON response reading {path}", "files": {}}
|
||||
if not isinstance(data, dict):
|
||||
return {"success": False, "message": f"Unexpected shape reading {path}", "files": {}}
|
||||
|
||||
content = data.get("content")
|
||||
if not isinstance(content, str):
|
||||
return {"success": False, "message": f"Missing content reading {path}", "files": {}}
|
||||
encoding = data.get("encoding", "base64")
|
||||
try:
|
||||
if encoding == "base64":
|
||||
files[path] = base64.b64decode(content).decode("utf-8")
|
||||
elif encoding in ("text", "utf-8", "plain"):
|
||||
files[path] = content
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Unsupported encoding {encoding!r} reading {path}",
|
||||
"files": {},
|
||||
}
|
||||
except (ValueError, UnicodeDecodeError) as e:
|
||||
return {"success": False, "message": f"Could not decode {path}: {type(e).__name__}", "files": {}}
|
||||
|
||||
return {"success": True, "message": "OK", "files": files}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("fetch_files failed for %s ref=%s", repo_url, ref)
|
||||
return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "files": {}}
|
||||
|
||||
async def push_files(
|
||||
self,
|
||||
repo_url: str,
|
||||
|
||||
@@ -173,9 +173,32 @@ class GitHubBackupService:
|
||||
Returns:
|
||||
dict with success, message, log_id, commit_sha, files_changed
|
||||
"""
|
||||
# Everything from here to `self._running_backup = True` must stay
|
||||
# await-free. Both flags are plain bools and both callers are coroutines
|
||||
# on one event loop, so with no suspension point in between the loop
|
||||
# cannot run the restore service's mirror-image region (see
|
||||
# github_restore.run_restore) in the gap — whichever gets here first sets
|
||||
# its flag before the other can read it. Adding an `await` inside this
|
||||
# block reintroduces the check-then-set race and lets a backup and a
|
||||
# restore run at once.
|
||||
if self._running_backup:
|
||||
return {"success": False, "message": "A backup is already running", "log_id": None}
|
||||
|
||||
# Imported locally to avoid a module-level import cycle — the restore
|
||||
# service imports this module's singleton to take the mirror-image lock.
|
||||
# A restore rewrites the same tables this collector reads and publishes
|
||||
# K-profiles to the same printers, so the two must not interleave.
|
||||
# (A local `import` of an already-loaded module is not a suspension
|
||||
# point, so it does not break the await-free rule above.)
|
||||
from backend.app.services.github_restore import github_restore_service
|
||||
|
||||
if github_restore_service.is_running:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "A restore is currently running. Wait for it to finish before backing up.",
|
||||
"log_id": None,
|
||||
}
|
||||
|
||||
self._running_backup = True
|
||||
log_id = None
|
||||
|
||||
@@ -840,6 +863,12 @@ class GitHubBackupService:
|
||||
"energy_kwh": a.energy_kwh,
|
||||
"energy_cost": a.energy_cost,
|
||||
"created_at": str(a.created_at) if a.created_at else None,
|
||||
# Soft-deleted archives are collected too — their row is kept on
|
||||
# purpose so the stats endpoint keeps counting their filament and
|
||||
# energy (see archive_service.soft_delete_archive). Recording
|
||||
# deleted_at is what lets a restore put them back the way they
|
||||
# were instead of resurrecting them as visible archives.
|
||||
"deleted_at": str(a.deleted_at) if a.deleted_at else None,
|
||||
}
|
||||
archive_list.append(archive_data)
|
||||
|
||||
|
||||
@@ -0,0 +1,923 @@
|
||||
"""Restore Bambuddy data from a Git provider backup (issue #2656).
|
||||
|
||||
The backup side (``github_backup.py``) is push-only: it collects a handful of
|
||||
JSON documents and commits them. This module is the read side — it walks the
|
||||
backup repository's history, lets a caller inspect what a given commit contains,
|
||||
and applies selected categories back into the local database (or, for
|
||||
K-profiles, back onto the printers).
|
||||
|
||||
Design notes worth knowing before editing:
|
||||
|
||||
* **A restore never reuses the backup's primary keys.** ``spool.id`` and
|
||||
``print_archives.id`` are bare autoincrement columns, so the ids in a backup
|
||||
taken weeks ago very likely belong to unrelated rows today. Rows are matched
|
||||
on natural keys instead, inserted without an explicit id, and an
|
||||
``old_id -> new_id`` map is threaded through so foreign keys in dependent
|
||||
tables (spool usage history) still line up.
|
||||
* **Categories are applied archives -> spools -> settings -> kprofiles.**
|
||||
Archives first because spool usage history references ``archive_id``;
|
||||
K-profiles last because they leave the database and talk to hardware.
|
||||
* **Cloud profiles are not restorable.** The backup collector never actually
|
||||
writes ``cloud_profiles/*.json``, and the preset list it would write carries
|
||||
no setting payload. Tracked separately from #2656.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.database import async_session
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.project import Project
|
||||
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.schemas.github_backup import RestoreCategory
|
||||
from backend.app.services.git_providers.factory import get_provider_backend
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
METADATA_PATH = "backup_metadata.json"
|
||||
SETTINGS_PATH = "settings/app_settings.json"
|
||||
SPOOLS_PATH = "spools/inventory.json"
|
||||
SPOOL_USAGE_PATH = "spools/usage_history.json"
|
||||
ARCHIVES_PATH = "archives/print_history.json"
|
||||
|
||||
# kprofiles/{printer_serial}/{nozzle_diameter}.json
|
||||
_KPROFILE_PATH_RE = re.compile(r"^kprofiles/([^/]+)/([^/]+)\.json$")
|
||||
|
||||
# Settings keys the backup collector already refuses to write. Applied again on
|
||||
# the read side because a backup taken before that denylist existed can still
|
||||
# contain them, and a restore must not resurrect a stale credential.
|
||||
_SENSITIVE_SETTING_KEYS = {"bambu_cloud_token", "auth_secret_key"}
|
||||
|
||||
# Belt-and-braces for the same reason: any key that looks like a secret is
|
||||
# skipped even if it isn't in the explicit denylist above.
|
||||
_SECRET_KEY_HINTS = ("token", "secret", "password", "access_code", "api_key", "passphrase")
|
||||
|
||||
# Nozzle diameters the backup collector iterates. A path outside this set means
|
||||
# the backup was written by a newer version, so accept it rather than dropping
|
||||
# data, but keep the list for validation messages.
|
||||
_KNOWN_NOZZLES = {"0.2", "0.4", "0.6", "0.8"}
|
||||
|
||||
|
||||
def _parse_dt(value) -> datetime | None:
|
||||
"""Best-effort parse of a datetime the backup wrote via ``str(...)``."""
|
||||
if not value or not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _is_blocked_setting_key(key: str) -> bool:
|
||||
lowered = key.lower()
|
||||
return key in _SENSITIVE_SETTING_KEYS or any(hint in lowered for hint in _SECRET_KEY_HINTS)
|
||||
|
||||
|
||||
class _CategoryTally:
|
||||
"""Mutable accumulator matching ``GitHubRestoreCategoryResult``."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.restored = 0
|
||||
self.skipped = 0
|
||||
self.failed = 0
|
||||
self.notes: list[str] = []
|
||||
|
||||
def note(self, message: str) -> None:
|
||||
# Notes are surfaced verbatim in the UI, so keep the list bounded rather
|
||||
# than emitting one line per row for a large backup.
|
||||
if message not in self.notes and len(self.notes) < 20:
|
||||
self.notes.append(message)
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {"restored": self.restored, "skipped": self.skipped, "failed": self.failed, "notes": self.notes}
|
||||
|
||||
|
||||
class GitHubRestoreService:
|
||||
"""Reads a backup repository and applies selected categories locally."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._running_restore: bool = False
|
||||
self._progress: str | None = None
|
||||
self._http_client: httpx.AsyncClient | None = None
|
||||
# Guards the check-then-set on ``_running_restore``. Without it two
|
||||
# concurrent POSTs can both observe False before either sets it.
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
if self._http_client is None or self._http_client.is_closed:
|
||||
self._http_client = httpx.AsyncClient(timeout=60.0)
|
||||
return self._http_client
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._running_restore
|
||||
|
||||
@property
|
||||
def progress(self) -> str | None:
|
||||
return self._progress
|
||||
|
||||
# --- Repository reads --------------------------------------------------
|
||||
|
||||
async def list_commits(self, config: GitHubBackupConfig, limit: int = 20) -> dict:
|
||||
"""List recent commits on the configured branch."""
|
||||
backend = get_provider_backend(config.provider)
|
||||
client = await self._get_client()
|
||||
result = await backend.list_commits(
|
||||
repo_url=config.repository_url,
|
||||
token=config.access_token,
|
||||
branch=config.branch,
|
||||
client=client,
|
||||
limit=limit,
|
||||
)
|
||||
result["branch"] = config.branch
|
||||
return result
|
||||
|
||||
async def _resolve_ref(self, config: GitHubBackupConfig, ref: str) -> tuple[str | None, str]:
|
||||
"""Turn ``HEAD`` into a concrete commit SHA.
|
||||
|
||||
Done once up front so a preview and the restore that follows it act on
|
||||
the same commit even if a scheduled backup lands in between.
|
||||
"""
|
||||
if ref and ref.upper() != "HEAD":
|
||||
return ref, ""
|
||||
result = await self.list_commits(config, limit=1)
|
||||
if not result.get("success"):
|
||||
return None, result.get("message") or "Could not read the backup repository"
|
||||
commits = result.get("commits") or []
|
||||
if not commits:
|
||||
return None, f"Branch '{config.branch}' has no commits to restore from"
|
||||
return commits[0]["sha"], ""
|
||||
|
||||
def _category_paths(self, category: RestoreCategory, available: list[str]) -> list[str]:
|
||||
"""Return the paths in ``available`` that belong to ``category``."""
|
||||
if category == RestoreCategory.SETTINGS:
|
||||
return [p for p in (SETTINGS_PATH,) if p in available]
|
||||
if category == RestoreCategory.SPOOLS:
|
||||
return [p for p in (SPOOLS_PATH, SPOOL_USAGE_PATH) if p in available]
|
||||
if category == RestoreCategory.ARCHIVES:
|
||||
return [p for p in (ARCHIVES_PATH,) if p in available]
|
||||
if category == RestoreCategory.KPROFILES:
|
||||
return sorted(p for p in available if _KPROFILE_PATH_RE.match(p))
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _parse_json_files(raw: dict[str, str]) -> tuple[dict[str, object], list[str]]:
|
||||
"""Parse each fetched file, collecting paths that failed to parse."""
|
||||
parsed: dict[str, object] = {}
|
||||
bad: list[str] = []
|
||||
for path, text in raw.items():
|
||||
try:
|
||||
parsed[path] = json.loads(text)
|
||||
except (ValueError, TypeError):
|
||||
bad.append(path)
|
||||
return parsed, bad
|
||||
|
||||
async def preview(self, config: GitHubBackupConfig, ref: str = "HEAD") -> dict:
|
||||
"""Report which categories a commit contains, and how much is in each."""
|
||||
resolved, error = await self._resolve_ref(config, ref)
|
||||
if resolved is None:
|
||||
return {"success": False, "message": error, "ref": ref, "categories": []}
|
||||
|
||||
backend = get_provider_backend(config.provider)
|
||||
client = await self._get_client()
|
||||
|
||||
tree = await backend.list_tree(
|
||||
repo_url=config.repository_url, token=config.access_token, ref=resolved, client=client
|
||||
)
|
||||
if not tree.get("success"):
|
||||
return {"success": False, "message": tree.get("message") or "Could not list the commit", "ref": resolved}
|
||||
available: list[str] = tree.get("paths") or []
|
||||
|
||||
# One batched read covers metadata plus every category payload.
|
||||
wanted = [METADATA_PATH] if METADATA_PATH in available else []
|
||||
for category in RestoreCategory:
|
||||
wanted.extend(self._category_paths(category, available))
|
||||
|
||||
fetched = await backend.fetch_files(
|
||||
repo_url=config.repository_url, token=config.access_token, ref=resolved, paths=wanted, client=client
|
||||
)
|
||||
if not fetched.get("success"):
|
||||
return {
|
||||
"success": False,
|
||||
"message": fetched.get("message") or "Could not read the commit contents",
|
||||
"ref": resolved,
|
||||
}
|
||||
parsed, bad_paths = self._parse_json_files(fetched.get("files") or {})
|
||||
|
||||
metadata = parsed.get(METADATA_PATH)
|
||||
metadata_version = metadata.get("version") if isinstance(metadata, dict) else None
|
||||
|
||||
categories = []
|
||||
for category in RestoreCategory:
|
||||
paths = self._category_paths(category, available)
|
||||
if not paths:
|
||||
categories.append(
|
||||
{
|
||||
"category": category,
|
||||
"available": False,
|
||||
"item_count": 0,
|
||||
"detail": "Not present in this backup commit",
|
||||
}
|
||||
)
|
||||
continue
|
||||
unreadable = [p for p in paths if p in bad_paths]
|
||||
if unreadable:
|
||||
categories.append(
|
||||
{
|
||||
"category": category,
|
||||
"available": False,
|
||||
"item_count": 0,
|
||||
"detail": f"Unreadable JSON: {', '.join(unreadable)}",
|
||||
}
|
||||
)
|
||||
continue
|
||||
count, detail = self._count_items(category, parsed)
|
||||
categories.append({"category": category, "available": True, "item_count": count, "detail": detail})
|
||||
|
||||
commit_info = None
|
||||
commits = (await self.list_commits(config, limit=20)).get("commits") or []
|
||||
for entry in commits:
|
||||
if entry["sha"] == resolved:
|
||||
commit_info = entry
|
||||
break
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "OK",
|
||||
"ref": resolved,
|
||||
"commit": commit_info,
|
||||
"metadata_version": metadata_version,
|
||||
"categories": categories,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _count_items(category: RestoreCategory, parsed: dict) -> tuple[int, str | None]:
|
||||
"""Count restorable items for ``category`` and describe any caveat."""
|
||||
if category == RestoreCategory.SETTINGS:
|
||||
payload = parsed.get(SETTINGS_PATH)
|
||||
values = payload.get("settings") if isinstance(payload, dict) else None
|
||||
if not isinstance(values, dict):
|
||||
return 0, "No settings in payload"
|
||||
blocked = sum(1 for key in values if _is_blocked_setting_key(key))
|
||||
detail = f"{blocked} credential-like keys will be skipped" if blocked else None
|
||||
return len(values) - blocked, detail
|
||||
|
||||
if category == RestoreCategory.SPOOLS:
|
||||
payload = parsed.get(SPOOLS_PATH)
|
||||
spools = payload.get("spools") if isinstance(payload, dict) else None
|
||||
usage_payload = parsed.get(SPOOL_USAGE_PATH)
|
||||
usage = usage_payload.get("usage_history") if isinstance(usage_payload, dict) else None
|
||||
count = len(spools) if isinstance(spools, list) else 0
|
||||
detail = f"plus {len(usage)} usage records" if isinstance(usage, list) and usage else None
|
||||
return count, detail
|
||||
|
||||
if category == RestoreCategory.ARCHIVES:
|
||||
payload = parsed.get(ARCHIVES_PATH)
|
||||
archives = payload.get("archives") if isinstance(payload, dict) else None
|
||||
count = len(archives) if isinstance(archives, list) else 0
|
||||
return count, "Metadata only — 3MF files and thumbnails are not in a Git backup"
|
||||
|
||||
if category == RestoreCategory.KPROFILES:
|
||||
total = 0
|
||||
serials = set()
|
||||
for path, payload in parsed.items():
|
||||
match = _KPROFILE_PATH_RE.match(path)
|
||||
if not match or not isinstance(payload, dict):
|
||||
continue
|
||||
serials.add(match.group(1))
|
||||
profiles = payload.get("profiles")
|
||||
if isinstance(profiles, list):
|
||||
total += len(profiles)
|
||||
detail = f"across {len(serials)} printer(s)" if serials else None
|
||||
return total, detail
|
||||
|
||||
return 0, None
|
||||
|
||||
# --- Restore -----------------------------------------------------------
|
||||
|
||||
async def run_restore(
|
||||
self,
|
||||
config_id: int,
|
||||
ref: str,
|
||||
categories: list[RestoreCategory],
|
||||
overwrite_existing: bool = False,
|
||||
) -> dict:
|
||||
"""Apply selected categories from one backup commit."""
|
||||
# Import locally to avoid a module-level cycle: the backup service takes
|
||||
# the mirror-image lock against us.
|
||||
from backend.app.services.github_backup import github_backup_service
|
||||
|
||||
# The lock serialises two concurrent restores; the backup side has no
|
||||
# lock of its own, and relies on this region staying await-free after the
|
||||
# acquisition. Both flags are plain bools on one event loop, so with no
|
||||
# suspension point between the two reads and the write, the loop cannot
|
||||
# slip github_backup.run_backup's mirror-image check in between. Adding an
|
||||
# `await` below the acquisition and above `self._running_restore = True`
|
||||
# would let a backup and a restore run at once.
|
||||
async with self._lock:
|
||||
if self._running_restore:
|
||||
return {"success": False, "message": "A restore is already running", "results": {}}
|
||||
if github_backup_service.is_running:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "A backup is currently running. Wait for it to finish before restoring.",
|
||||
"results": {},
|
||||
}
|
||||
self._running_restore = True
|
||||
|
||||
log_id = None
|
||||
try:
|
||||
async with async_session() as db:
|
||||
result = await db.execute(select(GitHubBackupConfig).where(GitHubBackupConfig.id == config_id))
|
||||
config = result.scalar_one_or_none()
|
||||
if not config:
|
||||
return {"success": False, "message": "Configuration not found", "results": {}}
|
||||
|
||||
self._progress = "Resolving commit..."
|
||||
resolved, error = await self._resolve_ref(config, ref)
|
||||
if resolved is None:
|
||||
return {"success": False, "message": error, "results": {}}
|
||||
|
||||
log = GitHubBackupLog(config_id=config_id, status="running", trigger="restore", commit_sha=resolved)
|
||||
db.add(log)
|
||||
await db.commit()
|
||||
await db.refresh(log)
|
||||
log_id = log.id
|
||||
|
||||
try:
|
||||
payload, error = await self._read_categories(config, resolved, categories)
|
||||
if error:
|
||||
raise RuntimeError(error)
|
||||
|
||||
results = await self._apply(db, payload, categories, overwrite_existing)
|
||||
await db.commit()
|
||||
|
||||
total_restored = sum(tally.restored for tally in results.values())
|
||||
any_failed = any(tally.failed for tally in results.values())
|
||||
|
||||
log.status = "failed" if any_failed and total_restored == 0 else "success"
|
||||
log.completed_at = datetime.now(timezone.utc)
|
||||
log.files_changed = total_restored
|
||||
if any_failed:
|
||||
log.error_message = "Some items could not be restored — see the restore result for detail"
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Restored {total_restored} item(s) from {resolved[:7]}",
|
||||
"log_id": log_id,
|
||||
"ref": resolved,
|
||||
"results": {name: tally.as_dict() for name, tally in results.items()},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Restore failed for config %s ref %s", config_id, resolved)
|
||||
await db.rollback()
|
||||
log.status = "failed"
|
||||
log.completed_at = datetime.now(timezone.utc)
|
||||
log.error_message = str(e)[:1000]
|
||||
await db.commit()
|
||||
return {"success": False, "message": str(e), "log_id": log_id, "ref": resolved, "results": {}}
|
||||
|
||||
finally:
|
||||
self._running_restore = False
|
||||
self._progress = None
|
||||
|
||||
async def _read_categories(
|
||||
self, config: GitHubBackupConfig, ref: str, categories: list[RestoreCategory]
|
||||
) -> tuple[dict, str]:
|
||||
"""Fetch and parse just the files the requested categories need."""
|
||||
backend = get_provider_backend(config.provider)
|
||||
client = await self._get_client()
|
||||
|
||||
self._progress = "Listing backup contents..."
|
||||
tree = await backend.list_tree(
|
||||
repo_url=config.repository_url, token=config.access_token, ref=ref, client=client
|
||||
)
|
||||
if not tree.get("success"):
|
||||
return {}, tree.get("message") or "Could not list the commit"
|
||||
available: list[str] = tree.get("paths") or []
|
||||
|
||||
wanted: list[str] = []
|
||||
for category in categories:
|
||||
wanted.extend(self._category_paths(category, available))
|
||||
if not wanted:
|
||||
return {}, "None of the selected categories are present in that commit"
|
||||
|
||||
self._progress = "Downloading backup files..."
|
||||
fetched = await backend.fetch_files(
|
||||
repo_url=config.repository_url, token=config.access_token, ref=ref, paths=wanted, client=client
|
||||
)
|
||||
if not fetched.get("success"):
|
||||
return {}, fetched.get("message") or "Could not read the commit contents"
|
||||
|
||||
parsed, bad = self._parse_json_files(fetched.get("files") or {})
|
||||
if bad:
|
||||
return {}, f"Backup contains unreadable JSON: {', '.join(sorted(bad))}"
|
||||
return parsed, ""
|
||||
|
||||
async def _apply(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: dict,
|
||||
categories: list[RestoreCategory],
|
||||
overwrite: bool,
|
||||
) -> dict[str, _CategoryTally]:
|
||||
"""Apply categories in dependency order and return per-category tallies."""
|
||||
results: dict[str, _CategoryTally] = {}
|
||||
archive_id_map: dict[int, int] = {}
|
||||
|
||||
# Archives first: spool usage history references archive_id.
|
||||
if RestoreCategory.ARCHIVES in categories:
|
||||
self._progress = "Restoring print archives..."
|
||||
tally = _CategoryTally()
|
||||
await self._restore_archives(db, payload.get(ARCHIVES_PATH), overwrite, tally, archive_id_map)
|
||||
results[RestoreCategory.ARCHIVES.value] = tally
|
||||
|
||||
if RestoreCategory.SPOOLS in categories:
|
||||
self._progress = "Restoring spool inventory..."
|
||||
tally = _CategoryTally()
|
||||
await self._restore_spools(
|
||||
db,
|
||||
payload.get(SPOOLS_PATH),
|
||||
payload.get(SPOOL_USAGE_PATH),
|
||||
overwrite,
|
||||
tally,
|
||||
archive_id_map,
|
||||
)
|
||||
results[RestoreCategory.SPOOLS.value] = tally
|
||||
|
||||
if RestoreCategory.SETTINGS in categories:
|
||||
self._progress = "Restoring app settings..."
|
||||
tally = _CategoryTally()
|
||||
await self._restore_settings(db, payload.get(SETTINGS_PATH), overwrite, tally)
|
||||
results[RestoreCategory.SETTINGS.value] = tally
|
||||
|
||||
# Last, because it leaves the database and publishes over MQTT.
|
||||
if RestoreCategory.KPROFILES in categories:
|
||||
self._progress = "Sending K-profiles to printers..."
|
||||
tally = _CategoryTally()
|
||||
await self._restore_kprofiles(db, payload, tally)
|
||||
results[RestoreCategory.KPROFILES.value] = tally
|
||||
|
||||
return results
|
||||
|
||||
# --- Per-category appliers --------------------------------------------
|
||||
|
||||
async def _restore_archives(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload,
|
||||
overwrite: bool,
|
||||
tally: _CategoryTally,
|
||||
id_map: dict[int, int],
|
||||
) -> None:
|
||||
archives = payload.get("archives") if isinstance(payload, dict) else None
|
||||
if not isinstance(archives, list):
|
||||
tally.note("No archive data in this backup")
|
||||
return
|
||||
|
||||
valid_printers = set((await db.execute(select(Printer.id))).scalars().all())
|
||||
valid_projects = set((await db.execute(select(Project.id))).scalars().all())
|
||||
|
||||
# Only metadata is backed up, never the 3MF/thumbnail bytes, and
|
||||
# print_archives.file_path is NOT NULL — so inserted rows get an empty
|
||||
# path and are history-only. Say so once rather than per row.
|
||||
warned_files = False
|
||||
|
||||
for entry in archives:
|
||||
if not isinstance(entry, dict):
|
||||
tally.failed += 1
|
||||
continue
|
||||
|
||||
old_id = entry.get("id") if isinstance(entry.get("id"), int) else None
|
||||
started_at = _parse_dt(entry.get("started_at"))
|
||||
existing = await self._find_archive(db, entry, started_at)
|
||||
|
||||
fields = {
|
||||
"print_name": entry.get("print_name"),
|
||||
"print_time_seconds": entry.get("print_time_seconds"),
|
||||
"filament_used_grams": entry.get("filament_used_grams"),
|
||||
"filament_type": entry.get("filament_type"),
|
||||
"filament_color": entry.get("filament_color"),
|
||||
"layer_height": entry.get("layer_height"),
|
||||
"total_layers": entry.get("total_layers"),
|
||||
"nozzle_diameter": entry.get("nozzle_diameter"),
|
||||
"bed_temperature": entry.get("bed_temperature"),
|
||||
"nozzle_temperature": entry.get("nozzle_temperature"),
|
||||
"sliced_for_model": entry.get("sliced_for_model"),
|
||||
"status": entry.get("status") or "completed",
|
||||
"started_at": started_at,
|
||||
"completed_at": _parse_dt(entry.get("completed_at")),
|
||||
"makerworld_url": entry.get("makerworld_url"),
|
||||
"designer": entry.get("designer"),
|
||||
"external_url": entry.get("external_url"),
|
||||
"is_favorite": bool(entry.get("is_favorite")),
|
||||
"tags": entry.get("tags"),
|
||||
"notes": entry.get("notes"),
|
||||
"cost": entry.get("cost"),
|
||||
"failure_reason": entry.get("failure_reason"),
|
||||
"quantity": entry.get("quantity") or 1,
|
||||
"energy_kwh": entry.get("energy_kwh"),
|
||||
"energy_cost": entry.get("energy_cost"),
|
||||
# A soft-deleted archive is still in the backup (its row is kept
|
||||
# so stats keep counting it), so carry the flag across or the
|
||||
# restore turns something the user deleted back into a visible
|
||||
# archive. Backups written before this key existed have no
|
||||
# deleted_at, and those rows can only come back live.
|
||||
"deleted_at": _parse_dt(entry.get("deleted_at")),
|
||||
}
|
||||
|
||||
printer_id = entry.get("printer_id")
|
||||
if printer_id is not None and printer_id not in valid_printers:
|
||||
tally.note("Some archives referenced printers that no longer exist — link cleared")
|
||||
printer_id = None
|
||||
project_id = entry.get("project_id")
|
||||
if project_id is not None and project_id not in valid_projects:
|
||||
tally.note("Some archives referenced projects that no longer exist — link cleared")
|
||||
project_id = None
|
||||
fields["printer_id"] = printer_id
|
||||
fields["project_id"] = project_id
|
||||
|
||||
if existing is not None:
|
||||
if old_id is not None:
|
||||
id_map[old_id] = existing.id
|
||||
if not overwrite:
|
||||
tally.skipped += 1
|
||||
continue
|
||||
# Overwrite means "make the local row match the backup", which
|
||||
# includes un-deleting one the user deleted after the backup was
|
||||
# taken. Legitimate, but not obvious from a restored/skipped
|
||||
# count, so say it.
|
||||
if existing.deleted_at is not None and fields["deleted_at"] is None:
|
||||
tally.note("Archive(s) deleted since the backup are visible again — overwrite was on")
|
||||
for key, value in fields.items():
|
||||
setattr(existing, key, value)
|
||||
tally.restored += 1
|
||||
continue
|
||||
|
||||
if not warned_files:
|
||||
tally.note(
|
||||
"Restored archives carry metadata only — the 3MF and thumbnail files are not in a Git backup"
|
||||
)
|
||||
warned_files = True
|
||||
|
||||
row = PrintArchive(
|
||||
filename=entry.get("filename") or "restored-from-backup",
|
||||
file_path="",
|
||||
file_size=entry.get("file_size") or 0,
|
||||
content_hash=entry.get("content_hash"),
|
||||
**fields,
|
||||
)
|
||||
created_at = _parse_dt(entry.get("created_at"))
|
||||
if created_at is not None:
|
||||
row.created_at = created_at
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
if old_id is not None:
|
||||
id_map[old_id] = row.id
|
||||
tally.restored += 1
|
||||
|
||||
async def _find_archive(self, db: AsyncSession, entry: dict, started_at: datetime | None) -> PrintArchive | None:
|
||||
"""Match a backed-up archive to a local row by natural key.
|
||||
|
||||
``started_at`` is nullable and genuinely NULL for a whole class of rows —
|
||||
the re-slice path in ``library.py`` constructs ``PrintArchive`` without
|
||||
one — so it cannot be *required* by the key. It narrows the match instead:
|
||||
a backed-up row with no ``started_at`` matches a local row that has none
|
||||
either. Requiring it meant those archives never matched, so each restore
|
||||
re-inserted them as duplicates and overwrite mode could never update them.
|
||||
|
||||
``content_hash`` identifies the sliced file on its own, which is why it is
|
||||
the branch allowed to run without a ``started_at``; ``filename`` is too
|
||||
weak for that (re-slices share it) and still requires one. Two backed-up
|
||||
rows sharing a hash *and* having no ``started_at`` are indistinguishable
|
||||
in the backup, so they collapse onto one local row — better than
|
||||
duplicating both on every restore.
|
||||
|
||||
Soft-deleted rows are matched deliberately: there is no ``deleted_at``
|
||||
filter here because the row still exists, and matching it is what stops a
|
||||
restore inserting a live duplicate of an archive the user has deleted.
|
||||
"""
|
||||
started_predicate = PrintArchive.started_at == started_at if started_at else PrintArchive.started_at.is_(None)
|
||||
|
||||
content_hash = entry.get("content_hash")
|
||||
if content_hash:
|
||||
result = await db.execute(
|
||||
select(PrintArchive).where(PrintArchive.content_hash == content_hash, started_predicate)
|
||||
)
|
||||
row = result.scalars().first()
|
||||
if row is not None:
|
||||
return row
|
||||
|
||||
filename = entry.get("filename")
|
||||
if filename and started_at:
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.filename == filename, started_predicate))
|
||||
return result.scalars().first()
|
||||
return None
|
||||
|
||||
async def _restore_spools(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
inventory,
|
||||
usage_payload,
|
||||
overwrite: bool,
|
||||
tally: _CategoryTally,
|
||||
archive_id_map: dict[int, int],
|
||||
) -> None:
|
||||
spools = inventory.get("spools") if isinstance(inventory, dict) else None
|
||||
if not isinstance(spools, list):
|
||||
tally.note("No spool data in this backup")
|
||||
return
|
||||
|
||||
spool_id_map: dict[int, int] = {}
|
||||
|
||||
for entry in spools:
|
||||
if not isinstance(entry, dict):
|
||||
tally.failed += 1
|
||||
continue
|
||||
|
||||
old_id = entry.get("id") if isinstance(entry.get("id"), int) else None
|
||||
existing = await self._find_spool(db, entry)
|
||||
|
||||
fields = {
|
||||
"material": entry.get("material") or "PLA",
|
||||
"subtype": entry.get("subtype"),
|
||||
"color_name": entry.get("color_name"),
|
||||
"rgba": entry.get("rgba"),
|
||||
"brand": entry.get("brand"),
|
||||
"label_weight": entry.get("label_weight") or 1000,
|
||||
"core_weight": entry.get("core_weight") or 250,
|
||||
"weight_used": entry.get("weight_used") or 0,
|
||||
"weight_locked": bool(entry.get("weight_locked")),
|
||||
"slicer_filament": entry.get("slicer_filament"),
|
||||
"slicer_filament_name": entry.get("slicer_filament_name"),
|
||||
"nozzle_temp_min": entry.get("nozzle_temp_min"),
|
||||
"nozzle_temp_max": entry.get("nozzle_temp_max"),
|
||||
"note": entry.get("note"),
|
||||
"cost_per_kg": entry.get("cost_per_kg"),
|
||||
"tag_uid": entry.get("tag_uid"),
|
||||
"tray_uuid": entry.get("tray_uuid"),
|
||||
"data_origin": entry.get("data_origin"),
|
||||
"tag_type": entry.get("tag_type"),
|
||||
"archived_at": _parse_dt(entry.get("archived_at")),
|
||||
}
|
||||
|
||||
if existing is not None:
|
||||
if old_id is not None:
|
||||
spool_id_map[old_id] = existing.id
|
||||
if not overwrite:
|
||||
tally.skipped += 1
|
||||
continue
|
||||
for key, value in fields.items():
|
||||
setattr(existing, key, value)
|
||||
tally.restored += 1
|
||||
continue
|
||||
|
||||
row = Spool(**fields)
|
||||
# Carry the original created_at across. Without it the row would be
|
||||
# stamped "now", and the composite fallback in _find_spool (which
|
||||
# keys on created_at) would miss on a second restore and insert a
|
||||
# duplicate instead of matching.
|
||||
created_at = _parse_dt(entry.get("created_at"))
|
||||
if created_at is not None:
|
||||
row.created_at = created_at
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
if old_id is not None:
|
||||
spool_id_map[old_id] = row.id
|
||||
tally.restored += 1
|
||||
|
||||
await self._restore_spool_usage(db, usage_payload, tally, spool_id_map, archive_id_map)
|
||||
|
||||
async def _find_spool(self, db: AsyncSession, entry: dict) -> Spool | None:
|
||||
"""Match a backed-up spool to a local row.
|
||||
|
||||
Physical identity first (an RFID/Bambu tag is the spool), then a
|
||||
descriptive composite including ``created_at`` so two otherwise
|
||||
identical spools added at different times stay distinct.
|
||||
"""
|
||||
tag_uid = entry.get("tag_uid")
|
||||
if tag_uid:
|
||||
result = await db.execute(select(Spool).where(Spool.tag_uid == tag_uid))
|
||||
row = result.scalars().first()
|
||||
if row is not None:
|
||||
return row
|
||||
|
||||
tray_uuid = entry.get("tray_uuid")
|
||||
if tray_uuid:
|
||||
result = await db.execute(select(Spool).where(Spool.tray_uuid == tray_uuid))
|
||||
row = result.scalars().first()
|
||||
if row is not None:
|
||||
return row
|
||||
|
||||
created_at = _parse_dt(entry.get("created_at"))
|
||||
if created_at is None:
|
||||
return None
|
||||
result = await db.execute(
|
||||
select(Spool).where(
|
||||
Spool.created_at == created_at,
|
||||
Spool.material == (entry.get("material") or "PLA"),
|
||||
Spool.brand == entry.get("brand"),
|
||||
Spool.subtype == entry.get("subtype"),
|
||||
Spool.color_name == entry.get("color_name"),
|
||||
)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def _restore_spool_usage(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
usage_payload,
|
||||
tally: _CategoryTally,
|
||||
spool_id_map: dict[int, int],
|
||||
archive_id_map: dict[int, int],
|
||||
) -> None:
|
||||
usage = usage_payload.get("usage_history") if isinstance(usage_payload, dict) else None
|
||||
if not isinstance(usage, list) or not usage:
|
||||
return
|
||||
|
||||
valid_printers = set((await db.execute(select(Printer.id))).scalars().all())
|
||||
unresolved = 0
|
||||
|
||||
for entry in usage:
|
||||
if not isinstance(entry, dict):
|
||||
tally.failed += 1
|
||||
continue
|
||||
|
||||
old_spool_id = entry.get("spool_id")
|
||||
spool_id = spool_id_map.get(old_spool_id) if isinstance(old_spool_id, int) else None
|
||||
if spool_id is None:
|
||||
# The parent spool never made it into the map: the backup's spool
|
||||
# list didn't include it, or its entry carried no integer id. A
|
||||
# spool that was merely *skipped* (matched locally, overwrite off)
|
||||
# is mapped a few lines up in _restore_spools, so it never lands
|
||||
# here — which is why the note below offers no remedy.
|
||||
unresolved += 1
|
||||
tally.skipped += 1
|
||||
continue
|
||||
|
||||
created_at = _parse_dt(entry.get("created_at"))
|
||||
# Usage history has no natural key of its own, so dedupe on the
|
||||
# tuple that makes a consumption event unique in practice.
|
||||
existing = await db.execute(
|
||||
select(SpoolUsageHistory).where(
|
||||
SpoolUsageHistory.spool_id == spool_id,
|
||||
SpoolUsageHistory.created_at == created_at,
|
||||
SpoolUsageHistory.weight_used == (entry.get("weight_used") or 0),
|
||||
SpoolUsageHistory.print_name == entry.get("print_name"),
|
||||
)
|
||||
)
|
||||
if existing.scalars().first() is not None:
|
||||
tally.skipped += 1
|
||||
continue
|
||||
|
||||
printer_id = entry.get("printer_id")
|
||||
if printer_id is not None and printer_id not in valid_printers:
|
||||
printer_id = None
|
||||
|
||||
old_archive_id = entry.get("archive_id")
|
||||
archive_id = archive_id_map.get(old_archive_id) if isinstance(old_archive_id, int) else None
|
||||
|
||||
row = SpoolUsageHistory(
|
||||
spool_id=spool_id,
|
||||
printer_id=printer_id,
|
||||
print_name=entry.get("print_name"),
|
||||
archive_id=archive_id,
|
||||
weight_used=entry.get("weight_used") or 0,
|
||||
percent_used=entry.get("percent_used") or 0,
|
||||
status=entry.get("status") or "completed",
|
||||
cost=entry.get("cost"),
|
||||
)
|
||||
if created_at is not None:
|
||||
row.created_at = created_at
|
||||
db.add(row)
|
||||
tally.restored += 1
|
||||
|
||||
if unresolved:
|
||||
tally.note(
|
||||
f"{unresolved} usage record(s) skipped — their spool is not in this backup's "
|
||||
"spool list, so there is nothing to attach them to."
|
||||
)
|
||||
|
||||
async def _restore_settings(self, db: AsyncSession, payload, overwrite: bool, tally: _CategoryTally) -> None:
|
||||
values = payload.get("settings") if isinstance(payload, dict) else None
|
||||
if not isinstance(values, dict):
|
||||
tally.note("No settings data in this backup")
|
||||
return
|
||||
|
||||
blocked = 0
|
||||
for key, value in values.items():
|
||||
if not isinstance(key, str) or not key:
|
||||
tally.failed += 1
|
||||
continue
|
||||
if _is_blocked_setting_key(key):
|
||||
blocked += 1
|
||||
tally.skipped += 1
|
||||
continue
|
||||
if value is None:
|
||||
tally.skipped += 1
|
||||
continue
|
||||
|
||||
result = await db.execute(select(Settings).where(Settings.key == key))
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing is not None:
|
||||
if not overwrite:
|
||||
tally.skipped += 1
|
||||
continue
|
||||
existing.value = str(value)
|
||||
tally.restored += 1
|
||||
continue
|
||||
|
||||
db.add(Settings(key=key, value=str(value)))
|
||||
tally.restored += 1
|
||||
|
||||
if blocked:
|
||||
tally.note(f"{blocked} credential-like key(s) skipped — re-enter secrets manually")
|
||||
|
||||
async def _restore_kprofiles(self, db: AsyncSession, payload: dict, tally: _CategoryTally) -> None:
|
||||
by_serial: dict[str, list[tuple[str, dict]]] = {}
|
||||
for path, content in payload.items():
|
||||
match = _KPROFILE_PATH_RE.match(path)
|
||||
if not match or not isinstance(content, dict):
|
||||
continue
|
||||
by_serial.setdefault(match.group(1), []).append((match.group(2), content))
|
||||
|
||||
if not by_serial:
|
||||
tally.note("No K-profile data in this backup")
|
||||
return
|
||||
|
||||
result = await db.execute(select(Printer))
|
||||
printers = {p.serial_number: p for p in result.scalars().all() if p.serial_number}
|
||||
|
||||
# Overwrite is not offered for K-profiles: extrusion_cali_set replaces
|
||||
# the profile occupying a slot, so writing is always an overwrite on the
|
||||
# printer side.
|
||||
tally.note("K-profiles always overwrite the matching slot on the printer")
|
||||
tally.note("Profiles are published over MQTT without acknowledgement — verify on the printer")
|
||||
|
||||
for serial, entries in sorted(by_serial.items()):
|
||||
profile_total = sum(len(c.get("profiles") or []) for _, c in entries)
|
||||
|
||||
printer = printers.get(serial)
|
||||
if printer is None:
|
||||
tally.skipped += profile_total
|
||||
tally.note(f"No printer with serial {serial} — skipped")
|
||||
continue
|
||||
|
||||
client = printer_manager.get_client(printer.id)
|
||||
if not client or not client.state.connected:
|
||||
tally.skipped += profile_total
|
||||
tally.note(f"{printer.name} ({serial}) is not connected — skipped")
|
||||
continue
|
||||
|
||||
for nozzle, content in sorted(entries):
|
||||
profiles = content.get("profiles")
|
||||
if not isinstance(profiles, list) or not profiles:
|
||||
continue
|
||||
if nozzle not in _KNOWN_NOZZLES:
|
||||
tally.note(f"Unexpected nozzle diameter {nozzle} for {serial} — sent as-is")
|
||||
|
||||
profile_dicts = [
|
||||
{
|
||||
"filament_id": p.get("filament_id", ""),
|
||||
"name": p.get("name", ""),
|
||||
"k_value": p.get("k_value", "0.020000"),
|
||||
"nozzle_id": p.get("nozzle_id"),
|
||||
"extruder_id": p.get("extruder_id", 0),
|
||||
"setting_id": p.get("setting_id"),
|
||||
"slot_id": p.get("slot_id", 0),
|
||||
}
|
||||
for p in profiles
|
||||
if isinstance(p, dict)
|
||||
]
|
||||
if not profile_dicts:
|
||||
continue
|
||||
|
||||
try:
|
||||
sent = client.set_kprofiles_batch(profile_dicts, nozzle)
|
||||
except Exception as e:
|
||||
logger.warning("K-profile restore failed for %s nozzle %s: %s", serial, nozzle, e)
|
||||
sent = False
|
||||
|
||||
if sent:
|
||||
tally.restored += len(profile_dicts)
|
||||
else:
|
||||
tally.failed += len(profile_dicts)
|
||||
tally.note(f"Failed to send {nozzle} profiles to {printer.name} ({serial})")
|
||||
|
||||
|
||||
# Singleton instance
|
||||
github_restore_service = GitHubRestoreService()
|
||||
@@ -0,0 +1,276 @@
|
||||
"""Integration tests for the Git backup restore API endpoints (#2656)."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_private_repo_check():
|
||||
"""POST /config refuses to save unless the repo is confirmed private."""
|
||||
with patch(
|
||||
"backend.app.services.github_backup.github_backup_service.test_connection",
|
||||
new=AsyncMock(
|
||||
return_value={
|
||||
"success": True,
|
||||
"message": "Connection successful",
|
||||
"repo_name": "test/repo",
|
||||
"permissions": {"push": True},
|
||||
"is_private": True,
|
||||
}
|
||||
),
|
||||
) as m:
|
||||
yield m
|
||||
|
||||
|
||||
async def _create_config(async_client: AsyncClient) -> dict:
|
||||
response = await async_client.post(
|
||||
"/api/v1/github-backup/config",
|
||||
json={
|
||||
"repository_url": "https://github.com/test/repo",
|
||||
"access_token": "ghp_testtoken123",
|
||||
"branch": "main",
|
||||
"backup_kprofiles": True,
|
||||
"backup_spools": True,
|
||||
"backup_archives": True,
|
||||
"backup_settings": True,
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()
|
||||
|
||||
|
||||
class TestCommitsEndpoint:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_404_when_not_configured(self, async_client: AsyncClient):
|
||||
response = await async_client.get("/api/v1/github-backup/commits")
|
||||
assert response.status_code == 404
|
||||
assert "Configure backup first" in response.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_returns_commits_from_the_provider(self, async_client: AsyncClient):
|
||||
await _create_config(async_client)
|
||||
commits = [
|
||||
{"sha": "aaa1111", "message": "Bambuddy backup", "author": "Bambuddy", "date": "2026-07-02T10:00:00Z"}
|
||||
]
|
||||
with patch(
|
||||
"backend.app.services.git_providers.github.GitHubBackend.list_commits",
|
||||
new=AsyncMock(return_value={"success": True, "message": "OK", "commits": commits}),
|
||||
):
|
||||
response = await async_client.get("/api/v1/github-backup/commits")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["success"] is True
|
||||
assert body["branch"] == "main"
|
||||
assert body["commits"][0]["sha"] == "aaa1111"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_provider_failure_is_reported_not_raised(self, async_client: AsyncClient):
|
||||
await _create_config(async_client)
|
||||
with patch(
|
||||
"backend.app.services.git_providers.github.GitHubBackend.list_commits",
|
||||
new=AsyncMock(return_value={"success": False, "message": "Invalid access token", "commits": []}),
|
||||
):
|
||||
response = await async_client.get("/api/v1/github-backup/commits")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["success"] is False
|
||||
assert response.json()["commits"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_limit_is_bounded(self, async_client: AsyncClient):
|
||||
await _create_config(async_client)
|
||||
assert (await async_client.get("/api/v1/github-backup/commits?limit=0")).status_code == 422
|
||||
assert (await async_client.get("/api/v1/github-backup/commits?limit=101")).status_code == 422
|
||||
|
||||
|
||||
class TestPreviewEndpoint:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_404_when_not_configured(self, async_client: AsyncClient):
|
||||
response = await async_client.get("/api/v1/github-backup/restore/preview")
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_reports_available_and_missing_categories(self, async_client: AsyncClient):
|
||||
await _create_config(async_client)
|
||||
preview = {
|
||||
"success": True,
|
||||
"message": "OK",
|
||||
"ref": "aaa1111",
|
||||
"commit": None,
|
||||
"metadata_version": "1.0",
|
||||
"categories": [
|
||||
{"category": "kprofiles", "available": False, "item_count": 0, "detail": "Not present"},
|
||||
{"category": "settings", "available": True, "item_count": 12, "detail": None},
|
||||
{"category": "spools", "available": True, "item_count": 4, "detail": "plus 9 usage records"},
|
||||
{"category": "archives", "available": True, "item_count": 30, "detail": "Metadata only"},
|
||||
],
|
||||
}
|
||||
with patch(
|
||||
"backend.app.services.github_restore.github_restore_service.preview",
|
||||
new=AsyncMock(return_value=preview),
|
||||
):
|
||||
response = await async_client.get("/api/v1/github-backup/restore/preview?ref=aaa1111")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["metadata_version"] == "1.0"
|
||||
by_name = {c["category"]: c for c in body["categories"]}
|
||||
assert by_name["kprofiles"]["available"] is False
|
||||
assert by_name["spools"]["item_count"] == 4
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.parametrize("ref", ["main", "abc", "../../etc/passwd", "zzzzzzz"])
|
||||
async def test_rejects_refs_that_are_not_object_names(self, async_client: AsyncClient, ref):
|
||||
await _create_config(async_client)
|
||||
response = await async_client.get(f"/api/v1/github-backup/restore/preview?ref={ref}")
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_defaults_to_head(self, async_client: AsyncClient):
|
||||
await _create_config(async_client)
|
||||
mock = AsyncMock(return_value={"success": True, "message": "OK", "ref": "aaa1111", "categories": []})
|
||||
with patch("backend.app.services.github_restore.github_restore_service.preview", new=mock):
|
||||
response = await async_client.get("/api/v1/github-backup/restore/preview")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert mock.await_args.kwargs["ref"] == "HEAD"
|
||||
|
||||
|
||||
class TestRestoreEndpoint:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_404_when_not_configured(self, async_client: AsyncClient):
|
||||
response = await async_client.post("/api/v1/github-backup/restore", json={"categories": ["spools"]})
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_applies_selected_categories(self, async_client: AsyncClient):
|
||||
await _create_config(async_client)
|
||||
outcome = {
|
||||
"success": True,
|
||||
"message": "Restored 5 item(s) from aaa1111",
|
||||
"log_id": 3,
|
||||
"ref": "aaa1111",
|
||||
"results": {
|
||||
"spools": {"restored": 4, "skipped": 1, "failed": 0, "notes": []},
|
||||
"settings": {"restored": 1, "skipped": 2, "failed": 0, "notes": ["1 credential-like key(s) skipped"]},
|
||||
},
|
||||
}
|
||||
with patch(
|
||||
"backend.app.services.github_restore.github_restore_service.run_restore",
|
||||
new=AsyncMock(return_value=outcome),
|
||||
) as mock:
|
||||
response = await async_client.post(
|
||||
"/api/v1/github-backup/restore",
|
||||
json={"ref": "aaa1111", "categories": ["spools", "settings"], "overwrite_existing": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["results"]["spools"]["restored"] == 4
|
||||
assert body["results"]["settings"]["notes"] == ["1 credential-like key(s) skipped"]
|
||||
assert mock.await_args.kwargs["overwrite_existing"] is True
|
||||
assert mock.await_args.kwargs["ref"] == "aaa1111"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_rejects_empty_category_list(self, async_client: AsyncClient):
|
||||
await _create_config(async_client)
|
||||
response = await async_client.post("/api/v1/github-backup/restore", json={"categories": []})
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_rejects_unknown_category(self, async_client: AsyncClient):
|
||||
await _create_config(async_client)
|
||||
response = await async_client.post("/api/v1/github-backup/restore", json={"categories": ["cloud_profiles"]})
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_rejects_malformed_ref(self, async_client: AsyncClient):
|
||||
await _create_config(async_client)
|
||||
response = await async_client.post(
|
||||
"/api/v1/github-backup/restore", json={"ref": "main", "categories": ["spools"]}
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_defaults_overwrite_to_false(self, async_client: AsyncClient):
|
||||
"""The safe default: a restore only inserts what's missing."""
|
||||
await _create_config(async_client)
|
||||
mock = AsyncMock(return_value={"success": True, "message": "ok", "results": {}})
|
||||
with patch("backend.app.services.github_restore.github_restore_service.run_restore", new=mock):
|
||||
response = await async_client.post("/api/v1/github-backup/restore", json={"categories": ["spools"]})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert mock.await_args.kwargs["overwrite_existing"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_service_failure_is_reported_in_body(self, async_client: AsyncClient):
|
||||
await _create_config(async_client)
|
||||
with patch(
|
||||
"backend.app.services.github_restore.github_restore_service.run_restore",
|
||||
new=AsyncMock(
|
||||
return_value={
|
||||
"success": False,
|
||||
"message": "A backup is currently running. Wait for it to finish before restoring.",
|
||||
"results": {},
|
||||
}
|
||||
),
|
||||
):
|
||||
response = await async_client.post("/api/v1/github-backup/restore", json={"categories": ["spools"]})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["success"] is False
|
||||
assert "backup is currently running" in response.json()["message"]
|
||||
|
||||
|
||||
class TestStatusExposesRestoreState:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_restore_running_is_false_when_idle(self, async_client: AsyncClient):
|
||||
await _create_config(async_client)
|
||||
response = await async_client.get("/api/v1/github-backup/status")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["restore_running"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_restore_running_is_reported(self, async_client: AsyncClient):
|
||||
"""The UI disables both action buttons off this flag."""
|
||||
await _create_config(async_client)
|
||||
from backend.app.services.github_restore import github_restore_service
|
||||
|
||||
github_restore_service._running_restore = True
|
||||
github_restore_service._progress = "Restoring spool inventory..."
|
||||
try:
|
||||
response = await async_client.get("/api/v1/github-backup/status")
|
||||
finally:
|
||||
github_restore_service._running_restore = False
|
||||
github_restore_service._progress = None
|
||||
|
||||
assert response.json()["restore_running"] is True
|
||||
assert response.json()["progress"] == "Restoring spool inventory..."
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_unconfigured_status_still_has_the_field(self, async_client: AsyncClient):
|
||||
response = await async_client.get("/api/v1/github-backup/status")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["restore_running"] is False
|
||||
@@ -0,0 +1,431 @@
|
||||
"""Unit tests for the git_providers read side used by restore (#2656).
|
||||
|
||||
Covers list_commits / list_tree / fetch_files across all four providers,
|
||||
including that Gitea and Forgejo inherit GitHub's Git Data API implementation
|
||||
rather than needing their own.
|
||||
"""
|
||||
|
||||
import base64
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.services.git_providers.forgejo import ForgejoBackend
|
||||
from backend.app.services.git_providers.gitea import GiteaBackend
|
||||
from backend.app.services.git_providers.github import GitHubBackend
|
||||
from backend.app.services.git_providers.gitlab import GitLabBackend
|
||||
|
||||
|
||||
def _make_mock_response(status_code: int, body=None, text: str = ""):
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.text = text
|
||||
resp.json = MagicMock(return_value=body if body is not None else {})
|
||||
return resp
|
||||
|
||||
|
||||
def _b64(text: str) -> str:
|
||||
return base64.b64encode(text.encode("utf-8")).decode()
|
||||
|
||||
|
||||
def _github_commit(sha: str, message: str = "Bambuddy backup", date: str = "2026-07-01T10:00:00Z"):
|
||||
return {"sha": sha, "commit": {"message": message, "author": {"name": "Bambuddy", "date": date}}}
|
||||
|
||||
|
||||
class TestGitHubListCommits:
|
||||
def setup_method(self):
|
||||
self.backend = GitHubBackend()
|
||||
self.repo_url = "https://github.com/owner/repo"
|
||||
self.token = "ghp_token"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_normalised_commits_newest_first(self):
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(
|
||||
return_value=_make_mock_response(
|
||||
200,
|
||||
[
|
||||
_github_commit("aaa111", "Bambuddy backup - newest", "2026-07-02T10:00:00Z"),
|
||||
_github_commit("bbb222", "Bambuddy backup - older", "2026-07-01T10:00:00Z"),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
result = await self.backend.list_commits(self.repo_url, self.token, "main", client)
|
||||
|
||||
assert result["success"] is True
|
||||
assert [c["sha"] for c in result["commits"]] == ["aaa111", "bbb222"]
|
||||
assert result["commits"][0]["message"] == "Bambuddy backup - newest"
|
||||
assert result["commits"][0]["author"] == "Bambuddy"
|
||||
assert result["commits"][0]["date"] == "2026-07-02T10:00:00Z"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sends_both_per_page_and_limit(self):
|
||||
"""GitHub honours per_page, Gitea honours limit — one call must carry both
|
||||
so GiteaBackend can inherit this method unchanged."""
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(return_value=_make_mock_response(200, []))
|
||||
|
||||
await self.backend.list_commits(self.repo_url, self.token, "main", client, limit=7)
|
||||
|
||||
params = client.get.await_args.kwargs["params"]
|
||||
assert params["per_page"] == 7
|
||||
assert params["limit"] == 7
|
||||
assert params["sha"] == "main"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respects_limit_even_if_provider_overshoots(self):
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(return_value=_make_mock_response(200, [_github_commit(f"sha{i}") for i in range(10)]))
|
||||
|
||||
result = await self.backend.list_commits(self.repo_url, self.token, "main", client, limit=3)
|
||||
|
||||
assert len(result["commits"]) == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_404_explains_empty_repository(self):
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(return_value=_make_mock_response(404, {}))
|
||||
|
||||
result = await self.backend.list_commits(self.repo_url, self.token, "nope", client)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "no commits yet" in result["message"]
|
||||
assert result["commits"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_entries_without_a_sha(self):
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(
|
||||
return_value=_make_mock_response(200, [{"commit": {"message": "no sha"}}, _github_commit("good")])
|
||||
)
|
||||
|
||||
result = await self.backend.list_commits(self.repo_url, self.token, "main", client)
|
||||
|
||||
assert [c["sha"] for c in result["commits"]] == ["good"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_list_body_is_an_error_not_a_crash(self):
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(return_value=_make_mock_response(200, {"unexpected": "shape"}))
|
||||
|
||||
result = await self.backend.list_commits(self.repo_url, self.token, "main", client)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Unexpected shape" in result["message"]
|
||||
|
||||
|
||||
class TestGitHubListTree:
|
||||
def setup_method(self):
|
||||
self.backend = GitHubBackend()
|
||||
self.repo_url = "https://github.com/owner/repo"
|
||||
self.token = "ghp_token"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_sorted_blob_paths_only(self):
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(
|
||||
return_value=_make_mock_response(
|
||||
200,
|
||||
{
|
||||
"tree": [
|
||||
{"type": "blob", "path": "spools/inventory.json", "sha": "s1"},
|
||||
{"type": "tree", "path": "spools", "sha": "d1"},
|
||||
{"type": "blob", "path": "backup_metadata.json", "sha": "m1"},
|
||||
]
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["paths"] == ["backup_metadata.json", "spools/inventory.json"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_truncated_tree_fails_loudly(self):
|
||||
"""A truncated listing would make restore silently miss categories."""
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(return_value=_make_mock_response(200, {"tree": [], "truncated": True}))
|
||||
|
||||
result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "truncated" in result["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_404_names_the_missing_ref(self):
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(return_value=_make_mock_response(404, {}))
|
||||
|
||||
result = await self.backend.list_tree(self.repo_url, self.token, "deadbee", client)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "deadbee" in result["message"]
|
||||
|
||||
|
||||
class TestGitHubFetchFiles:
|
||||
def setup_method(self):
|
||||
self.backend = GitHubBackend()
|
||||
self.repo_url = "https://github.com/owner/repo"
|
||||
self.token = "ghp_token"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reads_requested_paths_via_blob_api(self):
|
||||
tree = _make_mock_response(
|
||||
200,
|
||||
{
|
||||
"tree": [
|
||||
{"type": "blob", "path": "a.json", "sha": "sha-a"},
|
||||
{"type": "blob", "path": "b.json", "sha": "sha-b"},
|
||||
]
|
||||
},
|
||||
)
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(
|
||||
side_effect=[
|
||||
tree,
|
||||
_make_mock_response(200, {"content": _b64('{"a": 1}'), "encoding": "base64"}),
|
||||
]
|
||||
)
|
||||
|
||||
result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["files"] == {"a.json": '{"a": 1}'}
|
||||
# One tree listing regardless of how many files are read.
|
||||
assert client.get.await_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lists_the_tree_once_for_many_files(self):
|
||||
tree = _make_mock_response(
|
||||
200,
|
||||
{
|
||||
"tree": [
|
||||
{"type": "blob", "path": "a.json", "sha": "sha-a"},
|
||||
{"type": "blob", "path": "b.json", "sha": "sha-b"},
|
||||
]
|
||||
},
|
||||
)
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(
|
||||
side_effect=[
|
||||
tree,
|
||||
_make_mock_response(200, {"content": _b64("1"), "encoding": "base64"}),
|
||||
_make_mock_response(200, {"content": _b64("2"), "encoding": "base64"}),
|
||||
]
|
||||
)
|
||||
|
||||
result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json", "b.json"], client)
|
||||
|
||||
assert result["files"] == {"a.json": "1", "b.json": "2"}
|
||||
assert client.get.await_count == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_path_is_skipped_not_an_error(self):
|
||||
"""Which categories a backup contains varies by config, so an absent
|
||||
path is expected rather than a failure."""
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(return_value=_make_mock_response(200, {"tree": []}))
|
||||
|
||||
result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["gone.json"], client)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["files"] == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blob_error_fails_the_whole_read(self):
|
||||
tree = _make_mock_response(200, {"tree": [{"type": "blob", "path": "a.json", "sha": "sha-a"}]})
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(side_effect=[tree, _make_mock_response(500, {}, text="boom")])
|
||||
|
||||
result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "a.json" in result["message"]
|
||||
assert result["files"] == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_utf8_content_survives_round_trip(self):
|
||||
payload = '{"color_name": "Jadeweiß", "note": "日本語"}'
|
||||
tree = _make_mock_response(200, {"tree": [{"type": "blob", "path": "a.json", "sha": "sha-a"}]})
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(
|
||||
side_effect=[tree, _make_mock_response(200, {"content": _b64(payload), "encoding": "base64"})]
|
||||
)
|
||||
|
||||
result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client)
|
||||
|
||||
assert result["files"]["a.json"] == payload
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsupported_encoding_is_reported(self):
|
||||
tree = _make_mock_response(200, {"tree": [{"type": "blob", "path": "a.json", "sha": "sha-a"}]})
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(
|
||||
side_effect=[tree, _make_mock_response(200, {"content": "xx", "encoding": "quoted-printable"})]
|
||||
)
|
||||
|
||||
result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Unsupported blob encoding" in result["message"]
|
||||
|
||||
|
||||
class TestGiteaAndForgejoInheritReads:
|
||||
"""Gitea overrides the *write* path only; reads come from GitHubBackend."""
|
||||
|
||||
@pytest.mark.parametrize("backend_cls", [GiteaBackend, ForgejoBackend])
|
||||
def test_read_methods_are_not_overridden(self, backend_cls):
|
||||
for method in ("list_commits", "list_tree", "fetch_files"):
|
||||
assert getattr(backend_cls, method) is getattr(GitHubBackend, method)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitea_list_commits_uses_its_own_api_base(self):
|
||||
backend = GiteaBackend()
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(return_value=_make_mock_response(200, [_github_commit("abc")]))
|
||||
|
||||
result = await backend.list_commits("https://git.example.com/owner/repo", "tok", "main", client)
|
||||
|
||||
assert result["success"] is True
|
||||
url = client.get.await_args.args[0]
|
||||
assert url.startswith("https://git.example.com/api/v1/repos/owner/repo/commits")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitea_subpath_install_is_respected(self):
|
||||
"""Gitea/Forgejo behind a ROOT_URL sub-path (#2642)."""
|
||||
backend = GiteaBackend()
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(return_value=_make_mock_response(200, {"tree": []}))
|
||||
|
||||
await backend.list_tree("https://example.com/git/owner/repo", "tok", "abc1234", client)
|
||||
|
||||
url = client.get.await_args.args[0]
|
||||
assert "/git/api/v1/repos/owner/repo/git/trees/abc1234" in url
|
||||
|
||||
|
||||
class TestGitLabReads:
|
||||
def setup_method(self):
|
||||
self.backend = GitLabBackend()
|
||||
self.repo_url = "https://gitlab.com/owner/repo"
|
||||
self.token = "glpat-test"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_commits_reads_flattened_author_fields(self):
|
||||
"""GitLab puts message/author/date on the entry, not under 'commit'."""
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(
|
||||
return_value=_make_mock_response(
|
||||
200,
|
||||
[
|
||||
{
|
||||
"id": "abc123",
|
||||
"message": "Bambuddy backup",
|
||||
"author_name": "Bambuddy",
|
||||
"committed_date": "2026-07-02T10:00:00Z",
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
result = await self.backend.list_commits(self.repo_url, self.token, "main", client)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["commits"] == [
|
||||
{
|
||||
"sha": "abc123",
|
||||
"message": "Bambuddy backup",
|
||||
"author": "Bambuddy",
|
||||
"date": "2026-07-02T10:00:00Z",
|
||||
}
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_commits_uses_ref_name(self):
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(return_value=_make_mock_response(200, []))
|
||||
|
||||
await self.backend.list_commits(self.repo_url, self.token, "bambuddy-backup", client, limit=5)
|
||||
|
||||
params = client.get.await_args.kwargs["params"]
|
||||
assert params["ref_name"] == "bambuddy-backup"
|
||||
assert params["per_page"] == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subgroup_path_is_url_encoded(self):
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(return_value=_make_mock_response(200, []))
|
||||
|
||||
await self.backend.list_commits("https://gitlab.com/group/subgroup/proj", self.token, "main", client)
|
||||
|
||||
url = client.get.await_args.args[0]
|
||||
assert "projects/group%2Fsubgroup%2Fproj/repository/commits" in url
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tree_returns_blob_paths(self):
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(
|
||||
return_value=_make_mock_response(
|
||||
200,
|
||||
[
|
||||
{"type": "blob", "path": "spools/inventory.json"},
|
||||
{"type": "tree", "path": "spools"},
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["paths"] == ["spools/inventory.json"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tree_follows_pagination(self):
|
||||
"""GitLab paginates instead of exposing a truncated flag."""
|
||||
full_page = [{"type": "blob", "path": f"f{i}.json"} for i in range(100)]
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(
|
||||
side_effect=[
|
||||
_make_mock_response(200, full_page),
|
||||
_make_mock_response(200, [{"type": "blob", "path": "last.json"}]),
|
||||
]
|
||||
)
|
||||
|
||||
result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
|
||||
|
||||
assert client.get.await_count == 2
|
||||
assert len(result["paths"]) == 101
|
||||
assert "last.json" in result["paths"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_files_decodes_base64(self):
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(
|
||||
return_value=_make_mock_response(200, {"content": _b64('{"k": 1}'), "encoding": "base64"})
|
||||
)
|
||||
|
||||
result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["files"] == {"a.json": '{"k": 1}'}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_files_encodes_nested_path(self):
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(return_value=_make_mock_response(200, {"content": _b64("{}"), "encoding": "base64"}))
|
||||
|
||||
await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["spools/inventory.json"], client)
|
||||
|
||||
url = client.get.await_args.args[0]
|
||||
assert "repository/files/spools%2Finventory.json" in url
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_files_skips_404(self):
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(return_value=_make_mock_response(404, {}))
|
||||
|
||||
result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["gone.json"], client)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["files"] == {}
|
||||
@@ -0,0 +1,946 @@
|
||||
"""Unit tests for the Git backup restore service (#2656).
|
||||
|
||||
Focus is on the per-category appliers: natural-key matching, the deliberate
|
||||
refusal to reuse the backup's primary keys, old_id -> new_id remapping for
|
||||
dependent rows, overwrite-vs-skip, the settings credential blocklist, and the
|
||||
K-profile paths that depend on live printers.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models.archive import PrintArchive
|
||||
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.schemas.github_backup import GitHubRestoreRequest, RestoreCategory
|
||||
from backend.app.services.github_restore import (
|
||||
ARCHIVES_PATH,
|
||||
SETTINGS_PATH,
|
||||
SPOOL_USAGE_PATH,
|
||||
SPOOLS_PATH,
|
||||
GitHubRestoreService,
|
||||
_CategoryTally,
|
||||
_is_blocked_setting_key,
|
||||
_parse_dt,
|
||||
)
|
||||
|
||||
|
||||
def _service() -> GitHubRestoreService:
|
||||
return GitHubRestoreService()
|
||||
|
||||
|
||||
class TestParseDt:
|
||||
def test_parses_str_datetime_the_backup_writes(self):
|
||||
assert _parse_dt("2026-07-27 06:02:05.123456") == datetime(2026, 7, 27, 6, 2, 5, 123456)
|
||||
|
||||
def test_parses_iso_with_t_separator(self):
|
||||
assert _parse_dt("2026-07-27T06:02:05") == datetime(2026, 7, 27, 6, 2, 5)
|
||||
|
||||
@pytest.mark.parametrize("value", ["", None, "not a date", 12345, {}])
|
||||
def test_returns_none_for_junk(self, value):
|
||||
assert _parse_dt(value) is None
|
||||
|
||||
|
||||
class TestSettingKeyBlocklist:
|
||||
@pytest.mark.parametrize(
|
||||
"key",
|
||||
[
|
||||
"bambu_cloud_token",
|
||||
"auth_secret_key",
|
||||
"ha_token",
|
||||
"prometheus_token",
|
||||
"printer_access_code",
|
||||
"smtp_password",
|
||||
"some_api_key",
|
||||
"ftp_passphrase",
|
||||
"MQTT_SECRET",
|
||||
],
|
||||
)
|
||||
def test_credential_like_keys_are_blocked(self, key):
|
||||
assert _is_blocked_setting_key(key) is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key",
|
||||
["low_stock_threshold", "currency", "theme", "local_backup_enabled", "timezone"],
|
||||
)
|
||||
def test_ordinary_keys_are_allowed(self, key):
|
||||
assert _is_blocked_setting_key(key) is False
|
||||
|
||||
|
||||
class TestCategoryTally:
|
||||
def test_notes_are_deduplicated(self):
|
||||
tally = _CategoryTally()
|
||||
tally.note("same")
|
||||
tally.note("same")
|
||||
assert tally.notes == ["same"]
|
||||
|
||||
def test_notes_are_bounded(self):
|
||||
tally = _CategoryTally()
|
||||
for i in range(50):
|
||||
tally.note(f"note {i}")
|
||||
assert len(tally.notes) == 20
|
||||
|
||||
|
||||
class TestRestoreRequestSchema:
|
||||
def test_rejects_empty_category_list(self):
|
||||
with pytest.raises(ValueError):
|
||||
GitHubRestoreRequest(categories=[])
|
||||
|
||||
def test_deduplicates_categories(self):
|
||||
request = GitHubRestoreRequest(
|
||||
categories=[RestoreCategory.SPOOLS, RestoreCategory.SPOOLS, RestoreCategory.SETTINGS]
|
||||
)
|
||||
assert request.categories == [RestoreCategory.SPOOLS, RestoreCategory.SETTINGS]
|
||||
|
||||
def test_defaults_to_head(self):
|
||||
assert GitHubRestoreRequest(categories=[RestoreCategory.SPOOLS]).ref == "HEAD"
|
||||
|
||||
@pytest.mark.parametrize("ref", ["HEAD", "abc1234", "a" * 40])
|
||||
def test_accepts_valid_refs(self, ref):
|
||||
assert GitHubRestoreRequest(ref=ref, categories=[RestoreCategory.SPOOLS]).ref == ref
|
||||
|
||||
@pytest.mark.parametrize("ref", ["abc", "main", "../etc/passwd", "a" * 41, "zzzzzzz", "abc 123"])
|
||||
def test_rejects_refs_that_are_not_object_names(self, ref):
|
||||
with pytest.raises(ValueError):
|
||||
GitHubRestoreRequest(ref=ref, categories=[RestoreCategory.SPOOLS])
|
||||
|
||||
|
||||
class TestRestoreSettings:
|
||||
@pytest.mark.asyncio
|
||||
async def test_inserts_missing_keys(self, db_session):
|
||||
tally = _CategoryTally()
|
||||
payload = {"version": "1.0", "settings": {"currency": "EUR", "theme": "dark"}}
|
||||
|
||||
await _service()._restore_settings(db_session, payload, overwrite=False, tally=tally)
|
||||
await db_session.commit()
|
||||
|
||||
rows = {s.key: s.value for s in (await db_session.execute(select(Settings))).scalars().all()}
|
||||
assert rows == {"currency": "EUR", "theme": "dark"}
|
||||
assert tally.restored == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_existing_key_when_overwrite_off(self, db_session):
|
||||
db_session.add(Settings(key="currency", value="USD"))
|
||||
await db_session.commit()
|
||||
tally = _CategoryTally()
|
||||
|
||||
await _service()._restore_settings(db_session, {"settings": {"currency": "EUR"}}, overwrite=False, tally=tally)
|
||||
await db_session.commit()
|
||||
|
||||
row = (await db_session.execute(select(Settings).where(Settings.key == "currency"))).scalar_one()
|
||||
assert row.value == "USD"
|
||||
assert tally.skipped == 1
|
||||
assert tally.restored == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overwrites_existing_key_when_enabled(self, db_session):
|
||||
db_session.add(Settings(key="currency", value="USD"))
|
||||
await db_session.commit()
|
||||
tally = _CategoryTally()
|
||||
|
||||
await _service()._restore_settings(db_session, {"settings": {"currency": "EUR"}}, overwrite=True, tally=tally)
|
||||
await db_session.commit()
|
||||
|
||||
row = (await db_session.execute(select(Settings).where(Settings.key == "currency"))).scalar_one()
|
||||
assert row.value == "EUR"
|
||||
assert tally.restored == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_credential_keys_are_never_restored(self, db_session):
|
||||
"""A backup predating the collector's denylist can still contain secrets."""
|
||||
tally = _CategoryTally()
|
||||
payload = {"settings": {"currency": "EUR", "bambu_cloud_token": "leaked", "ha_token": "leaked"}}
|
||||
|
||||
await _service()._restore_settings(db_session, payload, overwrite=True, tally=tally)
|
||||
await db_session.commit()
|
||||
|
||||
keys = {s.key for s in (await db_session.execute(select(Settings))).scalars().all()}
|
||||
assert keys == {"currency"}
|
||||
assert tally.skipped == 2
|
||||
assert any("credential-like" in note for note in tally.notes)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_payload_is_noted_not_fatal(self, db_session):
|
||||
tally = _CategoryTally()
|
||||
await _service()._restore_settings(db_session, None, overwrite=True, tally=tally)
|
||||
assert tally.restored == 0
|
||||
assert tally.notes
|
||||
|
||||
|
||||
class TestRestoreSpools:
|
||||
def _spool_entry(self, **overrides):
|
||||
entry = {
|
||||
"id": 41,
|
||||
"material": "PLA",
|
||||
"subtype": "Basic",
|
||||
"color_name": "Jade White",
|
||||
"brand": "Bambu Lab",
|
||||
"tag_uid": "AABBCCDD",
|
||||
"created_at": "2026-01-05 12:00:00",
|
||||
"weight_used": 120.5,
|
||||
}
|
||||
entry.update(overrides)
|
||||
return entry
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inserts_without_reusing_backup_id(self, db_session):
|
||||
"""The backup's spool.id belongs to an unrelated row today."""
|
||||
db_session.add(Spool(material="PETG")) # occupies id 1
|
||||
await db_session.commit()
|
||||
|
||||
tally = _CategoryTally()
|
||||
payload = {"spools": [self._spool_entry(id=1)]}
|
||||
|
||||
await _service()._restore_spools(db_session, payload, None, False, tally, {})
|
||||
await db_session.commit()
|
||||
|
||||
spools = (await db_session.execute(select(Spool))).scalars().all()
|
||||
assert len(spools) == 2
|
||||
restored = next(s for s in spools if s.tag_uid == "AABBCCDD")
|
||||
assert restored.id != 1
|
||||
assert restored.material == "PLA"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matches_existing_spool_by_tag_uid(self, db_session):
|
||||
db_session.add(Spool(material="PLA", tag_uid="AABBCCDD", color_name="Old"))
|
||||
await db_session.commit()
|
||||
tally = _CategoryTally()
|
||||
|
||||
await _service()._restore_spools(db_session, {"spools": [self._spool_entry()]}, None, False, tally, {})
|
||||
await db_session.commit()
|
||||
|
||||
assert len((await db_session.execute(select(Spool))).scalars().all()) == 1
|
||||
assert tally.skipped == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matches_existing_spool_by_tray_uuid(self, db_session):
|
||||
db_session.add(Spool(material="PLA", tray_uuid="1234" * 8))
|
||||
await db_session.commit()
|
||||
tally = _CategoryTally()
|
||||
entry = self._spool_entry(tag_uid=None, tray_uuid="1234" * 8)
|
||||
|
||||
await _service()._restore_spools(db_session, {"spools": [entry]}, None, False, tally, {})
|
||||
await db_session.commit()
|
||||
|
||||
assert len((await db_session.execute(select(Spool))).scalars().all()) == 1
|
||||
assert tally.skipped == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matches_tagless_spool_by_descriptive_composite(self, db_session):
|
||||
"""Manually added spools have no tag, so fall back to created_at + description."""
|
||||
db_session.add(
|
||||
Spool(
|
||||
material="PLA",
|
||||
subtype="Basic",
|
||||
color_name="Jade White",
|
||||
brand="Bambu Lab",
|
||||
created_at=datetime(2026, 1, 5, 12, 0, 0),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
tally = _CategoryTally()
|
||||
|
||||
entry = self._spool_entry(tag_uid=None)
|
||||
await _service()._restore_spools(db_session, {"spools": [entry]}, None, False, tally, {})
|
||||
await db_session.commit()
|
||||
|
||||
assert len((await db_session.execute(select(Spool))).scalars().all()) == 1
|
||||
assert tally.skipped == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overwrite_updates_matched_spool(self, db_session):
|
||||
db_session.add(Spool(material="PLA", tag_uid="AABBCCDD", color_name="Old", weight_used=0))
|
||||
await db_session.commit()
|
||||
tally = _CategoryTally()
|
||||
|
||||
await _service()._restore_spools(db_session, {"spools": [self._spool_entry()]}, None, True, tally, {})
|
||||
await db_session.commit()
|
||||
|
||||
row = (await db_session.execute(select(Spool))).scalar_one()
|
||||
assert row.color_name == "Jade White"
|
||||
assert row.weight_used == 120.5
|
||||
assert tally.restored == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insert_preserves_created_at_so_repeat_restore_is_idempotent(self, db_session):
|
||||
"""Second restore of the same backup must match, not duplicate."""
|
||||
service = _service()
|
||||
payload = {"spools": [self._spool_entry(tag_uid=None)]}
|
||||
|
||||
await service._restore_spools(db_session, payload, None, False, _CategoryTally(), {})
|
||||
await db_session.commit()
|
||||
await service._restore_spools(db_session, payload, None, False, _CategoryTally(), {})
|
||||
await db_session.commit()
|
||||
|
||||
spools = (await db_session.execute(select(Spool))).scalars().all()
|
||||
assert len(spools) == 1
|
||||
assert spools[0].created_at == datetime(2026, 1, 5, 12, 0, 0)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_usage_history_spool_id_is_remapped(self, db_session):
|
||||
"""Usage rows must point at the new local spool id, not the backup's."""
|
||||
tally = _CategoryTally()
|
||||
inventory = {"spools": [self._spool_entry(id=41)]}
|
||||
usage = {
|
||||
"usage_history": [
|
||||
{
|
||||
"id": 900,
|
||||
"spool_id": 41,
|
||||
"printer_id": None,
|
||||
"print_name": "benchy.3mf",
|
||||
"archive_id": None,
|
||||
"weight_used": 12.0,
|
||||
"percent_used": 5,
|
||||
"status": "completed",
|
||||
"created_at": "2026-02-01 09:00:00",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
await _service()._restore_spools(db_session, inventory, usage, False, tally, {})
|
||||
await db_session.commit()
|
||||
|
||||
spool = (await db_session.execute(select(Spool))).scalar_one()
|
||||
row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
|
||||
assert row.spool_id == spool.id
|
||||
assert row.print_name == "benchy.3mf"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_usage_history_archive_id_is_remapped(self, db_session):
|
||||
tally = _CategoryTally()
|
||||
inventory = {"spools": [self._spool_entry(id=41)]}
|
||||
usage = {
|
||||
"usage_history": [
|
||||
{
|
||||
"spool_id": 41,
|
||||
"archive_id": 77,
|
||||
"weight_used": 1.0,
|
||||
"created_at": "2026-02-01 09:00:00",
|
||||
}
|
||||
]
|
||||
}
|
||||
archive = PrintArchive(filename="a.3mf", file_path="", file_size=1)
|
||||
db_session.add(archive)
|
||||
await db_session.flush()
|
||||
|
||||
await _service()._restore_spools(db_session, inventory, usage, False, tally, {77: archive.id})
|
||||
await db_session.commit()
|
||||
|
||||
row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
|
||||
assert row.archive_id == archive.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_usage_row_with_unresolvable_spool_is_skipped_and_explained(self, db_session):
|
||||
tally = _CategoryTally()
|
||||
usage = {"usage_history": [{"spool_id": 999, "weight_used": 1.0, "created_at": "2026-02-01 09:00:00"}]}
|
||||
|
||||
await _service()._restore_spools(db_session, {"spools": []}, usage, False, tally, {})
|
||||
await db_session.commit()
|
||||
|
||||
assert (await db_session.execute(select(SpoolUsageHistory))).scalars().first() is None
|
||||
assert tally.skipped == 1
|
||||
assert any("their spool is not in this backup's spool list" in note for note in tally.notes)
|
||||
# No remedy is offered, because none exists: overwrite does not change
|
||||
# which spools land in the map (a skipped spool is mapped anyway), and
|
||||
# usage history is always restored alongside the spools category.
|
||||
assert not any("overwrite" in note.lower() for note in tally.notes)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_usage_resolves_against_a_spool_skipped_because_overwrite_is_off(self, db_session):
|
||||
"""A skipped spool is still mapped, so its usage rows are not "unresolved".
|
||||
|
||||
This is why the note above offers no remedy: turning overwrite on would
|
||||
not rescue anything, and saying so misdescribed which records are lost.
|
||||
"""
|
||||
db_session.add(Spool(material="PLA", tag_uid="AABBCCDD", color_name="Old"))
|
||||
await db_session.commit()
|
||||
tally = _CategoryTally()
|
||||
inventory = {"spools": [self._spool_entry(id=41)]}
|
||||
usage = {
|
||||
"usage_history": [
|
||||
{"spool_id": 41, "print_name": "b.3mf", "weight_used": 5.0, "created_at": "2026-02-01 09:00:00"}
|
||||
]
|
||||
}
|
||||
|
||||
await _service()._restore_spools(db_session, inventory, usage, False, tally, {})
|
||||
await db_session.commit()
|
||||
|
||||
spool = (await db_session.execute(select(Spool))).scalar_one()
|
||||
row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
|
||||
assert row.spool_id == spool.id
|
||||
assert not any("spool list" in note for note in tally.notes)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_usage_history_is_not_duplicated_on_repeat_restore(self, db_session):
|
||||
service = _service()
|
||||
inventory = {"spools": [self._spool_entry(id=41)]}
|
||||
usage = {
|
||||
"usage_history": [
|
||||
{"spool_id": 41, "print_name": "b.3mf", "weight_used": 5.0, "created_at": "2026-02-01 09:00:00"}
|
||||
]
|
||||
}
|
||||
|
||||
await service._restore_spools(db_session, inventory, usage, False, _CategoryTally(), {})
|
||||
await db_session.commit()
|
||||
await service._restore_spools(db_session, inventory, usage, False, _CategoryTally(), {})
|
||||
await db_session.commit()
|
||||
|
||||
rows = (await db_session.execute(select(SpoolUsageHistory))).scalars().all()
|
||||
assert len(rows) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dangling_printer_id_is_cleared(self, db_session):
|
||||
tally = _CategoryTally()
|
||||
inventory = {"spools": [self._spool_entry(id=41)]}
|
||||
usage = {
|
||||
"usage_history": [
|
||||
{"spool_id": 41, "printer_id": 4242, "weight_used": 1.0, "created_at": "2026-02-01 09:00:00"}
|
||||
]
|
||||
}
|
||||
|
||||
await _service()._restore_spools(db_session, inventory, usage, False, tally, {})
|
||||
await db_session.commit()
|
||||
|
||||
row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
|
||||
assert row.printer_id is None
|
||||
|
||||
|
||||
class TestRestoreArchives:
|
||||
def _archive_entry(self, **overrides):
|
||||
entry = {
|
||||
"id": 77,
|
||||
"filename": "benchy.3mf",
|
||||
"file_size": 2048,
|
||||
"content_hash": "abc123",
|
||||
"print_name": "Benchy",
|
||||
"status": "completed",
|
||||
"started_at": "2026-03-01 10:00:00",
|
||||
"completed_at": "2026-03-01 11:00:00",
|
||||
"created_at": "2026-03-01 10:00:00",
|
||||
"quantity": 1,
|
||||
"is_favorite": False,
|
||||
}
|
||||
entry.update(overrides)
|
||||
return entry
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inserts_metadata_only_row_with_empty_file_path(self, db_session):
|
||||
"""print_archives.file_path is NOT NULL but is not in the backup."""
|
||||
tally = _CategoryTally()
|
||||
id_map: dict[int, int] = {}
|
||||
|
||||
await _service()._restore_archives(db_session, {"archives": [self._archive_entry()]}, False, tally, id_map)
|
||||
await db_session.commit()
|
||||
|
||||
row = (await db_session.execute(select(PrintArchive))).scalar_one()
|
||||
assert row.file_path == ""
|
||||
assert row.filename == "benchy.3mf"
|
||||
assert row.id != 77
|
||||
assert id_map == {77: row.id}
|
||||
assert any("metadata only" in note for note in tally.notes)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matches_existing_archive_by_hash_and_start(self, db_session):
|
||||
db_session.add(
|
||||
PrintArchive(
|
||||
filename="benchy.3mf",
|
||||
file_path="/data/benchy.3mf",
|
||||
file_size=2048,
|
||||
content_hash="abc123",
|
||||
started_at=datetime(2026, 3, 1, 10, 0, 0),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
tally = _CategoryTally()
|
||||
|
||||
await _service()._restore_archives(db_session, {"archives": [self._archive_entry()]}, False, tally, {})
|
||||
await db_session.commit()
|
||||
|
||||
rows = (await db_session.execute(select(PrintArchive))).scalars().all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].file_path == "/data/benchy.3mf"
|
||||
assert tally.skipped == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_falls_back_to_filename_and_start_without_hash(self, db_session):
|
||||
db_session.add(
|
||||
PrintArchive(
|
||||
filename="benchy.3mf",
|
||||
file_path="/data/benchy.3mf",
|
||||
file_size=2048,
|
||||
started_at=datetime(2026, 3, 1, 10, 0, 0),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
tally = _CategoryTally()
|
||||
|
||||
entry = self._archive_entry(content_hash=None)
|
||||
await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
|
||||
await db_session.commit()
|
||||
|
||||
assert len((await db_session.execute(select(PrintArchive))).scalars().all()) == 1
|
||||
assert tally.skipped == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matches_archive_with_no_started_at_by_hash(self, db_session):
|
||||
"""started_at is NULL for re-sliced archives, so it cannot be required.
|
||||
|
||||
Gating both match branches on it meant these rows never matched: every
|
||||
restore re-inserted them and overwrite mode could never update them.
|
||||
"""
|
||||
db_session.add(
|
||||
PrintArchive(
|
||||
filename="benchy.3mf",
|
||||
file_path="/data/benchy.3mf",
|
||||
file_size=2048,
|
||||
content_hash="abc123",
|
||||
started_at=None,
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
tally = _CategoryTally()
|
||||
|
||||
entry = self._archive_entry(started_at=None)
|
||||
await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
|
||||
await db_session.commit()
|
||||
|
||||
assert len((await db_session.execute(select(PrintArchive))).scalars().all()) == 1
|
||||
assert tally.skipped == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_started_at_still_discriminates_when_present(self, db_session):
|
||||
"""A NULL-tolerant match must not collapse rows that do differ."""
|
||||
db_session.add(
|
||||
PrintArchive(
|
||||
filename="benchy.3mf",
|
||||
file_path="/data/benchy.3mf",
|
||||
file_size=2048,
|
||||
content_hash="abc123",
|
||||
started_at=datetime(2026, 3, 1, 10, 0, 0),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
tally = _CategoryTally()
|
||||
|
||||
# Same file, no start time recorded — a different row, not that one.
|
||||
entry = self._archive_entry(started_at=None)
|
||||
await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
|
||||
await db_session.commit()
|
||||
|
||||
assert len((await db_session.execute(select(PrintArchive))).scalars().all()) == 2
|
||||
assert tally.restored == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_soft_deleted_archive_is_not_restored_as_visible(self, db_session):
|
||||
"""A backup keeps soft-deleted rows, so the flag has to survive.
|
||||
|
||||
Their row is retained on purpose (stats keep counting the filament and
|
||||
energy), so without carrying deleted_at a restore turns an archive the
|
||||
user deleted back into a visible one.
|
||||
"""
|
||||
tally = _CategoryTally()
|
||||
entry = self._archive_entry(deleted_at="2026-03-02 08:00:00")
|
||||
|
||||
await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
|
||||
await db_session.commit()
|
||||
|
||||
row = (await db_session.execute(select(PrintArchive))).scalar_one()
|
||||
assert row.deleted_at == datetime(2026, 3, 2, 8, 0, 0)
|
||||
assert tally.restored == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_locally_deleted_archive_stays_deleted_without_overwrite(self, db_session):
|
||||
db_session.add(
|
||||
PrintArchive(
|
||||
filename="benchy.3mf",
|
||||
file_path="",
|
||||
file_size=2048,
|
||||
content_hash="abc123",
|
||||
started_at=datetime(2026, 3, 1, 10, 0, 0),
|
||||
deleted_at=datetime(2026, 3, 5, 9, 0, 0),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
tally = _CategoryTally()
|
||||
|
||||
# The backup predates the deletion, so its copy is live.
|
||||
await _service()._restore_archives(db_session, {"archives": [self._archive_entry()]}, False, tally, {})
|
||||
await db_session.commit()
|
||||
|
||||
row = (await db_session.execute(select(PrintArchive))).scalar_one()
|
||||
assert row.deleted_at == datetime(2026, 3, 5, 9, 0, 0)
|
||||
assert tally.skipped == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overwrite_undeletes_a_locally_deleted_archive_and_says_so(self, db_session):
|
||||
db_session.add(
|
||||
PrintArchive(
|
||||
filename="benchy.3mf",
|
||||
file_path="",
|
||||
file_size=2048,
|
||||
content_hash="abc123",
|
||||
started_at=datetime(2026, 3, 1, 10, 0, 0),
|
||||
deleted_at=datetime(2026, 3, 5, 9, 0, 0),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
tally = _CategoryTally()
|
||||
|
||||
await _service()._restore_archives(db_session, {"archives": [self._archive_entry()]}, True, tally, {})
|
||||
await db_session.commit()
|
||||
|
||||
row = (await db_session.execute(select(PrintArchive))).scalar_one()
|
||||
assert row.deleted_at is None
|
||||
assert tally.restored == 1
|
||||
assert any("visible again" in note for note in tally.notes)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overwrite_updates_metadata_but_keeps_local_file_path(self, db_session):
|
||||
db_session.add(
|
||||
PrintArchive(
|
||||
filename="benchy.3mf",
|
||||
file_path="/data/benchy.3mf",
|
||||
file_size=2048,
|
||||
content_hash="abc123",
|
||||
started_at=datetime(2026, 3, 1, 10, 0, 0),
|
||||
notes="old",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
tally = _CategoryTally()
|
||||
|
||||
entry = self._archive_entry(notes="restored note")
|
||||
await _service()._restore_archives(db_session, {"archives": [entry]}, True, tally, {})
|
||||
await db_session.commit()
|
||||
|
||||
row = (await db_session.execute(select(PrintArchive))).scalar_one()
|
||||
assert row.notes == "restored note"
|
||||
# The 3MF on disk must not be orphaned by a metadata restore.
|
||||
assert row.file_path == "/data/benchy.3mf"
|
||||
assert tally.restored == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dangling_printer_and_project_links_are_cleared(self, db_session):
|
||||
tally = _CategoryTally()
|
||||
entry = self._archive_entry(printer_id=4242, project_id=4343)
|
||||
|
||||
await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
|
||||
await db_session.commit()
|
||||
|
||||
row = (await db_session.execute(select(PrintArchive))).scalar_one()
|
||||
assert row.printer_id is None
|
||||
assert row.project_id is None
|
||||
assert any("no longer exist" in note for note in tally.notes)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_printer_link_is_preserved(self, db_session, printer_factory):
|
||||
printer = await printer_factory()
|
||||
tally = _CategoryTally()
|
||||
entry = self._archive_entry(printer_id=printer.id)
|
||||
|
||||
await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
|
||||
await db_session.commit()
|
||||
|
||||
row = (await db_session.execute(select(PrintArchive))).scalar_one()
|
||||
assert row.printer_id == printer.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_dict_entry_counts_as_failed(self, db_session):
|
||||
tally = _CategoryTally()
|
||||
await _service()._restore_archives(db_session, {"archives": ["nonsense"]}, False, tally, {})
|
||||
assert tally.failed == 1
|
||||
|
||||
|
||||
class TestRestoreKprofiles:
|
||||
def _payload(self, serial="00M09A123456789", nozzle="0.4"):
|
||||
return {
|
||||
f"kprofiles/{serial}/{nozzle}.json": {
|
||||
"version": "1.0",
|
||||
"printer_serial": serial,
|
||||
"nozzle_diameter": nozzle,
|
||||
"profiles": [
|
||||
{
|
||||
"slot_id": 0,
|
||||
"name": "Bambu PLA",
|
||||
"k_value": "0.020000",
|
||||
"filament_id": "GFA00",
|
||||
"nozzle_id": "HS00-0.4",
|
||||
"extruder_id": 0,
|
||||
"setting_id": "PFUS123",
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sends_batch_to_connected_printer(self, db_session, printer_factory):
|
||||
printer = await printer_factory(serial_number="00M09A123456789")
|
||||
client = MagicMock()
|
||||
client.state.connected = True
|
||||
client.set_kprofiles_batch = MagicMock(return_value=True)
|
||||
tally = _CategoryTally()
|
||||
|
||||
with patch("backend.app.services.github_restore.printer_manager") as manager:
|
||||
manager.get_client = MagicMock(return_value=client)
|
||||
await _service()._restore_kprofiles(db_session, self._payload(), tally)
|
||||
|
||||
client.set_kprofiles_batch.assert_called_once()
|
||||
profiles, nozzle = client.set_kprofiles_batch.call_args.args
|
||||
assert nozzle == "0.4"
|
||||
assert profiles[0]["name"] == "Bambu PLA"
|
||||
assert profiles[0]["filament_id"] == "GFA00"
|
||||
assert tally.restored == 1
|
||||
assert manager.get_client.call_args.args == (printer.id,)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_always_warns_that_mqtt_is_unacknowledged(self, db_session, printer_factory):
|
||||
await printer_factory(serial_number="00M09A123456789")
|
||||
client = MagicMock()
|
||||
client.state.connected = True
|
||||
client.set_kprofiles_batch = MagicMock(return_value=True)
|
||||
tally = _CategoryTally()
|
||||
|
||||
with patch("backend.app.services.github_restore.printer_manager") as manager:
|
||||
manager.get_client = MagicMock(return_value=client)
|
||||
await _service()._restore_kprofiles(db_session, self._payload(), tally)
|
||||
|
||||
assert any("without acknowledgement" in note for note in tally.notes)
|
||||
assert any("always overwrite" in note for note in tally.notes)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_serial_is_skipped_with_reason(self, db_session):
|
||||
tally = _CategoryTally()
|
||||
|
||||
with patch("backend.app.services.github_restore.printer_manager"):
|
||||
await _service()._restore_kprofiles(db_session, self._payload(serial="NOSUCH"), tally)
|
||||
|
||||
assert tally.restored == 0
|
||||
assert tally.skipped == 1
|
||||
assert any("No printer with serial NOSUCH" in note for note in tally.notes)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_offline_printer_is_skipped_not_failed(self, db_session, printer_factory):
|
||||
await printer_factory(serial_number="00M09A123456789", name="Shelf Printer")
|
||||
client = MagicMock()
|
||||
client.state.connected = False
|
||||
tally = _CategoryTally()
|
||||
|
||||
with patch("backend.app.services.github_restore.printer_manager") as manager:
|
||||
manager.get_client = MagicMock(return_value=client)
|
||||
await _service()._restore_kprofiles(db_session, self._payload(), tally)
|
||||
|
||||
assert tally.skipped == 1
|
||||
assert tally.failed == 0
|
||||
assert any("not connected" in note for note in tally.notes)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_client_at_all_is_skipped(self, db_session, printer_factory):
|
||||
await printer_factory(serial_number="00M09A123456789")
|
||||
tally = _CategoryTally()
|
||||
|
||||
with patch("backend.app.services.github_restore.printer_manager") as manager:
|
||||
manager.get_client = MagicMock(return_value=None)
|
||||
await _service()._restore_kprofiles(db_session, self._payload(), tally)
|
||||
|
||||
assert tally.skipped == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_failure_counts_as_failed(self, db_session, printer_factory):
|
||||
await printer_factory(serial_number="00M09A123456789")
|
||||
client = MagicMock()
|
||||
client.state.connected = True
|
||||
client.set_kprofiles_batch = MagicMock(return_value=False)
|
||||
tally = _CategoryTally()
|
||||
|
||||
with patch("backend.app.services.github_restore.printer_manager") as manager:
|
||||
manager.get_client = MagicMock(return_value=client)
|
||||
await _service()._restore_kprofiles(db_session, self._payload(), tally)
|
||||
|
||||
assert tally.failed == 1
|
||||
assert tally.restored == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_exception_is_contained(self, db_session, printer_factory):
|
||||
await printer_factory(serial_number="00M09A123456789")
|
||||
client = MagicMock()
|
||||
client.state.connected = True
|
||||
client.set_kprofiles_batch = MagicMock(side_effect=RuntimeError("mqtt down"))
|
||||
tally = _CategoryTally()
|
||||
|
||||
with patch("backend.app.services.github_restore.printer_manager") as manager:
|
||||
manager.get_client = MagicMock(return_value=client)
|
||||
await _service()._restore_kprofiles(db_session, self._payload(), tally)
|
||||
|
||||
assert tally.failed == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_each_nozzle_is_sent_separately(self, db_session, printer_factory):
|
||||
await printer_factory(serial_number="00M09A123456789")
|
||||
payload = {**self._payload(nozzle="0.4"), **self._payload(nozzle="0.8")}
|
||||
client = MagicMock()
|
||||
client.state.connected = True
|
||||
client.set_kprofiles_batch = MagicMock(return_value=True)
|
||||
tally = _CategoryTally()
|
||||
|
||||
with patch("backend.app.services.github_restore.printer_manager") as manager:
|
||||
manager.get_client = MagicMock(return_value=client)
|
||||
await _service()._restore_kprofiles(db_session, payload, tally)
|
||||
|
||||
assert client.set_kprofiles_batch.call_count == 2
|
||||
assert {c.args[1] for c in client.set_kprofiles_batch.call_args_list} == {"0.4", "0.8"}
|
||||
assert tally.restored == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_payload_is_noted(self, db_session):
|
||||
tally = _CategoryTally()
|
||||
await _service()._restore_kprofiles(db_session, {}, tally)
|
||||
assert any("No K-profile data" in note for note in tally.notes)
|
||||
|
||||
|
||||
class TestSoftDeletedArchiveRoundTrip:
|
||||
"""The two halves of the soft-delete fix only work together.
|
||||
|
||||
The collector keeps soft-deleted rows on purpose (their stats still count),
|
||||
so if it doesn't write ``deleted_at`` there is nothing for the restore to
|
||||
carry across and a deleted archive comes back visible. Covered end to end
|
||||
because each half looks harmless on its own.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deleted_at_survives_collect_then_restore(self, db_session):
|
||||
from backend.app.services.github_backup import github_backup_service
|
||||
|
||||
deleted_at = datetime(2026, 3, 5, 9, 0, 0)
|
||||
db_session.add(
|
||||
PrintArchive(
|
||||
filename="trashed.3mf",
|
||||
file_path="",
|
||||
file_size=1024,
|
||||
content_hash="hash-trashed",
|
||||
started_at=datetime(2026, 3, 1, 10, 0, 0),
|
||||
deleted_at=deleted_at,
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
files: dict = {}
|
||||
await github_backup_service._collect_archives(db_session, files)
|
||||
payload = files[ARCHIVES_PATH]
|
||||
assert payload["archives"][0]["deleted_at"] == str(deleted_at)
|
||||
|
||||
# Restore that payload into an instance where the row is gone entirely.
|
||||
await db_session.execute(PrintArchive.__table__.delete())
|
||||
await db_session.commit()
|
||||
|
||||
tally = _CategoryTally()
|
||||
await _service()._restore_archives(db_session, payload, False, tally, {})
|
||||
await db_session.commit()
|
||||
|
||||
row = (await db_session.execute(select(PrintArchive))).scalar_one()
|
||||
assert row.deleted_at == deleted_at, "a deleted archive must not come back visible"
|
||||
|
||||
|
||||
class TestCategoryPathMapping:
|
||||
def setup_method(self):
|
||||
self.service = _service()
|
||||
self.available = [
|
||||
"backup_metadata.json",
|
||||
SETTINGS_PATH,
|
||||
SPOOLS_PATH,
|
||||
SPOOL_USAGE_PATH,
|
||||
ARCHIVES_PATH,
|
||||
"kprofiles/SERIAL1/0.4.json",
|
||||
"kprofiles/SERIAL1/0.8.json",
|
||||
"cloud_profiles/filament.json",
|
||||
]
|
||||
|
||||
def test_spools_includes_usage_history(self):
|
||||
paths = self.service._category_paths(RestoreCategory.SPOOLS, self.available)
|
||||
assert paths == [SPOOLS_PATH, SPOOL_USAGE_PATH]
|
||||
|
||||
def test_kprofiles_globs_all_serials_and_nozzles(self):
|
||||
paths = self.service._category_paths(RestoreCategory.KPROFILES, self.available)
|
||||
assert paths == ["kprofiles/SERIAL1/0.4.json", "kprofiles/SERIAL1/0.8.json"]
|
||||
|
||||
def test_absent_paths_are_omitted(self):
|
||||
paths = self.service._category_paths(RestoreCategory.SETTINGS, ["backup_metadata.json"])
|
||||
assert paths == []
|
||||
|
||||
def test_cloud_profiles_are_not_a_restore_category(self):
|
||||
assert "cloud_profiles" not in {c.value for c in RestoreCategory}
|
||||
|
||||
|
||||
class TestMutex:
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_refuses_while_a_backup_is_running(self):
|
||||
service = _service()
|
||||
with patch("backend.app.services.github_backup.github_backup_service") as backup:
|
||||
backup.is_running = True
|
||||
result = await service.run_restore(1, "HEAD", [RestoreCategory.SPOOLS])
|
||||
|
||||
assert result["success"] is False
|
||||
assert "backup is currently running" in result["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_refuses_while_another_restore_is_running(self):
|
||||
service = _service()
|
||||
service._running_restore = True
|
||||
|
||||
result = await service.run_restore(1, "HEAD", [RestoreCategory.SPOOLS])
|
||||
|
||||
assert result["success"] is False
|
||||
assert "restore is already running" in result["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backup_refuses_while_a_restore_is_running(self):
|
||||
from backend.app.services.github_backup import GitHubBackupService
|
||||
|
||||
backup_service = GitHubBackupService()
|
||||
with patch("backend.app.services.github_restore.github_restore_service") as restore:
|
||||
restore.is_running = True
|
||||
result = await backup_service.run_backup(1, trigger="manual")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "restore is currently running" in result["message"]
|
||||
|
||||
|
||||
class TestResolveRef:
|
||||
@pytest.mark.asyncio
|
||||
async def test_concrete_sha_passes_through_without_an_api_call(self):
|
||||
service = _service()
|
||||
service.list_commits = AsyncMock()
|
||||
config = MagicMock(branch="main")
|
||||
|
||||
resolved, error = await service._resolve_ref(config, "abc1234")
|
||||
|
||||
assert resolved == "abc1234"
|
||||
assert error == ""
|
||||
service.list_commits.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_head_resolves_to_the_tip_sha(self):
|
||||
service = _service()
|
||||
service.list_commits = AsyncMock(
|
||||
return_value={"success": True, "commits": [{"sha": "tipsha1"}, {"sha": "older"}]}
|
||||
)
|
||||
config = MagicMock(branch="main")
|
||||
|
||||
resolved, error = await service._resolve_ref(config, "HEAD")
|
||||
|
||||
assert resolved == "tipsha1"
|
||||
assert error == ""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_history_is_an_error(self):
|
||||
service = _service()
|
||||
service.list_commits = AsyncMock(return_value={"success": True, "commits": []})
|
||||
config = MagicMock(branch="main")
|
||||
|
||||
resolved, error = await service._resolve_ref(config, "HEAD")
|
||||
|
||||
assert resolved is None
|
||||
assert "no commits" in error
|
||||
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Tests for the Restore from Git Backup modal (#2656).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render } from '../utils';
|
||||
import { server } from '../mocks/server';
|
||||
import { GitHubRestoreModal } from '../../components/GitHubRestoreModal';
|
||||
|
||||
const mockCommits = {
|
||||
success: true,
|
||||
message: 'OK',
|
||||
branch: 'main',
|
||||
commits: [
|
||||
{
|
||||
sha: 'aaa1111bbb2222ccc3333ddd4444eee5555ffff0',
|
||||
message: 'Bambuddy backup - 2026-07-02 10:00:00 UTC',
|
||||
author: 'Bambuddy',
|
||||
date: '2026-07-02T10:00:00Z',
|
||||
},
|
||||
{
|
||||
sha: 'bbb2222ccc3333ddd4444eee5555ffff0aaa1111',
|
||||
message: 'Bambuddy backup - 2026-07-01 10:00:00 UTC',
|
||||
author: 'Bambuddy',
|
||||
date: '2026-07-01T10:00:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockPreview = {
|
||||
success: true,
|
||||
message: 'OK',
|
||||
ref: 'aaa1111bbb2222ccc3333ddd4444eee5555ffff0',
|
||||
commit: mockCommits.commits[0],
|
||||
metadata_version: '1.0',
|
||||
categories: [
|
||||
{ category: 'archives', available: true, item_count: 30, detail: 'Metadata only' },
|
||||
{ category: 'spools', available: true, item_count: 4, detail: null },
|
||||
{ category: 'settings', available: true, item_count: 12, detail: null },
|
||||
{ category: 'kprofiles', available: false, item_count: 0, detail: 'Not present in this backup commit' },
|
||||
],
|
||||
};
|
||||
|
||||
type JsonBody = Record<string, unknown>;
|
||||
|
||||
function mockEndpoints(overrides: { preview?: JsonBody; commits?: JsonBody } = {}) {
|
||||
server.use(
|
||||
http.get('/api/v1/github-backup/commits', () =>
|
||||
HttpResponse.json(overrides.commits ?? (mockCommits as unknown as JsonBody))
|
||||
),
|
||||
http.get('/api/v1/github-backup/restore/preview', () =>
|
||||
HttpResponse.json(overrides.preview ?? (mockPreview as unknown as JsonBody))
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe('GitHubRestoreModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockEndpoints();
|
||||
});
|
||||
|
||||
it('renders the title and commit picker', async () => {
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Restore from Git Backup')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByLabelText('Backup commit')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defaults to the latest commit and lists recent commits', async () => {
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
const select = (await screen.findByLabelText('Backup commit')) as HTMLSelectElement;
|
||||
expect(select.value).toBe('HEAD');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Latest backup/)).toBeInTheDocument();
|
||||
});
|
||||
// Commits are labelled by short SHA.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('option', { name: /aaa1111/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: /bbb2222/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows item counts for categories present in the commit', async () => {
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('30 in backup')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('4 in backup')).toBeInTheDocument();
|
||||
expect(screen.getByText('12 in backup')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables a category that is absent from the commit', async () => {
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Not present in this backup commit')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
|
||||
// Four categories in fixed order: archives, spools, settings, kprofiles.
|
||||
expect(checkboxes).toHaveLength(4);
|
||||
expect(checkboxes[3].disabled).toBe(true);
|
||||
expect(checkboxes[0].disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps Restore disabled until a category is selected', async () => {
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
const restoreButton = await screen.findByRole('button', { name: /Restore$/ });
|
||||
expect(restoreButton).toBeDisabled();
|
||||
|
||||
// Wait for the preview to populate the category list before selecting.
|
||||
const checkboxes = await waitFor(() => {
|
||||
const found = screen.getAllByRole('checkbox') as HTMLInputElement[];
|
||||
expect(found).toHaveLength(4);
|
||||
return found;
|
||||
});
|
||||
await userEvent.click(checkboxes[1]);
|
||||
|
||||
await waitFor(() => expect(restoreButton).not.toBeDisabled());
|
||||
expect(screen.getByText('1 selected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('requires confirmation before sending the restore', async () => {
|
||||
let restoreCalls = 0;
|
||||
server.use(
|
||||
http.post('/api/v1/github-backup/restore', async () => {
|
||||
restoreCalls += 1;
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
message: 'Restored 4 item(s) from aaa1111',
|
||||
log_id: 3,
|
||||
ref: mockPreview.ref,
|
||||
results: { spools: { restored: 4, skipped: 1, failed: 0, notes: [] } },
|
||||
});
|
||||
})
|
||||
);
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
|
||||
await userEvent.click(checkboxes[1]);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
|
||||
|
||||
// Confirm dialog appears; nothing sent yet.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Restore from backup?')).toBeInTheDocument();
|
||||
});
|
||||
expect(restoreCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('sends the selected categories and shows per-category results', async () => {
|
||||
let body: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.post('/api/v1/github-backup/restore', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
message: 'Restored 4 item(s) from aaa1111',
|
||||
log_id: 3,
|
||||
ref: mockPreview.ref,
|
||||
results: {
|
||||
spools: { restored: 4, skipped: 1, failed: 0, notes: ['1 usage record(s) skipped'] },
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
|
||||
await userEvent.click(checkboxes[1]);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
|
||||
await waitFor(() => screen.getByText('Restore from backup?'));
|
||||
|
||||
const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
|
||||
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Restored 4 item(s) from aaa1111')).toBeInTheDocument();
|
||||
});
|
||||
// The commit posted is the sha the preview resolved to, not the symbolic
|
||||
// 'HEAD' the picker defaults to: re-resolving server-side would restore a
|
||||
// backup that landed after the preview the user actually approved.
|
||||
expect(body).toMatchObject({
|
||||
categories: ['spools'],
|
||||
overwrite_existing: false,
|
||||
ref: mockPreview.ref,
|
||||
});
|
||||
expect(screen.getByText('4 restored, 1 skipped, 0 failed')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 usage record(s) skipped')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends overwrite_existing when the toggle is on', async () => {
|
||||
let body: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.post('/api/v1/github-backup/restore', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ success: true, message: 'done', log_id: 1, ref: 'x', results: {} });
|
||||
})
|
||||
);
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
|
||||
await userEvent.click(checkboxes[1]);
|
||||
await userEvent.click(screen.getByRole('switch'));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
|
||||
await waitFor(() => screen.getByText('Restore from backup?'));
|
||||
const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
|
||||
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
|
||||
|
||||
await waitFor(() => expect(body).toMatchObject({ overwrite_existing: true }));
|
||||
});
|
||||
|
||||
it('warns more strongly when overwrite is enabled', async () => {
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
|
||||
await userEvent.click(checkboxes[1]);
|
||||
await userEvent.click(screen.getByRole('switch'));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/This cannot be undone/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces a preview failure instead of an empty category list', async () => {
|
||||
mockEndpoints({
|
||||
preview: {
|
||||
success: false,
|
||||
message: 'Commit or tree deadbee not found in the repository',
|
||||
ref: 'deadbee',
|
||||
categories: [],
|
||||
},
|
||||
});
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Commit or tree deadbee not found in the repository')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('surfaces a commit listing failure', async () => {
|
||||
mockEndpoints({
|
||||
commits: { success: false, message: 'Invalid access token', branch: 'main', commits: [] },
|
||||
});
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invalid access token')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// A refused restore answers 200 with `success: false`, and two of the five
|
||||
// refusals are ordinary conditions rather than errors — a restore already
|
||||
// running, and a backup mid-flight. Rendering the result panel for those put a
|
||||
// green tick and "reload so the restored data appears" above a message saying
|
||||
// nothing had been restored, i.e. a failure that read as a success.
|
||||
it('reports a backend refusal such as the backup/restore mutex', async () => {
|
||||
server.use(
|
||||
http.post('/api/v1/github-backup/restore', () =>
|
||||
HttpResponse.json({
|
||||
success: false,
|
||||
message: 'A backup is currently running. Wait for it to finish before restoring.',
|
||||
results: {},
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
|
||||
await userEvent.click(checkboxes[1]);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
|
||||
await waitFor(() => screen.getByText('Restore from backup?'));
|
||||
const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
|
||||
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('A backup is currently running. Wait for it to finish before restoring.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Not the success panel: no reload hint, no "Reload now", and the form is
|
||||
// still there so the user can retry once the backup finishes.
|
||||
expect(screen.queryByText(/Reload Bambuddy so the restored data appears/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Reload now/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Backup commit')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Restore$/ })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('does not refresh the data caches when a restore was refused', async () => {
|
||||
server.use(
|
||||
http.post('/api/v1/github-backup/restore', () =>
|
||||
HttpResponse.json({ success: false, message: 'A restore is already running', results: {} })
|
||||
)
|
||||
);
|
||||
const invalidate = vi.spyOn(QueryClient.prototype, 'invalidateQueries');
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
|
||||
await userEvent.click(checkboxes[1]);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
|
||||
await waitFor(() => screen.getByText('Restore from backup?'));
|
||||
const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
|
||||
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
|
||||
await waitFor(() => screen.getByText('A restore is already running'));
|
||||
|
||||
const keys = invalidate.mock.calls.map((c) => JSON.stringify(c[0]?.queryKey));
|
||||
// Nothing was written, so nothing to re-read...
|
||||
expect(keys).not.toContain(JSON.stringify(['spools']));
|
||||
expect(keys).not.toContain(JSON.stringify(['archives']));
|
||||
// ...but a failure past the commit resolve writes a "failed" log row, so the
|
||||
// history is refreshed whatever the outcome.
|
||||
expect(keys).toContain(JSON.stringify(['github-backup-logs']));
|
||||
invalidate.mockRestore();
|
||||
});
|
||||
|
||||
// A provider-side failure answers 200 with `success: false`; a rejected
|
||||
// *request* throws in `request()`, leaving `data` undefined. Reading the
|
||||
// message off `data` alone meant the second kind rendered an empty modal —
|
||||
// picker holding only "Latest", every category greyed out, no explanation.
|
||||
it('explains a rejected preview request instead of greying out every category', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/github-backup/restore/preview', () =>
|
||||
HttpResponse.json({ detail: 'Not authenticated' }, { status: 401 })
|
||||
)
|
||||
);
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Not authenticated')).toBeInTheDocument();
|
||||
});
|
||||
// The category list is replaced by the error, not rendered disabled.
|
||||
expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('explains a rejected commit-list request', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/github-backup/commits', () => HttpResponse.json({}, { status: 500 }))
|
||||
);
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
// No detail in the body, so the generic string carries the message.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Could not read the backup repository|HTTP 500/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('closes via the close button', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<GitHubRestoreModal onClose={onClose} />);
|
||||
|
||||
await waitFor(() => screen.getByText('Restore from Git Backup'));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2820,12 +2820,68 @@ export interface GitHubBackupStatus {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
is_running: boolean;
|
||||
restore_running: boolean;
|
||||
progress: string | null;
|
||||
last_backup_at: string | null;
|
||||
last_backup_status: string | null;
|
||||
next_scheduled_run: string | null;
|
||||
}
|
||||
|
||||
// Restore from a Git backup (#2656). Cloud profiles are absent deliberately —
|
||||
// the backup collector never writes them, so there is nothing to restore.
|
||||
export type RestoreCategory = 'kprofiles' | 'settings' | 'spools' | 'archives';
|
||||
|
||||
export interface GitHubCommitInfo {
|
||||
sha: string;
|
||||
message: string;
|
||||
author: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export interface GitHubCommitListResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
branch: string;
|
||||
commits: GitHubCommitInfo[];
|
||||
}
|
||||
|
||||
export interface GitHubRestorePreviewCategory {
|
||||
category: RestoreCategory;
|
||||
available: boolean;
|
||||
item_count: number;
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
export interface GitHubRestorePreview {
|
||||
success: boolean;
|
||||
message: string;
|
||||
ref: string;
|
||||
commit: GitHubCommitInfo | null;
|
||||
metadata_version: string | null;
|
||||
categories: GitHubRestorePreviewCategory[];
|
||||
}
|
||||
|
||||
export interface GitHubRestoreRequest {
|
||||
ref?: string;
|
||||
categories: RestoreCategory[];
|
||||
overwrite_existing?: boolean;
|
||||
}
|
||||
|
||||
export interface GitHubRestoreCategoryResult {
|
||||
restored: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface GitHubRestoreResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
log_id: number | null;
|
||||
ref: string | null;
|
||||
results: Record<string, GitHubRestoreCategoryResult>;
|
||||
}
|
||||
|
||||
export interface LocalBackupStatus {
|
||||
enabled: boolean;
|
||||
schedule: string;
|
||||
@@ -6684,6 +6740,19 @@ export const api = {
|
||||
clearGitHubBackupLogs: (keepLast: number = 10) =>
|
||||
request<{ deleted: number; message: string }>(`/github-backup/logs?keep_last=${keepLast}`, { method: 'DELETE' }),
|
||||
|
||||
// Restore from a Git backup (#2656)
|
||||
getGitHubBackupCommits: (limit: number = 20) =>
|
||||
request<GitHubCommitListResponse>(`/github-backup/commits?limit=${limit}`),
|
||||
|
||||
getGitHubRestorePreview: (ref: string = 'HEAD') =>
|
||||
request<GitHubRestorePreview>(`/github-backup/restore/preview?ref=${encodeURIComponent(ref)}`),
|
||||
|
||||
restoreFromGitHub: (payload: GitHubRestoreRequest) =>
|
||||
request<GitHubRestoreResponse>('/github-backup/restore', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
// Scheduled local backups
|
||||
getLocalBackupStatus: () =>
|
||||
request<LocalBackupStatus>('/local-backup/status'),
|
||||
|
||||
@@ -40,6 +40,7 @@ import { Card, CardContent, CardHeader } from './Card';
|
||||
import { Button } from './Button';
|
||||
import { Toggle } from './Toggle';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import { GitHubRestoreModal } from './GitHubRestoreModal';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { formatRelativeTime, parseUTCDate } from '../utils/date';
|
||||
|
||||
@@ -158,6 +159,9 @@ export function GitHubBackupSettings() {
|
||||
const [restoreResult, setRestoreResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Restore from the Git backup repository (#2656)
|
||||
const [showGitRestore, setShowGitRestore] = useState(false);
|
||||
|
||||
// Scheduled local backup state
|
||||
const [deleteConfirmFile, setDeleteConfirmFile] = useState<string | null>(null);
|
||||
const [restoreConfirmFile, setRestoreConfirmFile] = useState<string | null>(null);
|
||||
@@ -951,6 +955,16 @@ export function GitHubBackupSettings() {
|
||||
{testLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||||
{t('backup.test')}
|
||||
</Button>
|
||||
{/* Restore from the backup repo (#2656) */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowGitRestore(true)}
|
||||
disabled={status.restore_running}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
{t('backup.restoreFromGit.button')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@@ -1445,6 +1459,9 @@ export function GitHubBackupSettings() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Restore from the Git backup repository (#2656) */}
|
||||
{showGitRestore && <GitHubRestoreModal onClose={() => setShowGitRestore(false)} />}
|
||||
|
||||
{/* Delete Backup Confirmation Modal */}
|
||||
{deleteConfirmFile && (
|
||||
<ConfirmModal
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Archive,
|
||||
CheckCircle2,
|
||||
Info,
|
||||
Loader2,
|
||||
Palette,
|
||||
RotateCcw,
|
||||
Settings as SettingsIcon,
|
||||
Thermometer,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from './Card';
|
||||
import { Button } from './Button';
|
||||
import { Toggle } from './Toggle';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import { api, type RestoreCategory, type GitHubRestoreResponse } from '../api/client';
|
||||
|
||||
interface GitHubRestoreModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface CategoryMeta {
|
||||
id: RestoreCategory;
|
||||
labelKey: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
// Order mirrors the order the backend applies them in. Labels reuse the keys
|
||||
// the backup checkbox group already ships in all locales.
|
||||
const CATEGORIES: CategoryMeta[] = [
|
||||
{ id: 'archives', labelKey: 'backup.printArchives', icon: <Archive className="w-4 h-4" /> },
|
||||
{ id: 'spools', labelKey: 'backup.spoolInventory', icon: <Palette className="w-4 h-4" /> },
|
||||
{ id: 'settings', labelKey: 'backup.appSettings', icon: <SettingsIcon className="w-4 h-4" /> },
|
||||
{ id: 'kprofiles', labelKey: 'backup.kProfiles', icon: <Thermometer className="w-4 h-4" /> },
|
||||
];
|
||||
|
||||
const CATEGORY_LABEL_KEYS: Record<string, string> = Object.fromEntries(
|
||||
CATEGORIES.map((c) => [c.id, c.labelKey])
|
||||
);
|
||||
|
||||
const LATEST = 'HEAD';
|
||||
|
||||
export function GitHubRestoreModal({ onClose }: GitHubRestoreModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [selectedRef, setSelectedRef] = useState<string>(LATEST);
|
||||
const [selected, setSelected] = useState<Record<string, boolean>>({});
|
||||
const [overwriteExisting, setOverwriteExisting] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [result, setResult] = useState<GitHubRestoreResponse | null>(null);
|
||||
|
||||
const commitsQuery = useQuery({
|
||||
queryKey: ['github-backup-commits'],
|
||||
queryFn: () => api.getGitHubBackupCommits(20),
|
||||
});
|
||||
|
||||
const previewQuery = useQuery({
|
||||
queryKey: ['github-restore-preview', selectedRef],
|
||||
queryFn: () => api.getGitHubRestorePreview(selectedRef),
|
||||
});
|
||||
|
||||
// Restore the exact commit the preview described, not the ref that was asked
|
||||
// for. They differ for the default "Latest backup" selection, which posts the
|
||||
// symbolic 'HEAD' and lets the backend re-resolve it — so a backup landing
|
||||
// between preview and restore would silently restore a different commit than
|
||||
// the one whose contents the user just approved.
|
||||
const resolvedRef = previewQuery.data?.success ? previewQuery.data.ref : selectedRef;
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
api.restoreFromGitHub({
|
||||
ref: resolvedRef,
|
||||
categories: CATEGORIES.filter((c) => selected[c.id]).map((c) => c.id),
|
||||
overwrite_existing: overwriteExisting,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
setShowConfirm(false);
|
||||
// The endpoint answers 200 for a refused or failed restore too, with
|
||||
// `success: false` and an empty `results` — and two of those are ordinary
|
||||
// conditions, not errors: another restore already running, and a backup
|
||||
// being mid-flight. Rendering the result panel for them showed a green
|
||||
// tick, no tally at all and a "reload so the restored data appears" hint
|
||||
// above a message saying nothing had been restored. Only a real success
|
||||
// gets the panel; a failure keeps the form and shows the red block below.
|
||||
if (data.success) {
|
||||
setResult(data);
|
||||
// A restore rewrites rows these caches hold.
|
||||
queryClient.invalidateQueries({ queryKey: ['spools'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['archives'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||
}
|
||||
// A failure that got as far as resolving the commit still writes a log row
|
||||
// (status "failed"), so refresh the history and status either way.
|
||||
queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
|
||||
},
|
||||
onError: () => setShowConfirm(false),
|
||||
});
|
||||
|
||||
const isRestoring = restoreMutation.isPending;
|
||||
|
||||
// Close on Escape, except while a restore is in flight.
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !isRestoring && !showConfirm) onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose, isRestoring, showConfirm]);
|
||||
|
||||
// Interrupting a restore mid-flight can leave a partly-applied category.
|
||||
useEffect(() => {
|
||||
if (!isRestoring) return;
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
};
|
||||
window.addEventListener('beforeunload', handler);
|
||||
return () => window.removeEventListener('beforeunload', handler);
|
||||
}, [isRestoring]);
|
||||
|
||||
const availability = useMemo(() => {
|
||||
const map: Record<string, { available: boolean; itemCount: number; detail: string | null }> = {};
|
||||
previewQuery.data?.categories?.forEach((c) => {
|
||||
map[c.category] = { available: c.available, itemCount: c.item_count, detail: c.detail };
|
||||
});
|
||||
return map;
|
||||
}, [previewQuery.data]);
|
||||
|
||||
// Selecting a category that isn't in the newly-picked commit would send a
|
||||
// request the backend rejects, so drop those whenever the preview changes.
|
||||
useEffect(() => {
|
||||
if (!previewQuery.data) return;
|
||||
setSelected((prev) => {
|
||||
const next: Record<string, boolean> = {};
|
||||
CATEGORIES.forEach((c) => {
|
||||
next[c.id] = Boolean(prev[c.id]) && Boolean(availability[c.id]?.available);
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, [previewQuery.data, availability]);
|
||||
|
||||
const selectedCount = CATEGORIES.filter((c) => selected[c.id]).length;
|
||||
const commits = commitsQuery.data?.commits ?? [];
|
||||
|
||||
const formatCommitLabel = (sha: string, message: string, date: string) => {
|
||||
const firstLine = (message || '').split('\n')[0];
|
||||
const when = date ? new Date(date).toLocaleString() : '';
|
||||
return `${sha.slice(0, 7)} — ${when}${firstLine ? ` — ${firstLine}` : ''}`;
|
||||
};
|
||||
|
||||
// Two ways these can fail, and both have to reach the user. A provider-side
|
||||
// failure (bad token, repo unreachable) answers 200 with `success: false` and
|
||||
// a message. A rejected *request* — a 401/403 once the session expires with
|
||||
// the modal open, a 500, the network dropping — throws in `request()`, so
|
||||
// `data` is undefined: reading the message off `data` alone left the picker
|
||||
// holding only "Latest" and every category greyed out by an empty availability
|
||||
// map, with nothing on screen saying why.
|
||||
const queryError = (query: { isError: boolean; error: unknown }) =>
|
||||
query.isError ? (query.error as Error)?.message || t('backup.restoreFromGit.loadFailed') : null;
|
||||
|
||||
const previewError =
|
||||
queryError(previewQuery) ??
|
||||
(previewQuery.data && !previewQuery.data.success ? previewQuery.data.message : null);
|
||||
const commitsError =
|
||||
queryError(commitsQuery) ??
|
||||
(commitsQuery.data && !commitsQuery.data.success ? commitsQuery.data.message : null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
onClick={isRestoring ? undefined : onClose}
|
||||
>
|
||||
<Card className="w-full max-w-lg" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<CardContent className="p-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green">
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">{t('backup.restoreFromGit.title')}</h3>
|
||||
<p className="text-sm text-bambu-gray">{t('backup.restoreFromGit.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isRestoring}
|
||||
aria-label={t('common.close')}
|
||||
className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{result ? (
|
||||
/* Result summary */
|
||||
<div className="p-4 space-y-3 max-h-[400px] overflow-y-auto">
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<CheckCircle2 className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
|
||||
<span className="text-white">{result.message}</span>
|
||||
</div>
|
||||
{Object.entries(result.results).map(([name, tally]) => (
|
||||
<div key={name} className="p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-white">
|
||||
{CATEGORY_LABEL_KEYS[name] ? t(CATEGORY_LABEL_KEYS[name]) : name}
|
||||
</span>
|
||||
<span className="text-xs text-bambu-gray">
|
||||
{t('backup.restoreFromGit.tally', {
|
||||
restored: tally.restored,
|
||||
skipped: tally.skipped,
|
||||
failed: tally.failed,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{tally.notes.length > 0 && (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{tally.notes.map((note) => (
|
||||
<li key={note} className="text-xs text-bambu-gray flex items-start gap-1.5">
|
||||
<Info className="w-3 h-3 mt-0.5 flex-shrink-0" />
|
||||
<span>{note}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="p-3 rounded-lg bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-300 dark:border-yellow-500/30">
|
||||
<p className="text-xs text-yellow-700 dark:text-yellow-200">
|
||||
{t('backup.restoreFromGit.reloadHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`p-4 space-y-4 max-h-[400px] overflow-y-auto ${isRestoring ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
{/* A restore that was refused or failed comes back here rather
|
||||
than to the result panel, so keep these above the fold. */}
|
||||
{restoreMutation.isError && (
|
||||
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">
|
||||
{(restoreMutation.error as Error)?.message || t('backup.restoreFromGit.failed')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{restoreMutation.data && !restoreMutation.data.success && (
|
||||
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{restoreMutation.data.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Commit picker */}
|
||||
<div>
|
||||
<label htmlFor="restore-commit" className="block text-sm font-medium text-white mb-1">
|
||||
{t('backup.restoreFromGit.commitLabel')}
|
||||
</label>
|
||||
<select
|
||||
id="restore-commit"
|
||||
value={selectedRef}
|
||||
onChange={(e) => {
|
||||
setSelectedRef(e.target.value);
|
||||
// Drop the previous attempt's failure banner: it refers to
|
||||
// the commit that was just switched away from. (A *result*
|
||||
// cannot be showing here — the summary replaces this form.)
|
||||
restoreMutation.reset();
|
||||
}}
|
||||
disabled={isRestoring || commitsQuery.isLoading}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
|
||||
>
|
||||
<option value={LATEST}>{t('backup.restoreFromGit.latestCommit')}</option>
|
||||
{commits.map((c) => (
|
||||
<option key={c.sha} value={c.sha}>
|
||||
{formatCommitLabel(c.sha, c.message, c.date)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{commitsError && <p className="mt-1 text-xs text-red-500 dark:text-red-400">{commitsError}</p>}
|
||||
</div>
|
||||
|
||||
{/* Category selection */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white mb-2">{t('backup.restoreFromGit.categoriesLabel')}</p>
|
||||
{previewQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-bambu-gray p-3">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t('backup.restoreFromGit.inspecting')}
|
||||
</div>
|
||||
) : previewError ? (
|
||||
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{previewError}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{CATEGORIES.map((category) => {
|
||||
const info = availability[category.id];
|
||||
const isAvailable = Boolean(info?.available);
|
||||
const isChecked = Boolean(selected[category.id]) && isAvailable;
|
||||
return (
|
||||
<label
|
||||
key={category.id}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg transition-colors ${
|
||||
isAvailable ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'
|
||||
} ${
|
||||
isChecked
|
||||
? 'bg-bambu-green/10 border border-bambu-green/30'
|
||||
: 'bg-bambu-dark hover:bg-bambu-dark-tertiary border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
disabled={!isAvailable || isRestoring}
|
||||
onChange={() =>
|
||||
setSelected((prev) => ({ ...prev, [category.id]: !prev[category.id] }))
|
||||
}
|
||||
className="w-4 h-4 rounded border-bambu-gray bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0"
|
||||
/>
|
||||
<div className={isChecked ? 'text-bambu-green' : 'text-bambu-gray'}>{category.icon}</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-white text-sm font-medium">
|
||||
{t(category.labelKey)}
|
||||
{isAvailable && info?.itemCount ? (
|
||||
<span className="ml-2 text-xs text-bambu-gray">
|
||||
{t('backup.restoreFromGit.itemCount', { count: info.itemCount })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{info?.detail && <div className="text-xs text-bambu-gray">{info.detail}</div>}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Overwrite toggle */}
|
||||
<div className="p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{t('backup.restoreFromGit.overwriteLabel')}</p>
|
||||
<p className="text-xs text-bambu-gray">
|
||||
{overwriteExisting
|
||||
? t('backup.restoreFromGit.overwriteOn')
|
||||
: t('backup.restoreFromGit.overwriteOff')}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle checked={overwriteExisting} onChange={setOverwriteExisting} disabled={isRestoring} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-4 border-t border-bambu-dark-tertiary">
|
||||
{result ? (
|
||||
<>
|
||||
<span />
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => window.location.reload()}
|
||||
className="bg-bambu-green hover:bg-bambu-green-dark"
|
||||
>
|
||||
{t('backup.reloadNow')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm text-bambu-gray">
|
||||
{t('backup.restoreFromGit.selectedCount', { count: selectedCount })}
|
||||
</span>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" onClick={onClose} disabled={isRestoring}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowConfirm(true)}
|
||||
disabled={selectedCount === 0 || isRestoring}
|
||||
className="bg-bambu-green hover:bg-bambu-green-dark disabled:opacity-50 disabled:cursor-not-allowed min-w-[100px]"
|
||||
>
|
||||
{isRestoring ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{t('backup.restoreFromGit.restoring')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
{t('backup.restore')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{showConfirm && (
|
||||
<ConfirmModal
|
||||
variant="danger"
|
||||
overlayZIndex="z-[110]"
|
||||
title={t('backup.restoreFromGit.confirmTitle')}
|
||||
message={
|
||||
overwriteExisting
|
||||
? t('backup.restoreFromGit.confirmMessageOverwrite')
|
||||
: t('backup.restoreFromGit.confirmMessage')
|
||||
}
|
||||
confirmText={t('backup.restore')}
|
||||
isLoading={isRestoring}
|
||||
loadingText={t('backup.restoreFromGit.restoring')}
|
||||
onConfirm={() => restoreMutation.mutate()}
|
||||
onCancel={() => setShowConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4873,6 +4873,30 @@ export default {
|
||||
clearedLogs: '{{count}} Protokolle gelöscht',
|
||||
failedToClearLogs: 'Protokolle löschen fehlgeschlagen: {{message}}',
|
||||
|
||||
// Restore from Git backup (#2656)
|
||||
restoreFromGit: {
|
||||
button: 'Wiederherstellen',
|
||||
title: 'Aus Git-Backup wiederherstellen',
|
||||
subtitle: 'Commit auswählen und festlegen, was wiederhergestellt wird',
|
||||
commitLabel: 'Backup-Commit',
|
||||
latestCommit: 'Neuestes Backup (Branch-Spitze)',
|
||||
categoriesLabel: 'Was wiederherstellen',
|
||||
inspecting: 'Backup-Inhalt wird gelesen...',
|
||||
itemCount: '{{count}} im Backup',
|
||||
overwriteLabel: 'Vorhandene Einträge überschreiben',
|
||||
overwriteOn: 'Vorhandene Einträge werden aus dem Backup aktualisiert.',
|
||||
overwriteOff: 'Es werden nur fehlende Einträge ergänzt; vorhandene bleiben unverändert.',
|
||||
selectedCount: '{{count}} ausgewählt',
|
||||
restoring: 'Wird wiederhergestellt...',
|
||||
confirmTitle: 'Aus Backup wiederherstellen?',
|
||||
confirmMessage: 'Die ausgewählten Kategorien werden aus diesem Commit wiederhergestellt. Fehlende Einträge werden ergänzt, vorhandene bleiben unverändert.',
|
||||
confirmMessageOverwrite: 'Die ausgewählten Kategorien werden aus diesem Commit wiederhergestellt und lokal vorhandene Einträge überschrieben. Dies kann nicht rückgängig gemacht werden.',
|
||||
tally: '{{restored}} wiederhergestellt, {{skipped}} übersprungen, {{failed}} fehlgeschlagen',
|
||||
reloadHint: 'Bambuddy neu laden, damit die wiederhergestellten Daten überall erscheinen.',
|
||||
failed: 'Wiederherstellung fehlgeschlagen.',
|
||||
loadFailed: 'Das Backup-Repository konnte nicht gelesen werden.',
|
||||
},
|
||||
|
||||
// History
|
||||
history: 'Verlauf',
|
||||
clear: 'Löschen',
|
||||
|
||||
@@ -4916,6 +4916,30 @@ export default {
|
||||
clearedLogs: 'Cleared {{count}} logs',
|
||||
failedToClearLogs: 'Failed to clear logs: {{message}}',
|
||||
|
||||
// Restore from Git backup (#2656)
|
||||
restoreFromGit: {
|
||||
button: 'Restore',
|
||||
title: 'Restore from Git Backup',
|
||||
subtitle: 'Pick a commit and choose what to restore',
|
||||
commitLabel: 'Backup commit',
|
||||
latestCommit: 'Latest backup (branch tip)',
|
||||
categoriesLabel: 'What to restore',
|
||||
inspecting: 'Reading backup contents...',
|
||||
itemCount: '{{count}} in backup',
|
||||
overwriteLabel: 'Overwrite existing entries',
|
||||
overwriteOn: 'Existing entries will be updated from the backup.',
|
||||
overwriteOff: 'Only missing entries are added; existing ones are left untouched.',
|
||||
selectedCount: '{{count}} selected',
|
||||
restoring: 'Restoring...',
|
||||
confirmTitle: 'Restore from backup?',
|
||||
confirmMessage: 'The selected categories will be restored from this commit. Missing entries are added; existing entries stay as they are.',
|
||||
confirmMessageOverwrite: 'The selected categories will be restored from this commit, overwriting entries that already exist locally. This cannot be undone.',
|
||||
tally: '{{restored}} restored, {{skipped}} skipped, {{failed}} failed',
|
||||
reloadHint: 'Reload Bambuddy so the restored data appears everywhere.',
|
||||
failed: 'Restore failed.',
|
||||
loadFailed: 'Could not read the backup repository.',
|
||||
},
|
||||
|
||||
// History
|
||||
history: 'History',
|
||||
clear: 'Clear',
|
||||
|
||||
@@ -4881,6 +4881,30 @@ export default {
|
||||
clearedLogs: 'Se borraron {{count}} registros',
|
||||
failedToClearLogs: 'Error al borrar los registros: {{message}}',
|
||||
|
||||
// Restore from Git backup (#2656)
|
||||
restoreFromGit: {
|
||||
button: 'Restaurar',
|
||||
title: 'Restaurar desde copia de Git',
|
||||
subtitle: 'Elige un commit y qué se debe restaurar',
|
||||
commitLabel: 'Commit de la copia',
|
||||
latestCommit: 'Última copia (punta de la rama)',
|
||||
categoriesLabel: 'Qué restaurar',
|
||||
inspecting: 'Leyendo el contenido de la copia...',
|
||||
itemCount: '{{count}} en la copia',
|
||||
overwriteLabel: 'Sobrescribir entradas existentes',
|
||||
overwriteOn: 'Las entradas existentes se actualizarán desde la copia.',
|
||||
overwriteOff: 'Solo se añaden las entradas que falten; las existentes no se modifican.',
|
||||
selectedCount: '{{count}} seleccionados',
|
||||
restoring: 'Restaurando...',
|
||||
confirmTitle: '¿Restaurar desde la copia?',
|
||||
confirmMessage: 'Las categorías seleccionadas se restaurarán desde este commit. Se añaden las entradas que falten y las existentes se mantienen igual.',
|
||||
confirmMessageOverwrite: 'Las categorías seleccionadas se restaurarán desde este commit y se sobrescribirán las entradas que ya existan localmente. Esto no se puede deshacer.',
|
||||
tally: '{{restored}} restaurados, {{skipped}} omitidos, {{failed}} fallidos',
|
||||
reloadHint: 'Recarga Bambuddy para que los datos restaurados aparezcan en todas partes.',
|
||||
failed: 'La restauración ha fallado.',
|
||||
loadFailed: 'No se pudo leer el repositorio de copias de seguridad.',
|
||||
},
|
||||
|
||||
// History
|
||||
history: 'Historial',
|
||||
clear: 'Borrar',
|
||||
|
||||
@@ -4862,6 +4862,30 @@ export default {
|
||||
clearedLogs: '{{count}} journaux supprimés',
|
||||
failedToClearLogs: 'Échec de la suppression des journaux : {{message}}',
|
||||
|
||||
// Restore from Git backup (#2656)
|
||||
restoreFromGit: {
|
||||
button: 'Restaurer',
|
||||
title: 'Restaurer depuis la sauvegarde Git',
|
||||
subtitle: 'Choisissez un commit et ce qui doit être restauré',
|
||||
commitLabel: 'Commit de sauvegarde',
|
||||
latestCommit: 'Dernière sauvegarde (tête de branche)',
|
||||
categoriesLabel: 'Éléments à restaurer',
|
||||
inspecting: 'Lecture du contenu de la sauvegarde...',
|
||||
itemCount: '{{count}} dans la sauvegarde',
|
||||
overwriteLabel: 'Écraser les entrées existantes',
|
||||
overwriteOn: 'Les entrées existantes seront mises à jour depuis la sauvegarde.',
|
||||
overwriteOff: 'Seules les entrées manquantes sont ajoutées ; les existantes ne sont pas modifiées.',
|
||||
selectedCount: '{{count}} sélectionné(s)',
|
||||
restoring: 'Restauration...',
|
||||
confirmTitle: 'Restaurer depuis la sauvegarde ?',
|
||||
confirmMessage: 'Les catégories sélectionnées seront restaurées depuis ce commit. Les entrées manquantes sont ajoutées, les existantes restent inchangées.',
|
||||
confirmMessageOverwrite: 'Les catégories sélectionnées seront restaurées depuis ce commit et les entrées déjà présentes localement seront écrasées. Cette action est irréversible.',
|
||||
tally: '{{restored}} restaurés, {{skipped}} ignorés, {{failed}} en échec',
|
||||
reloadHint: 'Rechargez Bambuddy pour que les données restaurées apparaissent partout.',
|
||||
failed: 'Échec de la restauration.',
|
||||
loadFailed: 'Impossible de lire le dépôt de sauvegarde.',
|
||||
},
|
||||
|
||||
// History
|
||||
history: 'Historique',
|
||||
clear: 'Effacer',
|
||||
|
||||
@@ -4861,6 +4861,30 @@ export default {
|
||||
clearedLogs: '{{count}} log eliminati',
|
||||
failedToClearLogs: 'Eliminazione log fallita: {{message}}',
|
||||
|
||||
// Restore from Git backup (#2656)
|
||||
restoreFromGit: {
|
||||
button: 'Ripristina',
|
||||
title: 'Ripristina dal backup Git',
|
||||
subtitle: 'Scegli un commit e cosa ripristinare',
|
||||
commitLabel: 'Commit del backup',
|
||||
latestCommit: 'Ultimo backup (punta del branch)',
|
||||
categoriesLabel: 'Cosa ripristinare',
|
||||
inspecting: 'Lettura del contenuto del backup...',
|
||||
itemCount: '{{count}} nel backup',
|
||||
overwriteLabel: 'Sovrascrivi le voci esistenti',
|
||||
overwriteOn: 'Le voci esistenti verranno aggiornate dal backup.',
|
||||
overwriteOff: 'Vengono aggiunte solo le voci mancanti; quelle esistenti restano invariate.',
|
||||
selectedCount: '{{count}} selezionati',
|
||||
restoring: 'Ripristino in corso...',
|
||||
confirmTitle: 'Ripristinare dal backup?',
|
||||
confirmMessage: 'Le categorie selezionate verranno ripristinate da questo commit. Le voci mancanti vengono aggiunte, quelle esistenti restano invariate.',
|
||||
confirmMessageOverwrite: 'Le categorie selezionate verranno ripristinate da questo commit sovrascrivendo le voci già presenti in locale. Operazione non annullabile.',
|
||||
tally: '{{restored}} ripristinati, {{skipped}} saltati, {{failed}} non riusciti',
|
||||
reloadHint: 'Ricarica Bambuddy per vedere i dati ripristinati in tutte le sezioni.',
|
||||
failed: 'Ripristino non riuscito.',
|
||||
loadFailed: 'Impossibile leggere il repository di backup.',
|
||||
},
|
||||
|
||||
// History
|
||||
history: 'Cronologia',
|
||||
clear: 'Cancella',
|
||||
|
||||
@@ -4873,6 +4873,30 @@ export default {
|
||||
clearedLogs: '{{count}}件のログを削除しました',
|
||||
failedToClearLogs: 'ログの削除に失敗しました: {{message}}',
|
||||
|
||||
// Restore from Git backup (#2656)
|
||||
restoreFromGit: {
|
||||
button: '復元',
|
||||
title: 'Git バックアップから復元',
|
||||
subtitle: 'コミットと復元する項目を選択します',
|
||||
commitLabel: 'バックアップのコミット',
|
||||
latestCommit: '最新のバックアップ (ブランチ先端)',
|
||||
categoriesLabel: '復元する項目',
|
||||
inspecting: 'バックアップの内容を読み込んでいます...',
|
||||
itemCount: 'バックアップ内に {{count}} 件',
|
||||
overwriteLabel: '既存のエントリを上書きする',
|
||||
overwriteOn: '既存のエントリはバックアップの内容で更新されます。',
|
||||
overwriteOff: '不足しているエントリのみ追加され、既存のものは変更されません。',
|
||||
selectedCount: '{{count}} 件選択中',
|
||||
restoring: '復元しています...',
|
||||
confirmTitle: 'バックアップから復元しますか?',
|
||||
confirmMessage: '選択したカテゴリをこのコミットから復元します。不足しているエントリが追加され、既存のエントリはそのまま残ります。',
|
||||
confirmMessageOverwrite: '選択したカテゴリをこのコミットから復元し、ローカルに既存のエントリを上書きします。この操作は取り消せません。',
|
||||
tally: '復元 {{restored}} 件、スキップ {{skipped}} 件、失敗 {{failed}} 件',
|
||||
reloadHint: '復元したデータを全体に反映するには Bambuddy を再読み込みしてください。',
|
||||
failed: '復元に失敗しました。',
|
||||
loadFailed: 'バックアップリポジトリを読み取れませんでした。',
|
||||
},
|
||||
|
||||
// History
|
||||
history: '履歴',
|
||||
clear: 'クリア',
|
||||
|
||||
@@ -4637,6 +4637,30 @@ export default {
|
||||
backupFailed2: '백업 실패: {{message}}',
|
||||
clearedLogs: '{{count}}개 로그 초기화됨',
|
||||
failedToClearLogs: '로그 초기화 실패: {{message}}',
|
||||
|
||||
// Restore from Git backup (#2656)
|
||||
restoreFromGit: {
|
||||
button: '복원',
|
||||
title: 'Git 백업에서 복원',
|
||||
subtitle: '커밋과 복원할 항목을 선택하세요',
|
||||
commitLabel: '백업 커밋',
|
||||
latestCommit: '최신 백업 (브랜치 최신 커밋)',
|
||||
categoriesLabel: '복원할 항목',
|
||||
inspecting: '백업 내용을 읽고 있습니다...',
|
||||
itemCount: '백업에 {{count}}개',
|
||||
overwriteLabel: '기존 항목 덮어쓰기',
|
||||
overwriteOn: '기존 항목이 백업 내용으로 업데이트됩니다.',
|
||||
overwriteOff: '없는 항목만 추가되고 기존 항목은 그대로 유지됩니다.',
|
||||
selectedCount: '{{count}}개 선택됨',
|
||||
restoring: '복원 중...',
|
||||
confirmTitle: '백업에서 복원하시겠습니까?',
|
||||
confirmMessage: '선택한 항목을 이 커밋에서 복원합니다. 없는 항목은 추가되고 기존 항목은 그대로 유지됩니다.',
|
||||
confirmMessageOverwrite: '선택한 항목을 이 커밋에서 복원하고 로컬에 이미 있는 항목을 덮어씁니다. 이 작업은 취소할 수 없습니다.',
|
||||
tally: '복원 {{restored}}개, 건너뜀 {{skipped}}개, 실패 {{failed}}개',
|
||||
reloadHint: '복원된 데이터가 모든 화면에 반영되도록 Bambuddy를 새로 고치세요.',
|
||||
failed: '복원에 실패했습니다.',
|
||||
loadFailed: '백업 저장소를 읽을 수 없습니다.',
|
||||
},
|
||||
history: '기록',
|
||||
clear: '초기화',
|
||||
date: '날짜',
|
||||
|
||||
@@ -4861,6 +4861,30 @@ export default {
|
||||
clearedLogs: '{{count}} logs removidos',
|
||||
failedToClearLogs: 'Falha ao limpar logs: {{message}}',
|
||||
|
||||
// Restore from Git backup (#2656)
|
||||
restoreFromGit: {
|
||||
button: 'Restaurar',
|
||||
title: 'Restaurar do backup Git',
|
||||
subtitle: 'Escolha um commit e o que deve ser restaurado',
|
||||
commitLabel: 'Commit do backup',
|
||||
latestCommit: 'Backup mais recente (ponta do branch)',
|
||||
categoriesLabel: 'O que restaurar',
|
||||
inspecting: 'Lendo o conteúdo do backup...',
|
||||
itemCount: '{{count}} no backup',
|
||||
overwriteLabel: 'Sobrescrever entradas existentes',
|
||||
overwriteOn: 'As entradas existentes serão atualizadas a partir do backup.',
|
||||
overwriteOff: 'Apenas as entradas ausentes são adicionadas; as existentes permanecem intactas.',
|
||||
selectedCount: '{{count}} selecionados',
|
||||
restoring: 'Restaurando...',
|
||||
confirmTitle: 'Restaurar do backup?',
|
||||
confirmMessage: 'As categorias selecionadas serão restauradas deste commit. As entradas ausentes são adicionadas e as existentes permanecem como estão.',
|
||||
confirmMessageOverwrite: 'As categorias selecionadas serão restauradas deste commit, sobrescrevendo as entradas que já existem localmente. Não é possível desfazer.',
|
||||
tally: '{{restored}} restaurados, {{skipped}} ignorados, {{failed}} com falha',
|
||||
reloadHint: 'Recarregue o Bambuddy para que os dados restaurados apareçam em todos os lugares.',
|
||||
failed: 'Falha na restauração.',
|
||||
loadFailed: 'Não foi possível ler o repositório de backup.',
|
||||
},
|
||||
|
||||
// History
|
||||
history: 'Histórico',
|
||||
clear: 'Limpar',
|
||||
|
||||
@@ -4629,6 +4629,30 @@ export default {
|
||||
backupFailed2: "Ошибка резервного копирования: {{message}}",
|
||||
clearedLogs: "Очищено записей журнала: {{count}}",
|
||||
failedToClearLogs: "Не удалось очистить журнал: {{message}}",
|
||||
|
||||
// Restore from Git backup (#2656)
|
||||
restoreFromGit: {
|
||||
button: 'Восстановить',
|
||||
title: 'Восстановление из резервной копии Git',
|
||||
subtitle: 'Выберите коммит и данные для восстановления',
|
||||
commitLabel: 'Коммит резервной копии',
|
||||
latestCommit: 'Последняя резервная копия (вершина ветки)',
|
||||
categoriesLabel: 'Что восстановить',
|
||||
inspecting: 'Чтение содержимого резервной копии...',
|
||||
itemCount: '{{count}} в резервной копии',
|
||||
overwriteLabel: 'Перезаписывать существующие записи',
|
||||
overwriteOn: 'Существующие записи будут обновлены из резервной копии.',
|
||||
overwriteOff: 'Добавляются только отсутствующие записи, существующие не изменяются.',
|
||||
selectedCount: 'Выбрано: {{count}}',
|
||||
restoring: 'Восстановление...',
|
||||
confirmTitle: 'Восстановить из резервной копии?',
|
||||
confirmMessage: 'Выбранные категории будут восстановлены из этого коммита. Отсутствующие записи будут добавлены, существующие останутся без изменений.',
|
||||
confirmMessageOverwrite: 'Выбранные категории будут восстановлены из этого коммита с перезаписью уже существующих локальных записей. Отменить это действие нельзя.',
|
||||
tally: 'восстановлено: {{restored}}, пропущено: {{skipped}}, с ошибкой: {{failed}}',
|
||||
reloadHint: 'Перезагрузите Bambuddy, чтобы восстановленные данные отобразились везде.',
|
||||
failed: 'Не удалось выполнить восстановление.',
|
||||
loadFailed: 'Не удалось прочитать репозиторий резервных копий.',
|
||||
},
|
||||
history: "История",
|
||||
clear: "Очистить",
|
||||
date: "Дата",
|
||||
|
||||
@@ -4851,6 +4851,30 @@ export default {
|
||||
clearedLogs: '{{count}} günlük temizlendi',
|
||||
failedToClearLogs: 'Günlükler temizlenemedi: {{message}}',
|
||||
|
||||
// Restore from Git backup (#2656)
|
||||
restoreFromGit: {
|
||||
button: 'Geri Yükle',
|
||||
title: 'Git yedeğinden geri yükle',
|
||||
subtitle: 'Bir commit ve geri yüklenecek verileri seçin',
|
||||
commitLabel: 'Yedek commit\'i',
|
||||
latestCommit: 'En son yedek (dal ucu)',
|
||||
categoriesLabel: 'Neler geri yüklenecek',
|
||||
inspecting: 'Yedek içeriği okunuyor...',
|
||||
itemCount: 'yedekte {{count}} kayıt',
|
||||
overwriteLabel: 'Mevcut kayıtların üzerine yaz',
|
||||
overwriteOn: 'Mevcut kayıtlar yedekten güncellenecek.',
|
||||
overwriteOff: 'Yalnızca eksik kayıtlar eklenir; mevcut olanlara dokunulmaz.',
|
||||
selectedCount: '{{count}} seçildi',
|
||||
restoring: 'Geri yükleniyor...',
|
||||
confirmTitle: 'Yedekten geri yüklensin mi?',
|
||||
confirmMessage: 'Seçilen kategoriler bu commit\'ten geri yüklenecek. Eksik kayıtlar eklenir, mevcut kayıtlar olduğu gibi kalır.',
|
||||
confirmMessageOverwrite: 'Seçilen kategoriler bu commit\'ten geri yüklenecek ve yerelde bulunan kayıtların üzerine yazılacak. Bu işlem geri alınamaz.',
|
||||
tally: '{{restored}} geri yüklendi, {{skipped}} atlandı, {{failed}} başarısız',
|
||||
reloadHint: 'Geri yüklenen verilerin her yerde görünmesi için Bambuddy\'yi yeniden yükleyin.',
|
||||
failed: 'Geri yükleme başarısız oldu.',
|
||||
loadFailed: 'Yedek deposu okunamadı.',
|
||||
},
|
||||
|
||||
history: 'Geçmiş',
|
||||
clear: 'Temizle',
|
||||
date: 'Tarih',
|
||||
|
||||
@@ -4861,6 +4861,30 @@ export default {
|
||||
clearedLogs: '已清除 {{count}} 条日志',
|
||||
failedToClearLogs: '清除日志失败:{{message}}',
|
||||
|
||||
// Restore from Git backup (#2656)
|
||||
restoreFromGit: {
|
||||
button: '恢复',
|
||||
title: '从 Git 备份恢复',
|
||||
subtitle: '选择提交以及要恢复的内容',
|
||||
commitLabel: '备份提交',
|
||||
latestCommit: '最新备份(分支最新提交)',
|
||||
categoriesLabel: '恢复内容',
|
||||
inspecting: '正在读取备份内容...',
|
||||
itemCount: '备份中有 {{count}} 项',
|
||||
overwriteLabel: '覆盖已有条目',
|
||||
overwriteOn: '已有条目将根据备份内容更新。',
|
||||
overwriteOff: '仅添加缺失的条目,已有条目保持不变。',
|
||||
selectedCount: '已选择 {{count}} 项',
|
||||
restoring: '正在恢复...',
|
||||
confirmTitle: '要从备份恢复吗?',
|
||||
confirmMessage: '将从此提交恢复所选类别。缺失的条目会被添加,已有条目保持不变。',
|
||||
confirmMessageOverwrite: '将从此提交恢复所选类别,并覆盖本地已存在的条目。此操作无法撤销。',
|
||||
tally: '已恢复 {{restored}} 项,跳过 {{skipped}} 项,失败 {{failed}} 项',
|
||||
reloadHint: '请重新加载 Bambuddy,以便恢复的数据在各处生效。',
|
||||
failed: '恢复失败。',
|
||||
loadFailed: '无法读取备份仓库。',
|
||||
},
|
||||
|
||||
// History
|
||||
history: '历史记录',
|
||||
clear: '清除',
|
||||
|
||||
@@ -4861,6 +4861,30 @@ export default {
|
||||
clearedLogs: '已清除 {{count}} 條日誌',
|
||||
failedToClearLogs: '清除日誌失敗:{{message}}',
|
||||
|
||||
// Restore from Git backup (#2656)
|
||||
restoreFromGit: {
|
||||
button: '還原',
|
||||
title: '從 Git 備份還原',
|
||||
subtitle: '選擇提交以及要還原的項目',
|
||||
commitLabel: '備份提交',
|
||||
latestCommit: '最新備份(分支最新提交)',
|
||||
categoriesLabel: '還原項目',
|
||||
inspecting: '正在讀取備份內容...',
|
||||
itemCount: '備份中有 {{count}} 筆',
|
||||
overwriteLabel: '覆寫既有項目',
|
||||
overwriteOn: '既有項目將依備份內容更新。',
|
||||
overwriteOff: '僅新增缺少的項目,既有項目保持不變。',
|
||||
selectedCount: '已選擇 {{count}} 筆',
|
||||
restoring: '正在還原...',
|
||||
confirmTitle: '要從備份還原嗎?',
|
||||
confirmMessage: '將從此提交還原所選類別。缺少的項目會被新增,既有項目保持不變。',
|
||||
confirmMessageOverwrite: '將從此提交還原所選類別,並覆寫本機已存在的項目。此操作無法復原。',
|
||||
tally: '已還原 {{restored}} 筆、略過 {{skipped}} 筆、失敗 {{failed}} 筆',
|
||||
reloadHint: '請重新載入 Bambuddy,讓還原的資料在各處生效。',
|
||||
failed: '還原失敗。',
|
||||
loadFailed: '無法讀取備份儲存庫。',
|
||||
},
|
||||
|
||||
// History
|
||||
history: '歷史紀錄',
|
||||
clear: '清除',
|
||||
|
||||
Reference in New Issue
Block a user