settings.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713
  1. import io
  2. import json
  3. import zipfile
  4. from datetime import datetime
  5. from pathlib import Path
  6. from typing import Optional
  7. from fastapi import APIRouter, Depends, UploadFile, File, Query
  8. from fastapi.responses import JSONResponse, StreamingResponse
  9. from sqlalchemy.ext.asyncio import AsyncSession
  10. from sqlalchemy import select
  11. from backend.app.core.config import settings as app_settings
  12. from backend.app.core.database import get_db
  13. from backend.app.models.settings import Settings
  14. from backend.app.models.notification import NotificationProvider
  15. from backend.app.models.notification_template import NotificationTemplate
  16. from backend.app.models.smart_plug import SmartPlug
  17. from backend.app.models.printer import Printer
  18. from backend.app.models.filament import Filament
  19. from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance, MaintenanceHistory
  20. from backend.app.models.archive import PrintArchive
  21. from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
  22. router = APIRouter(prefix="/settings", tags=["settings"])
  23. # Default settings
  24. DEFAULT_SETTINGS = AppSettings()
  25. async def get_setting(db: AsyncSession, key: str) -> str | None:
  26. """Get a single setting value by key."""
  27. result = await db.execute(select(Settings).where(Settings.key == key))
  28. setting = result.scalar_one_or_none()
  29. return setting.value if setting else None
  30. async def set_setting(db: AsyncSession, key: str, value: str) -> None:
  31. """Set a single setting value."""
  32. result = await db.execute(select(Settings).where(Settings.key == key))
  33. setting = result.scalar_one_or_none()
  34. if setting:
  35. setting.value = value
  36. else:
  37. setting = Settings(key=key, value=value)
  38. db.add(setting)
  39. @router.get("/", response_model=AppSettings)
  40. async def get_settings(db: AsyncSession = Depends(get_db)):
  41. """Get all application settings."""
  42. settings_dict = DEFAULT_SETTINGS.model_dump()
  43. # Load saved settings from database
  44. result = await db.execute(select(Settings))
  45. db_settings = result.scalars().all()
  46. for setting in db_settings:
  47. if setting.key in settings_dict:
  48. # Parse the value based on the expected type
  49. if setting.key in ["auto_archive", "save_thumbnails", "capture_finish_photo", "spoolman_enabled", "check_updates"]:
  50. settings_dict[setting.key] = setting.value.lower() == "true"
  51. elif setting.key in ["default_filament_cost", "energy_cost_per_kwh", "ams_temp_good", "ams_temp_fair"]:
  52. settings_dict[setting.key] = float(setting.value)
  53. elif setting.key in ["ams_humidity_good", "ams_humidity_fair"]:
  54. settings_dict[setting.key] = int(setting.value)
  55. elif setting.key == "default_printer_id":
  56. # Handle nullable integer
  57. settings_dict[setting.key] = int(setting.value) if setting.value and setting.value != "None" else None
  58. else:
  59. settings_dict[setting.key] = setting.value
  60. return AppSettings(**settings_dict)
  61. @router.put("/", response_model=AppSettings)
  62. async def update_settings(
  63. settings_update: AppSettingsUpdate,
  64. db: AsyncSession = Depends(get_db),
  65. ):
  66. """Update application settings."""
  67. update_data = settings_update.model_dump(exclude_unset=True)
  68. for key, value in update_data.items():
  69. # Convert value to string for storage
  70. if isinstance(value, bool):
  71. str_value = "true" if value else "false"
  72. elif value is None:
  73. str_value = "None"
  74. else:
  75. str_value = str(value)
  76. await set_setting(db, key, str_value)
  77. await db.commit()
  78. # Return updated settings
  79. return await get_settings(db)
  80. @router.post("/reset", response_model=AppSettings)
  81. async def reset_settings(db: AsyncSession = Depends(get_db)):
  82. """Reset all settings to defaults."""
  83. # Delete all settings
  84. result = await db.execute(select(Settings))
  85. for setting in result.scalars().all():
  86. await db.delete(setting)
  87. await db.commit()
  88. return DEFAULT_SETTINGS
  89. @router.get("/check-ffmpeg")
  90. async def check_ffmpeg():
  91. """Check if ffmpeg is installed and available."""
  92. from backend.app.services.camera import get_ffmpeg_path
  93. ffmpeg_path = get_ffmpeg_path()
  94. return {
  95. "installed": ffmpeg_path is not None,
  96. "path": ffmpeg_path,
  97. }
  98. @router.get("/spoolman")
  99. async def get_spoolman_settings(db: AsyncSession = Depends(get_db)):
  100. """Get Spoolman integration settings."""
  101. spoolman_enabled = await get_setting(db, "spoolman_enabled") or "false"
  102. spoolman_url = await get_setting(db, "spoolman_url") or ""
  103. spoolman_sync_mode = await get_setting(db, "spoolman_sync_mode") or "auto"
  104. return {
  105. "spoolman_enabled": spoolman_enabled,
  106. "spoolman_url": spoolman_url,
  107. "spoolman_sync_mode": spoolman_sync_mode,
  108. }
  109. @router.put("/spoolman")
  110. async def update_spoolman_settings(
  111. settings: dict,
  112. db: AsyncSession = Depends(get_db),
  113. ):
  114. """Update Spoolman integration settings."""
  115. if "spoolman_enabled" in settings:
  116. await set_setting(db, "spoolman_enabled", settings["spoolman_enabled"])
  117. if "spoolman_url" in settings:
  118. await set_setting(db, "spoolman_url", settings["spoolman_url"])
  119. if "spoolman_sync_mode" in settings:
  120. await set_setting(db, "spoolman_sync_mode", settings["spoolman_sync_mode"])
  121. await db.commit()
  122. # Return updated settings
  123. return await get_spoolman_settings(db)
  124. @router.get("/backup")
  125. async def export_backup(
  126. db: AsyncSession = Depends(get_db),
  127. include_settings: bool = Query(True, description="Include app settings"),
  128. include_notifications: bool = Query(True, description="Include notification providers"),
  129. include_templates: bool = Query(True, description="Include notification templates"),
  130. include_smart_plugs: bool = Query(True, description="Include smart plugs"),
  131. include_printers: bool = Query(False, description="Include printers (without access codes)"),
  132. include_filaments: bool = Query(False, description="Include filament inventory"),
  133. include_maintenance: bool = Query(False, description="Include maintenance types and records"),
  134. include_archives: bool = Query(False, description="Include print archive metadata"),
  135. ):
  136. """Export selected data as JSON backup."""
  137. backup: dict = {
  138. "version": "2.0",
  139. "exported_at": datetime.utcnow().isoformat(),
  140. "included": [],
  141. }
  142. # Settings
  143. if include_settings:
  144. result = await db.execute(select(Settings))
  145. db_settings = result.scalars().all()
  146. backup["settings"] = {s.key: s.value for s in db_settings}
  147. backup["included"].append("settings")
  148. # Notification providers
  149. if include_notifications:
  150. result = await db.execute(select(NotificationProvider))
  151. providers = result.scalars().all()
  152. backup["notification_providers"] = []
  153. for p in providers:
  154. backup["notification_providers"].append({
  155. "name": p.name,
  156. "provider_type": p.provider_type,
  157. "enabled": p.enabled,
  158. "config": json.loads(p.config) if isinstance(p.config, str) else p.config,
  159. "on_print_start": p.on_print_start,
  160. "on_print_complete": p.on_print_complete,
  161. "on_print_failed": p.on_print_failed,
  162. "on_print_stopped": p.on_print_stopped,
  163. "on_print_progress": p.on_print_progress,
  164. "on_printer_offline": p.on_printer_offline,
  165. "on_printer_error": p.on_printer_error,
  166. "on_filament_low": p.on_filament_low,
  167. "on_maintenance_due": p.on_maintenance_due,
  168. "quiet_hours_enabled": p.quiet_hours_enabled,
  169. "quiet_hours_start": p.quiet_hours_start,
  170. "quiet_hours_end": p.quiet_hours_end,
  171. "daily_digest_enabled": getattr(p, 'daily_digest_enabled', False),
  172. "daily_digest_time": getattr(p, 'daily_digest_time', None),
  173. "printer_id": getattr(p, 'printer_id', None),
  174. })
  175. backup["included"].append("notification_providers")
  176. # Notification templates
  177. if include_templates:
  178. result = await db.execute(select(NotificationTemplate))
  179. templates = result.scalars().all()
  180. backup["notification_templates"] = []
  181. for t in templates:
  182. backup["notification_templates"].append({
  183. "event_type": t.event_type,
  184. "name": t.name,
  185. "title_template": t.title_template,
  186. "body_template": t.body_template,
  187. "is_default": t.is_default,
  188. })
  189. backup["included"].append("notification_templates")
  190. # Smart plugs
  191. if include_smart_plugs:
  192. result = await db.execute(select(SmartPlug))
  193. plugs = result.scalars().all()
  194. backup["smart_plugs"] = []
  195. for plug in plugs:
  196. backup["smart_plugs"].append({
  197. "name": plug.name,
  198. "ip_address": plug.ip_address,
  199. "printer_id": plug.printer_id,
  200. "enabled": plug.enabled,
  201. "auto_on": plug.auto_on,
  202. "auto_off": plug.auto_off,
  203. "off_delay_mode": plug.off_delay_mode,
  204. "off_delay_minutes": plug.off_delay_minutes,
  205. "off_temp_threshold": plug.off_temp_threshold,
  206. "username": plug.username,
  207. "password": plug.password,
  208. "power_alert_enabled": plug.power_alert_enabled,
  209. "power_alert_high": plug.power_alert_high,
  210. "power_alert_low": plug.power_alert_low,
  211. "schedule_enabled": plug.schedule_enabled,
  212. "schedule_on_time": plug.schedule_on_time,
  213. "schedule_off_time": plug.schedule_off_time,
  214. })
  215. backup["included"].append("smart_plugs")
  216. # Printers (without access codes for security)
  217. if include_printers:
  218. result = await db.execute(select(Printer))
  219. printers = result.scalars().all()
  220. backup["printers"] = []
  221. for printer in printers:
  222. backup["printers"].append({
  223. "name": printer.name,
  224. "serial_number": printer.serial_number,
  225. "ip_address": printer.ip_address,
  226. # access_code intentionally excluded for security
  227. "model": printer.model,
  228. "location": printer.location,
  229. "nozzle_count": printer.nozzle_count,
  230. "is_active": printer.is_active,
  231. "auto_archive": printer.auto_archive,
  232. "print_hours_offset": printer.print_hours_offset,
  233. })
  234. backup["included"].append("printers")
  235. # Filaments
  236. if include_filaments:
  237. result = await db.execute(select(Filament))
  238. filaments = result.scalars().all()
  239. backup["filaments"] = []
  240. for f in filaments:
  241. backup["filaments"].append({
  242. "name": f.name,
  243. "type": f.type,
  244. "brand": f.brand,
  245. "color": f.color,
  246. "color_hex": f.color_hex,
  247. "cost_per_kg": f.cost_per_kg,
  248. "spool_weight_g": f.spool_weight_g,
  249. "currency": f.currency,
  250. "density": f.density,
  251. "print_temp_min": f.print_temp_min,
  252. "print_temp_max": f.print_temp_max,
  253. "bed_temp_min": f.bed_temp_min,
  254. "bed_temp_max": f.bed_temp_max,
  255. })
  256. backup["included"].append("filaments")
  257. # Maintenance types and records
  258. if include_maintenance:
  259. # Maintenance types
  260. result = await db.execute(select(MaintenanceType))
  261. types = result.scalars().all()
  262. backup["maintenance_types"] = []
  263. for mt in types:
  264. backup["maintenance_types"].append({
  265. "name": mt.name,
  266. "description": mt.description,
  267. "default_interval_hours": mt.default_interval_hours,
  268. "interval_type": mt.interval_type,
  269. "icon": mt.icon,
  270. "is_system": mt.is_system,
  271. })
  272. backup["included"].append("maintenance_types")
  273. # Print archives with file paths for ZIP
  274. archive_files: list[tuple[str, Path]] = [] # (zip_path, local_path)
  275. if include_archives:
  276. result = await db.execute(select(PrintArchive))
  277. archives = result.scalars().all()
  278. backup["archives"] = []
  279. base_dir = app_settings.base_dir
  280. for a in archives:
  281. archive_data = {
  282. "filename": a.filename,
  283. "file_size": a.file_size,
  284. "content_hash": a.content_hash,
  285. "print_name": a.print_name,
  286. "print_time_seconds": a.print_time_seconds,
  287. "filament_used_grams": a.filament_used_grams,
  288. "filament_type": a.filament_type,
  289. "filament_color": a.filament_color,
  290. "layer_height": a.layer_height,
  291. "total_layers": a.total_layers,
  292. "nozzle_diameter": a.nozzle_diameter,
  293. "bed_temperature": a.bed_temperature,
  294. "nozzle_temperature": a.nozzle_temperature,
  295. "status": a.status,
  296. "started_at": a.started_at.isoformat() if a.started_at else None,
  297. "completed_at": a.completed_at.isoformat() if a.completed_at else None,
  298. "makerworld_url": a.makerworld_url,
  299. "designer": a.designer,
  300. "is_favorite": a.is_favorite,
  301. "tags": a.tags,
  302. "notes": a.notes,
  303. "cost": a.cost,
  304. "failure_reason": a.failure_reason,
  305. "energy_kwh": a.energy_kwh,
  306. "energy_cost": a.energy_cost,
  307. "extra_data": a.extra_data,
  308. "photos": a.photos,
  309. }
  310. # Collect file paths for ZIP
  311. if a.file_path:
  312. file_path = base_dir / a.file_path
  313. if file_path.exists():
  314. archive_data["file_path"] = a.file_path
  315. archive_files.append((a.file_path, file_path))
  316. if a.thumbnail_path:
  317. thumb_path = base_dir / a.thumbnail_path
  318. if thumb_path.exists():
  319. archive_data["thumbnail_path"] = a.thumbnail_path
  320. archive_files.append((a.thumbnail_path, thumb_path))
  321. if a.timelapse_path:
  322. timelapse_path = base_dir / a.timelapse_path
  323. if timelapse_path.exists():
  324. archive_data["timelapse_path"] = a.timelapse_path
  325. archive_files.append((a.timelapse_path, timelapse_path))
  326. if a.source_3mf_path:
  327. source_path = base_dir / a.source_3mf_path
  328. if source_path.exists():
  329. archive_data["source_3mf_path"] = a.source_3mf_path
  330. archive_files.append((a.source_3mf_path, source_path))
  331. # Include photos
  332. if a.photos:
  333. for photo in a.photos:
  334. photo_path = base_dir / "archive" / "photos" / photo
  335. if photo_path.exists():
  336. zip_photo_path = f"archive/photos/{photo}"
  337. archive_files.append((zip_photo_path, photo_path))
  338. backup["archives"].append(archive_data)
  339. backup["included"].append("archives")
  340. # If archives included, create ZIP file with all files
  341. if include_archives and archive_files:
  342. zip_buffer = io.BytesIO()
  343. with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
  344. # Add backup.json
  345. zf.writestr("backup.json", json.dumps(backup, indent=2))
  346. # Add all archive files
  347. added_files = set()
  348. for zip_path, local_path in archive_files:
  349. if zip_path not in added_files and local_path.exists():
  350. try:
  351. zf.write(local_path, zip_path)
  352. added_files.add(zip_path)
  353. except Exception:
  354. pass # Skip files that can't be read
  355. zip_buffer.seek(0)
  356. filename = f"bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip"
  357. return StreamingResponse(
  358. zip_buffer,
  359. media_type="application/zip",
  360. headers={"Content-Disposition": f"attachment; filename={filename}"}
  361. )
  362. # Otherwise return JSON
  363. return JSONResponse(
  364. content=backup,
  365. headers={
  366. "Content-Disposition": f"attachment; filename=bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
  367. }
  368. )
  369. @router.post("/restore")
  370. async def import_backup(
  371. file: UploadFile = File(...),
  372. db: AsyncSession = Depends(get_db),
  373. ):
  374. """Restore data from JSON or ZIP backup. Skips duplicates."""
  375. try:
  376. content = await file.read()
  377. base_dir = app_settings.base_dir
  378. files_restored = 0
  379. # Check if it's a ZIP file
  380. if file.filename and file.filename.endswith('.zip'):
  381. try:
  382. zip_buffer = io.BytesIO(content)
  383. with zipfile.ZipFile(zip_buffer, 'r') as zf:
  384. # Extract backup.json
  385. if 'backup.json' not in zf.namelist():
  386. return {"success": False, "message": "Invalid ZIP: missing backup.json"}
  387. backup_content = zf.read('backup.json')
  388. backup = json.loads(backup_content.decode("utf-8"))
  389. # Extract all other files to base_dir
  390. for zip_path in zf.namelist():
  391. if zip_path == 'backup.json':
  392. continue
  393. # Ensure path is safe (no path traversal)
  394. if '..' in zip_path or zip_path.startswith('/'):
  395. continue
  396. target_path = base_dir / zip_path
  397. target_path.parent.mkdir(parents=True, exist_ok=True)
  398. with zf.open(zip_path) as src, open(target_path, 'wb') as dst:
  399. dst.write(src.read())
  400. files_restored += 1
  401. except zipfile.BadZipFile:
  402. return {"success": False, "message": "Invalid ZIP file"}
  403. else:
  404. backup = json.loads(content.decode("utf-8"))
  405. except json.JSONDecodeError as e:
  406. return {"success": False, "message": f"Invalid JSON: {str(e)}"}
  407. except Exception as e:
  408. return {"success": False, "message": f"Invalid backup file: {str(e)}"}
  409. restored = {
  410. "settings": 0,
  411. "notification_providers": 0,
  412. "notification_templates": 0,
  413. "smart_plugs": 0,
  414. "printers": 0,
  415. "filaments": 0,
  416. "maintenance_types": 0,
  417. }
  418. # Restore settings
  419. if "settings" in backup:
  420. for key, value in backup["settings"].items():
  421. await set_setting(db, key, value)
  422. restored["settings"] += 1
  423. # Restore notification providers (skip duplicates by name)
  424. if "notification_providers" in backup:
  425. for provider_data in backup["notification_providers"]:
  426. result = await db.execute(
  427. select(NotificationProvider).where(NotificationProvider.name == provider_data["name"])
  428. )
  429. existing = result.scalar_one_or_none()
  430. if not existing:
  431. provider = NotificationProvider(
  432. name=provider_data["name"],
  433. provider_type=provider_data["provider_type"],
  434. enabled=provider_data.get("enabled", True),
  435. config=json.dumps(provider_data.get("config", {})),
  436. on_print_start=provider_data.get("on_print_start", False),
  437. on_print_complete=provider_data.get("on_print_complete", True),
  438. on_print_failed=provider_data.get("on_print_failed", True),
  439. on_print_stopped=provider_data.get("on_print_stopped", True),
  440. on_print_progress=provider_data.get("on_print_progress", False),
  441. on_printer_offline=provider_data.get("on_printer_offline", False),
  442. on_printer_error=provider_data.get("on_printer_error", False),
  443. on_filament_low=provider_data.get("on_filament_low", False),
  444. on_maintenance_due=provider_data.get("on_maintenance_due", False),
  445. quiet_hours_enabled=provider_data.get("quiet_hours_enabled", False),
  446. quiet_hours_start=provider_data.get("quiet_hours_start"),
  447. quiet_hours_end=provider_data.get("quiet_hours_end"),
  448. daily_digest_enabled=provider_data.get("daily_digest_enabled", False),
  449. daily_digest_time=provider_data.get("daily_digest_time"),
  450. printer_id=provider_data.get("printer_id"),
  451. )
  452. db.add(provider)
  453. restored["notification_providers"] += 1
  454. # Restore notification templates (update existing by event_type)
  455. if "notification_templates" in backup:
  456. for template_data in backup["notification_templates"]:
  457. result = await db.execute(
  458. select(NotificationTemplate).where(
  459. NotificationTemplate.event_type == template_data["event_type"]
  460. )
  461. )
  462. existing = result.scalar_one_or_none()
  463. if existing:
  464. # Update existing template
  465. existing.name = template_data.get("name", existing.name)
  466. existing.title_template = template_data.get("title_template", existing.title_template)
  467. existing.body_template = template_data.get("body_template", existing.body_template)
  468. existing.is_default = template_data.get("is_default", False)
  469. else:
  470. template = NotificationTemplate(
  471. event_type=template_data["event_type"],
  472. name=template_data["name"],
  473. title_template=template_data["title_template"],
  474. body_template=template_data["body_template"],
  475. is_default=template_data.get("is_default", False),
  476. )
  477. db.add(template)
  478. restored["notification_templates"] += 1
  479. # Restore smart plugs (skip duplicates by IP)
  480. if "smart_plugs" in backup:
  481. for plug_data in backup["smart_plugs"]:
  482. result = await db.execute(
  483. select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"])
  484. )
  485. existing = result.scalar_one_or_none()
  486. if not existing:
  487. plug = SmartPlug(
  488. name=plug_data["name"],
  489. ip_address=plug_data["ip_address"],
  490. printer_id=plug_data.get("printer_id"),
  491. enabled=plug_data.get("enabled", True),
  492. auto_on=plug_data.get("auto_on", True),
  493. auto_off=plug_data.get("auto_off", True),
  494. off_delay_mode=plug_data.get("off_delay_mode", "time"),
  495. off_delay_minutes=plug_data.get("off_delay_minutes", 5),
  496. off_temp_threshold=plug_data.get("off_temp_threshold", 70),
  497. username=plug_data.get("username"),
  498. password=plug_data.get("password"),
  499. power_alert_enabled=plug_data.get("power_alert_enabled", False),
  500. power_alert_high=plug_data.get("power_alert_high"),
  501. power_alert_low=plug_data.get("power_alert_low"),
  502. schedule_enabled=plug_data.get("schedule_enabled", False),
  503. schedule_on_time=plug_data.get("schedule_on_time"),
  504. schedule_off_time=plug_data.get("schedule_off_time"),
  505. )
  506. db.add(plug)
  507. restored["smart_plugs"] += 1
  508. # Restore printers (skip duplicates by serial_number, requires access_code to be set manually)
  509. if "printers" in backup:
  510. for printer_data in backup["printers"]:
  511. result = await db.execute(
  512. select(Printer).where(Printer.serial_number == printer_data["serial_number"])
  513. )
  514. existing = result.scalar_one_or_none()
  515. if not existing:
  516. printer = Printer(
  517. name=printer_data["name"],
  518. serial_number=printer_data["serial_number"],
  519. ip_address=printer_data["ip_address"],
  520. access_code="CHANGE_ME", # Must be set manually for security
  521. model=printer_data.get("model"),
  522. location=printer_data.get("location"),
  523. nozzle_count=printer_data.get("nozzle_count", 1),
  524. is_active=False, # Disabled until access_code is set
  525. auto_archive=printer_data.get("auto_archive", True),
  526. print_hours_offset=printer_data.get("print_hours_offset", 0.0),
  527. )
  528. db.add(printer)
  529. restored["printers"] += 1
  530. # Restore filaments (skip duplicates by name+type+brand)
  531. if "filaments" in backup:
  532. for filament_data in backup["filaments"]:
  533. result = await db.execute(
  534. select(Filament).where(
  535. Filament.name == filament_data["name"],
  536. Filament.type == filament_data["type"],
  537. Filament.brand == filament_data.get("brand"),
  538. )
  539. )
  540. existing = result.scalar_one_or_none()
  541. if not existing:
  542. filament = Filament(
  543. name=filament_data["name"],
  544. type=filament_data["type"],
  545. brand=filament_data.get("brand"),
  546. color=filament_data.get("color"),
  547. color_hex=filament_data.get("color_hex"),
  548. cost_per_kg=filament_data.get("cost_per_kg", 25.0),
  549. spool_weight_g=filament_data.get("spool_weight_g", 1000.0),
  550. currency=filament_data.get("currency", "USD"),
  551. density=filament_data.get("density"),
  552. print_temp_min=filament_data.get("print_temp_min"),
  553. print_temp_max=filament_data.get("print_temp_max"),
  554. bed_temp_min=filament_data.get("bed_temp_min"),
  555. bed_temp_max=filament_data.get("bed_temp_max"),
  556. )
  557. db.add(filament)
  558. restored["filaments"] += 1
  559. # Restore maintenance types (skip duplicates by name)
  560. if "maintenance_types" in backup:
  561. for mt_data in backup["maintenance_types"]:
  562. result = await db.execute(
  563. select(MaintenanceType).where(MaintenanceType.name == mt_data["name"])
  564. )
  565. existing = result.scalar_one_or_none()
  566. if not existing:
  567. mt = MaintenanceType(
  568. name=mt_data["name"],
  569. description=mt_data.get("description"),
  570. default_interval_hours=mt_data.get("default_interval_hours", 100.0),
  571. interval_type=mt_data.get("interval_type", "hours"),
  572. icon=mt_data.get("icon"),
  573. is_system=mt_data.get("is_system", False),
  574. )
  575. db.add(mt)
  576. restored["maintenance_types"] += 1
  577. # Restore archives (skip duplicates by content_hash)
  578. if "archives" in backup:
  579. for archive_data in backup["archives"]:
  580. # Skip if no content_hash or already exists
  581. content_hash = archive_data.get("content_hash")
  582. if content_hash:
  583. result = await db.execute(
  584. select(PrintArchive).where(PrintArchive.content_hash == content_hash)
  585. )
  586. existing = result.scalar_one_or_none()
  587. if existing:
  588. continue
  589. # Only restore if file exists (from ZIP extraction)
  590. file_path = archive_data.get("file_path")
  591. if file_path and (base_dir / file_path).exists():
  592. archive = PrintArchive(
  593. filename=archive_data["filename"],
  594. file_path=file_path,
  595. file_size=archive_data.get("file_size", 0),
  596. content_hash=content_hash,
  597. thumbnail_path=archive_data.get("thumbnail_path"),
  598. timelapse_path=archive_data.get("timelapse_path"),
  599. source_3mf_path=archive_data.get("source_3mf_path"),
  600. print_name=archive_data.get("print_name"),
  601. print_time_seconds=archive_data.get("print_time_seconds"),
  602. filament_used_grams=archive_data.get("filament_used_grams"),
  603. filament_type=archive_data.get("filament_type"),
  604. filament_color=archive_data.get("filament_color"),
  605. layer_height=archive_data.get("layer_height"),
  606. total_layers=archive_data.get("total_layers"),
  607. nozzle_diameter=archive_data.get("nozzle_diameter"),
  608. bed_temperature=archive_data.get("bed_temperature"),
  609. nozzle_temperature=archive_data.get("nozzle_temperature"),
  610. status=archive_data.get("status", "completed"),
  611. makerworld_url=archive_data.get("makerworld_url"),
  612. designer=archive_data.get("designer"),
  613. is_favorite=archive_data.get("is_favorite", False),
  614. tags=archive_data.get("tags"),
  615. notes=archive_data.get("notes"),
  616. cost=archive_data.get("cost"),
  617. failure_reason=archive_data.get("failure_reason"),
  618. energy_kwh=archive_data.get("energy_kwh"),
  619. energy_cost=archive_data.get("energy_cost"),
  620. extra_data=archive_data.get("extra_data"),
  621. photos=archive_data.get("photos"),
  622. )
  623. db.add(archive)
  624. restored["archives"] = restored.get("archives", 0) + 1
  625. await db.commit()
  626. # Build summary message
  627. parts = []
  628. for key, count in restored.items():
  629. if count > 0:
  630. parts.append(f"{count} {key.replace('_', ' ')}")
  631. if files_restored > 0:
  632. parts.append(f"{files_restored} files")
  633. return {
  634. "success": True,
  635. "message": f"Restored: {', '.join(parts)}" if parts else "Nothing to restore",
  636. "restored": restored,
  637. "files_restored": files_restored,
  638. }