ajout d'un paneau admin
This commit is contained in:
+121
-129
@@ -1,27 +1,61 @@
|
||||
"""Database backup script for the Team Tryouts application.
|
||||
"""PostgreSQL database backup and restore helpers for the admin panel.
|
||||
|
||||
This module provides a simple backup mechanism for the SQLite database
|
||||
and uploaded contract documents. Designed to be run as a scheduled task
|
||||
(Windows Task Scheduler) or cron job.
|
||||
Unlike the original SQLite-based script, this module works against the
|
||||
PostgreSQL database configured via DATABASE_URL. It shells out to the
|
||||
standard ``pg_dump`` / ``pg_restore`` / ``psql`` command-line utilities.
|
||||
|
||||
Usage:
|
||||
python backup.py
|
||||
|
||||
Configuration via environment variables:
|
||||
BACKUP_DIR: Directory to store backups (default: ./backups)
|
||||
BACKUP_RETENTION_DAYS: Number of days to keep backups (default: 30)
|
||||
DATABASE_URL: PostgreSQL connection string (required for connectivity)
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import re
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Configuration
|
||||
BACKUP_DIR = os.getenv('BACKUP_DIR', os.path.join(os.getcwd(), 'backups'))
|
||||
BACKUP_RETENTION_DAYS = int(os.getenv('BACKUP_RETENTION_DAYS', 30))
|
||||
DATABASE_PATH = os.getenv('DATABASE_PATH', os.path.join(os.getcwd(), 'instance', 'team_tryouts.db'))
|
||||
DOCUMENTS_DIR = os.path.join(os.getcwd(), 'documents')
|
||||
|
||||
|
||||
def _database_url():
|
||||
"""Return the DATABASE_URL, normalizing to the psycopg2 SQLAlchemy form."""
|
||||
url = os.getenv('DATABASE_URL')
|
||||
if not url:
|
||||
raise RuntimeError('DATABASE_URL environment variable must be set')
|
||||
# SQLAlchemy may prefix driver; strip it for command-line tools.
|
||||
return re.sub(r'^postgresql\+[^:]+://', 'postgresql://', url)
|
||||
|
||||
|
||||
def _pg_env():
|
||||
"""Return the dict of PG* variables required by pg_dump / psql."""
|
||||
parsed = urlparse(_database_url())
|
||||
env = os.environ.copy()
|
||||
# PGPASSWORD avoids interactive password prompts.
|
||||
if parsed.password is not None:
|
||||
env['PGPASSWORD'] = parsed.password
|
||||
env.pop('PGHOST', None)
|
||||
env.pop('PGPORT', None)
|
||||
env.pop('PGUSER', None)
|
||||
env.pop('PGDATABASE', None)
|
||||
return env
|
||||
|
||||
|
||||
def _pg_args():
|
||||
"""Return [host, port, user, dbname] positional args for pg tools."""
|
||||
parsed = urlparse(_database_url())
|
||||
args = []
|
||||
if parsed.hostname:
|
||||
args += ['--host', parsed.hostname]
|
||||
if parsed.port:
|
||||
args += ['--port', str(parsed.port)]
|
||||
if parsed.username:
|
||||
args += ['--username', parsed.username]
|
||||
if parsed.path and parsed.path.lstrip('/'):
|
||||
args += ['--dbname', parsed.path.lstrip('/')]
|
||||
return args
|
||||
|
||||
|
||||
def create_backup_dir():
|
||||
@@ -29,155 +63,113 @@ def create_backup_dir():
|
||||
os.makedirs(BACKUP_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def backup_database():
|
||||
"""Backup the SQLite database using sqlite3's built-in backup API.
|
||||
|
||||
def create_backup(backup_type='manual', notes=None):
|
||||
"""Create a PostgreSQL dump backup.
|
||||
|
||||
Returns:
|
||||
str: Path to the created backup file, or None if failed.
|
||||
dict: {'filename', 'file_path', 'size_bytes'} or None on failure.
|
||||
"""
|
||||
if not os.path.exists(DATABASE_PATH):
|
||||
print(f'[WARNING] Database not found at {DATABASE_PATH}. Skipping database backup.')
|
||||
return None
|
||||
|
||||
create_backup_dir()
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
backup_filename = f'db_backup_{timestamp}.db'
|
||||
backup_path = os.path.join(BACKUP_DIR, backup_filename)
|
||||
filename = f'db_backup_{backup_type}_{timestamp}.sql'
|
||||
file_path = os.path.join(BACKUP_DIR, filename)
|
||||
|
||||
cmd = ['pg_dump'] + _pg_args() + ['--format', 'plain', '--no-owner', '--file', file_path]
|
||||
try:
|
||||
source = sqlite3.connect(DATABASE_PATH)
|
||||
destination = sqlite3.connect(backup_path)
|
||||
source.backup(destination)
|
||||
source.close()
|
||||
destination.close()
|
||||
print(f'[OK] Database backed up to: {backup_path}')
|
||||
return backup_path
|
||||
except Exception as e:
|
||||
print(f'[ERROR] Database backup failed: {e}')
|
||||
return None
|
||||
result = subprocess.run(cmd, env=_pg_env(), capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
_cleanup_failed_file(file_path)
|
||||
raise RuntimeError(result.stderr.strip() or 'pg_dump failed')
|
||||
except FileNotFoundError:
|
||||
_cleanup_failed_file(file_path)
|
||||
raise RuntimeError('pg_dump executable not found on PATH')
|
||||
except Exception:
|
||||
_cleanup_failed_file(file_path)
|
||||
raise
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
raise RuntimeError('Backup file was not created')
|
||||
|
||||
size = os.path.getsize(file_path)
|
||||
return {'filename': filename, 'file_path': file_path, 'size_bytes': size}
|
||||
|
||||
|
||||
def backup_documents():
|
||||
"""Backup the uploaded contract documents directory.
|
||||
|
||||
Returns:
|
||||
str: Path to the created archive, or None if no documents exist.
|
||||
def _cleanup_failed_file(file_path):
|
||||
"""Remove a partially-written dump file, ignoring errors."""
|
||||
if file_path and os.path.exists(file_path):
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def restore_backup(file_path):
|
||||
"""Restore a PostgreSQL dump file using psql.
|
||||
|
||||
Args:
|
||||
file_path: Path to a plain-text .sql dump produced by pg_dump.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If psql fails or is unavailable.
|
||||
"""
|
||||
if not os.path.exists(DOCUMENTS_DIR):
|
||||
print('[INFO] No documents directory found. Skipping document backup.')
|
||||
return None
|
||||
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
archive_basename = f'documents_backup_{timestamp}'
|
||||
archive_path = os.path.join(BACKUP_DIR, archive_basename)
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
raise RuntimeError('Backup file does not exist')
|
||||
|
||||
# The dump was produced with --no-owner + plain format, so we can
|
||||
# pipe it into psql. Use --set ON_ERROR_STOP=1 to fail fast.
|
||||
cmd = ['psql'] + _pg_args() + ['--set', 'ON_ERROR_STOP=1', '--file', file_path]
|
||||
try:
|
||||
shutil.make_archive(archive_path, 'zip', DOCUMENTS_DIR)
|
||||
zip_path = f'{archive_path}.zip'
|
||||
print(f'[OK] Documents backed up to: {zip_path}')
|
||||
return zip_path
|
||||
except Exception as e:
|
||||
print(f'[ERROR] Document backup failed: {e}')
|
||||
return None
|
||||
result = subprocess.run(cmd, env=_pg_env(), capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(result.stderr.strip() or 'psql restore failed')
|
||||
except FileNotFoundError:
|
||||
raise RuntimeError('psql executable not found on PATH')
|
||||
|
||||
|
||||
def cleanup_old_backups():
|
||||
"""Remove backup files older than BACKUP_RETENTION_DAYS."""
|
||||
if not os.path.exists(BACKUP_DIR):
|
||||
return
|
||||
return 0
|
||||
|
||||
cutoff = datetime.now() - timedelta(days=BACKUP_RETENTION_DAYS)
|
||||
removed_count = 0
|
||||
|
||||
for filename in os.listdir(BACKUP_DIR):
|
||||
file_path = os.path.join(BACKUP_DIR, filename)
|
||||
if os.path.isfile(file_path):
|
||||
file_time = datetime.fromtimestamp(os.path.getmtime(file_path))
|
||||
if file_time < cutoff:
|
||||
try:
|
||||
os.remove(file_path)
|
||||
removed_count += 1
|
||||
print(f'[CLEANUP] Removed old backup: {filename}')
|
||||
except OSError as e:
|
||||
print(f'[WARNING] Could not remove {filename}: {e}')
|
||||
|
||||
if removed_count > 0:
|
||||
print(f'[CLEANUP] Removed {removed_count} old backup(s).')
|
||||
else:
|
||||
print('[CLEANUP] No old backups to remove.')
|
||||
|
||||
|
||||
def verify_backup(backup_path):
|
||||
"""Verify a database backup by running a quick integrity check.
|
||||
|
||||
Args:
|
||||
backup_path: Path to the backup file to verify.
|
||||
|
||||
Returns:
|
||||
bool: True if backup is valid, False otherwise.
|
||||
"""
|
||||
if not backup_path or not os.path.exists(backup_path):
|
||||
return False
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(backup_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('PRAGMA integrity_check')
|
||||
result = cursor.fetchone()
|
||||
conn.close()
|
||||
is_valid = result[0] == 'ok'
|
||||
if is_valid:
|
||||
print(f'[OK] Backup integrity verified: {backup_path}')
|
||||
else:
|
||||
print(f'[ERROR] Backup integrity check failed: {backup_path} - {result[0]}')
|
||||
return is_valid
|
||||
except Exception as e:
|
||||
print(f'[ERROR] Backup verification failed: {e}')
|
||||
return False
|
||||
if not os.path.isfile(file_path):
|
||||
continue
|
||||
file_time = datetime.fromtimestamp(os.path.getmtime(file_path))
|
||||
if file_time < cutoff:
|
||||
try:
|
||||
os.remove(file_path)
|
||||
removed_count += 1
|
||||
except OSError:
|
||||
pass
|
||||
return removed_count
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the full backup process.
|
||||
|
||||
Steps:
|
||||
1. Create backup directory
|
||||
2. Backup database
|
||||
3. Backup documents (if any)
|
||||
4. Verify database backup
|
||||
5. Clean up old backups
|
||||
|
||||
Returns:
|
||||
int: 0 on success, 1 on failure.
|
||||
"""
|
||||
print(f'=== Team Tryouts Backup ===')
|
||||
"""Run a manual backup from the CLI (for cron/Task Scheduler)."""
|
||||
print('=== Team Tryouts PostgreSQL Backup ===')
|
||||
print(f'Started at: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
|
||||
print(f'Backup directory: {BACKUP_DIR}')
|
||||
print(f'Retention period: {BACKUP_RETENTION_DAYS} days')
|
||||
print()
|
||||
|
||||
create_backup_dir()
|
||||
try:
|
||||
result = create_backup(backup_type='scheduled')
|
||||
except RuntimeError as e:
|
||||
print(f'[ERROR] {e}')
|
||||
return 1
|
||||
|
||||
# 1. Backup database
|
||||
db_backup_path = backup_database()
|
||||
success = True
|
||||
print(f'[OK] Database backed up to: {result["file_path"]}')
|
||||
|
||||
# 2. Verify database backup
|
||||
if db_backup_path:
|
||||
if not verify_backup(db_backup_path):
|
||||
success = False
|
||||
|
||||
# 3. Backup documents
|
||||
backup_documents()
|
||||
|
||||
# 4. Cleanup old backups
|
||||
cleanup_old_backups()
|
||||
removed = cleanup_old_backups()
|
||||
if removed:
|
||||
print(f'[CLEANUP] Removed {removed} old backup(s).')
|
||||
|
||||
print()
|
||||
if success:
|
||||
print('=== Backup completed successfully ===')
|
||||
else:
|
||||
print('=== Backup completed with warnings ===')
|
||||
|
||||
return 0 if success else 1
|
||||
print('=== Backup completed successfully ===')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Reference in New Issue
Block a user