settings.py 52 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136
  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.archive import PrintArchive
  13. from backend.app.models.external_link import ExternalLink
  14. from backend.app.models.filament import Filament
  15. from backend.app.models.maintenance import MaintenanceType
  16. from backend.app.models.notification import NotificationProvider
  17. from backend.app.models.notification_template import NotificationTemplate
  18. from backend.app.models.printer import Printer
  19. from backend.app.models.project import Project
  20. from backend.app.models.project_bom import ProjectBOMItem
  21. from backend.app.models.settings import Settings
  22. from backend.app.models.smart_plug import SmartPlug
  23. from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
  24. from backend.app.services.printer_manager import printer_manager
  25. from backend.app.services.spoolman import init_spoolman_client
  26. router = APIRouter(prefix="/settings", tags=["settings"])
  27. # Default settings
  28. DEFAULT_SETTINGS = AppSettings()
  29. async def get_setting(db: AsyncSession, key: str) -> str | None:
  30. """Get a single setting value by key."""
  31. result = await db.execute(select(Settings).where(Settings.key == key))
  32. setting = result.scalar_one_or_none()
  33. return setting.value if setting else None
  34. async def set_setting(db: AsyncSession, key: str, value: str) -> None:
  35. """Set a single setting value."""
  36. result = await db.execute(select(Settings).where(Settings.key == key))
  37. setting = result.scalar_one_or_none()
  38. if setting:
  39. setting.value = value
  40. else:
  41. setting = Settings(key=key, value=value)
  42. db.add(setting)
  43. @router.get("/", response_model=AppSettings)
  44. async def get_settings(db: AsyncSession = Depends(get_db)):
  45. """Get all application settings."""
  46. settings_dict = DEFAULT_SETTINGS.model_dump()
  47. # Load saved settings from database
  48. result = await db.execute(select(Settings))
  49. db_settings = result.scalars().all()
  50. for setting in db_settings:
  51. if setting.key in settings_dict:
  52. # Parse the value based on the expected type
  53. if setting.key in [
  54. "auto_archive",
  55. "save_thumbnails",
  56. "capture_finish_photo",
  57. "spoolman_enabled",
  58. "check_updates",
  59. "telemetry_enabled",
  60. ]:
  61. settings_dict[setting.key] = setting.value.lower() == "true"
  62. elif setting.key in ["default_filament_cost", "energy_cost_per_kwh", "ams_temp_good", "ams_temp_fair"]:
  63. settings_dict[setting.key] = float(setting.value)
  64. elif setting.key in ["ams_humidity_good", "ams_humidity_fair", "ams_history_retention_days"]:
  65. settings_dict[setting.key] = int(setting.value)
  66. elif setting.key == "default_printer_id":
  67. # Handle nullable integer
  68. settings_dict[setting.key] = int(setting.value) if setting.value and setting.value != "None" else None
  69. else:
  70. settings_dict[setting.key] = setting.value
  71. return AppSettings(**settings_dict)
  72. @router.put("/", response_model=AppSettings)
  73. async def update_settings(
  74. settings_update: AppSettingsUpdate,
  75. db: AsyncSession = Depends(get_db),
  76. ):
  77. """Update application settings."""
  78. update_data = settings_update.model_dump(exclude_unset=True)
  79. for key, value in update_data.items():
  80. # Convert value to string for storage
  81. if isinstance(value, bool):
  82. str_value = "true" if value else "false"
  83. elif value is None:
  84. str_value = "None"
  85. else:
  86. str_value = str(value)
  87. await set_setting(db, key, str_value)
  88. await db.commit()
  89. # Return updated settings
  90. return await get_settings(db)
  91. @router.post("/reset", response_model=AppSettings)
  92. async def reset_settings(db: AsyncSession = Depends(get_db)):
  93. """Reset all settings to defaults."""
  94. # Delete all settings
  95. result = await db.execute(select(Settings))
  96. for setting in result.scalars().all():
  97. await db.delete(setting)
  98. await db.commit()
  99. return DEFAULT_SETTINGS
  100. @router.get("/check-ffmpeg")
  101. async def check_ffmpeg():
  102. """Check if ffmpeg is installed and available."""
  103. from backend.app.services.camera import get_ffmpeg_path
  104. ffmpeg_path = get_ffmpeg_path()
  105. return {
  106. "installed": ffmpeg_path is not None,
  107. "path": ffmpeg_path,
  108. }
  109. @router.get("/spoolman")
  110. async def get_spoolman_settings(db: AsyncSession = Depends(get_db)):
  111. """Get Spoolman integration settings."""
  112. spoolman_enabled = await get_setting(db, "spoolman_enabled") or "false"
  113. spoolman_url = await get_setting(db, "spoolman_url") or ""
  114. spoolman_sync_mode = await get_setting(db, "spoolman_sync_mode") or "auto"
  115. return {
  116. "spoolman_enabled": spoolman_enabled,
  117. "spoolman_url": spoolman_url,
  118. "spoolman_sync_mode": spoolman_sync_mode,
  119. }
  120. @router.put("/spoolman")
  121. async def update_spoolman_settings(
  122. settings: dict,
  123. db: AsyncSession = Depends(get_db),
  124. ):
  125. """Update Spoolman integration settings."""
  126. if "spoolman_enabled" in settings:
  127. await set_setting(db, "spoolman_enabled", settings["spoolman_enabled"])
  128. if "spoolman_url" in settings:
  129. await set_setting(db, "spoolman_url", settings["spoolman_url"])
  130. if "spoolman_sync_mode" in settings:
  131. await set_setting(db, "spoolman_sync_mode", settings["spoolman_sync_mode"])
  132. await db.commit()
  133. # Return updated settings
  134. return await get_spoolman_settings(db)
  135. @router.get("/backup")
  136. async def export_backup(
  137. db: AsyncSession = Depends(get_db),
  138. include_settings: bool = Query(True, description="Include app settings"),
  139. include_notifications: bool = Query(True, description="Include notification providers"),
  140. include_templates: bool = Query(True, description="Include notification templates"),
  141. include_smart_plugs: bool = Query(True, description="Include smart plugs"),
  142. include_external_links: bool = Query(True, description="Include external sidebar links"),
  143. include_printers: bool = Query(False, description="Include printers (without access codes)"),
  144. include_filaments: bool = Query(False, description="Include filament inventory"),
  145. include_maintenance: bool = Query(False, description="Include maintenance types and records"),
  146. include_archives: bool = Query(False, description="Include print archive metadata"),
  147. include_projects: bool = Query(False, description="Include projects with BOM items"),
  148. include_access_codes: bool = Query(False, description="Include printer access codes (security risk!)"),
  149. ):
  150. """Export selected data as JSON backup."""
  151. backup: dict = {
  152. "version": "2.0",
  153. "exported_at": datetime.utcnow().isoformat(),
  154. "included": [],
  155. }
  156. # Settings
  157. if include_settings:
  158. result = await db.execute(select(Settings))
  159. db_settings = result.scalars().all()
  160. backup["settings"] = {s.key: s.value for s in db_settings}
  161. backup["included"].append("settings")
  162. # Notification providers
  163. if include_notifications:
  164. result = await db.execute(select(NotificationProvider))
  165. providers = result.scalars().all()
  166. backup["notification_providers"] = []
  167. for p in providers:
  168. backup["notification_providers"].append(
  169. {
  170. "name": p.name,
  171. "provider_type": p.provider_type,
  172. "enabled": p.enabled,
  173. "config": json.loads(p.config) if isinstance(p.config, str) else p.config,
  174. "on_print_start": p.on_print_start,
  175. "on_print_complete": p.on_print_complete,
  176. "on_print_failed": p.on_print_failed,
  177. "on_print_stopped": p.on_print_stopped,
  178. "on_print_progress": p.on_print_progress,
  179. "on_printer_offline": p.on_printer_offline,
  180. "on_printer_error": p.on_printer_error,
  181. "on_filament_low": p.on_filament_low,
  182. "on_maintenance_due": p.on_maintenance_due,
  183. "quiet_hours_enabled": p.quiet_hours_enabled,
  184. "quiet_hours_start": p.quiet_hours_start,
  185. "quiet_hours_end": p.quiet_hours_end,
  186. "daily_digest_enabled": getattr(p, "daily_digest_enabled", False),
  187. "daily_digest_time": getattr(p, "daily_digest_time", None),
  188. "printer_id": getattr(p, "printer_id", None),
  189. }
  190. )
  191. backup["included"].append("notification_providers")
  192. # Notification templates
  193. if include_templates:
  194. result = await db.execute(select(NotificationTemplate))
  195. templates = result.scalars().all()
  196. backup["notification_templates"] = []
  197. for t in templates:
  198. backup["notification_templates"].append(
  199. {
  200. "event_type": t.event_type,
  201. "name": t.name,
  202. "title_template": t.title_template,
  203. "body_template": t.body_template,
  204. "is_default": t.is_default,
  205. }
  206. )
  207. backup["included"].append("notification_templates")
  208. # Smart plugs
  209. if include_smart_plugs:
  210. result = await db.execute(select(SmartPlug))
  211. plugs = result.scalars().all()
  212. backup["smart_plugs"] = []
  213. for plug in plugs:
  214. backup["smart_plugs"].append(
  215. {
  216. "name": plug.name,
  217. "ip_address": plug.ip_address,
  218. "printer_id": plug.printer_id,
  219. "enabled": plug.enabled,
  220. "auto_on": plug.auto_on,
  221. "auto_off": plug.auto_off,
  222. "off_delay_mode": plug.off_delay_mode,
  223. "off_delay_minutes": plug.off_delay_minutes,
  224. "off_temp_threshold": plug.off_temp_threshold,
  225. "username": plug.username,
  226. "password": plug.password,
  227. "power_alert_enabled": plug.power_alert_enabled,
  228. "power_alert_high": plug.power_alert_high,
  229. "power_alert_low": plug.power_alert_low,
  230. "schedule_enabled": plug.schedule_enabled,
  231. "schedule_on_time": plug.schedule_on_time,
  232. "schedule_off_time": plug.schedule_off_time,
  233. "show_in_switchbar": plug.show_in_switchbar,
  234. }
  235. )
  236. backup["included"].append("smart_plugs")
  237. # External links
  238. if include_external_links:
  239. result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order))
  240. links = result.scalars().all()
  241. backup["external_links"] = []
  242. icons_dir = app_settings.base_dir / "icons"
  243. for link in links:
  244. link_data = {
  245. "name": link.name,
  246. "url": link.url,
  247. "icon": link.icon,
  248. "sort_order": link.sort_order,
  249. }
  250. # Include custom icon file path if exists
  251. if link.custom_icon:
  252. link_data["custom_icon"] = link.custom_icon
  253. icon_path = icons_dir / link.custom_icon
  254. if icon_path.exists():
  255. link_data["custom_icon_path"] = f"icons/{link.custom_icon}"
  256. backup["external_links"].append(link_data)
  257. backup["included"].append("external_links")
  258. # Printers (access codes only included if explicitly requested)
  259. if include_printers:
  260. result = await db.execute(select(Printer))
  261. printers = result.scalars().all()
  262. backup["printers"] = []
  263. for printer in printers:
  264. printer_data = {
  265. "name": printer.name,
  266. "serial_number": printer.serial_number,
  267. "ip_address": printer.ip_address,
  268. "model": printer.model,
  269. "location": printer.location,
  270. "nozzle_count": printer.nozzle_count,
  271. "is_active": printer.is_active,
  272. "auto_archive": printer.auto_archive,
  273. "print_hours_offset": printer.print_hours_offset,
  274. }
  275. if include_access_codes:
  276. printer_data["access_code"] = printer.access_code
  277. backup["printers"].append(printer_data)
  278. backup["included"].append("printers")
  279. if include_access_codes:
  280. backup["included"].append("access_codes")
  281. # Filaments
  282. if include_filaments:
  283. result = await db.execute(select(Filament))
  284. filaments = result.scalars().all()
  285. backup["filaments"] = []
  286. for f in filaments:
  287. backup["filaments"].append(
  288. {
  289. "name": f.name,
  290. "type": f.type,
  291. "brand": f.brand,
  292. "color": f.color,
  293. "color_hex": f.color_hex,
  294. "cost_per_kg": f.cost_per_kg,
  295. "spool_weight_g": f.spool_weight_g,
  296. "currency": f.currency,
  297. "density": f.density,
  298. "print_temp_min": f.print_temp_min,
  299. "print_temp_max": f.print_temp_max,
  300. "bed_temp_min": f.bed_temp_min,
  301. "bed_temp_max": f.bed_temp_max,
  302. }
  303. )
  304. backup["included"].append("filaments")
  305. # Maintenance types and records
  306. if include_maintenance:
  307. # Maintenance types
  308. result = await db.execute(select(MaintenanceType))
  309. types = result.scalars().all()
  310. backup["maintenance_types"] = []
  311. for mt in types:
  312. backup["maintenance_types"].append(
  313. {
  314. "name": mt.name,
  315. "description": mt.description,
  316. "default_interval_hours": mt.default_interval_hours,
  317. "interval_type": mt.interval_type,
  318. "icon": mt.icon,
  319. "is_system": mt.is_system,
  320. }
  321. )
  322. backup["included"].append("maintenance_types")
  323. # Collect files for ZIP (icons + archives)
  324. backup_files: list[tuple[str, Path]] = [] # (zip_path, local_path)
  325. # Add external link icon files
  326. if include_external_links and "external_links" in backup:
  327. icons_dir = app_settings.base_dir / "icons"
  328. for link_data in backup["external_links"]:
  329. if "custom_icon_path" in link_data:
  330. icon_path = icons_dir / link_data["custom_icon"]
  331. if icon_path.exists():
  332. backup_files.append((link_data["custom_icon_path"], icon_path))
  333. # Print archives with file paths for ZIP
  334. if include_archives:
  335. result = await db.execute(select(PrintArchive))
  336. archives = result.scalars().all()
  337. backup["archives"] = []
  338. base_dir = app_settings.base_dir
  339. # Build project ID to name mapping for archive export
  340. project_id_to_name: dict[int, str] = {}
  341. if include_projects:
  342. proj_result = await db.execute(select(Project))
  343. for proj in proj_result.scalars().all():
  344. project_id_to_name[proj.id] = proj.name
  345. for a in archives:
  346. archive_data = {
  347. "filename": a.filename,
  348. "project_name": project_id_to_name.get(a.project_id) if a.project_id else None,
  349. "file_size": a.file_size,
  350. "content_hash": a.content_hash,
  351. "print_name": a.print_name,
  352. "print_time_seconds": a.print_time_seconds,
  353. "filament_used_grams": a.filament_used_grams,
  354. "filament_type": a.filament_type,
  355. "filament_color": a.filament_color,
  356. "layer_height": a.layer_height,
  357. "total_layers": a.total_layers,
  358. "nozzle_diameter": a.nozzle_diameter,
  359. "bed_temperature": a.bed_temperature,
  360. "nozzle_temperature": a.nozzle_temperature,
  361. "status": a.status,
  362. "started_at": a.started_at.isoformat() if a.started_at else None,
  363. "completed_at": a.completed_at.isoformat() if a.completed_at else None,
  364. "makerworld_url": a.makerworld_url,
  365. "designer": a.designer,
  366. "is_favorite": a.is_favorite,
  367. "tags": a.tags,
  368. "notes": a.notes,
  369. "cost": a.cost,
  370. "failure_reason": a.failure_reason,
  371. "energy_kwh": a.energy_kwh,
  372. "energy_cost": a.energy_cost,
  373. "extra_data": a.extra_data,
  374. "photos": a.photos,
  375. }
  376. # Collect file paths for ZIP
  377. if a.file_path:
  378. file_path = base_dir / a.file_path
  379. if file_path.exists():
  380. archive_data["file_path"] = a.file_path
  381. backup_files.append((a.file_path, file_path))
  382. if a.thumbnail_path:
  383. thumb_path = base_dir / a.thumbnail_path
  384. if thumb_path.exists():
  385. archive_data["thumbnail_path"] = a.thumbnail_path
  386. backup_files.append((a.thumbnail_path, thumb_path))
  387. if a.timelapse_path:
  388. timelapse_path = base_dir / a.timelapse_path
  389. if timelapse_path.exists():
  390. archive_data["timelapse_path"] = a.timelapse_path
  391. backup_files.append((a.timelapse_path, timelapse_path))
  392. if a.source_3mf_path:
  393. source_path = base_dir / a.source_3mf_path
  394. if source_path.exists():
  395. archive_data["source_3mf_path"] = a.source_3mf_path
  396. backup_files.append((a.source_3mf_path, source_path))
  397. # Include photos
  398. if a.photos:
  399. for photo in a.photos:
  400. photo_path = base_dir / "archive" / "photos" / photo
  401. if photo_path.exists():
  402. zip_photo_path = f"archive/photos/{photo}"
  403. backup_files.append((zip_photo_path, photo_path))
  404. backup["archives"].append(archive_data)
  405. backup["included"].append("archives")
  406. # Projects with BOM items
  407. if include_projects:
  408. result = await db.execute(select(Project))
  409. projects = result.scalars().all()
  410. backup["projects"] = []
  411. for p in projects:
  412. # Get BOM items for this project
  413. bom_result = await db.execute(select(ProjectBOMItem).where(ProjectBOMItem.project_id == p.id))
  414. bom_items = bom_result.scalars().all()
  415. project_data = {
  416. "name": p.name,
  417. "description": p.description,
  418. "color": p.color,
  419. "status": p.status,
  420. "target_count": p.target_count,
  421. "notes": p.notes,
  422. "tags": p.tags,
  423. "due_date": p.due_date.isoformat() if p.due_date else None,
  424. "priority": p.priority,
  425. "budget": p.budget,
  426. "is_template": p.is_template,
  427. "bom_items": [
  428. {
  429. "name": item.name,
  430. "quantity_needed": item.quantity_needed,
  431. "quantity_acquired": item.quantity_acquired,
  432. "unit_price": item.unit_price,
  433. "sourcing_url": item.sourcing_url,
  434. "stl_filename": item.stl_filename,
  435. "remarks": item.remarks,
  436. "sort_order": item.sort_order,
  437. }
  438. for item in bom_items
  439. ],
  440. }
  441. # Include attachment files for ZIP
  442. if p.attachments:
  443. project_data["attachments"] = p.attachments
  444. attachments_dir = base_dir / "projects" / str(p.id) / "attachments"
  445. for att in p.attachments:
  446. att_path = attachments_dir / att.get("filename", "")
  447. if att_path.exists():
  448. zip_path = f"projects/{p.id}/attachments/{att['filename']}"
  449. backup_files.append((zip_path, att_path))
  450. backup["projects"].append(project_data)
  451. backup["included"].append("projects")
  452. # If there are files to include (icons or archives), create ZIP file
  453. if backup_files:
  454. zip_buffer = io.BytesIO()
  455. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  456. # Add backup.json
  457. zf.writestr("backup.json", json.dumps(backup, indent=2))
  458. # Add all backup files (icons, archives, etc.)
  459. added_files = set()
  460. for zip_path, local_path in backup_files:
  461. if zip_path not in added_files and local_path.exists():
  462. try:
  463. zf.write(local_path, zip_path)
  464. added_files.add(zip_path)
  465. except Exception:
  466. pass # Skip files that can't be read
  467. zip_buffer.seek(0)
  468. filename = f"bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip"
  469. return StreamingResponse(
  470. zip_buffer,
  471. media_type="application/zip",
  472. headers={"Content-Disposition": f"attachment; filename={filename}"},
  473. )
  474. # Otherwise return JSON
  475. return JSONResponse(
  476. content=backup,
  477. headers={
  478. "Content-Disposition": f"attachment; filename=bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
  479. },
  480. )
  481. @router.post("/restore")
  482. async def import_backup(
  483. file: UploadFile = File(...),
  484. overwrite: bool = Query(False, description="Overwrite existing data instead of skipping duplicates"),
  485. db: AsyncSession = Depends(get_db),
  486. ):
  487. """Restore data from JSON or ZIP backup. By default skips duplicates, set overwrite=true to replace existing."""
  488. try:
  489. content = await file.read()
  490. base_dir = app_settings.base_dir
  491. files_restored = 0
  492. # Check if it's a ZIP file
  493. if file.filename and file.filename.endswith(".zip"):
  494. try:
  495. zip_buffer = io.BytesIO(content)
  496. with zipfile.ZipFile(zip_buffer, "r") as zf:
  497. # Extract backup.json
  498. if "backup.json" not in zf.namelist():
  499. return {"success": False, "message": "Invalid ZIP: missing backup.json"}
  500. backup_content = zf.read("backup.json")
  501. backup = json.loads(backup_content.decode("utf-8"))
  502. # Extract all other files to base_dir
  503. for zip_path in zf.namelist():
  504. if zip_path == "backup.json":
  505. continue
  506. # Ensure path is safe (no path traversal)
  507. if ".." in zip_path or zip_path.startswith("/"):
  508. continue
  509. target_path = base_dir / zip_path
  510. target_path.parent.mkdir(parents=True, exist_ok=True)
  511. with zf.open(zip_path) as src, open(target_path, "wb") as dst:
  512. dst.write(src.read())
  513. files_restored += 1
  514. except zipfile.BadZipFile:
  515. return {"success": False, "message": "Invalid ZIP file"}
  516. else:
  517. backup = json.loads(content.decode("utf-8"))
  518. except json.JSONDecodeError as e:
  519. return {"success": False, "message": f"Invalid JSON: {str(e)}"}
  520. except Exception as e:
  521. return {"success": False, "message": f"Invalid backup file: {str(e)}"}
  522. restored = {
  523. "settings": 0,
  524. "notification_providers": 0,
  525. "notification_templates": 0,
  526. "smart_plugs": 0,
  527. "external_links": 0,
  528. "printers": 0,
  529. "filaments": 0,
  530. "maintenance_types": 0,
  531. "projects": 0,
  532. }
  533. skipped = {
  534. "settings": 0,
  535. "notification_providers": 0,
  536. "notification_templates": 0,
  537. "smart_plugs": 0,
  538. "external_links": 0,
  539. "printers": 0,
  540. "filaments": 0,
  541. "maintenance_types": 0,
  542. "archives": 0,
  543. "projects": 0,
  544. }
  545. skipped_details = {
  546. "notification_providers": [],
  547. "smart_plugs": [],
  548. "external_links": [],
  549. "printers": [],
  550. "filaments": [],
  551. "maintenance_types": [],
  552. "archives": [],
  553. "projects": [],
  554. }
  555. # Restore settings (always overwrites)
  556. if "settings" in backup:
  557. for key, value in backup["settings"].items():
  558. # Convert value to proper string format for storage
  559. if isinstance(value, bool):
  560. str_value = "true" if value else "false"
  561. elif value is None:
  562. str_value = "None"
  563. else:
  564. str_value = str(value)
  565. await set_setting(db, key, str_value)
  566. restored["settings"] += 1
  567. # Restore notification providers (skip or overwrite duplicates by name)
  568. if "notification_providers" in backup:
  569. for provider_data in backup["notification_providers"]:
  570. result = await db.execute(
  571. select(NotificationProvider).where(NotificationProvider.name == provider_data["name"])
  572. )
  573. existing = result.scalar_one_or_none()
  574. if existing:
  575. if overwrite:
  576. # Update existing provider
  577. existing.provider_type = provider_data["provider_type"]
  578. existing.enabled = provider_data.get("enabled", True)
  579. existing.config = json.dumps(provider_data.get("config", {}))
  580. existing.on_print_start = provider_data.get("on_print_start", False)
  581. existing.on_print_complete = provider_data.get("on_print_complete", True)
  582. existing.on_print_failed = provider_data.get("on_print_failed", True)
  583. existing.on_print_stopped = provider_data.get("on_print_stopped", True)
  584. existing.on_print_progress = provider_data.get("on_print_progress", False)
  585. existing.on_printer_offline = provider_data.get("on_printer_offline", False)
  586. existing.on_printer_error = provider_data.get("on_printer_error", False)
  587. existing.on_filament_low = provider_data.get("on_filament_low", False)
  588. existing.on_maintenance_due = provider_data.get("on_maintenance_due", False)
  589. existing.quiet_hours_enabled = provider_data.get("quiet_hours_enabled", False)
  590. existing.quiet_hours_start = provider_data.get("quiet_hours_start")
  591. existing.quiet_hours_end = provider_data.get("quiet_hours_end")
  592. existing.daily_digest_enabled = provider_data.get("daily_digest_enabled", False)
  593. existing.daily_digest_time = provider_data.get("daily_digest_time")
  594. existing.printer_id = provider_data.get("printer_id")
  595. restored["notification_providers"] += 1
  596. else:
  597. skipped["notification_providers"] += 1
  598. skipped_details["notification_providers"].append(provider_data["name"])
  599. else:
  600. provider = NotificationProvider(
  601. name=provider_data["name"],
  602. provider_type=provider_data["provider_type"],
  603. enabled=provider_data.get("enabled", True),
  604. config=json.dumps(provider_data.get("config", {})),
  605. on_print_start=provider_data.get("on_print_start", False),
  606. on_print_complete=provider_data.get("on_print_complete", True),
  607. on_print_failed=provider_data.get("on_print_failed", True),
  608. on_print_stopped=provider_data.get("on_print_stopped", True),
  609. on_print_progress=provider_data.get("on_print_progress", False),
  610. on_printer_offline=provider_data.get("on_printer_offline", False),
  611. on_printer_error=provider_data.get("on_printer_error", False),
  612. on_filament_low=provider_data.get("on_filament_low", False),
  613. on_maintenance_due=provider_data.get("on_maintenance_due", False),
  614. quiet_hours_enabled=provider_data.get("quiet_hours_enabled", False),
  615. quiet_hours_start=provider_data.get("quiet_hours_start"),
  616. quiet_hours_end=provider_data.get("quiet_hours_end"),
  617. daily_digest_enabled=provider_data.get("daily_digest_enabled", False),
  618. daily_digest_time=provider_data.get("daily_digest_time"),
  619. printer_id=provider_data.get("printer_id"),
  620. )
  621. db.add(provider)
  622. restored["notification_providers"] += 1
  623. # Restore notification templates (update existing by event_type)
  624. if "notification_templates" in backup:
  625. for template_data in backup["notification_templates"]:
  626. result = await db.execute(
  627. select(NotificationTemplate).where(NotificationTemplate.event_type == template_data["event_type"])
  628. )
  629. existing = result.scalar_one_or_none()
  630. if existing:
  631. # Update existing template
  632. existing.name = template_data.get("name", existing.name)
  633. existing.title_template = template_data.get("title_template", existing.title_template)
  634. existing.body_template = template_data.get("body_template", existing.body_template)
  635. existing.is_default = template_data.get("is_default", False)
  636. else:
  637. template = NotificationTemplate(
  638. event_type=template_data["event_type"],
  639. name=template_data["name"],
  640. title_template=template_data["title_template"],
  641. body_template=template_data["body_template"],
  642. is_default=template_data.get("is_default", False),
  643. )
  644. db.add(template)
  645. restored["notification_templates"] += 1
  646. # Restore smart plugs (skip or overwrite duplicates by IP)
  647. if "smart_plugs" in backup:
  648. for plug_data in backup["smart_plugs"]:
  649. result = await db.execute(select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"]))
  650. existing = result.scalar_one_or_none()
  651. if existing:
  652. if overwrite:
  653. existing.name = plug_data["name"]
  654. existing.printer_id = plug_data.get("printer_id")
  655. existing.enabled = plug_data.get("enabled", True)
  656. existing.auto_on = plug_data.get("auto_on", True)
  657. existing.auto_off = plug_data.get("auto_off", True)
  658. existing.off_delay_mode = plug_data.get("off_delay_mode", "time")
  659. existing.off_delay_minutes = plug_data.get("off_delay_minutes", 5)
  660. existing.off_temp_threshold = plug_data.get("off_temp_threshold", 70)
  661. existing.username = plug_data.get("username")
  662. existing.password = plug_data.get("password")
  663. existing.power_alert_enabled = plug_data.get("power_alert_enabled", False)
  664. existing.power_alert_high = plug_data.get("power_alert_high")
  665. existing.power_alert_low = plug_data.get("power_alert_low")
  666. existing.schedule_enabled = plug_data.get("schedule_enabled", False)
  667. existing.schedule_on_time = plug_data.get("schedule_on_time")
  668. existing.schedule_off_time = plug_data.get("schedule_off_time")
  669. existing.show_in_switchbar = plug_data.get("show_in_switchbar", False)
  670. restored["smart_plugs"] += 1
  671. else:
  672. skipped["smart_plugs"] += 1
  673. skipped_details["smart_plugs"].append(f"{plug_data['name']} ({plug_data['ip_address']})")
  674. else:
  675. plug = SmartPlug(
  676. name=plug_data["name"],
  677. ip_address=plug_data["ip_address"],
  678. printer_id=plug_data.get("printer_id"),
  679. enabled=plug_data.get("enabled", True),
  680. auto_on=plug_data.get("auto_on", True),
  681. auto_off=plug_data.get("auto_off", True),
  682. off_delay_mode=plug_data.get("off_delay_mode", "time"),
  683. off_delay_minutes=plug_data.get("off_delay_minutes", 5),
  684. off_temp_threshold=plug_data.get("off_temp_threshold", 70),
  685. username=plug_data.get("username"),
  686. password=plug_data.get("password"),
  687. power_alert_enabled=plug_data.get("power_alert_enabled", False),
  688. power_alert_high=plug_data.get("power_alert_high"),
  689. power_alert_low=plug_data.get("power_alert_low"),
  690. schedule_enabled=plug_data.get("schedule_enabled", False),
  691. schedule_on_time=plug_data.get("schedule_on_time"),
  692. schedule_off_time=plug_data.get("schedule_off_time"),
  693. show_in_switchbar=plug_data.get("show_in_switchbar", False),
  694. )
  695. db.add(plug)
  696. restored["smart_plugs"] += 1
  697. # Restore external links (skip or overwrite duplicates by name+url)
  698. if "external_links" in backup:
  699. icons_dir = base_dir / "icons"
  700. icons_dir.mkdir(parents=True, exist_ok=True)
  701. for link_data in backup["external_links"]:
  702. result = await db.execute(
  703. select(ExternalLink).where(ExternalLink.name == link_data["name"], ExternalLink.url == link_data["url"])
  704. )
  705. existing = result.scalar_one_or_none()
  706. if existing:
  707. if overwrite:
  708. existing.icon = link_data.get("icon", "link")
  709. existing.sort_order = link_data.get("sort_order", 0)
  710. # Handle custom icon
  711. if link_data.get("custom_icon"):
  712. existing.custom_icon = link_data["custom_icon"]
  713. restored["external_links"] += 1
  714. else:
  715. skipped["external_links"] += 1
  716. skipped_details["external_links"].append(link_data["name"])
  717. else:
  718. link = ExternalLink(
  719. name=link_data["name"],
  720. url=link_data["url"],
  721. icon=link_data.get("icon", "link"),
  722. custom_icon=link_data.get("custom_icon"),
  723. sort_order=link_data.get("sort_order", 0),
  724. )
  725. db.add(link)
  726. restored["external_links"] += 1
  727. # Restore printers (skip or overwrite duplicates by serial_number)
  728. if "printers" in backup:
  729. for printer_data in backup["printers"]:
  730. result = await db.execute(select(Printer).where(Printer.serial_number == printer_data["serial_number"]))
  731. existing = result.scalar_one_or_none()
  732. if existing:
  733. if overwrite:
  734. existing.name = printer_data["name"]
  735. existing.ip_address = printer_data["ip_address"]
  736. existing.model = printer_data.get("model")
  737. existing.location = printer_data.get("location")
  738. existing.nozzle_count = printer_data.get("nozzle_count", 1)
  739. existing.auto_archive = printer_data.get("auto_archive", True)
  740. existing.print_hours_offset = printer_data.get("print_hours_offset", 0.0)
  741. # If backup includes access_code, also update access_code and is_active
  742. backup_access_code = printer_data.get("access_code")
  743. if backup_access_code and backup_access_code != "CHANGE_ME":
  744. existing.access_code = backup_access_code
  745. is_active_val = printer_data.get("is_active", False)
  746. if isinstance(is_active_val, str):
  747. is_active_val = is_active_val.lower() == "true"
  748. existing.is_active = is_active_val
  749. restored["printers"] += 1
  750. else:
  751. skipped["printers"] += 1
  752. skipped_details["printers"].append(f"{printer_data['name']} ({printer_data['serial_number']})")
  753. else:
  754. # Use access code from backup if provided, otherwise require manual setup
  755. access_code = printer_data.get("access_code")
  756. has_access_code = access_code and access_code != "CHANGE_ME"
  757. is_active_from_backup = printer_data.get("is_active", False)
  758. # Handle bool or string "true"/"false"
  759. if isinstance(is_active_from_backup, str):
  760. is_active_from_backup = is_active_from_backup.lower() == "true"
  761. printer = Printer(
  762. name=printer_data["name"],
  763. serial_number=printer_data["serial_number"],
  764. ip_address=printer_data["ip_address"],
  765. access_code=access_code if has_access_code else "CHANGE_ME",
  766. model=printer_data.get("model"),
  767. location=printer_data.get("location"),
  768. nozzle_count=printer_data.get("nozzle_count", 1),
  769. is_active=is_active_from_backup if has_access_code else False,
  770. auto_archive=printer_data.get("auto_archive", True),
  771. print_hours_offset=printer_data.get("print_hours_offset", 0.0),
  772. )
  773. db.add(printer)
  774. restored["printers"] += 1
  775. # Restore filaments (skip or overwrite duplicates by name+type+brand)
  776. if "filaments" in backup:
  777. for filament_data in backup["filaments"]:
  778. result = await db.execute(
  779. select(Filament).where(
  780. Filament.name == filament_data["name"],
  781. Filament.type == filament_data["type"],
  782. Filament.brand == filament_data.get("brand"),
  783. )
  784. )
  785. existing = result.scalar_one_or_none()
  786. if existing:
  787. if overwrite:
  788. existing.color = filament_data.get("color")
  789. existing.color_hex = filament_data.get("color_hex")
  790. existing.cost_per_kg = filament_data.get("cost_per_kg", 25.0)
  791. existing.spool_weight_g = filament_data.get("spool_weight_g", 1000.0)
  792. existing.currency = filament_data.get("currency", "USD")
  793. existing.density = filament_data.get("density")
  794. existing.print_temp_min = filament_data.get("print_temp_min")
  795. existing.print_temp_max = filament_data.get("print_temp_max")
  796. existing.bed_temp_min = filament_data.get("bed_temp_min")
  797. existing.bed_temp_max = filament_data.get("bed_temp_max")
  798. restored["filaments"] += 1
  799. else:
  800. skipped["filaments"] += 1
  801. skipped_details["filaments"].append(
  802. f"{filament_data.get('brand', '')} {filament_data['name']} ({filament_data['type']})"
  803. )
  804. else:
  805. filament = Filament(
  806. name=filament_data["name"],
  807. type=filament_data["type"],
  808. brand=filament_data.get("brand"),
  809. color=filament_data.get("color"),
  810. color_hex=filament_data.get("color_hex"),
  811. cost_per_kg=filament_data.get("cost_per_kg", 25.0),
  812. spool_weight_g=filament_data.get("spool_weight_g", 1000.0),
  813. currency=filament_data.get("currency", "USD"),
  814. density=filament_data.get("density"),
  815. print_temp_min=filament_data.get("print_temp_min"),
  816. print_temp_max=filament_data.get("print_temp_max"),
  817. bed_temp_min=filament_data.get("bed_temp_min"),
  818. bed_temp_max=filament_data.get("bed_temp_max"),
  819. )
  820. db.add(filament)
  821. restored["filaments"] += 1
  822. # Restore maintenance types (skip or overwrite duplicates by name)
  823. if "maintenance_types" in backup:
  824. for mt_data in backup["maintenance_types"]:
  825. result = await db.execute(select(MaintenanceType).where(MaintenanceType.name == mt_data["name"]))
  826. existing = result.scalar_one_or_none()
  827. if existing:
  828. if overwrite:
  829. existing.description = mt_data.get("description")
  830. existing.default_interval_hours = mt_data.get("default_interval_hours", 100.0)
  831. existing.interval_type = mt_data.get("interval_type", "hours")
  832. existing.icon = mt_data.get("icon")
  833. # Don't overwrite is_system
  834. restored["maintenance_types"] += 1
  835. else:
  836. skipped["maintenance_types"] += 1
  837. skipped_details["maintenance_types"].append(mt_data["name"])
  838. else:
  839. mt = MaintenanceType(
  840. name=mt_data["name"],
  841. description=mt_data.get("description"),
  842. default_interval_hours=mt_data.get("default_interval_hours", 100.0),
  843. interval_type=mt_data.get("interval_type", "hours"),
  844. icon=mt_data.get("icon"),
  845. is_system=mt_data.get("is_system", False),
  846. )
  847. db.add(mt)
  848. restored["maintenance_types"] += 1
  849. # Restore archives (skip duplicates by content_hash - overwrite not supported for archives)
  850. if "archives" in backup:
  851. for archive_data in backup["archives"]:
  852. # Skip if no content_hash or already exists
  853. content_hash = archive_data.get("content_hash")
  854. if content_hash:
  855. result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash == content_hash))
  856. existing = result.scalar_one_or_none()
  857. if existing:
  858. skipped["archives"] += 1
  859. skipped_details["archives"].append(archive_data.get("filename", "Unknown"))
  860. continue
  861. # Only restore if file exists (from ZIP extraction)
  862. file_path = archive_data.get("file_path")
  863. if file_path and (base_dir / file_path).exists():
  864. archive = PrintArchive(
  865. filename=archive_data["filename"],
  866. file_path=file_path,
  867. file_size=archive_data.get("file_size", 0),
  868. content_hash=content_hash,
  869. thumbnail_path=archive_data.get("thumbnail_path"),
  870. timelapse_path=archive_data.get("timelapse_path"),
  871. source_3mf_path=archive_data.get("source_3mf_path"),
  872. print_name=archive_data.get("print_name"),
  873. print_time_seconds=archive_data.get("print_time_seconds"),
  874. filament_used_grams=archive_data.get("filament_used_grams"),
  875. filament_type=archive_data.get("filament_type"),
  876. filament_color=archive_data.get("filament_color"),
  877. layer_height=archive_data.get("layer_height"),
  878. total_layers=archive_data.get("total_layers"),
  879. nozzle_diameter=archive_data.get("nozzle_diameter"),
  880. bed_temperature=archive_data.get("bed_temperature"),
  881. nozzle_temperature=archive_data.get("nozzle_temperature"),
  882. status=archive_data.get("status", "completed"),
  883. makerworld_url=archive_data.get("makerworld_url"),
  884. designer=archive_data.get("designer"),
  885. is_favorite=archive_data.get("is_favorite", False),
  886. tags=archive_data.get("tags"),
  887. notes=archive_data.get("notes"),
  888. cost=archive_data.get("cost"),
  889. failure_reason=archive_data.get("failure_reason"),
  890. energy_kwh=archive_data.get("energy_kwh"),
  891. energy_cost=archive_data.get("energy_cost"),
  892. extra_data=archive_data.get("extra_data"),
  893. photos=archive_data.get("photos"),
  894. )
  895. db.add(archive)
  896. restored["archives"] = restored.get("archives", 0) + 1
  897. # Restore projects (skip or overwrite duplicates by name)
  898. if "projects" in backup:
  899. for project_data in backup["projects"]:
  900. result = await db.execute(select(Project).where(Project.name == project_data["name"]))
  901. existing = result.scalar_one_or_none()
  902. if existing:
  903. if overwrite:
  904. # Update existing project
  905. existing.description = project_data.get("description")
  906. existing.color = project_data.get("color")
  907. existing.status = project_data.get("status", "active")
  908. existing.target_count = project_data.get("target_count")
  909. existing.notes = project_data.get("notes")
  910. existing.tags = project_data.get("tags")
  911. existing.priority = project_data.get("priority", "normal")
  912. existing.budget = project_data.get("budget")
  913. existing.is_template = project_data.get("is_template", False)
  914. existing.attachments = project_data.get("attachments")
  915. if project_data.get("due_date"):
  916. existing.due_date = datetime.fromisoformat(project_data["due_date"])
  917. # Delete existing BOM items and re-add
  918. await db.execute(ProjectBOMItem.__table__.delete().where(ProjectBOMItem.project_id == existing.id))
  919. for bom_data in project_data.get("bom_items", []):
  920. bom_item = ProjectBOMItem(
  921. project_id=existing.id,
  922. name=bom_data["name"],
  923. quantity_needed=bom_data.get("quantity_needed", 1),
  924. quantity_acquired=bom_data.get("quantity_acquired", 0),
  925. unit_price=bom_data.get("unit_price"),
  926. sourcing_url=bom_data.get("sourcing_url"),
  927. stl_filename=bom_data.get("stl_filename"),
  928. remarks=bom_data.get("remarks"),
  929. sort_order=bom_data.get("sort_order", 0),
  930. )
  931. db.add(bom_item)
  932. restored["projects"] += 1
  933. else:
  934. skipped["projects"] += 1
  935. skipped_details["projects"].append(project_data["name"])
  936. else:
  937. # Create new project
  938. project = Project(
  939. name=project_data["name"],
  940. description=project_data.get("description"),
  941. color=project_data.get("color"),
  942. status=project_data.get("status", "active"),
  943. target_count=project_data.get("target_count"),
  944. notes=project_data.get("notes"),
  945. tags=project_data.get("tags"),
  946. priority=project_data.get("priority", "normal"),
  947. budget=project_data.get("budget"),
  948. is_template=project_data.get("is_template", False),
  949. attachments=project_data.get("attachments"),
  950. )
  951. if project_data.get("due_date"):
  952. project.due_date = datetime.fromisoformat(project_data["due_date"])
  953. db.add(project)
  954. await db.flush() # Get the project ID
  955. # Add BOM items
  956. for bom_data in project_data.get("bom_items", []):
  957. bom_item = ProjectBOMItem(
  958. project_id=project.id,
  959. name=bom_data["name"],
  960. quantity_needed=bom_data.get("quantity_needed", 1),
  961. quantity_acquired=bom_data.get("quantity_acquired", 0),
  962. unit_price=bom_data.get("unit_price"),
  963. sourcing_url=bom_data.get("sourcing_url"),
  964. stl_filename=bom_data.get("stl_filename"),
  965. remarks=bom_data.get("remarks"),
  966. sort_order=bom_data.get("sort_order", 0),
  967. )
  968. db.add(bom_item)
  969. restored["projects"] += 1
  970. # Link archives to projects by name (after both are restored)
  971. if "archives" in backup and "projects" in backup:
  972. # Build project name to ID mapping
  973. proj_result = await db.execute(select(Project))
  974. project_name_to_id: dict[str, int] = {}
  975. for proj in proj_result.scalars().all():
  976. project_name_to_id[proj.name] = proj.id
  977. # Update archives with project_id
  978. for archive_data in backup["archives"]:
  979. project_name = archive_data.get("project_name")
  980. if project_name and project_name in project_name_to_id:
  981. content_hash = archive_data.get("content_hash")
  982. if content_hash:
  983. result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash == content_hash))
  984. archive = result.scalar_one_or_none()
  985. if archive:
  986. archive.project_id = project_name_to_id[project_name]
  987. await db.commit()
  988. # If printers were in the backup (restored, updated, or skipped), reconnect all active printers
  989. # This ensures connections are re-established after restore, even if printers were skipped
  990. if "printers" in backup:
  991. # Need fresh query after commit to get proper IDs for newly created printers
  992. result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
  993. active_printers = result.scalars().all()
  994. for printer in active_printers:
  995. # This will disconnect existing connection (if any) and reconnect
  996. try:
  997. await printer_manager.connect_printer(printer)
  998. except Exception:
  999. pass # Connection failed, but don't fail the restore
  1000. # If settings were restored, check if Spoolman needs to be reconnected
  1001. if "settings" in backup:
  1002. spoolman_enabled = await get_setting(db, "spoolman_enabled")
  1003. spoolman_url = await get_setting(db, "spoolman_url")
  1004. if spoolman_enabled and spoolman_enabled.lower() == "true" and spoolman_url:
  1005. try:
  1006. client = await init_spoolman_client(spoolman_url)
  1007. if await client.health_check():
  1008. pass # Connected successfully
  1009. except Exception:
  1010. pass # Spoolman connection failed, but don't fail the restore
  1011. # Build summary message
  1012. restored_parts = []
  1013. for key, count in restored.items():
  1014. if count > 0:
  1015. restored_parts.append(f"{count} {key.replace('_', ' ')}")
  1016. if files_restored > 0:
  1017. restored_parts.append(f"{files_restored} files")
  1018. skipped_parts = []
  1019. total_skipped = sum(skipped.values())
  1020. for key, count in skipped.items():
  1021. if count > 0:
  1022. skipped_parts.append(f"{count} {key.replace('_', ' ')}")
  1023. message_parts = []
  1024. if restored_parts:
  1025. message_parts.append(f"Restored: {', '.join(restored_parts)}")
  1026. if skipped_parts:
  1027. message_parts.append(f"Skipped (already exist): {', '.join(skipped_parts)}")
  1028. return {
  1029. "success": True,
  1030. "message": ". ".join(message_parts) if message_parts else "Nothing to restore",
  1031. "restored": restored,
  1032. "skipped": skipped,
  1033. "skipped_details": skipped_details,
  1034. "files_restored": files_restored,
  1035. "total_skipped": total_skipped,
  1036. }