""" Database Backup and Restore Service Handles full database snapshots, restoration, and remote synchronization """ import os import shutil import sqlite3 from datetime import datetime from pathlib import Path from typing import List, Dict, Optional import json class DatabaseBackupService: """Manages database backup operations""" def __init__(self, db_path: str = "./data/seismo_fleet.db", backups_dir: str = "./data/backups"): self.db_path = Path(db_path) self.backups_dir = Path(backups_dir) self.backups_dir.mkdir(parents=True, exist_ok=True) def create_snapshot(self, description: Optional[str] = None) -> Dict: """ Create a full database snapshot using SQLite backup API Returns snapshot metadata """ if not self.db_path.exists(): raise FileNotFoundError(f"Database not found at {self.db_path}") # Generate snapshot filename with timestamp timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") snapshot_name = f"snapshot_{timestamp}.db" snapshot_path = self.backups_dir / snapshot_name # Get database size before backup db_size = self.db_path.stat().st_size try: # Use SQLite backup API for safe backup (handles concurrent access) source_conn = sqlite3.connect(str(self.db_path)) dest_conn = sqlite3.connect(str(snapshot_path)) # Perform the backup with dest_conn: source_conn.backup(dest_conn) source_conn.close() dest_conn.close() # Create metadata metadata = { "filename": snapshot_name, "created_at": timestamp, "created_at_iso": datetime.utcnow().isoformat(), "description": description or "Manual snapshot", "size_bytes": snapshot_path.stat().st_size, "size_mb": round(snapshot_path.stat().st_size / (1024 * 1024), 2), "original_db_size_bytes": db_size, "type": "manual" } # Save metadata as JSON sidecar file metadata_path = self.backups_dir / f"{snapshot_name}.meta.json" with open(metadata_path, 'w') as f: json.dump(metadata, f, indent=2) return metadata except Exception as e: # Clean up partial snapshot if it exists if snapshot_path.exists(): snapshot_path.unlink() raise Exception(f"Snapshot creation failed: {str(e)}") def list_snapshots(self) -> List[Dict]: """ List all available snapshots with metadata Returns list sorted by creation date (newest first) """ snapshots = [] for db_file in sorted(self.backups_dir.glob("snapshot_*.db"), reverse=True): metadata_file = self.backups_dir / f"{db_file.name}.meta.json" if metadata_file.exists(): with open(metadata_file, 'r') as f: metadata = json.load(f) else: # Fallback for legacy snapshots without metadata stat_info = db_file.stat() metadata = { "filename": db_file.name, "created_at": datetime.fromtimestamp(stat_info.st_mtime).strftime("%Y%m%d_%H%M%S"), "created_at_iso": datetime.fromtimestamp(stat_info.st_mtime).isoformat(), "description": "Legacy snapshot", "size_bytes": stat_info.st_size, "size_mb": round(stat_info.st_size / (1024 * 1024), 2), "type": "manual" } snapshots.append(metadata) return snapshots def delete_snapshot(self, filename: str) -> bool: """Delete a snapshot and its metadata""" snapshot_path = self.backups_dir / filename metadata_path = self.backups_dir / f"{filename}.meta.json" if not snapshot_path.exists(): raise FileNotFoundError(f"Snapshot {filename} not found") snapshot_path.unlink() if metadata_path.exists(): metadata_path.unlink() return True def restore_snapshot(self, filename: str, create_backup_before_restore: bool = True) -> Dict: """ Restore database from a snapshot Creates a safety backup before restoring if requested """ snapshot_path = self.backups_dir / filename if not snapshot_path.exists(): raise FileNotFoundError(f"Snapshot {filename} not found") if not self.db_path.exists(): raise FileNotFoundError(f"Database not found at {self.db_path}") backup_info = None # Create safety backup before restore if create_backup_before_restore: backup_info = self.create_snapshot(description="Auto-backup before restore") try: # Replace database file shutil.copy2(str(snapshot_path), str(self.db_path)) return { "message": "Database restored successfully", "restored_from": filename, "restored_at": datetime.utcnow().isoformat(), "backup_created": backup_info["filename"] if backup_info else None } except Exception as e: raise Exception(f"Restore failed: {str(e)}") def get_database_stats(self) -> Dict: """Get statistics about the current database""" if not self.db_path.exists(): return {"error": "Database not found"} conn = sqlite3.connect(str(self.db_path)) cursor = conn.cursor() # Get table counts cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") tables = cursor.fetchall() table_stats = {} total_rows = 0 for (table_name,) in tables: cursor.execute(f"SELECT COUNT(*) FROM {table_name}") count = cursor.fetchone()[0] table_stats[table_name] = count total_rows += count conn.close() db_size = self.db_path.stat().st_size return { "database_path": str(self.db_path), "size_bytes": db_size, "size_mb": round(db_size / (1024 * 1024), 2), "total_rows": total_rows, "tables": table_stats, "last_modified": datetime.fromtimestamp(self.db_path.stat().st_mtime).isoformat() } def download_snapshot(self, filename: str) -> Path: """Get the file path for downloading a snapshot""" snapshot_path = self.backups_dir / filename if not snapshot_path.exists(): raise FileNotFoundError(f"Snapshot {filename} not found") return snapshot_path