mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-08 05:19:48 -05:00
b4486a627f
- 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
145 lines
4.7 KiB
Python
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"}
|