Files
bambuddy/backend/app/api/routes/github_backup.py
T
maziggy 4ea7f7c4aa 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
2026-01-27 15:01:06 +01:00

320 lines
11 KiB
Python

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