Просмотр исходного кода

- Added frontend/backend tests for new theme functions
- Fixed some bugs, where settings were not backed up or restored
- Docker: Fixed mounting of database for persistence

maziggy 4 месяцев назад
Родитель
Сommit
2457577f54

+ 1 - 0
Dockerfile

@@ -37,6 +37,7 @@ RUN mkdir -p /app/data /app/logs
 # Environment variables
 # Environment variables
 ENV PYTHONUNBUFFERED=1
 ENV PYTHONUNBUFFERED=1
 ENV DATA_DIR=/app/data
 ENV DATA_DIR=/app/data
+ENV LOG_DIR=/app/logs
 
 
 EXPOSE 8000
 EXPOSE 8000
 
 

+ 432 - 67
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.archive import PrintArchive
 from backend.app.models.external_link import ExternalLink
 from backend.app.models.external_link import ExternalLink
 from backend.app.models.filament import Filament
 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 import NotificationProvider
 from backend.app.models.notification_template import NotificationTemplate
 from backend.app.models.notification_template import NotificationTemplate
 from backend.app.models.pending_upload import PendingUpload
 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.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.project import Project
 from backend.app.models.project_bom import ProjectBOMItem
 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:
 async def set_setting(db: AsyncSession, key: str, value: str) -> None:
     """Set a single setting value."""
     """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)
 @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_external_links: bool = Query(True, description="Include external sidebar links"),
     include_printers: bool = Query(False, description="Include printers (without access codes)"),
     include_printers: bool = Query(False, description="Include printers (without access codes)"),
     include_filaments: bool = Query(False, description="Include filament inventory"),
     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_archives: bool = Query(False, description="Include print archive metadata"),
     include_projects: bool = Query(False, description="Include projects with BOM items"),
     include_projects: bool = Query(False, description="Include projects with BOM items"),
     include_pending_uploads: bool = Query(False, description="Include pending virtual printer uploads"),
     include_pending_uploads: bool = Query(False, description="Include pending virtual printer uploads"),
@@ -202,10 +205,20 @@ async def export_backup(
 
 
     # Notification providers
     # Notification providers
     if include_notifications:
     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))
         result = await db.execute(select(NotificationProvider))
         providers = result.scalars().all()
         providers = result.scalars().all()
         backup["notification_providers"] = []
         backup["notification_providers"] = []
         for p in 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(
             backup["notification_providers"].append(
                 {
                 {
                     "name": p.name,
                     "name": p.name,
@@ -221,12 +234,16 @@ async def export_backup(
                     "on_printer_error": p.on_printer_error,
                     "on_printer_error": p.on_printer_error,
                     "on_filament_low": p.on_filament_low,
                     "on_filament_low": p.on_filament_low,
                     "on_maintenance_due": p.on_maintenance_due,
                     "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_enabled": p.quiet_hours_enabled,
                     "quiet_hours_start": p.quiet_hours_start,
                     "quiet_hours_start": p.quiet_hours_start,
                     "quiet_hours_end": p.quiet_hours_end,
                     "quiet_hours_end": p.quiet_hours_end,
                     "daily_digest_enabled": getattr(p, "daily_digest_enabled", False),
                     "daily_digest_enabled": getattr(p, "daily_digest_enabled", False),
                     "daily_digest_time": getattr(p, "daily_digest_time", None),
                     "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")
         backup["included"].append("notification_providers")
@@ -253,12 +270,19 @@ async def export_backup(
         result = await db.execute(select(SmartPlug))
         result = await db.execute(select(SmartPlug))
         plugs = result.scalars().all()
         plugs = result.scalars().all()
         backup["smart_plugs"] = []
         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:
         for plug in plugs:
             backup["smart_plugs"].append(
             backup["smart_plugs"].append(
                 {
                 {
                     "name": plug.name,
                     "name": plug.name,
                     "ip_address": plug.ip_address,
                     "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,
                     "enabled": plug.enabled,
                     "auto_on": plug.auto_on,
                     "auto_on": plug.auto_on,
                     "auto_off": plug.auto_off,
                     "auto_off": plug.auto_off,
@@ -316,6 +340,7 @@ async def export_backup(
                 "is_active": printer.is_active,
                 "is_active": printer.is_active,
                 "auto_archive": printer.auto_archive,
                 "auto_archive": printer.auto_archive,
                 "print_hours_offset": printer.print_hours_offset,
                 "print_hours_offset": printer.print_hours_offset,
+                "runtime_seconds": printer.runtime_seconds,
             }
             }
             if include_access_codes:
             if include_access_codes:
                 printer_data["access_code"] = printer.access_code
                 printer_data["access_code"] = printer.access_code
@@ -368,6 +393,99 @@ async def export_backup(
             )
             )
         backup["included"].append("maintenance_types")
         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)
     # Collect files for ZIP (icons + archives)
     backup_files: list[tuple[str, Path]] = []  # (zip_path, local_path)
     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():
             for proj in proj_result.scalars().all():
                 project_id_to_name[proj.id] = proj.name
                 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:
         for a in archives:
             archive_data = {
             archive_data = {
                 "filename": a.filename,
                 "filename": a.filename,
                 "project_name": project_id_to_name.get(a.project_id) if a.project_id else None,
                 "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,
                 "file_size": a.file_size,
                 "content_hash": a.content_hash,
                 "content_hash": a.content_hash,
                 "print_name": a.print_name,
                 "print_name": a.print_name,
@@ -485,6 +611,8 @@ async def export_backup(
                 "priority": p.priority,
                 "priority": p.priority,
                 "budget": p.budget,
                 "budget": p.budget,
                 "is_template": p.is_template,
                 "is_template": p.is_template,
+                "template_source_id": p.template_source_id,
+                "parent_id": p.parent_id,
                 "bom_items": [
                 "bom_items": [
                     {
                     {
                         "name": item.name,
                         "name": item.name,
@@ -671,10 +799,79 @@ async def import_backup(
                 str_value = str(value)
                 str_value = str(value)
             await set_setting(db, key, str_value)
             await set_setting(db, key, str_value)
             restored["settings"] += 1
             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)
     # Restore notification providers (skip or overwrite duplicates by name)
+    # Build printer serial to ID lookup (printers were restored first)
     if "notification_providers" in backup:
     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"]:
         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(
             result = await db.execute(
                 select(NotificationProvider).where(NotificationProvider.name == provider_data["name"])
                 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_printer_error = provider_data.get("on_printer_error", False)
                     existing.on_filament_low = provider_data.get("on_filament_low", 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_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_enabled = provider_data.get("quiet_hours_enabled", False)
                     existing.quiet_hours_start = provider_data.get("quiet_hours_start")
                     existing.quiet_hours_start = provider_data.get("quiet_hours_start")
                     existing.quiet_hours_end = provider_data.get("quiet_hours_end")
                     existing.quiet_hours_end = provider_data.get("quiet_hours_end")
                     existing.daily_digest_enabled = provider_data.get("daily_digest_enabled", False)
                     existing.daily_digest_enabled = provider_data.get("daily_digest_enabled", False)
                     existing.daily_digest_time = provider_data.get("daily_digest_time")
                     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
                     restored["notification_providers"] += 1
                 else:
                 else:
                     skipped["notification_providers"] += 1
                     skipped["notification_providers"] += 1
@@ -719,12 +920,16 @@ async def import_backup(
                     on_printer_error=provider_data.get("on_printer_error", False),
                     on_printer_error=provider_data.get("on_printer_error", False),
                     on_filament_low=provider_data.get("on_filament_low", False),
                     on_filament_low=provider_data.get("on_filament_low", False),
                     on_maintenance_due=provider_data.get("on_maintenance_due", 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_enabled=provider_data.get("quiet_hours_enabled", False),
                     quiet_hours_start=provider_data.get("quiet_hours_start"),
                     quiet_hours_start=provider_data.get("quiet_hours_start"),
                     quiet_hours_end=provider_data.get("quiet_hours_end"),
                     quiet_hours_end=provider_data.get("quiet_hours_end"),
                     daily_digest_enabled=provider_data.get("daily_digest_enabled", False),
                     daily_digest_enabled=provider_data.get("daily_digest_enabled", False),
                     daily_digest_time=provider_data.get("daily_digest_time"),
                     daily_digest_time=provider_data.get("daily_digest_time"),
-                    printer_id=provider_data.get("printer_id"),
+                    printer_id=printer_id,
                 )
                 )
                 db.add(provider)
                 db.add(provider)
                 restored["notification_providers"] += 1
                 restored["notification_providers"] += 1
@@ -754,14 +959,25 @@ async def import_backup(
             restored["notification_templates"] += 1
             restored["notification_templates"] += 1
 
 
     # Restore smart plugs (skip or overwrite duplicates by IP)
     # 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:
     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"]:
         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"]))
             result = await db.execute(select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"]))
             existing = result.scalar_one_or_none()
             existing = result.scalar_one_or_none()
             if existing:
             if existing:
                 if overwrite:
                 if overwrite:
                     existing.name = plug_data["name"]
                     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.enabled = plug_data.get("enabled", True)
                     existing.auto_on = plug_data.get("auto_on", True)
                     existing.auto_on = plug_data.get("auto_on", True)
                     existing.auto_off = plug_data.get("auto_off", True)
                     existing.auto_off = plug_data.get("auto_off", True)
@@ -785,7 +1001,7 @@ async def import_backup(
                 plug = SmartPlug(
                 plug = SmartPlug(
                     name=plug_data["name"],
                     name=plug_data["name"],
                     ip_address=plug_data["ip_address"],
                     ip_address=plug_data["ip_address"],
-                    printer_id=plug_data.get("printer_id"),
+                    printer_id=printer_id,
                     enabled=plug_data.get("enabled", True),
                     enabled=plug_data.get("enabled", True),
                     auto_on=plug_data.get("auto_on", True),
                     auto_on=plug_data.get("auto_on", True),
                     auto_off=plug_data.get("auto_off", True),
                     auto_off=plug_data.get("auto_off", True),
@@ -837,58 +1053,6 @@ async def import_backup(
                 db.add(link)
                 db.add(link)
                 restored["external_links"] += 1
                 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)
     # Restore filaments (skip or overwrite duplicates by name+type+brand)
     if "filaments" in backup:
     if "filaments" in backup:
         for filament_data in backup["filaments"]:
         for filament_data in backup["filaments"]:
@@ -965,8 +1129,138 @@ async def import_backup(
                 db.add(mt)
                 db.add(mt)
                 restored["maintenance_types"] += 1
                 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)
     # Restore archives (skip duplicates by content_hash - overwrite not supported for archives)
     if "archives" in backup:
     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"]:
         for archive_data in backup["archives"]:
             # Skip if no content_hash or already exists
             # Skip if no content_hash or already exists
             content_hash = archive_data.get("content_hash")
             content_hash = archive_data.get("content_hash")
@@ -981,11 +1275,16 @@ async def import_backup(
             # Only restore if file exists (from ZIP extraction)
             # Only restore if file exists (from ZIP extraction)
             file_path = archive_data.get("file_path")
             file_path = archive_data.get("file_path")
             if file_path and (base_dir / file_path).exists():
             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(
                 archive = PrintArchive(
                     filename=archive_data["filename"],
                     filename=archive_data["filename"],
                     file_path=file_path,
                     file_path=file_path,
                     file_size=archive_data.get("file_size", 0),
                     file_size=archive_data.get("file_size", 0),
                     content_hash=content_hash,
                     content_hash=content_hash,
+                    printer_id=printer_id,
                     thumbnail_path=archive_data.get("thumbnail_path"),
                     thumbnail_path=archive_data.get("thumbnail_path"),
                     timelapse_path=archive_data.get("timelapse_path"),
                     timelapse_path=archive_data.get("timelapse_path"),
                     source_3mf_path=archive_data.get("source_3mf_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.priority = project_data.get("priority", "normal")
                     existing.budget = project_data.get("budget")
                     existing.budget = project_data.get("budget")
                     existing.is_template = project_data.get("is_template", False)
                     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")
                     existing.attachments = project_data.get("attachments")
                     if project_data.get("due_date"):
                     if project_data.get("due_date"):
                         existing.due_date = datetime.fromisoformat(project_data["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"),
                     priority=project_data.get("priority", "normal"),
                     budget=project_data.get("budget"),
                     budget=project_data.get("budget"),
                     is_template=project_data.get("is_template", False),
                     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"),
                     attachments=project_data.get("attachments"),
                 )
                 )
                 if project_data.get("due_date"):
                 if project_data.get("due_date"):
@@ -1113,6 +1416,68 @@ async def import_backup(
                     if archive:
                     if archive:
                         archive.project_id = project_name_to_id[project_name]
                         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)
     # Restore pending uploads (skip duplicates by filename)
     if "pending_uploads" in backup:
     if "pending_uploads" in backup:
         # Ensure the pending uploads directory exists
         # Ensure the pending uploads directory exists

+ 18 - 8
backend/app/core/config.py

@@ -1,4 +1,5 @@
 import logging
 import logging
+import os
 from pathlib import Path
 from pathlib import Path
 
 
 from pydantic_settings import BaseSettings
 from pydantic_settings import BaseSettings
@@ -7,14 +8,23 @@ from pydantic_settings import BaseSettings
 APP_VERSION = "0.1.6b3"
 APP_VERSION = "0.1.6b3"
 GITHUB_REPO = "maziggy/bambuddy"
 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:
 def _migrate_database() -> Path:
     """Migrate database from old name to new name if needed."""
     """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 database exists and new one doesn't, rename it
     if old_db.exists() and not new_db.exists():
     if old_db.exists() and not new_db.exists():
@@ -40,10 +50,10 @@ class Settings(BaseSettings):
     debug: bool = False  # Default to production mode
     debug: bool = False  # Default to production mode
 
 
     # Paths
     # 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}"
     database_url: str = f"sqlite+aiosqlite:///{_db_path}"
 
 
     # Logging
     # Logging

+ 2 - 0
frontend/src/__tests__/components/AMSHistoryModal.test.tsx

@@ -12,6 +12,8 @@ import { api } from '../../api/client';
 vi.mock('../../api/client', () => ({
 vi.mock('../../api/client', () => ({
   api: {
   api: {
     getAMSHistory: vi.fn(),
     getAMSHistory: vi.fn(),
+    getSettings: vi.fn().mockResolvedValue({}),
+    updateSettings: vi.fn().mockResolvedValue({}),
   },
   },
 }));
 }));
 
 

+ 4 - 0
frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx

@@ -16,6 +16,10 @@ import { VirtualPrinterSettings } from '../../components/VirtualPrinterSettings'
 
 
 // Mock the API client
 // Mock the API client
 vi.mock('../../api/client', () => ({
 vi.mock('../../api/client', () => ({
+  api: {
+    getSettings: vi.fn().mockResolvedValue({}),
+    updateSettings: vi.fn().mockResolvedValue({}),
+  },
   virtualPrinterApi: {
   virtualPrinterApi: {
     getSettings: vi.fn(),
     getSettings: vi.fn(),
     updateSettings: vi.fn(),
     updateSettings: vi.fn(),

+ 2 - 0
frontend/src/__tests__/pages/SystemInfoPage.test.tsx

@@ -12,6 +12,8 @@ import { api } from '../../api/client';
 vi.mock('../../api/client', () => ({
 vi.mock('../../api/client', () => ({
   api: {
   api: {
     getSystemInfo: vi.fn(),
     getSystemInfo: vi.fn(),
+    getSettings: vi.fn().mockResolvedValue({}),
+    updateSettings: vi.fn().mockResolvedValue({}),
   },
   },
 }));
 }));