Forecasting app: hybrid port to HNF stack
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>
This commit is contained in:
commit
75d2c1fa9d
103 changed files with 70316 additions and 0 deletions
303
backend/api/backup.py
Normal file
303
backend/api/backup.py
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
"""
|
||||
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))
|
||||
Loading…
Add table
Add a link
Reference in a new issue