Merge pull request #383 from DRYTRIX/rc/v4.8.11

Rc/v4.8.11
This commit is contained in:
Dries Peeters
2026-01-02 17:22:35 +01:00
committed by GitHub
5 changed files with 436 additions and 229 deletions

View File

@@ -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():

View File

@@ -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():

View File

@@ -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')

View File

@@ -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():

View File

@@ -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=[