mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 23:21:58 +02:00
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.
924 lines
40 KiB
Python
924 lines
40 KiB
Python
"""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()
|