github_restore.py 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923
  1. """Restore Bambuddy data from a Git provider backup (issue #2656).
  2. The backup side (``github_backup.py``) is push-only: it collects a handful of
  3. JSON documents and commits them. This module is the read side — it walks the
  4. backup repository's history, lets a caller inspect what a given commit contains,
  5. and applies selected categories back into the local database (or, for
  6. K-profiles, back onto the printers).
  7. Design notes worth knowing before editing:
  8. * **A restore never reuses the backup's primary keys.** ``spool.id`` and
  9. ``print_archives.id`` are bare autoincrement columns, so the ids in a backup
  10. taken weeks ago very likely belong to unrelated rows today. Rows are matched
  11. on natural keys instead, inserted without an explicit id, and an
  12. ``old_id -> new_id`` map is threaded through so foreign keys in dependent
  13. tables (spool usage history) still line up.
  14. * **Categories are applied archives -> spools -> settings -> kprofiles.**
  15. Archives first because spool usage history references ``archive_id``;
  16. K-profiles last because they leave the database and talk to hardware.
  17. * **Cloud profiles are not restorable.** The backup collector never actually
  18. writes ``cloud_profiles/*.json``, and the preset list it would write carries
  19. no setting payload. Tracked separately from #2656.
  20. """
  21. import asyncio
  22. import json
  23. import logging
  24. import re
  25. from datetime import datetime, timezone
  26. import httpx
  27. from sqlalchemy import select
  28. from sqlalchemy.ext.asyncio import AsyncSession
  29. from backend.app.core.database import async_session
  30. from backend.app.models.archive import PrintArchive
  31. from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
  32. from backend.app.models.printer import Printer
  33. from backend.app.models.project import Project
  34. from backend.app.models.settings import Settings
  35. from backend.app.models.spool import Spool
  36. from backend.app.models.spool_usage_history import SpoolUsageHistory
  37. from backend.app.schemas.github_backup import RestoreCategory
  38. from backend.app.services.git_providers.factory import get_provider_backend
  39. from backend.app.services.printer_manager import printer_manager
  40. logger = logging.getLogger(__name__)
  41. METADATA_PATH = "backup_metadata.json"
  42. SETTINGS_PATH = "settings/app_settings.json"
  43. SPOOLS_PATH = "spools/inventory.json"
  44. SPOOL_USAGE_PATH = "spools/usage_history.json"
  45. ARCHIVES_PATH = "archives/print_history.json"
  46. # kprofiles/{printer_serial}/{nozzle_diameter}.json
  47. _KPROFILE_PATH_RE = re.compile(r"^kprofiles/([^/]+)/([^/]+)\.json$")
  48. # Settings keys the backup collector already refuses to write. Applied again on
  49. # the read side because a backup taken before that denylist existed can still
  50. # contain them, and a restore must not resurrect a stale credential.
  51. _SENSITIVE_SETTING_KEYS = {"bambu_cloud_token", "auth_secret_key"}
  52. # Belt-and-braces for the same reason: any key that looks like a secret is
  53. # skipped even if it isn't in the explicit denylist above.
  54. _SECRET_KEY_HINTS = ("token", "secret", "password", "access_code", "api_key", "passphrase")
  55. # Nozzle diameters the backup collector iterates. A path outside this set means
  56. # the backup was written by a newer version, so accept it rather than dropping
  57. # data, but keep the list for validation messages.
  58. _KNOWN_NOZZLES = {"0.2", "0.4", "0.6", "0.8"}
  59. def _parse_dt(value) -> datetime | None:
  60. """Best-effort parse of a datetime the backup wrote via ``str(...)``."""
  61. if not value or not isinstance(value, str):
  62. return None
  63. try:
  64. return datetime.fromisoformat(value)
  65. except ValueError:
  66. return None
  67. def _is_blocked_setting_key(key: str) -> bool:
  68. lowered = key.lower()
  69. return key in _SENSITIVE_SETTING_KEYS or any(hint in lowered for hint in _SECRET_KEY_HINTS)
  70. class _CategoryTally:
  71. """Mutable accumulator matching ``GitHubRestoreCategoryResult``."""
  72. def __init__(self) -> None:
  73. self.restored = 0
  74. self.skipped = 0
  75. self.failed = 0
  76. self.notes: list[str] = []
  77. def note(self, message: str) -> None:
  78. # Notes are surfaced verbatim in the UI, so keep the list bounded rather
  79. # than emitting one line per row for a large backup.
  80. if message not in self.notes and len(self.notes) < 20:
  81. self.notes.append(message)
  82. def as_dict(self) -> dict:
  83. return {"restored": self.restored, "skipped": self.skipped, "failed": self.failed, "notes": self.notes}
  84. class GitHubRestoreService:
  85. """Reads a backup repository and applies selected categories locally."""
  86. def __init__(self) -> None:
  87. self._running_restore: bool = False
  88. self._progress: str | None = None
  89. self._http_client: httpx.AsyncClient | None = None
  90. # Guards the check-then-set on ``_running_restore``. Without it two
  91. # concurrent POSTs can both observe False before either sets it.
  92. self._lock = asyncio.Lock()
  93. async def _get_client(self) -> httpx.AsyncClient:
  94. if self._http_client is None or self._http_client.is_closed:
  95. self._http_client = httpx.AsyncClient(timeout=60.0)
  96. return self._http_client
  97. @property
  98. def is_running(self) -> bool:
  99. return self._running_restore
  100. @property
  101. def progress(self) -> str | None:
  102. return self._progress
  103. # --- Repository reads --------------------------------------------------
  104. async def list_commits(self, config: GitHubBackupConfig, limit: int = 20) -> dict:
  105. """List recent commits on the configured branch."""
  106. backend = get_provider_backend(config.provider)
  107. client = await self._get_client()
  108. result = await backend.list_commits(
  109. repo_url=config.repository_url,
  110. token=config.access_token,
  111. branch=config.branch,
  112. client=client,
  113. limit=limit,
  114. )
  115. result["branch"] = config.branch
  116. return result
  117. async def _resolve_ref(self, config: GitHubBackupConfig, ref: str) -> tuple[str | None, str]:
  118. """Turn ``HEAD`` into a concrete commit SHA.
  119. Done once up front so a preview and the restore that follows it act on
  120. the same commit even if a scheduled backup lands in between.
  121. """
  122. if ref and ref.upper() != "HEAD":
  123. return ref, ""
  124. result = await self.list_commits(config, limit=1)
  125. if not result.get("success"):
  126. return None, result.get("message") or "Could not read the backup repository"
  127. commits = result.get("commits") or []
  128. if not commits:
  129. return None, f"Branch '{config.branch}' has no commits to restore from"
  130. return commits[0]["sha"], ""
  131. def _category_paths(self, category: RestoreCategory, available: list[str]) -> list[str]:
  132. """Return the paths in ``available`` that belong to ``category``."""
  133. if category == RestoreCategory.SETTINGS:
  134. return [p for p in (SETTINGS_PATH,) if p in available]
  135. if category == RestoreCategory.SPOOLS:
  136. return [p for p in (SPOOLS_PATH, SPOOL_USAGE_PATH) if p in available]
  137. if category == RestoreCategory.ARCHIVES:
  138. return [p for p in (ARCHIVES_PATH,) if p in available]
  139. if category == RestoreCategory.KPROFILES:
  140. return sorted(p for p in available if _KPROFILE_PATH_RE.match(p))
  141. return []
  142. @staticmethod
  143. def _parse_json_files(raw: dict[str, str]) -> tuple[dict[str, object], list[str]]:
  144. """Parse each fetched file, collecting paths that failed to parse."""
  145. parsed: dict[str, object] = {}
  146. bad: list[str] = []
  147. for path, text in raw.items():
  148. try:
  149. parsed[path] = json.loads(text)
  150. except (ValueError, TypeError):
  151. bad.append(path)
  152. return parsed, bad
  153. async def preview(self, config: GitHubBackupConfig, ref: str = "HEAD") -> dict:
  154. """Report which categories a commit contains, and how much is in each."""
  155. resolved, error = await self._resolve_ref(config, ref)
  156. if resolved is None:
  157. return {"success": False, "message": error, "ref": ref, "categories": []}
  158. backend = get_provider_backend(config.provider)
  159. client = await self._get_client()
  160. tree = await backend.list_tree(
  161. repo_url=config.repository_url, token=config.access_token, ref=resolved, client=client
  162. )
  163. if not tree.get("success"):
  164. return {"success": False, "message": tree.get("message") or "Could not list the commit", "ref": resolved}
  165. available: list[str] = tree.get("paths") or []
  166. # One batched read covers metadata plus every category payload.
  167. wanted = [METADATA_PATH] if METADATA_PATH in available else []
  168. for category in RestoreCategory:
  169. wanted.extend(self._category_paths(category, available))
  170. fetched = await backend.fetch_files(
  171. repo_url=config.repository_url, token=config.access_token, ref=resolved, paths=wanted, client=client
  172. )
  173. if not fetched.get("success"):
  174. return {
  175. "success": False,
  176. "message": fetched.get("message") or "Could not read the commit contents",
  177. "ref": resolved,
  178. }
  179. parsed, bad_paths = self._parse_json_files(fetched.get("files") or {})
  180. metadata = parsed.get(METADATA_PATH)
  181. metadata_version = metadata.get("version") if isinstance(metadata, dict) else None
  182. categories = []
  183. for category in RestoreCategory:
  184. paths = self._category_paths(category, available)
  185. if not paths:
  186. categories.append(
  187. {
  188. "category": category,
  189. "available": False,
  190. "item_count": 0,
  191. "detail": "Not present in this backup commit",
  192. }
  193. )
  194. continue
  195. unreadable = [p for p in paths if p in bad_paths]
  196. if unreadable:
  197. categories.append(
  198. {
  199. "category": category,
  200. "available": False,
  201. "item_count": 0,
  202. "detail": f"Unreadable JSON: {', '.join(unreadable)}",
  203. }
  204. )
  205. continue
  206. count, detail = self._count_items(category, parsed)
  207. categories.append({"category": category, "available": True, "item_count": count, "detail": detail})
  208. commit_info = None
  209. commits = (await self.list_commits(config, limit=20)).get("commits") or []
  210. for entry in commits:
  211. if entry["sha"] == resolved:
  212. commit_info = entry
  213. break
  214. return {
  215. "success": True,
  216. "message": "OK",
  217. "ref": resolved,
  218. "commit": commit_info,
  219. "metadata_version": metadata_version,
  220. "categories": categories,
  221. }
  222. @staticmethod
  223. def _count_items(category: RestoreCategory, parsed: dict) -> tuple[int, str | None]:
  224. """Count restorable items for ``category`` and describe any caveat."""
  225. if category == RestoreCategory.SETTINGS:
  226. payload = parsed.get(SETTINGS_PATH)
  227. values = payload.get("settings") if isinstance(payload, dict) else None
  228. if not isinstance(values, dict):
  229. return 0, "No settings in payload"
  230. blocked = sum(1 for key in values if _is_blocked_setting_key(key))
  231. detail = f"{blocked} credential-like keys will be skipped" if blocked else None
  232. return len(values) - blocked, detail
  233. if category == RestoreCategory.SPOOLS:
  234. payload = parsed.get(SPOOLS_PATH)
  235. spools = payload.get("spools") if isinstance(payload, dict) else None
  236. usage_payload = parsed.get(SPOOL_USAGE_PATH)
  237. usage = usage_payload.get("usage_history") if isinstance(usage_payload, dict) else None
  238. count = len(spools) if isinstance(spools, list) else 0
  239. detail = f"plus {len(usage)} usage records" if isinstance(usage, list) and usage else None
  240. return count, detail
  241. if category == RestoreCategory.ARCHIVES:
  242. payload = parsed.get(ARCHIVES_PATH)
  243. archives = payload.get("archives") if isinstance(payload, dict) else None
  244. count = len(archives) if isinstance(archives, list) else 0
  245. return count, "Metadata only — 3MF files and thumbnails are not in a Git backup"
  246. if category == RestoreCategory.KPROFILES:
  247. total = 0
  248. serials = set()
  249. for path, payload in parsed.items():
  250. match = _KPROFILE_PATH_RE.match(path)
  251. if not match or not isinstance(payload, dict):
  252. continue
  253. serials.add(match.group(1))
  254. profiles = payload.get("profiles")
  255. if isinstance(profiles, list):
  256. total += len(profiles)
  257. detail = f"across {len(serials)} printer(s)" if serials else None
  258. return total, detail
  259. return 0, None
  260. # --- Restore -----------------------------------------------------------
  261. async def run_restore(
  262. self,
  263. config_id: int,
  264. ref: str,
  265. categories: list[RestoreCategory],
  266. overwrite_existing: bool = False,
  267. ) -> dict:
  268. """Apply selected categories from one backup commit."""
  269. # Import locally to avoid a module-level cycle: the backup service takes
  270. # the mirror-image lock against us.
  271. from backend.app.services.github_backup import github_backup_service
  272. # The lock serialises two concurrent restores; the backup side has no
  273. # lock of its own, and relies on this region staying await-free after the
  274. # acquisition. Both flags are plain bools on one event loop, so with no
  275. # suspension point between the two reads and the write, the loop cannot
  276. # slip github_backup.run_backup's mirror-image check in between. Adding an
  277. # `await` below the acquisition and above `self._running_restore = True`
  278. # would let a backup and a restore run at once.
  279. async with self._lock:
  280. if self._running_restore:
  281. return {"success": False, "message": "A restore is already running", "results": {}}
  282. if github_backup_service.is_running:
  283. return {
  284. "success": False,
  285. "message": "A backup is currently running. Wait for it to finish before restoring.",
  286. "results": {},
  287. }
  288. self._running_restore = True
  289. log_id = None
  290. try:
  291. async with async_session() as db:
  292. result = await db.execute(select(GitHubBackupConfig).where(GitHubBackupConfig.id == config_id))
  293. config = result.scalar_one_or_none()
  294. if not config:
  295. return {"success": False, "message": "Configuration not found", "results": {}}
  296. self._progress = "Resolving commit..."
  297. resolved, error = await self._resolve_ref(config, ref)
  298. if resolved is None:
  299. return {"success": False, "message": error, "results": {}}
  300. log = GitHubBackupLog(config_id=config_id, status="running", trigger="restore", commit_sha=resolved)
  301. db.add(log)
  302. await db.commit()
  303. await db.refresh(log)
  304. log_id = log.id
  305. try:
  306. payload, error = await self._read_categories(config, resolved, categories)
  307. if error:
  308. raise RuntimeError(error)
  309. results = await self._apply(db, payload, categories, overwrite_existing)
  310. await db.commit()
  311. total_restored = sum(tally.restored for tally in results.values())
  312. any_failed = any(tally.failed for tally in results.values())
  313. log.status = "failed" if any_failed and total_restored == 0 else "success"
  314. log.completed_at = datetime.now(timezone.utc)
  315. log.files_changed = total_restored
  316. if any_failed:
  317. log.error_message = "Some items could not be restored — see the restore result for detail"
  318. await db.commit()
  319. return {
  320. "success": True,
  321. "message": f"Restored {total_restored} item(s) from {resolved[:7]}",
  322. "log_id": log_id,
  323. "ref": resolved,
  324. "results": {name: tally.as_dict() for name, tally in results.items()},
  325. }
  326. except Exception as e:
  327. logger.exception("Restore failed for config %s ref %s", config_id, resolved)
  328. await db.rollback()
  329. log.status = "failed"
  330. log.completed_at = datetime.now(timezone.utc)
  331. log.error_message = str(e)[:1000]
  332. await db.commit()
  333. return {"success": False, "message": str(e), "log_id": log_id, "ref": resolved, "results": {}}
  334. finally:
  335. self._running_restore = False
  336. self._progress = None
  337. async def _read_categories(
  338. self, config: GitHubBackupConfig, ref: str, categories: list[RestoreCategory]
  339. ) -> tuple[dict, str]:
  340. """Fetch and parse just the files the requested categories need."""
  341. backend = get_provider_backend(config.provider)
  342. client = await self._get_client()
  343. self._progress = "Listing backup contents..."
  344. tree = await backend.list_tree(
  345. repo_url=config.repository_url, token=config.access_token, ref=ref, client=client
  346. )
  347. if not tree.get("success"):
  348. return {}, tree.get("message") or "Could not list the commit"
  349. available: list[str] = tree.get("paths") or []
  350. wanted: list[str] = []
  351. for category in categories:
  352. wanted.extend(self._category_paths(category, available))
  353. if not wanted:
  354. return {}, "None of the selected categories are present in that commit"
  355. self._progress = "Downloading backup files..."
  356. fetched = await backend.fetch_files(
  357. repo_url=config.repository_url, token=config.access_token, ref=ref, paths=wanted, client=client
  358. )
  359. if not fetched.get("success"):
  360. return {}, fetched.get("message") or "Could not read the commit contents"
  361. parsed, bad = self._parse_json_files(fetched.get("files") or {})
  362. if bad:
  363. return {}, f"Backup contains unreadable JSON: {', '.join(sorted(bad))}"
  364. return parsed, ""
  365. async def _apply(
  366. self,
  367. db: AsyncSession,
  368. payload: dict,
  369. categories: list[RestoreCategory],
  370. overwrite: bool,
  371. ) -> dict[str, _CategoryTally]:
  372. """Apply categories in dependency order and return per-category tallies."""
  373. results: dict[str, _CategoryTally] = {}
  374. archive_id_map: dict[int, int] = {}
  375. # Archives first: spool usage history references archive_id.
  376. if RestoreCategory.ARCHIVES in categories:
  377. self._progress = "Restoring print archives..."
  378. tally = _CategoryTally()
  379. await self._restore_archives(db, payload.get(ARCHIVES_PATH), overwrite, tally, archive_id_map)
  380. results[RestoreCategory.ARCHIVES.value] = tally
  381. if RestoreCategory.SPOOLS in categories:
  382. self._progress = "Restoring spool inventory..."
  383. tally = _CategoryTally()
  384. await self._restore_spools(
  385. db,
  386. payload.get(SPOOLS_PATH),
  387. payload.get(SPOOL_USAGE_PATH),
  388. overwrite,
  389. tally,
  390. archive_id_map,
  391. )
  392. results[RestoreCategory.SPOOLS.value] = tally
  393. if RestoreCategory.SETTINGS in categories:
  394. self._progress = "Restoring app settings..."
  395. tally = _CategoryTally()
  396. await self._restore_settings(db, payload.get(SETTINGS_PATH), overwrite, tally)
  397. results[RestoreCategory.SETTINGS.value] = tally
  398. # Last, because it leaves the database and publishes over MQTT.
  399. if RestoreCategory.KPROFILES in categories:
  400. self._progress = "Sending K-profiles to printers..."
  401. tally = _CategoryTally()
  402. await self._restore_kprofiles(db, payload, tally)
  403. results[RestoreCategory.KPROFILES.value] = tally
  404. return results
  405. # --- Per-category appliers --------------------------------------------
  406. async def _restore_archives(
  407. self,
  408. db: AsyncSession,
  409. payload,
  410. overwrite: bool,
  411. tally: _CategoryTally,
  412. id_map: dict[int, int],
  413. ) -> None:
  414. archives = payload.get("archives") if isinstance(payload, dict) else None
  415. if not isinstance(archives, list):
  416. tally.note("No archive data in this backup")
  417. return
  418. valid_printers = set((await db.execute(select(Printer.id))).scalars().all())
  419. valid_projects = set((await db.execute(select(Project.id))).scalars().all())
  420. # Only metadata is backed up, never the 3MF/thumbnail bytes, and
  421. # print_archives.file_path is NOT NULL — so inserted rows get an empty
  422. # path and are history-only. Say so once rather than per row.
  423. warned_files = False
  424. for entry in archives:
  425. if not isinstance(entry, dict):
  426. tally.failed += 1
  427. continue
  428. old_id = entry.get("id") if isinstance(entry.get("id"), int) else None
  429. started_at = _parse_dt(entry.get("started_at"))
  430. existing = await self._find_archive(db, entry, started_at)
  431. fields = {
  432. "print_name": entry.get("print_name"),
  433. "print_time_seconds": entry.get("print_time_seconds"),
  434. "filament_used_grams": entry.get("filament_used_grams"),
  435. "filament_type": entry.get("filament_type"),
  436. "filament_color": entry.get("filament_color"),
  437. "layer_height": entry.get("layer_height"),
  438. "total_layers": entry.get("total_layers"),
  439. "nozzle_diameter": entry.get("nozzle_diameter"),
  440. "bed_temperature": entry.get("bed_temperature"),
  441. "nozzle_temperature": entry.get("nozzle_temperature"),
  442. "sliced_for_model": entry.get("sliced_for_model"),
  443. "status": entry.get("status") or "completed",
  444. "started_at": started_at,
  445. "completed_at": _parse_dt(entry.get("completed_at")),
  446. "makerworld_url": entry.get("makerworld_url"),
  447. "designer": entry.get("designer"),
  448. "external_url": entry.get("external_url"),
  449. "is_favorite": bool(entry.get("is_favorite")),
  450. "tags": entry.get("tags"),
  451. "notes": entry.get("notes"),
  452. "cost": entry.get("cost"),
  453. "failure_reason": entry.get("failure_reason"),
  454. "quantity": entry.get("quantity") or 1,
  455. "energy_kwh": entry.get("energy_kwh"),
  456. "energy_cost": entry.get("energy_cost"),
  457. # A soft-deleted archive is still in the backup (its row is kept
  458. # so stats keep counting it), so carry the flag across or the
  459. # restore turns something the user deleted back into a visible
  460. # archive. Backups written before this key existed have no
  461. # deleted_at, and those rows can only come back live.
  462. "deleted_at": _parse_dt(entry.get("deleted_at")),
  463. }
  464. printer_id = entry.get("printer_id")
  465. if printer_id is not None and printer_id not in valid_printers:
  466. tally.note("Some archives referenced printers that no longer exist — link cleared")
  467. printer_id = None
  468. project_id = entry.get("project_id")
  469. if project_id is not None and project_id not in valid_projects:
  470. tally.note("Some archives referenced projects that no longer exist — link cleared")
  471. project_id = None
  472. fields["printer_id"] = printer_id
  473. fields["project_id"] = project_id
  474. if existing is not None:
  475. if old_id is not None:
  476. id_map[old_id] = existing.id
  477. if not overwrite:
  478. tally.skipped += 1
  479. continue
  480. # Overwrite means "make the local row match the backup", which
  481. # includes un-deleting one the user deleted after the backup was
  482. # taken. Legitimate, but not obvious from a restored/skipped
  483. # count, so say it.
  484. if existing.deleted_at is not None and fields["deleted_at"] is None:
  485. tally.note("Archive(s) deleted since the backup are visible again — overwrite was on")
  486. for key, value in fields.items():
  487. setattr(existing, key, value)
  488. tally.restored += 1
  489. continue
  490. if not warned_files:
  491. tally.note(
  492. "Restored archives carry metadata only — the 3MF and thumbnail files are not in a Git backup"
  493. )
  494. warned_files = True
  495. row = PrintArchive(
  496. filename=entry.get("filename") or "restored-from-backup",
  497. file_path="",
  498. file_size=entry.get("file_size") or 0,
  499. content_hash=entry.get("content_hash"),
  500. **fields,
  501. )
  502. created_at = _parse_dt(entry.get("created_at"))
  503. if created_at is not None:
  504. row.created_at = created_at
  505. db.add(row)
  506. await db.flush()
  507. if old_id is not None:
  508. id_map[old_id] = row.id
  509. tally.restored += 1
  510. async def _find_archive(self, db: AsyncSession, entry: dict, started_at: datetime | None) -> PrintArchive | None:
  511. """Match a backed-up archive to a local row by natural key.
  512. ``started_at`` is nullable and genuinely NULL for a whole class of rows —
  513. the re-slice path in ``library.py`` constructs ``PrintArchive`` without
  514. one — so it cannot be *required* by the key. It narrows the match instead:
  515. a backed-up row with no ``started_at`` matches a local row that has none
  516. either. Requiring it meant those archives never matched, so each restore
  517. re-inserted them as duplicates and overwrite mode could never update them.
  518. ``content_hash`` identifies the sliced file on its own, which is why it is
  519. the branch allowed to run without a ``started_at``; ``filename`` is too
  520. weak for that (re-slices share it) and still requires one. Two backed-up
  521. rows sharing a hash *and* having no ``started_at`` are indistinguishable
  522. in the backup, so they collapse onto one local row — better than
  523. duplicating both on every restore.
  524. Soft-deleted rows are matched deliberately: there is no ``deleted_at``
  525. filter here because the row still exists, and matching it is what stops a
  526. restore inserting a live duplicate of an archive the user has deleted.
  527. """
  528. started_predicate = PrintArchive.started_at == started_at if started_at else PrintArchive.started_at.is_(None)
  529. content_hash = entry.get("content_hash")
  530. if content_hash:
  531. result = await db.execute(
  532. select(PrintArchive).where(PrintArchive.content_hash == content_hash, started_predicate)
  533. )
  534. row = result.scalars().first()
  535. if row is not None:
  536. return row
  537. filename = entry.get("filename")
  538. if filename and started_at:
  539. result = await db.execute(select(PrintArchive).where(PrintArchive.filename == filename, started_predicate))
  540. return result.scalars().first()
  541. return None
  542. async def _restore_spools(
  543. self,
  544. db: AsyncSession,
  545. inventory,
  546. usage_payload,
  547. overwrite: bool,
  548. tally: _CategoryTally,
  549. archive_id_map: dict[int, int],
  550. ) -> None:
  551. spools = inventory.get("spools") if isinstance(inventory, dict) else None
  552. if not isinstance(spools, list):
  553. tally.note("No spool data in this backup")
  554. return
  555. spool_id_map: dict[int, int] = {}
  556. for entry in spools:
  557. if not isinstance(entry, dict):
  558. tally.failed += 1
  559. continue
  560. old_id = entry.get("id") if isinstance(entry.get("id"), int) else None
  561. existing = await self._find_spool(db, entry)
  562. fields = {
  563. "material": entry.get("material") or "PLA",
  564. "subtype": entry.get("subtype"),
  565. "color_name": entry.get("color_name"),
  566. "rgba": entry.get("rgba"),
  567. "brand": entry.get("brand"),
  568. "label_weight": entry.get("label_weight") or 1000,
  569. "core_weight": entry.get("core_weight") or 250,
  570. "weight_used": entry.get("weight_used") or 0,
  571. "weight_locked": bool(entry.get("weight_locked")),
  572. "slicer_filament": entry.get("slicer_filament"),
  573. "slicer_filament_name": entry.get("slicer_filament_name"),
  574. "nozzle_temp_min": entry.get("nozzle_temp_min"),
  575. "nozzle_temp_max": entry.get("nozzle_temp_max"),
  576. "note": entry.get("note"),
  577. "cost_per_kg": entry.get("cost_per_kg"),
  578. "tag_uid": entry.get("tag_uid"),
  579. "tray_uuid": entry.get("tray_uuid"),
  580. "data_origin": entry.get("data_origin"),
  581. "tag_type": entry.get("tag_type"),
  582. "archived_at": _parse_dt(entry.get("archived_at")),
  583. }
  584. if existing is not None:
  585. if old_id is not None:
  586. spool_id_map[old_id] = existing.id
  587. if not overwrite:
  588. tally.skipped += 1
  589. continue
  590. for key, value in fields.items():
  591. setattr(existing, key, value)
  592. tally.restored += 1
  593. continue
  594. row = Spool(**fields)
  595. # Carry the original created_at across. Without it the row would be
  596. # stamped "now", and the composite fallback in _find_spool (which
  597. # keys on created_at) would miss on a second restore and insert a
  598. # duplicate instead of matching.
  599. created_at = _parse_dt(entry.get("created_at"))
  600. if created_at is not None:
  601. row.created_at = created_at
  602. db.add(row)
  603. await db.flush()
  604. if old_id is not None:
  605. spool_id_map[old_id] = row.id
  606. tally.restored += 1
  607. await self._restore_spool_usage(db, usage_payload, tally, spool_id_map, archive_id_map)
  608. async def _find_spool(self, db: AsyncSession, entry: dict) -> Spool | None:
  609. """Match a backed-up spool to a local row.
  610. Physical identity first (an RFID/Bambu tag is the spool), then a
  611. descriptive composite including ``created_at`` so two otherwise
  612. identical spools added at different times stay distinct.
  613. """
  614. tag_uid = entry.get("tag_uid")
  615. if tag_uid:
  616. result = await db.execute(select(Spool).where(Spool.tag_uid == tag_uid))
  617. row = result.scalars().first()
  618. if row is not None:
  619. return row
  620. tray_uuid = entry.get("tray_uuid")
  621. if tray_uuid:
  622. result = await db.execute(select(Spool).where(Spool.tray_uuid == tray_uuid))
  623. row = result.scalars().first()
  624. if row is not None:
  625. return row
  626. created_at = _parse_dt(entry.get("created_at"))
  627. if created_at is None:
  628. return None
  629. result = await db.execute(
  630. select(Spool).where(
  631. Spool.created_at == created_at,
  632. Spool.material == (entry.get("material") or "PLA"),
  633. Spool.brand == entry.get("brand"),
  634. Spool.subtype == entry.get("subtype"),
  635. Spool.color_name == entry.get("color_name"),
  636. )
  637. )
  638. return result.scalars().first()
  639. async def _restore_spool_usage(
  640. self,
  641. db: AsyncSession,
  642. usage_payload,
  643. tally: _CategoryTally,
  644. spool_id_map: dict[int, int],
  645. archive_id_map: dict[int, int],
  646. ) -> None:
  647. usage = usage_payload.get("usage_history") if isinstance(usage_payload, dict) else None
  648. if not isinstance(usage, list) or not usage:
  649. return
  650. valid_printers = set((await db.execute(select(Printer.id))).scalars().all())
  651. unresolved = 0
  652. for entry in usage:
  653. if not isinstance(entry, dict):
  654. tally.failed += 1
  655. continue
  656. old_spool_id = entry.get("spool_id")
  657. spool_id = spool_id_map.get(old_spool_id) if isinstance(old_spool_id, int) else None
  658. if spool_id is None:
  659. # The parent spool never made it into the map: the backup's spool
  660. # list didn't include it, or its entry carried no integer id. A
  661. # spool that was merely *skipped* (matched locally, overwrite off)
  662. # is mapped a few lines up in _restore_spools, so it never lands
  663. # here — which is why the note below offers no remedy.
  664. unresolved += 1
  665. tally.skipped += 1
  666. continue
  667. created_at = _parse_dt(entry.get("created_at"))
  668. # Usage history has no natural key of its own, so dedupe on the
  669. # tuple that makes a consumption event unique in practice.
  670. existing = await db.execute(
  671. select(SpoolUsageHistory).where(
  672. SpoolUsageHistory.spool_id == spool_id,
  673. SpoolUsageHistory.created_at == created_at,
  674. SpoolUsageHistory.weight_used == (entry.get("weight_used") or 0),
  675. SpoolUsageHistory.print_name == entry.get("print_name"),
  676. )
  677. )
  678. if existing.scalars().first() is not None:
  679. tally.skipped += 1
  680. continue
  681. printer_id = entry.get("printer_id")
  682. if printer_id is not None and printer_id not in valid_printers:
  683. printer_id = None
  684. old_archive_id = entry.get("archive_id")
  685. archive_id = archive_id_map.get(old_archive_id) if isinstance(old_archive_id, int) else None
  686. row = SpoolUsageHistory(
  687. spool_id=spool_id,
  688. printer_id=printer_id,
  689. print_name=entry.get("print_name"),
  690. archive_id=archive_id,
  691. weight_used=entry.get("weight_used") or 0,
  692. percent_used=entry.get("percent_used") or 0,
  693. status=entry.get("status") or "completed",
  694. cost=entry.get("cost"),
  695. )
  696. if created_at is not None:
  697. row.created_at = created_at
  698. db.add(row)
  699. tally.restored += 1
  700. if unresolved:
  701. tally.note(
  702. f"{unresolved} usage record(s) skipped — their spool is not in this backup's "
  703. "spool list, so there is nothing to attach them to."
  704. )
  705. async def _restore_settings(self, db: AsyncSession, payload, overwrite: bool, tally: _CategoryTally) -> None:
  706. values = payload.get("settings") if isinstance(payload, dict) else None
  707. if not isinstance(values, dict):
  708. tally.note("No settings data in this backup")
  709. return
  710. blocked = 0
  711. for key, value in values.items():
  712. if not isinstance(key, str) or not key:
  713. tally.failed += 1
  714. continue
  715. if _is_blocked_setting_key(key):
  716. blocked += 1
  717. tally.skipped += 1
  718. continue
  719. if value is None:
  720. tally.skipped += 1
  721. continue
  722. result = await db.execute(select(Settings).where(Settings.key == key))
  723. existing = result.scalar_one_or_none()
  724. if existing is not None:
  725. if not overwrite:
  726. tally.skipped += 1
  727. continue
  728. existing.value = str(value)
  729. tally.restored += 1
  730. continue
  731. db.add(Settings(key=key, value=str(value)))
  732. tally.restored += 1
  733. if blocked:
  734. tally.note(f"{blocked} credential-like key(s) skipped — re-enter secrets manually")
  735. async def _restore_kprofiles(self, db: AsyncSession, payload: dict, tally: _CategoryTally) -> None:
  736. by_serial: dict[str, list[tuple[str, dict]]] = {}
  737. for path, content in payload.items():
  738. match = _KPROFILE_PATH_RE.match(path)
  739. if not match or not isinstance(content, dict):
  740. continue
  741. by_serial.setdefault(match.group(1), []).append((match.group(2), content))
  742. if not by_serial:
  743. tally.note("No K-profile data in this backup")
  744. return
  745. result = await db.execute(select(Printer))
  746. printers = {p.serial_number: p for p in result.scalars().all() if p.serial_number}
  747. # Overwrite is not offered for K-profiles: extrusion_cali_set replaces
  748. # the profile occupying a slot, so writing is always an overwrite on the
  749. # printer side.
  750. tally.note("K-profiles always overwrite the matching slot on the printer")
  751. tally.note("Profiles are published over MQTT without acknowledgement — verify on the printer")
  752. for serial, entries in sorted(by_serial.items()):
  753. profile_total = sum(len(c.get("profiles") or []) for _, c in entries)
  754. printer = printers.get(serial)
  755. if printer is None:
  756. tally.skipped += profile_total
  757. tally.note(f"No printer with serial {serial} — skipped")
  758. continue
  759. client = printer_manager.get_client(printer.id)
  760. if not client or not client.state.connected:
  761. tally.skipped += profile_total
  762. tally.note(f"{printer.name} ({serial}) is not connected — skipped")
  763. continue
  764. for nozzle, content in sorted(entries):
  765. profiles = content.get("profiles")
  766. if not isinstance(profiles, list) or not profiles:
  767. continue
  768. if nozzle not in _KNOWN_NOZZLES:
  769. tally.note(f"Unexpected nozzle diameter {nozzle} for {serial} — sent as-is")
  770. profile_dicts = [
  771. {
  772. "filament_id": p.get("filament_id", ""),
  773. "name": p.get("name", ""),
  774. "k_value": p.get("k_value", "0.020000"),
  775. "nozzle_id": p.get("nozzle_id"),
  776. "extruder_id": p.get("extruder_id", 0),
  777. "setting_id": p.get("setting_id"),
  778. "slot_id": p.get("slot_id", 0),
  779. }
  780. for p in profiles
  781. if isinstance(p, dict)
  782. ]
  783. if not profile_dicts:
  784. continue
  785. try:
  786. sent = client.set_kprofiles_batch(profile_dicts, nozzle)
  787. except Exception as e:
  788. logger.warning("K-profile restore failed for %s nozzle %s: %s", serial, nozzle, e)
  789. sent = False
  790. if sent:
  791. tally.restored += len(profile_dicts)
  792. else:
  793. tally.failed += len(profile_dicts)
  794. tally.note(f"Failed to send {nozzle} profiles to {printer.name} ({serial})")
  795. # Singleton instance
  796. github_restore_service = GitHubRestoreService()