Files
TimeTrack/migrations/postgres_only_migration.py
Jens Luedicke 9a79778ad6 Squashed commit of the following:
commit 1eeea9f83ad9230a5c1f7a75662770eaab0df837
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 21:15:41 2025 +0200

    Disable resuming of old time entries.

commit 3e3ec2f01cb7943622b819a19179388078ae1315
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 20:59:19 2025 +0200

    Refactor db migrations.

commit 15a51a569da36c6b7c9e01ab17b6fdbdee6ad994
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 19:58:04 2025 +0200

    Apply new style for Time Tracking view.

commit 77e5278b303e060d2b03853b06277f8aa567ae68
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 18:06:04 2025 +0200

    Allow direct registrations as a Company.

commit 188a8772757cbef374243d3a5f29e4440ddecabe
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 18:04:45 2025 +0200

    Add email invitation feature.

commit d9ebaa02aa01b518960a20dccdd5a327d82f30c6
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 17:12:32 2025 +0200

    Apply common style for Company, User, Team management pages.

commit 81149caf4d8fc6317e2ab1b4f022b32fc5aa6d22
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 16:44:32 2025 +0200

    Move export functions to own module.

commit 1a26e19338e73f8849c671471dd15cc3c1b1fe82
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 15:51:15 2025 +0200

    Split up models.py.

commit 61f1ccd10f721b0ff4dc1eccf30c7a1ee13f204d
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 12:05:28 2025 +0200

    Move utility function into own modules.

commit 84b341ed35e2c5387819a8b9f9d41eca900ae79f
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 11:44:24 2025 +0200

    Refactor auth functions use.

commit 923e311e3da5b26d85845c2832b73b7b17c48adb
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 11:35:52 2025 +0200

    Refactor route nameing and fix bugs along the way.

commit f0a5c4419c340e62a2615c60b2a9de28204d2995
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 10:34:33 2025 +0200

    Fix URL endpoints in announcement template.

commit b74d74542a1c8dc350749e4788a9464d067a88b5
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 09:25:53 2025 +0200

    Move announcements to own module.

commit 9563a28021ac46c82c04fe4649b394dbf96f92c7
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 09:16:30 2025 +0200

    Combine Company view and edit templates.

commit 6687c373e681d54e4deab6b2582fed5cea9aadf6
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 08:17:42 2025 +0200

    Move Users, Company and System Administration to own modules.

commit 8b7894a2e3eb84bb059f546648b6b9536fea724e
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 07:40:57 2025 +0200

    Move Teams and Projects to own modules.

commit d11bf059d99839ecf1f5d7020b8c8c8a2454c00b
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 07:09:33 2025 +0200

    Move Tasks and Sprints to own modules.
2025-07-07 21:16:36 +02:00

327 lines
13 KiB
Python
Executable File

#!/usr/bin/env python3
"""
PostgreSQL-only migration script for TimeTrack
Applies all schema changes from commit 4214e88 onward
"""
import os
import sys
import psycopg2
from psycopg2.extras import RealDictCursor
import logging
from datetime import datetime
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
class PostgresMigration:
def __init__(self, database_url):
self.database_url = database_url
self.conn = None
def connect(self):
"""Connect to PostgreSQL database"""
try:
self.conn = psycopg2.connect(self.database_url)
self.conn.autocommit = False
logger.info("Connected to PostgreSQL database")
return True
except Exception as e:
logger.error(f"Failed to connect to database: {e}")
return False
def close(self):
"""Close database connection"""
if self.conn:
self.conn.close()
def execute_migration(self, name, sql_statements):
"""Execute a migration with proper error handling"""
logger.info(f"Running migration: {name}")
cursor = self.conn.cursor()
try:
for statement in sql_statements:
if statement.strip():
cursor.execute(statement)
self.conn.commit()
logger.info(f"{name} completed successfully")
return True
except Exception as e:
self.conn.rollback()
logger.error(f"{name} failed: {e}")
return False
finally:
cursor.close()
def check_column_exists(self, table_name, column_name):
"""Check if a column exists in a table"""
cursor = self.conn.cursor()
cursor.execute("""
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = %s AND column_name = %s
)
""", (table_name, column_name))
exists = cursor.fetchone()[0]
cursor.close()
return exists
def check_table_exists(self, table_name):
"""Check if a table exists"""
cursor = self.conn.cursor()
cursor.execute("""
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = %s
)
""", (table_name,))
exists = cursor.fetchone()[0]
cursor.close()
return exists
def check_enum_value_exists(self, enum_name, value):
"""Check if an enum value exists"""
cursor = self.conn.cursor()
cursor.execute("""
SELECT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = %s
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = %s)
)
""", (value, enum_name))
exists = cursor.fetchone()[0]
cursor.close()
return exists
def run_all_migrations(self):
"""Run all migrations in order"""
if not self.connect():
return False
success = True
# 1. Add company.updated_at
if not self.check_column_exists('company', 'updated_at'):
success &= self.execute_migration("Add company.updated_at", [
"""
ALTER TABLE company
ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
""",
"""
UPDATE company SET updated_at = created_at WHERE updated_at IS NULL;
"""
])
# 2. Add user columns for 2FA and avatar
if not self.check_column_exists('user', 'two_factor_enabled'):
success &= self.execute_migration("Add user 2FA and avatar columns", [
"""
ALTER TABLE "user"
ADD COLUMN two_factor_enabled BOOLEAN DEFAULT FALSE,
ADD COLUMN two_factor_secret VARCHAR(32),
ADD COLUMN avatar_url VARCHAR(255);
"""
])
# 3. Create company_invitation table
if not self.check_table_exists('company_invitation'):
success &= self.execute_migration("Create company_invitation table", [
"""
CREATE TABLE company_invitation (
id SERIAL PRIMARY KEY,
company_id INTEGER NOT NULL REFERENCES company(id),
email VARCHAR(255) NOT NULL,
role VARCHAR(50) NOT NULL,
token VARCHAR(255) UNIQUE NOT NULL,
invited_by_id INTEGER REFERENCES "user"(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP,
used_by_id INTEGER REFERENCES "user"(id)
);
""",
"""
CREATE INDEX idx_invitation_token ON company_invitation(token);
""",
"""
CREATE INDEX idx_invitation_company ON company_invitation(company_id);
""",
"""
CREATE INDEX idx_invitation_email ON company_invitation(email);
"""
])
# 4. Add user_preferences columns
if self.check_table_exists('user_preferences'):
columns_to_add = [
('theme', 'VARCHAR(20) DEFAULT \'light\''),
('language', 'VARCHAR(10) DEFAULT \'en\''),
('timezone', 'VARCHAR(50) DEFAULT \'UTC\''),
('date_format', 'VARCHAR(20) DEFAULT \'YYYY-MM-DD\''),
('time_format', 'VARCHAR(10) DEFAULT \'24h\''),
('week_start', 'INTEGER DEFAULT 1'),
('show_weekends', 'BOOLEAN DEFAULT TRUE'),
('compact_mode', 'BOOLEAN DEFAULT FALSE'),
('email_notifications', 'BOOLEAN DEFAULT TRUE'),
('push_notifications', 'BOOLEAN DEFAULT FALSE'),
('task_reminders', 'BOOLEAN DEFAULT TRUE'),
('daily_summary', 'BOOLEAN DEFAULT FALSE'),
('weekly_report', 'BOOLEAN DEFAULT TRUE'),
('mention_notifications', 'BOOLEAN DEFAULT TRUE'),
('task_assigned_notifications', 'BOOLEAN DEFAULT TRUE'),
('task_completed_notifications', 'BOOLEAN DEFAULT FALSE'),
('sound_enabled', 'BOOLEAN DEFAULT TRUE'),
('keyboard_shortcuts', 'BOOLEAN DEFAULT TRUE'),
('auto_start_timer', 'BOOLEAN DEFAULT FALSE'),
('idle_time_detection', 'BOOLEAN DEFAULT TRUE'),
('pomodoro_enabled', 'BOOLEAN DEFAULT FALSE'),
('pomodoro_duration', 'INTEGER DEFAULT 25'),
('pomodoro_break', 'INTEGER DEFAULT 5')
]
for col_name, col_def in columns_to_add:
if not self.check_column_exists('user_preferences', col_name):
success &= self.execute_migration(f"Add user_preferences.{col_name}", [
f'ALTER TABLE user_preferences ADD COLUMN {col_name} {col_def};'
])
# 5. Add user_dashboard columns
if self.check_table_exists('user_dashboard'):
if not self.check_column_exists('user_dashboard', 'layout'):
success &= self.execute_migration("Add user_dashboard layout columns", [
"""
ALTER TABLE user_dashboard
ADD COLUMN layout JSON DEFAULT '{}',
ADD COLUMN is_locked BOOLEAN DEFAULT FALSE;
"""
])
# 6. Add company_work_config columns
if self.check_table_exists('company_work_config'):
columns_to_add = [
('standard_hours_per_day', 'FLOAT DEFAULT 8.0'),
('standard_hours_per_week', 'FLOAT DEFAULT 40.0'),
('overtime_rate', 'FLOAT DEFAULT 1.5'),
('double_time_enabled', 'BOOLEAN DEFAULT FALSE'),
('double_time_threshold', 'FLOAT DEFAULT 12.0'),
('double_time_rate', 'FLOAT DEFAULT 2.0'),
('weekly_overtime_threshold', 'FLOAT DEFAULT 40.0'),
('weekly_overtime_rate', 'FLOAT DEFAULT 1.5'),
('created_at', 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP'),
('updated_at', 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP')
]
for col_name, col_def in columns_to_add:
if not self.check_column_exists('company_work_config', col_name):
success &= self.execute_migration(f"Add company_work_config.{col_name}", [
f'ALTER TABLE company_work_config ADD COLUMN {col_name} {col_def};'
])
# 7. Add company_settings columns
if self.check_table_exists('company_settings'):
columns_to_add = [
('work_week_start', 'INTEGER DEFAULT 1'),
('work_days', 'VARCHAR(20) DEFAULT \'1,2,3,4,5\''),
('time_tracking_mode', 'VARCHAR(20) DEFAULT \'flexible\''),
('allow_manual_time', 'BOOLEAN DEFAULT TRUE'),
('require_project_selection', 'BOOLEAN DEFAULT TRUE'),
('allow_future_entries', 'BOOLEAN DEFAULT FALSE'),
('max_hours_per_entry', 'FLOAT DEFAULT 24.0'),
('min_hours_per_entry', 'FLOAT DEFAULT 0.0'),
('round_time_to', 'INTEGER DEFAULT 1'),
('auto_break_deduction', 'BOOLEAN DEFAULT FALSE'),
('allow_overlapping_entries', 'BOOLEAN DEFAULT FALSE'),
('require_daily_notes', 'BOOLEAN DEFAULT FALSE'),
('enable_tasks', 'BOOLEAN DEFAULT TRUE'),
('enable_projects', 'BOOLEAN DEFAULT TRUE'),
('enable_teams', 'BOOLEAN DEFAULT TRUE'),
('enable_reports', 'BOOLEAN DEFAULT TRUE'),
('enable_invoicing', 'BOOLEAN DEFAULT FALSE'),
('enable_client_access', 'BOOLEAN DEFAULT FALSE'),
('default_currency', 'VARCHAR(3) DEFAULT \'USD\''),
('created_at', 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP'),
('updated_at', 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP')
]
for col_name, col_def in columns_to_add:
if not self.check_column_exists('company_settings', col_name):
success &= self.execute_migration(f"Add company_settings.{col_name}", [
f'ALTER TABLE company_settings ADD COLUMN {col_name} {col_def};'
])
# 8. Add dashboard_widget columns
if self.check_table_exists('dashboard_widget'):
if not self.check_column_exists('dashboard_widget', 'config'):
success &= self.execute_migration("Add dashboard_widget config columns", [
"""
ALTER TABLE dashboard_widget
ADD COLUMN config JSON DEFAULT '{}',
ADD COLUMN is_visible BOOLEAN DEFAULT TRUE;
"""
])
# 9. Update WorkRegion enum
if not self.check_enum_value_exists('workregion', 'GERMANY'):
success &= self.execute_migration("Add GERMANY to WorkRegion enum", [
"""
ALTER TYPE workregion ADD VALUE IF NOT EXISTS 'GERMANY';
"""
])
# 10. Update TaskStatus enum
if not self.check_enum_value_exists('taskstatus', 'ARCHIVED'):
success &= self.execute_migration("Add ARCHIVED to TaskStatus enum", [
"""
ALTER TYPE taskstatus ADD VALUE IF NOT EXISTS 'ARCHIVED';
"""
])
# 11. Update WidgetType enum
widget_types_to_add = [
'REVENUE_CHART', 'EXPENSE_CHART', 'PROFIT_CHART', 'CASH_FLOW',
'INVOICE_STATUS', 'CLIENT_LIST', 'PROJECT_BUDGET', 'TEAM_CAPACITY',
'SPRINT_BURNDOWN', 'VELOCITY_CHART', 'BACKLOG_STATUS', 'RELEASE_TIMELINE',
'CODE_COMMITS', 'BUILD_STATUS', 'DEPLOYMENT_HISTORY', 'ERROR_RATE',
'SYSTEM_HEALTH', 'USER_ACTIVITY', 'SECURITY_ALERTS', 'AUDIT_LOG'
]
for widget_type in widget_types_to_add:
if not self.check_enum_value_exists('widgettype', widget_type):
success &= self.execute_migration(f"Add {widget_type} to WidgetType enum", [
f"ALTER TYPE widgettype ADD VALUE IF NOT EXISTS '{widget_type}';"
])
self.close()
if success:
logger.info("\n✅ All migrations completed successfully!")
else:
logger.error("\n❌ Some migrations failed. Check the logs above.")
return success
def main():
"""Main migration function"""
# Get database URL from environment
database_url = os.environ.get('DATABASE_URL')
if not database_url:
logger.error("DATABASE_URL environment variable not set")
return 1
# Run migrations
migration = PostgresMigration(database_url)
success = migration.run_all_migrations()
return 0 if success else 1
if __name__ == "__main__":
sys.exit(main())