settings.py 118 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562
  1. import io
  2. import json
  3. import zipfile
  4. from datetime import datetime
  5. from pathlib import Path
  6. from fastapi import APIRouter, Depends, File, Query, UploadFile
  7. from fastapi.responses import JSONResponse, StreamingResponse
  8. from sqlalchemy import select
  9. from sqlalchemy.ext.asyncio import AsyncSession
  10. from backend.app.core.config import settings as app_settings
  11. from backend.app.core.database import get_db
  12. from backend.app.models.api_key import APIKey
  13. from backend.app.models.archive import PrintArchive
  14. from backend.app.models.external_link import ExternalLink
  15. from backend.app.models.filament import Filament
  16. from backend.app.models.github_backup import GitHubBackupConfig
  17. from backend.app.models.group import Group
  18. from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
  19. from backend.app.models.notification import NotificationProvider
  20. from backend.app.models.notification_template import NotificationTemplate
  21. from backend.app.models.pending_upload import PendingUpload
  22. from backend.app.models.print_queue import PrintQueueItem
  23. from backend.app.models.printer import Printer
  24. from backend.app.models.project import Project
  25. from backend.app.models.project_bom import ProjectBOMItem
  26. from backend.app.models.settings import Settings
  27. from backend.app.models.smart_plug import SmartPlug
  28. from backend.app.models.user import User
  29. from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
  30. from backend.app.services.printer_manager import printer_manager
  31. from backend.app.services.spoolman import init_spoolman_client
  32. router = APIRouter(prefix="/settings", tags=["settings"])
  33. # Default settings
  34. DEFAULT_SETTINGS = AppSettings()
  35. async def get_setting(db: AsyncSession, key: str) -> str | None:
  36. """Get a single setting value by key."""
  37. result = await db.execute(select(Settings).where(Settings.key == key))
  38. setting = result.scalar_one_or_none()
  39. return setting.value if setting else None
  40. async def set_setting(db: AsyncSession, key: str, value: str) -> None:
  41. """Set a single setting value."""
  42. from sqlalchemy import func
  43. from sqlalchemy.dialects.sqlite import insert as sqlite_insert
  44. # Use upsert (INSERT ... ON CONFLICT UPDATE) for reliability
  45. stmt = sqlite_insert(Settings).values(key=key, value=value)
  46. stmt = stmt.on_conflict_do_update(index_elements=["key"], set_={"value": value, "updated_at": func.now()})
  47. await db.execute(stmt)
  48. @router.get("", response_model=AppSettings)
  49. @router.get("/", response_model=AppSettings)
  50. async def get_settings(db: AsyncSession = Depends(get_db)):
  51. """Get all application settings."""
  52. settings_dict = DEFAULT_SETTINGS.model_dump()
  53. # Load saved settings from database
  54. result = await db.execute(select(Settings))
  55. db_settings = result.scalars().all()
  56. for setting in db_settings:
  57. if setting.key in settings_dict:
  58. # Parse the value based on the expected type
  59. if setting.key in [
  60. "auto_archive",
  61. "save_thumbnails",
  62. "capture_finish_photo",
  63. "spoolman_enabled",
  64. "check_updates",
  65. "check_printer_firmware",
  66. "virtual_printer_enabled",
  67. "ftp_retry_enabled",
  68. "mqtt_enabled",
  69. "mqtt_use_tls",
  70. "ha_enabled",
  71. "per_printer_mapping_expanded",
  72. "prometheus_enabled",
  73. ]:
  74. settings_dict[setting.key] = setting.value.lower() == "true"
  75. elif setting.key in [
  76. "default_filament_cost",
  77. "energy_cost_per_kwh",
  78. "ams_temp_good",
  79. "ams_temp_fair",
  80. "library_disk_warning_gb",
  81. ]:
  82. settings_dict[setting.key] = float(setting.value)
  83. elif setting.key in [
  84. "ams_humidity_good",
  85. "ams_humidity_fair",
  86. "ams_history_retention_days",
  87. "ftp_retry_count",
  88. "ftp_retry_delay",
  89. "mqtt_port",
  90. ]:
  91. settings_dict[setting.key] = int(setting.value)
  92. elif setting.key == "default_printer_id":
  93. # Handle nullable integer
  94. settings_dict[setting.key] = int(setting.value) if setting.value and setting.value != "None" else None
  95. else:
  96. settings_dict[setting.key] = setting.value
  97. return AppSettings(**settings_dict)
  98. @router.put("/", response_model=AppSettings)
  99. async def update_settings(
  100. settings_update: AppSettingsUpdate,
  101. db: AsyncSession = Depends(get_db),
  102. ):
  103. """Update application settings."""
  104. update_data = settings_update.model_dump(exclude_unset=True)
  105. # Check if any MQTT settings are being updated
  106. mqtt_keys = {
  107. "mqtt_enabled",
  108. "mqtt_broker",
  109. "mqtt_port",
  110. "mqtt_username",
  111. "mqtt_password",
  112. "mqtt_topic_prefix",
  113. "mqtt_use_tls",
  114. }
  115. mqtt_updated = bool(mqtt_keys & set(update_data.keys()))
  116. for key, value in update_data.items():
  117. # Convert value to string for storage
  118. if isinstance(value, bool):
  119. str_value = "true" if value else "false"
  120. elif value is None:
  121. str_value = "None"
  122. else:
  123. str_value = str(value)
  124. await set_setting(db, key, str_value)
  125. await db.commit()
  126. # Expire all objects to ensure fresh reads after commit
  127. db.expire_all()
  128. # Reconfigure MQTT relay if any MQTT settings changed
  129. if mqtt_updated:
  130. try:
  131. from backend.app.services.mqtt_relay import mqtt_relay
  132. mqtt_settings = {
  133. "mqtt_enabled": (await get_setting(db, "mqtt_enabled") or "false") == "true",
  134. "mqtt_broker": await get_setting(db, "mqtt_broker") or "",
  135. "mqtt_port": int(await get_setting(db, "mqtt_port") or "1883"),
  136. "mqtt_username": await get_setting(db, "mqtt_username") or "",
  137. "mqtt_password": await get_setting(db, "mqtt_password") or "",
  138. "mqtt_topic_prefix": await get_setting(db, "mqtt_topic_prefix") or "bambuddy",
  139. "mqtt_use_tls": (await get_setting(db, "mqtt_use_tls") or "false") == "true",
  140. }
  141. await mqtt_relay.configure(mqtt_settings)
  142. except Exception:
  143. pass # Don't fail the settings update if MQTT reconfiguration fails
  144. # Return updated settings
  145. return await get_settings(db)
  146. @router.patch("/", response_model=AppSettings)
  147. @router.patch("", response_model=AppSettings)
  148. async def patch_settings(
  149. settings_update: AppSettingsUpdate,
  150. db: AsyncSession = Depends(get_db),
  151. ):
  152. """Partially update application settings (same as PUT, for REST compatibility)."""
  153. return await update_settings(settings_update, db)
  154. @router.post("/reset", response_model=AppSettings)
  155. async def reset_settings(db: AsyncSession = Depends(get_db)):
  156. """Reset all settings to defaults."""
  157. # Delete all settings
  158. result = await db.execute(select(Settings))
  159. for setting in result.scalars().all():
  160. await db.delete(setting)
  161. await db.commit()
  162. return DEFAULT_SETTINGS
  163. @router.get("/check-ffmpeg")
  164. async def check_ffmpeg():
  165. """Check if ffmpeg is installed and available."""
  166. from backend.app.services.camera import get_ffmpeg_path
  167. ffmpeg_path = get_ffmpeg_path()
  168. return {
  169. "installed": ffmpeg_path is not None,
  170. "path": ffmpeg_path,
  171. }
  172. @router.get("/spoolman")
  173. async def get_spoolman_settings(db: AsyncSession = Depends(get_db)):
  174. """Get Spoolman integration settings."""
  175. spoolman_enabled = await get_setting(db, "spoolman_enabled") or "false"
  176. spoolman_url = await get_setting(db, "spoolman_url") or ""
  177. spoolman_sync_mode = await get_setting(db, "spoolman_sync_mode") or "auto"
  178. return {
  179. "spoolman_enabled": spoolman_enabled,
  180. "spoolman_url": spoolman_url,
  181. "spoolman_sync_mode": spoolman_sync_mode,
  182. }
  183. @router.put("/spoolman")
  184. async def update_spoolman_settings(
  185. settings: dict,
  186. db: AsyncSession = Depends(get_db),
  187. ):
  188. """Update Spoolman integration settings."""
  189. if "spoolman_enabled" in settings:
  190. await set_setting(db, "spoolman_enabled", settings["spoolman_enabled"])
  191. if "spoolman_url" in settings:
  192. await set_setting(db, "spoolman_url", settings["spoolman_url"])
  193. if "spoolman_sync_mode" in settings:
  194. await set_setting(db, "spoolman_sync_mode", settings["spoolman_sync_mode"])
  195. await db.commit()
  196. db.expire_all()
  197. # Return updated settings
  198. return await get_spoolman_settings(db)
  199. @router.get("/backup")
  200. async def create_backup(db: AsyncSession = Depends(get_db)):
  201. """Create a complete backup (database + all files) as a ZIP.
  202. This is a simplified backup that includes the entire SQLite database
  203. and all data directories. It is complete by definition and cannot miss data.
  204. """
  205. import shutil
  206. import tempfile
  207. from sqlalchemy import text
  208. from backend.app.core.database import engine
  209. base_dir = app_settings.base_dir
  210. db_path = Path(app_settings.database_url.replace("sqlite+aiosqlite:///", ""))
  211. with tempfile.TemporaryDirectory() as temp_dir:
  212. temp_path = Path(temp_dir)
  213. # 1. Checkpoint WAL to ensure all data is in main db file
  214. async with engine.begin() as conn:
  215. await conn.execute(text("PRAGMA wal_checkpoint(TRUNCATE)"))
  216. # 2. Copy database file
  217. shutil.copy2(db_path, temp_path / "bambuddy.db")
  218. # 3. Copy data directories (if they exist)
  219. dirs_to_backup = [
  220. ("archive", base_dir / "archive"),
  221. ("virtual_printer", base_dir / "virtual_printer"),
  222. ("plate_calibration", app_settings.plate_calibration_dir),
  223. ("icons", base_dir / "icons"),
  224. ("projects", base_dir / "projects"),
  225. ]
  226. for name, src_dir in dirs_to_backup:
  227. if src_dir.exists() and any(src_dir.iterdir()):
  228. shutil.copytree(src_dir, temp_path / name)
  229. # 4. Create ZIP
  230. zip_buffer = io.BytesIO()
  231. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  232. for file_path in temp_path.rglob("*"):
  233. if file_path.is_file():
  234. arcname = file_path.relative_to(temp_path)
  235. zf.write(file_path, arcname)
  236. zip_buffer.seek(0)
  237. filename = f"bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip"
  238. return StreamingResponse(
  239. zip_buffer,
  240. media_type="application/zip",
  241. headers={"Content-Disposition": f"attachment; filename={filename}"},
  242. )
  243. @router.get("/backup-legacy")
  244. async def export_backup_legacy(
  245. db: AsyncSession = Depends(get_db),
  246. include_settings: bool = Query(True, description="Include app settings"),
  247. include_notifications: bool = Query(True, description="Include notification providers"),
  248. include_templates: bool = Query(True, description="Include notification templates"),
  249. include_smart_plugs: bool = Query(True, description="Include smart plugs"),
  250. include_external_links: bool = Query(True, description="Include external sidebar links"),
  251. include_printers: bool = Query(False, description="Include printers (without access codes)"),
  252. include_plate_calibration: bool = Query(False, description="Include plate detection reference images"),
  253. include_filaments: bool = Query(False, description="Include filament inventory"),
  254. include_maintenance: bool = Query(
  255. False, description="Include maintenance types, per-printer settings, and history"
  256. ),
  257. include_print_queue: bool = Query(False, description="Include print queue items"),
  258. include_archives: bool = Query(False, description="Include print archive metadata"),
  259. include_projects: bool = Query(False, description="Include projects with BOM items"),
  260. include_pending_uploads: bool = Query(False, description="Include pending virtual printer uploads"),
  261. include_access_codes: bool = Query(False, description="Include printer access codes (security risk!)"),
  262. include_api_keys: bool = Query(False, description="Include API keys (keys will need to be regenerated on import)"),
  263. include_users: bool = Query(
  264. False, description="Include users (passwords not exported - users will need new passwords)"
  265. ),
  266. include_groups: bool = Query(False, description="Include groups and user-group assignments"),
  267. include_github_backup: bool = Query(False, description="Include GitHub backup configuration (token not exported)"),
  268. ):
  269. """Export selected data as JSON backup."""
  270. backup: dict = {
  271. "version": "2.0",
  272. "exported_at": datetime.utcnow().isoformat(),
  273. "included": [],
  274. }
  275. # Settings
  276. if include_settings:
  277. result = await db.execute(select(Settings))
  278. db_settings = result.scalars().all()
  279. backup["settings"] = {s.key: s.value for s in db_settings}
  280. backup["included"].append("settings")
  281. # Notification providers
  282. if include_notifications:
  283. # Build printer ID to serial lookup for cross-system backup
  284. printer_id_to_serial: dict[int, str] = {}
  285. pr_result = await db.execute(select(Printer))
  286. for pr in pr_result.scalars().all():
  287. printer_id_to_serial[pr.id] = pr.serial_number
  288. result = await db.execute(select(NotificationProvider))
  289. providers = result.scalars().all()
  290. backup["notification_providers"] = []
  291. for p in providers:
  292. # Use printer_serial for cross-system compatibility
  293. provider_printer_id = getattr(p, "printer_id", None)
  294. printer_serial = printer_id_to_serial.get(provider_printer_id) if provider_printer_id else None
  295. backup["notification_providers"].append(
  296. {
  297. "name": p.name,
  298. "provider_type": p.provider_type,
  299. "enabled": p.enabled,
  300. "config": json.loads(p.config) if isinstance(p.config, str) else p.config,
  301. "on_print_start": p.on_print_start,
  302. "on_print_complete": p.on_print_complete,
  303. "on_print_failed": p.on_print_failed,
  304. "on_print_stopped": p.on_print_stopped,
  305. "on_print_progress": p.on_print_progress,
  306. "on_printer_offline": p.on_printer_offline,
  307. "on_printer_error": p.on_printer_error,
  308. "on_filament_low": p.on_filament_low,
  309. "on_maintenance_due": p.on_maintenance_due,
  310. "on_ams_humidity_high": getattr(p, "on_ams_humidity_high", False),
  311. "on_ams_temperature_high": getattr(p, "on_ams_temperature_high", False),
  312. "on_ams_ht_humidity_high": getattr(p, "on_ams_ht_humidity_high", False),
  313. "on_ams_ht_temperature_high": getattr(p, "on_ams_ht_temperature_high", False),
  314. "on_plate_not_empty": getattr(p, "on_plate_not_empty", True),
  315. "on_queue_job_added": getattr(p, "on_queue_job_added", False),
  316. "on_queue_job_assigned": getattr(p, "on_queue_job_assigned", False),
  317. "on_queue_job_started": getattr(p, "on_queue_job_started", False),
  318. "on_queue_job_waiting": getattr(p, "on_queue_job_waiting", True),
  319. "on_queue_job_skipped": getattr(p, "on_queue_job_skipped", True),
  320. "on_queue_job_failed": getattr(p, "on_queue_job_failed", True),
  321. "on_queue_completed": getattr(p, "on_queue_completed", False),
  322. "quiet_hours_enabled": p.quiet_hours_enabled,
  323. "quiet_hours_start": p.quiet_hours_start,
  324. "quiet_hours_end": p.quiet_hours_end,
  325. "daily_digest_enabled": getattr(p, "daily_digest_enabled", False),
  326. "daily_digest_time": getattr(p, "daily_digest_time", None),
  327. "printer_serial": printer_serial,
  328. }
  329. )
  330. backup["included"].append("notification_providers")
  331. # Notification templates
  332. if include_templates:
  333. result = await db.execute(select(NotificationTemplate))
  334. templates = result.scalars().all()
  335. backup["notification_templates"] = []
  336. for t in templates:
  337. backup["notification_templates"].append(
  338. {
  339. "event_type": t.event_type,
  340. "name": t.name,
  341. "title_template": t.title_template,
  342. "body_template": t.body_template,
  343. "is_default": t.is_default,
  344. }
  345. )
  346. backup["included"].append("notification_templates")
  347. # Smart plugs
  348. if include_smart_plugs:
  349. result = await db.execute(select(SmartPlug))
  350. plugs = result.scalars().all()
  351. backup["smart_plugs"] = []
  352. # Build printer ID to serial mapping
  353. printer_id_to_serial: dict[int, str] = {}
  354. pr_result = await db.execute(select(Printer))
  355. for pr in pr_result.scalars().all():
  356. printer_id_to_serial[pr.id] = pr.serial_number
  357. for plug in plugs:
  358. backup["smart_plugs"].append(
  359. {
  360. "name": plug.name,
  361. "plug_type": plug.plug_type,
  362. "ip_address": plug.ip_address,
  363. "ha_entity_id": plug.ha_entity_id,
  364. "ha_power_entity": plug.ha_power_entity,
  365. "ha_energy_today_entity": plug.ha_energy_today_entity,
  366. "ha_energy_total_entity": plug.ha_energy_total_entity,
  367. # MQTT plug fields (legacy)
  368. "mqtt_topic": plug.mqtt_topic,
  369. "mqtt_multiplier": plug.mqtt_multiplier,
  370. # MQTT power fields
  371. "mqtt_power_topic": plug.mqtt_power_topic,
  372. "mqtt_power_path": plug.mqtt_power_path,
  373. "mqtt_power_multiplier": plug.mqtt_power_multiplier,
  374. # MQTT energy fields
  375. "mqtt_energy_topic": plug.mqtt_energy_topic,
  376. "mqtt_energy_path": plug.mqtt_energy_path,
  377. "mqtt_energy_multiplier": plug.mqtt_energy_multiplier,
  378. # MQTT state fields
  379. "mqtt_state_topic": plug.mqtt_state_topic,
  380. "mqtt_state_path": plug.mqtt_state_path,
  381. "mqtt_state_on_value": plug.mqtt_state_on_value,
  382. "printer_serial": printer_id_to_serial.get(plug.printer_id) if plug.printer_id else None,
  383. "enabled": plug.enabled,
  384. "auto_on": plug.auto_on,
  385. "auto_off": plug.auto_off,
  386. "off_delay_mode": plug.off_delay_mode,
  387. "off_delay_minutes": plug.off_delay_minutes,
  388. "off_temp_threshold": plug.off_temp_threshold,
  389. "username": plug.username,
  390. "password": plug.password,
  391. "power_alert_enabled": plug.power_alert_enabled,
  392. "power_alert_high": plug.power_alert_high,
  393. "power_alert_low": plug.power_alert_low,
  394. "schedule_enabled": plug.schedule_enabled,
  395. "schedule_on_time": plug.schedule_on_time,
  396. "schedule_off_time": plug.schedule_off_time,
  397. "show_in_switchbar": plug.show_in_switchbar,
  398. "show_on_printer_card": plug.show_on_printer_card,
  399. }
  400. )
  401. backup["included"].append("smart_plugs")
  402. # External links
  403. if include_external_links:
  404. result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order))
  405. links = result.scalars().all()
  406. backup["external_links"] = []
  407. icons_dir = app_settings.base_dir / "icons"
  408. for link in links:
  409. link_data = {
  410. "name": link.name,
  411. "url": link.url,
  412. "icon": link.icon,
  413. "sort_order": link.sort_order,
  414. }
  415. # Include custom icon file path if exists
  416. if link.custom_icon:
  417. link_data["custom_icon"] = link.custom_icon
  418. icon_path = icons_dir / link.custom_icon
  419. if icon_path.exists():
  420. link_data["custom_icon_path"] = f"icons/{link.custom_icon}"
  421. backup["external_links"].append(link_data)
  422. backup["included"].append("external_links")
  423. # Printers (access codes only included if explicitly requested)
  424. if include_printers:
  425. result = await db.execute(select(Printer))
  426. printers = result.scalars().all()
  427. backup["printers"] = []
  428. for printer in printers:
  429. printer_data = {
  430. "name": printer.name,
  431. "serial_number": printer.serial_number,
  432. "ip_address": printer.ip_address,
  433. "model": printer.model,
  434. "location": printer.location,
  435. "nozzle_count": printer.nozzle_count,
  436. "is_active": printer.is_active,
  437. "auto_archive": printer.auto_archive,
  438. "print_hours_offset": printer.print_hours_offset,
  439. "runtime_seconds": printer.runtime_seconds,
  440. "external_camera_url": printer.external_camera_url,
  441. "external_camera_type": printer.external_camera_type,
  442. "external_camera_enabled": printer.external_camera_enabled,
  443. "plate_detection_enabled": printer.plate_detection_enabled,
  444. "plate_detection_roi_x": printer.plate_detection_roi_x,
  445. "plate_detection_roi_y": printer.plate_detection_roi_y,
  446. "plate_detection_roi_w": printer.plate_detection_roi_w,
  447. "plate_detection_roi_h": printer.plate_detection_roi_h,
  448. }
  449. if include_access_codes:
  450. printer_data["access_code"] = printer.access_code
  451. backup["printers"].append(printer_data)
  452. backup["included"].append("printers")
  453. if include_access_codes:
  454. backup["included"].append("access_codes")
  455. # Plate calibration references (requires include_printers)
  456. if include_printers and include_plate_calibration:
  457. plate_cal_dir = app_settings.plate_calibration_dir
  458. if plate_cal_dir.exists():
  459. backup["plate_calibration"] = {
  460. "files": [],
  461. "printer_id_to_serial": {}, # Map old printer IDs to serial numbers for restore
  462. }
  463. for cal_file in plate_cal_dir.iterdir():
  464. if cal_file.is_file():
  465. backup["plate_calibration"]["files"].append(cal_file.name)
  466. # Extract printer ID from filename (e.g., "printer_1_ref_0.jpg" -> 1)
  467. if cal_file.name.startswith("printer_"):
  468. parts = cal_file.name.split("_")
  469. if len(parts) >= 2 and parts[1].isdigit():
  470. old_printer_id = int(parts[1])
  471. if old_printer_id not in backup["plate_calibration"]["printer_id_to_serial"]:
  472. # Look up serial number for this printer ID
  473. backup["plate_calibration"]["printer_id_to_serial"][old_printer_id] = (
  474. printer_id_to_serial.get(old_printer_id)
  475. )
  476. if backup["plate_calibration"]["files"]:
  477. backup["included"].append("plate_calibration")
  478. # Filaments
  479. if include_filaments:
  480. result = await db.execute(select(Filament))
  481. filaments = result.scalars().all()
  482. backup["filaments"] = []
  483. for f in filaments:
  484. backup["filaments"].append(
  485. {
  486. "name": f.name,
  487. "type": f.type,
  488. "brand": f.brand,
  489. "color": f.color,
  490. "color_hex": f.color_hex,
  491. "cost_per_kg": f.cost_per_kg,
  492. "spool_weight_g": f.spool_weight_g,
  493. "currency": f.currency,
  494. "density": f.density,
  495. "print_temp_min": f.print_temp_min,
  496. "print_temp_max": f.print_temp_max,
  497. "bed_temp_min": f.bed_temp_min,
  498. "bed_temp_max": f.bed_temp_max,
  499. }
  500. )
  501. backup["included"].append("filaments")
  502. # Maintenance types and records
  503. if include_maintenance:
  504. # Maintenance types
  505. result = await db.execute(select(MaintenanceType))
  506. types = result.scalars().all()
  507. backup["maintenance_types"] = []
  508. for mt in types:
  509. backup["maintenance_types"].append(
  510. {
  511. "name": mt.name,
  512. "description": mt.description,
  513. "default_interval_hours": mt.default_interval_hours,
  514. "interval_type": mt.interval_type,
  515. "icon": mt.icon,
  516. "is_system": mt.is_system,
  517. }
  518. )
  519. backup["included"].append("maintenance_types")
  520. # Printer maintenance settings (per-printer custom intervals, enabled status, last performed)
  521. result = await db.execute(select(PrinterMaintenance))
  522. printer_maint = result.scalars().all()
  523. backup["printer_maintenance"] = []
  524. # Build lookups for printer serial and maintenance type name
  525. printer_id_to_serial: dict[int, str] = {}
  526. maint_type_id_to_name: dict[int, str] = {}
  527. pr_result = await db.execute(select(Printer))
  528. for pr in pr_result.scalars().all():
  529. printer_id_to_serial[pr.id] = pr.serial_number
  530. for mt in types:
  531. maint_type_id_to_name[mt.id] = mt.name
  532. for pm in printer_maint:
  533. backup["printer_maintenance"].append(
  534. {
  535. "printer_serial": printer_id_to_serial.get(pm.printer_id),
  536. "maintenance_type_name": maint_type_id_to_name.get(pm.maintenance_type_id),
  537. "custom_interval_hours": pm.custom_interval_hours,
  538. "custom_interval_type": pm.custom_interval_type,
  539. "enabled": pm.enabled,
  540. "last_performed_at": pm.last_performed_at.isoformat() if pm.last_performed_at else None,
  541. "last_performed_hours": pm.last_performed_hours,
  542. }
  543. )
  544. backup["included"].append("printer_maintenance")
  545. # Maintenance history
  546. result = await db.execute(select(MaintenanceHistory))
  547. history = result.scalars().all()
  548. backup["maintenance_history"] = []
  549. # Build printer_maintenance ID to (printer_serial, maint_type_name) mapping
  550. pm_id_to_info: dict[int, tuple[str | None, str | None]] = {}
  551. for pm in printer_maint:
  552. pm_id_to_info[pm.id] = (
  553. printer_id_to_serial.get(pm.printer_id),
  554. maint_type_id_to_name.get(pm.maintenance_type_id),
  555. )
  556. for mh in history:
  557. info = pm_id_to_info.get(mh.printer_maintenance_id, (None, None))
  558. backup["maintenance_history"].append(
  559. {
  560. "printer_serial": info[0],
  561. "maintenance_type_name": info[1],
  562. "performed_at": mh.performed_at.isoformat() if mh.performed_at else None,
  563. "hours_at_maintenance": mh.hours_at_maintenance,
  564. "notes": mh.notes,
  565. }
  566. )
  567. backup["included"].append("maintenance_history")
  568. # Print queue
  569. if include_print_queue:
  570. result = await db.execute(select(PrintQueueItem))
  571. queue_items = result.scalars().all()
  572. backup["print_queue"] = []
  573. # Build lookups
  574. printer_id_to_serial: dict[int, str] = {}
  575. archive_id_to_hash: dict[int, str | None] = {}
  576. project_id_to_name: dict[int, str] = {}
  577. pr_result = await db.execute(select(Printer))
  578. for pr in pr_result.scalars().all():
  579. printer_id_to_serial[pr.id] = pr.serial_number
  580. ar_result = await db.execute(select(PrintArchive))
  581. for ar in ar_result.scalars().all():
  582. archive_id_to_hash[ar.id] = ar.content_hash
  583. proj_result = await db.execute(select(Project))
  584. for proj in proj_result.scalars().all():
  585. project_id_to_name[proj.id] = proj.name
  586. for qi in queue_items:
  587. backup["print_queue"].append(
  588. {
  589. "printer_serial": printer_id_to_serial.get(qi.printer_id) if qi.printer_id else None,
  590. "archive_hash": archive_id_to_hash.get(qi.archive_id),
  591. "project_name": project_id_to_name.get(qi.project_id) if qi.project_id else None,
  592. "position": qi.position,
  593. "scheduled_time": qi.scheduled_time.isoformat() if qi.scheduled_time else None,
  594. "require_previous_success": qi.require_previous_success,
  595. "auto_off_after": qi.auto_off_after,
  596. "manual_start": qi.manual_start,
  597. "ams_mapping": qi.ams_mapping,
  598. "plate_id": qi.plate_id,
  599. "bed_levelling": qi.bed_levelling,
  600. "flow_cali": qi.flow_cali,
  601. "vibration_cali": qi.vibration_cali,
  602. "layer_inspect": qi.layer_inspect,
  603. "timelapse": qi.timelapse,
  604. "use_ams": qi.use_ams,
  605. "status": qi.status,
  606. "started_at": qi.started_at.isoformat() if qi.started_at else None,
  607. "completed_at": qi.completed_at.isoformat() if qi.completed_at else None,
  608. "error_message": qi.error_message,
  609. }
  610. )
  611. backup["included"].append("print_queue")
  612. # Collect files for ZIP (icons + archives + project attachments)
  613. backup_files: list[tuple[str, Path]] = [] # (zip_path, local_path)
  614. base_dir = app_settings.base_dir
  615. # Add external link icon files
  616. if include_external_links and "external_links" in backup:
  617. icons_dir = base_dir / "icons"
  618. for link_data in backup["external_links"]:
  619. if "custom_icon_path" in link_data:
  620. icon_path = icons_dir / link_data["custom_icon"]
  621. if icon_path.exists():
  622. backup_files.append((link_data["custom_icon_path"], icon_path))
  623. # Add plate calibration reference images
  624. if "plate_calibration" in backup:
  625. plate_cal_dir = app_settings.plate_calibration_dir
  626. plate_cal_data = backup["plate_calibration"]
  627. # Support both old list format and new dict format
  628. filenames = plate_cal_data.get("files", []) if isinstance(plate_cal_data, dict) else plate_cal_data
  629. for filename in filenames:
  630. file_path = plate_cal_dir / filename
  631. if file_path.exists():
  632. backup_files.append((f"plate_calibration/{filename}", file_path))
  633. # Print archives with file paths for ZIP
  634. if include_archives:
  635. result = await db.execute(select(PrintArchive))
  636. archives = result.scalars().all()
  637. backup["archives"] = []
  638. # Build project ID to name mapping for archive export
  639. project_id_to_name: dict[int, str] = {}
  640. if include_projects:
  641. proj_result = await db.execute(select(Project))
  642. for proj in proj_result.scalars().all():
  643. project_id_to_name[proj.id] = proj.name
  644. # Build printer ID to serial mapping for archive export
  645. printer_id_to_serial: dict[int, str] = {}
  646. if include_printers:
  647. printer_result = await db.execute(select(Printer))
  648. for pr in printer_result.scalars().all():
  649. printer_id_to_serial[pr.id] = pr.serial_number
  650. for a in archives:
  651. archive_data = {
  652. "filename": a.filename,
  653. "project_name": project_id_to_name.get(a.project_id) if a.project_id else None,
  654. "printer_serial": printer_id_to_serial.get(a.printer_id) if a.printer_id else None,
  655. "file_size": a.file_size,
  656. "content_hash": a.content_hash,
  657. "print_name": a.print_name,
  658. "print_time_seconds": a.print_time_seconds,
  659. "filament_used_grams": a.filament_used_grams,
  660. "filament_type": a.filament_type,
  661. "filament_color": a.filament_color,
  662. "layer_height": a.layer_height,
  663. "total_layers": a.total_layers,
  664. "nozzle_diameter": a.nozzle_diameter,
  665. "bed_temperature": a.bed_temperature,
  666. "nozzle_temperature": a.nozzle_temperature,
  667. "status": a.status,
  668. "started_at": a.started_at.isoformat() if a.started_at else None,
  669. "completed_at": a.completed_at.isoformat() if a.completed_at else None,
  670. "makerworld_url": a.makerworld_url,
  671. "designer": a.designer,
  672. "external_url": a.external_url,
  673. "is_favorite": a.is_favorite,
  674. "tags": a.tags,
  675. "notes": a.notes,
  676. "cost": a.cost,
  677. "failure_reason": a.failure_reason,
  678. "quantity": a.quantity,
  679. "energy_kwh": a.energy_kwh,
  680. "energy_cost": a.energy_cost,
  681. "extra_data": a.extra_data,
  682. "photos": a.photos,
  683. }
  684. # Collect file paths for ZIP
  685. if a.file_path:
  686. file_path = base_dir / a.file_path
  687. if file_path.exists():
  688. archive_data["file_path"] = a.file_path
  689. backup_files.append((a.file_path, file_path))
  690. if a.thumbnail_path:
  691. thumb_path = base_dir / a.thumbnail_path
  692. if thumb_path.exists():
  693. archive_data["thumbnail_path"] = a.thumbnail_path
  694. backup_files.append((a.thumbnail_path, thumb_path))
  695. if a.timelapse_path:
  696. timelapse_path = base_dir / a.timelapse_path
  697. if timelapse_path.exists():
  698. archive_data["timelapse_path"] = a.timelapse_path
  699. backup_files.append((a.timelapse_path, timelapse_path))
  700. if a.source_3mf_path:
  701. source_path = base_dir / a.source_3mf_path
  702. if source_path.exists():
  703. archive_data["source_3mf_path"] = a.source_3mf_path
  704. backup_files.append((a.source_3mf_path, source_path))
  705. if a.f3d_path:
  706. f3d_path = base_dir / a.f3d_path
  707. if f3d_path.exists():
  708. archive_data["f3d_path"] = a.f3d_path
  709. backup_files.append((a.f3d_path, f3d_path))
  710. # Include photos
  711. if a.photos:
  712. for photo in a.photos:
  713. photo_path = base_dir / "archive" / "photos" / photo
  714. if photo_path.exists():
  715. zip_photo_path = f"archive/photos/{photo}"
  716. backup_files.append((zip_photo_path, photo_path))
  717. backup["archives"].append(archive_data)
  718. backup["included"].append("archives")
  719. # Projects with BOM items
  720. if include_projects:
  721. result = await db.execute(select(Project))
  722. projects = result.scalars().all()
  723. backup["projects"] = []
  724. for p in projects:
  725. # Get BOM items for this project
  726. bom_result = await db.execute(select(ProjectBOMItem).where(ProjectBOMItem.project_id == p.id))
  727. bom_items = bom_result.scalars().all()
  728. project_data = {
  729. "name": p.name,
  730. "description": p.description,
  731. "color": p.color,
  732. "status": p.status,
  733. "target_count": p.target_count,
  734. "notes": p.notes,
  735. "tags": p.tags,
  736. "due_date": p.due_date.isoformat() if p.due_date else None,
  737. "priority": p.priority,
  738. "budget": p.budget,
  739. "is_template": p.is_template,
  740. "template_source_id": p.template_source_id,
  741. "parent_id": p.parent_id,
  742. "bom_items": [
  743. {
  744. "name": item.name,
  745. "quantity_needed": item.quantity_needed,
  746. "quantity_acquired": item.quantity_acquired,
  747. "unit_price": item.unit_price,
  748. "sourcing_url": item.sourcing_url,
  749. "stl_filename": item.stl_filename,
  750. "remarks": item.remarks,
  751. "sort_order": item.sort_order,
  752. }
  753. for item in bom_items
  754. ],
  755. }
  756. # Include attachment files for ZIP
  757. if p.attachments:
  758. project_data["attachments"] = p.attachments
  759. attachments_dir = base_dir / "projects" / str(p.id) / "attachments"
  760. for att in p.attachments:
  761. att_path = attachments_dir / att.get("filename", "")
  762. if att_path.exists():
  763. zip_path = f"projects/{p.id}/attachments/{att['filename']}"
  764. backup_files.append((zip_path, att_path))
  765. backup["projects"].append(project_data)
  766. backup["included"].append("projects")
  767. # Pending uploads (virtual printer queue mode)
  768. if include_pending_uploads:
  769. result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
  770. pending_uploads = result.scalars().all()
  771. backup["pending_uploads"] = []
  772. for p in pending_uploads:
  773. upload_data = {
  774. "filename": p.filename,
  775. "file_size": p.file_size,
  776. "source_ip": p.source_ip,
  777. "status": p.status,
  778. "tags": p.tags,
  779. "notes": p.notes,
  780. "project_id": p.project_id,
  781. "uploaded_at": p.uploaded_at.isoformat() if p.uploaded_at else None,
  782. }
  783. # Include the actual file if it exists
  784. if p.file_path:
  785. file_path = Path(p.file_path)
  786. if file_path.exists():
  787. # Store relative path for ZIP
  788. rel_path = f"pending_uploads/{p.filename}"
  789. upload_data["file_path"] = rel_path
  790. backup_files.append((rel_path, file_path))
  791. backup["pending_uploads"].append(upload_data)
  792. backup["included"].append("pending_uploads")
  793. # API keys (note: key_hash cannot be restored, new keys must be generated)
  794. if include_api_keys:
  795. # Build printer ID to serial mapping for cross-system compatibility
  796. printer_id_to_serial: dict[int, str] = {}
  797. pr_result = await db.execute(select(Printer))
  798. for pr in pr_result.scalars().all():
  799. printer_id_to_serial[pr.id] = pr.serial_number
  800. result = await db.execute(select(APIKey))
  801. api_keys = result.scalars().all()
  802. backup["api_keys"] = []
  803. for key in api_keys:
  804. # Convert printer_ids from list of IDs to list of serials
  805. printer_serials = None
  806. if key.printer_ids:
  807. printer_serials = [
  808. printer_id_to_serial.get(pid) for pid in key.printer_ids if pid in printer_id_to_serial
  809. ]
  810. backup["api_keys"].append(
  811. {
  812. "name": key.name,
  813. "key_prefix": key.key_prefix, # For identification only
  814. "can_queue": key.can_queue,
  815. "can_control_printer": key.can_control_printer,
  816. "can_read_status": key.can_read_status,
  817. "printer_serials": printer_serials, # Use serials instead of IDs
  818. "enabled": key.enabled,
  819. "expires_at": key.expires_at.isoformat() if key.expires_at else None,
  820. }
  821. )
  822. backup["included"].append("api_keys")
  823. # Users (note: passwords not exported for security - users will need new passwords on import)
  824. if include_users:
  825. result = await db.execute(select(User))
  826. users = result.scalars().all()
  827. backup["users"] = []
  828. for user in users:
  829. backup["users"].append(
  830. {
  831. "username": user.username,
  832. "role": user.role,
  833. "is_active": user.is_active,
  834. "groups": [g.name for g in user.groups],
  835. # password_hash intentionally not exported for security
  836. }
  837. )
  838. backup["included"].append("users")
  839. # Groups (permission groups)
  840. if include_groups:
  841. result = await db.execute(select(Group))
  842. groups = result.scalars().all()
  843. backup["groups"] = []
  844. for group in groups:
  845. backup["groups"].append(
  846. {
  847. "name": group.name,
  848. "description": group.description,
  849. "permissions": group.permissions,
  850. "is_system": group.is_system,
  851. }
  852. )
  853. backup["included"].append("groups")
  854. # GitHub backup configuration
  855. if include_github_backup:
  856. result = await db.execute(select(GitHubBackupConfig).limit(1))
  857. config = result.scalar_one_or_none()
  858. if config:
  859. backup["github_backup"] = {
  860. "repository_url": config.repository_url,
  861. # access_token intentionally not exported for security
  862. "branch": config.branch,
  863. "schedule_enabled": config.schedule_enabled,
  864. "schedule_type": config.schedule_type,
  865. "backup_kprofiles": config.backup_kprofiles,
  866. "backup_cloud_profiles": config.backup_cloud_profiles,
  867. "backup_settings": config.backup_settings,
  868. "enabled": config.enabled,
  869. }
  870. backup["included"].append("github_backup")
  871. # If there are files to include (icons or archives), create ZIP file
  872. if backup_files:
  873. zip_buffer = io.BytesIO()
  874. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  875. # Add backup.json
  876. zf.writestr("backup.json", json.dumps(backup, indent=2))
  877. # Add all backup files (icons, archives, etc.)
  878. added_files = set()
  879. for zip_path, local_path in backup_files:
  880. if zip_path not in added_files and local_path.exists():
  881. try:
  882. zf.write(local_path, zip_path)
  883. added_files.add(zip_path)
  884. except Exception:
  885. pass # Skip files that can't be read
  886. zip_buffer.seek(0)
  887. filename = f"bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip"
  888. return StreamingResponse(
  889. zip_buffer,
  890. media_type="application/zip",
  891. headers={"Content-Disposition": f"attachment; filename={filename}"},
  892. )
  893. # Otherwise return JSON
  894. return JSONResponse(
  895. content=backup,
  896. headers={
  897. "Content-Disposition": f"attachment; filename=bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
  898. },
  899. )
  900. @router.post("/restore")
  901. async def restore_backup(
  902. file: UploadFile = File(...),
  903. db: AsyncSession = Depends(get_db),
  904. ):
  905. """Restore from a complete backup ZIP.
  906. This is a simplified restore that replaces the database and all data directories
  907. from the backup ZIP. Requires a restart after restore.
  908. """
  909. import shutil
  910. import tempfile
  911. from fastapi import HTTPException
  912. from backend.app.core.database import close_all_connections
  913. base_dir = app_settings.base_dir
  914. db_path = Path(app_settings.database_url.replace("sqlite+aiosqlite:///", ""))
  915. with tempfile.TemporaryDirectory() as temp_dir:
  916. temp_path = Path(temp_dir)
  917. # 1. Read and extract ZIP
  918. content = await file.read()
  919. # Check if it's a valid ZIP
  920. if not file.filename or not file.filename.endswith(".zip"):
  921. raise HTTPException(400, "Invalid backup file: must be a .zip file")
  922. try:
  923. with zipfile.ZipFile(io.BytesIO(content), "r") as zf:
  924. zf.extractall(temp_path)
  925. except zipfile.BadZipFile:
  926. raise HTTPException(400, "Invalid backup file: not a valid ZIP")
  927. # 2. Validate backup (must have database)
  928. backup_db = temp_path / "bambuddy.db"
  929. if not backup_db.exists():
  930. raise HTTPException(400, "Invalid backup: missing bambuddy.db")
  931. # 3. Close current database connections
  932. await close_all_connections()
  933. # 4. Replace database
  934. shutil.copy2(backup_db, db_path)
  935. # 5. Replace data directories
  936. dirs_to_restore = [
  937. ("archive", base_dir / "archive"),
  938. ("virtual_printer", base_dir / "virtual_printer"),
  939. ("plate_calibration", app_settings.plate_calibration_dir),
  940. ("icons", base_dir / "icons"),
  941. ("projects", base_dir / "projects"),
  942. ]
  943. for name, dest_dir in dirs_to_restore:
  944. src_dir = temp_path / name
  945. if src_dir.exists():
  946. if dest_dir.exists():
  947. shutil.rmtree(dest_dir)
  948. shutil.copytree(src_dir, dest_dir)
  949. # 6. Note: Database connection will be reinitialized on restart
  950. # The application should be restarted after restore
  951. return {
  952. "success": True,
  953. "message": "Backup restored successfully. Please restart Bambuddy for changes to take effect.",
  954. }
  955. @router.post("/restore-legacy")
  956. async def import_backup_legacy(
  957. file: UploadFile = File(...),
  958. overwrite: bool = Query(False, description="Overwrite existing data instead of skipping duplicates"),
  959. db: AsyncSession = Depends(get_db),
  960. ):
  961. """Legacy restore: Restore data from JSON or ZIP backup. By default skips duplicates, set overwrite=true to replace existing."""
  962. try:
  963. content = await file.read()
  964. base_dir = app_settings.base_dir
  965. files_restored = 0
  966. # Store plate calibration files for later (need printer ID remapping after printers restored)
  967. plate_cal_files: dict[str, bytes] = {}
  968. # Check if it's a ZIP file
  969. if file.filename and file.filename.endswith(".zip"):
  970. try:
  971. zip_buffer = io.BytesIO(content)
  972. with zipfile.ZipFile(zip_buffer, "r") as zf:
  973. # Extract backup.json
  974. if "backup.json" not in zf.namelist():
  975. return {"success": False, "message": "Invalid ZIP: missing backup.json"}
  976. backup_content = zf.read("backup.json")
  977. backup = json.loads(backup_content.decode("utf-8"))
  978. # Extract all other files to base_dir
  979. for zip_path in zf.namelist():
  980. if zip_path == "backup.json":
  981. continue
  982. # Ensure path is safe (no path traversal)
  983. if ".." in zip_path or zip_path.startswith("/"):
  984. continue
  985. # Plate calibration files - store for later processing after printers are restored
  986. if zip_path.startswith("plate_calibration/"):
  987. filename = zip_path.replace("plate_calibration/", "", 1)
  988. if filename: # Skip directory entries
  989. plate_cal_files[filename] = zf.read(zip_path)
  990. continue
  991. target_path = base_dir / zip_path
  992. target_path.parent.mkdir(parents=True, exist_ok=True)
  993. with zf.open(zip_path) as src, open(target_path, "wb") as dst:
  994. dst.write(src.read())
  995. files_restored += 1
  996. except zipfile.BadZipFile:
  997. return {"success": False, "message": "Invalid ZIP file"}
  998. else:
  999. backup = json.loads(content.decode("utf-8"))
  1000. except json.JSONDecodeError as e:
  1001. return {"success": False, "message": f"Invalid JSON: {str(e)}"}
  1002. except Exception as e:
  1003. return {"success": False, "message": f"Invalid backup file: {str(e)}"}
  1004. restored = {
  1005. "settings": 0,
  1006. "notification_providers": 0,
  1007. "notification_templates": 0,
  1008. "smart_plugs": 0,
  1009. "external_links": 0,
  1010. "printers": 0,
  1011. "filaments": 0,
  1012. "maintenance_types": 0,
  1013. "projects": 0,
  1014. "pending_uploads": 0,
  1015. "users": 0,
  1016. "groups": 0,
  1017. "github_backup": 0,
  1018. }
  1019. skipped = {
  1020. "settings": 0,
  1021. "notification_providers": 0,
  1022. "notification_templates": 0,
  1023. "smart_plugs": 0,
  1024. "external_links": 0,
  1025. "printers": 0,
  1026. "filaments": 0,
  1027. "maintenance_types": 0,
  1028. "archives": 0,
  1029. "projects": 0,
  1030. "pending_uploads": 0,
  1031. "users": 0,
  1032. "groups": 0,
  1033. "github_backup": 0,
  1034. }
  1035. skipped_details = {
  1036. "notification_providers": [],
  1037. "smart_plugs": [],
  1038. "external_links": [],
  1039. "printers": [],
  1040. "filaments": [],
  1041. "maintenance_types": [],
  1042. "archives": [],
  1043. "projects": [],
  1044. "pending_uploads": [],
  1045. "users": [],
  1046. "groups": [],
  1047. }
  1048. # Restore settings (always overwrites)
  1049. if "settings" in backup:
  1050. for key, value in backup["settings"].items():
  1051. # Convert value to proper string format for storage
  1052. if isinstance(value, bool):
  1053. str_value = "true" if value else "false"
  1054. elif value is None:
  1055. str_value = "None"
  1056. else:
  1057. str_value = str(value)
  1058. await set_setting(db, key, str_value)
  1059. restored["settings"] += 1
  1060. # Flush settings to ensure they're persisted before continuing
  1061. await db.flush()
  1062. # Restore printers FIRST (skip or overwrite duplicates by serial_number)
  1063. # Nearly everything in the app references printers, so they must be imported first
  1064. if "printers" in backup:
  1065. for printer_data in backup["printers"]:
  1066. result = await db.execute(select(Printer).where(Printer.serial_number == printer_data["serial_number"]))
  1067. existing = result.scalar_one_or_none()
  1068. if existing:
  1069. if overwrite:
  1070. existing.name = printer_data["name"]
  1071. existing.ip_address = printer_data["ip_address"]
  1072. existing.model = printer_data.get("model")
  1073. existing.location = printer_data.get("location")
  1074. existing.nozzle_count = printer_data.get("nozzle_count", 1)
  1075. existing.auto_archive = printer_data.get("auto_archive", True)
  1076. existing.print_hours_offset = printer_data.get("print_hours_offset", 0.0)
  1077. existing.runtime_seconds = printer_data.get("runtime_seconds", 0)
  1078. # If backup includes access_code, also update access_code and is_active
  1079. backup_access_code = printer_data.get("access_code")
  1080. if backup_access_code and backup_access_code != "CHANGE_ME":
  1081. existing.access_code = backup_access_code
  1082. is_active_val = printer_data.get("is_active", False)
  1083. if isinstance(is_active_val, str):
  1084. is_active_val = is_active_val.lower() == "true"
  1085. existing.is_active = is_active_val
  1086. # Restore external camera settings
  1087. existing.external_camera_url = printer_data.get("external_camera_url")
  1088. existing.external_camera_type = printer_data.get("external_camera_type")
  1089. existing.external_camera_enabled = printer_data.get("external_camera_enabled", False)
  1090. # Restore plate detection settings
  1091. existing.plate_detection_enabled = printer_data.get("plate_detection_enabled", False)
  1092. existing.plate_detection_roi_x = printer_data.get("plate_detection_roi_x")
  1093. existing.plate_detection_roi_y = printer_data.get("plate_detection_roi_y")
  1094. existing.plate_detection_roi_w = printer_data.get("plate_detection_roi_w")
  1095. existing.plate_detection_roi_h = printer_data.get("plate_detection_roi_h")
  1096. restored["printers"] += 1
  1097. else:
  1098. skipped["printers"] += 1
  1099. skipped_details["printers"].append(f"{printer_data['name']} ({printer_data['serial_number']})")
  1100. else:
  1101. # Use access code from backup if provided, otherwise require manual setup
  1102. access_code = printer_data.get("access_code")
  1103. has_access_code = access_code and access_code != "CHANGE_ME"
  1104. is_active_from_backup = printer_data.get("is_active", False)
  1105. # Handle bool or string "true"/"false"
  1106. if isinstance(is_active_from_backup, str):
  1107. is_active_from_backup = is_active_from_backup.lower() == "true"
  1108. printer = Printer(
  1109. name=printer_data["name"],
  1110. serial_number=printer_data["serial_number"],
  1111. ip_address=printer_data["ip_address"],
  1112. access_code=access_code if has_access_code else "CHANGE_ME",
  1113. model=printer_data.get("model"),
  1114. location=printer_data.get("location"),
  1115. nozzle_count=printer_data.get("nozzle_count", 1),
  1116. is_active=is_active_from_backup if has_access_code else False,
  1117. auto_archive=printer_data.get("auto_archive", True),
  1118. print_hours_offset=printer_data.get("print_hours_offset", 0.0),
  1119. runtime_seconds=printer_data.get("runtime_seconds", 0),
  1120. external_camera_url=printer_data.get("external_camera_url"),
  1121. external_camera_type=printer_data.get("external_camera_type"),
  1122. external_camera_enabled=printer_data.get("external_camera_enabled", False),
  1123. plate_detection_enabled=printer_data.get("plate_detection_enabled", False),
  1124. plate_detection_roi_x=printer_data.get("plate_detection_roi_x"),
  1125. plate_detection_roi_y=printer_data.get("plate_detection_roi_y"),
  1126. plate_detection_roi_w=printer_data.get("plate_detection_roi_w"),
  1127. plate_detection_roi_h=printer_data.get("plate_detection_roi_h"),
  1128. )
  1129. db.add(printer)
  1130. restored["printers"] += 1
  1131. # Flush printers so other sections can look them up
  1132. await db.flush()
  1133. # Restore plate calibration files (remap printer IDs based on serial numbers)
  1134. if plate_cal_files:
  1135. # Build serial_number -> new_printer_id mapping
  1136. serial_to_new_id: dict[str, int] = {}
  1137. pr_result = await db.execute(select(Printer))
  1138. for pr in pr_result.scalars().all():
  1139. serial_to_new_id[pr.serial_number] = pr.id
  1140. # Get old_id -> serial mapping from backup (supports both old list format and new dict format)
  1141. plate_cal_data = backup.get("plate_calibration", {})
  1142. if isinstance(plate_cal_data, dict):
  1143. old_id_to_serial: dict[int, str | None] = {
  1144. int(k): v for k, v in plate_cal_data.get("printer_id_to_serial", {}).items()
  1145. }
  1146. else:
  1147. old_id_to_serial = {}
  1148. # Build old_id -> new_id mapping
  1149. old_id_to_new_id: dict[int, int] = {}
  1150. for old_id, serial in old_id_to_serial.items():
  1151. if serial and serial in serial_to_new_id:
  1152. old_id_to_new_id[old_id] = serial_to_new_id[serial]
  1153. app_settings.plate_calibration_dir.mkdir(parents=True, exist_ok=True)
  1154. for filename, file_data in plate_cal_files.items():
  1155. # Parse old printer ID from filename (e.g., "printer_3_ref_0.jpg" -> 3)
  1156. new_filename = filename
  1157. if filename.startswith("printer_"):
  1158. parts = filename.split("_")
  1159. if len(parts) >= 2 and parts[1].isdigit():
  1160. old_printer_id = int(parts[1])
  1161. if old_printer_id in old_id_to_new_id:
  1162. new_printer_id = old_id_to_new_id[old_printer_id]
  1163. # Replace old ID with new ID in filename
  1164. new_filename = filename.replace(f"printer_{old_printer_id}_", f"printer_{new_printer_id}_", 1)
  1165. target_path = app_settings.plate_calibration_dir / new_filename
  1166. with open(target_path, "wb") as f:
  1167. f.write(file_data)
  1168. files_restored += 1
  1169. # Restore notification providers (skip or overwrite duplicates by name)
  1170. # Build printer serial to ID lookup (printers were restored first)
  1171. if "notification_providers" in backup:
  1172. printer_serial_to_id: dict[str, int] = {}
  1173. pr_result = await db.execute(select(Printer))
  1174. for pr in pr_result.scalars().all():
  1175. printer_serial_to_id[pr.serial_number] = pr.id
  1176. for provider_data in backup["notification_providers"]:
  1177. # Look up printer_id from serial (supports both old printer_id and new printer_serial format)
  1178. printer_serial = provider_data.get("printer_serial")
  1179. printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else provider_data.get("printer_id")
  1180. result = await db.execute(
  1181. select(NotificationProvider).where(NotificationProvider.name == provider_data["name"])
  1182. )
  1183. existing = result.scalar_one_or_none()
  1184. if existing:
  1185. if overwrite:
  1186. # Update existing provider
  1187. existing.provider_type = provider_data["provider_type"]
  1188. existing.enabled = provider_data.get("enabled", True)
  1189. existing.config = json.dumps(provider_data.get("config", {}))
  1190. existing.on_print_start = provider_data.get("on_print_start", False)
  1191. existing.on_print_complete = provider_data.get("on_print_complete", True)
  1192. existing.on_print_failed = provider_data.get("on_print_failed", True)
  1193. existing.on_print_stopped = provider_data.get("on_print_stopped", True)
  1194. existing.on_print_progress = provider_data.get("on_print_progress", False)
  1195. existing.on_printer_offline = provider_data.get("on_printer_offline", False)
  1196. existing.on_printer_error = provider_data.get("on_printer_error", False)
  1197. existing.on_filament_low = provider_data.get("on_filament_low", False)
  1198. existing.on_maintenance_due = provider_data.get("on_maintenance_due", False)
  1199. existing.on_ams_humidity_high = provider_data.get("on_ams_humidity_high", False)
  1200. existing.on_ams_temperature_high = provider_data.get("on_ams_temperature_high", False)
  1201. existing.on_ams_ht_humidity_high = provider_data.get("on_ams_ht_humidity_high", False)
  1202. existing.on_ams_ht_temperature_high = provider_data.get("on_ams_ht_temperature_high", False)
  1203. existing.on_plate_not_empty = provider_data.get("on_plate_not_empty", True)
  1204. existing.on_queue_job_added = provider_data.get("on_queue_job_added", False)
  1205. existing.on_queue_job_assigned = provider_data.get("on_queue_job_assigned", False)
  1206. existing.on_queue_job_started = provider_data.get("on_queue_job_started", False)
  1207. existing.on_queue_job_waiting = provider_data.get("on_queue_job_waiting", True)
  1208. existing.on_queue_job_skipped = provider_data.get("on_queue_job_skipped", True)
  1209. existing.on_queue_job_failed = provider_data.get("on_queue_job_failed", True)
  1210. existing.on_queue_completed = provider_data.get("on_queue_completed", False)
  1211. existing.quiet_hours_enabled = provider_data.get("quiet_hours_enabled", False)
  1212. existing.quiet_hours_start = provider_data.get("quiet_hours_start")
  1213. existing.quiet_hours_end = provider_data.get("quiet_hours_end")
  1214. existing.daily_digest_enabled = provider_data.get("daily_digest_enabled", False)
  1215. existing.daily_digest_time = provider_data.get("daily_digest_time")
  1216. existing.printer_id = printer_id
  1217. restored["notification_providers"] += 1
  1218. else:
  1219. skipped["notification_providers"] += 1
  1220. skipped_details["notification_providers"].append(provider_data["name"])
  1221. else:
  1222. provider = NotificationProvider(
  1223. name=provider_data["name"],
  1224. provider_type=provider_data["provider_type"],
  1225. enabled=provider_data.get("enabled", True),
  1226. config=json.dumps(provider_data.get("config", {})),
  1227. on_print_start=provider_data.get("on_print_start", False),
  1228. on_print_complete=provider_data.get("on_print_complete", True),
  1229. on_print_failed=provider_data.get("on_print_failed", True),
  1230. on_print_stopped=provider_data.get("on_print_stopped", True),
  1231. on_print_progress=provider_data.get("on_print_progress", False),
  1232. on_printer_offline=provider_data.get("on_printer_offline", False),
  1233. on_printer_error=provider_data.get("on_printer_error", False),
  1234. on_filament_low=provider_data.get("on_filament_low", False),
  1235. on_maintenance_due=provider_data.get("on_maintenance_due", False),
  1236. on_ams_humidity_high=provider_data.get("on_ams_humidity_high", False),
  1237. on_ams_temperature_high=provider_data.get("on_ams_temperature_high", False),
  1238. on_ams_ht_humidity_high=provider_data.get("on_ams_ht_humidity_high", False),
  1239. on_ams_ht_temperature_high=provider_data.get("on_ams_ht_temperature_high", False),
  1240. on_plate_not_empty=provider_data.get("on_plate_not_empty", True),
  1241. on_queue_job_added=provider_data.get("on_queue_job_added", False),
  1242. on_queue_job_assigned=provider_data.get("on_queue_job_assigned", False),
  1243. on_queue_job_started=provider_data.get("on_queue_job_started", False),
  1244. on_queue_job_waiting=provider_data.get("on_queue_job_waiting", True),
  1245. on_queue_job_skipped=provider_data.get("on_queue_job_skipped", True),
  1246. on_queue_job_failed=provider_data.get("on_queue_job_failed", True),
  1247. on_queue_completed=provider_data.get("on_queue_completed", False),
  1248. quiet_hours_enabled=provider_data.get("quiet_hours_enabled", False),
  1249. quiet_hours_start=provider_data.get("quiet_hours_start"),
  1250. quiet_hours_end=provider_data.get("quiet_hours_end"),
  1251. daily_digest_enabled=provider_data.get("daily_digest_enabled", False),
  1252. daily_digest_time=provider_data.get("daily_digest_time"),
  1253. printer_id=printer_id,
  1254. )
  1255. db.add(provider)
  1256. restored["notification_providers"] += 1
  1257. # Restore notification templates (update existing by event_type)
  1258. if "notification_templates" in backup:
  1259. for template_data in backup["notification_templates"]:
  1260. result = await db.execute(
  1261. select(NotificationTemplate).where(NotificationTemplate.event_type == template_data["event_type"])
  1262. )
  1263. existing = result.scalar_one_or_none()
  1264. if existing:
  1265. # Update existing template
  1266. existing.name = template_data.get("name", existing.name)
  1267. existing.title_template = template_data.get("title_template", existing.title_template)
  1268. existing.body_template = template_data.get("body_template", existing.body_template)
  1269. existing.is_default = template_data.get("is_default", False)
  1270. else:
  1271. template = NotificationTemplate(
  1272. event_type=template_data["event_type"],
  1273. name=template_data["name"],
  1274. title_template=template_data["title_template"],
  1275. body_template=template_data["body_template"],
  1276. is_default=template_data.get("is_default", False),
  1277. )
  1278. db.add(template)
  1279. restored["notification_templates"] += 1
  1280. # Restore smart plugs (skip or overwrite duplicates by IP)
  1281. # Note: Smart plugs reference printers, so printers should be restored first
  1282. if "smart_plugs" in backup:
  1283. # Build printer serial to ID lookup
  1284. printer_serial_to_id: dict[str, int] = {}
  1285. pr_result = await db.execute(select(Printer))
  1286. for pr in pr_result.scalars().all():
  1287. printer_serial_to_id[pr.serial_number] = pr.id
  1288. for plug_data in backup["smart_plugs"]:
  1289. # Look up printer_id from serial (supports both old printer_id and new printer_serial format)
  1290. printer_serial = plug_data.get("printer_serial")
  1291. printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else plug_data.get("printer_id")
  1292. # Determine plug type (default to tasmota for backwards compatibility)
  1293. plug_type = plug_data.get("plug_type", "tasmota")
  1294. # Find existing plug by IP (Tasmota), entity_id (Home Assistant), or mqtt_topic (MQTT)
  1295. existing = None
  1296. plug_identifier = None
  1297. if plug_type == "homeassistant" and plug_data.get("ha_entity_id"):
  1298. result = await db.execute(select(SmartPlug).where(SmartPlug.ha_entity_id == plug_data["ha_entity_id"]))
  1299. existing = result.scalar_one_or_none()
  1300. plug_identifier = plug_data["ha_entity_id"]
  1301. elif plug_type == "mqtt" and (plug_data.get("mqtt_power_topic") or plug_data.get("mqtt_topic")):
  1302. # Check by mqtt_power_topic first (new format), fall back to mqtt_topic (legacy)
  1303. power_topic = plug_data.get("mqtt_power_topic") or plug_data.get("mqtt_topic")
  1304. result = await db.execute(
  1305. select(SmartPlug).where(
  1306. (SmartPlug.mqtt_power_topic == power_topic) | (SmartPlug.mqtt_topic == power_topic)
  1307. )
  1308. )
  1309. existing = result.scalar_one_or_none()
  1310. plug_identifier = power_topic
  1311. elif plug_data.get("ip_address"):
  1312. result = await db.execute(select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"]))
  1313. existing = result.scalar_one_or_none()
  1314. plug_identifier = plug_data["ip_address"]
  1315. else:
  1316. # Skip invalid plug data
  1317. continue
  1318. if existing:
  1319. if overwrite:
  1320. existing.name = plug_data["name"]
  1321. existing.plug_type = plug_type
  1322. existing.ha_entity_id = plug_data.get("ha_entity_id")
  1323. existing.ha_power_entity = plug_data.get("ha_power_entity")
  1324. existing.ha_energy_today_entity = plug_data.get("ha_energy_today_entity")
  1325. existing.ha_energy_total_entity = plug_data.get("ha_energy_total_entity")
  1326. # MQTT fields (legacy)
  1327. existing.mqtt_topic = plug_data.get("mqtt_topic")
  1328. existing.mqtt_multiplier = plug_data.get("mqtt_multiplier", 1.0)
  1329. # MQTT power fields
  1330. existing.mqtt_power_topic = plug_data.get("mqtt_power_topic")
  1331. existing.mqtt_power_path = plug_data.get("mqtt_power_path")
  1332. existing.mqtt_power_multiplier = plug_data.get("mqtt_power_multiplier", 1.0)
  1333. # MQTT energy fields
  1334. existing.mqtt_energy_topic = plug_data.get("mqtt_energy_topic")
  1335. existing.mqtt_energy_path = plug_data.get("mqtt_energy_path")
  1336. existing.mqtt_energy_multiplier = plug_data.get("mqtt_energy_multiplier", 1.0)
  1337. # MQTT state fields
  1338. existing.mqtt_state_topic = plug_data.get("mqtt_state_topic")
  1339. existing.mqtt_state_path = plug_data.get("mqtt_state_path")
  1340. existing.mqtt_state_on_value = plug_data.get("mqtt_state_on_value")
  1341. existing.printer_id = printer_id
  1342. existing.enabled = plug_data.get("enabled", True)
  1343. existing.auto_on = plug_data.get("auto_on", True)
  1344. existing.auto_off = plug_data.get("auto_off", True)
  1345. existing.off_delay_mode = plug_data.get("off_delay_mode", "time")
  1346. existing.off_delay_minutes = plug_data.get("off_delay_minutes", 5)
  1347. existing.off_temp_threshold = plug_data.get("off_temp_threshold", 70)
  1348. existing.username = plug_data.get("username")
  1349. existing.password = plug_data.get("password")
  1350. existing.power_alert_enabled = plug_data.get("power_alert_enabled", False)
  1351. existing.power_alert_high = plug_data.get("power_alert_high")
  1352. existing.power_alert_low = plug_data.get("power_alert_low")
  1353. existing.schedule_enabled = plug_data.get("schedule_enabled", False)
  1354. existing.schedule_on_time = plug_data.get("schedule_on_time")
  1355. existing.schedule_off_time = plug_data.get("schedule_off_time")
  1356. existing.show_in_switchbar = plug_data.get("show_in_switchbar", False)
  1357. existing.show_on_printer_card = plug_data.get("show_on_printer_card", True)
  1358. restored["smart_plugs"] += 1
  1359. else:
  1360. skipped["smart_plugs"] += 1
  1361. skipped_details["smart_plugs"].append(f"{plug_data['name']} ({plug_identifier})")
  1362. else:
  1363. plug = SmartPlug(
  1364. name=plug_data["name"],
  1365. plug_type=plug_type,
  1366. ip_address=plug_data.get("ip_address"),
  1367. ha_entity_id=plug_data.get("ha_entity_id"),
  1368. ha_power_entity=plug_data.get("ha_power_entity"),
  1369. ha_energy_today_entity=plug_data.get("ha_energy_today_entity"),
  1370. ha_energy_total_entity=plug_data.get("ha_energy_total_entity"),
  1371. # MQTT fields (legacy)
  1372. mqtt_topic=plug_data.get("mqtt_topic"),
  1373. mqtt_multiplier=plug_data.get("mqtt_multiplier", 1.0),
  1374. # MQTT power fields
  1375. mqtt_power_topic=plug_data.get("mqtt_power_topic"),
  1376. mqtt_power_path=plug_data.get("mqtt_power_path"),
  1377. mqtt_power_multiplier=plug_data.get("mqtt_power_multiplier", 1.0),
  1378. # MQTT energy fields
  1379. mqtt_energy_topic=plug_data.get("mqtt_energy_topic"),
  1380. mqtt_energy_path=plug_data.get("mqtt_energy_path"),
  1381. mqtt_energy_multiplier=plug_data.get("mqtt_energy_multiplier", 1.0),
  1382. # MQTT state fields
  1383. mqtt_state_topic=plug_data.get("mqtt_state_topic"),
  1384. mqtt_state_path=plug_data.get("mqtt_state_path"),
  1385. mqtt_state_on_value=plug_data.get("mqtt_state_on_value"),
  1386. printer_id=printer_id,
  1387. enabled=plug_data.get("enabled", True),
  1388. auto_on=plug_data.get("auto_on", True),
  1389. auto_off=plug_data.get("auto_off", True),
  1390. off_delay_mode=plug_data.get("off_delay_mode", "time"),
  1391. off_delay_minutes=plug_data.get("off_delay_minutes", 5),
  1392. off_temp_threshold=plug_data.get("off_temp_threshold", 70),
  1393. username=plug_data.get("username"),
  1394. password=plug_data.get("password"),
  1395. power_alert_enabled=plug_data.get("power_alert_enabled", False),
  1396. power_alert_high=plug_data.get("power_alert_high"),
  1397. power_alert_low=plug_data.get("power_alert_low"),
  1398. schedule_enabled=plug_data.get("schedule_enabled", False),
  1399. schedule_on_time=plug_data.get("schedule_on_time"),
  1400. schedule_off_time=plug_data.get("schedule_off_time"),
  1401. show_in_switchbar=plug_data.get("show_in_switchbar", False),
  1402. show_on_printer_card=plug_data.get("show_on_printer_card", True),
  1403. )
  1404. db.add(plug)
  1405. restored["smart_plugs"] += 1
  1406. # Restore external links (skip or overwrite duplicates by name+url)
  1407. if "external_links" in backup:
  1408. icons_dir = base_dir / "icons"
  1409. icons_dir.mkdir(parents=True, exist_ok=True)
  1410. for link_data in backup["external_links"]:
  1411. result = await db.execute(
  1412. select(ExternalLink).where(ExternalLink.name == link_data["name"], ExternalLink.url == link_data["url"])
  1413. )
  1414. existing = result.scalar_one_or_none()
  1415. if existing:
  1416. if overwrite:
  1417. existing.icon = link_data.get("icon", "link")
  1418. existing.sort_order = link_data.get("sort_order", 0)
  1419. # Handle custom icon
  1420. if link_data.get("custom_icon"):
  1421. existing.custom_icon = link_data["custom_icon"]
  1422. restored["external_links"] += 1
  1423. else:
  1424. skipped["external_links"] += 1
  1425. skipped_details["external_links"].append(link_data["name"])
  1426. else:
  1427. link = ExternalLink(
  1428. name=link_data["name"],
  1429. url=link_data["url"],
  1430. icon=link_data.get("icon", "link"),
  1431. custom_icon=link_data.get("custom_icon"),
  1432. sort_order=link_data.get("sort_order", 0),
  1433. )
  1434. db.add(link)
  1435. restored["external_links"] += 1
  1436. # Restore filaments (skip or overwrite duplicates by name+type+brand)
  1437. if "filaments" in backup:
  1438. for filament_data in backup["filaments"]:
  1439. result = await db.execute(
  1440. select(Filament).where(
  1441. Filament.name == filament_data["name"],
  1442. Filament.type == filament_data["type"],
  1443. Filament.brand == filament_data.get("brand"),
  1444. )
  1445. )
  1446. existing = result.scalar_one_or_none()
  1447. if existing:
  1448. if overwrite:
  1449. existing.color = filament_data.get("color")
  1450. existing.color_hex = filament_data.get("color_hex")
  1451. existing.cost_per_kg = filament_data.get("cost_per_kg", 25.0)
  1452. existing.spool_weight_g = filament_data.get("spool_weight_g", 1000.0)
  1453. existing.currency = filament_data.get("currency", "USD")
  1454. existing.density = filament_data.get("density")
  1455. existing.print_temp_min = filament_data.get("print_temp_min")
  1456. existing.print_temp_max = filament_data.get("print_temp_max")
  1457. existing.bed_temp_min = filament_data.get("bed_temp_min")
  1458. existing.bed_temp_max = filament_data.get("bed_temp_max")
  1459. restored["filaments"] += 1
  1460. else:
  1461. skipped["filaments"] += 1
  1462. skipped_details["filaments"].append(
  1463. f"{filament_data.get('brand', '')} {filament_data['name']} ({filament_data['type']})"
  1464. )
  1465. else:
  1466. filament = Filament(
  1467. name=filament_data["name"],
  1468. type=filament_data["type"],
  1469. brand=filament_data.get("brand"),
  1470. color=filament_data.get("color"),
  1471. color_hex=filament_data.get("color_hex"),
  1472. cost_per_kg=filament_data.get("cost_per_kg", 25.0),
  1473. spool_weight_g=filament_data.get("spool_weight_g", 1000.0),
  1474. currency=filament_data.get("currency", "USD"),
  1475. density=filament_data.get("density"),
  1476. print_temp_min=filament_data.get("print_temp_min"),
  1477. print_temp_max=filament_data.get("print_temp_max"),
  1478. bed_temp_min=filament_data.get("bed_temp_min"),
  1479. bed_temp_max=filament_data.get("bed_temp_max"),
  1480. )
  1481. db.add(filament)
  1482. restored["filaments"] += 1
  1483. # Restore maintenance types (skip or overwrite duplicates by name)
  1484. if "maintenance_types" in backup:
  1485. for mt_data in backup["maintenance_types"]:
  1486. result = await db.execute(select(MaintenanceType).where(MaintenanceType.name == mt_data["name"]))
  1487. existing = result.scalar_one_or_none()
  1488. if existing:
  1489. if overwrite:
  1490. existing.description = mt_data.get("description")
  1491. existing.default_interval_hours = mt_data.get("default_interval_hours", 100.0)
  1492. existing.interval_type = mt_data.get("interval_type", "hours")
  1493. existing.icon = mt_data.get("icon")
  1494. # Don't overwrite is_system
  1495. restored["maintenance_types"] += 1
  1496. else:
  1497. skipped["maintenance_types"] += 1
  1498. skipped_details["maintenance_types"].append(mt_data["name"])
  1499. else:
  1500. mt = MaintenanceType(
  1501. name=mt_data["name"],
  1502. description=mt_data.get("description"),
  1503. default_interval_hours=mt_data.get("default_interval_hours", 100.0),
  1504. interval_type=mt_data.get("interval_type", "hours"),
  1505. icon=mt_data.get("icon"),
  1506. is_system=mt_data.get("is_system", False),
  1507. )
  1508. db.add(mt)
  1509. restored["maintenance_types"] += 1
  1510. # Restore printer maintenance settings (per-printer)
  1511. if "printer_maintenance" in backup:
  1512. # Build lookups
  1513. printer_serial_to_id: dict[str, int] = {}
  1514. maint_type_name_to_id: dict[str, int] = {}
  1515. pr_result = await db.execute(select(Printer))
  1516. for pr in pr_result.scalars().all():
  1517. printer_serial_to_id[pr.serial_number] = pr.id
  1518. mt_result = await db.execute(select(MaintenanceType))
  1519. for mt in mt_result.scalars().all():
  1520. maint_type_name_to_id[mt.name] = mt.id
  1521. restored["printer_maintenance"] = 0
  1522. skipped["printer_maintenance"] = 0
  1523. skipped_details["printer_maintenance"] = []
  1524. for pm_data in backup["printer_maintenance"]:
  1525. printer_serial = pm_data.get("printer_serial")
  1526. maint_type_name = pm_data.get("maintenance_type_name")
  1527. if not printer_serial or not maint_type_name:
  1528. continue
  1529. printer_id = printer_serial_to_id.get(printer_serial)
  1530. maint_type_id = maint_type_name_to_id.get(maint_type_name)
  1531. if not printer_id or not maint_type_id:
  1532. skipped["printer_maintenance"] += 1
  1533. skipped_details["printer_maintenance"].append(f"{printer_serial}/{maint_type_name}")
  1534. continue
  1535. # Check if exists
  1536. result = await db.execute(
  1537. select(PrinterMaintenance).where(
  1538. PrinterMaintenance.printer_id == printer_id,
  1539. PrinterMaintenance.maintenance_type_id == maint_type_id,
  1540. )
  1541. )
  1542. existing = result.scalar_one_or_none()
  1543. if existing:
  1544. if overwrite:
  1545. existing.custom_interval_hours = pm_data.get("custom_interval_hours")
  1546. existing.custom_interval_type = pm_data.get("custom_interval_type")
  1547. existing.enabled = pm_data.get("enabled", True)
  1548. existing.last_performed_hours = pm_data.get("last_performed_hours", 0.0)
  1549. if pm_data.get("last_performed_at"):
  1550. existing.last_performed_at = datetime.fromisoformat(pm_data["last_performed_at"])
  1551. restored["printer_maintenance"] += 1
  1552. else:
  1553. skipped["printer_maintenance"] += 1
  1554. skipped_details["printer_maintenance"].append(f"{printer_serial}/{maint_type_name}")
  1555. else:
  1556. pm = PrinterMaintenance(
  1557. printer_id=printer_id,
  1558. maintenance_type_id=maint_type_id,
  1559. custom_interval_hours=pm_data.get("custom_interval_hours"),
  1560. custom_interval_type=pm_data.get("custom_interval_type"),
  1561. enabled=pm_data.get("enabled", True),
  1562. last_performed_hours=pm_data.get("last_performed_hours", 0.0),
  1563. )
  1564. if pm_data.get("last_performed_at"):
  1565. pm.last_performed_at = datetime.fromisoformat(pm_data["last_performed_at"])
  1566. db.add(pm)
  1567. restored["printer_maintenance"] += 1
  1568. # Restore maintenance history
  1569. if "maintenance_history" in backup:
  1570. # Build lookups
  1571. printer_serial_to_id: dict[str, int] = {}
  1572. maint_type_name_to_id: dict[str, int] = {}
  1573. pr_result = await db.execute(select(Printer))
  1574. for pr in pr_result.scalars().all():
  1575. printer_serial_to_id[pr.serial_number] = pr.id
  1576. mt_result = await db.execute(select(MaintenanceType))
  1577. for mt in mt_result.scalars().all():
  1578. maint_type_name_to_id[mt.name] = mt.id
  1579. restored["maintenance_history"] = 0
  1580. skipped["maintenance_history"] = 0
  1581. skipped_details["maintenance_history"] = []
  1582. for mh_data in backup["maintenance_history"]:
  1583. printer_serial = mh_data.get("printer_serial")
  1584. maint_type_name = mh_data.get("maintenance_type_name")
  1585. if not printer_serial or not maint_type_name:
  1586. continue
  1587. printer_id = printer_serial_to_id.get(printer_serial)
  1588. maint_type_id = maint_type_name_to_id.get(maint_type_name)
  1589. if not printer_id or not maint_type_id:
  1590. skipped["maintenance_history"] += 1
  1591. continue
  1592. # Find the PrinterMaintenance record
  1593. result = await db.execute(
  1594. select(PrinterMaintenance).where(
  1595. PrinterMaintenance.printer_id == printer_id,
  1596. PrinterMaintenance.maintenance_type_id == maint_type_id,
  1597. )
  1598. )
  1599. pm = result.scalar_one_or_none()
  1600. if not pm:
  1601. skipped["maintenance_history"] += 1
  1602. continue
  1603. # Create history entry (no duplicate check - history is append-only)
  1604. mh = MaintenanceHistory(
  1605. printer_maintenance_id=pm.id,
  1606. hours_at_maintenance=mh_data.get("hours_at_maintenance", 0.0),
  1607. notes=mh_data.get("notes"),
  1608. )
  1609. if mh_data.get("performed_at"):
  1610. mh.performed_at = datetime.fromisoformat(mh_data["performed_at"])
  1611. db.add(mh)
  1612. restored["maintenance_history"] += 1
  1613. # Restore archives (skip duplicates by content_hash - overwrite not supported for archives)
  1614. if "archives" in backup:
  1615. # Build printer serial to ID mapping
  1616. printer_serial_to_id: dict[str, int] = {}
  1617. printer_result = await db.execute(select(Printer))
  1618. for pr in printer_result.scalars().all():
  1619. printer_serial_to_id[pr.serial_number] = pr.id
  1620. for archive_data in backup["archives"]:
  1621. # Skip if no content_hash or already exists
  1622. content_hash = archive_data.get("content_hash")
  1623. if content_hash:
  1624. result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash == content_hash))
  1625. existing = result.scalar_one_or_none()
  1626. if existing:
  1627. skipped["archives"] += 1
  1628. skipped_details["archives"].append(archive_data.get("filename", "Unknown"))
  1629. continue
  1630. # Only restore if file exists (from ZIP extraction)
  1631. file_path = archive_data.get("file_path")
  1632. if file_path and (base_dir / file_path).exists():
  1633. # Look up printer_id from serial
  1634. printer_serial = archive_data.get("printer_serial")
  1635. printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else None
  1636. archive = PrintArchive(
  1637. filename=archive_data["filename"],
  1638. file_path=file_path,
  1639. file_size=archive_data.get("file_size", 0),
  1640. content_hash=content_hash,
  1641. printer_id=printer_id,
  1642. thumbnail_path=archive_data.get("thumbnail_path"),
  1643. timelapse_path=archive_data.get("timelapse_path"),
  1644. source_3mf_path=archive_data.get("source_3mf_path"),
  1645. f3d_path=archive_data.get("f3d_path"),
  1646. print_name=archive_data.get("print_name"),
  1647. print_time_seconds=archive_data.get("print_time_seconds"),
  1648. filament_used_grams=archive_data.get("filament_used_grams"),
  1649. filament_type=archive_data.get("filament_type"),
  1650. filament_color=archive_data.get("filament_color"),
  1651. layer_height=archive_data.get("layer_height"),
  1652. total_layers=archive_data.get("total_layers"),
  1653. nozzle_diameter=archive_data.get("nozzle_diameter"),
  1654. bed_temperature=archive_data.get("bed_temperature"),
  1655. nozzle_temperature=archive_data.get("nozzle_temperature"),
  1656. status=archive_data.get("status", "completed"),
  1657. makerworld_url=archive_data.get("makerworld_url"),
  1658. designer=archive_data.get("designer"),
  1659. external_url=archive_data.get("external_url"),
  1660. is_favorite=archive_data.get("is_favorite", False),
  1661. tags=archive_data.get("tags"),
  1662. notes=archive_data.get("notes"),
  1663. cost=archive_data.get("cost"),
  1664. failure_reason=archive_data.get("failure_reason"),
  1665. quantity=archive_data.get("quantity", 1),
  1666. energy_kwh=archive_data.get("energy_kwh"),
  1667. energy_cost=archive_data.get("energy_cost"),
  1668. extra_data=archive_data.get("extra_data"),
  1669. photos=archive_data.get("photos"),
  1670. )
  1671. db.add(archive)
  1672. restored["archives"] = restored.get("archives", 0) + 1
  1673. # Restore projects (skip or overwrite duplicates by name)
  1674. if "projects" in backup:
  1675. for project_data in backup["projects"]:
  1676. result = await db.execute(select(Project).where(Project.name == project_data["name"]))
  1677. existing = result.scalar_one_or_none()
  1678. if existing:
  1679. if overwrite:
  1680. # Update existing project
  1681. existing.description = project_data.get("description")
  1682. existing.color = project_data.get("color")
  1683. existing.status = project_data.get("status", "active")
  1684. existing.target_count = project_data.get("target_count")
  1685. existing.notes = project_data.get("notes")
  1686. existing.tags = project_data.get("tags")
  1687. existing.priority = project_data.get("priority", "normal")
  1688. existing.budget = project_data.get("budget")
  1689. existing.is_template = project_data.get("is_template", False)
  1690. existing.template_source_id = project_data.get("template_source_id")
  1691. existing.parent_id = project_data.get("parent_id")
  1692. existing.attachments = project_data.get("attachments")
  1693. if project_data.get("due_date"):
  1694. existing.due_date = datetime.fromisoformat(project_data["due_date"])
  1695. # Delete existing BOM items and re-add
  1696. await db.execute(ProjectBOMItem.__table__.delete().where(ProjectBOMItem.project_id == existing.id))
  1697. for bom_data in project_data.get("bom_items", []):
  1698. bom_item = ProjectBOMItem(
  1699. project_id=existing.id,
  1700. name=bom_data["name"],
  1701. quantity_needed=bom_data.get("quantity_needed", 1),
  1702. quantity_acquired=bom_data.get("quantity_acquired", 0),
  1703. unit_price=bom_data.get("unit_price"),
  1704. sourcing_url=bom_data.get("sourcing_url"),
  1705. stl_filename=bom_data.get("stl_filename"),
  1706. remarks=bom_data.get("remarks"),
  1707. sort_order=bom_data.get("sort_order", 0),
  1708. )
  1709. db.add(bom_item)
  1710. restored["projects"] += 1
  1711. else:
  1712. skipped["projects"] += 1
  1713. skipped_details["projects"].append(project_data["name"])
  1714. else:
  1715. # Create new project
  1716. project = Project(
  1717. name=project_data["name"],
  1718. description=project_data.get("description"),
  1719. color=project_data.get("color"),
  1720. status=project_data.get("status", "active"),
  1721. target_count=project_data.get("target_count"),
  1722. notes=project_data.get("notes"),
  1723. tags=project_data.get("tags"),
  1724. priority=project_data.get("priority", "normal"),
  1725. budget=project_data.get("budget"),
  1726. is_template=project_data.get("is_template", False),
  1727. template_source_id=project_data.get("template_source_id"),
  1728. parent_id=project_data.get("parent_id"),
  1729. attachments=project_data.get("attachments"),
  1730. )
  1731. if project_data.get("due_date"):
  1732. project.due_date = datetime.fromisoformat(project_data["due_date"])
  1733. db.add(project)
  1734. await db.flush() # Get the project ID
  1735. # Add BOM items
  1736. for bom_data in project_data.get("bom_items", []):
  1737. bom_item = ProjectBOMItem(
  1738. project_id=project.id,
  1739. name=bom_data["name"],
  1740. quantity_needed=bom_data.get("quantity_needed", 1),
  1741. quantity_acquired=bom_data.get("quantity_acquired", 0),
  1742. unit_price=bom_data.get("unit_price"),
  1743. sourcing_url=bom_data.get("sourcing_url"),
  1744. stl_filename=bom_data.get("stl_filename"),
  1745. remarks=bom_data.get("remarks"),
  1746. sort_order=bom_data.get("sort_order", 0),
  1747. )
  1748. db.add(bom_item)
  1749. restored["projects"] += 1
  1750. # Link archives to projects by name (after both are restored)
  1751. if "archives" in backup and "projects" in backup:
  1752. # Build project name to ID mapping
  1753. proj_result = await db.execute(select(Project))
  1754. project_name_to_id: dict[str, int] = {}
  1755. for proj in proj_result.scalars().all():
  1756. project_name_to_id[proj.name] = proj.id
  1757. # Update archives with project_id
  1758. for archive_data in backup["archives"]:
  1759. project_name = archive_data.get("project_name")
  1760. if project_name and project_name in project_name_to_id:
  1761. content_hash = archive_data.get("content_hash")
  1762. if content_hash:
  1763. result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash == content_hash))
  1764. archive = result.scalar_one_or_none()
  1765. if archive:
  1766. archive.project_id = project_name_to_id[project_name]
  1767. # Restore print queue (must be after archives and projects)
  1768. if "print_queue" in backup:
  1769. # Build lookups
  1770. printer_serial_to_id: dict[str, int] = {}
  1771. archive_hash_to_id: dict[str, int] = {}
  1772. project_name_to_id: dict[str, int] = {}
  1773. pr_result = await db.execute(select(Printer))
  1774. for pr in pr_result.scalars().all():
  1775. printer_serial_to_id[pr.serial_number] = pr.id
  1776. ar_result = await db.execute(select(PrintArchive))
  1777. for ar in ar_result.scalars().all():
  1778. if ar.content_hash:
  1779. archive_hash_to_id[ar.content_hash] = ar.id
  1780. proj_result = await db.execute(select(Project))
  1781. for proj in proj_result.scalars().all():
  1782. project_name_to_id[proj.name] = proj.id
  1783. restored["print_queue"] = 0
  1784. skipped["print_queue"] = 0
  1785. skipped_details["print_queue"] = []
  1786. for qi_data in backup["print_queue"]:
  1787. printer_serial = qi_data.get("printer_serial") # Can be None for unassigned items
  1788. archive_hash = qi_data.get("archive_hash")
  1789. # Archive is required, but printer can be None (unassigned)
  1790. if not archive_hash:
  1791. skipped["print_queue"] += 1
  1792. continue
  1793. # Look up printer_id (None if unassigned or printer not found)
  1794. printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else None
  1795. archive_id = archive_hash_to_id.get(archive_hash)
  1796. # Archive must exist, but printer is optional (unassigned items)
  1797. if not archive_id:
  1798. skipped["print_queue"] += 1
  1799. skipped_details["print_queue"].append(
  1800. f"{printer_serial or 'unassigned'}/{archive_hash[:8] if archive_hash else 'N/A'}"
  1801. )
  1802. continue
  1803. # If printer_serial was specified but printer not found, skip
  1804. if printer_serial and not printer_id:
  1805. skipped["print_queue"] += 1
  1806. skipped_details["print_queue"].append(f"{printer_serial}/{archive_hash[:8]}")
  1807. continue
  1808. project_name = qi_data.get("project_name")
  1809. project_id = project_name_to_id.get(project_name) if project_name else None
  1810. qi = PrintQueueItem(
  1811. printer_id=printer_id, # Can be None for unassigned items
  1812. archive_id=archive_id,
  1813. project_id=project_id,
  1814. position=qi_data.get("position", 0),
  1815. require_previous_success=qi_data.get("require_previous_success", False),
  1816. auto_off_after=qi_data.get("auto_off_after", False),
  1817. manual_start=qi_data.get("manual_start", False),
  1818. ams_mapping=qi_data.get("ams_mapping"),
  1819. plate_id=qi_data.get("plate_id"),
  1820. bed_levelling=qi_data.get("bed_levelling", True),
  1821. flow_cali=qi_data.get("flow_cali", False),
  1822. vibration_cali=qi_data.get("vibration_cali", True),
  1823. layer_inspect=qi_data.get("layer_inspect", False),
  1824. timelapse=qi_data.get("timelapse", False),
  1825. use_ams=qi_data.get("use_ams", True),
  1826. status=qi_data.get("status", "pending"),
  1827. error_message=qi_data.get("error_message"),
  1828. )
  1829. if qi_data.get("scheduled_time"):
  1830. qi.scheduled_time = datetime.fromisoformat(qi_data["scheduled_time"])
  1831. if qi_data.get("started_at"):
  1832. qi.started_at = datetime.fromisoformat(qi_data["started_at"])
  1833. if qi_data.get("completed_at"):
  1834. qi.completed_at = datetime.fromisoformat(qi_data["completed_at"])
  1835. db.add(qi)
  1836. restored["print_queue"] += 1
  1837. # Restore pending uploads (skip duplicates by filename)
  1838. if "pending_uploads" in backup:
  1839. # Ensure the pending uploads directory exists
  1840. pending_uploads_dir = base_dir / "virtual_printer" / "uploads"
  1841. pending_uploads_dir.mkdir(parents=True, exist_ok=True)
  1842. for upload_data in backup["pending_uploads"]:
  1843. # Check for existing by filename
  1844. result = await db.execute(
  1845. select(PendingUpload).where(
  1846. PendingUpload.filename == upload_data["filename"],
  1847. PendingUpload.status == "pending",
  1848. )
  1849. )
  1850. existing = result.scalar_one_or_none()
  1851. if existing:
  1852. if overwrite:
  1853. # Update existing
  1854. existing.file_size = upload_data.get("file_size", 0)
  1855. existing.source_ip = upload_data.get("source_ip")
  1856. existing.tags = upload_data.get("tags")
  1857. existing.notes = upload_data.get("notes")
  1858. existing.project_id = upload_data.get("project_id")
  1859. # Update file path if file was restored from ZIP
  1860. if upload_data.get("file_path"):
  1861. restored_file = base_dir / upload_data["file_path"]
  1862. if restored_file.exists():
  1863. # Move to proper location
  1864. target_path = pending_uploads_dir / upload_data["filename"]
  1865. if restored_file != target_path:
  1866. import shutil
  1867. shutil.move(str(restored_file), str(target_path))
  1868. existing.file_path = str(target_path)
  1869. restored["pending_uploads"] += 1
  1870. else:
  1871. skipped["pending_uploads"] += 1
  1872. skipped_details["pending_uploads"].append(upload_data["filename"])
  1873. else:
  1874. # Determine file path
  1875. file_path_str = None
  1876. if upload_data.get("file_path"):
  1877. restored_file = base_dir / upload_data["file_path"]
  1878. if restored_file.exists():
  1879. # Move to proper location
  1880. target_path = pending_uploads_dir / upload_data["filename"]
  1881. if restored_file != target_path:
  1882. import shutil
  1883. shutil.move(str(restored_file), str(target_path))
  1884. file_path_str = str(target_path)
  1885. # Parse uploaded_at
  1886. uploaded_at = None
  1887. if upload_data.get("uploaded_at"):
  1888. try:
  1889. uploaded_at = datetime.fromisoformat(upload_data["uploaded_at"].replace("Z", "+00:00"))
  1890. except (ValueError, AttributeError):
  1891. uploaded_at = datetime.utcnow()
  1892. else:
  1893. uploaded_at = datetime.utcnow()
  1894. pending = PendingUpload(
  1895. filename=upload_data["filename"],
  1896. file_path=file_path_str or "",
  1897. file_size=upload_data.get("file_size", 0),
  1898. source_ip=upload_data.get("source_ip"),
  1899. status="pending",
  1900. tags=upload_data.get("tags"),
  1901. notes=upload_data.get("notes"),
  1902. project_id=upload_data.get("project_id"),
  1903. uploaded_at=uploaded_at,
  1904. )
  1905. db.add(pending)
  1906. restored["pending_uploads"] += 1
  1907. # Restore API keys (generates new keys since we can't restore the hash)
  1908. new_api_keys: list[dict] = [] # Track newly generated keys for response
  1909. if "api_keys" in backup:
  1910. from backend.app.core.auth import generate_api_key
  1911. # Build printer serial to ID mapping
  1912. printer_serial_to_id: dict[str, int] = {}
  1913. pr_result = await db.execute(select(Printer))
  1914. for pr in pr_result.scalars().all():
  1915. printer_serial_to_id[pr.serial_number] = pr.id
  1916. restored["api_keys"] = 0
  1917. skipped["api_keys"] = 0
  1918. skipped_details["api_keys"] = []
  1919. for key_data in backup["api_keys"]:
  1920. # Check if key with same name already exists
  1921. result = await db.execute(select(APIKey).where(APIKey.name == key_data["name"]))
  1922. existing = result.scalar_one_or_none()
  1923. if existing:
  1924. if overwrite:
  1925. # Update permissions but keep the existing key
  1926. existing.can_queue = key_data.get("can_queue", True)
  1927. existing.can_control_printer = key_data.get("can_control_printer", False)
  1928. existing.can_read_status = key_data.get("can_read_status", True)
  1929. existing.enabled = key_data.get("enabled", True)
  1930. if key_data.get("expires_at"):
  1931. existing.expires_at = datetime.fromisoformat(key_data["expires_at"])
  1932. # Convert printer serials to IDs
  1933. if key_data.get("printer_serials"):
  1934. existing.printer_ids = [
  1935. printer_serial_to_id[s] for s in key_data["printer_serials"] if s in printer_serial_to_id
  1936. ]
  1937. restored["api_keys"] += 1
  1938. else:
  1939. skipped["api_keys"] += 1
  1940. skipped_details["api_keys"].append(key_data["name"])
  1941. else:
  1942. # Generate new key
  1943. full_key, key_hash, key_prefix = generate_api_key()
  1944. # Convert printer serials to IDs
  1945. printer_ids = None
  1946. if key_data.get("printer_serials"):
  1947. printer_ids = [
  1948. printer_serial_to_id[s] for s in key_data["printer_serials"] if s in printer_serial_to_id
  1949. ]
  1950. api_key = APIKey(
  1951. name=key_data["name"],
  1952. key_hash=key_hash,
  1953. key_prefix=key_prefix,
  1954. can_queue=key_data.get("can_queue", True),
  1955. can_control_printer=key_data.get("can_control_printer", False),
  1956. can_read_status=key_data.get("can_read_status", True),
  1957. printer_ids=printer_ids,
  1958. enabled=key_data.get("enabled", True),
  1959. )
  1960. if key_data.get("expires_at"):
  1961. api_key.expires_at = datetime.fromisoformat(key_data["expires_at"])
  1962. db.add(api_key)
  1963. restored["api_keys"] += 1
  1964. # Track the new key so user can see it
  1965. new_api_keys.append(
  1966. {
  1967. "name": key_data["name"],
  1968. "key": full_key,
  1969. "key_prefix": key_prefix,
  1970. }
  1971. )
  1972. # Restore groups (before users, so groups exist for assignment)
  1973. if "groups" in backup:
  1974. for group_data in backup["groups"]:
  1975. result = await db.execute(select(Group).where(Group.name == group_data["name"]))
  1976. existing = result.scalar_one_or_none()
  1977. if existing:
  1978. if overwrite and not existing.is_system:
  1979. # Update non-system groups
  1980. existing.description = group_data.get("description")
  1981. existing.permissions = group_data.get("permissions", [])
  1982. restored["groups"] += 1
  1983. else:
  1984. skipped["groups"] += 1
  1985. skipped_details["groups"].append(group_data["name"])
  1986. else:
  1987. group = Group(
  1988. name=group_data["name"],
  1989. description=group_data.get("description"),
  1990. permissions=group_data.get("permissions", []),
  1991. is_system=group_data.get("is_system", False),
  1992. )
  1993. db.add(group)
  1994. restored["groups"] += 1
  1995. # Flush to ensure groups are persisted before user assignment
  1996. await db.flush()
  1997. # Build group name to object lookup for user assignment
  1998. group_name_to_obj: dict[str, Group] = {}
  1999. result = await db.execute(select(Group))
  2000. for g in result.scalars().all():
  2001. group_name_to_obj[g.name] = g
  2002. # Restore users (note: passwords not included in backup - users will need new passwords)
  2003. # Users are skipped by default since they have no passwords; admin must recreate them
  2004. new_users: list[str] = []
  2005. if "users" in backup:
  2006. from backend.app.core.auth import get_password_hash
  2007. for user_data in backup["users"]:
  2008. result = await db.execute(select(User).where(User.username == user_data["username"]))
  2009. existing = result.scalar_one_or_none()
  2010. if existing:
  2011. if overwrite:
  2012. existing.role = user_data.get("role", "user")
  2013. existing.is_active = user_data.get("is_active", True)
  2014. # Assign groups if provided
  2015. group_names = user_data.get("groups", [])
  2016. if group_names:
  2017. existing.groups = [group_name_to_obj[name] for name in group_names if name in group_name_to_obj]
  2018. # Don't change password - keep existing
  2019. restored["users"] += 1
  2020. else:
  2021. skipped["users"] += 1
  2022. skipped_details["users"].append(user_data["username"])
  2023. else:
  2024. # Create user with a temporary password that must be changed
  2025. # Generate a random temporary password
  2026. import secrets
  2027. temp_password = secrets.token_urlsafe(16)
  2028. user = User(
  2029. username=user_data["username"],
  2030. password_hash=get_password_hash(temp_password),
  2031. role=user_data.get("role", "user"),
  2032. is_active=user_data.get("is_active", True),
  2033. )
  2034. # Assign groups if provided
  2035. group_names = user_data.get("groups", [])
  2036. if group_names:
  2037. user.groups = [group_name_to_obj[name] for name in group_names if name in group_name_to_obj]
  2038. db.add(user)
  2039. restored["users"] += 1
  2040. new_users.append(f"{user_data['username']} (temp password: {temp_password})")
  2041. # Restore GitHub backup configuration (note: access_token not included for security)
  2042. if "github_backup" in backup:
  2043. github_data = backup["github_backup"]
  2044. result = await db.execute(select(GitHubBackupConfig).limit(1))
  2045. existing = result.scalar_one_or_none()
  2046. if existing:
  2047. if overwrite:
  2048. existing.repository_url = github_data.get("repository_url", existing.repository_url)
  2049. existing.branch = github_data.get("branch", existing.branch)
  2050. existing.schedule_enabled = github_data.get("schedule_enabled", existing.schedule_enabled)
  2051. existing.schedule_type = github_data.get("schedule_type", existing.schedule_type)
  2052. existing.backup_kprofiles = github_data.get("backup_kprofiles", existing.backup_kprofiles)
  2053. existing.backup_cloud_profiles = github_data.get(
  2054. "backup_cloud_profiles", existing.backup_cloud_profiles
  2055. )
  2056. existing.backup_settings = github_data.get("backup_settings", existing.backup_settings)
  2057. existing.enabled = github_data.get("enabled", existing.enabled)
  2058. # Note: access_token must be re-entered after restore
  2059. restored["github_backup"] += 1
  2060. else:
  2061. skipped["github_backup"] += 1
  2062. else:
  2063. config = GitHubBackupConfig(
  2064. repository_url=github_data.get("repository_url", ""),
  2065. access_token="", # Must be entered after restore
  2066. branch=github_data.get("branch", "main"),
  2067. schedule_enabled=github_data.get("schedule_enabled", False),
  2068. schedule_type=github_data.get("schedule_type", "daily"),
  2069. backup_kprofiles=github_data.get("backup_kprofiles", True),
  2070. backup_cloud_profiles=github_data.get("backup_cloud_profiles", True),
  2071. backup_settings=github_data.get("backup_settings", False),
  2072. enabled=False, # Disabled until token is entered
  2073. )
  2074. db.add(config)
  2075. restored["github_backup"] += 1
  2076. await db.commit()
  2077. # If printers were in the backup (restored, updated, or skipped), reconnect all active printers
  2078. # This ensures connections are re-established after restore, even if printers were skipped
  2079. if "printers" in backup:
  2080. # Need fresh query after commit to get proper IDs for newly created printers
  2081. result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
  2082. active_printers = result.scalars().all()
  2083. for printer in active_printers:
  2084. # This will disconnect existing connection (if any) and reconnect
  2085. try:
  2086. await printer_manager.connect_printer(printer)
  2087. except Exception:
  2088. pass # Connection failed, but don't fail the restore
  2089. # If settings were restored, check if Spoolman needs to be reconnected
  2090. if "settings" in backup:
  2091. spoolman_enabled = await get_setting(db, "spoolman_enabled")
  2092. spoolman_url = await get_setting(db, "spoolman_url")
  2093. if spoolman_enabled and spoolman_enabled.lower() == "true" and spoolman_url:
  2094. try:
  2095. client = await init_spoolman_client(spoolman_url)
  2096. if await client.health_check():
  2097. pass # Connected successfully
  2098. except Exception:
  2099. pass # Spoolman connection failed, but don't fail the restore
  2100. # Reconfigure virtual printer if settings were restored
  2101. try:
  2102. from backend.app.services.virtual_printer import virtual_printer_manager
  2103. vp_enabled = await get_setting(db, "virtual_printer_enabled")
  2104. vp_access_code = await get_setting(db, "virtual_printer_access_code")
  2105. vp_mode = await get_setting(db, "virtual_printer_mode")
  2106. vp_model = await get_setting(db, "virtual_printer_model")
  2107. enabled = vp_enabled and vp_enabled.lower() == "true"
  2108. access_code = vp_access_code or ""
  2109. mode = vp_mode or "immediate"
  2110. model = vp_model or ""
  2111. if enabled and access_code:
  2112. await virtual_printer_manager.configure(
  2113. enabled=True,
  2114. access_code=access_code,
  2115. mode=mode,
  2116. model=model,
  2117. )
  2118. elif not enabled and virtual_printer_manager.is_enabled:
  2119. await virtual_printer_manager.configure(
  2120. enabled=False,
  2121. access_code=access_code,
  2122. mode=mode,
  2123. model=model,
  2124. )
  2125. except Exception:
  2126. pass # Virtual printer config failed, but don't fail the restore
  2127. # Reconfigure MQTT relay if settings were restored
  2128. try:
  2129. from backend.app.services.mqtt_relay import mqtt_relay
  2130. mqtt_settings = {
  2131. "mqtt_enabled": (await get_setting(db, "mqtt_enabled") or "false") == "true",
  2132. "mqtt_broker": await get_setting(db, "mqtt_broker") or "",
  2133. "mqtt_port": int(await get_setting(db, "mqtt_port") or "1883"),
  2134. "mqtt_username": await get_setting(db, "mqtt_username") or "",
  2135. "mqtt_password": await get_setting(db, "mqtt_password") or "",
  2136. "mqtt_topic_prefix": await get_setting(db, "mqtt_topic_prefix") or "bambuddy",
  2137. "mqtt_use_tls": (await get_setting(db, "mqtt_use_tls") or "false") == "true",
  2138. }
  2139. await mqtt_relay.configure(mqtt_settings)
  2140. except Exception:
  2141. pass # MQTT relay config failed, but don't fail the restore
  2142. # Build summary message
  2143. restored_parts = []
  2144. for key, count in restored.items():
  2145. if count > 0:
  2146. restored_parts.append(f"{count} {key.replace('_', ' ')}")
  2147. if files_restored > 0:
  2148. restored_parts.append(f"{files_restored} files")
  2149. skipped_parts = []
  2150. total_skipped = sum(skipped.values())
  2151. for key, count in skipped.items():
  2152. if count > 0:
  2153. skipped_parts.append(f"{count} {key.replace('_', ' ')}")
  2154. message_parts = []
  2155. if restored_parts:
  2156. message_parts.append(f"Restored: {', '.join(restored_parts)}")
  2157. if skipped_parts:
  2158. message_parts.append(f"Skipped (already exist): {', '.join(skipped_parts)}")
  2159. response = {
  2160. "success": True,
  2161. "message": ". ".join(message_parts) if message_parts else "Nothing to restore",
  2162. "restored": restored,
  2163. "skipped": skipped,
  2164. "skipped_details": skipped_details,
  2165. "files_restored": files_restored,
  2166. "total_skipped": total_skipped,
  2167. }
  2168. # Include newly generated API keys if any (so user can see them)
  2169. if new_api_keys:
  2170. response["new_api_keys"] = new_api_keys
  2171. # Include newly created users with temp passwords (so admin can share them)
  2172. if new_users:
  2173. response["new_users"] = new_users
  2174. return response
  2175. # =============================================================================
  2176. # Virtual Printer Settings
  2177. # =============================================================================
  2178. @router.get("/virtual-printer/models")
  2179. async def get_virtual_printer_models():
  2180. """Get available virtual printer models."""
  2181. from backend.app.services.virtual_printer import (
  2182. DEFAULT_VIRTUAL_PRINTER_MODEL,
  2183. VIRTUAL_PRINTER_MODELS,
  2184. )
  2185. return {
  2186. "models": VIRTUAL_PRINTER_MODELS,
  2187. "default": DEFAULT_VIRTUAL_PRINTER_MODEL,
  2188. }
  2189. @router.get("/virtual-printer")
  2190. async def get_virtual_printer_settings(db: AsyncSession = Depends(get_db)):
  2191. """Get virtual printer settings and status."""
  2192. from backend.app.services.virtual_printer import (
  2193. DEFAULT_VIRTUAL_PRINTER_MODEL,
  2194. virtual_printer_manager,
  2195. )
  2196. enabled = await get_setting(db, "virtual_printer_enabled")
  2197. access_code = await get_setting(db, "virtual_printer_access_code")
  2198. mode = await get_setting(db, "virtual_printer_mode")
  2199. model = await get_setting(db, "virtual_printer_model")
  2200. return {
  2201. "enabled": enabled == "true" if enabled else False,
  2202. "access_code_set": bool(access_code),
  2203. "mode": mode or "immediate",
  2204. "model": model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  2205. "status": virtual_printer_manager.get_status(),
  2206. }
  2207. @router.put("/virtual-printer")
  2208. async def update_virtual_printer_settings(
  2209. enabled: bool = None,
  2210. access_code: str = None,
  2211. mode: str = None,
  2212. model: str = None,
  2213. db: AsyncSession = Depends(get_db),
  2214. ):
  2215. """Update virtual printer settings and restart services if needed."""
  2216. from backend.app.services.virtual_printer import (
  2217. DEFAULT_VIRTUAL_PRINTER_MODEL,
  2218. VIRTUAL_PRINTER_MODELS,
  2219. virtual_printer_manager,
  2220. )
  2221. # Get current values
  2222. current_enabled = await get_setting(db, "virtual_printer_enabled") == "true"
  2223. current_access_code = await get_setting(db, "virtual_printer_access_code") or ""
  2224. current_mode = await get_setting(db, "virtual_printer_mode") or "immediate"
  2225. current_model = await get_setting(db, "virtual_printer_model") or DEFAULT_VIRTUAL_PRINTER_MODEL
  2226. # Apply updates
  2227. new_enabled = enabled if enabled is not None else current_enabled
  2228. new_access_code = access_code if access_code is not None else current_access_code
  2229. new_mode = mode if mode is not None else current_mode
  2230. new_model = model if model is not None else current_model
  2231. # Validate mode
  2232. # "review" is the new name for "queue" (pending review before archiving)
  2233. # "print_queue" archives and adds to print queue (unassigned)
  2234. if new_mode not in ("immediate", "queue", "review", "print_queue"):
  2235. return JSONResponse(
  2236. status_code=400,
  2237. content={"detail": "Mode must be 'immediate', 'review', or 'print_queue'"},
  2238. )
  2239. # Normalize legacy "queue" to "review" for storage
  2240. if new_mode == "queue":
  2241. new_mode = "review"
  2242. # Validate model
  2243. if model is not None and model not in VIRTUAL_PRINTER_MODELS:
  2244. return JSONResponse(
  2245. status_code=400,
  2246. content={"detail": f"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}"},
  2247. )
  2248. # Validate access code when enabling
  2249. if new_enabled and not new_access_code:
  2250. return JSONResponse(
  2251. status_code=400,
  2252. content={"detail": "Access code is required when enabling virtual printer"},
  2253. )
  2254. # Validate access code length (Bambu Studio requires exactly 8 characters)
  2255. if access_code is not None and len(access_code) != 8:
  2256. return JSONResponse(
  2257. status_code=400,
  2258. content={"detail": "Access code must be exactly 8 characters"},
  2259. )
  2260. # Save settings
  2261. await set_setting(db, "virtual_printer_enabled", "true" if new_enabled else "false")
  2262. if access_code is not None:
  2263. await set_setting(db, "virtual_printer_access_code", access_code)
  2264. await set_setting(db, "virtual_printer_mode", new_mode)
  2265. if model is not None:
  2266. await set_setting(db, "virtual_printer_model", model)
  2267. await db.commit()
  2268. db.expire_all()
  2269. # Reconfigure virtual printer
  2270. try:
  2271. await virtual_printer_manager.configure(
  2272. enabled=new_enabled,
  2273. access_code=new_access_code,
  2274. mode=new_mode,
  2275. model=new_model,
  2276. )
  2277. except ValueError as e:
  2278. return JSONResponse(
  2279. status_code=400,
  2280. content={"detail": str(e)},
  2281. )
  2282. except Exception as e:
  2283. return JSONResponse(
  2284. status_code=500,
  2285. content={"detail": f"Failed to configure virtual printer: {e}"},
  2286. )
  2287. return await get_virtual_printer_settings(db)
  2288. # =============================================================================
  2289. # MQTT Relay Settings
  2290. # =============================================================================
  2291. @router.get("/mqtt/status")
  2292. async def get_mqtt_status():
  2293. """Get MQTT relay connection status."""
  2294. from backend.app.services.mqtt_relay import mqtt_relay
  2295. return mqtt_relay.get_status()