feat: Add database import/export functionality (Issue #10)
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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": "<p>Here you can add extra custom fields to your entities.</p><p>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.</p><p>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.</p><p>Extra fields can not be sorted or filtered in the table views.</p>",
|
||||
|
||||
@@ -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() {
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Divider />
|
||||
|
||||
<BackupRestore messageApi={messageApi} t={t} />
|
||||
|
||||
{contextHolder}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface BackupRestoreProps {
|
||||
messageApi: ReturnType<typeof message.useMessage>[0];
|
||||
t: ReturnType<typeof useTranslate>;
|
||||
}
|
||||
|
||||
function BackupRestore({ messageApi, t }: BackupRestoreProps) {
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
|
||||
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 (
|
||||
<div style={{ maxWidth: "600px", margin: "0 auto" }}>
|
||||
<h3>{t("settings.backup.title")}</h3>
|
||||
<p style={{ marginBottom: "1em", color: "#888" }}>
|
||||
{t("settings.backup.description")}
|
||||
</p>
|
||||
|
||||
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
||||
<div>
|
||||
<h4>{t("settings.backup.export_title")}</h4>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExport}>
|
||||
{t("settings.backup.export_button")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4>{t("settings.backup.import_title")}</h4>
|
||||
<Space direction="vertical" size="small" style={{ width: "100%" }}>
|
||||
<Upload
|
||||
accept=".json"
|
||||
maxCount={1}
|
||||
fileList={fileList}
|
||||
beforeUpload={() => false}
|
||||
onChange={({ fileList }) => setFileList(fileList)}
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>{t("settings.backup.select_file")}</Button>
|
||||
</Upload>
|
||||
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => handleImport(false)}
|
||||
loading={importing}
|
||||
disabled={fileList.length === 0}
|
||||
>
|
||||
{t("settings.backup.import_merge")}
|
||||
</Button>
|
||||
|
||||
<Popconfirm
|
||||
title={t("settings.backup.replace_warning_title")}
|
||||
description={t("settings.backup.replace_warning")}
|
||||
onConfirm={() => handleImport(true)}
|
||||
okText={t("buttons.yes")}
|
||||
cancelText={t("buttons.no")}
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
loading={importing}
|
||||
disabled={fileList.length === 0}
|
||||
>
|
||||
{t("settings.backup.import_replace")}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
293
spoolman/api/v1/backup.py
Normal file
293
spoolman/api/v1/backup.py
Normal file
@@ -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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user