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:
jtricerolph 2026-07-04 18:49:34 +00:00
commit 75d2c1fa9d
103 changed files with 70316 additions and 0 deletions

303
backend/api/backup.py Normal file
View 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))