mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-18 04:08:48 -05:00
4a8607f400
- Add Google Calendar integration with OAuth 2.0 support - Implement integration service and workflow engine - Add new routes: auth, clients, custom_reports, integrations, invoices, team_chat - Add utility modules: config_manager, email, excel_export, file_upload, permissions_seed - Add integration view template - Add Docker permission fixes and enhanced start scripts - Add migration management utilities and legacy schema migration - Add validation and version management scripts - Update setup.py version to 4.9.16 This release significantly expands the application's integration capabilities, adds new business logic services, and improves infrastructure tooling.
413 lines
15 KiB
Python
413 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Comprehensive Database Migration Script for TimeTracker
|
|
This script can migrate ANY existing database to the new Flask-Migrate system
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import subprocess
|
|
import shlex
|
|
import sqlite3
|
|
import psycopg2
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
import shutil
|
|
|
|
def run_command(command, description, capture_output=True):
|
|
"""Run a command and handle errors
|
|
|
|
Args:
|
|
command: Command string to run (will be split into list)
|
|
description: Human-readable description of the command
|
|
capture_output: Whether to capture output
|
|
"""
|
|
print(f"\n--- {description} ---")
|
|
print(f"Running: {command}")
|
|
|
|
# Split command into list to avoid shell injection
|
|
try:
|
|
cmd_list = shlex.split(command)
|
|
except ValueError:
|
|
# Fallback to simple split if shlex fails
|
|
cmd_list = command.split()
|
|
|
|
try:
|
|
if capture_output:
|
|
result = subprocess.run(cmd_list, check=True, capture_output=True, text=True)
|
|
print(f"✓ {description} completed successfully")
|
|
if result.stdout:
|
|
print(result.stdout)
|
|
return True
|
|
else:
|
|
subprocess.run(cmd_list, check=True)
|
|
print(f"✓ {description} completed successfully")
|
|
return True
|
|
except subprocess.CalledProcessError as e:
|
|
print(f"✗ {description} failed:")
|
|
print(f"Error code: {e.returncode}")
|
|
if e.stdout:
|
|
print(f"STDOUT: {e.stdout}")
|
|
if e.stderr:
|
|
print(f"STDERR: {e.stderr}")
|
|
return False
|
|
|
|
def check_flask_migrate_installed():
|
|
"""Check if Flask-Migrate is properly installed"""
|
|
try:
|
|
import flask_migrate
|
|
print("✓ Flask-Migrate is installed")
|
|
return True
|
|
except ImportError:
|
|
print("✗ Flask-Migrate is not installed")
|
|
print("Please install it with: pip install Flask-Migrate")
|
|
return False
|
|
|
|
def detect_database_type():
|
|
"""Detect the type of database being used"""
|
|
db_url = os.getenv('DATABASE_URL', '')
|
|
|
|
if db_url.startswith('postgresql'):
|
|
return 'postgresql', db_url
|
|
elif db_url.startswith('sqlite'):
|
|
return 'sqlite', db_url
|
|
else:
|
|
# Check for common database files
|
|
if os.path.exists('instance/timetracker.db'):
|
|
return 'sqlite', 'sqlite:///instance/timetracker.db'
|
|
elif os.path.exists('timetracker.db'):
|
|
return 'sqlite', 'sqlite:///timetracker.db'
|
|
else:
|
|
return 'unknown', None
|
|
|
|
def backup_database(db_type, db_url):
|
|
"""Create a comprehensive backup of the current database"""
|
|
print("\n--- Creating Database Backup ---")
|
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
backup_dir = os.getenv("TT_BACKUP_DIR", "/data/backups")
|
|
try:
|
|
os.makedirs(backup_dir, exist_ok=True)
|
|
except Exception:
|
|
# Fall back to CWD if /data isn't writable
|
|
backup_dir = os.getcwd()
|
|
|
|
if db_type == 'postgresql':
|
|
backup_file = os.path.join(backup_dir, f"backup_postgresql_{timestamp}.dump")
|
|
backup_cmd = ["pg_dump", "--format=custom", f'--dbname={db_url}', f"--file={backup_file}"]
|
|
print(f"PostgreSQL database detected")
|
|
print(f"Running: {' '.join(backup_cmd)}")
|
|
|
|
try:
|
|
subprocess.run(backup_cmd, check=True)
|
|
print(f"✓ Database backed up to: {backup_file}")
|
|
return backup_file
|
|
except subprocess.CalledProcessError as e:
|
|
print(f"✗ PostgreSQL backup failed: {e}")
|
|
print("Please ensure pg_dump is available and you have proper permissions")
|
|
return None
|
|
|
|
elif db_type == 'sqlite':
|
|
# Find the actual database file
|
|
if 'instance/timetracker.db' in db_url:
|
|
db_file = 'instance/timetracker.db'
|
|
elif 'timetracker.db' in db_url:
|
|
db_file = 'timetracker.db'
|
|
else:
|
|
db_file = db_url.replace('sqlite:///', '')
|
|
|
|
if os.path.exists(db_file):
|
|
backup_file = os.path.join(backup_dir, f"backup_sqlite_{timestamp}.db")
|
|
shutil.copy2(db_file, backup_file)
|
|
print(f"✓ SQLite database backed up to: {backup_file}")
|
|
return backup_file
|
|
else:
|
|
print(f"✗ SQLite database file not found: {db_file}")
|
|
return None
|
|
else:
|
|
print("✗ Unknown database type")
|
|
return None
|
|
|
|
def analyze_existing_schema(db_type, db_url):
|
|
"""Analyze the existing database schema to understand what needs to be migrated"""
|
|
print("\n--- Analyzing Existing Database Schema ---")
|
|
|
|
if db_type == 'postgresql':
|
|
try:
|
|
conn = psycopg2.connect(db_url)
|
|
cursor = conn.cursor()
|
|
|
|
# Get list of existing tables
|
|
cursor.execute("""
|
|
SELECT table_name
|
|
FROM information_schema.tables
|
|
WHERE table_schema = 'public'
|
|
ORDER BY table_name
|
|
""")
|
|
existing_tables = [row[0] for row in cursor.fetchall()]
|
|
|
|
# Get table schemas
|
|
table_schemas = {}
|
|
for table in existing_tables:
|
|
cursor.execute(f"""
|
|
SELECT column_name, data_type, is_nullable, column_default
|
|
FROM information_schema.columns
|
|
WHERE table_name = '{table}'
|
|
ORDER BY ordinal_position
|
|
""")
|
|
columns = cursor.fetchall()
|
|
table_schemas[table] = columns
|
|
|
|
conn.close()
|
|
|
|
print(f"✓ Found {len(existing_tables)} existing tables: {existing_tables}")
|
|
return existing_tables, table_schemas
|
|
|
|
except Exception as e:
|
|
print(f"✗ Error analyzing PostgreSQL schema: {e}")
|
|
return [], {}
|
|
|
|
elif db_type == 'sqlite':
|
|
try:
|
|
# Find the actual database file
|
|
if 'instance/timetracker.db' in db_url:
|
|
db_file = 'instance/timetracker.db'
|
|
elif 'timetracker.db' in db_url:
|
|
db_file = 'timetracker.db'
|
|
else:
|
|
db_file = db_url.replace('sqlite:///', '')
|
|
|
|
if not os.path.exists(db_file):
|
|
print(f"✗ SQLite database file not found: {db_file}")
|
|
return [], {}
|
|
|
|
conn = sqlite3.connect(db_file)
|
|
cursor = conn.cursor()
|
|
|
|
# Get list of existing tables
|
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
|
existing_tables = [row[0] for row in cursor.fetchall()]
|
|
|
|
# Get table schemas
|
|
table_schemas = {}
|
|
for table in existing_tables:
|
|
cursor.execute(f"PRAGMA table_info({table})")
|
|
columns = cursor.fetchall()
|
|
table_schemas[table] = columns
|
|
|
|
conn.close()
|
|
|
|
print(f"✓ Found {len(existing_tables)} existing tables: {existing_tables}")
|
|
return existing_tables, table_schemas
|
|
|
|
except Exception as e:
|
|
print(f"✗ Error analyzing SQLite schema: {e}")
|
|
return [], {}
|
|
|
|
return [], {}
|
|
|
|
def create_migration_strategy(existing_tables, table_schemas):
|
|
"""Create a migration strategy based on existing schema"""
|
|
print("\n--- Creating Migration Strategy ---")
|
|
|
|
# Define expected tables and their priority
|
|
expected_tables = [
|
|
'clients', 'users', 'projects', 'tasks', 'time_entries',
|
|
'settings', 'invoices', 'invoice_items'
|
|
]
|
|
|
|
missing_tables = [table for table in expected_tables if table not in existing_tables]
|
|
existing_but_modified = []
|
|
|
|
if missing_tables:
|
|
print(f"Tables to create: {missing_tables}")
|
|
|
|
# Check for schema modifications
|
|
for table in existing_tables:
|
|
if table in expected_tables:
|
|
# This table exists, check if it needs modifications
|
|
print(f"Table '{table}' exists - will preserve data")
|
|
|
|
return missing_tables, existing_but_modified
|
|
|
|
def initialize_flask_migrate():
|
|
"""Initialize Flask-Migrate if not already initialized"""
|
|
migrations_dir = Path("migrations")
|
|
|
|
if not migrations_dir.exists():
|
|
print("Initializing Flask-Migrate...")
|
|
return run_command("flask db init", "Initialize Flask-Migrate")
|
|
else:
|
|
print("✓ Migrations directory already exists")
|
|
return True
|
|
|
|
def create_initial_migration():
|
|
"""Create the initial migration that captures the current state"""
|
|
versions_dir = Path("migrations/versions")
|
|
|
|
if not versions_dir.exists() or not list(versions_dir.glob("*.py")):
|
|
print("Creating initial migration...")
|
|
return run_command("flask db migrate -m 'Initial schema from existing database'", "Create initial migration")
|
|
else:
|
|
print("✓ Initial migration already exists")
|
|
return True
|
|
|
|
def stamp_database_with_current_revision():
|
|
"""Mark the database as being at the current migration revision"""
|
|
print("Stamping database with current migration revision...")
|
|
return run_command("flask db stamp head", "Stamp database with current revision")
|
|
|
|
def apply_migrations():
|
|
"""Apply any pending migrations"""
|
|
return run_command("flask db upgrade", "Apply database migrations")
|
|
|
|
def verify_migration_success():
|
|
"""Verify that the migration was successful"""
|
|
print("\n--- Verifying Migration Success ---")
|
|
|
|
# Check migration status
|
|
print("Current migration status:")
|
|
run_command("flask db current", "Show current migration", capture_output=False)
|
|
|
|
# Check migration history
|
|
print("\nMigration history:")
|
|
run_command("flask db history", "Show migration history", capture_output=False)
|
|
|
|
# Test database connection
|
|
try:
|
|
from app import create_app, db
|
|
app = create_app()
|
|
with app.app_context():
|
|
# Try to query the database
|
|
result = db.session.execute(db.text("SELECT 1"))
|
|
print("✓ Database connection test successful")
|
|
return True
|
|
except Exception as e:
|
|
print(f"✗ Database connection test failed: {e}")
|
|
return False
|
|
|
|
def create_data_migration_script(existing_tables, table_schemas):
|
|
"""Create a data migration script for any existing data"""
|
|
print("\n--- Creating Data Migration Script ---")
|
|
|
|
script_content = """#!/usr/bin/env python3
|
|
\"\"\"
|
|
Data Migration Script for Existing Database
|
|
This script handles data migration from old schema to new schema
|
|
\"\"\"
|
|
|
|
from app import create_app, db
|
|
from app.models import User, Project, TimeEntry, Task, Settings, Invoice, Client
|
|
|
|
def migrate_existing_data():
|
|
\"\"\"Migrate existing data to new schema\"\"\"
|
|
app = create_app()
|
|
|
|
with app.app_context():
|
|
print("Starting data migration...")
|
|
|
|
# Add your data migration logic here
|
|
# Example: Migrate old client names to new client table
|
|
|
|
print("Data migration completed")
|
|
|
|
if __name__ == "__main__":
|
|
migrate_existing_data()
|
|
"""
|
|
|
|
script_path = "migrations/migrate_existing_data.py"
|
|
with open(script_path, 'w') as f:
|
|
f.write(script_content)
|
|
|
|
print(f"✓ Data migration script created: {script_path}")
|
|
return script_path
|
|
|
|
def main():
|
|
"""Main migration function"""
|
|
print("=== TimeTracker Comprehensive Database Migration ===")
|
|
print("This script will migrate ANY existing database to the new Flask-Migrate system")
|
|
|
|
# Check prerequisites
|
|
if not check_flask_migrate_installed():
|
|
sys.exit(1)
|
|
|
|
# Detect database type
|
|
db_type, db_url = detect_database_type()
|
|
if not db_url:
|
|
print("✗ Could not detect database configuration")
|
|
print("Please set DATABASE_URL environment variable or ensure database files exist")
|
|
sys.exit(1)
|
|
|
|
print(f"✓ Detected {db_type} database")
|
|
|
|
# Create backup
|
|
print("\n⚠️ IMPORTANT: Creating database backup before proceeding...")
|
|
backup_file = backup_database(db_type, db_url)
|
|
if not backup_file:
|
|
# Never block in non-interactive environments (like Docker startup).
|
|
continue_on_fail = os.getenv("TT_MIGRATE_CONTINUE_ON_BACKUP_FAILURE", "false").strip().lower() in ("1", "true", "yes")
|
|
if sys.stdin.isatty() and not continue_on_fail:
|
|
response = input("Failed to create backup. Continue anyway? (y/N): ")
|
|
if response.lower() != 'y':
|
|
print("Migration cancelled.")
|
|
sys.exit(1)
|
|
elif continue_on_fail:
|
|
print("⚠ Continuing without backup (TT_MIGRATE_CONTINUE_ON_BACKUP_FAILURE enabled).")
|
|
else:
|
|
print("✗ Backup failed and input is not interactive. Aborting migration.")
|
|
print(" To force continue, set TT_MIGRATE_CONTINUE_ON_BACKUP_FAILURE=true")
|
|
sys.exit(1)
|
|
|
|
# Analyze existing schema
|
|
existing_tables, table_schemas = analyze_existing_schema(db_type, db_url)
|
|
|
|
# Create migration strategy
|
|
missing_tables, modified_tables = create_migration_strategy(existing_tables, table_schemas)
|
|
|
|
# Initialize Flask-Migrate
|
|
if not initialize_flask_migrate():
|
|
print("Failed to initialize Flask-Migrate")
|
|
sys.exit(1)
|
|
|
|
# Create initial migration
|
|
if not create_initial_migration():
|
|
print("Failed to create initial migration")
|
|
sys.exit(1)
|
|
|
|
# Create data migration script if needed
|
|
if existing_tables:
|
|
create_data_migration_script(existing_tables, table_schemas)
|
|
|
|
# Stamp database with current revision
|
|
if not stamp_database_with_current_revision():
|
|
print("Failed to stamp database")
|
|
sys.exit(1)
|
|
|
|
# Apply any pending migrations
|
|
if not apply_migrations():
|
|
print("Failed to apply migrations")
|
|
sys.exit(1)
|
|
|
|
# Verify migration success
|
|
if not verify_migration_success():
|
|
print("Migration verification failed")
|
|
sys.exit(1)
|
|
|
|
# Show final status
|
|
print("\n=== Migration Complete ===")
|
|
print("🎉 Your database has been successfully migrated to Flask-Migrate!")
|
|
|
|
print("\nNext steps:")
|
|
print("1. Test your application to ensure everything works")
|
|
print("2. Review the generated migration files in migrations/versions/")
|
|
print("3. If you have existing data, run the data migration script:")
|
|
print(" python migrations/migrate_existing_data.py")
|
|
print("4. For future schema changes, use:")
|
|
print(" - flask db migrate -m 'Description of changes'")
|
|
print(" - flask db upgrade")
|
|
|
|
print("\nBackup created at:", backup_file)
|
|
print("For more information, see: migrations/README.md")
|
|
|
|
if __name__ == "__main__":
|
|
main()
|