github_restore.py 128 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742
  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. The printer-side ``cali_idx`` behaves the same way and gets the same
  15. treatment. Editing a K-profile in Bambuddy is a delete-then-add on a
  16. single-nozzle printer, which re-keys it, and ``extrusion_cali_set`` aimed at a
  17. slot that no longer exists is silently dropped — so the live index is read
  18. back and matched before writing, never taken from the backup.
  19. * **Categories are applied archives -> spools -> settings -> kprofiles.**
  20. Archives first because spool usage history references ``archive_id``;
  21. K-profiles last because they leave the database and talk to hardware.
  22. * **Cloud profiles are not restorable.** Restoring a preset means writing to a
  23. Bambu or Orca Cloud account, which is a different operation from everything
  24. else here — every other category lands in the local database or, for
  25. K-profiles, on a printer the instance already owns. Tracked separately from
  26. #2656. (The collector does write ``cloud_profiles/*.json`` as of #2717; the
  27. earlier claim that it did not is no longer true.)
  28. """
  29. import asyncio
  30. import json
  31. import logging
  32. import math
  33. import os
  34. import re
  35. from collections.abc import Iterable
  36. from contextvars import ContextVar
  37. from dataclasses import dataclass, field as dataclasses_field
  38. from datetime import datetime, timezone
  39. import httpx
  40. from pydantic import BaseModel
  41. from sqlalchemy import select
  42. from sqlalchemy.ext.asyncio import AsyncSession
  43. from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
  44. from backend.app.core.database import async_session
  45. from backend.app.core.printer_scope import drop_printers_from_obico
  46. from backend.app.models.archive import PrintArchive
  47. from backend.app.models.archive_alias import ArchiveAlias
  48. from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
  49. from backend.app.models.print_log import INCLUDE_OPEN_RUNS, RUN_OPEN, PrintLogEntry
  50. from backend.app.models.printer import Printer
  51. from backend.app.models.project import Project
  52. from backend.app.models.settings import Settings
  53. from backend.app.models.spool import Spool
  54. from backend.app.models.spool_usage_history import SpoolUsageHistory
  55. from backend.app.models.user import User
  56. from backend.app.schemas.auth import SMTPSettings
  57. from backend.app.schemas.github_backup import RestoreCategory
  58. from backend.app.schemas.settings import ADMIN_ONLY_SETTING_KEYS, AppSettings, AppSettingsUpdate
  59. from backend.app.services.git_providers.factory import get_provider_backend
  60. from backend.app.services.model_providers.manyfold.config import normalize_url
  61. from backend.app.services.printer_manager import printer_manager
  62. from backend.app.services.secret_fields import SETTINGS_SECRETS, SMTP_DESTINATIONS, is_secret_setting_key
  63. logger = logging.getLogger(__name__)
  64. METADATA_PATH = "backup_metadata.json"
  65. SETTINGS_PATH = "settings/app_settings.json"
  66. SPOOLS_PATH = "spools/inventory.json"
  67. SPOOL_USAGE_PATH = "spools/usage_history.json"
  68. ARCHIVES_PATH = "archives/print_history.json"
  69. # Each print of an archive and which archive went into which (archive
  70. # redesign P3). Backups written before carry neither.
  71. RUNS_PATH = "archives/print_runs.json"
  72. ALIASES_PATH = "archives/aliases.json"
  73. # kprofiles/{printer_serial}/{nozzle_diameter}.json
  74. _KPROFILE_PATH_RE = re.compile(r"^kprofiles/([^/]+)/([^/]+)\.json$")
  75. # Settings keys holding a credential are refused by name (is_secret_setting_key,
  76. # shared with the backup collector). A current backup carries them only as the
  77. # placeholder; one taken before that can still hold the values, and a restore
  78. # must not resurrect a stale credential. _COMPANION_CREDENTIALS sits downstream
  79. # of that: it withholds a toggle when the credential it needs was refused, so
  80. # narrowing the predicate would both write a stale credential and quietly make
  81. # that rule inert.
  82. # Settings the MQTT relay reads only when it is (re)configured, so restoring the
  83. # rows is not enough on its own. Mirrors the set the settings PUT handler
  84. # watches. mqtt_password is in here for the configure() payload's sake — the
  85. # credential blocklist means a restore never writes it.
  86. _MQTT_SETTING_KEYS = {
  87. "mqtt_enabled",
  88. "mqtt_broker",
  89. "mqtt_port",
  90. "mqtt_username",
  91. "mqtt_password",
  92. "mqtt_topic_prefix",
  93. "mqtt_use_tls",
  94. }
  95. # Keys that decide *who can reach the instance* rather than how it behaves. The
  96. # backup collector writes them like any other Settings row, so a backup taken
  97. # before auth was turned on carries auth_enabled=false — and a restore reaches
  98. # the table directly, so honouring them would:
  99. #
  100. # * disable authentication outright. ``set_auth_enabled`` pairs its write with
  101. # ``invalidate_auth_enabled_cache()``; we cannot, so the 30 s TTL in
  102. # core.auth is the only thing between the write and an open instance. That
  103. # cache is built to fail closed — writing the stored value behind its back
  104. # is what would make it fail open.
  105. # * bypass the lockout refusals ``update_settings`` enforces (a
  106. # ``local_login_enabled=false`` with no enabled OIDC provider, or with no
  107. # OIDC link on the caller, is a 400 there — #1589).
  108. # * cross a permission boundary: a restore would be a way to rewrite auth
  109. # config without SETTINGS_UPDATE. (The endpoint gates each category on the
  110. # permission owning its rows now, but that is settings:update — still not
  111. # the auth UI's own guards, which is what these keys actually need.)
  112. #
  113. # Auth is reconfigured through the auth UI, which has the guards. Restoring it
  114. # from a snapshot has no safe reading.
  115. _PROTECTED_SETTING_KEYS = {
  116. "auth_enabled",
  117. "advanced_auth_enabled",
  118. "local_login_enabled",
  119. "setup_completed",
  120. }
  121. # The LDAP family, refused for the same reason and by prefix rather than by
  122. # name, so a key added to the schema later is refused by default.
  123. #
  124. # These are not "how the instance behaves" settings — together they name *which
  125. # directory server decides who you are*. auth.py reads them live from this table
  126. # on every login (see the ldap_keys list in _get_ldap_settings), so a restore
  127. # that writes them substitutes the authentication source wholesale:
  128. # ldap_server_url points at another directory, ldap_auto_provision creates a
  129. # local account for whoever it vouches for, and ldap_default_group decides what
  130. # that account gets — Administrators, if the backup says so.
  131. #
  132. # The companion rule does NOT cover this, which is the trap. ldap_enabled is
  133. # paired with ldap_bind_password there, but an *anonymous* bind is a working
  134. # config, so a backup that simply omits the password skips the refusal at the
  135. # _COMPANION_EXPOSURE_TOGGLES check and the toggle is written. Omitting a
  136. # credential is exactly what an attacker authoring this file would do — they own
  137. # the directory being pointed at, so they need no bind credential from us.
  138. _PROTECTED_SETTING_PREFIXES = ("ldap_",)
  139. # Restoring as a non-admin: the settings only an admin may change through the
  140. # settings page (ADMIN_ONLY_SETTING_KEYS) and the mail server's, which only an
  141. # admin may set (routes/auth.update_smtp_settings), are kept as they are here.
  142. # A restore is otherwise a way around those checks. Set by preview() and
  143. # run_restore() for the call; an admin's restore is unchanged.
  144. _restoring_as_admin: ContextVar[bool] = ContextVar("restoring_as_admin", default=True)
  145. def _admin_only_for_restore(key: str) -> bool:
  146. return key in ADMIN_ONLY_SETTING_KEYS or key in _EXTRA_RESTORABLE_KEYS
  147. # Nozzle diameters the backup collector iterates. A path outside this set means
  148. # the backup was written by a newer version, so accept it rather than dropping
  149. # data, but keep the list for validation messages.
  150. _KNOWN_NOZZLES = {"0.2", "0.4", "0.6", "0.8"}
  151. def _parse_dt(value) -> datetime | None:
  152. """Best-effort parse of a datetime the backup wrote via ``str(...)``.
  153. Normalised to naive UTC, because that is what every ``DateTime`` column
  154. here holds: the models write ``datetime.now(timezone.utc)`` into naive
  155. columns and both dialects drop the offset on the way in. Carrying an aware
  156. value through would store the wrong wall clock, and comparing one against a
  157. value read back out of a naive column raises ``TypeError``. The collector
  158. only ever writes naive strings, so this is a guard on hand-edited or
  159. foreign backups rather than a path Bambuddy takes itself.
  160. """
  161. if not value or not isinstance(value, str):
  162. return None
  163. try:
  164. parsed = datetime.fromisoformat(value)
  165. except ValueError:
  166. return None
  167. if parsed.tzinfo is not None:
  168. parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
  169. return parsed
  170. def _created_at_matches(row, created_at: datetime | None) -> bool:
  171. """Does ``row.created_at`` equal a timestamp read out of a backup?
  172. Compared in Python, not in SQL, and that is the whole point. Every
  173. ``created_at`` these callers dedupe on is ``server_default=func.now()``, so
  174. SQLite fills it from ``CURRENT_TIMESTAMP``, which has second precision and
  175. stores ``'2026-08-02 11:28:41'``. SQLAlchemy binds a Python datetime as
  176. ``'2026-08-02 11:28:41.000000'``, and SQLite compares the two as strings —
  177. so ``Model.created_at == created_at`` never matches a row the application
  178. itself created, not even when handed that row's own value straight back.
  179. Every dedupe keyed on it misses, and the restore inserts a duplicate of
  180. everything instead of recognising what is already there.
  181. Reading the candidates back and comparing the parsed datetimes sidesteps
  182. the bind format entirely, and is equally correct on PostgreSQL (where the
  183. column keeps microseconds and the SQL comparison happened to work).
  184. """
  185. return created_at is not None and row.created_at == created_at
  186. def _is_blocked_setting_key(key: str) -> bool:
  187. return is_secret_setting_key(key)
  188. def _is_protected_setting_key(key: str) -> bool:
  189. # Lowered for the prefix test for the same reason _is_blocked_setting_key
  190. # lowers: the key comes from the backup's JSON, not from our own writer, so
  191. # its casing is whatever the file says. An exact-match name stays exact —
  192. # those four are ours and are only ever written lowercase.
  193. return key in _PROTECTED_SETTING_KEYS or key.lower().startswith(_PROTECTED_SETTING_PREFIXES)
  194. # A user's email code sign-in switch. Per user, and it decides how that user
  195. # signs in: never taken from a backup, whatever its value.
  196. _TWO_FACTOR_KEY = re.compile(r"^user_\d+_email_2fa_enabled$")
  197. # Settings kept outside AppSettings that a restore writes, each checked as the
  198. # editor that owns it checks it: (model, field) for a field of that editor's
  199. # request model. Anything else outside AppSettings is runtime state, a login's
  200. # account state, automation that needs its own permission (the purge
  201. # schedules), a migration flag or unknown, and is not restored.
  202. _EXTRA_RESTORABLE_KEYS: dict[str, tuple[type[BaseModel], str]] = {
  203. key: (SMTPSettings, key)
  204. for key in (
  205. "smtp_host",
  206. "smtp_port",
  207. "smtp_security",
  208. "smtp_auth_enabled",
  209. "smtp_from_email",
  210. "smtp_from_name",
  211. "smtp_username",
  212. "smtp_use_tls",
  213. )
  214. }
  215. # Settings that decide where Bambuddy sends something: the links in its emails
  216. # (password reset and welcome links carry a sign-in token), the mail server
  217. # itself, the full database backups, printer status, camera snapshots, model
  218. # files, inventory. A backup naming another address would send it there, so a
  219. # value that differs from this instance's is kept as it is here.
  220. _ADDRESS_KEYS = (
  221. "external_url",
  222. "smtp_host",
  223. "smtp_port",
  224. "smtp_security",
  225. "smtp_use_tls",
  226. "local_backup_path",
  227. "mqtt_broker",
  228. "mqtt_port",
  229. "mqtt_use_tls",
  230. "ha_url",
  231. "obico_ml_url",
  232. "bambuddy_internal_url",
  233. "orcaslicer_api_url",
  234. "bambu_studio_api_url",
  235. "spoolman_url",
  236. "manyfold_url",
  237. )
  238. # Restored without a model check: the Manyfold editor checks the URL with
  239. # normalize_url and takes the client id as text.
  240. _MANYFOLD_URL_KEY = "manyfold_url"
  241. _MANYFOLD_CLIENT_ID_KEY = "manyfold_client_id"
  242. # The settings whose saved null (the text "None") the settings reader reads as
  243. # null. Anywhere else it is refused, as the editor refuses a null.
  244. _READS_A_SAVED_NULL = frozenset({"ams_temp_alarm", "default_printer_id", "open_in_slicer"})
  245. # Settings that no longer exist. Older backups still hold them; a restore drops
  246. # them without listing them as skipped.
  247. _RETIRED_SETTING_KEYS = frozenset({"library_archive_mode"})
  248. def is_restorable_setting_key(key: str) -> bool:
  249. """Whether a restore writes this settings key at all (with a value its
  250. editor accepts). The Git backup holds only these, so nothing a restore
  251. would refuse as unknown leaves the instance."""
  252. return (
  253. key in AppSettingsUpdate.model_fields
  254. or key in _EXTRA_RESTORABLE_KEYS
  255. or key in (_MANYFOLD_URL_KEY, _MANYFOLD_CLIENT_ID_KEY)
  256. )
  257. def _stored_form(value: object) -> str:
  258. """A checked value as its editor stores it."""
  259. if isinstance(value, bool):
  260. return "true" if value else "false"
  261. if value is None:
  262. return "None"
  263. return str(value)
  264. def _checked_setting(key: str, value: object) -> tuple[bool | None, str]:
  265. """A restored value checked as its editor checks it.
  266. ``(True, stored form)`` if the editor would accept it, ``(False, "")`` if
  267. not, ``(None, "")`` if no editor writes the key, so it is not restored.
  268. The stored form is what the editor would write: a value the check only
  269. coerced ("587.0", "yes") is written as the editor writes it.
  270. """
  271. candidate = None if value == "None" and key in _READS_A_SAVED_NULL else value
  272. try:
  273. if key in AppSettingsUpdate.model_fields:
  274. checked = getattr(AppSettingsUpdate.model_validate({key: candidate}), key)
  275. # Checked where it is used rather than where it is saved: checked
  276. # here as well, before it is written.
  277. if key == "spoolman_url" and checked:
  278. assert_safe_spoolman_url(str(checked))
  279. return True, _stored_form(checked)
  280. if key in _EXTRA_RESTORABLE_KEYS:
  281. model, field = _EXTRA_RESTORABLE_KEYS[key]
  282. instance = model.__pydantic_validator__.validate_assignment(model.model_construct(), field, candidate)
  283. return True, _stored_form(getattr(instance, field))
  284. if key == _MANYFOLD_URL_KEY:
  285. return True, normalize_url(str(candidate)) if candidate else ""
  286. except ValueError:
  287. return False, ""
  288. if key == _MANYFOLD_CLIENT_ID_KEY:
  289. return True, str(value)
  290. return None, ""
  291. # There used to be an ``_is_skipped_setting_key`` here, the union of the two
  292. # predicates above, shared by the preview and the restore so neither could drift
  293. # from the other. It is gone because a name is no longer enough to decide: the
  294. # third refusal below depends on the payload's *other* values and on local
  295. # database state. ``_plan_settings`` is the shared classifier now, and it covers
  296. # all three reasons.
  297. # Toggles whose *safety* depends on a companion credential that the blocklist
  298. # above refuses to restore. Writing the toggle alone is not a partial restore,
  299. # it is a downgrade:
  300. #
  301. # * prometheus_enabled with no token opens /api/v1/metrics. The route is on
  302. # PUBLIC_API_ROUTES and its own gate is ``if token:`` (api/routes/metrics.py),
  303. # so an empty or absent token means no authentication at all — a full,
  304. # unauthenticated dump of the instance to anyone who can reach the port. On
  305. # an instance that never enabled Prometheus there is no token row, so
  306. # overwrite-off alone is enough to do it.
  307. # * the other four switch an integration on with no way to authenticate to it,
  308. # which breaks the login path (LDAP) or the connection (MQTT, HA).
  309. #
  310. # ldap_enabled is deliberately NOT here. It was, paired with
  311. # ldap_bind_password — but this rule judges availability ("will the integration
  312. # work?"), and that is the wrong question for an authentication source. An
  313. # anonymous bind is a working config, so the pair let a backup omit the password
  314. # and have the toggle written; the whole LDAP family is refused by prefix above
  315. # instead. _is_protected_setting_key runs first in _plan_settings, so leaving the
  316. # entry here would be dead code that reads like coverage.
  317. _COMPANION_CREDENTIALS = {
  318. "prometheus_enabled": "prometheus_token",
  319. "mqtt_enabled": "mqtt_password",
  320. "ha_enabled": "ha_token",
  321. }
  322. # Companion credentials a reader takes from the environment rather than from a
  323. # Settings row. ha_token is the only one: get_homeassistant_settings prefers
  324. # HA_TOKEN over the row, and auto-enables ha_enabled when HA_URL and HA_TOKEN are
  325. # both set, so an env-configured instance has a usable credential and no row.
  326. _COMPANION_CREDENTIAL_ENV = {"ha_token": "HA_TOKEN"}
  327. # The pairs above divide into two classes, because "did the *backup* carry a
  328. # usable credential?" does not mean the same thing for both.
  329. #
  330. # For the availability pairs it is the condition that stops the rule
  331. # over-refusing. An anonymous MQTT broker and an anonymous LDAP bind are working
  332. # configs, so a backup with an empty credential is describing something that
  333. # works, and refusing its toggle would be a false positive. Those pairs only
  334. # matter when the restore would produce a config weaker than *both* the backup
  335. # and the local instance.
  336. #
  337. # For the exposure pair it does not transfer. An empty prometheus_token removes
  338. # /api/v1/metrics' only gate (the route is on PUBLIC_API_ROUTES and its own
  339. # check is ``if token:``), so the exposure is a property of the toggle itself,
  340. # not of a downgrade relative to the backup: a backup taken on an instance that
  341. # enabled Prometheus *without* a token — the field is optional and defaults to
  342. # "" — is the more likely source of one, not the less. So an exposure toggle
  343. # skips this condition and is judged on local state alone.
  344. _COMPANION_EXPOSURE_TOGGLES = frozenset({"prometheus_enabled"})
  345. # Setting -> the credentials it decides the destination of. smtp_use_tls
  346. # decides the SMTP security mode when smtp_security is not set.
  347. _DESTINATION_SECRETS: dict[str, tuple[str, ...]] = {}
  348. for _secret, _fields in {
  349. **SETTINGS_SECRETS,
  350. "smtp_password": (*SMTP_DESTINATIONS, "smtp_use_tls"),
  351. "manyfold_client_secret": ("manyfold_url",),
  352. }.items():
  353. for _field in _fields:
  354. _DESTINATION_SECRETS[_field] = (*_DESTINATION_SECRETS.get(_field, ()), _secret)
  355. def _setting_value_is_true(value: object) -> bool:
  356. """True if a settings *payload* value would be stored as "on".
  357. Deliberately as narrow as ``api.routes.settings.setting_is_true``: a restore
  358. writes ``str(value)`` verbatim and no reader in the codebase treats "1",
  359. "on" or "yes" as on, so restoring one of those cannot switch anything on.
  360. Bool-tolerant because a backup's JSON can carry a real boolean.
  361. """
  362. if isinstance(value, bool):
  363. return value
  364. if value is None:
  365. return False
  366. return str(value).strip().lower() == "true"
  367. def _as_setting_text(value: object) -> str:
  368. """A setting as its row stores it, so a backup value compares with a local one."""
  369. if value is None:
  370. return ""
  371. if isinstance(value, bool) or str(value).lower() in ("true", "false"):
  372. return str(value).lower()
  373. return str(value)
  374. def _is_usable_credential(value: object) -> bool:
  375. """True if a credential value is present and not blank.
  376. A present-but-*blank* ``prometheus_token`` row counts as unusable, because an
  377. empty token is exactly the ``if token:`` hole the companion rule exists to
  378. stop a restore from opening.
  379. """
  380. return value is not None and bool(str(value).strip())
  381. @dataclass(frozen=True)
  382. class _SettingsPlan:
  383. """Which keys of a settings payload will not be written, and why.
  384. Built once, before anything is added to the session, and shared by the
  385. preview and the restore so the two cannot disagree about what a commit will
  386. change. The companion bucket is why this needs a session at all: unlike the
  387. two name-based buckets it depends on local database state.
  388. The buckets are disjoint — a key is classified once, in order. The
  389. destination bucket depends on local state as well.
  390. """
  391. blocked: tuple[str, ...] = ()
  392. protected: tuple[str, ...] = ()
  393. companion: tuple[str, ...] = ()
  394. destination: tuple[str, ...] = ()
  395. two_factor: tuple[str, ...] = ()
  396. invalid: tuple[str, ...] = ()
  397. unknown: tuple[str, ...] = ()
  398. # Bambuddy's own one-shot flags (a leading underscore): refused without a
  399. # word, as every backup carries them.
  400. internal: tuple[str, ...] = ()
  401. address: tuple[str, ...] = ()
  402. # Each key to write, in the form its editor stores.
  403. stored: dict[str, str] = dataclasses_field(default_factory=dict)
  404. @property
  405. def _buckets(self) -> tuple[tuple[str, ...], ...]:
  406. return (
  407. self.blocked,
  408. self.protected,
  409. self.companion,
  410. self.destination,
  411. self.two_factor,
  412. self.invalid,
  413. self.unknown,
  414. self.internal,
  415. self.address,
  416. )
  417. @property
  418. def refused(self) -> frozenset[str]:
  419. return frozenset(key for bucket in self._buckets for key in bucket)
  420. @property
  421. def refused_count(self) -> int:
  422. return sum(len(bucket) for bucket in self._buckets)
  423. @dataclass(frozen=True)
  424. class _Detail:
  425. """A preview caveat, as a translation code plus its English rendering.
  426. Same contract as a note: the client translates ``code`` with ``params`` and
  427. falls back to ``message``.
  428. """
  429. code: str
  430. message: str
  431. params: dict[str, str | int] = dataclasses_field(default_factory=dict)
  432. # The archive editor's own bound on grams (ArchiveUpdate).
  433. _MAX_RESTORED_GRAMS = 100_000
  434. # What a 32-bit integer column takes (PostgreSQL rejects more).
  435. _MAX_RESTORED_INT = 2**31 - 1
  436. def _restored_number(value, *, maximum: float | None = None, integer: bool = False):
  437. """A number from a backup as the database should hold it, or None.
  438. Only a finite real number of at least 0 (and at most ``maximum``) passes:
  439. a damaged or hand-edited backup must not put text, negative or absurd
  440. values into the filament and cost totals.
  441. """
  442. if isinstance(value, bool) or not isinstance(value, (int, float)):
  443. return None
  444. if integer and maximum is None:
  445. maximum = _MAX_RESTORED_INT
  446. try:
  447. if not math.isfinite(value) or value < 0 or (maximum is not None and value > maximum):
  448. return None
  449. return int(value) if integer else float(value)
  450. except OverflowError: # an integer too large for a float
  451. return None
  452. def _runs_of(payload) -> list:
  453. """The prints in a backup's print_runs.json, or none."""
  454. runs = payload.get("runs") if isinstance(payload, dict) else None
  455. return runs if isinstance(runs, list) else []
  456. def _aliases_of(payload) -> dict[int, int]:
  457. """merged archive id -> the archive it went into, from aliases.json."""
  458. aliases = payload.get("aliases") if isinstance(payload, dict) else None
  459. found: dict[int, int] = {}
  460. for alias in aliases if isinstance(aliases, list) else []:
  461. if not isinstance(alias, dict):
  462. continue
  463. merged, into = alias.get("merged_archive_id"), alias.get("archive_id")
  464. if isinstance(merged, int) and isinstance(into, int) and merged != into:
  465. found[merged] = into
  466. return found
  467. # A print's outcome verdict, as the confirm flow writes it.
  468. _RUN_VERDICTS = ("good", "reject")
  469. class _CategoryTally:
  470. """Mutable accumulator matching ``GitHubRestoreCategoryResult``."""
  471. def __init__(self) -> None:
  472. self.restored = 0
  473. self.skipped = 0
  474. self.failed = 0
  475. self.notes: list[dict] = []
  476. def note(self, code: str, message: str, **params) -> None:
  477. """Record a note as a translation code, its params and an English fallback.
  478. Deduped on ``(code, params)`` rather than on the rendered text, which is
  479. the same thing today but keeps two notes that differ only in a printer
  480. name from collapsing into one. Bounded for the reason it always was: the
  481. UI renders every note, so a large backup must not emit one per row.
  482. """
  483. if any(existing["code"] == code and existing["params"] == params for existing in self.notes):
  484. return
  485. if len(self.notes) >= 20:
  486. return
  487. self.notes.append({"code": code, "params": params, "message": message})
  488. def as_dict(self) -> dict:
  489. return {"restored": self.restored, "skipped": self.skipped, "failed": self.failed, "notes": self.notes}
  490. class GitHubRestoreService:
  491. """Reads a backup repository and applies selected categories locally."""
  492. def __init__(self) -> None:
  493. self._running_restore: bool = False
  494. self._progress: str | None = None
  495. self._http_client: httpx.AsyncClient | None = None
  496. # Guards the check-then-set on ``_running_restore``. Without it two
  497. # concurrent POSTs can both observe False before either sets it.
  498. self._lock = asyncio.Lock()
  499. async def _get_client(self) -> httpx.AsyncClient:
  500. if self._http_client is None or self._http_client.is_closed:
  501. self._http_client = httpx.AsyncClient(timeout=60.0)
  502. return self._http_client
  503. @property
  504. def is_running(self) -> bool:
  505. return self._running_restore
  506. @property
  507. def progress(self) -> str | None:
  508. return self._progress
  509. # --- Repository reads --------------------------------------------------
  510. async def list_commits(self, config: GitHubBackupConfig, limit: int = 20) -> dict:
  511. """List recent commits on the configured branch."""
  512. backend = get_provider_backend(config.provider)
  513. client = await self._get_client()
  514. result = await backend.list_commits(
  515. repo_url=config.repository_url,
  516. token=config.access_token,
  517. branch=config.branch,
  518. client=client,
  519. limit=limit,
  520. )
  521. result["branch"] = config.branch
  522. return result
  523. async def _resolve_ref(self, config: GitHubBackupConfig, ref: str) -> tuple[str | None, str, dict | None]:
  524. """Turn ``HEAD`` into a concrete commit SHA.
  525. Done once up front so a preview and the restore that follows it act on
  526. the same commit even if a scheduled backup lands in between.
  527. The third element is the commit entry, when resolving already fetched
  528. one. ``preview`` displays it, and taking it from here means the ``HEAD``
  529. case — by far the common one — costs one ``list_commits`` call rather
  530. than two.
  531. """
  532. if ref and ref.upper() != "HEAD":
  533. return ref, "", None
  534. result = await self.list_commits(config, limit=1)
  535. if not result.get("success"):
  536. return None, result.get("message") or "Could not read the backup repository", None
  537. commits = result.get("commits") or []
  538. if not commits:
  539. return None, f"Branch '{config.branch}' has no commits to restore from", None
  540. return commits[0]["sha"], "", commits[0]
  541. async def _describe_commit(self, config: GitHubBackupConfig, resolved: str) -> dict | None:
  542. """Find the display metadata for one commit SHA.
  543. Two things used to leave ``commit: null`` in a preview, and the second is
  544. the one that bit in practice:
  545. * the commit is older than the 20 the picker lists, so it is not in the
  546. scan at all — that is what ``get_commit`` is for;
  547. * ``REF_PATTERN`` accepts a 7-character ref while providers return the
  548. full 40, so an exact ``==`` never matched an abbreviated SHA *even when
  549. the commit was in the window*. Hence the prefix comparison.
  550. Best-effort throughout: this is a subject line and a date, so a failure
  551. returns None and the preview renders without them rather than failing.
  552. """
  553. commits = (await self.list_commits(config, limit=20)).get("commits") or []
  554. for entry in commits:
  555. sha = entry.get("sha") or ""
  556. if sha == resolved or sha.startswith(resolved) or resolved.startswith(sha):
  557. return entry
  558. backend = get_provider_backend(config.provider)
  559. client = await self._get_client()
  560. result = await backend.get_commit(
  561. repo_url=config.repository_url, token=config.access_token, ref=resolved, client=client
  562. )
  563. return result.get("commit") if result.get("success") else None
  564. def _category_paths(self, category: RestoreCategory, available: list[str]) -> list[str]:
  565. """Return the paths in ``available`` that belong to ``category``."""
  566. if category == RestoreCategory.SETTINGS:
  567. return [p for p in (SETTINGS_PATH,) if p in available]
  568. if category == RestoreCategory.SPOOLS:
  569. return [p for p in (SPOOLS_PATH, SPOOL_USAGE_PATH) if p in available]
  570. if category == RestoreCategory.ARCHIVES:
  571. return [p for p in (ARCHIVES_PATH, RUNS_PATH, ALIASES_PATH) if p in available]
  572. if category == RestoreCategory.KPROFILES:
  573. return sorted(p for p in available if _KPROFILE_PATH_RE.match(p))
  574. return []
  575. @staticmethod
  576. def _parse_json_files(raw: dict[str, str]) -> tuple[dict[str, object], list[str]]:
  577. """Parse each fetched file, collecting paths that failed to parse."""
  578. parsed: dict[str, object] = {}
  579. bad: list[str] = []
  580. for path, text in raw.items():
  581. try:
  582. parsed[path] = json.loads(text)
  583. except (ValueError, TypeError):
  584. bad.append(path)
  585. return parsed, bad
  586. @staticmethod
  587. async def _plan_settings(db: AsyncSession, values: dict) -> _SettingsPlan:
  588. """Classify every key of a settings payload into its refusal bucket.
  589. Keys with an unusable name land in no bucket: they are the restore's
  590. ``failed``, not a refusal, and the preview counts them because the run
  591. will still report on them.
  592. Reads local state, so it must run before anything is added to the
  593. session — otherwise "does this instance already have a credential" would
  594. see the restore's own writes.
  595. """
  596. blocked: list[str] = []
  597. protected: list[str] = []
  598. two_factor: list[str] = []
  599. invalid: list[str] = []
  600. unknown: list[str] = []
  601. internal: list[str] = []
  602. stored: dict[str, str] = {}
  603. # Toggle -> credential for the pairs that survived the payload-only
  604. # conditions and still need local state to judge.
  605. candidates: dict[str, str] = {}
  606. for key, value in values.items():
  607. if not isinstance(key, str) or not key:
  608. continue
  609. if _is_blocked_setting_key(key):
  610. blocked.append(key)
  611. continue
  612. if _is_protected_setting_key(key) or (not _restoring_as_admin.get() and _admin_only_for_restore(key)):
  613. protected.append(key)
  614. continue
  615. if _TWO_FACTOR_KEY.match(key):
  616. two_factor.append(key)
  617. continue
  618. if key.startswith("_"):
  619. internal.append(key)
  620. continue
  621. if key in _RETIRED_SETTING_KEYS:
  622. continue
  623. # A null is skipped by the restore itself, not refused.
  624. if value is not None:
  625. valid, form = _checked_setting(key, value)
  626. if valid is None:
  627. unknown.append(key)
  628. continue
  629. if not valid:
  630. invalid.append(key)
  631. continue
  632. stored[key] = form
  633. credential = _COMPANION_CREDENTIALS.get(key)
  634. if credential is None:
  635. continue
  636. # Turning something *off* is always safe to write.
  637. # Judged as written: a spelling the check reads as on is stored as on.
  638. if not _setting_value_is_true(stored.get(key, value)):
  639. continue
  640. # Expressed as the predicate rather than assumed, so the map cannot
  641. # go quietly inert if the secret hints are ever edited: a credential
  642. # the restore is willing to write travels with its toggle.
  643. if not _is_blocked_setting_key(credential):
  644. continue
  645. # The backup itself carried no credential here. For an availability
  646. # pair that describes a working config — an anonymous MQTT broker and
  647. # an anonymous LDAP bind both are (mqtt_relay.py and ldap_service.py
  648. # pass empty credentials straight through) — so refusing the toggle
  649. # would be a false positive. For an exposure pair a blank credential
  650. # is the hole itself, so the condition is skipped and only local
  651. # state decides. See _COMPANION_EXPOSURE_TOGGLES.
  652. if key not in _COMPANION_EXPOSURE_TOGGLES and not _is_usable_credential(values.get(credential)):
  653. continue
  654. candidates[key] = credential
  655. # Settings that decide where a credential is sent, which the payload
  656. # sets. Judged against local state below.
  657. destinations = {
  658. key: secrets
  659. for key, value in values.items()
  660. if value is not None
  661. and (secrets := _DESTINATION_SECRETS.get(key))
  662. and key not in protected
  663. and key not in invalid
  664. and key not in unknown
  665. and key not in internal
  666. }
  667. addresses = [key for key in _ADDRESS_KEYS if key in stored]
  668. if not candidates and not destinations and not addresses:
  669. return _SettingsPlan(
  670. blocked=tuple(blocked),
  671. protected=tuple(protected),
  672. two_factor=tuple(two_factor),
  673. invalid=tuple(invalid),
  674. unknown=tuple(unknown),
  675. internal=tuple(internal),
  676. stored=stored,
  677. )
  678. # One SELECT covering both halves of every candidate pair.
  679. wanted = set(candidates) | set(candidates.values()) | set(destinations)
  680. wanted |= {secret for secrets in destinations.values() for secret in secrets}
  681. wanted |= set(addresses)
  682. rows = await db.execute(select(Settings).where(Settings.key.in_(wanted)))
  683. local = {row.key: row.value for row in rows.scalars().all()}
  684. companion: list[str] = []
  685. for toggle, credential in candidates.items():
  686. if _is_usable_credential(local.get(credential)):
  687. continue
  688. env_name = _COMPANION_CREDENTIAL_ENV.get(credential)
  689. if env_name and _is_usable_credential(os.environ.get(env_name)):
  690. continue
  691. # Already on locally with no credential: the exposure pre-dates this
  692. # restore, so refusing changes nothing and "left switched off" would
  693. # be a lie.
  694. if _setting_value_is_true(local.get(toggle)):
  695. continue
  696. companion.append(toggle)
  697. # A credential kept here does not follow a destination the backup
  698. # changes, as the settings editor refuses the same change without the
  699. # credential entered again. A token from the environment is the
  700. # deployment's own and may follow, as it may there.
  701. destination: list[str] = []
  702. for key, secrets in destinations.items():
  703. kept = [
  704. secret
  705. for secret in secrets
  706. if _is_usable_credential(local.get(secret))
  707. and not (
  708. (env_name := _COMPANION_CREDENTIAL_ENV.get(secret))
  709. and _is_usable_credential(os.environ.get(env_name))
  710. )
  711. ]
  712. if not kept:
  713. continue
  714. if key in local:
  715. current = local[key]
  716. elif key in AppSettings.model_fields:
  717. current = AppSettings.model_fields[key].get_default(call_default_factory=True)
  718. else:
  719. # No row and no schema default: nothing to compare with, so
  720. # counted as a change.
  721. destination.append(key)
  722. continue
  723. if _as_setting_text(stored.get(key, values[key])) != _as_setting_text(current):
  724. destination.append(key)
  725. # Compared as written with this instance's value: its row, else the
  726. # schema default, else nothing.
  727. address = [
  728. key
  729. for key in addresses
  730. if key not in destination
  731. and _as_setting_text(stored[key])
  732. != _as_setting_text(
  733. local[key]
  734. if key in local
  735. else AppSettings.model_fields[key].get_default(call_default_factory=True)
  736. if key in AppSettings.model_fields
  737. else None
  738. )
  739. ]
  740. for key in address:
  741. del stored[key]
  742. return _SettingsPlan(
  743. blocked=tuple(blocked),
  744. protected=tuple(protected),
  745. companion=tuple(companion),
  746. address=tuple(address),
  747. destination=tuple(destination),
  748. two_factor=tuple(two_factor),
  749. invalid=tuple(invalid),
  750. unknown=tuple(unknown),
  751. internal=tuple(internal),
  752. stored=stored,
  753. )
  754. async def preview(
  755. self, db: AsyncSession, config: GitHubBackupConfig, ref: str = "HEAD", *, as_admin: bool = True
  756. ) -> dict:
  757. """Report which categories a commit contains, and how much is in each.
  758. Takes a session because the settings count depends on local state — see
  759. ``_plan_settings``. ``ref`` stays keyword-friendly for callers.
  760. ``as_admin`` False counts as a non-admin's restore would write.
  761. """
  762. token = _restoring_as_admin.set(as_admin)
  763. try:
  764. return await self._preview(db, config, ref)
  765. finally:
  766. _restoring_as_admin.reset(token)
  767. async def _preview(self, db: AsyncSession, config: GitHubBackupConfig, ref: str) -> dict:
  768. resolved, error, commit_info = await self._resolve_ref(config, ref)
  769. if resolved is None:
  770. return {"success": False, "message": error, "ref": ref, "categories": []}
  771. backend = get_provider_backend(config.provider)
  772. client = await self._get_client()
  773. tree = await backend.list_tree(
  774. repo_url=config.repository_url, token=config.access_token, ref=resolved, client=client
  775. )
  776. if not tree.get("success"):
  777. return {"success": False, "message": tree.get("message") or "Could not list the commit", "ref": resolved}
  778. available: list[str] = tree.get("paths") or []
  779. # One batched read covers metadata plus every category payload.
  780. wanted = [METADATA_PATH] if METADATA_PATH in available else []
  781. for category in RestoreCategory:
  782. wanted.extend(self._category_paths(category, available))
  783. fetched = await backend.fetch_files(
  784. repo_url=config.repository_url,
  785. token=config.access_token,
  786. ref=resolved,
  787. paths=wanted,
  788. client=client,
  789. # The listing above already built this map; without it the GitHub
  790. # family would GET the same recursive tree a second time.
  791. blob_shas=tree.get("blob_shas") or None,
  792. )
  793. if not fetched.get("success"):
  794. return {
  795. "success": False,
  796. "message": fetched.get("message") or "Could not read the commit contents",
  797. "ref": resolved,
  798. }
  799. parsed, bad_paths = self._parse_json_files(fetched.get("files") or {})
  800. metadata = parsed.get(METADATA_PATH)
  801. metadata_version = metadata.get("version") if isinstance(metadata, dict) else None
  802. categories = []
  803. for category in RestoreCategory:
  804. paths = self._category_paths(category, available)
  805. if not paths:
  806. categories.append(
  807. self._category_entry(category, False, 0, _Detail("notPresent", "Not present in this backup commit"))
  808. )
  809. continue
  810. unreadable = [p for p in paths if p in bad_paths]
  811. if unreadable:
  812. joined = ", ".join(unreadable)
  813. categories.append(
  814. self._category_entry(
  815. category,
  816. False,
  817. 0,
  818. _Detail("unreadableJson", f"Unreadable JSON: {joined}", {"paths": joined}),
  819. )
  820. )
  821. continue
  822. count, detail = await self._count_items(db, category, parsed)
  823. categories.append(self._category_entry(category, True, count, detail))
  824. if commit_info is None:
  825. commit_info = await self._describe_commit(config, resolved)
  826. return {
  827. "success": True,
  828. "message": "OK",
  829. "ref": resolved,
  830. "commit": commit_info,
  831. "metadata_version": metadata_version,
  832. "categories": categories,
  833. }
  834. @staticmethod
  835. def _category_entry(category: RestoreCategory, available: bool, item_count: int, detail: _Detail | None) -> dict:
  836. """Shape one ``GitHubRestorePreviewCategory``, translated detail included."""
  837. return {
  838. "category": category,
  839. "available": available,
  840. "item_count": item_count,
  841. "detail": detail.message if detail else None,
  842. "detail_code": detail.code if detail else None,
  843. "detail_params": detail.params if detail else {},
  844. }
  845. async def _count_items(
  846. self, db: AsyncSession, category: RestoreCategory, parsed: dict
  847. ) -> tuple[int, _Detail | None]:
  848. """Count restorable items for ``category`` and describe any caveat."""
  849. if category == RestoreCategory.SETTINGS:
  850. payload = parsed.get(SETTINGS_PATH)
  851. values = payload.get("settings") if isinstance(payload, dict) else None
  852. if not isinstance(values, dict):
  853. return 0, _Detail("settingsNoPayload", "No settings in payload")
  854. # Every refusal is subtracted so the count matches what the restore
  855. # actually writes. The wording calls out the credential ones (what a
  856. # user might expect to come back) and the companion ones (a
  857. # behaviour change worth explaining before it happens); the auth
  858. # policy keys stay unmentioned on purpose.
  859. plan = await self._plan_settings(db, values)
  860. detail = None
  861. not_restorable = len(plan.two_factor) + len(plan.invalid) + len(plan.unknown) + len(plan.address)
  862. if plan.destination:
  863. detail = _Detail(
  864. "settingsDestinationWillKeep",
  865. f"{len(plan.destination)} setting(s) will be left as they are - they would send a credential "
  866. "stored here somewhere else; credentials are never restored",
  867. {"destination": len(plan.destination)},
  868. )
  869. elif not_restorable:
  870. detail = _Detail(
  871. "settingsNotRestorableWillSkip",
  872. f"{not_restorable} setting(s) will be skipped - values Settings would not accept, or keys a "
  873. "backup does not restore",
  874. {"count": not_restorable},
  875. )
  876. elif plan.companion and not plan.blocked:
  877. # An exposure toggle becomes a candidate whether or not the
  878. # backup carried its credential, so this commit can refuse a
  879. # switch without having a single credential-like key to skip —
  880. # "0 credential-like key(s) will be skipped" would read as noise.
  881. detail = _Detail(
  882. "settingsCompanionOnlyWillSkip",
  883. f"{len(plan.companion)} switch(es) will be left off — the credential each one needs "
  884. "cannot be restored from a backup",
  885. {"companion": len(plan.companion)},
  886. )
  887. elif plan.companion:
  888. detail = _Detail(
  889. "settingsCompanionWillSkip",
  890. f"{len(plan.blocked)} credential-like key(s) will be skipped, and "
  891. f"{len(plan.companion)} switch(es) that depend on them will be left off",
  892. {"count": len(plan.blocked), "companion": len(plan.companion)},
  893. )
  894. elif plan.blocked:
  895. detail = _Detail(
  896. "settingsCredentialsWillSkip",
  897. f"{len(plan.blocked)} credential-like keys will be skipped",
  898. {"count": len(plan.blocked)},
  899. )
  900. return len(values) - plan.refused_count, detail
  901. if category == RestoreCategory.SPOOLS:
  902. payload = parsed.get(SPOOLS_PATH)
  903. spools = payload.get("spools") if isinstance(payload, dict) else None
  904. usage_payload = parsed.get(SPOOL_USAGE_PATH)
  905. usage = usage_payload.get("usage_history") if isinstance(usage_payload, dict) else None
  906. # Usage records are counted here, not just described in the detail:
  907. # _restore_spool_usage increments this category's tally, so counting
  908. # only the spools broke restored + skipped + failed == item_count —
  909. # the invariant the settings count is careful to hold. The detail
  910. # breaks the total down rather than adding to it.
  911. count = len(spools) if isinstance(spools, list) else 0
  912. detail = None
  913. if isinstance(usage, list) and usage:
  914. count += len(usage)
  915. detail = _Detail("spoolsUsageCount", f"including {len(usage)} usage records", {"count": len(usage)})
  916. return count, detail
  917. if category == RestoreCategory.ARCHIVES:
  918. payload = parsed.get(ARCHIVES_PATH)
  919. archives = payload.get("archives") if isinstance(payload, dict) else None
  920. count = len(archives) if isinstance(archives, list) else 0
  921. # Prints are tallied with the archives, so counted here too.
  922. runs = _runs_of(parsed.get(RUNS_PATH))
  923. if runs:
  924. return count + len(runs), _Detail(
  925. "archivesRunsCount",
  926. f"including {len(runs)} print(s) - metadata only, 3MF files and thumbnails are not in a Git backup",
  927. {"count": len(runs)},
  928. )
  929. return count, _Detail(
  930. "archivesMetadataOnly", "Metadata only — 3MF files and thumbnails are not in a Git backup"
  931. )
  932. if category == RestoreCategory.KPROFILES:
  933. total = 0
  934. serials = set()
  935. for path, payload in parsed.items():
  936. match = _KPROFILE_PATH_RE.match(path)
  937. if not match or not isinstance(payload, dict):
  938. continue
  939. serials.add(match.group(1))
  940. profiles = payload.get("profiles")
  941. if isinstance(profiles, list):
  942. total += len(profiles)
  943. detail = None
  944. if serials:
  945. detail = _Detail("kprofilesPrinterCount", f"across {len(serials)} printer(s)", {"count": len(serials)})
  946. return total, detail
  947. return 0, None
  948. # --- Restore -----------------------------------------------------------
  949. async def run_restore(
  950. self,
  951. config_id: int,
  952. ref: str,
  953. categories: list[RestoreCategory],
  954. overwrite_existing: bool = False,
  955. *,
  956. as_admin: bool = True,
  957. ) -> dict:
  958. """Apply selected categories from one backup commit.
  959. ``as_admin`` False keeps the admin-only settings as they are here
  960. (see ``_restoring_as_admin``).
  961. """
  962. token = _restoring_as_admin.set(as_admin)
  963. try:
  964. return await self._run_restore(config_id, ref, categories, overwrite_existing)
  965. finally:
  966. _restoring_as_admin.reset(token)
  967. async def _run_restore(
  968. self,
  969. config_id: int,
  970. ref: str,
  971. categories: list[RestoreCategory],
  972. overwrite_existing: bool,
  973. ) -> dict:
  974. # Import locally to avoid a module-level cycle: the backup service takes
  975. # the mirror-image lock against us.
  976. from backend.app.services.github_backup import github_backup_service
  977. # The lock serialises two concurrent restores; the backup side has no
  978. # lock of its own, and relies on this region staying await-free after the
  979. # acquisition. Both flags are plain bools on one event loop, so with no
  980. # suspension point between the two reads and the write, the loop cannot
  981. # slip github_backup.run_backup's mirror-image check in between. Adding an
  982. # `await` below the acquisition and above `self._running_restore = True`
  983. # would let a backup and a restore run at once.
  984. async with self._lock:
  985. if self._running_restore:
  986. return {"success": False, "message": "A restore is already running", "results": {}}
  987. if github_backup_service.is_running:
  988. return {
  989. "success": False,
  990. "message": "A backup is currently running. Wait for it to finish before restoring.",
  991. "results": {},
  992. }
  993. self._running_restore = True
  994. log_id = None
  995. try:
  996. async with async_session() as db:
  997. result = await db.execute(select(GitHubBackupConfig).where(GitHubBackupConfig.id == config_id))
  998. config = result.scalar_one_or_none()
  999. if not config:
  1000. return {"success": False, "message": "Configuration not found", "results": {}}
  1001. self._progress = "Resolving commit..."
  1002. resolved, error, _ = await self._resolve_ref(config, ref)
  1003. if resolved is None:
  1004. return {"success": False, "message": error, "results": {}}
  1005. log = GitHubBackupLog(config_id=config_id, status="running", trigger="restore", commit_sha=resolved)
  1006. db.add(log)
  1007. await db.commit()
  1008. await db.refresh(log)
  1009. log_id = log.id
  1010. # Owned here rather than by _apply so the failure path can see
  1011. # the categories that were already committed when the raise
  1012. # happened. _apply records a tally only after its category's
  1013. # commit, so every entry present is on disk.
  1014. results: dict[str, _CategoryTally] = {}
  1015. try:
  1016. payload, error = await self._read_categories(config, resolved, categories)
  1017. if error:
  1018. raise RuntimeError(error)
  1019. settings_keys_written: set[str] = set()
  1020. await self._apply(
  1021. db, payload, categories, overwrite_existing, settings_keys_written, results=results
  1022. )
  1023. await db.commit()
  1024. # After the commit: this reconnects the relay, which is not
  1025. # something to do on values that could still roll back.
  1026. settings_tally = results.get(RestoreCategory.SETTINGS.value)
  1027. if settings_tally is not None:
  1028. self._progress = "Reconnecting the MQTT relay..."
  1029. await self._reconfigure_mqtt_relay(db, settings_keys_written, settings_tally)
  1030. total_restored = sum(tally.restored for tally in results.values())
  1031. any_failed = any(tally.failed for tally in results.values())
  1032. log.status = "failed" if any_failed and total_restored == 0 else "success"
  1033. log.completed_at = datetime.now(timezone.utc)
  1034. log.files_changed = total_restored
  1035. if any_failed:
  1036. log.error_message = "Some items could not be restored — see the restore result for detail"
  1037. await db.commit()
  1038. return {
  1039. "success": True,
  1040. "message": f"Restored {total_restored} item(s) from {resolved[:7]}",
  1041. "log_id": log_id,
  1042. "ref": resolved,
  1043. "results": {name: tally.as_dict() for name, tally in results.items()},
  1044. }
  1045. except Exception as e:
  1046. # Rolls back the category that was mid-flight. Every category
  1047. # already in ``results`` committed as it finished (see
  1048. # _apply), so those rows survive this — and reporting an
  1049. # empty result over them would tell the user nothing was
  1050. # restored while their archives and spools are on disk.
  1051. logger.exception("Restore failed for config %s ref %s", config_id, resolved)
  1052. await db.rollback()
  1053. committed = sum(tally.restored for tally in results.values())
  1054. log.status = "failed"
  1055. log.completed_at = datetime.now(timezone.utc)
  1056. log.files_changed = committed
  1057. log.error_message = str(e)[:1000]
  1058. await db.commit()
  1059. return {
  1060. "success": False,
  1061. "message": str(e),
  1062. "log_id": log_id,
  1063. "ref": resolved,
  1064. "results": {name: tally.as_dict() for name, tally in results.items()},
  1065. }
  1066. finally:
  1067. self._running_restore = False
  1068. self._progress = None
  1069. async def _read_categories(
  1070. self, config: GitHubBackupConfig, ref: str, categories: list[RestoreCategory]
  1071. ) -> tuple[dict, str]:
  1072. """Fetch and parse just the files the requested categories need."""
  1073. backend = get_provider_backend(config.provider)
  1074. client = await self._get_client()
  1075. self._progress = "Listing backup contents..."
  1076. tree = await backend.list_tree(
  1077. repo_url=config.repository_url, token=config.access_token, ref=ref, client=client
  1078. )
  1079. if not tree.get("success"):
  1080. return {}, tree.get("message") or "Could not list the commit"
  1081. available: list[str] = tree.get("paths") or []
  1082. wanted: list[str] = []
  1083. for category in categories:
  1084. wanted.extend(self._category_paths(category, available))
  1085. if not wanted:
  1086. return {}, "None of the selected categories are present in that commit"
  1087. self._progress = "Downloading backup files..."
  1088. fetched = await backend.fetch_files(
  1089. repo_url=config.repository_url,
  1090. token=config.access_token,
  1091. ref=ref,
  1092. paths=wanted,
  1093. client=client,
  1094. blob_shas=tree.get("blob_shas") or None,
  1095. )
  1096. if not fetched.get("success"):
  1097. return {}, fetched.get("message") or "Could not read the commit contents"
  1098. parsed, bad = self._parse_json_files(fetched.get("files") or {})
  1099. if bad:
  1100. return {}, f"Backup contains unreadable JSON: {', '.join(sorted(bad))}"
  1101. return parsed, ""
  1102. async def _apply(
  1103. self,
  1104. db: AsyncSession,
  1105. payload: dict,
  1106. categories: list[RestoreCategory],
  1107. overwrite: bool,
  1108. settings_keys_written: set[str] | None = None,
  1109. results: dict[str, _CategoryTally] | None = None,
  1110. ) -> dict[str, _CategoryTally]:
  1111. """Apply categories in dependency order and return per-category tallies.
  1112. ``settings_keys_written``, if given, collects the setting keys actually
  1113. written, for the caller's post-commit side effects (see
  1114. ``_reconfigure_mqtt_relay``).
  1115. ``results``, if given, is the caller's own dict rather than a fresh one.
  1116. Each category is committed before it is recorded there, so on a raise
  1117. the caller can report exactly what is already on disk — see the
  1118. per-category commit below.
  1119. """
  1120. results = {} if results is None else results
  1121. archive_id_map: dict[int, int] = {}
  1122. # Every database category commits before the next one starts, and only
  1123. # then is its tally recorded. Two reasons:
  1124. #
  1125. # * SQLite has one writer. Each category is a long run of one SELECT per
  1126. # row or per key — _find_archive, _find_spool, the usage dedupe,
  1127. # _restore_settings — interleaved with autoflushed INSERTs, all inside
  1128. # the open write transaction. A few thousand archives plus a full
  1129. # usage history plausibly passes the 15 s busy_timeout
  1130. # (core/database.py), at which point every concurrent writer in the
  1131. # app fails with "database is locked". This is the same hold the
  1132. # K-profile phase had, arriving by volume rather than by awaiting a
  1133. # sulking printer.
  1134. # * The ordering tolerates it: the only cross-category state is
  1135. # archive_id_map and spool_id_map, both plain dicts in memory, and
  1136. # the session is expire_on_commit=False so nothing reloads.
  1137. #
  1138. # The cost is that a later failure no longer rolls back an earlier
  1139. # category — which is why the tally is recorded after the commit, so
  1140. # run_restore's failure path reports the rows that really landed instead
  1141. # of claiming nothing was restored.
  1142. # Archives first: spool usage history references archive_id.
  1143. if RestoreCategory.ARCHIVES in categories:
  1144. self._progress = "Restoring print archives..."
  1145. tally = _CategoryTally()
  1146. await self._restore_archives(
  1147. db,
  1148. payload.get(ARCHIVES_PATH),
  1149. overwrite,
  1150. tally,
  1151. archive_id_map,
  1152. runs_payload=payload.get(RUNS_PATH),
  1153. aliases_payload=payload.get(ALIASES_PATH),
  1154. )
  1155. await self._restore_runs(db, payload.get(RUNS_PATH), overwrite, tally, archive_id_map)
  1156. await db.commit()
  1157. results[RestoreCategory.ARCHIVES.value] = tally
  1158. if RestoreCategory.SPOOLS in categories:
  1159. self._progress = "Restoring spool inventory..."
  1160. tally = _CategoryTally()
  1161. await self._restore_spools(
  1162. db,
  1163. payload.get(SPOOLS_PATH),
  1164. payload.get(SPOOL_USAGE_PATH),
  1165. overwrite,
  1166. tally,
  1167. archive_id_map,
  1168. )
  1169. await db.commit()
  1170. results[RestoreCategory.SPOOLS.value] = tally
  1171. if RestoreCategory.SETTINGS in categories:
  1172. self._progress = "Restoring app settings..."
  1173. tally = _CategoryTally()
  1174. await self._restore_settings(
  1175. db, payload.get(SETTINGS_PATH), overwrite, tally, keys_written=settings_keys_written
  1176. )
  1177. await db.commit()
  1178. results[RestoreCategory.SETTINGS.value] = tally
  1179. # Last, because it leaves the database and publishes over MQTT.
  1180. if RestoreCategory.KPROFILES in categories:
  1181. # The database categories are already committed by the loop above,
  1182. # and that is load-bearing here rather than tidiness:
  1183. # _restore_kprofiles awaits get_kprofiles per printer per nozzle,
  1184. # which is timeout=5.0 * max_retries=3, i.e. up to ~15 s each against
  1185. # an unresponsive printer. Holding SQLite's writer across that would
  1186. # pass the 15 s busy_timeout on a farm with a couple of sulking
  1187. # printers.
  1188. #
  1189. # The cost is that a K-profile failure no longer rolls back the
  1190. # categories that already succeeded. That is the correct trade
  1191. # anyway: extrusion_cali_set has left for the printer by then and
  1192. # cannot be rolled back either, so a rollback would only have made
  1193. # the database disagree with the hardware.
  1194. self._progress = "Sending K-profiles to printers..."
  1195. tally = _CategoryTally()
  1196. try:
  1197. await self._restore_kprofiles(db, payload, tally)
  1198. except Exception as e:
  1199. # Everything above is committed and cannot be un-committed, so
  1200. # letting this reach run_restore's handler would report
  1201. # "nothing was restored" over durable archive, spool and
  1202. # settings rows — and skip the post-commit MQTT reconfigure,
  1203. # leaving the relay on the pre-restore broker. The K-profile
  1204. # phase is the last thing that runs, so containing it here is
  1205. # what keeps the result honest about what actually landed.
  1206. logger.exception("The K-profile step failed after the database categories were committed")
  1207. # Discards the phase's own read transaction. The rows above went
  1208. # in at the commit two statements up; this only stops a session
  1209. # left in a failed state by a database error from turning the
  1210. # caller's commit into that same false report.
  1211. await db.rollback()
  1212. outstanding = self._kprofile_profile_count(
  1213. content for path, content in payload.items() if _KPROFILE_PATH_RE.match(path)
  1214. )
  1215. outstanding -= tally.restored + tally.skipped + tally.failed
  1216. tally.failed += max(outstanding, 0)
  1217. tally.note(
  1218. "kprofilesStepFailed",
  1219. f"The K-profile step could not be completed: {e}",
  1220. reason=str(e)[:200],
  1221. )
  1222. results[RestoreCategory.KPROFILES.value] = tally
  1223. return results
  1224. # --- Per-category appliers --------------------------------------------
  1225. async def _restore_archives(
  1226. self,
  1227. db: AsyncSession,
  1228. payload,
  1229. overwrite: bool,
  1230. tally: _CategoryTally,
  1231. id_map: dict[int, int],
  1232. *,
  1233. runs_payload=None,
  1234. aliases_payload=None,
  1235. ) -> None:
  1236. archives = payload.get("archives") if isinstance(payload, dict) else None
  1237. if not isinstance(archives, list):
  1238. tally.note("noData", "No data of this kind in this backup")
  1239. return
  1240. # An archive merged into another is not restored as a row of its own:
  1241. # what pointed at it (its prints, spool usage) goes to the archive it
  1242. # went into, as the merge moved them.
  1243. aliases = _aliases_of(aliases_payload)
  1244. # Local archives an entry of this restore already went to.
  1245. claimed: set[int] = set()
  1246. # Its prints' starts recognise an archive printed again since the
  1247. # backup: the archive's own start is its latest print's.
  1248. run_starts: dict[int, set[datetime]] = {}
  1249. for run in _runs_of(runs_payload):
  1250. if isinstance(run, dict) and isinstance(run.get("archive_id"), int):
  1251. started = _parse_dt(run.get("started_at"))
  1252. if started is not None:
  1253. run_starts.setdefault(run["archive_id"], set()).add(started)
  1254. valid_printers = set((await db.execute(select(Printer.id))).scalars().all())
  1255. valid_projects = set((await db.execute(select(Project.id))).scalars().all())
  1256. # Ownership decides visibility, not just attribution: an archive with a
  1257. # NULL created_by_id is a 404 to every caller without archives:read_all
  1258. # (_ensure_archive_visible fails closed on it) and never appears in the
  1259. # ownership-scoped list queries. Hoisted like the two above.
  1260. #
  1261. # username is the natural key and wins, per the module's rule at the top
  1262. # of the file; created_by_id is the fallback for a pre-#2656 commit that
  1263. # carries no username. That ordering is what makes restoring onto a
  1264. # rebuilt instance safe: the users table renumbers there, so a live id
  1265. # can land on a different person, and the id path alone cannot tell that
  1266. # from a correct match. Resolving on the name instead means the one case
  1267. # it cannot resolve — a user renamed since the backup — falls through to
  1268. # ownerless-with-a-note below rather than misattributing in silence.
  1269. users = (await db.execute(select(User.id, User.username))).all()
  1270. valid_users = {user_id for user_id, _ in users}
  1271. users_by_name = {username: user_id for user_id, username in users}
  1272. # Only metadata is backed up, never the 3MF/thumbnail bytes, and
  1273. # print_archives.file_path is NOT NULL — so inserted rows get an empty
  1274. # path and are history-only. Say so once rather than per row.
  1275. warned_files = False
  1276. for entry in archives:
  1277. if not isinstance(entry, dict):
  1278. tally.failed += 1
  1279. continue
  1280. old_id = entry.get("id") if isinstance(entry.get("id"), int) else None
  1281. if old_id in aliases:
  1282. tally.skipped += 1
  1283. tally.note(
  1284. "archivesMergedFollowed",
  1285. "Archives merged into another are not restored as rows of their own - what pointed at them "
  1286. "goes to the archive they were merged into",
  1287. )
  1288. continue
  1289. started_at = _parse_dt(entry.get("started_at"))
  1290. existing = await self._find_archive(
  1291. db, entry, started_at, run_starts.get(old_id, set()) if old_id is not None else ()
  1292. )
  1293. # Merged here since the backup: its prints went into the archive
  1294. # it went into, and so does everything else of this entry. That
  1295. # archive is not overwritten with this one's fields, nor with a
  1296. # second entry's: it holds what the merge joined.
  1297. followed = False
  1298. if existing is not None:
  1299. into = await db.scalar(
  1300. select(ArchiveAlias.archive_id).where(ArchiveAlias.merged_archive_id == existing.id)
  1301. )
  1302. if into is not None:
  1303. existing = await db.get(PrintArchive, into)
  1304. followed = True
  1305. if existing is not None and (followed or existing.id in claimed):
  1306. if old_id is not None:
  1307. id_map[old_id] = existing.id
  1308. tally.skipped += 1
  1309. tally.note(
  1310. "archivesMergedFollowed",
  1311. "Archives merged into another are not restored as rows of their own - what pointed at them "
  1312. "goes to the archive they were merged into",
  1313. )
  1314. continue
  1315. numbers = {
  1316. "print_time_seconds": _restored_number(entry.get("print_time_seconds"), integer=True),
  1317. "filament_used_grams": _restored_number(entry.get("filament_used_grams"), maximum=_MAX_RESTORED_GRAMS),
  1318. "cost": _restored_number(entry.get("cost")),
  1319. "energy_kwh": _restored_number(entry.get("energy_kwh")),
  1320. "energy_cost": _restored_number(entry.get("energy_cost")),
  1321. "wear_cost": _restored_number(entry.get("wear_cost")),
  1322. }
  1323. if any(entry.get(key) is not None and value is None for key, value in numbers.items()):
  1324. tally.note(
  1325. "archivesNumbersDropped",
  1326. "Some archives had grams, costs, energy or print times that are not valid numbers - left empty",
  1327. )
  1328. # 0 is a real quantity (a ruined plate, #3051); the archive editor
  1329. # takes 0..10000. Anything else is the one print it stood for.
  1330. quantity = _restored_number(entry.get("quantity"), maximum=10_000, integer=True)
  1331. if quantity is None:
  1332. quantity = 1
  1333. # A print running when the backup was made is long over: closed
  1334. # here the way the stale-archive cleanup closes one. Restored as
  1335. # running, the next reconnect closed it as a print that had just
  1336. # failed, with a failure notification and a Print Log row (ST/I7).
  1337. status = entry.get("status") or "completed"
  1338. failure_reason = entry.get("failure_reason")
  1339. backed_up_in_flight = status in ("printing", "paused")
  1340. if backed_up_in_flight:
  1341. status, failure_reason = "cancelled", "noStatusUpdate"
  1342. fields = {
  1343. "print_name": entry.get("print_name"),
  1344. "print_time_seconds": numbers["print_time_seconds"],
  1345. "filament_used_grams": numbers["filament_used_grams"],
  1346. "filament_type": entry.get("filament_type"),
  1347. "filament_color": entry.get("filament_color"),
  1348. "layer_height": entry.get("layer_height"),
  1349. "total_layers": entry.get("total_layers"),
  1350. "nozzle_diameter": entry.get("nozzle_diameter"),
  1351. "bed_temperature": entry.get("bed_temperature"),
  1352. "nozzle_temperature": entry.get("nozzle_temperature"),
  1353. "sliced_for_model": entry.get("sliced_for_model"),
  1354. "status": status,
  1355. "started_at": started_at,
  1356. "completed_at": _parse_dt(entry.get("completed_at")),
  1357. "makerworld_url": entry.get("makerworld_url"),
  1358. "designer": entry.get("designer"),
  1359. "external_url": entry.get("external_url"),
  1360. "is_favorite": bool(entry.get("is_favorite")),
  1361. "tags": entry.get("tags"),
  1362. "notes": entry.get("notes"),
  1363. "cost": numbers["cost"],
  1364. "failure_reason": failure_reason,
  1365. "quantity": quantity,
  1366. "energy_kwh": numbers["energy_kwh"],
  1367. "energy_cost": numbers["energy_cost"],
  1368. "wear_cost": numbers["wear_cost"],
  1369. }
  1370. printer_id = entry.get("printer_id")
  1371. if printer_id is not None and printer_id not in valid_printers:
  1372. tally.note(
  1373. "archivesPrinterMissing", "Some archives referenced printers that no longer exist — link cleared"
  1374. )
  1375. printer_id = None
  1376. project_id = entry.get("project_id")
  1377. if project_id is not None and project_id not in valid_projects:
  1378. tally.note(
  1379. "archivesProjectMissing", "Some archives referenced projects that no longer exist — link cleared"
  1380. )
  1381. project_id = None
  1382. fields["printer_id"] = printer_id
  1383. fields["project_id"] = project_id
  1384. # The ownership pair and deleted_at are the late arrivals — a backup
  1385. # commit taken before the collector wrote them carries neither key.
  1386. # Absent is NOT the same as null here, because the overwrite branch
  1387. # below is a blanket setattr: treating a missing key as None would
  1388. # write NULL over a live owner (_ensure_archive_visible then 404s the
  1389. # archive for the very user who owns it — the failure carrying the
  1390. # column was added to fix) and silently un-delete a row the user
  1391. # deleted. So only carry a column the backup actually knows about;
  1392. # on insert, an absent key just takes the model default.
  1393. # An owner the backup names but this instance cannot resolve is the
  1394. # same epistemic state as an absent key — we do not know who owns
  1395. # this archive — so it takes the same action: the column is left out
  1396. # of ``fields`` entirely rather than set to None. Writing NULL there
  1397. # would take the owner away from a local archive that has a perfectly
  1398. # good one, which is the 404-for-its-own-owner failure this column is
  1399. # carried across to fix, and it would do it on the overwrite path
  1400. # where there is a local answer to keep. On insert there is nothing
  1401. # to keep, so the row takes the model default and lands ownerless,
  1402. # which is what the note says.
  1403. owner_cleared = False
  1404. backup_username = entry.get("created_by_username")
  1405. if isinstance(backup_username, str) and backup_username:
  1406. # The natural-key path. A miss here is a user renamed or deleted
  1407. # since the backup, and there is nothing else to resolve on: the
  1408. # id alongside it is from the source instance's numbering, so
  1409. # trusting it is exactly the misattribution the name is here to
  1410. # prevent. Not a reason to fail the row — the archive is still
  1411. # worth having, and an admin can reassign it — but said out loud
  1412. # on insert, because an ownerless archive is not silent-safe.
  1413. created_by_id = users_by_name.get(backup_username)
  1414. if created_by_id is None:
  1415. if existing is None:
  1416. tally.note(
  1417. "archivesOwnerUnmatched",
  1418. "Some archives name an owner this instance does not have — owner cleared rather than "
  1419. "guessed from the backup's user id, so they are visible only to users with the "
  1420. "archives:read_all permission until an admin reassigns them",
  1421. )
  1422. owner_cleared = True
  1423. else:
  1424. fields["created_by_id"] = created_by_id
  1425. elif "created_by_id" in entry:
  1426. # Fallback for a commit taken before the collector recorded the
  1427. # username. Validated rather than trusted, so a *stale* id is
  1428. # dropped instead of pointing somewhere wrong; a live id
  1429. # belonging to a different person on a rebuilt instance is the
  1430. # case this path cannot see, and is why the branch above exists.
  1431. # An explicit null is not a miss — the backup is saying the
  1432. # archive had no owner — so it is written, and overwrite keeps
  1433. # meaning "make the local row match the backup".
  1434. created_by_id = entry.get("created_by_id")
  1435. if created_by_id is not None and created_by_id not in valid_users:
  1436. if existing is None:
  1437. tally.note(
  1438. "archivesOwnerCleared",
  1439. "Some archives referenced users that no longer exist — owner cleared, so they are "
  1440. "visible only to users with the archives:read_all permission until an admin "
  1441. "reassigns them",
  1442. )
  1443. owner_cleared = True
  1444. else:
  1445. fields["created_by_id"] = created_by_id
  1446. if "deleted_at" in entry:
  1447. # A soft-deleted archive is still in the backup (its row is kept
  1448. # so stats keep counting it), so carry the flag across or the
  1449. # restore turns something the user deleted back into a visible
  1450. # archive.
  1451. fields["deleted_at"] = _parse_dt(entry.get("deleted_at"))
  1452. if existing is not None:
  1453. claimed.add(existing.id)
  1454. if old_id is not None:
  1455. id_map[old_id] = existing.id
  1456. if not overwrite:
  1457. tally.skipped += 1
  1458. continue
  1459. # Overwrite means "make the local row match the backup", which
  1460. # includes un-deleting one the user deleted after the backup was
  1461. # taken. Legitimate, but not obvious from a restored/skipped
  1462. # count, so say it.
  1463. if existing.deleted_at is not None and "deleted_at" in fields and fields["deleted_at"] is None:
  1464. tally.note(
  1465. "archivesUndeleted",
  1466. "Archive(s) deleted since the backup are visible again — overwrite was on",
  1467. )
  1468. if backed_up_in_flight:
  1469. # The local row is that same print, still running or since
  1470. # ended: it knows more about its outcome than the backup's
  1471. # snapshot of it mid-way. Closing it here would end a live
  1472. # print's archive, or overwrite a real result.
  1473. for key in ("status", "failure_reason", "completed_at"):
  1474. fields.pop(key, None)
  1475. for key, value in fields.items():
  1476. setattr(existing, key, value)
  1477. tally.restored += 1
  1478. continue
  1479. if not warned_files:
  1480. tally.note(
  1481. "archivesMetadataOnly",
  1482. "Restored archives carry metadata only — the 3MF and thumbnail files are not in a Git backup",
  1483. )
  1484. warned_files = True
  1485. # Insert-only, and the mirror of the rule above: an owner the backup
  1486. # cannot tell us is never written, so on overwrite the local one
  1487. # survives — but there is no local row here to fall back on, so the
  1488. # archive lands ownerless, a 404 for everyone without
  1489. # archives:read_all. Three ways to get here: a commit taken before
  1490. # the collector recorded the column (every pre-#2656 backup), an
  1491. # archive that genuinely had no owner on the source instance, or one
  1492. # whose owner this instance cannot resolve. All restore fine and all
  1493. # were silent, so the tally said "N archives restored" while the user
  1494. # who asked for them saw none. The unresolved cases above already
  1495. # said their piece; don't say it twice for the same row.
  1496. if fields.get("created_by_id") is None and not owner_cleared:
  1497. tally.note(
  1498. "archivesOwnerUnknown",
  1499. "Some archives were restored without an owner — this backup does not record one, so they "
  1500. "are visible only to users with the archives:read_all permission until an admin reassigns "
  1501. "them",
  1502. )
  1503. row = PrintArchive(
  1504. filename=entry.get("filename") or "restored-from-backup",
  1505. file_path="",
  1506. file_size=entry.get("file_size") or 0,
  1507. content_hash=entry.get("content_hash"),
  1508. **fields,
  1509. )
  1510. created_at = _parse_dt(entry.get("created_at"))
  1511. if created_at is not None:
  1512. row.created_at = created_at
  1513. db.add(row)
  1514. await db.flush()
  1515. claimed.add(row.id)
  1516. if old_id is not None:
  1517. id_map[old_id] = row.id
  1518. tally.restored += 1
  1519. for merged, into in aliases.items():
  1520. if into in id_map:
  1521. id_map[merged] = id_map[into]
  1522. async def _find_archive(
  1523. self, db: AsyncSession, entry: dict, started_at: datetime | None, run_starts: Iterable[datetime] = ()
  1524. ) -> PrintArchive | None:
  1525. """Match a backed-up archive to a local row by natural key.
  1526. ``started_at`` is nullable and genuinely NULL for a whole class of rows —
  1527. the re-slice path in ``library.py`` constructs ``PrintArchive`` without
  1528. one — so it cannot be *required* by the key. It narrows the match instead:
  1529. a backed-up row with no ``started_at`` matches a local row that has none
  1530. either. Requiring it meant those archives never matched, so each restore
  1531. re-inserted them as duplicates and overwrite mode could never update them.
  1532. ``content_hash`` identifies the sliced file on its own, which is why it is
  1533. the branch allowed to run without a ``started_at``; ``filename`` is too
  1534. weak for that (re-slices share it) and still requires one. Two backed-up
  1535. rows sharing a hash *and* having no ``started_at`` are indistinguishable
  1536. in the backup, so they collapse onto one local row — better than
  1537. duplicating both on every restore.
  1538. Soft-deleted rows are matched deliberately: there is no ``deleted_at``
  1539. filter here because the row still exists, and matching it is what stops a
  1540. restore inserting a live duplicate of an archive the user has deleted.
  1541. """
  1542. created_at = _parse_dt(entry.get("created_at"))
  1543. # A print of it starting then, here or in the backup: printed again
  1544. # since, the archive's own start is a later print's (archive redesign
  1545. # P3).
  1546. starts = set(run_starts) | ({started_at} if started_at else set())
  1547. async def best(where, *, own_start: bool) -> PrintArchive | None:
  1548. rows = (await db.execute(select(PrintArchive).where(where).order_by(PrintArchive.id))).scalars().all()
  1549. if not rows:
  1550. return None
  1551. # Its own row first, by its own start: one merged since is then
  1552. # followed by the caller rather than taken for its survivor.
  1553. if own_start:
  1554. for row in rows:
  1555. if row.started_at == started_at:
  1556. return row
  1557. if starts:
  1558. # Core table, so a print running now counts too.
  1559. runs = PrintLogEntry.__table__.c
  1560. printed = set(
  1561. (
  1562. await db.execute(
  1563. select(runs.archive_id).where(
  1564. runs.archive_id.in_([row.id for row in rows]), runs.started_at.in_(starts)
  1565. )
  1566. )
  1567. )
  1568. .scalars()
  1569. .all()
  1570. )
  1571. for row in rows:
  1572. if row.id in printed:
  1573. return row
  1574. # When it was archived, which no print changes: an archive printed
  1575. # since, without a start or print in the backup to go by.
  1576. return next((row for row in rows if _created_at_matches(row, created_at)), None)
  1577. content_hash = entry.get("content_hash")
  1578. if content_hash:
  1579. row = await best(PrintArchive.content_hash == content_hash, own_start=True)
  1580. if row is not None:
  1581. return row
  1582. filename = entry.get("filename")
  1583. if filename:
  1584. return await best(PrintArchive.filename == filename, own_start=started_at is not None)
  1585. return None
  1586. async def _restore_runs(
  1587. self,
  1588. db: AsyncSession,
  1589. payload,
  1590. overwrite: bool,
  1591. tally: _CategoryTally,
  1592. id_map: dict[int, int],
  1593. ) -> None:
  1594. """Each print of the restored archives (archive redesign P3).
  1595. After the archives, through their ``old_id -> new_id`` map. A print
  1596. already here is the same archive's at the same start on the same
  1597. plate; it is left alone, or made to match the backup with overwrite.
  1598. Each archive that gained or changed a print then takes its latest
  1599. print's outcome, as everywhere else. A backup written before prints
  1600. were backed up has none, and its archives restore as before.
  1601. """
  1602. from backend.app.services.print_log import mirror_latest_run
  1603. runs = _runs_of(payload)
  1604. if not runs:
  1605. return
  1606. valid_printers = set((await db.execute(select(Printer.id))).scalars().all())
  1607. users_by_name = {name: user_id for user_id, name in (await db.execute(select(User.id, User.username))).all()}
  1608. touched: set[int] = set()
  1609. for entry in runs:
  1610. if not isinstance(entry, dict):
  1611. tally.failed += 1
  1612. continue
  1613. backup_archive = entry.get("archive_id")
  1614. archive_id = None
  1615. if backup_archive is not None:
  1616. archive_id = id_map.get(backup_archive) if isinstance(backup_archive, int) else None
  1617. if archive_id is None:
  1618. # Its archive is not in this restore (it failed, or the
  1619. # backup is damaged): there is nothing to attach it to.
  1620. tally.skipped += 1
  1621. tally.note("runsArchiveMissing", "Some prints were skipped - their archive is not in this restore")
  1622. continue
  1623. started_at = _parse_dt(entry.get("started_at"))
  1624. created_at = _parse_dt(entry.get("created_at"))
  1625. plate_id = entry.get("plate_id") if isinstance(entry.get("plate_id"), int) else None
  1626. existing = await self._find_run(db, archive_id, started_at, plate_id, created_at, entry)
  1627. numbers = {
  1628. "duration_seconds": _restored_number(entry.get("duration_seconds"), integer=True),
  1629. "filament_used_grams": _restored_number(entry.get("filament_used_grams"), maximum=_MAX_RESTORED_GRAMS),
  1630. "cost": _restored_number(entry.get("cost")),
  1631. "energy_kwh": _restored_number(entry.get("energy_kwh")),
  1632. "energy_cost": _restored_number(entry.get("energy_cost")),
  1633. "wear_cost": _restored_number(entry.get("wear_cost")),
  1634. "quantity": _restored_number(entry.get("quantity"), maximum=10_000, integer=True),
  1635. }
  1636. if any(entry.get(key) is not None and value is None for key, value in numbers.items()):
  1637. tally.note(
  1638. "runsNumbersDropped",
  1639. "Some prints had grams, costs, energy or durations that are not valid numbers - left empty",
  1640. )
  1641. # A print running when the backup was made is long over: closed
  1642. # as its archive is (see _restore_archives).
  1643. status = entry.get("status") if isinstance(entry.get("status"), str) else "completed"
  1644. failure_reason = entry.get("failure_reason")
  1645. in_flight = status in (RUN_OPEN, "paused")
  1646. if in_flight:
  1647. status, failure_reason = "cancelled", "noStatusUpdate"
  1648. verdict = entry.get("user_verdict") if entry.get("user_verdict") in _RUN_VERDICTS else None
  1649. printer_id = entry.get("printer_id")
  1650. if printer_id is not None and printer_id not in valid_printers:
  1651. tally.note(
  1652. "runsPrinterMissing",
  1653. "Some prints referenced printers that no longer exist - link cleared, their printer name kept",
  1654. )
  1655. printer_id = None
  1656. username = entry.get("created_by_username")
  1657. owner_name = entry.get("owner_username")
  1658. fields = {
  1659. "archive_id": archive_id,
  1660. "print_name": entry.get("print_name"),
  1661. "plate_id": plate_id,
  1662. "printer_id": printer_id,
  1663. "printer_name": entry.get("printer_name"),
  1664. "status": status,
  1665. "started_at": started_at,
  1666. "completed_at": _parse_dt(entry.get("completed_at")),
  1667. "duration_unmeasured": entry.get("duration_unmeasured"),
  1668. "ended_offline": entry.get("ended_offline"),
  1669. "filament_type": entry.get("filament_type"),
  1670. "filament_color": entry.get("filament_color"),
  1671. "failure_reason": failure_reason,
  1672. "user_verdict": verdict,
  1673. "user_verdict_source": entry.get("user_verdict_source") if verdict else None,
  1674. "user_verdict_at": _parse_dt(entry.get("user_verdict_at")) if verdict else None,
  1675. "notes": entry.get("notes"),
  1676. "created_by_username": username if isinstance(username, str) else None,
  1677. **numbers,
  1678. }
  1679. # By name, never the backup's id, and only the owner's: the
  1680. # recorded name stays when its user is deleted, and may be someone
  1681. # else's by now. A name this instance does not have is left out,
  1682. # as for archives: a local print keeps its owner, a new one has
  1683. # none and keeps the name.
  1684. owner = users_by_name.get(owner_name) if isinstance(owner_name, str) else None
  1685. if owner is not None or not isinstance(owner_name, str):
  1686. fields["created_by_id"] = owner
  1687. if existing is not None:
  1688. # The local print knows more than the backup's snapshot of it
  1689. # mid-way (its figures came at its end), and one printing now
  1690. # is still being written by its print.
  1691. if not overwrite or in_flight or existing.status == RUN_OPEN:
  1692. tally.skipped += 1
  1693. continue
  1694. for key, value in fields.items():
  1695. setattr(existing, key, value)
  1696. tally.restored += 1
  1697. else:
  1698. row = PrintLogEntry(**fields)
  1699. if created_at is not None:
  1700. # Statistics date a print by it.
  1701. row.created_at = created_at
  1702. db.add(row)
  1703. await db.flush()
  1704. tally.restored += 1
  1705. if archive_id is not None:
  1706. touched.add(archive_id)
  1707. for archive_id in touched:
  1708. await mirror_latest_run(db, archive_id)
  1709. async def _find_run(
  1710. self,
  1711. db: AsyncSession,
  1712. archive_id: int | None,
  1713. started_at: datetime | None,
  1714. plate_id: int | None,
  1715. created_at: datetime | None,
  1716. entry: dict,
  1717. ) -> PrintLogEntry | None:
  1718. """The same print already here: its archive's, started then, on that
  1719. plate. Without a start, by when it was logged (compared in Python, see
  1720. _created_at_matches); one without an archive by its printer and name
  1721. too. A print running now counts."""
  1722. query = (
  1723. select(PrintLogEntry)
  1724. .where(
  1725. PrintLogEntry.archive_id == archive_id
  1726. if archive_id is not None
  1727. else PrintLogEntry.archive_id.is_(None),
  1728. PrintLogEntry.plate_id == plate_id if plate_id is not None else PrintLogEntry.plate_id.is_(None),
  1729. PrintLogEntry.started_at == started_at if started_at else PrintLogEntry.started_at.is_(None),
  1730. )
  1731. .order_by(PrintLogEntry.id)
  1732. .execution_options(**{INCLUDE_OPEN_RUNS: True})
  1733. )
  1734. if archive_id is None:
  1735. query = query.where(
  1736. PrintLogEntry.printer_name == entry.get("printer_name"),
  1737. PrintLogEntry.print_name == entry.get("print_name"),
  1738. )
  1739. candidates = (await db.execute(query)).scalars().all()
  1740. if started_at:
  1741. return candidates[0] if candidates else None
  1742. return next((row for row in candidates if _created_at_matches(row, created_at)), None)
  1743. async def _restore_spools(
  1744. self,
  1745. db: AsyncSession,
  1746. inventory,
  1747. usage_payload,
  1748. overwrite: bool,
  1749. tally: _CategoryTally,
  1750. archive_id_map: dict[int, int],
  1751. ) -> None:
  1752. spools = inventory.get("spools") if isinstance(inventory, dict) else None
  1753. if not isinstance(spools, list):
  1754. usage = usage_payload.get("usage_history") if isinstance(usage_payload, dict) else None
  1755. if isinstance(usage, list) and usage:
  1756. # A Spoolman-mode backup: usage of Spoolman spools, no built-in
  1757. # spools to go with it (#3407).
  1758. await self._restore_spool_usage(db, usage_payload, tally, {}, archive_id_map)
  1759. return
  1760. tally.note("noData", "No data of this kind in this backup")
  1761. return
  1762. spool_id_map: dict[int, int] = {}
  1763. tags_kept = 0
  1764. for entry in spools:
  1765. if not isinstance(entry, dict):
  1766. tally.failed += 1
  1767. continue
  1768. old_id = entry.get("id") if isinstance(entry.get("id"), int) else None
  1769. existing, matched_on = await self._find_spool(db, entry)
  1770. fields = {
  1771. "material": entry.get("material") or "PLA",
  1772. "subtype": entry.get("subtype"),
  1773. "color_name": entry.get("color_name"),
  1774. "rgba": entry.get("rgba"),
  1775. "brand": entry.get("brand"),
  1776. "label_weight": entry.get("label_weight") or 1000,
  1777. "core_weight": entry.get("core_weight") or 250,
  1778. "weight_used": entry.get("weight_used") or 0,
  1779. "weight_locked": bool(entry.get("weight_locked")),
  1780. "slicer_filament": entry.get("slicer_filament"),
  1781. "slicer_filament_name": entry.get("slicer_filament_name"),
  1782. "nozzle_temp_min": entry.get("nozzle_temp_min"),
  1783. "nozzle_temp_max": entry.get("nozzle_temp_max"),
  1784. "note": entry.get("note"),
  1785. "cost_per_kg": entry.get("cost_per_kg"),
  1786. "tag_uid": entry.get("tag_uid"),
  1787. "tray_uuid": entry.get("tray_uuid"),
  1788. "data_origin": entry.get("data_origin"),
  1789. "tag_type": entry.get("tag_type"),
  1790. "archived_at": _parse_dt(entry.get("archived_at")),
  1791. }
  1792. # The user's own bookkeeping on the spool: purchasing number
  1793. # (#2870), category and low-stock override (#729), free-text
  1794. # storage. Added to the backup format after the fields above, so
  1795. # they are applied only when the file actually carries them —
  1796. # an older backup must not wipe what the live row holds.
  1797. # `location_id` is deliberately absent: the locations table is
  1798. # not backed up, so the ID would point at whatever owns it here.
  1799. for late_field in ("material_number", "category", "low_stock_threshold_pct", "storage_location"):
  1800. if late_field in entry:
  1801. fields[late_field] = entry[late_field]
  1802. # The "Reset Total Consumed" point (WU-7), same late-field rule; a
  1803. # value that is no amount of filament keeps what the row holds.
  1804. baseline = _restored_number(entry.get("weight_used_baseline"))
  1805. if baseline is not None:
  1806. fields["weight_used_baseline"] = baseline
  1807. # When it was last used (LU-4), same late-field rule.
  1808. if "last_used" in entry:
  1809. fields["last_used"] = _parse_dt(entry.get("last_used"))
  1810. # Last drying (#2863), same late-field rule.
  1811. if "last_dried_at" in entry:
  1812. fields["last_dried_at"] = _parse_dt(entry.get("last_dried_at"))
  1813. fields["last_dried_temp"] = entry.get("last_dried_temp")
  1814. fields["last_dried_hours"] = entry.get("last_dried_hours")
  1815. if existing is not None:
  1816. if old_id is not None:
  1817. spool_id_map[old_id] = existing.id
  1818. if not overwrite:
  1819. tally.skipped += 1
  1820. continue
  1821. tags_kept += await self._guard_tag_overwrite(db, existing, fields, matched_on)
  1822. for key, value in fields.items():
  1823. setattr(existing, key, value)
  1824. tally.restored += 1
  1825. continue
  1826. row = Spool(**fields)
  1827. # Carry the original created_at across. Without it the row would be
  1828. # stamped "now", and the composite fallback in _find_spool (which
  1829. # keys on created_at) would miss on a second restore and insert a
  1830. # duplicate instead of matching.
  1831. created_at = _parse_dt(entry.get("created_at"))
  1832. if created_at is not None:
  1833. row.created_at = created_at
  1834. db.add(row)
  1835. await db.flush()
  1836. if old_id is not None:
  1837. spool_id_map[old_id] = row.id
  1838. tally.restored += 1
  1839. if tags_kept:
  1840. tally.note(
  1841. "spoolTagKept",
  1842. f"{tags_kept} spool tag(s) left as they are — the backup would have cleared a tag that "
  1843. "has since been scanned, or moved one onto a second spool.",
  1844. count=tags_kept,
  1845. )
  1846. await self._restore_spool_usage(db, usage_payload, tally, spool_id_map, archive_id_map)
  1847. async def _find_spool(self, db: AsyncSession, entry: dict) -> tuple[Spool | None, str | None]:
  1848. """Match a backed-up spool to a local row, and say which key matched.
  1849. Physical identity first (an RFID/Bambu tag is the spool), then a
  1850. descriptive composite including ``created_at`` so two otherwise
  1851. identical spools added at different times stay distinct.
  1852. The second element names the column that matched — ``"tag_uid"``,
  1853. ``"tray_uuid"`` or ``None`` for the composite. ``_guard_tag_overwrite``
  1854. needs it: the matched column holds the incoming value by definition, so
  1855. it is the *other* one that overwrite can corrupt.
  1856. """
  1857. tag_uid = entry.get("tag_uid")
  1858. if tag_uid:
  1859. result = await db.execute(select(Spool).where(Spool.tag_uid == tag_uid))
  1860. row = result.scalars().first()
  1861. if row is not None:
  1862. return row, "tag_uid"
  1863. tray_uuid = entry.get("tray_uuid")
  1864. if tray_uuid:
  1865. result = await db.execute(select(Spool).where(Spool.tray_uuid == tray_uuid))
  1866. row = result.scalars().first()
  1867. if row is not None:
  1868. return row, "tray_uuid"
  1869. created_at = _parse_dt(entry.get("created_at"))
  1870. if created_at is None:
  1871. return None, None
  1872. # created_at is filtered in Python, not here — see _created_at_matches.
  1873. result = await db.execute(
  1874. select(Spool).where(
  1875. Spool.material == (entry.get("material") or "PLA"),
  1876. Spool.brand == entry.get("brand"),
  1877. Spool.subtype == entry.get("subtype"),
  1878. Spool.color_name == entry.get("color_name"),
  1879. )
  1880. )
  1881. for row in result.scalars():
  1882. if _created_at_matches(row, created_at):
  1883. return row, None
  1884. return None, None
  1885. @staticmethod
  1886. async def _guard_tag_overwrite(db: AsyncSession, existing: Spool, fields: dict, matched_on: str | None) -> int:
  1887. """Remove tag columns from ``fields`` that an overwrite would corrupt.
  1888. ``tag_uid`` and ``tray_uuid`` are both in ``fields`` and overwrite is a
  1889. blanket ``setattr`` loop, so a spool matched on one key gets the backup's
  1890. *other* key written onto it. Neither column has a unique constraint
  1891. (``models/spool.py``, and no unique index in the migrations), so nothing
  1892. errors — a duplicate tag simply appears, after which ``_find_spool``'s
  1893. ``.first()`` is non-deterministic and an AMS tag lookup resolves to an
  1894. arbitrary one of the two spools. The same loop can also *clear* a tag the
  1895. user has scanned since the backup was taken, when the backup entry holds
  1896. ``None``.
  1897. Two refusals, and the row is otherwise overwritten as normal:
  1898. * the incoming value is empty and the local row has one — the backup
  1899. predates the scan, so the local tag is the newer fact;
  1900. * the incoming value is already held by a different local spool — writing
  1901. it would create the duplicate described above.
  1902. Returns how many columns were left alone, so the caller can say so in the
  1903. tally rather than doing it silently.
  1904. """
  1905. kept = 0
  1906. for column in ("tag_uid", "tray_uuid"):
  1907. # The column we matched on already holds the incoming value.
  1908. if column == matched_on:
  1909. continue
  1910. incoming = fields.get(column)
  1911. current = getattr(existing, column)
  1912. if incoming == current:
  1913. continue
  1914. if not incoming:
  1915. if current:
  1916. fields.pop(column)
  1917. kept += 1
  1918. continue
  1919. clash = await db.execute(
  1920. select(Spool.id).where(getattr(Spool, column) == incoming, Spool.id != existing.id)
  1921. )
  1922. if clash.scalars().first() is not None:
  1923. fields.pop(column)
  1924. kept += 1
  1925. return kept
  1926. async def _restore_spool_usage(
  1927. self,
  1928. db: AsyncSession,
  1929. usage_payload,
  1930. tally: _CategoryTally,
  1931. spool_id_map: dict[int, int],
  1932. archive_id_map: dict[int, int],
  1933. ) -> None:
  1934. usage = usage_payload.get("usage_history") if isinstance(usage_payload, dict) else None
  1935. if not isinstance(usage, list) or not usage:
  1936. return
  1937. valid_printers = set((await db.execute(select(Printer.id))).scalars().all())
  1938. unresolved = 0
  1939. unlinked_archives = 0
  1940. for entry in usage:
  1941. if not isinstance(entry, dict):
  1942. tally.failed += 1
  1943. continue
  1944. old_spool_id = entry.get("spool_id")
  1945. spool_id = spool_id_map.get(old_spool_id) if isinstance(old_spool_id, int) else None
  1946. # Usage of a spool deleted before the backup has no spool, and is
  1947. # restored without one; only a spool id that resolves to nothing is skipped.
  1948. if spool_id is None and old_spool_id is not None:
  1949. # The parent spool never made it into the map: the backup's spool
  1950. # list didn't include it, or its entry carried no integer id. A
  1951. # spool that was merely *skipped* (matched locally, overwrite off)
  1952. # is mapped a few lines up in _restore_spools, so it never lands
  1953. # here — which is why the note below offers no remedy.
  1954. unresolved += 1
  1955. tally.skipped += 1
  1956. continue
  1957. created_at = _parse_dt(entry.get("created_at"))
  1958. # Usage history has no natural key of its own, so dedupe on the
  1959. # tuple that makes a consumption event unique in practice. As in
  1960. # _find_spool, created_at is compared in Python — see
  1961. # _created_at_matches. An entry carrying no created_at at all
  1962. # cannot be recognised and is re-inserted, which is what the
  1963. # IS NULL comparison this replaced did too: the column is
  1964. # non-nullable, so it never matched either.
  1965. # Spoolman's ids are its own, so they come back as they were.
  1966. spoolman_spool_id = entry.get("spoolman_spool_id")
  1967. if not isinstance(spoolman_spool_id, int) or isinstance(spoolman_spool_id, bool):
  1968. spoolman_spool_id = None
  1969. existing = await db.execute(
  1970. select(SpoolUsageHistory).where(
  1971. SpoolUsageHistory.spool_id == spool_id,
  1972. SpoolUsageHistory.spoolman_spool_id == spoolman_spool_id,
  1973. SpoolUsageHistory.weight_used == (entry.get("weight_used") or 0),
  1974. SpoolUsageHistory.print_name == entry.get("print_name"),
  1975. )
  1976. )
  1977. if any(_created_at_matches(row, created_at) for row in existing.scalars()):
  1978. tally.skipped += 1
  1979. continue
  1980. printer_id = entry.get("printer_id")
  1981. if printer_id is not None and printer_id not in valid_printers:
  1982. printer_id = None
  1983. old_archive_id = entry.get("archive_id")
  1984. archive_id = archive_id_map.get(old_archive_id) if isinstance(old_archive_id, int) else None
  1985. if archive_id is None and isinstance(old_archive_id, int):
  1986. # Restoring spools without archives leaves archive_id_map empty,
  1987. # so every "this print consumed that spool" link is dropped — the
  1988. # local archive may well exist, but its payload wasn't fetched,
  1989. # so there is no natural key here to match it on. Nor is it
  1990. # repairable by a later archives-only restore: the dedupe key
  1991. # above doesn't include archive_id, so these rows are recognised
  1992. # as already-present and skipped. Worth telling the user while
  1993. # they can still redo the run with both categories ticked.
  1994. unlinked_archives += 1
  1995. weight_used = _restored_number(entry.get("weight_used"))
  1996. percent_used = _restored_number(entry.get("percent_used"), integer=True)
  1997. cost = _restored_number(entry.get("cost"))
  1998. if any(
  1999. entry.get(key) is not None and value is None
  2000. for key, value in (("weight_used", weight_used), ("percent_used", percent_used), ("cost", cost))
  2001. ):
  2002. tally.note(
  2003. "usageNumbersDropped",
  2004. "Some usage entries had weights or costs that are not valid numbers - left empty",
  2005. )
  2006. row = SpoolUsageHistory(
  2007. spool_id=spool_id,
  2008. spoolman_spool_id=spoolman_spool_id,
  2009. printer_id=printer_id,
  2010. print_name=entry.get("print_name"),
  2011. archive_id=archive_id,
  2012. weight_used=weight_used or 0,
  2013. percent_used=percent_used or 0,
  2014. status=entry.get("status") or "completed",
  2015. cost=cost,
  2016. )
  2017. if created_at is not None:
  2018. row.created_at = created_at
  2019. db.add(row)
  2020. tally.restored += 1
  2021. if unresolved:
  2022. tally.note(
  2023. "spoolUsageUnresolved",
  2024. f"{unresolved} usage record(s) skipped — their spool is not in this backup's "
  2025. "spool list, so there is nothing to attach them to.",
  2026. count=unresolved,
  2027. )
  2028. if unlinked_archives:
  2029. tally.note(
  2030. "spoolUsageUnlinked",
  2031. f"{unlinked_archives} usage record(s) restored without their print-history link — "
  2032. "select Print archives alongside Spool inventory to keep it.",
  2033. count=unlinked_archives,
  2034. )
  2035. async def _restore_settings(
  2036. self,
  2037. db: AsyncSession,
  2038. payload,
  2039. overwrite: bool,
  2040. tally: _CategoryTally,
  2041. keys_written: set[str] | None = None,
  2042. ) -> None:
  2043. values = payload.get("settings") if isinstance(payload, dict) else None
  2044. if not isinstance(values, dict):
  2045. tally.note("noData", "No data of this kind in this backup")
  2046. return
  2047. # Planned before the first write, so the companion rule reads genuinely
  2048. # pre-restore local state, and so the preview and this run classify the
  2049. # payload identically.
  2050. plan = await self._plan_settings(db, values)
  2051. refused = plan.refused
  2052. for key, value in values.items():
  2053. if not isinstance(key, str) or not key:
  2054. tally.failed += 1
  2055. continue
  2056. if key in refused:
  2057. # Refusals are reported in the notes and nowhere else. They are
  2058. # already outside the preview's item count, and the preview is
  2059. # the number the user was shown, so counting them here would
  2060. # make restored + skipped + failed exceed it. The two skips
  2061. # below stay counted because they depend on this run's flags,
  2062. # which the preview cannot see.
  2063. continue
  2064. if value is None:
  2065. tally.skipped += 1
  2066. continue
  2067. result = await db.execute(select(Settings).where(Settings.key == key))
  2068. existing = result.scalar_one_or_none()
  2069. if existing is not None:
  2070. if not overwrite:
  2071. tally.skipped += 1
  2072. continue
  2073. existing.value = plan.stored[key]
  2074. tally.restored += 1
  2075. if keys_written is not None:
  2076. keys_written.add(key)
  2077. continue
  2078. db.add(Settings(key=key, value=plan.stored[key]))
  2079. tally.restored += 1
  2080. if keys_written is not None:
  2081. keys_written.add(key)
  2082. # A backup taken before a printer delete still lists it, and SQLite gives
  2083. # its id to the next printer created.
  2084. await drop_printers_from_obico(db)
  2085. if plan.blocked:
  2086. tally.note(
  2087. "settingsCredentialsSkipped",
  2088. f"{len(plan.blocked)} credential-like key(s) skipped — re-enter secrets manually",
  2089. count=len(plan.blocked),
  2090. )
  2091. if plan.protected:
  2092. tally.note(
  2093. "settingsAuthSkipped",
  2094. f"{len(plan.protected)} authentication setting(s) skipped — change those in Settings > "
  2095. "Authentication so the lockout checks still run",
  2096. count=len(plan.protected),
  2097. )
  2098. if plan.companion:
  2099. keys = ", ".join(sorted(plan.companion))
  2100. tally.note(
  2101. "settingsCompanionSkipped",
  2102. f"{keys} left switched off — the credential each one needs cannot be restored from a "
  2103. "backup and this instance has none stored, so switching them on would leave the "
  2104. "integration unauthenticated",
  2105. keys=keys,
  2106. count=len(plan.companion),
  2107. )
  2108. if plan.two_factor:
  2109. tally.note(
  2110. "settingsTwoFactorSkipped",
  2111. f"{len(plan.two_factor)} email sign-in code setting(s) skipped - each user switches email codes "
  2112. "on or off in their own account",
  2113. count=len(plan.two_factor),
  2114. )
  2115. if plan.address:
  2116. keys = ", ".join(sorted(plan.address))
  2117. tally.note(
  2118. "settingsAddressKept",
  2119. f"{keys} left as they are - they decide where Bambuddy sends data, emails or backups; change them "
  2120. "in Settings",
  2121. keys=keys,
  2122. count=len(plan.address),
  2123. )
  2124. if plan.invalid:
  2125. keys = ", ".join(sorted(plan.invalid))
  2126. tally.note(
  2127. "settingsInvalidSkipped",
  2128. f"{keys} skipped - the value in the backup is not one Settings would accept",
  2129. keys=keys,
  2130. count=len(plan.invalid),
  2131. )
  2132. if plan.unknown:
  2133. keys = ", ".join(sorted(plan.unknown))
  2134. tally.note(
  2135. "settingsUnknownSkipped",
  2136. f"{keys} skipped - not settings a backup restores (runtime state, account sessions or unknown keys)",
  2137. keys=keys,
  2138. count=len(plan.unknown),
  2139. )
  2140. if plan.destination:
  2141. keys = ", ".join(sorted(plan.destination))
  2142. tally.note(
  2143. "settingsDestinationKept",
  2144. f"{keys} left as they are - this instance has a stored credential that would otherwise be sent "
  2145. "to the address in the backup. Change them in Settings and enter the credential again.",
  2146. keys=keys,
  2147. count=len(plan.destination),
  2148. )
  2149. async def _reconfigure_mqtt_relay(self, db: AsyncSession, keys_written: set[str], tally: _CategoryTally) -> None:
  2150. """Push restored mqtt_* settings into the live relay.
  2151. The relay reads its broker config once, at configure() time — the
  2152. settings PUT handler reconfigures it for exactly this reason
  2153. (api/routes/settings.py). Writing the rows alone left the relay on the
  2154. pre-restore broker until the next backend restart while the UI showed
  2155. the restored values, which is the one way a restore could look applied
  2156. and not be.
  2157. Called after the commit, never before: configure() tears the connection
  2158. down and rebuilds it, so it must not run against values a later failure
  2159. could roll back. Only mqtt_password can't come back this way (the
  2160. credential blocklist skips it) — the row already in the database is
  2161. reused, so an unchanged broker keeps working.
  2162. """
  2163. if not _MQTT_SETTING_KEYS & keys_written:
  2164. return
  2165. try:
  2166. from backend.app.services.mqtt_relay import mqtt_relay
  2167. rows = await db.execute(select(Settings).where(Settings.key.in_(_MQTT_SETTING_KEYS)))
  2168. stored = {s.key: s.value for s in rows.scalars().all()}
  2169. # Same shape and defaults the settings PUT handler builds.
  2170. await mqtt_relay.configure(
  2171. {
  2172. "mqtt_enabled": (stored.get("mqtt_enabled") or "false") == "true",
  2173. "mqtt_broker": stored.get("mqtt_broker") or "",
  2174. "mqtt_port": int(stored.get("mqtt_port") or "1883"),
  2175. "mqtt_username": stored.get("mqtt_username") or "",
  2176. "mqtt_password": stored.get("mqtt_password") or "",
  2177. "mqtt_topic_prefix": stored.get("mqtt_topic_prefix") or "bambuddy",
  2178. "mqtt_use_tls": (stored.get("mqtt_use_tls") or "false") == "true",
  2179. }
  2180. )
  2181. except Exception:
  2182. # Same call is best-effort in the settings PUT handler: the rows are
  2183. # committed either way, and a broker that refuses the new config
  2184. # must not turn a successful restore into a failed one. Noted rather
  2185. # than swallowed silently, so the user knows to restart.
  2186. logger.warning("Could not reconfigure the MQTT relay after a settings restore", exc_info=True)
  2187. tally.note(
  2188. "settingsMqttRelayFailed",
  2189. "MQTT settings restored, but the relay could not be reconnected — restart Bambuddy",
  2190. )
  2191. async def _restore_kprofiles(self, db: AsyncSession, payload: dict, tally: _CategoryTally) -> None:
  2192. by_serial: dict[str, list[tuple[str, dict]]] = {}
  2193. for path, content in payload.items():
  2194. match = _KPROFILE_PATH_RE.match(path)
  2195. if not match or not isinstance(content, dict):
  2196. continue
  2197. by_serial.setdefault(match.group(1), []).append((match.group(2), content))
  2198. if not by_serial:
  2199. tally.note("noData", "No data of this kind in this backup")
  2200. return
  2201. result = await db.execute(select(Printer))
  2202. printers = {p.serial_number: p for p in result.scalars().all() if p.serial_number}
  2203. # Overwrite is not offered for K-profiles: extrusion_cali_set replaces
  2204. # the profile occupying a slot, so writing is always an overwrite on the
  2205. # printer side.
  2206. tally.note("kprofilesAlwaysOverwrite", "K-profiles always overwrite the matching slot on the printer")
  2207. # A refusal is now believed and counted failed (#2718 made the ack worth
  2208. # reading), but silence still counts restored, so the caveat stands —
  2209. # narrowed to what is actually left uncertain.
  2210. tally.note(
  2211. "kprofilesAckUnreliable",
  2212. "A printer that does not confirm the write still counts as restored — verify the profiles on the printer",
  2213. )
  2214. for serial, entries in sorted(by_serial.items()):
  2215. profile_total = self._kprofile_profile_count(c for _, c in entries)
  2216. printer = printers.get(serial)
  2217. if printer is None:
  2218. tally.skipped += profile_total
  2219. tally.note("kprofilesPrinterMissing", f"No printer with serial {serial} — skipped", serial=serial)
  2220. continue
  2221. client = printer_manager.get_client(printer.id)
  2222. if not client or not client.state.connected:
  2223. tally.skipped += profile_total
  2224. tally.note(
  2225. "kprofilesPrinterOffline",
  2226. f"{printer.name} ({serial}) is not connected — skipped",
  2227. printer=printer.name,
  2228. serial=serial,
  2229. )
  2230. continue
  2231. for nozzle, content in sorted(entries):
  2232. profiles = content.get("profiles")
  2233. if not isinstance(profiles, list) or not profiles:
  2234. continue
  2235. if nozzle not in _KNOWN_NOZZLES:
  2236. tally.note(
  2237. "kprofilesUnknownNozzle",
  2238. f"Unexpected nozzle diameter {nozzle} for {serial} — sent as-is",
  2239. nozzle=nozzle,
  2240. serial=serial,
  2241. )
  2242. # The backup's slot_id is a cali_idx, and cali_idx is as
  2243. # unstable as the autoincrement ids we already refuse to reuse
  2244. # for spools and archives: editing a profile in Bambuddy is a
  2245. # delete-then-add on a single-nozzle printer, which re-keys it.
  2246. # Addressing extrusion_cali_set at a slot that no longer exists
  2247. # is a silent no-op — the printer drops it and we would still
  2248. # report the profile restored. So resolve the live index first.
  2249. current = await self._current_kprofile_index(client, nozzle, serial)
  2250. if current is None:
  2251. # Unread is not empty: matching against nothing would send
  2252. # every profile as a new one beside the originals still on
  2253. # the printer (#3404). Nothing is sent.
  2254. tally.failed += len(profiles)
  2255. tally.note(
  2256. "kprofilesReadFailed",
  2257. f"{printer.name} ({serial}) did not answer the K-profile read for {nozzle} - not restored",
  2258. nozzle=nozzle,
  2259. printer=printer.name,
  2260. serial=serial,
  2261. )
  2262. continue
  2263. profile_dicts = []
  2264. unmatched = 0
  2265. # A live profile can only stand in for one backed-up entry. Two
  2266. # entries resolving to the same cali_idx both go into the batch,
  2267. # the second overwrites the first on the printer, and the tally
  2268. # counts two restored where one landed.
  2269. claimed: set[int] = set()
  2270. for p in profiles:
  2271. if not isinstance(p, dict):
  2272. # Counted, not dropped. _kprofile_profile_count includes
  2273. # it, so the offline and printer-missing paths already
  2274. # count the same entry skipped and the failure path
  2275. # counts it outstanding — leaving the tally here was the
  2276. # one place a profile could vanish from
  2277. # restored + skipped + failed entirely.
  2278. #
  2279. # failed here against skipped there is not a
  2280. # disagreement about the entry. The three counters say
  2281. # what happened to an item on this run, not whether it
  2282. # was ever usable: an offline printer skips everything it
  2283. # holds, well-formed or not, because nothing was
  2284. # attempted, while here the entry was reached and could
  2285. # not be used.
  2286. tally.failed += 1
  2287. continue
  2288. match = self._match_kprofile(p, current, claimed)
  2289. if match is None:
  2290. unmatched += 1
  2291. else:
  2292. claimed.add(match.slot_id)
  2293. entry = {
  2294. "filament_id": p.get("filament_id", ""),
  2295. "name": p.get("name", ""),
  2296. "k_value": p.get("k_value", "0.020000"),
  2297. "extruder_id": p.get("extruder_id", 0),
  2298. # Prefer the live setting_id when we matched: it is
  2299. # what the printer currently associates with the slot.
  2300. "setting_id": (match.setting_id if match else None) or p.get("setting_id"),
  2301. # cali_idx -1 tells the printer to add a new profile
  2302. # rather than address a slot that isn't there.
  2303. "cali_idx": match.slot_id if match else -1,
  2304. # Only consulted for the generated-setting_id
  2305. # fallback; cali_idx above takes precedence.
  2306. "slot_id": 0,
  2307. }
  2308. # Same precedence as setting_id, and set only when known.
  2309. # nozzle_id encodes the fitted nozzle's type and diameter
  2310. # ("HS00-0.4"), so the live value beats the backup's: the
  2311. # user may have swapped the nozzle since. When neither knows,
  2312. # the key has to be *absent* — set_kprofiles_batch supplies
  2313. # HS00-{diameter} via p.get(..., default), which a key
  2314. # present-and-None defeats, publishing a null nozzle_id.
  2315. # Printers that omit it are the reason the default is there
  2316. # (#1748), so it has to be reachable.
  2317. nozzle_id = (getattr(match, "nozzle_id", None) if match else None) or p.get("nozzle_id")
  2318. if nozzle_id:
  2319. entry["nozzle_id"] = nozzle_id
  2320. profile_dicts.append(entry)
  2321. if not profile_dicts:
  2322. continue
  2323. if unmatched:
  2324. tally.note(
  2325. "kprofilesUnmatched",
  2326. f"{unmatched} profile(s) for {nozzle} had no counterpart on {printer.name} "
  2327. "— added as new profiles",
  2328. count=unmatched,
  2329. nozzle=nozzle,
  2330. printer=printer.name,
  2331. )
  2332. try:
  2333. seq = client.set_kprofiles_batch(profile_dicts, nozzle)
  2334. except Exception as e:
  2335. logger.warning("K-profile restore failed for %s nozzle %s: %s", serial, nozzle, e)
  2336. seq = None
  2337. if not seq:
  2338. tally.failed += len(profile_dicts)
  2339. tally.note(
  2340. "kprofilesSendFailed",
  2341. f"Failed to send {nozzle} profiles to {printer.name} ({serial})",
  2342. nozzle=nozzle,
  2343. printer=printer.name,
  2344. serial=serial,
  2345. )
  2346. continue
  2347. # What came back is the sequence_id the command was published
  2348. # under, not a verdict (#2718) — a truthy string only means the
  2349. # command left the building. The printer answers separately, and
  2350. # every other caller of this API now reads that answer; without
  2351. # this the restore would be the one path left that reports a
  2352. # refused write as saved.
  2353. ok, detail = await self._kprofile_ack(client, seq, serial, nozzle)
  2354. if ok:
  2355. tally.restored += len(profile_dicts)
  2356. else:
  2357. tally.failed += len(profile_dicts)
  2358. tally.note(
  2359. "kprofilesRefused",
  2360. f"{printer.name} ({serial}) refused the {nozzle} profiles: {detail}",
  2361. nozzle=nozzle,
  2362. printer=printer.name,
  2363. serial=serial,
  2364. reason=detail,
  2365. )
  2366. @staticmethod
  2367. def _kprofile_profile_count(contents) -> int:
  2368. """Count the profiles across parsed K-profile files.
  2369. Defensive on purpose. A hand-edited or truncated backup can carry a
  2370. ``profiles`` value that is not a list, and this count runs *before* the
  2371. per-call guards in the loop below — after ``_apply`` has already
  2372. committed the database categories. A malformed file has to be a skipped
  2373. category, not an exception thrown over committed rows.
  2374. """
  2375. total = 0
  2376. for content in contents:
  2377. profiles = content.get("profiles") if isinstance(content, dict) else None
  2378. if isinstance(profiles, list):
  2379. total += len(profiles)
  2380. return total
  2381. @staticmethod
  2382. async def _kprofile_ack(client, seq: str, serial: str, nozzle: str) -> tuple[bool, str]:
  2383. """Read the printer's verdict on one batch write.
  2384. ``await_cali_ack`` already treats silence as success — no answer is not
  2385. evidence of refusal, and firmware that predates the ack never answers at
  2386. all. An exception reading it is the same situation one layer up, so it
  2387. degrades the same way rather than turning a write that most likely
  2388. landed into a reported failure.
  2389. """
  2390. try:
  2391. ok, detail = await client.await_cali_ack(seq)
  2392. return bool(ok), str(detail or "")
  2393. except Exception as e:
  2394. logger.warning("Could not read the K-profile ack for %s nozzle %s: %s", serial, nozzle, e)
  2395. return True, ""
  2396. @staticmethod
  2397. async def _current_kprofile_index(client, nozzle: str, serial: str) -> list | None:
  2398. """Read the printer's live profiles for one nozzle, or None when the
  2399. read failed.
  2400. A failed read used to be "nothing matched", which made every profile
  2401. an add next to the ones still on the printer (#3404). The caller skips
  2402. the nozzle instead; the rest of the restore goes on.
  2403. """
  2404. try:
  2405. return list(await client.get_kprofiles(nozzle_diameter=nozzle) or [])
  2406. except Exception as e:
  2407. logger.warning("Could not read live K-profiles for %s nozzle %s: %s", serial, nozzle, e)
  2408. return None
  2409. @staticmethod
  2410. def _match_kprofile(entry: dict, current: list, claimed: set[int]):
  2411. """Find the live profile a backed-up entry corresponds to.
  2412. ``setting_id`` is the filament preset the profile was calibrated for and
  2413. is the strongest signal; a delete-then-add edit regenerates it, so fall
  2414. back to the display name, which Bambuddy's own editor preserves.
  2415. Both are scoped by ``filament_id`` — the same preset on a different
  2416. filament is a different profile — and by ``extruder_id``, because on a
  2417. dual-nozzle printer the same preset on the other extruder is a different
  2418. profile too.
  2419. ``claimed`` holds the slot ids already taken by earlier entries in this
  2420. nozzle's loop, and no live profile may be claimed twice. Without it, two
  2421. backed-up entries sharing a ``filament_id`` and matching on neither
  2422. ``setting_id`` nor ``name`` both fell through to the single-candidate
  2423. arm and both took the same slot — reachable whenever the user has since
  2424. deleted one of a pair, because the delete-then-add re-key is what strips
  2425. the ``setting_id`` match. Returning None for the displaced entry means
  2426. ``cali_idx: -1``, i.e. add-as-new, which is the safe outcome.
  2427. """
  2428. filament_id = entry.get("filament_id")
  2429. if not filament_id:
  2430. return None
  2431. candidates = [c for c in current if c.filament_id == filament_id]
  2432. # The live index is read per nozzle *diameter*, so on an H2D both
  2433. # extruders' profiles come back together. With the same filament
  2434. # calibrated on both — the ordinary case on a dual-nozzle printer, not an
  2435. # exotic one — filament_id alone lets extruder 0's backed-up entry match
  2436. # extruder 1's live profile, and the batch then carries
  2437. # {extruder_id: 0, cali_idx: <extruder-1 slot>}: one extruder's
  2438. # calibration written over the other's, counted restored.
  2439. #
  2440. # Conditional on both sides saying which extruder they mean. A pre-#2656
  2441. # backup carries no extruder_id, and a live index that reports none must
  2442. # not turn every entry into an add.
  2443. extruder_id = entry.get("extruder_id")
  2444. if isinstance(extruder_id, int) and any(getattr(c, "extruder_id", None) is not None for c in candidates):
  2445. candidates = [c for c in candidates if getattr(c, "extruder_id", None) == extruder_id]
  2446. available = [c for c in candidates if c.slot_id not in claimed]
  2447. if not available:
  2448. return None
  2449. setting_id = entry.get("setting_id")
  2450. if setting_id:
  2451. for c in available:
  2452. if c.setting_id == setting_id:
  2453. return c
  2454. name = entry.get("name")
  2455. if name:
  2456. for c in available:
  2457. if c.name == name:
  2458. return c
  2459. # Exactly one profile for this filament and no better discriminator:
  2460. # treat it as the same profile rather than duplicating it. Judged
  2461. # against every candidate rather than the unclaimed ones, because two
  2462. # live profiles for one filament are ambiguous whether or not another
  2463. # entry has already taken one of them.
  2464. return available[0] if len(candidates) == 1 else None
  2465. # Singleton instance
  2466. github_restore_service = GitHubRestoreService()