Files
TimeTracker/app/services/backup_service.py
T
Dries Peeters b4486a627f fix: CI tests, code quality, and duplicate DB indexes
- Webhook models: remove duplicate index definitions so db.create_all()
  no longer raises 'index already exists' (columns already have index=True)
- ImportService: fix circular import by late-importing ClientService,
  ProjectService, TimeTrackingService in __init__
- reports: fix F823 by renaming unpack variable _ to _entry_count to avoid
  shadowing gettext _ in export_task_excel()
- Code quality: add .flake8 with extend-ignore so flake8 CI passes;
  simplify pyproject.toml isort config (drop unsupported options)
- Format: run black and isort on app/
- tests: restore minimal app fixture in test_import_export_models
2026-03-15 10:51:52 +01:00

145 lines
4.7 KiB
Python

"""
Service for backup operations.
"""
import os
import shutil
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from flask import current_app
from sqlalchemy import text
from app import db
class BackupService:
"""Service for backup operations"""
def __init__(self):
self.backup_dir = os.path.join(current_app.config.get("UPLOAD_FOLDER", "/data"), "backups")
os.makedirs(self.backup_dir, exist_ok=True)
def create_database_backup(self, backup_name: Optional[str] = None) -> Dict[str, Any]:
"""
Create a database backup.
Returns:
dict with 'success', 'message', and 'backup_path' keys
"""
try:
# Generate backup filename
if not backup_name:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_name = f"timetracker_backup_{timestamp}.sql"
backup_path = os.path.join(self.backup_dir, backup_name)
# Get database URL
db_url = current_app.config.get("SQLALCHEMY_DATABASE_URI", "")
# PostgreSQL backup using pg_dump
if "postgresql" in db_url:
import subprocess
from urllib.parse import urlparse
parsed = urlparse(db_url.replace("postgresql+psycopg2://", "postgresql://"))
cmd = [
"pg_dump",
"-h",
parsed.hostname or "localhost",
"-p",
str(parsed.port or 5432),
"-U",
parsed.username or "timetracker",
"-d",
parsed.path.lstrip("/") or "timetracker",
"-f",
backup_path,
"--no-password", # Use .pgpass file
]
# Set password via environment
env = os.environ.copy()
if parsed.password:
env["PGPASSWORD"] = parsed.password
result = subprocess.run(cmd, env=env, capture_output=True, text=True)
if result.returncode != 0:
return {"success": False, "message": f"Backup failed: {result.stderr}", "error": "backup_failed"}
# SQLite backup
elif "sqlite" in db_url:
db_path = db_url.replace("sqlite:///", "")
shutil.copy2(db_path, backup_path)
else:
return {"success": False, "message": "Unsupported database type", "error": "unsupported_db"}
# Get backup size
backup_size = os.path.getsize(backup_path)
return {
"success": True,
"message": "Backup created successfully",
"backup_path": backup_path,
"backup_size": backup_size,
"backup_name": backup_name,
}
except Exception as e:
current_app.logger.error(f"Backup failed: {e}")
return {"success": False, "message": f"Backup failed: {str(e)}", "error": "backup_error"}
def list_backups(self) -> List[Dict[str, Any]]:
"""
List all available backups.
Returns:
List of backup information dicts
"""
backups = []
if not os.path.exists(self.backup_dir):
return backups
for filename in os.listdir(self.backup_dir):
if filename.endswith(".sql") or filename.endswith(".db"):
filepath = os.path.join(self.backup_dir, filename)
stat = os.stat(filepath)
backups.append(
{
"name": filename,
"path": filepath,
"size": stat.st_size,
"created": datetime.fromtimestamp(stat.st_mtime).isoformat(),
}
)
# Sort by creation time (newest first)
backups.sort(key=lambda x: x["created"], reverse=True)
return backups
def delete_backup(self, backup_name: str) -> Dict[str, Any]:
"""
Delete a backup file.
Returns:
dict with 'success' and 'message' keys
"""
backup_path = os.path.join(self.backup_dir, backup_name)
if not os.path.exists(backup_path):
return {"success": False, "message": "Backup not found", "error": "not_found"}
try:
os.remove(backup_path)
return {"success": True, "message": "Backup deleted successfully"}
except Exception as e:
return {"success": False, "message": f"Failed to delete backup: {str(e)}", "error": "delete_error"}