Python FastAPI ML backend kept intact; auth replaced with central hnf_session cookie verification. Frontend rebuilt on React 18 + TS + Vite with stack design system, Plotly charts retained. Shared Postgres via DATABASE_URL; schema applied on startup. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
303 lines
8.5 KiB
Python
303 lines
8.5 KiB
Python
"""
|
|
Backup and Restore API Endpoints
|
|
"""
|
|
import logging
|
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
|
from fastapi.responses import FileResponse
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from pathlib import Path
|
|
|
|
from database import get_db
|
|
from auth import get_current_user
|
|
from services.backup_service import BackupService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ============================================
|
|
# HELPER FUNCTIONS
|
|
# ============================================
|
|
# Note: All backup endpoints require authentication but not admin role,
|
|
# consistent with other sensitive operations in the application
|
|
|
|
|
|
# ============================================
|
|
# API ENDPOINTS
|
|
# ============================================
|
|
|
|
@router.get("/settings")
|
|
async def get_backup_settings(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Get backup configuration settings
|
|
|
|
Returns backup frequency, retention count, destination, and last backup status
|
|
"""
|
|
try:
|
|
service = BackupService(db)
|
|
await service.ensure_backup_table_exists()
|
|
settings = await service.get_backup_settings()
|
|
return settings
|
|
except Exception as e:
|
|
logger.error(f"Failed to get backup settings: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.patch("/settings")
|
|
async def update_backup_settings(
|
|
updates: dict,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Update backup configuration settings
|
|
|
|
Body: {
|
|
frequency?: 'manual' | 'daily' | 'weekly' | 'monthly',
|
|
retention_count?: number,
|
|
destination?: 'local',
|
|
time?: 'HH:MM'
|
|
}
|
|
"""
|
|
try:
|
|
service = BackupService(db)
|
|
success = await service.update_backup_settings(updates)
|
|
|
|
if success:
|
|
return {"message": "Settings updated successfully"}
|
|
else:
|
|
raise HTTPException(status_code=500, detail="Failed to update settings")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to update backup settings: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.post("/create")
|
|
async def create_backup(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Trigger manual backup creation
|
|
|
|
Creates a ZIP file containing database dump, JSON export, and all data files.
|
|
Returns backup ID and status.
|
|
"""
|
|
try:
|
|
service = BackupService(db)
|
|
await service.ensure_backup_table_exists()
|
|
|
|
username = current_user.get('username', 'unknown')
|
|
success, message, backup_id = await service.create_backup(
|
|
backup_type='manual',
|
|
created_by=username
|
|
)
|
|
|
|
if success:
|
|
return {
|
|
"success": True,
|
|
"message": message,
|
|
"backup_id": backup_id
|
|
}
|
|
else:
|
|
raise HTTPException(status_code=500, detail=message)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Backup creation failed: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/history")
|
|
async def get_backup_history(
|
|
limit: int = 50,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""
|
|
List all backups, newest first
|
|
|
|
Query params:
|
|
- limit: Maximum number of backups to return (default 50)
|
|
"""
|
|
try:
|
|
service = BackupService(db)
|
|
await service.ensure_backup_table_exists()
|
|
backups = await service.list_backups(limit=limit)
|
|
return backups
|
|
except Exception as e:
|
|
logger.error(f"Failed to get backup history: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/{backup_id}")
|
|
async def get_backup(
|
|
backup_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""Get a specific backup by ID"""
|
|
try:
|
|
service = BackupService(db)
|
|
backup = await service.get_backup(backup_id)
|
|
|
|
if not backup:
|
|
raise HTTPException(status_code=404, detail="Backup not found")
|
|
|
|
return backup
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to get backup: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/{backup_id}/download")
|
|
async def download_backup(
|
|
backup_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Download a backup file
|
|
|
|
Returns the backup ZIP file for download
|
|
"""
|
|
try:
|
|
service = BackupService(db)
|
|
backup = await service.get_backup(backup_id)
|
|
|
|
if not backup:
|
|
raise HTTPException(status_code=404, detail="Backup not found")
|
|
|
|
if backup['status'] != 'success':
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Cannot download backup that did not complete successfully"
|
|
)
|
|
|
|
file_path = Path(backup['file_path'])
|
|
if not file_path.exists():
|
|
raise HTTPException(status_code=404, detail="Backup file not found on disk")
|
|
|
|
return FileResponse(
|
|
path=str(file_path),
|
|
filename=backup['filename'],
|
|
media_type='application/zip'
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to download backup: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.post("/{backup_id}/restore")
|
|
async def restore_backup(
|
|
backup_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Restore from an existing backup
|
|
|
|
WARNING: This will overwrite the current database with the backup data.
|
|
All current data will be replaced.
|
|
"""
|
|
try:
|
|
service = BackupService(db)
|
|
success, message = await service.restore_from_backup(backup_id)
|
|
|
|
if success:
|
|
return {"success": True, "message": message}
|
|
else:
|
|
raise HTTPException(status_code=500, detail=message)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Restore failed: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.post("/upload-restore")
|
|
async def upload_and_restore(
|
|
file: UploadFile = File(...),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Upload and restore from a backup file
|
|
|
|
Upload a backup ZIP file and restore database and files from it.
|
|
|
|
WARNING: This will overwrite the current database with the backup data.
|
|
"""
|
|
try:
|
|
# Validate file type
|
|
if not file.filename or not file.filename.endswith('.zip'):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Only ZIP files are accepted"
|
|
)
|
|
|
|
# Read file content
|
|
file_content = await file.read()
|
|
|
|
# Validate file size (max 5GB)
|
|
max_size = 5 * 1024 * 1024 * 1024 # 5GB
|
|
if len(file_content) > max_size:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"File too large (max {max_size / 1024 / 1024 / 1024}GB)"
|
|
)
|
|
|
|
# Restore from uploaded file
|
|
service = BackupService(db)
|
|
success, message = await service.restore_from_upload(
|
|
file_content=file_content,
|
|
filename=file.filename
|
|
)
|
|
|
|
if success:
|
|
return {"success": True, "message": message}
|
|
else:
|
|
raise HTTPException(status_code=500, detail=message)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Upload restore failed: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.delete("/{backup_id}")
|
|
async def delete_backup(
|
|
backup_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Delete a backup file and record
|
|
|
|
Removes the backup file from disk and deletes the database record.
|
|
"""
|
|
try:
|
|
service = BackupService(db)
|
|
success, message = await service.delete_backup(backup_id)
|
|
|
|
if success:
|
|
return {"success": True, "message": message}
|
|
else:
|
|
raise HTTPException(status_code=500, detail=message)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to delete backup: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|