From 613176dd42440b316eccdec31855afb96927cae3 Mon Sep 17 00:00:00 2001 From: tonym Date: Wed, 14 Jan 2026 22:47:09 -0600 Subject: [PATCH] feat: Add database import/export functionality (Issue #10) Backend: - New /api/v1/backup/export endpoint - exports all data as single JSON - New /api/v1/backup/import endpoint - imports JSON with merge or replace - Handles vendors, filaments, spools, adjustments, settings - Remaps IDs to maintain relationships during import Frontend: - Added Backup & Restore section to Settings page - Download Backup button for export - Import with merge or replace options - Warning confirmation for replace mode This enables migrating between PostgreSQL and SQLite databases. Co-Authored-By: Claude Opus 4.5 --- client/public/locales/en/common.json | 16 + client/src/pages/settings/generalSettings.tsx | 141 ++++++++- spoolman/api/v1/backup.py | 293 ++++++++++++++++++ spoolman/api/v1/router.py | 3 +- 4 files changed, 450 insertions(+), 3 deletions(-) create mode 100644 spoolman/api/v1/backup.py diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index 35030cd..6698259 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -348,6 +348,22 @@ "tooltip": "Round prices to the nearest whole number." } }, + "backup": { + "title": "Backup & Restore", + "description": "Export all your data for backup or migrate to a different database.", + "export_title": "Export Data", + "export_button": "Download Backup", + "export_success": "Backup downloaded successfully", + "export_error": "Failed to export data", + "import_title": "Import Data", + "select_file": "Select backup file", + "import_merge": "Import (Merge)", + "import_replace": "Import (Replace All)", + "import_success": "Data imported successfully", + "import_error": "Failed to import data", + "replace_warning_title": "Replace all data?", + "replace_warning": "This will DELETE all existing data and replace it with the backup. This cannot be undone!" + }, "extra_fields": { "tab": "Extra Fields", "description": "

Here you can add extra custom fields to your entities.

Once a field is added, you can not change its key or type, and for choice type fields you can not remove choices or change the multi choice state. If you remove a field, the associated data for all entities will be deleted.

The key is what other programs read/write the data as, so if your custom field is supposed to integrate with a third-party program, make sure to set it correctly. Default value is only applied to new items.

Extra fields can not be sorted or filtered in the table views.

", diff --git a/client/src/pages/settings/generalSettings.tsx b/client/src/pages/settings/generalSettings.tsx index 9c1a219..366cbfd 100644 --- a/client/src/pages/settings/generalSettings.tsx +++ b/client/src/pages/settings/generalSettings.tsx @@ -1,6 +1,9 @@ +import { DownloadOutlined, UploadOutlined } from "@ant-design/icons"; import { useTranslate } from "@refinedev/core"; -import { Button, Checkbox, Form, Input, message } from "antd"; -import { useEffect } from "react"; +import { Button, Checkbox, Divider, Form, Input, message, Popconfirm, Space, Upload } from "antd"; +import type { UploadFile } from "antd"; +import { useEffect, useState } from "react"; +import { getAPIURL } from "../../utils/url"; import { useGetSettings, useSetSetting } from "../../utils/querySettings"; export function GeneralSettings() { @@ -110,7 +113,141 @@ export function GeneralSettings() { + + + + + {contextHolder} ); } + +interface BackupRestoreProps { + messageApi: ReturnType[0]; + t: ReturnType; +} + +function BackupRestore({ messageApi, t }: BackupRestoreProps) { + const [importing, setImporting] = useState(false); + const [fileList, setFileList] = useState([]); + + const handleExport = async () => { + try { + const response = await fetch(`${getAPIURL()}/backup/export`); + if (!response.ok) throw new Error("Export failed"); + + const blob = await response.blob(); + const filename = response.headers.get("content-disposition")?.match(/filename="(.+)"/)?.[1] + || `spoolman_backup_${new Date().toISOString().slice(0, 10)}.json`; + + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + messageApi.success(t("settings.backup.export_success")); + } catch (error) { + messageApi.error(t("settings.backup.export_error")); + } + }; + + const handleImport = async (replace: boolean) => { + if (fileList.length === 0) { + messageApi.warning(t("settings.backup.select_file")); + return; + } + + setImporting(true); + try { + const formData = new FormData(); + formData.append("file", fileList[0].originFileObj as Blob); + + const response = await fetch(`${getAPIURL()}/backup/import?replace=${replace}`, { + method: "POST", + body: formData, + }); + + const result = await response.json(); + + if (result.success) { + messageApi.success( + `${t("settings.backup.import_success")}: ${result.vendors_imported} vendors, ${result.filaments_imported} filaments, ${result.spools_imported} spools` + ); + setFileList([]); + // Reload the page to refresh all data + window.location.reload(); + } else { + messageApi.error(`${t("settings.backup.import_error")}: ${result.errors.join(", ")}`); + } + } catch (error) { + messageApi.error(t("settings.backup.import_error")); + } finally { + setImporting(false); + } + }; + + return ( +
+

{t("settings.backup.title")}

+

+ {t("settings.backup.description")} +

+ + +
+

{t("settings.backup.export_title")}

+ +
+ +
+

{t("settings.backup.import_title")}

+ + false} + onChange={({ fileList }) => setFileList(fileList)} + > + + + + + + + handleImport(true)} + okText={t("buttons.yes")} + cancelText={t("buttons.no")} + > + + + + +
+
+
+ ); +} diff --git a/spoolman/api/v1/backup.py b/spoolman/api/v1/backup.py new file mode 100644 index 0000000..6d54fa4 --- /dev/null +++ b/spoolman/api/v1/backup.py @@ -0,0 +1,293 @@ +"""Database backup and restore API endpoints.""" + +import json +import logging +from datetime import datetime +from typing import Annotated, Any, Optional + +from fastapi import APIRouter, Depends, File, UploadFile +from fastapi.responses import JSONResponse, Response +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from spoolman.database.database import get_db_session +from spoolman.database.models import ( + Filament, + FilamentField, + Setting, + Spool, + SpoolAdjustment, + SpoolField, + Vendor, + VendorField, +) + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/backup", + tags=["backup"], +) + + +class BackupData(BaseModel): + """Schema for backup data.""" + + version: str = Field(default="1.0", description="Backup format version") + exported_at: str = Field(description="ISO timestamp of export") + vendors: list[dict[str, Any]] = Field(default_factory=list) + filaments: list[dict[str, Any]] = Field(default_factory=list) + spools: list[dict[str, Any]] = Field(default_factory=list) + spool_adjustments: list[dict[str, Any]] = Field(default_factory=list) + settings: list[dict[str, Any]] = Field(default_factory=list) + + +class ImportResult(BaseModel): + """Result of import operation.""" + + success: bool + vendors_imported: int = 0 + filaments_imported: int = 0 + spools_imported: int = 0 + adjustments_imported: int = 0 + settings_imported: int = 0 + errors: list[str] = Field(default_factory=list) + + +def _serialize_datetime(obj: Any) -> Any: + """Convert datetime objects to ISO strings.""" + if isinstance(obj, datetime): + return obj.isoformat() + return obj + + +def _model_to_dict(obj: Any, exclude: Optional[set[str]] = None) -> dict[str, Any]: + """Convert SQLAlchemy model to dict, handling extra fields.""" + exclude = exclude or set() + result = {} + + for column in obj.__table__.columns: + if column.name not in exclude: + value = getattr(obj, column.name) + result[column.name] = _serialize_datetime(value) + + # Handle extra fields if present + if hasattr(obj, "extra") and obj.extra: + result["extra"] = {field.key: field.value for field in obj.extra} + + return result + + +@router.get( + "/export", + name="Export all data", + description="Export all Spoolman data as a single JSON file for backup or migration.", + response_class=Response, +) +async def export_all( + db: Annotated[AsyncSession, Depends(get_db_session)], +) -> Response: + """Export all database data as JSON.""" + + # Fetch all vendors + vendors_result = await db.execute(select(Vendor)) + vendors = [_model_to_dict(v) for v in vendors_result.unique().scalars().all()] + + # Fetch all filaments + filaments_result = await db.execute(select(Filament)) + filaments = [_model_to_dict(f) for f in filaments_result.unique().scalars().all()] + + # Fetch all spools + spools_result = await db.execute(select(Spool)) + spools = [_model_to_dict(s) for s in spools_result.unique().scalars().all()] + + # Fetch all spool adjustments + adjustments_result = await db.execute(select(SpoolAdjustment)) + adjustments = [_model_to_dict(a, exclude={"spool"}) for a in adjustments_result.scalars().all()] + + # Fetch all settings + settings_result = await db.execute(select(Setting)) + settings = [_model_to_dict(s) for s in settings_result.scalars().all()] + + backup = BackupData( + version="1.0", + exported_at=datetime.utcnow().isoformat(), + vendors=vendors, + filaments=filaments, + spools=spools, + spool_adjustments=adjustments, + settings=settings, + ) + + # Generate filename with timestamp + filename = f"spoolman_backup_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.json" + + return Response( + content=json.dumps(backup.model_dump(), indent=2, default=str), + media_type="application/json", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.post( + "/import", + name="Import data", + description="Import Spoolman data from a backup JSON file.", + response_model=ImportResult, +) +async def import_all( + db: Annotated[AsyncSession, Depends(get_db_session)], + file: Annotated[UploadFile, File(description="Backup JSON file to import")], + replace: bool = False, +) -> ImportResult: + """Import database data from JSON backup. + + Args: + file: The backup JSON file to import + replace: If True, clear existing data before import. If False, merge/skip conflicts. + """ + result = ImportResult(success=False) + + try: + content = await file.read() + data = json.loads(content) + + # Validate version + version = data.get("version", "1.0") + if version != "1.0": + result.errors.append(f"Unsupported backup version: {version}") + return result + + # If replace mode, clear existing data (order matters due to foreign keys) + if replace: + await db.execute(SpoolAdjustment.__table__.delete()) + await db.execute(SpoolField.__table__.delete()) + await db.execute(Spool.__table__.delete()) + await db.execute(FilamentField.__table__.delete()) + await db.execute(Filament.__table__.delete()) + await db.execute(VendorField.__table__.delete()) + await db.execute(Vendor.__table__.delete()) + await db.commit() + + # Track ID mappings for relationships (old_id -> new_id) + vendor_id_map: dict[int, int] = {} + filament_id_map: dict[int, int] = {} + spool_id_map: dict[int, int] = {} + + # Import vendors + for vendor_data in data.get("vendors", []): + old_id = vendor_data.pop("id", None) + extra_fields = vendor_data.pop("extra", {}) + + # Parse datetime + if "registered" in vendor_data and isinstance(vendor_data["registered"], str): + vendor_data["registered"] = datetime.fromisoformat(vendor_data["registered"]) + + vendor = Vendor(**vendor_data) + db.add(vendor) + await db.flush() # Get the new ID + + if old_id is not None: + vendor_id_map[old_id] = vendor.id + + # Add extra fields + for key, value in extra_fields.items(): + db.add(VendorField(vendor_id=vendor.id, key=key, value=value)) + + result.vendors_imported += 1 + + # Import filaments + for filament_data in data.get("filaments", []): + old_id = filament_data.pop("id", None) + extra_fields = filament_data.pop("extra", {}) + + # Remap vendor_id + if filament_data.get("vendor_id") and filament_data["vendor_id"] in vendor_id_map: + filament_data["vendor_id"] = vendor_id_map[filament_data["vendor_id"]] + + # Parse datetime + if "registered" in filament_data and isinstance(filament_data["registered"], str): + filament_data["registered"] = datetime.fromisoformat(filament_data["registered"]) + + filament = Filament(**filament_data) + db.add(filament) + await db.flush() + + if old_id is not None: + filament_id_map[old_id] = filament.id + + # Add extra fields + for key, value in extra_fields.items(): + db.add(FilamentField(filament_id=filament.id, key=key, value=value)) + + result.filaments_imported += 1 + + # Import spools + for spool_data in data.get("spools", []): + old_id = spool_data.pop("id", None) + extra_fields = spool_data.pop("extra", {}) + + # Remap filament_id + if spool_data.get("filament_id") and spool_data["filament_id"] in filament_id_map: + spool_data["filament_id"] = filament_id_map[spool_data["filament_id"]] + + # Parse datetimes + for field in ["registered", "first_used", "last_used"]: + if field in spool_data and isinstance(spool_data[field], str): + spool_data[field] = datetime.fromisoformat(spool_data[field]) + + spool = Spool(**spool_data) + db.add(spool) + await db.flush() + + if old_id is not None: + spool_id_map[old_id] = spool.id + + # Add extra fields + for key, value in extra_fields.items(): + db.add(SpoolField(spool_id=spool.id, key=key, value=value)) + + result.spools_imported += 1 + + # Import spool adjustments + for adj_data in data.get("spool_adjustments", []): + adj_data.pop("id", None) # Remove old ID + + # Remap spool_id + if adj_data.get("spool_id") and adj_data["spool_id"] in spool_id_map: + adj_data["spool_id"] = spool_id_map[adj_data["spool_id"]] + else: + # Skip if spool doesn't exist + continue + + # Parse timestamp + if "timestamp" in adj_data and isinstance(adj_data["timestamp"], str): + adj_data["timestamp"] = datetime.fromisoformat(adj_data["timestamp"]) + + adjustment = SpoolAdjustment(**adj_data) + db.add(adjustment) + result.adjustments_imported += 1 + + # Import settings + for setting_data in data.get("settings", []): + # Parse datetime + if "last_updated" in setting_data and isinstance(setting_data["last_updated"], str): + setting_data["last_updated"] = datetime.fromisoformat(setting_data["last_updated"]) + + # Use merge to handle existing settings + setting = Setting(**setting_data) + await db.merge(setting) + result.settings_imported += 1 + + await db.commit() + result.success = True + + except json.JSONDecodeError as e: + result.errors.append(f"Invalid JSON: {e}") + except Exception as e: + logger.exception("Import failed") + result.errors.append(str(e)) + await db.rollback() + + return result diff --git a/spoolman/api/v1/router.py b/spoolman/api/v1/router.py index f8466fa..8454eaa 100644 --- a/spoolman/api/v1/router.py +++ b/spoolman/api/v1/router.py @@ -15,7 +15,7 @@ from spoolman.database.database import backup_global_db from spoolman.exceptions import ItemNotFoundError from spoolman.ws import websocket_manager -from . import export, externaldb, field, filament, models, other, setting, spool, vendor +from . import backup as backup_module, export, externaldb, field, filament, models, other, setting, spool, vendor logger = logging.getLogger(__name__) @@ -112,3 +112,4 @@ app.include_router(field.router) app.include_router(other.router) app.include_router(externaldb.router) app.include_router(export.router) +app.include_router(backup_module.router)