Files
TimeTracker/scripts/verify_and_fix_schema.py
Dries Peeters 7791e6ada0 feat: Add comprehensive issue/bug tracking system
Implement a complete issue management system with client portal integration
and internal admin interface for tracking and resolving client-reported issues.

Features:
- New Issue model with full lifecycle management (open, in_progress, resolved, closed, cancelled)
- Priority levels (low, medium, high, urgent) with visual indicators
- Issue linking to projects and tasks
- Create tasks directly from issues
- Client portal integration for issue reporting and viewing
- Internal admin routes for issue management, filtering, and assignment
- Comprehensive templates for both client and admin views
- Status filtering and search functionality
- Issue assignment to internal users
- Automatic timestamp tracking (created, updated, resolved, closed)

Client Portal:
- Clients can report new issues with project association
- View all issues with status filtering
- View individual issue details
- Submit issues with optional submitter name/email

Admin Interface:
- List all issues with advanced filtering (status, priority, client, project, assignee, search)
- View, edit, and delete issues
- Link issues to existing tasks
- Create tasks from issues
- Update issue status, priority, and assignment
- Issue statistics dashboard

Technical:
- Added Issue model with relationships to Client, Project, Task, and User
- New issues blueprint for internal management
- Extended client_portal routes with issue endpoints
- Updated model imports and relationships
- Added navigation links in base templates
- Version bump to 4.6.0
- Code cleanup in docker scripts and schema verification
2025-12-14 07:25:42 +01:00

255 lines
9.2 KiB
Python

#!/usr/bin/env python3
"""
Comprehensive schema verification and fix script.
This script checks all SQLAlchemy models against the actual database schema
and adds any missing columns based on the model definitions.
Usage:
python scripts/verify_and_fix_schema.py
"""
import os
import sys
from sqlalchemy import create_engine, inspect, text, MetaData
from sqlalchemy.exc import OperationalError
from sqlalchemy.schema import CreateTable
from sqlalchemy.dialects import postgresql, sqlite
# Add parent directory to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def get_sqlalchemy_type(column_type, dialect):
"""Convert SQLAlchemy column type to SQL string for the given dialect"""
if dialect == 'postgresql':
return str(column_type.compile(dialect=postgresql.dialect()))
else:
return str(column_type.compile(dialect=sqlite.dialect()))
def get_column_default(column, dialect):
"""Get the default value for a column as SQL string"""
if column.default is None:
return None
# Handle server defaults (like server_default=text("CURRENT_TIMESTAMP"))
if hasattr(column, 'server_default') and column.server_default is not None:
if hasattr(column.server_default, 'arg'):
default_text = str(column.server_default.arg)
# Remove quotes if it's a function call
if default_text.startswith("'") and default_text.endswith("'"):
default_text = default_text[1:-1]
return default_text
# Handle Python defaults
if hasattr(column.default, 'arg'):
default_arg = column.default.arg
if isinstance(default_arg, str):
# Escape single quotes in strings
escaped = default_arg.replace("'", "''")
return f"'{escaped}'"
elif isinstance(default_arg, (int, float)):
return str(default_arg)
elif isinstance(default_arg, bool):
return 'true' if default_arg else 'false'
elif callable(default_arg):
# For callable defaults like datetime.utcnow, skip default
# The database will handle NULL values
return None
elif hasattr(column.default, 'text'):
return column.default.text
return None
def has_column(inspector, table_name, column_name):
"""Check if a column exists in a table"""
try:
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
except Exception:
return False
def add_column_sql(table_name, column, dialect):
"""Generate SQL to add a column"""
col_type = get_sqlalchemy_type(column.type, dialect)
nullable = "NULL" if column.nullable else "NOT NULL"
default = get_column_default(column, dialect)
# Build SQL statement
sql_parts = [f"ALTER TABLE {table_name} ADD COLUMN {column.name} {col_type}"]
# Add default if specified
if default is not None:
sql_parts.append(f"DEFAULT {default}")
# Add nullable constraint
sql_parts.append(nullable)
return " ".join(sql_parts)
def verify_and_fix_table(engine, inspector, model_class, dialect):
"""Verify and fix columns for a single table"""
table_name = model_class.__tablename__
# Check if table exists
if table_name not in inspector.get_table_names():
print(f"⚠ Table '{table_name}' does not exist, creating it...")
try:
# Create the table
model_class.__table__.create(engine, checkfirst=True)
print(f" ✓ Created table '{table_name}'")
except Exception as e:
print(f" ✗ Failed to create table '{table_name}': {e}")
return 0
# Get expected columns from model
expected_columns = {}
for column in model_class.__table__.columns:
expected_columns[column.name] = column
# Get actual columns from database
try:
actual_columns = {col['name']: col for col in inspector.get_columns(table_name)}
except Exception as e:
print(f"✗ Error inspecting table '{table_name}': {e}")
return 0
# Find missing columns
missing_columns = []
for col_name, col_def in expected_columns.items():
if col_name not in actual_columns:
missing_columns.append((col_name, col_def))
if not missing_columns:
return 0
# Add missing columns
added_count = 0
with engine.begin() as conn: # Use begin() for automatic transaction management
for col_name, col_def in missing_columns:
try:
sql = add_column_sql(table_name, col_def, dialect)
# Execute with explicit transaction
conn.execute(text(sql))
print(f" ✓ Added column '{col_name}' to '{table_name}'")
added_count += 1
except Exception as e:
# Log error but continue with other columns
error_msg = str(e)
# Don't fail on "column already exists" errors (race condition)
if "already exists" not in error_msg.lower() and "duplicate" not in error_msg.lower():
print(f" ✗ Failed to add column '{col_name}' to '{table_name}': {error_msg}")
else:
print(f" ⚠ Column '{col_name}' already exists in '{table_name}' (skipping)")
return added_count
def main():
"""Main function to verify and fix database schema"""
print("=" * 70)
print("TimeTracker - Comprehensive Schema Verification and Fix")
print("=" * 70)
print()
# Get database URL from environment
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+psycopg2://timetracker:timetracker@localhost:5432/timetracker")
try:
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
# Test connection
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
print("✓ Database connection successful")
# Detect database dialect
dialect = engine.dialect.name
print(f"✓ Database dialect: {dialect}")
print()
# Create inspector
inspector = inspect(engine)
# Import all models
print("Loading SQLAlchemy models...")
try:
from app import create_app
app = create_app()
with app.app_context():
# Import all models dynamically from app.models
from app.models import __all__ as model_names
import app.models as models_module
# Get all model classes
models = []
for name in model_names:
try:
model_class = getattr(models_module, name)
if hasattr(model_class, '__tablename__'):
models.append(model_class)
except AttributeError:
pass
# Also get any models that might not be in __all__
# This ensures we catch everything
for attr_name in dir(models_module):
if not attr_name.startswith('_'):
attr = getattr(models_module, attr_name)
if (hasattr(attr, '__tablename__') and
hasattr(attr, '__table__') and
attr not in models):
models.append(attr)
print(f"✓ Loaded {len(models)} model classes")
print()
print("Verifying database schema...")
print()
total_added = 0
tables_checked = 0
for model in models:
if hasattr(model, '__tablename__'):
tables_checked += 1
added = verify_and_fix_table(engine, inspector, model, dialect)
total_added += added
if added > 0:
print(f" → Fixed {added} column(s) in '{model.__tablename__}'")
print()
print("=" * 70)
print(f"✓ Schema verification complete")
print(f" - Tables checked: {tables_checked}")
print(f" - Columns added: {total_added}")
print("=" * 70)
return 0 if total_added == 0 else 0 # Return 0 even if columns were added (success)
except ImportError as e:
print(f"✗ Error importing models: {e}")
print(" This script must be run from the application root directory")
return 1
except Exception as e:
print(f"✗ Error during schema verification: {e}")
import traceback
traceback.print_exc()
return 1
except OperationalError as e:
print(f"✗ Database connection error: {e}")
print(" Please check your DATABASE_URL environment variable")
return 1
except Exception as e:
print(f"✗ Unexpected error: {e}")
import traceback
traceback.print_exc()
return 1
if __name__ == "__main__":
sys.exit(main())