From dcc4f8776947b81fa5da5c77c08bab14af6d8a2f Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Fri, 2 Jan 2026 17:12:18 +0100 Subject: [PATCH 1/2] fix: make migration 075 idempotent for existing link_templates Skip creating link_templates when it already exists and only create missing indexes, so partially-migrated customer databases can continue upgrading. --- ...client_custom_fields_and_link_templates.py | 74 +++++++++++++------ 1 file changed, 52 insertions(+), 22 deletions(-) diff --git a/migrations/versions/075_add_client_custom_fields_and_link_templates.py b/migrations/versions/075_add_client_custom_fields_and_link_templates.py index f16248e..32e9c14 100644 --- a/migrations/versions/075_add_client_custom_fields_and_link_templates.py +++ b/migrations/versions/075_add_client_custom_fields_and_link_templates.py @@ -27,6 +27,22 @@ def _has_column(inspector, table_name: str, column_name: str) -> bool: return False +def _has_table(inspector, table_name: str) -> bool: + """Check if a table exists""" + try: + return table_name in inspector.get_table_names() + except Exception: + return False + + +def _has_index(inspector, table_name: str, index_name: str) -> bool: + """Check if an index exists on a table""" + try: + return any((idx.get("name") or "") == index_name for idx in inspector.get_indexes(table_name)) + except Exception: + return False + + def upgrade(): """Add custom_fields to clients and create link_templates table""" bind = op.get_bind() @@ -35,30 +51,44 @@ def upgrade(): bool_true_default = '1' if dialect_name == 'sqlite' else ('true' if dialect_name == 'postgresql' else '1') # Add custom_fields column to clients table if it doesn't exist - if 'clients' in inspector.get_table_names(): + if _has_table(inspector, 'clients'): if not _has_column(inspector, 'clients', 'custom_fields'): # Use portable JSON type for cross-db compatibility (SQLite + PostgreSQL). op.add_column('clients', sa.Column('custom_fields', sa.JSON(), nullable=True)) - # Create link_templates table - op.create_table( - 'link_templates', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(length=200), nullable=False), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('url_template', sa.String(length=1000), nullable=False), - sa.Column('icon', sa.String(length=50), nullable=True), - sa.Column('field_key', sa.String(length=100), nullable=False), - sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text(bool_true_default)), - sa.Column('order', sa.Integer(), nullable=False, server_default='0'), - sa.Column('created_by', sa.Integer(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['created_by'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('idx_link_templates_is_active', 'link_templates', ['is_active']) - op.create_index('idx_link_templates_field_key', 'link_templates', ['field_key']) + # Create link_templates table (idempotent; some installs may already have it) + if not _has_table(inspector, 'link_templates'): + op.create_table( + 'link_templates', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('url_template', sa.String(length=1000), nullable=False), + sa.Column('icon', sa.String(length=50), nullable=True), + sa.Column('field_key', sa.String(length=100), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text(bool_true_default)), + sa.Column('order', sa.Integer(), nullable=False, server_default='0'), + sa.Column('created_by', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['created_by'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + else: + print("[Migration 075] ℹ Table link_templates already exists, skipping creation") + + # Ensure indexes exist (best-effort / idempotent) + if _has_table(inspector, 'link_templates'): + if not _has_index(inspector, 'link_templates', 'idx_link_templates_is_active'): + try: + op.create_index('idx_link_templates_is_active', 'link_templates', ['is_active']) + except Exception: + pass + if not _has_index(inspector, 'link_templates', 'idx_link_templates_field_key'): + try: + op.create_index('idx_link_templates_field_key', 'link_templates', ['field_key']) + except Exception: + pass def downgrade(): @@ -67,13 +97,13 @@ def downgrade(): inspector = sa.inspect(bind) # Drop link_templates table - if 'link_templates' in inspector.get_table_names(): + if _has_table(inspector, 'link_templates'): op.drop_index('idx_link_templates_field_key', table_name='link_templates') op.drop_index('idx_link_templates_is_active', table_name='link_templates') op.drop_table('link_templates') # Remove custom_fields column from clients table - if 'clients' in inspector.get_table_names(): + if _has_table(inspector, 'clients'): if _has_column(inspector, 'clients', 'custom_fields'): op.drop_column('clients', 'custom_fields') From bd59488132d33a3c92ad1cd9a91e5249dfb8d687 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Fri, 2 Jan 2026 17:19:19 +0100 Subject: [PATCH 2/2] fix: make template-related migrations idempotent Avoid DuplicateTable/duplicate index errors on partially-migrated databases by skipping creation of existing template tables (quote_templates, project_templates, time_entry_templates) and only creating missing indexes/columns. Also bump version to 4.8.11. --- .../versions/057_add_quote_templates.py | 75 ++-- migrations/versions/065_add_new_features.py | 341 +++++++++++------- .../versions/add_quick_wins_features.py | 173 ++++++--- setup.py | 2 +- 4 files changed, 384 insertions(+), 207 deletions(-) diff --git a/migrations/versions/057_add_quote_templates.py b/migrations/versions/057_add_quote_templates.py index 1107bd9..0e2df2f 100644 --- a/migrations/versions/057_add_quote_templates.py +++ b/migrations/versions/057_add_quote_templates.py @@ -17,29 +17,58 @@ depends_on = None def upgrade(): """Create quote_templates table""" - op.create_table('quote_templates', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(length=200), nullable=False), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('template_data', sa.Text(), nullable=True), - sa.Column('default_tax_rate', sa.Numeric(precision=5, scale=2), nullable=True), - sa.Column('default_currency_code', sa.String(length=3), nullable=True), - sa.Column('default_payment_terms', sa.String(length=100), nullable=True), - sa.Column('default_terms', sa.Text(), nullable=True), - sa.Column('default_valid_until_days', sa.Integer(), nullable=True), - sa.Column('default_requires_approval', sa.Boolean(), nullable=False, server_default='false'), - sa.Column('default_approval_level', sa.Integer(), nullable=True), - sa.Column('default_items', sa.Text(), nullable=True), - sa.Column('created_by', sa.Integer(), nullable=False), - sa.Column('is_public', sa.Boolean(), nullable=False, server_default='false'), - sa.Column('usage_count', sa.Integer(), nullable=False, server_default='0'), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['created_by'], ['users.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('ix_quote_templates_name', 'quote_templates', ['name'], unique=False) - op.create_index('ix_quote_templates_created_by', 'quote_templates', ['created_by'], unique=False) + bind = op.get_bind() + inspector = sa.inspect(bind) + + def _has_table(name: str) -> bool: + try: + return name in inspector.get_table_names() + except Exception: + return False + + def _has_index(table_name: str, index_name: str) -> bool: + try: + return any((idx.get("name") or "") == index_name for idx in inspector.get_indexes(table_name)) + except Exception: + return False + + if not _has_table('quote_templates'): + op.create_table('quote_templates', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('template_data', sa.Text(), nullable=True), + sa.Column('default_tax_rate', sa.Numeric(precision=5, scale=2), nullable=True), + sa.Column('default_currency_code', sa.String(length=3), nullable=True), + sa.Column('default_payment_terms', sa.String(length=100), nullable=True), + sa.Column('default_terms', sa.Text(), nullable=True), + sa.Column('default_valid_until_days', sa.Integer(), nullable=True), + sa.Column('default_requires_approval', sa.Boolean(), nullable=False, server_default='false'), + sa.Column('default_approval_level', sa.Integer(), nullable=True), + sa.Column('default_items', sa.Text(), nullable=True), + sa.Column('created_by', sa.Integer(), nullable=False), + sa.Column('is_public', sa.Boolean(), nullable=False, server_default='false'), + sa.Column('usage_count', sa.Integer(), nullable=False, server_default='0'), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['created_by'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + else: + print("[Migration 057] ℹ Table quote_templates already exists, skipping creation") + + # Ensure indexes exist (best-effort / idempotent) + if _has_table('quote_templates'): + if not _has_index('quote_templates', 'ix_quote_templates_name'): + try: + op.create_index('ix_quote_templates_name', 'quote_templates', ['name'], unique=False) + except Exception: + pass + if not _has_index('quote_templates', 'ix_quote_templates_created_by'): + try: + op.create_index('ix_quote_templates_created_by', 'quote_templates', ['created_by'], unique=False) + except Exception: + pass def downgrade(): diff --git a/migrations/versions/065_add_new_features.py b/migrations/versions/065_add_new_features.py index d7db839..1ee4313 100644 --- a/migrations/versions/065_add_new_features.py +++ b/migrations/versions/065_add_new_features.py @@ -23,147 +23,230 @@ depends_on = None def upgrade(): """Add new feature tables""" + bind = op.get_bind() + inspector = sa.inspect(bind) + + def _has_table(name: str) -> bool: + try: + return name in inspector.get_table_names() + except Exception: + return False + + def _has_index(table_name: str, index_name: str) -> bool: + try: + return any((idx.get("name") or "") == index_name for idx in inspector.get_indexes(table_name)) + except Exception: + return False # Project Templates - op.create_table('project_templates', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(200), nullable=False), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('config', sa.JSON(), nullable=False, server_default='{}'), - sa.Column('tasks', sa.JSON(), nullable=True, server_default='[]'), - sa.Column('category', sa.String(100), nullable=True), - sa.Column('tags', sa.JSON(), nullable=True, server_default='[]'), - sa.Column('is_public', sa.Boolean(), nullable=False, server_default='0'), - sa.Column('created_by', sa.Integer(), nullable=False), - sa.Column('usage_count', sa.Integer(), nullable=False, server_default='0'), - sa.Column('last_used_at', sa.DateTime(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['created_by'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_project_templates_name'), 'project_templates', ['name'], unique=False) - op.create_index(op.f('ix_project_templates_category'), 'project_templates', ['category'], unique=False) - op.create_index(op.f('ix_project_templates_is_public'), 'project_templates', ['is_public'], unique=False) - op.create_index(op.f('ix_project_templates_created_by'), 'project_templates', ['created_by'], unique=False) + if not _has_table('project_templates'): + op.create_table('project_templates', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(200), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('config', sa.JSON(), nullable=False, server_default='{}'), + sa.Column('tasks', sa.JSON(), nullable=True, server_default='[]'), + sa.Column('category', sa.String(100), nullable=True), + sa.Column('tags', sa.JSON(), nullable=True, server_default='[]'), + sa.Column('is_public', sa.Boolean(), nullable=False, server_default='0'), + sa.Column('created_by', sa.Integer(), nullable=False), + sa.Column('usage_count', sa.Integer(), nullable=False, server_default='0'), + sa.Column('last_used_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['created_by'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + else: + print("[Migration 065] ℹ Table project_templates already exists, skipping creation") + + for idx_name, cols in [ + (op.f('ix_project_templates_name'), ['name']), + (op.f('ix_project_templates_category'), ['category']), + (op.f('ix_project_templates_is_public'), ['is_public']), + (op.f('ix_project_templates_created_by'), ['created_by']), + ]: + if _has_table('project_templates') and not _has_index('project_templates', idx_name): + try: + op.create_index(idx_name, 'project_templates', cols, unique=False) + except Exception: + pass # Invoice Approval Workflow - op.create_table('invoice_approvals', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('invoice_id', sa.Integer(), nullable=False), - sa.Column('status', sa.String(20), nullable=False, server_default='pending'), - sa.Column('stages', sa.JSON(), nullable=False, server_default='[]'), - sa.Column('current_stage', sa.Integer(), nullable=False, server_default='0'), - sa.Column('total_stages', sa.Integer(), nullable=False, server_default='1'), - sa.Column('requested_by', sa.Integer(), nullable=False), - sa.Column('requested_at', sa.DateTime(), nullable=False), - sa.Column('approved_by', sa.Integer(), nullable=True), - sa.Column('approved_at', sa.DateTime(), nullable=True), - sa.Column('rejected_by', sa.Integer(), nullable=True), - sa.Column('rejected_at', sa.DateTime(), nullable=True), - sa.Column('rejection_reason', sa.Text(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['invoice_id'], ['invoices.id'], ), - sa.ForeignKeyConstraint(['requested_by'], ['users.id'], ), - sa.ForeignKeyConstraint(['approved_by'], ['users.id'], ), - sa.ForeignKeyConstraint(['rejected_by'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_invoice_approvals_invoice_id'), 'invoice_approvals', ['invoice_id'], unique=False) - op.create_index(op.f('ix_invoice_approvals_status'), 'invoice_approvals', ['status'], unique=False) - op.create_index(op.f('ix_invoice_approvals_requested_by'), 'invoice_approvals', ['requested_by'], unique=False) - op.create_index(op.f('ix_invoice_approvals_approved_by'), 'invoice_approvals', ['approved_by'], unique=False) + if not _has_table('invoice_approvals'): + op.create_table('invoice_approvals', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('invoice_id', sa.Integer(), nullable=False), + sa.Column('status', sa.String(20), nullable=False, server_default='pending'), + sa.Column('stages', sa.JSON(), nullable=False, server_default='[]'), + sa.Column('current_stage', sa.Integer(), nullable=False, server_default='0'), + sa.Column('total_stages', sa.Integer(), nullable=False, server_default='1'), + sa.Column('requested_by', sa.Integer(), nullable=False), + sa.Column('requested_at', sa.DateTime(), nullable=False), + sa.Column('approved_by', sa.Integer(), nullable=True), + sa.Column('approved_at', sa.DateTime(), nullable=True), + sa.Column('rejected_by', sa.Integer(), nullable=True), + sa.Column('rejected_at', sa.DateTime(), nullable=True), + sa.Column('rejection_reason', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['invoice_id'], ['invoices.id'], ), + sa.ForeignKeyConstraint(['requested_by'], ['users.id'], ), + sa.ForeignKeyConstraint(['approved_by'], ['users.id'], ), + sa.ForeignKeyConstraint(['rejected_by'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + else: + print("[Migration 065] ℹ Table invoice_approvals already exists, skipping creation") + + for idx_name, cols in [ + (op.f('ix_invoice_approvals_invoice_id'), ['invoice_id']), + (op.f('ix_invoice_approvals_status'), ['status']), + (op.f('ix_invoice_approvals_requested_by'), ['requested_by']), + (op.f('ix_invoice_approvals_approved_by'), ['approved_by']), + ]: + if _has_table('invoice_approvals') and not _has_index('invoice_approvals', idx_name): + try: + op.create_index(idx_name, 'invoice_approvals', cols, unique=False) + except Exception: + pass # Payment Gateways - op.create_table('payment_gateways', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(50), nullable=False), - sa.Column('provider', sa.String(50), nullable=False), - sa.Column('config', sa.Text(), nullable=False), - sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'), - sa.Column('is_test_mode', sa.Boolean(), nullable=False, server_default='0'), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('name') - ) - op.create_index(op.f('ix_payment_gateways_name'), 'payment_gateways', ['name'], unique=True) - op.create_index(op.f('ix_payment_gateways_is_active'), 'payment_gateways', ['is_active'], unique=False) + if not _has_table('payment_gateways'): + op.create_table('payment_gateways', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(50), nullable=False), + sa.Column('provider', sa.String(50), nullable=False), + sa.Column('config', sa.Text(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'), + sa.Column('is_test_mode', sa.Boolean(), nullable=False, server_default='0'), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + else: + print("[Migration 065] ℹ Table payment_gateways already exists, skipping creation") + + idx_pg_name = op.f('ix_payment_gateways_name') + idx_pg_active = op.f('ix_payment_gateways_is_active') + if _has_table('payment_gateways') and not _has_index('payment_gateways', idx_pg_name): + try: + op.create_index(idx_pg_name, 'payment_gateways', ['name'], unique=True) + except Exception: + pass + if _has_table('payment_gateways') and not _has_index('payment_gateways', idx_pg_active): + try: + op.create_index(idx_pg_active, 'payment_gateways', ['is_active'], unique=False) + except Exception: + pass # Payment Transactions - op.create_table('payment_transactions', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('invoice_id', sa.Integer(), nullable=False), - sa.Column('gateway_id', sa.Integer(), nullable=False), - sa.Column('transaction_id', sa.String(200), nullable=False), - sa.Column('amount', sa.Numeric(10, 2), nullable=False), - sa.Column('currency', sa.String(3), nullable=False, server_default='EUR'), - sa.Column('gateway_fee', sa.Numeric(10, 2), nullable=True), - sa.Column('net_amount', sa.Numeric(10, 2), nullable=True), - sa.Column('status', sa.String(20), nullable=False), - sa.Column('payment_method', sa.String(50), nullable=True), - sa.Column('gateway_response', sa.JSON(), nullable=True), - sa.Column('error_message', sa.Text(), nullable=True), - sa.Column('error_code', sa.String(50), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.Column('processed_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['invoice_id'], ['invoices.id'], ), - sa.ForeignKeyConstraint(['gateway_id'], ['payment_gateways.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('transaction_id') - ) - op.create_index(op.f('ix_payment_transactions_invoice_id'), 'payment_transactions', ['invoice_id'], unique=False) - op.create_index(op.f('ix_payment_transactions_gateway_id'), 'payment_transactions', ['gateway_id'], unique=False) - op.create_index(op.f('ix_payment_transactions_transaction_id'), 'payment_transactions', ['transaction_id'], unique=True) - op.create_index(op.f('ix_payment_transactions_status'), 'payment_transactions', ['status'], unique=False) + if not _has_table('payment_transactions'): + op.create_table('payment_transactions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('invoice_id', sa.Integer(), nullable=False), + sa.Column('gateway_id', sa.Integer(), nullable=False), + sa.Column('transaction_id', sa.String(200), nullable=False), + sa.Column('amount', sa.Numeric(10, 2), nullable=False), + sa.Column('currency', sa.String(3), nullable=False, server_default='EUR'), + sa.Column('gateway_fee', sa.Numeric(10, 2), nullable=True), + sa.Column('net_amount', sa.Numeric(10, 2), nullable=True), + sa.Column('status', sa.String(20), nullable=False), + sa.Column('payment_method', sa.String(50), nullable=True), + sa.Column('gateway_response', sa.JSON(), nullable=True), + sa.Column('error_message', sa.Text(), nullable=True), + sa.Column('error_code', sa.String(50), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('processed_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['invoice_id'], ['invoices.id'], ), + sa.ForeignKeyConstraint(['gateway_id'], ['payment_gateways.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('transaction_id') + ) + else: + print("[Migration 065] ℹ Table payment_transactions already exists, skipping creation") + + for idx_name, cols, unique in [ + (op.f('ix_payment_transactions_invoice_id'), ['invoice_id'], False), + (op.f('ix_payment_transactions_gateway_id'), ['gateway_id'], False), + (op.f('ix_payment_transactions_transaction_id'), ['transaction_id'], True), + (op.f('ix_payment_transactions_status'), ['status'], False), + ]: + if _has_table('payment_transactions') and not _has_index('payment_transactions', idx_name): + try: + op.create_index(idx_name, 'payment_transactions', cols, unique=unique) + except Exception: + pass # Calendar Integrations - op.create_table('calendar_integrations', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('provider', sa.String(50), nullable=False), - sa.Column('access_token', sa.Text(), nullable=False), - sa.Column('refresh_token', sa.Text(), nullable=True), - sa.Column('token_expires_at', sa.DateTime(), nullable=True), - sa.Column('calendar_id', sa.String(200), nullable=True), - sa.Column('calendar_name', sa.String(200), nullable=True), - sa.Column('sync_settings', sa.JSON(), nullable=False, server_default='{}'), - sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'), - sa.Column('last_sync_at', sa.DateTime(), nullable=True), - sa.Column('last_sync_status', sa.String(20), nullable=True), - sa.Column('last_sync_error', sa.Text(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_calendar_integrations_user_id'), 'calendar_integrations', ['user_id'], unique=False) - op.create_index(op.f('ix_calendar_integrations_provider'), 'calendar_integrations', ['provider'], unique=False) - op.create_index(op.f('ix_calendar_integrations_is_active'), 'calendar_integrations', ['is_active'], unique=False) + if not _has_table('calendar_integrations'): + op.create_table('calendar_integrations', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('provider', sa.String(50), nullable=False), + sa.Column('access_token', sa.Text(), nullable=False), + sa.Column('refresh_token', sa.Text(), nullable=True), + sa.Column('token_expires_at', sa.DateTime(), nullable=True), + sa.Column('calendar_id', sa.String(200), nullable=True), + sa.Column('calendar_name', sa.String(200), nullable=True), + sa.Column('sync_settings', sa.JSON(), nullable=False, server_default='{}'), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'), + sa.Column('last_sync_at', sa.DateTime(), nullable=True), + sa.Column('last_sync_status', sa.String(20), nullable=True), + sa.Column('last_sync_error', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + else: + print("[Migration 065] ℹ Table calendar_integrations already exists, skipping creation") + + for idx_name, cols in [ + (op.f('ix_calendar_integrations_user_id'), ['user_id']), + (op.f('ix_calendar_integrations_provider'), ['provider']), + (op.f('ix_calendar_integrations_is_active'), ['is_active']), + ]: + if _has_table('calendar_integrations') and not _has_index('calendar_integrations', idx_name): + try: + op.create_index(idx_name, 'calendar_integrations', cols, unique=False) + except Exception: + pass # Calendar Sync Events - op.create_table('calendar_sync_events', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('integration_id', sa.Integer(), nullable=False), - sa.Column('event_type', sa.String(50), nullable=False), - sa.Column('time_entry_id', sa.Integer(), nullable=True), - sa.Column('calendar_event_id', sa.String(200), nullable=True), - sa.Column('direction', sa.String(20), nullable=False), - sa.Column('status', sa.String(20), nullable=False), - sa.Column('error_message', sa.Text(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('synced_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['integration_id'], ['calendar_integrations.id'], ), - sa.ForeignKeyConstraint(['time_entry_id'], ['time_entries.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_calendar_sync_events_integration_id'), 'calendar_sync_events', ['integration_id'], unique=False) - op.create_index(op.f('ix_calendar_sync_events_event_type'), 'calendar_sync_events', ['event_type'], unique=False) - op.create_index(op.f('ix_calendar_sync_events_time_entry_id'), 'calendar_sync_events', ['time_entry_id'], unique=False) - op.create_index(op.f('ix_calendar_sync_events_calendar_event_id'), 'calendar_sync_events', ['calendar_event_id'], unique=False) - op.create_index(op.f('ix_calendar_sync_events_status'), 'calendar_sync_events', ['status'], unique=False) + if not _has_table('calendar_sync_events'): + op.create_table('calendar_sync_events', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('integration_id', sa.Integer(), nullable=False), + sa.Column('event_type', sa.String(50), nullable=False), + sa.Column('time_entry_id', sa.Integer(), nullable=True), + sa.Column('calendar_event_id', sa.String(200), nullable=True), + sa.Column('direction', sa.String(20), nullable=False), + sa.Column('status', sa.String(20), nullable=False), + sa.Column('error_message', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('synced_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['integration_id'], ['calendar_integrations.id'], ), + sa.ForeignKeyConstraint(['time_entry_id'], ['time_entries.id'], ), + sa.PrimaryKeyConstraint('id') + ) + else: + print("[Migration 065] ℹ Table calendar_sync_events already exists, skipping creation") + + for idx_name, cols in [ + (op.f('ix_calendar_sync_events_integration_id'), ['integration_id']), + (op.f('ix_calendar_sync_events_event_type'), ['event_type']), + (op.f('ix_calendar_sync_events_time_entry_id'), ['time_entry_id']), + (op.f('ix_calendar_sync_events_calendar_event_id'), ['calendar_event_id']), + (op.f('ix_calendar_sync_events_status'), ['status']), + ]: + if _has_table('calendar_sync_events') and not _has_index('calendar_sync_events', idx_name): + try: + op.create_index(idx_name, 'calendar_sync_events', cols, unique=False) + except Exception: + pass def downgrade(): diff --git a/migrations/versions/add_quick_wins_features.py b/migrations/versions/add_quick_wins_features.py index 87e19d0..862e7c0 100644 --- a/migrations/versions/add_quick_wins_features.py +++ b/migrations/versions/add_quick_wins_features.py @@ -17,66 +17,131 @@ depends_on = None def upgrade(): + bind = op.get_bind() + inspector = sa.inspect(bind) + + def _has_table(name: str) -> bool: + try: + return name in inspector.get_table_names() + except Exception: + return False + + def _has_index(table_name: str, index_name: str) -> bool: + try: + return any((idx.get("name") or "") == index_name for idx in inspector.get_indexes(table_name)) + except Exception: + return False + + def _has_column(table_name: str, column_name: str) -> bool: + try: + return column_name in {c["name"] for c in inspector.get_columns(table_name)} + except Exception: + return False + # Create time_entry_templates table - op.create_table('time_entry_templates', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(length=200), nullable=False), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('project_id', sa.Integer(), nullable=False), - sa.Column('task_id', sa.Integer(), nullable=True), - sa.Column('default_duration_minutes', sa.Integer(), nullable=True), - sa.Column('default_notes', sa.Text(), nullable=True), - sa.Column('tags', sa.String(length=500), nullable=True), - sa.Column('billable', sa.Boolean(), nullable=False, server_default='true'), - sa.Column('usage_count', sa.Integer(), nullable=False, server_default='0'), - sa.Column('last_used_at', sa.DateTime(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), - sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), - sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ), - sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_time_entry_templates_project_id'), 'time_entry_templates', ['project_id'], unique=False) - op.create_index(op.f('ix_time_entry_templates_task_id'), 'time_entry_templates', ['task_id'], unique=False) - op.create_index(op.f('ix_time_entry_templates_user_id'), 'time_entry_templates', ['user_id'], unique=False) + if not _has_table('time_entry_templates'): + op.create_table('time_entry_templates', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('project_id', sa.Integer(), nullable=False), + sa.Column('task_id', sa.Integer(), nullable=True), + sa.Column('default_duration_minutes', sa.Integer(), nullable=True), + sa.Column('default_notes', sa.Text(), nullable=True), + sa.Column('tags', sa.String(length=500), nullable=True), + sa.Column('billable', sa.Boolean(), nullable=False, server_default='true'), + sa.Column('usage_count', sa.Integer(), nullable=False, server_default='0'), + sa.Column('last_used_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ), + sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + else: + print("[Migration 022] ℹ Table time_entry_templates already exists, skipping creation") + + # Ensure indexes exist (best-effort / idempotent) + idx_te_project = op.f('ix_time_entry_templates_project_id') + idx_te_task = op.f('ix_time_entry_templates_task_id') + idx_te_user = op.f('ix_time_entry_templates_user_id') + if _has_table('time_entry_templates'): + if not _has_index('time_entry_templates', idx_te_project): + try: + op.create_index(idx_te_project, 'time_entry_templates', ['project_id'], unique=False) + except Exception: + pass + if not _has_index('time_entry_templates', idx_te_task): + try: + op.create_index(idx_te_task, 'time_entry_templates', ['task_id'], unique=False) + except Exception: + pass + if not _has_index('time_entry_templates', idx_te_user): + try: + op.create_index(idx_te_user, 'time_entry_templates', ['user_id'], unique=False) + except Exception: + pass # Create activities table - op.create_table('activities', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('action', sa.String(length=50), nullable=False), - sa.Column('entity_type', sa.String(length=50), nullable=False), - sa.Column('entity_id', sa.Integer(), nullable=False), - sa.Column('entity_name', sa.String(length=500), nullable=True), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('extra_data', sa.JSON(), nullable=True), - sa.Column('ip_address', sa.String(length=45), nullable=True), - sa.Column('user_agent', sa.Text(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_activities_action'), 'activities', ['action'], unique=False) - op.create_index(op.f('ix_activities_created_at'), 'activities', ['created_at'], unique=False) - op.create_index(op.f('ix_activities_entity_id'), 'activities', ['entity_id'], unique=False) - op.create_index(op.f('ix_activities_entity_type'), 'activities', ['entity_type'], unique=False) - op.create_index(op.f('ix_activities_user_id'), 'activities', ['user_id'], unique=False) - op.create_index('ix_activities_user_created', 'activities', ['user_id', 'created_at'], unique=False) - op.create_index('ix_activities_entity', 'activities', ['entity_type', 'entity_id'], unique=False) + if not _has_table('activities'): + op.create_table('activities', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('action', sa.String(length=50), nullable=False), + sa.Column('entity_type', sa.String(length=50), nullable=False), + sa.Column('entity_id', sa.Integer(), nullable=False), + sa.Column('entity_name', sa.String(length=500), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('extra_data', sa.JSON(), nullable=True), + sa.Column('ip_address', sa.String(length=45), nullable=True), + sa.Column('user_agent', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + else: + print("[Migration 022] ℹ Table activities already exists, skipping creation") + + # Ensure indexes exist (best-effort / idempotent) + if _has_table('activities'): + for idx_name, cols in [ + (op.f('ix_activities_action'), ['action']), + (op.f('ix_activities_created_at'), ['created_at']), + (op.f('ix_activities_entity_id'), ['entity_id']), + (op.f('ix_activities_entity_type'), ['entity_type']), + (op.f('ix_activities_user_id'), ['user_id']), + ('ix_activities_user_created', ['user_id', 'created_at']), + ('ix_activities_entity', ['entity_type', 'entity_id']), + ]: + if not _has_index('activities', idx_name): + try: + op.create_index(idx_name, 'activities', cols, unique=False) + except Exception: + pass # Add user preference columns to users table with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.add_column(sa.Column('email_notifications', sa.Boolean(), nullable=False, server_default='true')) - batch_op.add_column(sa.Column('notification_overdue_invoices', sa.Boolean(), nullable=False, server_default='true')) - batch_op.add_column(sa.Column('notification_task_assigned', sa.Boolean(), nullable=False, server_default='true')) - batch_op.add_column(sa.Column('notification_task_comments', sa.Boolean(), nullable=False, server_default='true')) - batch_op.add_column(sa.Column('notification_weekly_summary', sa.Boolean(), nullable=False, server_default='false')) - batch_op.add_column(sa.Column('timezone', sa.String(length=50), nullable=True)) - batch_op.add_column(sa.Column('date_format', sa.String(length=20), nullable=False, server_default='YYYY-MM-DD')) - batch_op.add_column(sa.Column('time_format', sa.String(length=10), nullable=False, server_default='24h')) - batch_op.add_column(sa.Column('week_start_day', sa.Integer(), nullable=False, server_default='1')) + if _has_table('users'): + if not _has_column('users', 'email_notifications'): + batch_op.add_column(sa.Column('email_notifications', sa.Boolean(), nullable=False, server_default='true')) + if not _has_column('users', 'notification_overdue_invoices'): + batch_op.add_column(sa.Column('notification_overdue_invoices', sa.Boolean(), nullable=False, server_default='true')) + if not _has_column('users', 'notification_task_assigned'): + batch_op.add_column(sa.Column('notification_task_assigned', sa.Boolean(), nullable=False, server_default='true')) + if not _has_column('users', 'notification_task_comments'): + batch_op.add_column(sa.Column('notification_task_comments', sa.Boolean(), nullable=False, server_default='true')) + if not _has_column('users', 'notification_weekly_summary'): + batch_op.add_column(sa.Column('notification_weekly_summary', sa.Boolean(), nullable=False, server_default='false')) + if not _has_column('users', 'timezone'): + batch_op.add_column(sa.Column('timezone', sa.String(length=50), nullable=True)) + if not _has_column('users', 'date_format'): + batch_op.add_column(sa.Column('date_format', sa.String(length=20), nullable=False, server_default='YYYY-MM-DD')) + if not _has_column('users', 'time_format'): + batch_op.add_column(sa.Column('time_format', sa.String(length=10), nullable=False, server_default='24h')) + if not _has_column('users', 'week_start_day'): + batch_op.add_column(sa.Column('week_start_day', sa.Integer(), nullable=False, server_default='1')) def downgrade(): diff --git a/setup.py b/setup.py index 61fe63a..d3927c0 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ from setuptools import setup, find_packages setup( name='timetracker', - version='4.8.10', + version='4.8.11', packages=find_packages(), include_package_data=True, install_requires=[