Add GitHub profile backup feature

New feature to automatically backup K-profiles, cloud profiles, and app
settings to a GitHub repository with scheduled or on-demand execution.

Features:
- Configure GitHub repo URL and Personal Access Token
- Schedule backups hourly, daily, or weekly (background scheduler)
- Manual backup trigger with real-time progress tracking
- Skip unchanged commits (only creates commit when data changes)
- Backup history log with status and commit links
- Requires Bambu Cloud login for full profile access
- New Settings → Backup & Restore tab consolidating all backup options
- GitHub backup config included in local backup/restore (except PAT)

Backend:
- New models: GitHubBackupConfig, GitHubBackupLog
- New service: GitHubBackupService with scheduler and GitHub API client
- New routes: /github-backup/* for config, status, logs, and triggers
- Updated settings.py to include github_backup in backup/restore

Frontend:
- New GitHubBackupSettings.tsx component with auto-save
- Updated SettingsPage with Backup tab and status indicator
- Added API types and methods to client.ts

Tests:
- Backend integration tests for all GitHub backup API endpoints
- Frontend API type and endpoint tests
This commit is contained in:
maziggy
2026-01-27 15:01:06 +01:00
parent ea737c4719
commit 4ea7f7c4aa
20 changed files with 11536 additions and 8848 deletions
+319
View File
@@ -0,0 +1,319 @@
"""API routes for GitHub profile backup."""
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import delete, desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.database import get_db
from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
from backend.app.schemas.github_backup import (
GitHubBackupConfigCreate,
GitHubBackupConfigResponse,
GitHubBackupConfigUpdate,
GitHubBackupLogResponse,
GitHubBackupStatus,
GitHubBackupTriggerResponse,
GitHubTestConnectionResponse,
)
from backend.app.services.github_backup import github_backup_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/github-backup", tags=["github-backup"])
def _config_to_response(config: GitHubBackupConfig) -> dict:
"""Convert config model to response dict."""
return {
"id": config.id,
"repository_url": config.repository_url,
"has_token": bool(config.access_token),
"branch": config.branch,
"schedule_enabled": config.schedule_enabled,
"schedule_type": config.schedule_type,
"backup_kprofiles": config.backup_kprofiles,
"backup_cloud_profiles": config.backup_cloud_profiles,
"backup_settings": config.backup_settings,
"enabled": config.enabled,
"last_backup_at": config.last_backup_at,
"last_backup_status": config.last_backup_status,
"last_backup_message": config.last_backup_message,
"last_backup_commit_sha": config.last_backup_commit_sha,
"next_scheduled_run": config.next_scheduled_run,
"created_at": config.created_at,
"updated_at": config.updated_at,
}
@router.get("/config", response_model=GitHubBackupConfigResponse | None)
async def get_config(db: AsyncSession = Depends(get_db)):
"""Get the current GitHub backup configuration."""
result = await db.execute(select(GitHubBackupConfig).limit(1))
config = result.scalar_one_or_none()
if not config:
return None
return _config_to_response(config)
@router.post("/config", response_model=GitHubBackupConfigResponse)
async def save_config(
config_data: GitHubBackupConfigCreate,
db: AsyncSession = Depends(get_db),
):
"""Create or update GitHub backup configuration.
Only one configuration is supported. If one exists, it will be updated.
"""
# Check for existing config
result = await db.execute(select(GitHubBackupConfig).limit(1))
config = result.scalar_one_or_none()
if config:
# Update existing
config.repository_url = config_data.repository_url
config.access_token = config_data.access_token
config.branch = config_data.branch
config.schedule_enabled = config_data.schedule_enabled
config.schedule_type = config_data.schedule_type.value
config.backup_kprofiles = config_data.backup_kprofiles
config.backup_cloud_profiles = config_data.backup_cloud_profiles
config.backup_settings = config_data.backup_settings
config.enabled = config_data.enabled
# Calculate next scheduled run if enabled
if config.schedule_enabled:
config.next_scheduled_run = github_backup_service._calculate_next_run(config.schedule_type)
else:
config.next_scheduled_run = None
logger.info(f"Updated GitHub backup config: {config.repository_url}")
else:
# Create new
config = GitHubBackupConfig(
repository_url=config_data.repository_url,
access_token=config_data.access_token,
branch=config_data.branch,
schedule_enabled=config_data.schedule_enabled,
schedule_type=config_data.schedule_type.value,
backup_kprofiles=config_data.backup_kprofiles,
backup_cloud_profiles=config_data.backup_cloud_profiles,
backup_settings=config_data.backup_settings,
enabled=config_data.enabled,
)
if config.schedule_enabled:
config.next_scheduled_run = github_backup_service._calculate_next_run(config.schedule_type)
db.add(config)
logger.info(f"Created GitHub backup config: {config.repository_url}")
await db.commit()
await db.refresh(config)
return _config_to_response(config)
@router.patch("/config", response_model=GitHubBackupConfigResponse)
async def update_config(
update_data: GitHubBackupConfigUpdate,
db: AsyncSession = Depends(get_db),
):
"""Partially update GitHub backup configuration."""
result = await db.execute(select(GitHubBackupConfig).limit(1))
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=404, detail="No configuration found")
update_dict = update_data.model_dump(exclude_unset=True)
for key, value in update_dict.items():
if key == "schedule_type" and value is not None:
setattr(config, key, value.value)
else:
setattr(config, key, value)
# Recalculate next scheduled run if schedule settings changed
if "schedule_enabled" in update_dict or "schedule_type" in update_dict:
if config.schedule_enabled:
config.next_scheduled_run = github_backup_service._calculate_next_run(config.schedule_type)
else:
config.next_scheduled_run = None
await db.commit()
await db.refresh(config)
logger.info(f"Updated GitHub backup config: {config.repository_url}")
return _config_to_response(config)
@router.delete("/config")
async def delete_config(db: AsyncSession = Depends(get_db)):
"""Delete the GitHub backup configuration and all logs."""
result = await db.execute(select(GitHubBackupConfig).limit(1))
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=404, detail="No configuration found")
await db.delete(config)
await db.commit()
logger.info("Deleted GitHub backup config")
return {"message": "Configuration deleted"}
@router.post("/test", response_model=GitHubTestConnectionResponse)
async def test_connection(
repo_url: str = Query(..., description="GitHub repository URL"),
token: str = Query(..., description="Personal Access Token"),
):
"""Test GitHub connection with provided credentials."""
result = await github_backup_service.test_connection(repo_url, token)
return GitHubTestConnectionResponse(**result)
@router.post("/test-stored", response_model=GitHubTestConnectionResponse)
async def test_stored_connection(db: AsyncSession = Depends(get_db)):
"""Test GitHub connection using stored configuration."""
result = await db.execute(select(GitHubBackupConfig).limit(1))
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=404, detail="No configuration found")
if not config.access_token:
raise HTTPException(status_code=400, detail="No access token configured")
test_result = await github_backup_service.test_connection(config.repository_url, config.access_token)
return GitHubTestConnectionResponse(**test_result)
@router.post("/run", response_model=GitHubBackupTriggerResponse)
async def trigger_backup(db: AsyncSession = Depends(get_db)):
"""Manually trigger a backup."""
result = await db.execute(select(GitHubBackupConfig).limit(1))
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
if not config.enabled:
raise HTTPException(status_code=400, detail="Backup is disabled")
backup_result = await github_backup_service.run_backup(config.id, trigger="manual")
return GitHubBackupTriggerResponse(**backup_result)
@router.get("/status", response_model=GitHubBackupStatus)
async def get_status(db: AsyncSession = Depends(get_db)):
"""Get current backup status."""
result = await db.execute(select(GitHubBackupConfig).limit(1))
config = result.scalar_one_or_none()
if not config:
return GitHubBackupStatus(
configured=False,
enabled=False,
is_running=False,
progress=None,
last_backup_at=None,
last_backup_status=None,
next_scheduled_run=None,
)
return GitHubBackupStatus(
configured=True,
enabled=config.enabled,
is_running=github_backup_service.is_running,
progress=github_backup_service.progress,
last_backup_at=config.last_backup_at,
last_backup_status=config.last_backup_status,
next_scheduled_run=config.next_scheduled_run,
)
@router.get("/logs", response_model=list[GitHubBackupLogResponse])
async def get_logs(
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
db: AsyncSession = Depends(get_db),
):
"""Get backup logs."""
result = await db.execute(select(GitHubBackupConfig).limit(1))
config = result.scalar_one_or_none()
if not config:
return []
logs_result = await db.execute(
select(GitHubBackupLog)
.where(GitHubBackupLog.config_id == config.id)
.order_by(desc(GitHubBackupLog.started_at))
.offset(offset)
.limit(limit)
)
logs = logs_result.scalars().all()
return [
GitHubBackupLogResponse(
id=log.id,
config_id=log.config_id,
started_at=log.started_at,
completed_at=log.completed_at,
status=log.status,
trigger=log.trigger,
commit_sha=log.commit_sha,
files_changed=log.files_changed,
error_message=log.error_message,
)
for log in logs
]
@router.delete("/logs")
async def clear_logs(
keep_last: int = Query(default=10, ge=0, le=100, description="Number of recent logs to keep"),
db: AsyncSession = Depends(get_db),
):
"""Clear backup logs, optionally keeping the most recent entries."""
result = await db.execute(select(GitHubBackupConfig).limit(1))
config = result.scalar_one_or_none()
if not config:
return {"deleted": 0, "message": "No configuration found"}
if keep_last > 0:
# Get IDs to keep
keep_result = await db.execute(
select(GitHubBackupLog.id)
.where(GitHubBackupLog.config_id == config.id)
.order_by(desc(GitHubBackupLog.started_at))
.limit(keep_last)
)
keep_ids = [row[0] for row in keep_result.fetchall()]
if keep_ids:
delete_result = await db.execute(
delete(GitHubBackupLog).where(
GitHubBackupLog.config_id == config.id, GitHubBackupLog.id.not_in(keep_ids)
)
)
else:
delete_result = await db.execute(delete(GitHubBackupLog).where(GitHubBackupLog.config_id == config.id))
else:
delete_result = await db.execute(delete(GitHubBackupLog).where(GitHubBackupLog.config_id == config.id))
await db.commit()
deleted_count = delete_result.rowcount
logger.info(f"Deleted {deleted_count} GitHub backup logs (kept {keep_last})")
return {"deleted": deleted_count, "message": f"Deleted {deleted_count} logs"}
+58
View File
@@ -15,6 +15,7 @@ from backend.app.models.api_key import APIKey
from backend.app.models.archive import PrintArchive
from backend.app.models.external_link import ExternalLink
from backend.app.models.filament import Filament
from backend.app.models.github_backup import GitHubBackupConfig
from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
from backend.app.models.notification import NotificationProvider
from backend.app.models.notification_template import NotificationTemplate
@@ -256,6 +257,7 @@ async def export_backup(
include_users: bool = Query(
False, description="Include users (passwords not exported - users will need new passwords)"
),
include_github_backup: bool = Query(False, description="Include GitHub backup configuration (token not exported)"),
):
"""Export selected data as JSON backup."""
backup: dict = {
@@ -854,6 +856,24 @@ async def export_backup(
)
backup["included"].append("users")
# GitHub backup configuration
if include_github_backup:
result = await db.execute(select(GitHubBackupConfig).limit(1))
config = result.scalar_one_or_none()
if config:
backup["github_backup"] = {
"repository_url": config.repository_url,
# access_token intentionally not exported for security
"branch": config.branch,
"schedule_enabled": config.schedule_enabled,
"schedule_type": config.schedule_type,
"backup_kprofiles": config.backup_kprofiles,
"backup_cloud_profiles": config.backup_cloud_profiles,
"backup_settings": config.backup_settings,
"enabled": config.enabled,
}
backup["included"].append("github_backup")
# If there are files to include (icons or archives), create ZIP file
if backup_files:
zip_buffer = io.BytesIO()
@@ -953,6 +973,7 @@ async def import_backup(
"projects": 0,
"pending_uploads": 0,
"users": 0,
"github_backup": 0,
}
skipped = {
"settings": 0,
@@ -967,6 +988,7 @@ async def import_backup(
"projects": 0,
"pending_uploads": 0,
"users": 0,
"github_backup": 0,
}
skipped_details = {
"notification_providers": [],
@@ -1966,6 +1988,42 @@ async def import_backup(
restored["users"] += 1
new_users.append(f"{user_data['username']} (temp password: {temp_password})")
# Restore GitHub backup configuration (note: access_token not included for security)
if "github_backup" in backup:
github_data = backup["github_backup"]
result = await db.execute(select(GitHubBackupConfig).limit(1))
existing = result.scalar_one_or_none()
if existing:
if overwrite:
existing.repository_url = github_data.get("repository_url", existing.repository_url)
existing.branch = github_data.get("branch", existing.branch)
existing.schedule_enabled = github_data.get("schedule_enabled", existing.schedule_enabled)
existing.schedule_type = github_data.get("schedule_type", existing.schedule_type)
existing.backup_kprofiles = github_data.get("backup_kprofiles", existing.backup_kprofiles)
existing.backup_cloud_profiles = github_data.get(
"backup_cloud_profiles", existing.backup_cloud_profiles
)
existing.backup_settings = github_data.get("backup_settings", existing.backup_settings)
existing.enabled = github_data.get("enabled", existing.enabled)
# Note: access_token must be re-entered after restore
restored["github_backup"] += 1
else:
skipped["github_backup"] += 1
else:
config = GitHubBackupConfig(
repository_url=github_data.get("repository_url", ""),
access_token="", # Must be entered after restore
branch=github_data.get("branch", "main"),
schedule_enabled=github_data.get("schedule_enabled", False),
schedule_type=github_data.get("schedule_type", "daily"),
backup_kprofiles=github_data.get("backup_kprofiles", True),
backup_cloud_profiles=github_data.get("backup_cloud_profiles", True),
backup_settings=github_data.get("backup_settings", False),
enabled=False, # Disabled until token is entered
)
db.add(config)
restored["github_backup"] += 1
await db.commit()
# If printers were in the backup (restored, updated, or skipped), reconnect all active printers