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"}