settings.py 80 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782
  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 MaintenanceHistory, MaintenanceType, PrinterMaintenance
  16. from backend.app.models.notification import NotificationProvider
  17. from backend.app.models.notification_template import NotificationTemplate
  18. from backend.app.models.pending_upload import PendingUpload
  19. from backend.app.models.print_queue import PrintQueueItem
  20. from backend.app.models.printer import Printer
  21. from backend.app.models.project import Project
  22. from backend.app.models.project_bom import ProjectBOMItem
  23. from backend.app.models.settings import Settings
  24. from backend.app.models.smart_plug import SmartPlug
  25. from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
  26. from backend.app.services.printer_manager import printer_manager
  27. from backend.app.services.spoolman import init_spoolman_client
  28. router = APIRouter(prefix="/settings", tags=["settings"])
  29. # Default settings
  30. DEFAULT_SETTINGS = AppSettings()
  31. async def get_setting(db: AsyncSession, key: str) -> str | None:
  32. """Get a single setting value by key."""
  33. result = await db.execute(select(Settings).where(Settings.key == key))
  34. setting = result.scalar_one_or_none()
  35. return setting.value if setting else None
  36. async def set_setting(db: AsyncSession, key: str, value: str) -> None:
  37. """Set a single setting value."""
  38. from sqlalchemy import func
  39. from sqlalchemy.dialects.sqlite import insert as sqlite_insert
  40. # Use upsert (INSERT ... ON CONFLICT UPDATE) for reliability
  41. stmt = sqlite_insert(Settings).values(key=key, value=value)
  42. stmt = stmt.on_conflict_do_update(index_elements=["key"], set_={"value": value, "updated_at": func.now()})
  43. await db.execute(stmt)
  44. @router.get("/", response_model=AppSettings)
  45. async def get_settings(db: AsyncSession = Depends(get_db)):
  46. """Get all application settings."""
  47. settings_dict = DEFAULT_SETTINGS.model_dump()
  48. # Load saved settings from database
  49. result = await db.execute(select(Settings))
  50. db_settings = result.scalars().all()
  51. for setting in db_settings:
  52. if setting.key in settings_dict:
  53. # Parse the value based on the expected type
  54. if setting.key in [
  55. "auto_archive",
  56. "save_thumbnails",
  57. "capture_finish_photo",
  58. "spoolman_enabled",
  59. "check_updates",
  60. "telemetry_enabled",
  61. "virtual_printer_enabled",
  62. "ftp_retry_enabled",
  63. ]:
  64. settings_dict[setting.key] = setting.value.lower() == "true"
  65. elif setting.key in ["default_filament_cost", "energy_cost_per_kwh", "ams_temp_good", "ams_temp_fair"]:
  66. settings_dict[setting.key] = float(setting.value)
  67. elif setting.key in [
  68. "ams_humidity_good",
  69. "ams_humidity_fair",
  70. "ams_history_retention_days",
  71. "ftp_retry_count",
  72. "ftp_retry_delay",
  73. ]:
  74. settings_dict[setting.key] = int(setting.value)
  75. elif setting.key == "default_printer_id":
  76. # Handle nullable integer
  77. settings_dict[setting.key] = int(setting.value) if setting.value and setting.value != "None" else None
  78. else:
  79. settings_dict[setting.key] = setting.value
  80. return AppSettings(**settings_dict)
  81. @router.put("/", response_model=AppSettings)
  82. async def update_settings(
  83. settings_update: AppSettingsUpdate,
  84. db: AsyncSession = Depends(get_db),
  85. ):
  86. """Update application settings."""
  87. update_data = settings_update.model_dump(exclude_unset=True)
  88. for key, value in update_data.items():
  89. # Convert value to string for storage
  90. if isinstance(value, bool):
  91. str_value = "true" if value else "false"
  92. elif value is None:
  93. str_value = "None"
  94. else:
  95. str_value = str(value)
  96. await set_setting(db, key, str_value)
  97. await db.commit()
  98. # Return updated settings
  99. return await get_settings(db)
  100. @router.post("/reset", response_model=AppSettings)
  101. async def reset_settings(db: AsyncSession = Depends(get_db)):
  102. """Reset all settings to defaults."""
  103. # Delete all settings
  104. result = await db.execute(select(Settings))
  105. for setting in result.scalars().all():
  106. await db.delete(setting)
  107. await db.commit()
  108. return DEFAULT_SETTINGS
  109. @router.get("/check-ffmpeg")
  110. async def check_ffmpeg():
  111. """Check if ffmpeg is installed and available."""
  112. from backend.app.services.camera import get_ffmpeg_path
  113. ffmpeg_path = get_ffmpeg_path()
  114. return {
  115. "installed": ffmpeg_path is not None,
  116. "path": ffmpeg_path,
  117. }
  118. @router.get("/spoolman")
  119. async def get_spoolman_settings(db: AsyncSession = Depends(get_db)):
  120. """Get Spoolman integration settings."""
  121. spoolman_enabled = await get_setting(db, "spoolman_enabled") or "false"
  122. spoolman_url = await get_setting(db, "spoolman_url") or ""
  123. spoolman_sync_mode = await get_setting(db, "spoolman_sync_mode") or "auto"
  124. return {
  125. "spoolman_enabled": spoolman_enabled,
  126. "spoolman_url": spoolman_url,
  127. "spoolman_sync_mode": spoolman_sync_mode,
  128. }
  129. @router.put("/spoolman")
  130. async def update_spoolman_settings(
  131. settings: dict,
  132. db: AsyncSession = Depends(get_db),
  133. ):
  134. """Update Spoolman integration settings."""
  135. if "spoolman_enabled" in settings:
  136. await set_setting(db, "spoolman_enabled", settings["spoolman_enabled"])
  137. if "spoolman_url" in settings:
  138. await set_setting(db, "spoolman_url", settings["spoolman_url"])
  139. if "spoolman_sync_mode" in settings:
  140. await set_setting(db, "spoolman_sync_mode", settings["spoolman_sync_mode"])
  141. await db.commit()
  142. # Return updated settings
  143. return await get_spoolman_settings(db)
  144. @router.get("/backup")
  145. async def export_backup(
  146. db: AsyncSession = Depends(get_db),
  147. include_settings: bool = Query(True, description="Include app settings"),
  148. include_notifications: bool = Query(True, description="Include notification providers"),
  149. include_templates: bool = Query(True, description="Include notification templates"),
  150. include_smart_plugs: bool = Query(True, description="Include smart plugs"),
  151. include_external_links: bool = Query(True, description="Include external sidebar links"),
  152. include_printers: bool = Query(False, description="Include printers (without access codes)"),
  153. include_filaments: bool = Query(False, description="Include filament inventory"),
  154. include_maintenance: bool = Query(
  155. False, description="Include maintenance types, per-printer settings, and history"
  156. ),
  157. include_print_queue: bool = Query(False, description="Include print queue items"),
  158. include_archives: bool = Query(False, description="Include print archive metadata"),
  159. include_projects: bool = Query(False, description="Include projects with BOM items"),
  160. include_pending_uploads: bool = Query(False, description="Include pending virtual printer uploads"),
  161. include_access_codes: bool = Query(False, description="Include printer access codes (security risk!)"),
  162. ):
  163. """Export selected data as JSON backup."""
  164. backup: dict = {
  165. "version": "2.0",
  166. "exported_at": datetime.utcnow().isoformat(),
  167. "included": [],
  168. }
  169. # Settings
  170. if include_settings:
  171. result = await db.execute(select(Settings))
  172. db_settings = result.scalars().all()
  173. backup["settings"] = {s.key: s.value for s in db_settings}
  174. backup["included"].append("settings")
  175. # Notification providers
  176. if include_notifications:
  177. # Build printer ID to serial lookup for cross-system backup
  178. printer_id_to_serial: dict[int, str] = {}
  179. pr_result = await db.execute(select(Printer))
  180. for pr in pr_result.scalars().all():
  181. printer_id_to_serial[pr.id] = pr.serial_number
  182. result = await db.execute(select(NotificationProvider))
  183. providers = result.scalars().all()
  184. backup["notification_providers"] = []
  185. for p in providers:
  186. # Use printer_serial for cross-system compatibility
  187. provider_printer_id = getattr(p, "printer_id", None)
  188. printer_serial = printer_id_to_serial.get(provider_printer_id) if provider_printer_id else None
  189. backup["notification_providers"].append(
  190. {
  191. "name": p.name,
  192. "provider_type": p.provider_type,
  193. "enabled": p.enabled,
  194. "config": json.loads(p.config) if isinstance(p.config, str) else p.config,
  195. "on_print_start": p.on_print_start,
  196. "on_print_complete": p.on_print_complete,
  197. "on_print_failed": p.on_print_failed,
  198. "on_print_stopped": p.on_print_stopped,
  199. "on_print_progress": p.on_print_progress,
  200. "on_printer_offline": p.on_printer_offline,
  201. "on_printer_error": p.on_printer_error,
  202. "on_filament_low": p.on_filament_low,
  203. "on_maintenance_due": p.on_maintenance_due,
  204. "on_ams_humidity_high": getattr(p, "on_ams_humidity_high", False),
  205. "on_ams_temperature_high": getattr(p, "on_ams_temperature_high", False),
  206. "on_ams_ht_humidity_high": getattr(p, "on_ams_ht_humidity_high", False),
  207. "on_ams_ht_temperature_high": getattr(p, "on_ams_ht_temperature_high", False),
  208. "quiet_hours_enabled": p.quiet_hours_enabled,
  209. "quiet_hours_start": p.quiet_hours_start,
  210. "quiet_hours_end": p.quiet_hours_end,
  211. "daily_digest_enabled": getattr(p, "daily_digest_enabled", False),
  212. "daily_digest_time": getattr(p, "daily_digest_time", None),
  213. "printer_serial": printer_serial,
  214. }
  215. )
  216. backup["included"].append("notification_providers")
  217. # Notification templates
  218. if include_templates:
  219. result = await db.execute(select(NotificationTemplate))
  220. templates = result.scalars().all()
  221. backup["notification_templates"] = []
  222. for t in templates:
  223. backup["notification_templates"].append(
  224. {
  225. "event_type": t.event_type,
  226. "name": t.name,
  227. "title_template": t.title_template,
  228. "body_template": t.body_template,
  229. "is_default": t.is_default,
  230. }
  231. )
  232. backup["included"].append("notification_templates")
  233. # Smart plugs
  234. if include_smart_plugs:
  235. result = await db.execute(select(SmartPlug))
  236. plugs = result.scalars().all()
  237. backup["smart_plugs"] = []
  238. # Build printer ID to serial mapping
  239. printer_id_to_serial: dict[int, str] = {}
  240. pr_result = await db.execute(select(Printer))
  241. for pr in pr_result.scalars().all():
  242. printer_id_to_serial[pr.id] = pr.serial_number
  243. for plug in plugs:
  244. backup["smart_plugs"].append(
  245. {
  246. "name": plug.name,
  247. "ip_address": plug.ip_address,
  248. "printer_serial": printer_id_to_serial.get(plug.printer_id) if plug.printer_id else None,
  249. "enabled": plug.enabled,
  250. "auto_on": plug.auto_on,
  251. "auto_off": plug.auto_off,
  252. "off_delay_mode": plug.off_delay_mode,
  253. "off_delay_minutes": plug.off_delay_minutes,
  254. "off_temp_threshold": plug.off_temp_threshold,
  255. "username": plug.username,
  256. "password": plug.password,
  257. "power_alert_enabled": plug.power_alert_enabled,
  258. "power_alert_high": plug.power_alert_high,
  259. "power_alert_low": plug.power_alert_low,
  260. "schedule_enabled": plug.schedule_enabled,
  261. "schedule_on_time": plug.schedule_on_time,
  262. "schedule_off_time": plug.schedule_off_time,
  263. "show_in_switchbar": plug.show_in_switchbar,
  264. }
  265. )
  266. backup["included"].append("smart_plugs")
  267. # External links
  268. if include_external_links:
  269. result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order))
  270. links = result.scalars().all()
  271. backup["external_links"] = []
  272. icons_dir = app_settings.base_dir / "icons"
  273. for link in links:
  274. link_data = {
  275. "name": link.name,
  276. "url": link.url,
  277. "icon": link.icon,
  278. "sort_order": link.sort_order,
  279. }
  280. # Include custom icon file path if exists
  281. if link.custom_icon:
  282. link_data["custom_icon"] = link.custom_icon
  283. icon_path = icons_dir / link.custom_icon
  284. if icon_path.exists():
  285. link_data["custom_icon_path"] = f"icons/{link.custom_icon}"
  286. backup["external_links"].append(link_data)
  287. backup["included"].append("external_links")
  288. # Printers (access codes only included if explicitly requested)
  289. if include_printers:
  290. result = await db.execute(select(Printer))
  291. printers = result.scalars().all()
  292. backup["printers"] = []
  293. for printer in printers:
  294. printer_data = {
  295. "name": printer.name,
  296. "serial_number": printer.serial_number,
  297. "ip_address": printer.ip_address,
  298. "model": printer.model,
  299. "location": printer.location,
  300. "nozzle_count": printer.nozzle_count,
  301. "is_active": printer.is_active,
  302. "auto_archive": printer.auto_archive,
  303. "print_hours_offset": printer.print_hours_offset,
  304. "runtime_seconds": printer.runtime_seconds,
  305. }
  306. if include_access_codes:
  307. printer_data["access_code"] = printer.access_code
  308. backup["printers"].append(printer_data)
  309. backup["included"].append("printers")
  310. if include_access_codes:
  311. backup["included"].append("access_codes")
  312. # Filaments
  313. if include_filaments:
  314. result = await db.execute(select(Filament))
  315. filaments = result.scalars().all()
  316. backup["filaments"] = []
  317. for f in filaments:
  318. backup["filaments"].append(
  319. {
  320. "name": f.name,
  321. "type": f.type,
  322. "brand": f.brand,
  323. "color": f.color,
  324. "color_hex": f.color_hex,
  325. "cost_per_kg": f.cost_per_kg,
  326. "spool_weight_g": f.spool_weight_g,
  327. "currency": f.currency,
  328. "density": f.density,
  329. "print_temp_min": f.print_temp_min,
  330. "print_temp_max": f.print_temp_max,
  331. "bed_temp_min": f.bed_temp_min,
  332. "bed_temp_max": f.bed_temp_max,
  333. }
  334. )
  335. backup["included"].append("filaments")
  336. # Maintenance types and records
  337. if include_maintenance:
  338. # Maintenance types
  339. result = await db.execute(select(MaintenanceType))
  340. types = result.scalars().all()
  341. backup["maintenance_types"] = []
  342. for mt in types:
  343. backup["maintenance_types"].append(
  344. {
  345. "name": mt.name,
  346. "description": mt.description,
  347. "default_interval_hours": mt.default_interval_hours,
  348. "interval_type": mt.interval_type,
  349. "icon": mt.icon,
  350. "is_system": mt.is_system,
  351. }
  352. )
  353. backup["included"].append("maintenance_types")
  354. # Printer maintenance settings (per-printer custom intervals, enabled status, last performed)
  355. result = await db.execute(select(PrinterMaintenance))
  356. printer_maint = result.scalars().all()
  357. backup["printer_maintenance"] = []
  358. # Build lookups for printer serial and maintenance type name
  359. printer_id_to_serial: dict[int, str] = {}
  360. maint_type_id_to_name: dict[int, str] = {}
  361. pr_result = await db.execute(select(Printer))
  362. for pr in pr_result.scalars().all():
  363. printer_id_to_serial[pr.id] = pr.serial_number
  364. for mt in types:
  365. maint_type_id_to_name[mt.id] = mt.name
  366. for pm in printer_maint:
  367. backup["printer_maintenance"].append(
  368. {
  369. "printer_serial": printer_id_to_serial.get(pm.printer_id),
  370. "maintenance_type_name": maint_type_id_to_name.get(pm.maintenance_type_id),
  371. "custom_interval_hours": pm.custom_interval_hours,
  372. "custom_interval_type": pm.custom_interval_type,
  373. "enabled": pm.enabled,
  374. "last_performed_at": pm.last_performed_at.isoformat() if pm.last_performed_at else None,
  375. "last_performed_hours": pm.last_performed_hours,
  376. }
  377. )
  378. backup["included"].append("printer_maintenance")
  379. # Maintenance history
  380. result = await db.execute(select(MaintenanceHistory))
  381. history = result.scalars().all()
  382. backup["maintenance_history"] = []
  383. # Build printer_maintenance ID to (printer_serial, maint_type_name) mapping
  384. pm_id_to_info: dict[int, tuple[str | None, str | None]] = {}
  385. for pm in printer_maint:
  386. pm_id_to_info[pm.id] = (
  387. printer_id_to_serial.get(pm.printer_id),
  388. maint_type_id_to_name.get(pm.maintenance_type_id),
  389. )
  390. for mh in history:
  391. info = pm_id_to_info.get(mh.printer_maintenance_id, (None, None))
  392. backup["maintenance_history"].append(
  393. {
  394. "printer_serial": info[0],
  395. "maintenance_type_name": info[1],
  396. "performed_at": mh.performed_at.isoformat() if mh.performed_at else None,
  397. "hours_at_maintenance": mh.hours_at_maintenance,
  398. "notes": mh.notes,
  399. }
  400. )
  401. backup["included"].append("maintenance_history")
  402. # Print queue
  403. if include_print_queue:
  404. result = await db.execute(select(PrintQueueItem))
  405. queue_items = result.scalars().all()
  406. backup["print_queue"] = []
  407. # Build lookups
  408. printer_id_to_serial: dict[int, str] = {}
  409. archive_id_to_hash: dict[int, str | None] = {}
  410. project_id_to_name: dict[int, str] = {}
  411. pr_result = await db.execute(select(Printer))
  412. for pr in pr_result.scalars().all():
  413. printer_id_to_serial[pr.id] = pr.serial_number
  414. ar_result = await db.execute(select(PrintArchive))
  415. for ar in ar_result.scalars().all():
  416. archive_id_to_hash[ar.id] = ar.content_hash
  417. proj_result = await db.execute(select(Project))
  418. for proj in proj_result.scalars().all():
  419. project_id_to_name[proj.id] = proj.name
  420. for qi in queue_items:
  421. backup["print_queue"].append(
  422. {
  423. "printer_serial": printer_id_to_serial.get(qi.printer_id),
  424. "archive_hash": archive_id_to_hash.get(qi.archive_id),
  425. "project_name": project_id_to_name.get(qi.project_id) if qi.project_id else None,
  426. "position": qi.position,
  427. "scheduled_time": qi.scheduled_time.isoformat() if qi.scheduled_time else None,
  428. "require_previous_success": qi.require_previous_success,
  429. "auto_off_after": qi.auto_off_after,
  430. "manual_start": qi.manual_start,
  431. "status": qi.status,
  432. "started_at": qi.started_at.isoformat() if qi.started_at else None,
  433. "completed_at": qi.completed_at.isoformat() if qi.completed_at else None,
  434. "error_message": qi.error_message,
  435. }
  436. )
  437. backup["included"].append("print_queue")
  438. # Collect files for ZIP (icons + archives)
  439. backup_files: list[tuple[str, Path]] = [] # (zip_path, local_path)
  440. # Add external link icon files
  441. if include_external_links and "external_links" in backup:
  442. icons_dir = app_settings.base_dir / "icons"
  443. for link_data in backup["external_links"]:
  444. if "custom_icon_path" in link_data:
  445. icon_path = icons_dir / link_data["custom_icon"]
  446. if icon_path.exists():
  447. backup_files.append((link_data["custom_icon_path"], icon_path))
  448. # Print archives with file paths for ZIP
  449. if include_archives:
  450. result = await db.execute(select(PrintArchive))
  451. archives = result.scalars().all()
  452. backup["archives"] = []
  453. base_dir = app_settings.base_dir
  454. # Build project ID to name mapping for archive export
  455. project_id_to_name: dict[int, str] = {}
  456. if include_projects:
  457. proj_result = await db.execute(select(Project))
  458. for proj in proj_result.scalars().all():
  459. project_id_to_name[proj.id] = proj.name
  460. # Build printer ID to serial mapping for archive export
  461. printer_id_to_serial: dict[int, str] = {}
  462. if include_printers:
  463. printer_result = await db.execute(select(Printer))
  464. for pr in printer_result.scalars().all():
  465. printer_id_to_serial[pr.id] = pr.serial_number
  466. for a in archives:
  467. archive_data = {
  468. "filename": a.filename,
  469. "project_name": project_id_to_name.get(a.project_id) if a.project_id else None,
  470. "printer_serial": printer_id_to_serial.get(a.printer_id) if a.printer_id else None,
  471. "file_size": a.file_size,
  472. "content_hash": a.content_hash,
  473. "print_name": a.print_name,
  474. "print_time_seconds": a.print_time_seconds,
  475. "filament_used_grams": a.filament_used_grams,
  476. "filament_type": a.filament_type,
  477. "filament_color": a.filament_color,
  478. "layer_height": a.layer_height,
  479. "total_layers": a.total_layers,
  480. "nozzle_diameter": a.nozzle_diameter,
  481. "bed_temperature": a.bed_temperature,
  482. "nozzle_temperature": a.nozzle_temperature,
  483. "status": a.status,
  484. "started_at": a.started_at.isoformat() if a.started_at else None,
  485. "completed_at": a.completed_at.isoformat() if a.completed_at else None,
  486. "makerworld_url": a.makerworld_url,
  487. "designer": a.designer,
  488. "is_favorite": a.is_favorite,
  489. "tags": a.tags,
  490. "notes": a.notes,
  491. "cost": a.cost,
  492. "failure_reason": a.failure_reason,
  493. "quantity": a.quantity,
  494. "energy_kwh": a.energy_kwh,
  495. "energy_cost": a.energy_cost,
  496. "extra_data": a.extra_data,
  497. "photos": a.photos,
  498. }
  499. # Collect file paths for ZIP
  500. if a.file_path:
  501. file_path = base_dir / a.file_path
  502. if file_path.exists():
  503. archive_data["file_path"] = a.file_path
  504. backup_files.append((a.file_path, file_path))
  505. if a.thumbnail_path:
  506. thumb_path = base_dir / a.thumbnail_path
  507. if thumb_path.exists():
  508. archive_data["thumbnail_path"] = a.thumbnail_path
  509. backup_files.append((a.thumbnail_path, thumb_path))
  510. if a.timelapse_path:
  511. timelapse_path = base_dir / a.timelapse_path
  512. if timelapse_path.exists():
  513. archive_data["timelapse_path"] = a.timelapse_path
  514. backup_files.append((a.timelapse_path, timelapse_path))
  515. if a.source_3mf_path:
  516. source_path = base_dir / a.source_3mf_path
  517. if source_path.exists():
  518. archive_data["source_3mf_path"] = a.source_3mf_path
  519. backup_files.append((a.source_3mf_path, source_path))
  520. # Include photos
  521. if a.photos:
  522. for photo in a.photos:
  523. photo_path = base_dir / "archive" / "photos" / photo
  524. if photo_path.exists():
  525. zip_photo_path = f"archive/photos/{photo}"
  526. backup_files.append((zip_photo_path, photo_path))
  527. backup["archives"].append(archive_data)
  528. backup["included"].append("archives")
  529. # Projects with BOM items
  530. if include_projects:
  531. result = await db.execute(select(Project))
  532. projects = result.scalars().all()
  533. backup["projects"] = []
  534. for p in projects:
  535. # Get BOM items for this project
  536. bom_result = await db.execute(select(ProjectBOMItem).where(ProjectBOMItem.project_id == p.id))
  537. bom_items = bom_result.scalars().all()
  538. project_data = {
  539. "name": p.name,
  540. "description": p.description,
  541. "color": p.color,
  542. "status": p.status,
  543. "target_count": p.target_count,
  544. "notes": p.notes,
  545. "tags": p.tags,
  546. "due_date": p.due_date.isoformat() if p.due_date else None,
  547. "priority": p.priority,
  548. "budget": p.budget,
  549. "is_template": p.is_template,
  550. "template_source_id": p.template_source_id,
  551. "parent_id": p.parent_id,
  552. "bom_items": [
  553. {
  554. "name": item.name,
  555. "quantity_needed": item.quantity_needed,
  556. "quantity_acquired": item.quantity_acquired,
  557. "unit_price": item.unit_price,
  558. "sourcing_url": item.sourcing_url,
  559. "stl_filename": item.stl_filename,
  560. "remarks": item.remarks,
  561. "sort_order": item.sort_order,
  562. }
  563. for item in bom_items
  564. ],
  565. }
  566. # Include attachment files for ZIP
  567. if p.attachments:
  568. project_data["attachments"] = p.attachments
  569. attachments_dir = base_dir / "projects" / str(p.id) / "attachments"
  570. for att in p.attachments:
  571. att_path = attachments_dir / att.get("filename", "")
  572. if att_path.exists():
  573. zip_path = f"projects/{p.id}/attachments/{att['filename']}"
  574. backup_files.append((zip_path, att_path))
  575. backup["projects"].append(project_data)
  576. backup["included"].append("projects")
  577. # Pending uploads (virtual printer queue mode)
  578. if include_pending_uploads:
  579. result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
  580. pending_uploads = result.scalars().all()
  581. backup["pending_uploads"] = []
  582. for p in pending_uploads:
  583. upload_data = {
  584. "filename": p.filename,
  585. "file_size": p.file_size,
  586. "source_ip": p.source_ip,
  587. "status": p.status,
  588. "tags": p.tags,
  589. "notes": p.notes,
  590. "project_id": p.project_id,
  591. "uploaded_at": p.uploaded_at.isoformat() if p.uploaded_at else None,
  592. }
  593. # Include the actual file if it exists
  594. if p.file_path:
  595. file_path = Path(p.file_path)
  596. if file_path.exists():
  597. # Store relative path for ZIP
  598. rel_path = f"pending_uploads/{p.filename}"
  599. upload_data["file_path"] = rel_path
  600. backup_files.append((rel_path, file_path))
  601. backup["pending_uploads"].append(upload_data)
  602. backup["included"].append("pending_uploads")
  603. # If there are files to include (icons or archives), create ZIP file
  604. if backup_files:
  605. zip_buffer = io.BytesIO()
  606. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  607. # Add backup.json
  608. zf.writestr("backup.json", json.dumps(backup, indent=2))
  609. # Add all backup files (icons, archives, etc.)
  610. added_files = set()
  611. for zip_path, local_path in backup_files:
  612. if zip_path not in added_files and local_path.exists():
  613. try:
  614. zf.write(local_path, zip_path)
  615. added_files.add(zip_path)
  616. except Exception:
  617. pass # Skip files that can't be read
  618. zip_buffer.seek(0)
  619. filename = f"bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip"
  620. return StreamingResponse(
  621. zip_buffer,
  622. media_type="application/zip",
  623. headers={"Content-Disposition": f"attachment; filename={filename}"},
  624. )
  625. # Otherwise return JSON
  626. return JSONResponse(
  627. content=backup,
  628. headers={
  629. "Content-Disposition": f"attachment; filename=bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
  630. },
  631. )
  632. @router.post("/restore")
  633. async def import_backup(
  634. file: UploadFile = File(...),
  635. overwrite: bool = Query(False, description="Overwrite existing data instead of skipping duplicates"),
  636. db: AsyncSession = Depends(get_db),
  637. ):
  638. """Restore data from JSON or ZIP backup. By default skips duplicates, set overwrite=true to replace existing."""
  639. try:
  640. content = await file.read()
  641. base_dir = app_settings.base_dir
  642. files_restored = 0
  643. # Check if it's a ZIP file
  644. if file.filename and file.filename.endswith(".zip"):
  645. try:
  646. zip_buffer = io.BytesIO(content)
  647. with zipfile.ZipFile(zip_buffer, "r") as zf:
  648. # Extract backup.json
  649. if "backup.json" not in zf.namelist():
  650. return {"success": False, "message": "Invalid ZIP: missing backup.json"}
  651. backup_content = zf.read("backup.json")
  652. backup = json.loads(backup_content.decode("utf-8"))
  653. # Extract all other files to base_dir
  654. for zip_path in zf.namelist():
  655. if zip_path == "backup.json":
  656. continue
  657. # Ensure path is safe (no path traversal)
  658. if ".." in zip_path or zip_path.startswith("/"):
  659. continue
  660. target_path = base_dir / zip_path
  661. target_path.parent.mkdir(parents=True, exist_ok=True)
  662. with zf.open(zip_path) as src, open(target_path, "wb") as dst:
  663. dst.write(src.read())
  664. files_restored += 1
  665. except zipfile.BadZipFile:
  666. return {"success": False, "message": "Invalid ZIP file"}
  667. else:
  668. backup = json.loads(content.decode("utf-8"))
  669. except json.JSONDecodeError as e:
  670. return {"success": False, "message": f"Invalid JSON: {str(e)}"}
  671. except Exception as e:
  672. return {"success": False, "message": f"Invalid backup file: {str(e)}"}
  673. restored = {
  674. "settings": 0,
  675. "notification_providers": 0,
  676. "notification_templates": 0,
  677. "smart_plugs": 0,
  678. "external_links": 0,
  679. "printers": 0,
  680. "filaments": 0,
  681. "maintenance_types": 0,
  682. "projects": 0,
  683. "pending_uploads": 0,
  684. }
  685. skipped = {
  686. "settings": 0,
  687. "notification_providers": 0,
  688. "notification_templates": 0,
  689. "smart_plugs": 0,
  690. "external_links": 0,
  691. "printers": 0,
  692. "filaments": 0,
  693. "maintenance_types": 0,
  694. "archives": 0,
  695. "projects": 0,
  696. "pending_uploads": 0,
  697. }
  698. skipped_details = {
  699. "notification_providers": [],
  700. "smart_plugs": [],
  701. "external_links": [],
  702. "printers": [],
  703. "filaments": [],
  704. "maintenance_types": [],
  705. "archives": [],
  706. "projects": [],
  707. "pending_uploads": [],
  708. }
  709. # Restore settings (always overwrites)
  710. if "settings" in backup:
  711. for key, value in backup["settings"].items():
  712. # Convert value to proper string format for storage
  713. if isinstance(value, bool):
  714. str_value = "true" if value else "false"
  715. elif value is None:
  716. str_value = "None"
  717. else:
  718. str_value = str(value)
  719. await set_setting(db, key, str_value)
  720. restored["settings"] += 1
  721. # Flush settings to ensure they're persisted before continuing
  722. await db.flush()
  723. # Restore printers FIRST (skip or overwrite duplicates by serial_number)
  724. # Nearly everything in the app references printers, so they must be imported first
  725. if "printers" in backup:
  726. for printer_data in backup["printers"]:
  727. result = await db.execute(select(Printer).where(Printer.serial_number == printer_data["serial_number"]))
  728. existing = result.scalar_one_or_none()
  729. if existing:
  730. if overwrite:
  731. existing.name = printer_data["name"]
  732. existing.ip_address = printer_data["ip_address"]
  733. existing.model = printer_data.get("model")
  734. existing.location = printer_data.get("location")
  735. existing.nozzle_count = printer_data.get("nozzle_count", 1)
  736. existing.auto_archive = printer_data.get("auto_archive", True)
  737. existing.print_hours_offset = printer_data.get("print_hours_offset", 0.0)
  738. existing.runtime_seconds = printer_data.get("runtime_seconds", 0)
  739. # If backup includes access_code, also update access_code and is_active
  740. backup_access_code = printer_data.get("access_code")
  741. if backup_access_code and backup_access_code != "CHANGE_ME":
  742. existing.access_code = backup_access_code
  743. is_active_val = printer_data.get("is_active", False)
  744. if isinstance(is_active_val, str):
  745. is_active_val = is_active_val.lower() == "true"
  746. existing.is_active = is_active_val
  747. restored["printers"] += 1
  748. else:
  749. skipped["printers"] += 1
  750. skipped_details["printers"].append(f"{printer_data['name']} ({printer_data['serial_number']})")
  751. else:
  752. # Use access code from backup if provided, otherwise require manual setup
  753. access_code = printer_data.get("access_code")
  754. has_access_code = access_code and access_code != "CHANGE_ME"
  755. is_active_from_backup = printer_data.get("is_active", False)
  756. # Handle bool or string "true"/"false"
  757. if isinstance(is_active_from_backup, str):
  758. is_active_from_backup = is_active_from_backup.lower() == "true"
  759. printer = Printer(
  760. name=printer_data["name"],
  761. serial_number=printer_data["serial_number"],
  762. ip_address=printer_data["ip_address"],
  763. access_code=access_code if has_access_code else "CHANGE_ME",
  764. model=printer_data.get("model"),
  765. location=printer_data.get("location"),
  766. nozzle_count=printer_data.get("nozzle_count", 1),
  767. is_active=is_active_from_backup if has_access_code else False,
  768. auto_archive=printer_data.get("auto_archive", True),
  769. print_hours_offset=printer_data.get("print_hours_offset", 0.0),
  770. runtime_seconds=printer_data.get("runtime_seconds", 0),
  771. )
  772. db.add(printer)
  773. restored["printers"] += 1
  774. # Flush printers so other sections can look them up
  775. await db.flush()
  776. # Restore notification providers (skip or overwrite duplicates by name)
  777. # Build printer serial to ID lookup (printers were restored first)
  778. if "notification_providers" in backup:
  779. printer_serial_to_id: dict[str, int] = {}
  780. pr_result = await db.execute(select(Printer))
  781. for pr in pr_result.scalars().all():
  782. printer_serial_to_id[pr.serial_number] = pr.id
  783. for provider_data in backup["notification_providers"]:
  784. # Look up printer_id from serial (supports both old printer_id and new printer_serial format)
  785. printer_serial = provider_data.get("printer_serial")
  786. printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else provider_data.get("printer_id")
  787. result = await db.execute(
  788. select(NotificationProvider).where(NotificationProvider.name == provider_data["name"])
  789. )
  790. existing = result.scalar_one_or_none()
  791. if existing:
  792. if overwrite:
  793. # Update existing provider
  794. existing.provider_type = provider_data["provider_type"]
  795. existing.enabled = provider_data.get("enabled", True)
  796. existing.config = json.dumps(provider_data.get("config", {}))
  797. existing.on_print_start = provider_data.get("on_print_start", False)
  798. existing.on_print_complete = provider_data.get("on_print_complete", True)
  799. existing.on_print_failed = provider_data.get("on_print_failed", True)
  800. existing.on_print_stopped = provider_data.get("on_print_stopped", True)
  801. existing.on_print_progress = provider_data.get("on_print_progress", False)
  802. existing.on_printer_offline = provider_data.get("on_printer_offline", False)
  803. existing.on_printer_error = provider_data.get("on_printer_error", False)
  804. existing.on_filament_low = provider_data.get("on_filament_low", False)
  805. existing.on_maintenance_due = provider_data.get("on_maintenance_due", False)
  806. existing.on_ams_humidity_high = provider_data.get("on_ams_humidity_high", False)
  807. existing.on_ams_temperature_high = provider_data.get("on_ams_temperature_high", False)
  808. existing.on_ams_ht_humidity_high = provider_data.get("on_ams_ht_humidity_high", False)
  809. existing.on_ams_ht_temperature_high = provider_data.get("on_ams_ht_temperature_high", False)
  810. existing.quiet_hours_enabled = provider_data.get("quiet_hours_enabled", False)
  811. existing.quiet_hours_start = provider_data.get("quiet_hours_start")
  812. existing.quiet_hours_end = provider_data.get("quiet_hours_end")
  813. existing.daily_digest_enabled = provider_data.get("daily_digest_enabled", False)
  814. existing.daily_digest_time = provider_data.get("daily_digest_time")
  815. existing.printer_id = printer_id
  816. restored["notification_providers"] += 1
  817. else:
  818. skipped["notification_providers"] += 1
  819. skipped_details["notification_providers"].append(provider_data["name"])
  820. else:
  821. provider = NotificationProvider(
  822. name=provider_data["name"],
  823. provider_type=provider_data["provider_type"],
  824. enabled=provider_data.get("enabled", True),
  825. config=json.dumps(provider_data.get("config", {})),
  826. on_print_start=provider_data.get("on_print_start", False),
  827. on_print_complete=provider_data.get("on_print_complete", True),
  828. on_print_failed=provider_data.get("on_print_failed", True),
  829. on_print_stopped=provider_data.get("on_print_stopped", True),
  830. on_print_progress=provider_data.get("on_print_progress", False),
  831. on_printer_offline=provider_data.get("on_printer_offline", False),
  832. on_printer_error=provider_data.get("on_printer_error", False),
  833. on_filament_low=provider_data.get("on_filament_low", False),
  834. on_maintenance_due=provider_data.get("on_maintenance_due", False),
  835. on_ams_humidity_high=provider_data.get("on_ams_humidity_high", False),
  836. on_ams_temperature_high=provider_data.get("on_ams_temperature_high", False),
  837. on_ams_ht_humidity_high=provider_data.get("on_ams_ht_humidity_high", False),
  838. on_ams_ht_temperature_high=provider_data.get("on_ams_ht_temperature_high", False),
  839. quiet_hours_enabled=provider_data.get("quiet_hours_enabled", False),
  840. quiet_hours_start=provider_data.get("quiet_hours_start"),
  841. quiet_hours_end=provider_data.get("quiet_hours_end"),
  842. daily_digest_enabled=provider_data.get("daily_digest_enabled", False),
  843. daily_digest_time=provider_data.get("daily_digest_time"),
  844. printer_id=printer_id,
  845. )
  846. db.add(provider)
  847. restored["notification_providers"] += 1
  848. # Restore notification templates (update existing by event_type)
  849. if "notification_templates" in backup:
  850. for template_data in backup["notification_templates"]:
  851. result = await db.execute(
  852. select(NotificationTemplate).where(NotificationTemplate.event_type == template_data["event_type"])
  853. )
  854. existing = result.scalar_one_or_none()
  855. if existing:
  856. # Update existing template
  857. existing.name = template_data.get("name", existing.name)
  858. existing.title_template = template_data.get("title_template", existing.title_template)
  859. existing.body_template = template_data.get("body_template", existing.body_template)
  860. existing.is_default = template_data.get("is_default", False)
  861. else:
  862. template = NotificationTemplate(
  863. event_type=template_data["event_type"],
  864. name=template_data["name"],
  865. title_template=template_data["title_template"],
  866. body_template=template_data["body_template"],
  867. is_default=template_data.get("is_default", False),
  868. )
  869. db.add(template)
  870. restored["notification_templates"] += 1
  871. # Restore smart plugs (skip or overwrite duplicates by IP)
  872. # Note: Smart plugs reference printers, so printers should be restored first
  873. if "smart_plugs" in backup:
  874. # Build printer serial to ID lookup
  875. printer_serial_to_id: dict[str, int] = {}
  876. pr_result = await db.execute(select(Printer))
  877. for pr in pr_result.scalars().all():
  878. printer_serial_to_id[pr.serial_number] = pr.id
  879. for plug_data in backup["smart_plugs"]:
  880. # Look up printer_id from serial (supports both old printer_id and new printer_serial format)
  881. printer_serial = plug_data.get("printer_serial")
  882. printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else plug_data.get("printer_id")
  883. result = await db.execute(select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"]))
  884. existing = result.scalar_one_or_none()
  885. if existing:
  886. if overwrite:
  887. existing.name = plug_data["name"]
  888. existing.printer_id = printer_id
  889. existing.enabled = plug_data.get("enabled", True)
  890. existing.auto_on = plug_data.get("auto_on", True)
  891. existing.auto_off = plug_data.get("auto_off", True)
  892. existing.off_delay_mode = plug_data.get("off_delay_mode", "time")
  893. existing.off_delay_minutes = plug_data.get("off_delay_minutes", 5)
  894. existing.off_temp_threshold = plug_data.get("off_temp_threshold", 70)
  895. existing.username = plug_data.get("username")
  896. existing.password = plug_data.get("password")
  897. existing.power_alert_enabled = plug_data.get("power_alert_enabled", False)
  898. existing.power_alert_high = plug_data.get("power_alert_high")
  899. existing.power_alert_low = plug_data.get("power_alert_low")
  900. existing.schedule_enabled = plug_data.get("schedule_enabled", False)
  901. existing.schedule_on_time = plug_data.get("schedule_on_time")
  902. existing.schedule_off_time = plug_data.get("schedule_off_time")
  903. existing.show_in_switchbar = plug_data.get("show_in_switchbar", False)
  904. restored["smart_plugs"] += 1
  905. else:
  906. skipped["smart_plugs"] += 1
  907. skipped_details["smart_plugs"].append(f"{plug_data['name']} ({plug_data['ip_address']})")
  908. else:
  909. plug = SmartPlug(
  910. name=plug_data["name"],
  911. ip_address=plug_data["ip_address"],
  912. printer_id=printer_id,
  913. enabled=plug_data.get("enabled", True),
  914. auto_on=plug_data.get("auto_on", True),
  915. auto_off=plug_data.get("auto_off", True),
  916. off_delay_mode=plug_data.get("off_delay_mode", "time"),
  917. off_delay_minutes=plug_data.get("off_delay_minutes", 5),
  918. off_temp_threshold=plug_data.get("off_temp_threshold", 70),
  919. username=plug_data.get("username"),
  920. password=plug_data.get("password"),
  921. power_alert_enabled=plug_data.get("power_alert_enabled", False),
  922. power_alert_high=plug_data.get("power_alert_high"),
  923. power_alert_low=plug_data.get("power_alert_low"),
  924. schedule_enabled=plug_data.get("schedule_enabled", False),
  925. schedule_on_time=plug_data.get("schedule_on_time"),
  926. schedule_off_time=plug_data.get("schedule_off_time"),
  927. show_in_switchbar=plug_data.get("show_in_switchbar", False),
  928. )
  929. db.add(plug)
  930. restored["smart_plugs"] += 1
  931. # Restore external links (skip or overwrite duplicates by name+url)
  932. if "external_links" in backup:
  933. icons_dir = base_dir / "icons"
  934. icons_dir.mkdir(parents=True, exist_ok=True)
  935. for link_data in backup["external_links"]:
  936. result = await db.execute(
  937. select(ExternalLink).where(ExternalLink.name == link_data["name"], ExternalLink.url == link_data["url"])
  938. )
  939. existing = result.scalar_one_or_none()
  940. if existing:
  941. if overwrite:
  942. existing.icon = link_data.get("icon", "link")
  943. existing.sort_order = link_data.get("sort_order", 0)
  944. # Handle custom icon
  945. if link_data.get("custom_icon"):
  946. existing.custom_icon = link_data["custom_icon"]
  947. restored["external_links"] += 1
  948. else:
  949. skipped["external_links"] += 1
  950. skipped_details["external_links"].append(link_data["name"])
  951. else:
  952. link = ExternalLink(
  953. name=link_data["name"],
  954. url=link_data["url"],
  955. icon=link_data.get("icon", "link"),
  956. custom_icon=link_data.get("custom_icon"),
  957. sort_order=link_data.get("sort_order", 0),
  958. )
  959. db.add(link)
  960. restored["external_links"] += 1
  961. # Restore filaments (skip or overwrite duplicates by name+type+brand)
  962. if "filaments" in backup:
  963. for filament_data in backup["filaments"]:
  964. result = await db.execute(
  965. select(Filament).where(
  966. Filament.name == filament_data["name"],
  967. Filament.type == filament_data["type"],
  968. Filament.brand == filament_data.get("brand"),
  969. )
  970. )
  971. existing = result.scalar_one_or_none()
  972. if existing:
  973. if overwrite:
  974. existing.color = filament_data.get("color")
  975. existing.color_hex = filament_data.get("color_hex")
  976. existing.cost_per_kg = filament_data.get("cost_per_kg", 25.0)
  977. existing.spool_weight_g = filament_data.get("spool_weight_g", 1000.0)
  978. existing.currency = filament_data.get("currency", "USD")
  979. existing.density = filament_data.get("density")
  980. existing.print_temp_min = filament_data.get("print_temp_min")
  981. existing.print_temp_max = filament_data.get("print_temp_max")
  982. existing.bed_temp_min = filament_data.get("bed_temp_min")
  983. existing.bed_temp_max = filament_data.get("bed_temp_max")
  984. restored["filaments"] += 1
  985. else:
  986. skipped["filaments"] += 1
  987. skipped_details["filaments"].append(
  988. f"{filament_data.get('brand', '')} {filament_data['name']} ({filament_data['type']})"
  989. )
  990. else:
  991. filament = Filament(
  992. name=filament_data["name"],
  993. type=filament_data["type"],
  994. brand=filament_data.get("brand"),
  995. color=filament_data.get("color"),
  996. color_hex=filament_data.get("color_hex"),
  997. cost_per_kg=filament_data.get("cost_per_kg", 25.0),
  998. spool_weight_g=filament_data.get("spool_weight_g", 1000.0),
  999. currency=filament_data.get("currency", "USD"),
  1000. density=filament_data.get("density"),
  1001. print_temp_min=filament_data.get("print_temp_min"),
  1002. print_temp_max=filament_data.get("print_temp_max"),
  1003. bed_temp_min=filament_data.get("bed_temp_min"),
  1004. bed_temp_max=filament_data.get("bed_temp_max"),
  1005. )
  1006. db.add(filament)
  1007. restored["filaments"] += 1
  1008. # Restore maintenance types (skip or overwrite duplicates by name)
  1009. if "maintenance_types" in backup:
  1010. for mt_data in backup["maintenance_types"]:
  1011. result = await db.execute(select(MaintenanceType).where(MaintenanceType.name == mt_data["name"]))
  1012. existing = result.scalar_one_or_none()
  1013. if existing:
  1014. if overwrite:
  1015. existing.description = mt_data.get("description")
  1016. existing.default_interval_hours = mt_data.get("default_interval_hours", 100.0)
  1017. existing.interval_type = mt_data.get("interval_type", "hours")
  1018. existing.icon = mt_data.get("icon")
  1019. # Don't overwrite is_system
  1020. restored["maintenance_types"] += 1
  1021. else:
  1022. skipped["maintenance_types"] += 1
  1023. skipped_details["maintenance_types"].append(mt_data["name"])
  1024. else:
  1025. mt = MaintenanceType(
  1026. name=mt_data["name"],
  1027. description=mt_data.get("description"),
  1028. default_interval_hours=mt_data.get("default_interval_hours", 100.0),
  1029. interval_type=mt_data.get("interval_type", "hours"),
  1030. icon=mt_data.get("icon"),
  1031. is_system=mt_data.get("is_system", False),
  1032. )
  1033. db.add(mt)
  1034. restored["maintenance_types"] += 1
  1035. # Restore printer maintenance settings (per-printer)
  1036. if "printer_maintenance" in backup:
  1037. # Build lookups
  1038. printer_serial_to_id: dict[str, int] = {}
  1039. maint_type_name_to_id: dict[str, int] = {}
  1040. pr_result = await db.execute(select(Printer))
  1041. for pr in pr_result.scalars().all():
  1042. printer_serial_to_id[pr.serial_number] = pr.id
  1043. mt_result = await db.execute(select(MaintenanceType))
  1044. for mt in mt_result.scalars().all():
  1045. maint_type_name_to_id[mt.name] = mt.id
  1046. restored["printer_maintenance"] = 0
  1047. skipped["printer_maintenance"] = 0
  1048. skipped_details["printer_maintenance"] = []
  1049. for pm_data in backup["printer_maintenance"]:
  1050. printer_serial = pm_data.get("printer_serial")
  1051. maint_type_name = pm_data.get("maintenance_type_name")
  1052. if not printer_serial or not maint_type_name:
  1053. continue
  1054. printer_id = printer_serial_to_id.get(printer_serial)
  1055. maint_type_id = maint_type_name_to_id.get(maint_type_name)
  1056. if not printer_id or not maint_type_id:
  1057. skipped["printer_maintenance"] += 1
  1058. skipped_details["printer_maintenance"].append(f"{printer_serial}/{maint_type_name}")
  1059. continue
  1060. # Check if exists
  1061. result = await db.execute(
  1062. select(PrinterMaintenance).where(
  1063. PrinterMaintenance.printer_id == printer_id,
  1064. PrinterMaintenance.maintenance_type_id == maint_type_id,
  1065. )
  1066. )
  1067. existing = result.scalar_one_or_none()
  1068. if existing:
  1069. if overwrite:
  1070. existing.custom_interval_hours = pm_data.get("custom_interval_hours")
  1071. existing.custom_interval_type = pm_data.get("custom_interval_type")
  1072. existing.enabled = pm_data.get("enabled", True)
  1073. existing.last_performed_hours = pm_data.get("last_performed_hours", 0.0)
  1074. if pm_data.get("last_performed_at"):
  1075. existing.last_performed_at = datetime.fromisoformat(pm_data["last_performed_at"])
  1076. restored["printer_maintenance"] += 1
  1077. else:
  1078. skipped["printer_maintenance"] += 1
  1079. skipped_details["printer_maintenance"].append(f"{printer_serial}/{maint_type_name}")
  1080. else:
  1081. pm = PrinterMaintenance(
  1082. printer_id=printer_id,
  1083. maintenance_type_id=maint_type_id,
  1084. custom_interval_hours=pm_data.get("custom_interval_hours"),
  1085. custom_interval_type=pm_data.get("custom_interval_type"),
  1086. enabled=pm_data.get("enabled", True),
  1087. last_performed_hours=pm_data.get("last_performed_hours", 0.0),
  1088. )
  1089. if pm_data.get("last_performed_at"):
  1090. pm.last_performed_at = datetime.fromisoformat(pm_data["last_performed_at"])
  1091. db.add(pm)
  1092. restored["printer_maintenance"] += 1
  1093. # Restore maintenance history
  1094. if "maintenance_history" in backup:
  1095. # Build lookups
  1096. printer_serial_to_id: dict[str, int] = {}
  1097. maint_type_name_to_id: dict[str, int] = {}
  1098. pr_result = await db.execute(select(Printer))
  1099. for pr in pr_result.scalars().all():
  1100. printer_serial_to_id[pr.serial_number] = pr.id
  1101. mt_result = await db.execute(select(MaintenanceType))
  1102. for mt in mt_result.scalars().all():
  1103. maint_type_name_to_id[mt.name] = mt.id
  1104. restored["maintenance_history"] = 0
  1105. skipped["maintenance_history"] = 0
  1106. skipped_details["maintenance_history"] = []
  1107. for mh_data in backup["maintenance_history"]:
  1108. printer_serial = mh_data.get("printer_serial")
  1109. maint_type_name = mh_data.get("maintenance_type_name")
  1110. if not printer_serial or not maint_type_name:
  1111. continue
  1112. printer_id = printer_serial_to_id.get(printer_serial)
  1113. maint_type_id = maint_type_name_to_id.get(maint_type_name)
  1114. if not printer_id or not maint_type_id:
  1115. skipped["maintenance_history"] += 1
  1116. continue
  1117. # Find the PrinterMaintenance record
  1118. result = await db.execute(
  1119. select(PrinterMaintenance).where(
  1120. PrinterMaintenance.printer_id == printer_id,
  1121. PrinterMaintenance.maintenance_type_id == maint_type_id,
  1122. )
  1123. )
  1124. pm = result.scalar_one_or_none()
  1125. if not pm:
  1126. skipped["maintenance_history"] += 1
  1127. continue
  1128. # Create history entry (no duplicate check - history is append-only)
  1129. mh = MaintenanceHistory(
  1130. printer_maintenance_id=pm.id,
  1131. hours_at_maintenance=mh_data.get("hours_at_maintenance", 0.0),
  1132. notes=mh_data.get("notes"),
  1133. )
  1134. if mh_data.get("performed_at"):
  1135. mh.performed_at = datetime.fromisoformat(mh_data["performed_at"])
  1136. db.add(mh)
  1137. restored["maintenance_history"] += 1
  1138. # Restore archives (skip duplicates by content_hash - overwrite not supported for archives)
  1139. if "archives" in backup:
  1140. # Build printer serial to ID mapping
  1141. printer_serial_to_id: dict[str, int] = {}
  1142. printer_result = await db.execute(select(Printer))
  1143. for pr in printer_result.scalars().all():
  1144. printer_serial_to_id[pr.serial_number] = pr.id
  1145. for archive_data in backup["archives"]:
  1146. # Skip if no content_hash or already exists
  1147. content_hash = archive_data.get("content_hash")
  1148. if content_hash:
  1149. result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash == content_hash))
  1150. existing = result.scalar_one_or_none()
  1151. if existing:
  1152. skipped["archives"] += 1
  1153. skipped_details["archives"].append(archive_data.get("filename", "Unknown"))
  1154. continue
  1155. # Only restore if file exists (from ZIP extraction)
  1156. file_path = archive_data.get("file_path")
  1157. if file_path and (base_dir / file_path).exists():
  1158. # Look up printer_id from serial
  1159. printer_serial = archive_data.get("printer_serial")
  1160. printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else None
  1161. archive = PrintArchive(
  1162. filename=archive_data["filename"],
  1163. file_path=file_path,
  1164. file_size=archive_data.get("file_size", 0),
  1165. content_hash=content_hash,
  1166. printer_id=printer_id,
  1167. thumbnail_path=archive_data.get("thumbnail_path"),
  1168. timelapse_path=archive_data.get("timelapse_path"),
  1169. source_3mf_path=archive_data.get("source_3mf_path"),
  1170. print_name=archive_data.get("print_name"),
  1171. print_time_seconds=archive_data.get("print_time_seconds"),
  1172. filament_used_grams=archive_data.get("filament_used_grams"),
  1173. filament_type=archive_data.get("filament_type"),
  1174. filament_color=archive_data.get("filament_color"),
  1175. layer_height=archive_data.get("layer_height"),
  1176. total_layers=archive_data.get("total_layers"),
  1177. nozzle_diameter=archive_data.get("nozzle_diameter"),
  1178. bed_temperature=archive_data.get("bed_temperature"),
  1179. nozzle_temperature=archive_data.get("nozzle_temperature"),
  1180. status=archive_data.get("status", "completed"),
  1181. makerworld_url=archive_data.get("makerworld_url"),
  1182. designer=archive_data.get("designer"),
  1183. is_favorite=archive_data.get("is_favorite", False),
  1184. tags=archive_data.get("tags"),
  1185. notes=archive_data.get("notes"),
  1186. cost=archive_data.get("cost"),
  1187. failure_reason=archive_data.get("failure_reason"),
  1188. quantity=archive_data.get("quantity", 1),
  1189. energy_kwh=archive_data.get("energy_kwh"),
  1190. energy_cost=archive_data.get("energy_cost"),
  1191. extra_data=archive_data.get("extra_data"),
  1192. photos=archive_data.get("photos"),
  1193. )
  1194. db.add(archive)
  1195. restored["archives"] = restored.get("archives", 0) + 1
  1196. # Restore projects (skip or overwrite duplicates by name)
  1197. if "projects" in backup:
  1198. for project_data in backup["projects"]:
  1199. result = await db.execute(select(Project).where(Project.name == project_data["name"]))
  1200. existing = result.scalar_one_or_none()
  1201. if existing:
  1202. if overwrite:
  1203. # Update existing project
  1204. existing.description = project_data.get("description")
  1205. existing.color = project_data.get("color")
  1206. existing.status = project_data.get("status", "active")
  1207. existing.target_count = project_data.get("target_count")
  1208. existing.notes = project_data.get("notes")
  1209. existing.tags = project_data.get("tags")
  1210. existing.priority = project_data.get("priority", "normal")
  1211. existing.budget = project_data.get("budget")
  1212. existing.is_template = project_data.get("is_template", False)
  1213. existing.template_source_id = project_data.get("template_source_id")
  1214. existing.parent_id = project_data.get("parent_id")
  1215. existing.attachments = project_data.get("attachments")
  1216. if project_data.get("due_date"):
  1217. existing.due_date = datetime.fromisoformat(project_data["due_date"])
  1218. # Delete existing BOM items and re-add
  1219. await db.execute(ProjectBOMItem.__table__.delete().where(ProjectBOMItem.project_id == existing.id))
  1220. for bom_data in project_data.get("bom_items", []):
  1221. bom_item = ProjectBOMItem(
  1222. project_id=existing.id,
  1223. name=bom_data["name"],
  1224. quantity_needed=bom_data.get("quantity_needed", 1),
  1225. quantity_acquired=bom_data.get("quantity_acquired", 0),
  1226. unit_price=bom_data.get("unit_price"),
  1227. sourcing_url=bom_data.get("sourcing_url"),
  1228. stl_filename=bom_data.get("stl_filename"),
  1229. remarks=bom_data.get("remarks"),
  1230. sort_order=bom_data.get("sort_order", 0),
  1231. )
  1232. db.add(bom_item)
  1233. restored["projects"] += 1
  1234. else:
  1235. skipped["projects"] += 1
  1236. skipped_details["projects"].append(project_data["name"])
  1237. else:
  1238. # Create new project
  1239. project = Project(
  1240. name=project_data["name"],
  1241. description=project_data.get("description"),
  1242. color=project_data.get("color"),
  1243. status=project_data.get("status", "active"),
  1244. target_count=project_data.get("target_count"),
  1245. notes=project_data.get("notes"),
  1246. tags=project_data.get("tags"),
  1247. priority=project_data.get("priority", "normal"),
  1248. budget=project_data.get("budget"),
  1249. is_template=project_data.get("is_template", False),
  1250. template_source_id=project_data.get("template_source_id"),
  1251. parent_id=project_data.get("parent_id"),
  1252. attachments=project_data.get("attachments"),
  1253. )
  1254. if project_data.get("due_date"):
  1255. project.due_date = datetime.fromisoformat(project_data["due_date"])
  1256. db.add(project)
  1257. await db.flush() # Get the project ID
  1258. # Add BOM items
  1259. for bom_data in project_data.get("bom_items", []):
  1260. bom_item = ProjectBOMItem(
  1261. project_id=project.id,
  1262. name=bom_data["name"],
  1263. quantity_needed=bom_data.get("quantity_needed", 1),
  1264. quantity_acquired=bom_data.get("quantity_acquired", 0),
  1265. unit_price=bom_data.get("unit_price"),
  1266. sourcing_url=bom_data.get("sourcing_url"),
  1267. stl_filename=bom_data.get("stl_filename"),
  1268. remarks=bom_data.get("remarks"),
  1269. sort_order=bom_data.get("sort_order", 0),
  1270. )
  1271. db.add(bom_item)
  1272. restored["projects"] += 1
  1273. # Link archives to projects by name (after both are restored)
  1274. if "archives" in backup and "projects" in backup:
  1275. # Build project name to ID mapping
  1276. proj_result = await db.execute(select(Project))
  1277. project_name_to_id: dict[str, int] = {}
  1278. for proj in proj_result.scalars().all():
  1279. project_name_to_id[proj.name] = proj.id
  1280. # Update archives with project_id
  1281. for archive_data in backup["archives"]:
  1282. project_name = archive_data.get("project_name")
  1283. if project_name and project_name in project_name_to_id:
  1284. content_hash = archive_data.get("content_hash")
  1285. if content_hash:
  1286. result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash == content_hash))
  1287. archive = result.scalar_one_or_none()
  1288. if archive:
  1289. archive.project_id = project_name_to_id[project_name]
  1290. # Restore print queue (must be after archives and projects)
  1291. if "print_queue" in backup:
  1292. # Build lookups
  1293. printer_serial_to_id: dict[str, int] = {}
  1294. archive_hash_to_id: dict[str, int] = {}
  1295. project_name_to_id: dict[str, int] = {}
  1296. pr_result = await db.execute(select(Printer))
  1297. for pr in pr_result.scalars().all():
  1298. printer_serial_to_id[pr.serial_number] = pr.id
  1299. ar_result = await db.execute(select(PrintArchive))
  1300. for ar in ar_result.scalars().all():
  1301. if ar.content_hash:
  1302. archive_hash_to_id[ar.content_hash] = ar.id
  1303. proj_result = await db.execute(select(Project))
  1304. for proj in proj_result.scalars().all():
  1305. project_name_to_id[proj.name] = proj.id
  1306. restored["print_queue"] = 0
  1307. skipped["print_queue"] = 0
  1308. skipped_details["print_queue"] = []
  1309. for qi_data in backup["print_queue"]:
  1310. printer_serial = qi_data.get("printer_serial")
  1311. archive_hash = qi_data.get("archive_hash")
  1312. if not printer_serial or not archive_hash:
  1313. skipped["print_queue"] += 1
  1314. continue
  1315. printer_id = printer_serial_to_id.get(printer_serial)
  1316. archive_id = archive_hash_to_id.get(archive_hash)
  1317. if not printer_id or not archive_id:
  1318. skipped["print_queue"] += 1
  1319. skipped_details["print_queue"].append(f"{printer_serial}/{archive_hash[:8] if archive_hash else 'N/A'}")
  1320. continue
  1321. project_name = qi_data.get("project_name")
  1322. project_id = project_name_to_id.get(project_name) if project_name else None
  1323. qi = PrintQueueItem(
  1324. printer_id=printer_id,
  1325. archive_id=archive_id,
  1326. project_id=project_id,
  1327. position=qi_data.get("position", 0),
  1328. require_previous_success=qi_data.get("require_previous_success", False),
  1329. auto_off_after=qi_data.get("auto_off_after", False),
  1330. manual_start=qi_data.get("manual_start", False),
  1331. status=qi_data.get("status", "pending"),
  1332. error_message=qi_data.get("error_message"),
  1333. )
  1334. if qi_data.get("scheduled_time"):
  1335. qi.scheduled_time = datetime.fromisoformat(qi_data["scheduled_time"])
  1336. if qi_data.get("started_at"):
  1337. qi.started_at = datetime.fromisoformat(qi_data["started_at"])
  1338. if qi_data.get("completed_at"):
  1339. qi.completed_at = datetime.fromisoformat(qi_data["completed_at"])
  1340. db.add(qi)
  1341. restored["print_queue"] += 1
  1342. # Restore pending uploads (skip duplicates by filename)
  1343. if "pending_uploads" in backup:
  1344. # Ensure the pending uploads directory exists
  1345. pending_uploads_dir = base_dir / "virtual_printer" / "uploads"
  1346. pending_uploads_dir.mkdir(parents=True, exist_ok=True)
  1347. for upload_data in backup["pending_uploads"]:
  1348. # Check for existing by filename
  1349. result = await db.execute(
  1350. select(PendingUpload).where(
  1351. PendingUpload.filename == upload_data["filename"],
  1352. PendingUpload.status == "pending",
  1353. )
  1354. )
  1355. existing = result.scalar_one_or_none()
  1356. if existing:
  1357. if overwrite:
  1358. # Update existing
  1359. existing.file_size = upload_data.get("file_size", 0)
  1360. existing.source_ip = upload_data.get("source_ip")
  1361. existing.tags = upload_data.get("tags")
  1362. existing.notes = upload_data.get("notes")
  1363. existing.project_id = upload_data.get("project_id")
  1364. # Update file path if file was restored from ZIP
  1365. if upload_data.get("file_path"):
  1366. restored_file = base_dir / upload_data["file_path"]
  1367. if restored_file.exists():
  1368. # Move to proper location
  1369. target_path = pending_uploads_dir / upload_data["filename"]
  1370. if restored_file != target_path:
  1371. import shutil
  1372. shutil.move(str(restored_file), str(target_path))
  1373. existing.file_path = str(target_path)
  1374. restored["pending_uploads"] += 1
  1375. else:
  1376. skipped["pending_uploads"] += 1
  1377. skipped_details["pending_uploads"].append(upload_data["filename"])
  1378. else:
  1379. # Determine file path
  1380. file_path_str = None
  1381. if upload_data.get("file_path"):
  1382. restored_file = base_dir / upload_data["file_path"]
  1383. if restored_file.exists():
  1384. # Move to proper location
  1385. target_path = pending_uploads_dir / upload_data["filename"]
  1386. if restored_file != target_path:
  1387. import shutil
  1388. shutil.move(str(restored_file), str(target_path))
  1389. file_path_str = str(target_path)
  1390. # Parse uploaded_at
  1391. uploaded_at = None
  1392. if upload_data.get("uploaded_at"):
  1393. try:
  1394. uploaded_at = datetime.fromisoformat(upload_data["uploaded_at"].replace("Z", "+00:00"))
  1395. except (ValueError, AttributeError):
  1396. uploaded_at = datetime.utcnow()
  1397. else:
  1398. uploaded_at = datetime.utcnow()
  1399. pending = PendingUpload(
  1400. filename=upload_data["filename"],
  1401. file_path=file_path_str or "",
  1402. file_size=upload_data.get("file_size", 0),
  1403. source_ip=upload_data.get("source_ip"),
  1404. status="pending",
  1405. tags=upload_data.get("tags"),
  1406. notes=upload_data.get("notes"),
  1407. project_id=upload_data.get("project_id"),
  1408. uploaded_at=uploaded_at,
  1409. )
  1410. db.add(pending)
  1411. restored["pending_uploads"] += 1
  1412. await db.commit()
  1413. # If printers were in the backup (restored, updated, or skipped), reconnect all active printers
  1414. # This ensures connections are re-established after restore, even if printers were skipped
  1415. if "printers" in backup:
  1416. # Need fresh query after commit to get proper IDs for newly created printers
  1417. result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
  1418. active_printers = result.scalars().all()
  1419. for printer in active_printers:
  1420. # This will disconnect existing connection (if any) and reconnect
  1421. try:
  1422. await printer_manager.connect_printer(printer)
  1423. except Exception:
  1424. pass # Connection failed, but don't fail the restore
  1425. # If settings were restored, check if Spoolman needs to be reconnected
  1426. if "settings" in backup:
  1427. spoolman_enabled = await get_setting(db, "spoolman_enabled")
  1428. spoolman_url = await get_setting(db, "spoolman_url")
  1429. if spoolman_enabled and spoolman_enabled.lower() == "true" and spoolman_url:
  1430. try:
  1431. client = await init_spoolman_client(spoolman_url)
  1432. if await client.health_check():
  1433. pass # Connected successfully
  1434. except Exception:
  1435. pass # Spoolman connection failed, but don't fail the restore
  1436. # Reconfigure virtual printer if settings were restored
  1437. try:
  1438. from backend.app.services.virtual_printer import virtual_printer_manager
  1439. vp_enabled = await get_setting(db, "virtual_printer_enabled")
  1440. vp_access_code = await get_setting(db, "virtual_printer_access_code")
  1441. vp_mode = await get_setting(db, "virtual_printer_mode")
  1442. vp_model = await get_setting(db, "virtual_printer_model")
  1443. enabled = vp_enabled and vp_enabled.lower() == "true"
  1444. access_code = vp_access_code or ""
  1445. mode = vp_mode or "immediate"
  1446. model = vp_model or ""
  1447. if enabled and access_code:
  1448. await virtual_printer_manager.configure(
  1449. enabled=True,
  1450. access_code=access_code,
  1451. mode=mode,
  1452. model=model,
  1453. )
  1454. elif not enabled and virtual_printer_manager.is_enabled:
  1455. await virtual_printer_manager.configure(
  1456. enabled=False,
  1457. access_code=access_code,
  1458. mode=mode,
  1459. model=model,
  1460. )
  1461. except Exception:
  1462. pass # Virtual printer config failed, but don't fail the restore
  1463. # Build summary message
  1464. restored_parts = []
  1465. for key, count in restored.items():
  1466. if count > 0:
  1467. restored_parts.append(f"{count} {key.replace('_', ' ')}")
  1468. if files_restored > 0:
  1469. restored_parts.append(f"{files_restored} files")
  1470. skipped_parts = []
  1471. total_skipped = sum(skipped.values())
  1472. for key, count in skipped.items():
  1473. if count > 0:
  1474. skipped_parts.append(f"{count} {key.replace('_', ' ')}")
  1475. message_parts = []
  1476. if restored_parts:
  1477. message_parts.append(f"Restored: {', '.join(restored_parts)}")
  1478. if skipped_parts:
  1479. message_parts.append(f"Skipped (already exist): {', '.join(skipped_parts)}")
  1480. return {
  1481. "success": True,
  1482. "message": ". ".join(message_parts) if message_parts else "Nothing to restore",
  1483. "restored": restored,
  1484. "skipped": skipped,
  1485. "skipped_details": skipped_details,
  1486. "files_restored": files_restored,
  1487. "total_skipped": total_skipped,
  1488. }
  1489. # =============================================================================
  1490. # Virtual Printer Settings
  1491. # =============================================================================
  1492. @router.get("/virtual-printer/models")
  1493. async def get_virtual_printer_models():
  1494. """Get available virtual printer models."""
  1495. from backend.app.services.virtual_printer import (
  1496. DEFAULT_VIRTUAL_PRINTER_MODEL,
  1497. VIRTUAL_PRINTER_MODELS,
  1498. )
  1499. return {
  1500. "models": VIRTUAL_PRINTER_MODELS,
  1501. "default": DEFAULT_VIRTUAL_PRINTER_MODEL,
  1502. }
  1503. @router.get("/virtual-printer")
  1504. async def get_virtual_printer_settings(db: AsyncSession = Depends(get_db)):
  1505. """Get virtual printer settings and status."""
  1506. from backend.app.services.virtual_printer import (
  1507. DEFAULT_VIRTUAL_PRINTER_MODEL,
  1508. virtual_printer_manager,
  1509. )
  1510. enabled = await get_setting(db, "virtual_printer_enabled")
  1511. access_code = await get_setting(db, "virtual_printer_access_code")
  1512. mode = await get_setting(db, "virtual_printer_mode")
  1513. model = await get_setting(db, "virtual_printer_model")
  1514. return {
  1515. "enabled": enabled == "true" if enabled else False,
  1516. "access_code_set": bool(access_code),
  1517. "mode": mode or "immediate",
  1518. "model": model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  1519. "status": virtual_printer_manager.get_status(),
  1520. }
  1521. @router.put("/virtual-printer")
  1522. async def update_virtual_printer_settings(
  1523. enabled: bool = None,
  1524. access_code: str = None,
  1525. mode: str = None,
  1526. model: str = None,
  1527. db: AsyncSession = Depends(get_db),
  1528. ):
  1529. """Update virtual printer settings and restart services if needed."""
  1530. from backend.app.services.virtual_printer import (
  1531. DEFAULT_VIRTUAL_PRINTER_MODEL,
  1532. VIRTUAL_PRINTER_MODELS,
  1533. virtual_printer_manager,
  1534. )
  1535. # Get current values
  1536. current_enabled = await get_setting(db, "virtual_printer_enabled") == "true"
  1537. current_access_code = await get_setting(db, "virtual_printer_access_code") or ""
  1538. current_mode = await get_setting(db, "virtual_printer_mode") or "immediate"
  1539. current_model = await get_setting(db, "virtual_printer_model") or DEFAULT_VIRTUAL_PRINTER_MODEL
  1540. # Apply updates
  1541. new_enabled = enabled if enabled is not None else current_enabled
  1542. new_access_code = access_code if access_code is not None else current_access_code
  1543. new_mode = mode if mode is not None else current_mode
  1544. new_model = model if model is not None else current_model
  1545. # Validate mode
  1546. if new_mode not in ("immediate", "queue"):
  1547. return JSONResponse(
  1548. status_code=400,
  1549. content={"detail": "Mode must be 'immediate' or 'queue'"},
  1550. )
  1551. # Validate model
  1552. if model is not None and model not in VIRTUAL_PRINTER_MODELS:
  1553. return JSONResponse(
  1554. status_code=400,
  1555. content={"detail": f"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}"},
  1556. )
  1557. # Validate access code when enabling
  1558. if new_enabled and not new_access_code:
  1559. return JSONResponse(
  1560. status_code=400,
  1561. content={"detail": "Access code is required when enabling virtual printer"},
  1562. )
  1563. # Validate access code length (Bambu Studio requires exactly 8 characters)
  1564. if access_code is not None and len(access_code) != 8:
  1565. return JSONResponse(
  1566. status_code=400,
  1567. content={"detail": "Access code must be exactly 8 characters"},
  1568. )
  1569. # Save settings
  1570. await set_setting(db, "virtual_printer_enabled", "true" if new_enabled else "false")
  1571. if access_code is not None:
  1572. await set_setting(db, "virtual_printer_access_code", access_code)
  1573. await set_setting(db, "virtual_printer_mode", new_mode)
  1574. if model is not None:
  1575. await set_setting(db, "virtual_printer_model", model)
  1576. await db.commit()
  1577. # Reconfigure virtual printer
  1578. try:
  1579. await virtual_printer_manager.configure(
  1580. enabled=new_enabled,
  1581. access_code=new_access_code,
  1582. mode=new_mode,
  1583. model=new_model,
  1584. )
  1585. except ValueError as e:
  1586. return JSONResponse(
  1587. status_code=400,
  1588. content={"detail": str(e)},
  1589. )
  1590. except Exception as e:
  1591. return JSONResponse(
  1592. status_code=500,
  1593. content={"detail": f"Failed to configure virtual printer: {e}"},
  1594. )
  1595. return await get_virtual_printer_settings(db)