diff --git a/Dockerfile b/Dockerfile index c96c85b88..a24bcf552 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,6 +37,7 @@ RUN mkdir -p /app/data /app/logs # Environment variables ENV PYTHONUNBUFFERED=1 ENV DATA_DIR=/app/data +ENV LOG_DIR=/app/logs EXPOSE 8000 diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index 50ebae51c..fc266f05f 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -14,10 +14,11 @@ from backend.app.core.database import get_db from backend.app.models.archive import PrintArchive from backend.app.models.external_link import ExternalLink from backend.app.models.filament import Filament -from backend.app.models.maintenance import MaintenanceType +from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance from backend.app.models.notification import NotificationProvider from backend.app.models.notification_template import NotificationTemplate from backend.app.models.pending_upload import PendingUpload +from backend.app.models.print_queue import PrintQueueItem from backend.app.models.printer import Printer from backend.app.models.project import Project from backend.app.models.project_bom import ProjectBOMItem @@ -42,14 +43,13 @@ async def get_setting(db: AsyncSession, key: str) -> str | None: async def set_setting(db: AsyncSession, key: str, value: str) -> None: """Set a single setting value.""" - result = await db.execute(select(Settings).where(Settings.key == key)) - setting = result.scalar_one_or_none() + from sqlalchemy import func + from sqlalchemy.dialects.sqlite import insert as sqlite_insert - if setting: - setting.value = value - else: - setting = Settings(key=key, value=value) - db.add(setting) + # Use upsert (INSERT ... ON CONFLICT UPDATE) for reliability + stmt = sqlite_insert(Settings).values(key=key, value=value) + stmt = stmt.on_conflict_do_update(index_elements=["key"], set_={"value": value, "updated_at": func.now()}) + await db.execute(stmt) @router.get("/", response_model=AppSettings) @@ -180,7 +180,10 @@ async def export_backup( include_external_links: bool = Query(True, description="Include external sidebar links"), include_printers: bool = Query(False, description="Include printers (without access codes)"), include_filaments: bool = Query(False, description="Include filament inventory"), - include_maintenance: bool = Query(False, description="Include maintenance types and records"), + include_maintenance: bool = Query( + False, description="Include maintenance types, per-printer settings, and history" + ), + include_print_queue: bool = Query(False, description="Include print queue items"), include_archives: bool = Query(False, description="Include print archive metadata"), include_projects: bool = Query(False, description="Include projects with BOM items"), include_pending_uploads: bool = Query(False, description="Include pending virtual printer uploads"), @@ -202,10 +205,20 @@ async def export_backup( # Notification providers if include_notifications: + # Build printer ID to serial lookup for cross-system backup + printer_id_to_serial: dict[int, str] = {} + pr_result = await db.execute(select(Printer)) + for pr in pr_result.scalars().all(): + printer_id_to_serial[pr.id] = pr.serial_number + result = await db.execute(select(NotificationProvider)) providers = result.scalars().all() backup["notification_providers"] = [] for p in providers: + # Use printer_serial for cross-system compatibility + provider_printer_id = getattr(p, "printer_id", None) + printer_serial = printer_id_to_serial.get(provider_printer_id) if provider_printer_id else None + backup["notification_providers"].append( { "name": p.name, @@ -221,12 +234,16 @@ async def export_backup( "on_printer_error": p.on_printer_error, "on_filament_low": p.on_filament_low, "on_maintenance_due": p.on_maintenance_due, + "on_ams_humidity_high": getattr(p, "on_ams_humidity_high", False), + "on_ams_temperature_high": getattr(p, "on_ams_temperature_high", False), + "on_ams_ht_humidity_high": getattr(p, "on_ams_ht_humidity_high", False), + "on_ams_ht_temperature_high": getattr(p, "on_ams_ht_temperature_high", False), "quiet_hours_enabled": p.quiet_hours_enabled, "quiet_hours_start": p.quiet_hours_start, "quiet_hours_end": p.quiet_hours_end, "daily_digest_enabled": getattr(p, "daily_digest_enabled", False), "daily_digest_time": getattr(p, "daily_digest_time", None), - "printer_id": getattr(p, "printer_id", None), + "printer_serial": printer_serial, } ) backup["included"].append("notification_providers") @@ -253,12 +270,19 @@ async def export_backup( result = await db.execute(select(SmartPlug)) plugs = result.scalars().all() backup["smart_plugs"] = [] + + # Build printer ID to serial mapping + printer_id_to_serial: dict[int, str] = {} + pr_result = await db.execute(select(Printer)) + for pr in pr_result.scalars().all(): + printer_id_to_serial[pr.id] = pr.serial_number + for plug in plugs: backup["smart_plugs"].append( { "name": plug.name, "ip_address": plug.ip_address, - "printer_id": plug.printer_id, + "printer_serial": printer_id_to_serial.get(plug.printer_id) if plug.printer_id else None, "enabled": plug.enabled, "auto_on": plug.auto_on, "auto_off": plug.auto_off, @@ -316,6 +340,7 @@ async def export_backup( "is_active": printer.is_active, "auto_archive": printer.auto_archive, "print_hours_offset": printer.print_hours_offset, + "runtime_seconds": printer.runtime_seconds, } if include_access_codes: printer_data["access_code"] = printer.access_code @@ -368,6 +393,99 @@ async def export_backup( ) backup["included"].append("maintenance_types") + # Printer maintenance settings (per-printer custom intervals, enabled status, last performed) + result = await db.execute(select(PrinterMaintenance)) + printer_maint = result.scalars().all() + backup["printer_maintenance"] = [] + + # Build lookups for printer serial and maintenance type name + printer_id_to_serial: dict[int, str] = {} + maint_type_id_to_name: dict[int, str] = {} + pr_result = await db.execute(select(Printer)) + for pr in pr_result.scalars().all(): + printer_id_to_serial[pr.id] = pr.serial_number + for mt in types: + maint_type_id_to_name[mt.id] = mt.name + + for pm in printer_maint: + backup["printer_maintenance"].append( + { + "printer_serial": printer_id_to_serial.get(pm.printer_id), + "maintenance_type_name": maint_type_id_to_name.get(pm.maintenance_type_id), + "custom_interval_hours": pm.custom_interval_hours, + "custom_interval_type": pm.custom_interval_type, + "enabled": pm.enabled, + "last_performed_at": pm.last_performed_at.isoformat() if pm.last_performed_at else None, + "last_performed_hours": pm.last_performed_hours, + } + ) + backup["included"].append("printer_maintenance") + + # Maintenance history + result = await db.execute(select(MaintenanceHistory)) + history = result.scalars().all() + backup["maintenance_history"] = [] + + # Build printer_maintenance ID to (printer_serial, maint_type_name) mapping + pm_id_to_info: dict[int, tuple[str | None, str | None]] = {} + for pm in printer_maint: + pm_id_to_info[pm.id] = ( + printer_id_to_serial.get(pm.printer_id), + maint_type_id_to_name.get(pm.maintenance_type_id), + ) + + for mh in history: + info = pm_id_to_info.get(mh.printer_maintenance_id, (None, None)) + backup["maintenance_history"].append( + { + "printer_serial": info[0], + "maintenance_type_name": info[1], + "performed_at": mh.performed_at.isoformat() if mh.performed_at else None, + "hours_at_maintenance": mh.hours_at_maintenance, + "notes": mh.notes, + } + ) + backup["included"].append("maintenance_history") + + # Print queue + if include_print_queue: + result = await db.execute(select(PrintQueueItem)) + queue_items = result.scalars().all() + backup["print_queue"] = [] + + # Build lookups + printer_id_to_serial: dict[int, str] = {} + archive_id_to_hash: dict[int, str | None] = {} + project_id_to_name: dict[int, str] = {} + + pr_result = await db.execute(select(Printer)) + for pr in pr_result.scalars().all(): + printer_id_to_serial[pr.id] = pr.serial_number + ar_result = await db.execute(select(PrintArchive)) + for ar in ar_result.scalars().all(): + archive_id_to_hash[ar.id] = ar.content_hash + proj_result = await db.execute(select(Project)) + for proj in proj_result.scalars().all(): + project_id_to_name[proj.id] = proj.name + + for qi in queue_items: + backup["print_queue"].append( + { + "printer_serial": printer_id_to_serial.get(qi.printer_id), + "archive_hash": archive_id_to_hash.get(qi.archive_id), + "project_name": project_id_to_name.get(qi.project_id) if qi.project_id else None, + "position": qi.position, + "scheduled_time": qi.scheduled_time.isoformat() if qi.scheduled_time else None, + "require_previous_success": qi.require_previous_success, + "auto_off_after": qi.auto_off_after, + "status": qi.status, + "started_at": qi.started_at.isoformat() if qi.started_at else None, + "completed_at": qi.completed_at.isoformat() if qi.completed_at else None, + "error_message": qi.error_message, + } + ) + backup["included"].append("print_queue") + # Collect files for ZIP (icons + archives) backup_files: list[tuple[str, Path]] = [] # (zip_path, local_path) @@ -394,10 +512,18 @@ async def export_backup( for proj in proj_result.scalars().all(): project_id_to_name[proj.id] = proj.name + # Build printer ID to serial mapping for archive export + printer_id_to_serial: dict[int, str] = {} + if include_printers: + printer_result = await db.execute(select(Printer)) + for pr in printer_result.scalars().all(): + printer_id_to_serial[pr.id] = pr.serial_number + for a in archives: archive_data = { "filename": a.filename, "project_name": project_id_to_name.get(a.project_id) if a.project_id else None, + "printer_serial": printer_id_to_serial.get(a.printer_id) if a.printer_id else None, "file_size": a.file_size, "content_hash": a.content_hash, "print_name": a.print_name, @@ -485,6 +611,8 @@ async def export_backup( "priority": p.priority, "budget": p.budget, "is_template": p.is_template, + "template_source_id": p.template_source_id, + "parent_id": p.parent_id, "bom_items": [ { "name": item.name, @@ -671,10 +799,79 @@ async def import_backup( str_value = str(value) await set_setting(db, key, str_value) restored["settings"] += 1 + # Flush settings to ensure they're persisted before continuing + await db.flush() + + # Restore printers FIRST (skip or overwrite duplicates by serial_number) + # Nearly everything in the app references printers, so they must be imported first + if "printers" in backup: + for printer_data in backup["printers"]: + result = await db.execute(select(Printer).where(Printer.serial_number == printer_data["serial_number"])) + existing = result.scalar_one_or_none() + if existing: + if overwrite: + existing.name = printer_data["name"] + existing.ip_address = printer_data["ip_address"] + existing.model = printer_data.get("model") + existing.location = printer_data.get("location") + existing.nozzle_count = printer_data.get("nozzle_count", 1) + existing.auto_archive = printer_data.get("auto_archive", True) + existing.print_hours_offset = printer_data.get("print_hours_offset", 0.0) + existing.runtime_seconds = printer_data.get("runtime_seconds", 0) + + # If backup includes access_code, also update access_code and is_active + backup_access_code = printer_data.get("access_code") + if backup_access_code and backup_access_code != "CHANGE_ME": + existing.access_code = backup_access_code + is_active_val = printer_data.get("is_active", False) + if isinstance(is_active_val, str): + is_active_val = is_active_val.lower() == "true" + existing.is_active = is_active_val + + restored["printers"] += 1 + else: + skipped["printers"] += 1 + skipped_details["printers"].append(f"{printer_data['name']} ({printer_data['serial_number']})") + else: + # Use access code from backup if provided, otherwise require manual setup + access_code = printer_data.get("access_code") + has_access_code = access_code and access_code != "CHANGE_ME" + is_active_from_backup = printer_data.get("is_active", False) + # Handle bool or string "true"/"false" + if isinstance(is_active_from_backup, str): + is_active_from_backup = is_active_from_backup.lower() == "true" + + printer = Printer( + name=printer_data["name"], + serial_number=printer_data["serial_number"], + ip_address=printer_data["ip_address"], + access_code=access_code if has_access_code else "CHANGE_ME", + model=printer_data.get("model"), + location=printer_data.get("location"), + nozzle_count=printer_data.get("nozzle_count", 1), + is_active=is_active_from_backup if has_access_code else False, + auto_archive=printer_data.get("auto_archive", True), + print_hours_offset=printer_data.get("print_hours_offset", 0.0), + runtime_seconds=printer_data.get("runtime_seconds", 0), + ) + db.add(printer) + restored["printers"] += 1 + # Flush printers so other sections can look them up + await db.flush() # Restore notification providers (skip or overwrite duplicates by name) + # Build printer serial to ID lookup (printers were restored first) if "notification_providers" in backup: + printer_serial_to_id: dict[str, int] = {} + pr_result = await db.execute(select(Printer)) + for pr in pr_result.scalars().all(): + printer_serial_to_id[pr.serial_number] = pr.id + for provider_data in backup["notification_providers"]: + # Look up printer_id from serial (supports both old printer_id and new printer_serial format) + printer_serial = provider_data.get("printer_serial") + printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else provider_data.get("printer_id") + result = await db.execute( select(NotificationProvider).where(NotificationProvider.name == provider_data["name"]) ) @@ -694,12 +891,16 @@ async def import_backup( existing.on_printer_error = provider_data.get("on_printer_error", False) existing.on_filament_low = provider_data.get("on_filament_low", False) existing.on_maintenance_due = provider_data.get("on_maintenance_due", False) + existing.on_ams_humidity_high = provider_data.get("on_ams_humidity_high", False) + existing.on_ams_temperature_high = provider_data.get("on_ams_temperature_high", False) + existing.on_ams_ht_humidity_high = provider_data.get("on_ams_ht_humidity_high", False) + existing.on_ams_ht_temperature_high = provider_data.get("on_ams_ht_temperature_high", False) existing.quiet_hours_enabled = provider_data.get("quiet_hours_enabled", False) existing.quiet_hours_start = provider_data.get("quiet_hours_start") existing.quiet_hours_end = provider_data.get("quiet_hours_end") existing.daily_digest_enabled = provider_data.get("daily_digest_enabled", False) existing.daily_digest_time = provider_data.get("daily_digest_time") - existing.printer_id = provider_data.get("printer_id") + existing.printer_id = printer_id restored["notification_providers"] += 1 else: skipped["notification_providers"] += 1 @@ -719,12 +920,16 @@ async def import_backup( on_printer_error=provider_data.get("on_printer_error", False), on_filament_low=provider_data.get("on_filament_low", False), on_maintenance_due=provider_data.get("on_maintenance_due", False), + on_ams_humidity_high=provider_data.get("on_ams_humidity_high", False), + on_ams_temperature_high=provider_data.get("on_ams_temperature_high", False), + on_ams_ht_humidity_high=provider_data.get("on_ams_ht_humidity_high", False), + on_ams_ht_temperature_high=provider_data.get("on_ams_ht_temperature_high", False), quiet_hours_enabled=provider_data.get("quiet_hours_enabled", False), quiet_hours_start=provider_data.get("quiet_hours_start"), quiet_hours_end=provider_data.get("quiet_hours_end"), daily_digest_enabled=provider_data.get("daily_digest_enabled", False), daily_digest_time=provider_data.get("daily_digest_time"), - printer_id=provider_data.get("printer_id"), + printer_id=printer_id, ) db.add(provider) restored["notification_providers"] += 1 @@ -754,14 +959,25 @@ async def import_backup( restored["notification_templates"] += 1 # Restore smart plugs (skip or overwrite duplicates by IP) + # Note: Smart plugs reference printers, so printers should be restored first if "smart_plugs" in backup: + # Build printer serial to ID lookup + printer_serial_to_id: dict[str, int] = {} + pr_result = await db.execute(select(Printer)) + for pr in pr_result.scalars().all(): + printer_serial_to_id[pr.serial_number] = pr.id + for plug_data in backup["smart_plugs"]: + # Look up printer_id from serial (supports both old printer_id and new printer_serial format) + printer_serial = plug_data.get("printer_serial") + printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else plug_data.get("printer_id") + result = await db.execute(select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"])) existing = result.scalar_one_or_none() if existing: if overwrite: existing.name = plug_data["name"] - existing.printer_id = plug_data.get("printer_id") + existing.printer_id = printer_id existing.enabled = plug_data.get("enabled", True) existing.auto_on = plug_data.get("auto_on", True) existing.auto_off = plug_data.get("auto_off", True) @@ -785,7 +1001,7 @@ async def import_backup( plug = SmartPlug( name=plug_data["name"], ip_address=plug_data["ip_address"], - printer_id=plug_data.get("printer_id"), + printer_id=printer_id, enabled=plug_data.get("enabled", True), auto_on=plug_data.get("auto_on", True), auto_off=plug_data.get("auto_off", True), @@ -837,58 +1053,6 @@ async def import_backup( db.add(link) restored["external_links"] += 1 - # Restore printers (skip or overwrite duplicates by serial_number) - if "printers" in backup: - for printer_data in backup["printers"]: - result = await db.execute(select(Printer).where(Printer.serial_number == printer_data["serial_number"])) - existing = result.scalar_one_or_none() - if existing: - if overwrite: - existing.name = printer_data["name"] - existing.ip_address = printer_data["ip_address"] - existing.model = printer_data.get("model") - existing.location = printer_data.get("location") - existing.nozzle_count = printer_data.get("nozzle_count", 1) - existing.auto_archive = printer_data.get("auto_archive", True) - existing.print_hours_offset = printer_data.get("print_hours_offset", 0.0) - - # If backup includes access_code, also update access_code and is_active - backup_access_code = printer_data.get("access_code") - if backup_access_code and backup_access_code != "CHANGE_ME": - existing.access_code = backup_access_code - is_active_val = printer_data.get("is_active", False) - if isinstance(is_active_val, str): - is_active_val = is_active_val.lower() == "true" - existing.is_active = is_active_val - - restored["printers"] += 1 - else: - skipped["printers"] += 1 - skipped_details["printers"].append(f"{printer_data['name']} ({printer_data['serial_number']})") - else: - # Use access code from backup if provided, otherwise require manual setup - access_code = printer_data.get("access_code") - has_access_code = access_code and access_code != "CHANGE_ME" - is_active_from_backup = printer_data.get("is_active", False) - # Handle bool or string "true"/"false" - if isinstance(is_active_from_backup, str): - is_active_from_backup = is_active_from_backup.lower() == "true" - - printer = Printer( - name=printer_data["name"], - serial_number=printer_data["serial_number"], - ip_address=printer_data["ip_address"], - access_code=access_code if has_access_code else "CHANGE_ME", - model=printer_data.get("model"), - location=printer_data.get("location"), - nozzle_count=printer_data.get("nozzle_count", 1), - is_active=is_active_from_backup if has_access_code else False, - auto_archive=printer_data.get("auto_archive", True), - print_hours_offset=printer_data.get("print_hours_offset", 0.0), - ) - db.add(printer) - restored["printers"] += 1 - # Restore filaments (skip or overwrite duplicates by name+type+brand) if "filaments" in backup: for filament_data in backup["filaments"]: @@ -965,8 +1129,138 @@ async def import_backup( db.add(mt) restored["maintenance_types"] += 1 + # Restore printer maintenance settings (per-printer) + if "printer_maintenance" in backup: + # Build lookups + printer_serial_to_id: dict[str, int] = {} + maint_type_name_to_id: dict[str, int] = {} + + pr_result = await db.execute(select(Printer)) + for pr in pr_result.scalars().all(): + printer_serial_to_id[pr.serial_number] = pr.id + + mt_result = await db.execute(select(MaintenanceType)) + for mt in mt_result.scalars().all(): + maint_type_name_to_id[mt.name] = mt.id + + restored["printer_maintenance"] = 0 + skipped["printer_maintenance"] = 0 + skipped_details["printer_maintenance"] = [] + + for pm_data in backup["printer_maintenance"]: + printer_serial = pm_data.get("printer_serial") + maint_type_name = pm_data.get("maintenance_type_name") + + if not printer_serial or not maint_type_name: + continue + + printer_id = printer_serial_to_id.get(printer_serial) + maint_type_id = maint_type_name_to_id.get(maint_type_name) + + if not printer_id or not maint_type_id: + skipped["printer_maintenance"] += 1 + skipped_details["printer_maintenance"].append(f"{printer_serial}/{maint_type_name}") + continue + + # Check if exists + result = await db.execute( + select(PrinterMaintenance).where( + PrinterMaintenance.printer_id == printer_id, + PrinterMaintenance.maintenance_type_id == maint_type_id, + ) + ) + existing = result.scalar_one_or_none() + + if existing: + if overwrite: + existing.custom_interval_hours = pm_data.get("custom_interval_hours") + existing.custom_interval_type = pm_data.get("custom_interval_type") + existing.enabled = pm_data.get("enabled", True) + existing.last_performed_hours = pm_data.get("last_performed_hours", 0.0) + if pm_data.get("last_performed_at"): + existing.last_performed_at = datetime.fromisoformat(pm_data["last_performed_at"]) + restored["printer_maintenance"] += 1 + else: + skipped["printer_maintenance"] += 1 + skipped_details["printer_maintenance"].append(f"{printer_serial}/{maint_type_name}") + else: + pm = PrinterMaintenance( + printer_id=printer_id, + maintenance_type_id=maint_type_id, + custom_interval_hours=pm_data.get("custom_interval_hours"), + custom_interval_type=pm_data.get("custom_interval_type"), + enabled=pm_data.get("enabled", True), + last_performed_hours=pm_data.get("last_performed_hours", 0.0), + ) + if pm_data.get("last_performed_at"): + pm.last_performed_at = datetime.fromisoformat(pm_data["last_performed_at"]) + db.add(pm) + restored["printer_maintenance"] += 1 + + # Restore maintenance history + if "maintenance_history" in backup: + # Build lookups + printer_serial_to_id: dict[str, int] = {} + maint_type_name_to_id: dict[str, int] = {} + + pr_result = await db.execute(select(Printer)) + for pr in pr_result.scalars().all(): + printer_serial_to_id[pr.serial_number] = pr.id + + mt_result = await db.execute(select(MaintenanceType)) + for mt in mt_result.scalars().all(): + maint_type_name_to_id[mt.name] = mt.id + + restored["maintenance_history"] = 0 + skipped["maintenance_history"] = 0 + skipped_details["maintenance_history"] = [] + + for mh_data in backup["maintenance_history"]: + printer_serial = mh_data.get("printer_serial") + maint_type_name = mh_data.get("maintenance_type_name") + + if not printer_serial or not maint_type_name: + continue + + printer_id = printer_serial_to_id.get(printer_serial) + maint_type_id = maint_type_name_to_id.get(maint_type_name) + + if not printer_id or not maint_type_id: + skipped["maintenance_history"] += 1 + continue + + # Find the PrinterMaintenance record + result = await db.execute( + select(PrinterMaintenance).where( + PrinterMaintenance.printer_id == printer_id, + PrinterMaintenance.maintenance_type_id == maint_type_id, + ) + ) + pm = result.scalar_one_or_none() + + if not pm: + skipped["maintenance_history"] += 1 + continue + + # Create history entry (no duplicate check - history is append-only) + mh = MaintenanceHistory( + printer_maintenance_id=pm.id, + hours_at_maintenance=mh_data.get("hours_at_maintenance", 0.0), + notes=mh_data.get("notes"), + ) + if mh_data.get("performed_at"): + mh.performed_at = datetime.fromisoformat(mh_data["performed_at"]) + db.add(mh) + restored["maintenance_history"] += 1 + # Restore archives (skip duplicates by content_hash - overwrite not supported for archives) if "archives" in backup: + # Build printer serial to ID mapping + printer_serial_to_id: dict[str, int] = {} + printer_result = await db.execute(select(Printer)) + for pr in printer_result.scalars().all(): + printer_serial_to_id[pr.serial_number] = pr.id + for archive_data in backup["archives"]: # Skip if no content_hash or already exists content_hash = archive_data.get("content_hash") @@ -981,11 +1275,16 @@ async def import_backup( # Only restore if file exists (from ZIP extraction) file_path = archive_data.get("file_path") if file_path and (base_dir / file_path).exists(): + # Look up printer_id from serial + printer_serial = archive_data.get("printer_serial") + printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else None + archive = PrintArchive( filename=archive_data["filename"], file_path=file_path, file_size=archive_data.get("file_size", 0), content_hash=content_hash, + printer_id=printer_id, thumbnail_path=archive_data.get("thumbnail_path"), timelapse_path=archive_data.get("timelapse_path"), source_3mf_path=archive_data.get("source_3mf_path"), @@ -1032,6 +1331,8 @@ async def import_backup( existing.priority = project_data.get("priority", "normal") existing.budget = project_data.get("budget") existing.is_template = project_data.get("is_template", False) + existing.template_source_id = project_data.get("template_source_id") + existing.parent_id = project_data.get("parent_id") existing.attachments = project_data.get("attachments") if project_data.get("due_date"): existing.due_date = datetime.fromisoformat(project_data["due_date"]) @@ -1069,6 +1370,8 @@ async def import_backup( priority=project_data.get("priority", "normal"), budget=project_data.get("budget"), is_template=project_data.get("is_template", False), + template_source_id=project_data.get("template_source_id"), + parent_id=project_data.get("parent_id"), attachments=project_data.get("attachments"), ) if project_data.get("due_date"): @@ -1113,6 +1416,68 @@ async def import_backup( if archive: archive.project_id = project_name_to_id[project_name] + # Restore print queue (must be after archives and projects) + if "print_queue" in backup: + # Build lookups + printer_serial_to_id: dict[str, int] = {} + archive_hash_to_id: dict[str, int] = {} + project_name_to_id: dict[str, int] = {} + + pr_result = await db.execute(select(Printer)) + for pr in pr_result.scalars().all(): + printer_serial_to_id[pr.serial_number] = pr.id + + ar_result = await db.execute(select(PrintArchive)) + for ar in ar_result.scalars().all(): + if ar.content_hash: + archive_hash_to_id[ar.content_hash] = ar.id + + proj_result = await db.execute(select(Project)) + for proj in proj_result.scalars().all(): + project_name_to_id[proj.name] = proj.id + + restored["print_queue"] = 0 + skipped["print_queue"] = 0 + skipped_details["print_queue"] = [] + + for qi_data in backup["print_queue"]: + printer_serial = qi_data.get("printer_serial") + archive_hash = qi_data.get("archive_hash") + + if not printer_serial or not archive_hash: + skipped["print_queue"] += 1 + continue + + printer_id = printer_serial_to_id.get(printer_serial) + archive_id = archive_hash_to_id.get(archive_hash) + + if not printer_id or not archive_id: + skipped["print_queue"] += 1 + skipped_details["print_queue"].append(f"{printer_serial}/{archive_hash[:8] if archive_hash else 'N/A'}") + continue + + project_name = qi_data.get("project_name") + project_id = project_name_to_id.get(project_name) if project_name else None + + qi = PrintQueueItem( + printer_id=printer_id, + archive_id=archive_id, + project_id=project_id, + position=qi_data.get("position", 0), + require_previous_success=qi_data.get("require_previous_success", False), + auto_off_after=qi_data.get("auto_off_after", False), + status=qi_data.get("status", "pending"), + error_message=qi_data.get("error_message"), + ) + if qi_data.get("scheduled_time"): + qi.scheduled_time = datetime.fromisoformat(qi_data["scheduled_time"]) + if qi_data.get("started_at"): + qi.started_at = datetime.fromisoformat(qi_data["started_at"]) + if qi_data.get("completed_at"): + qi.completed_at = datetime.fromisoformat(qi_data["completed_at"]) + db.add(qi) + restored["print_queue"] += 1 + # Restore pending uploads (skip duplicates by filename) if "pending_uploads" in backup: # Ensure the pending uploads directory exists diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 9b3e47f00..a4bfe3db9 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,4 +1,5 @@ import logging +import os from pathlib import Path from pydantic_settings import BaseSettings @@ -7,14 +8,23 @@ from pydantic_settings import BaseSettings APP_VERSION = "0.1.6b3" GITHUB_REPO = "maziggy/bambuddy" -# Base directory for path calculations -_base_dir = Path(__file__).resolve().parent.parent.parent.parent +# App directory - where the application is installed (for static files) +_app_dir = Path(__file__).resolve().parent.parent.parent.parent + +# Data directory - for persistent data (database, archives) +# Use DATA_DIR env var if set (Docker), otherwise use project root (local dev) +_data_dir_env = os.environ.get("DATA_DIR") +_data_dir = Path(_data_dir_env) if _data_dir_env else _app_dir + +# Log directory - use LOG_DIR env var if set, otherwise use app_dir/logs +_log_dir_env = os.environ.get("LOG_DIR") +_log_dir = Path(_log_dir_env) if _log_dir_env else _app_dir / "logs" def _migrate_database() -> Path: """Migrate database from old name to new name if needed.""" - old_db = _base_dir / "bambutrack.db" - new_db = _base_dir / "bambuddy.db" + old_db = _data_dir / "bambutrack.db" + new_db = _data_dir / "bambuddy.db" # If old database exists and new one doesn't, rename it if old_db.exists() and not new_db.exists(): @@ -40,10 +50,10 @@ class Settings(BaseSettings): debug: bool = False # Default to production mode # Paths - base_dir: Path = _base_dir - archive_dir: Path = base_dir / "archive" - static_dir: Path = base_dir / "static" - log_dir: Path = base_dir / "logs" + base_dir: Path = _data_dir # For backwards compatibility + archive_dir: Path = _data_dir / "archive" + static_dir: Path = _app_dir / "static" # Static files are part of app, not data + log_dir: Path = _log_dir database_url: str = f"sqlite+aiosqlite:///{_db_path}" # Logging diff --git a/frontend/src/__tests__/components/AMSHistoryModal.test.tsx b/frontend/src/__tests__/components/AMSHistoryModal.test.tsx index daac6125b..fcf824f9d 100644 --- a/frontend/src/__tests__/components/AMSHistoryModal.test.tsx +++ b/frontend/src/__tests__/components/AMSHistoryModal.test.tsx @@ -12,6 +12,8 @@ import { api } from '../../api/client'; vi.mock('../../api/client', () => ({ api: { getAMSHistory: vi.fn(), + getSettings: vi.fn().mockResolvedValue({}), + updateSettings: vi.fn().mockResolvedValue({}), }, })); diff --git a/frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx b/frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx index 8363b9034..8dec3fd4c 100644 --- a/frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx +++ b/frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx @@ -16,6 +16,10 @@ import { VirtualPrinterSettings } from '../../components/VirtualPrinterSettings' // Mock the API client vi.mock('../../api/client', () => ({ + api: { + getSettings: vi.fn().mockResolvedValue({}), + updateSettings: vi.fn().mockResolvedValue({}), + }, virtualPrinterApi: { getSettings: vi.fn(), updateSettings: vi.fn(), diff --git a/frontend/src/__tests__/pages/SystemInfoPage.test.tsx b/frontend/src/__tests__/pages/SystemInfoPage.test.tsx index 73fef248a..75bea562a 100644 --- a/frontend/src/__tests__/pages/SystemInfoPage.test.tsx +++ b/frontend/src/__tests__/pages/SystemInfoPage.test.tsx @@ -12,6 +12,8 @@ import { api } from '../../api/client'; vi.mock('../../api/client', () => ({ api: { getSystemInfo: vi.fn(), + getSettings: vi.fn().mockResolvedValue({}), + updateSettings: vi.fn().mockResolvedValue({}), }, }));