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")}
+ } onClick={handleExport}>
+ {t("settings.backup.export_button")}
+
+
+
+
+
{t("settings.backup.import_title")}
+
+ false}
+ onChange={({ fileList }) => setFileList(fileList)}
+ >
+ }>{t("settings.backup.select_file")}
+
+
+
+ handleImport(false)}
+ loading={importing}
+ disabled={fileList.length === 0}
+ >
+ {t("settings.backup.import_merge")}
+
+
+ handleImport(true)}
+ okText={t("buttons.yes")}
+ cancelText={t("buttons.no")}
+ >
+
+ {t("settings.backup.import_replace")}
+
+
+
+
+
+
+
+ );
+}
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)