"""Add user authentication tables and update existing models This migration: 1. Creates user authentication tables (users, login_history, user_sessions, revoked_tokens) 2. Updates translation and transcription tables to link to users 3. Adds proper foreign key constraints and indexes """ from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql import uuid # revision identifiers revision = 'add_user_authentication' down_revision = None branch_labels = None depends_on = None def upgrade(): # Create users table op.create_table('users', sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False, default=uuid.uuid4), sa.Column('email', sa.String(255), nullable=False), sa.Column('username', sa.String(100), nullable=False), sa.Column('password_hash', sa.String(255), nullable=False), sa.Column('full_name', sa.String(255), nullable=True), sa.Column('avatar_url', sa.String(500), nullable=True), sa.Column('api_key', sa.String(64), nullable=False), sa.Column('api_key_created_at', sa.DateTime(), nullable=False), sa.Column('is_active', sa.Boolean(), nullable=False, default=True), sa.Column('is_verified', sa.Boolean(), nullable=False, default=False), sa.Column('is_suspended', sa.Boolean(), nullable=False, default=False), sa.Column('suspension_reason', sa.Text(), nullable=True), sa.Column('suspended_at', sa.DateTime(), nullable=True), sa.Column('suspended_until', sa.DateTime(), nullable=True), sa.Column('role', sa.String(20), nullable=False, default='user'), sa.Column('permissions', postgresql.JSONB(astext_type=sa.Text()), nullable=False, default=[]), sa.Column('rate_limit_per_minute', sa.Integer(), nullable=False, default=30), sa.Column('rate_limit_per_hour', sa.Integer(), nullable=False, default=500), sa.Column('rate_limit_per_day', sa.Integer(), nullable=False, default=5000), sa.Column('total_requests', sa.Integer(), nullable=False, default=0), sa.Column('total_translations', sa.Integer(), nullable=False, default=0), sa.Column('total_transcriptions', sa.Integer(), nullable=False, default=0), sa.Column('total_tts_requests', sa.Integer(), nullable=False, default=0), sa.Column('created_at', sa.DateTime(), nullable=False), sa.Column('updated_at', sa.DateTime(), nullable=False), sa.Column('last_login_at', sa.DateTime(), nullable=True), sa.Column('last_active_at', sa.DateTime(), nullable=True), sa.Column('password_changed_at', sa.DateTime(), nullable=False), sa.Column('failed_login_attempts', sa.Integer(), nullable=False, default=0), sa.Column('locked_until', sa.DateTime(), nullable=True), sa.Column('settings', postgresql.JSONB(astext_type=sa.Text()), nullable=False, default={}), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('email'), sa.UniqueConstraint('username'), sa.UniqueConstraint('api_key') ) # Create indexes on users table op.create_index('idx_users_email', 'users', ['email']) op.create_index('idx_users_username', 'users', ['username']) op.create_index('idx_users_api_key', 'users', ['api_key']) op.create_index('idx_users_email_active', 'users', ['email', 'is_active']) op.create_index('idx_users_role_active', 'users', ['role', 'is_active']) op.create_index('idx_users_created_at', 'users', ['created_at']) # Create login_history table op.create_table('login_history', sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False, default=uuid.uuid4), sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False), sa.Column('login_at', sa.DateTime(), nullable=False), sa.Column('logout_at', sa.DateTime(), nullable=True), sa.Column('login_method', sa.String(20), nullable=False), sa.Column('success', sa.Boolean(), nullable=False), sa.Column('failure_reason', sa.String(255), nullable=True), sa.Column('session_id', sa.String(255), nullable=True), sa.Column('jwt_jti', sa.String(255), nullable=True), sa.Column('ip_address', sa.String(45), nullable=False), sa.Column('user_agent', sa.String(500), nullable=True), sa.Column('device_info', postgresql.JSONB(astext_type=sa.Text()), nullable=True), sa.Column('country', sa.String(2), nullable=True), sa.Column('city', sa.String(100), nullable=True), sa.Column('is_suspicious', sa.Boolean(), nullable=False, default=False), sa.Column('security_notes', sa.Text(), nullable=True), sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), sa.PrimaryKeyConstraint('id') ) # Create indexes on login_history op.create_index('idx_login_history_user_id', 'login_history', ['user_id']) op.create_index('idx_login_history_user_time', 'login_history', ['user_id', 'login_at']) op.create_index('idx_login_history_session', 'login_history', ['session_id']) op.create_index('idx_login_history_jwt_jti', 'login_history', ['jwt_jti']) op.create_index('idx_login_history_ip', 'login_history', ['ip_address']) # Create user_sessions table op.create_table('user_sessions', sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False, default=uuid.uuid4), sa.Column('session_id', sa.String(255), nullable=False), sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False), sa.Column('access_token_jti', sa.String(255), nullable=True), sa.Column('refresh_token_jti', sa.String(255), nullable=True), sa.Column('created_at', sa.DateTime(), nullable=False), sa.Column('last_active_at', sa.DateTime(), nullable=False), sa.Column('expires_at', sa.DateTime(), nullable=False), sa.Column('ip_address', sa.String(45), nullable=False), sa.Column('user_agent', sa.String(500), nullable=True), sa.Column('data', postgresql.JSONB(astext_type=sa.Text()), nullable=False, default={}), sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('session_id') ) # Create indexes on user_sessions op.create_index('idx_user_sessions_session_id', 'user_sessions', ['session_id']) op.create_index('idx_user_sessions_user_id', 'user_sessions', ['user_id']) op.create_index('idx_user_sessions_user_active', 'user_sessions', ['user_id', 'expires_at']) op.create_index('idx_user_sessions_token', 'user_sessions', ['access_token_jti']) # Create revoked_tokens table op.create_table('revoked_tokens', sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False, default=uuid.uuid4), sa.Column('jti', sa.String(255), nullable=False), sa.Column('token_type', sa.String(20), nullable=False), sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=True), sa.Column('revoked_at', sa.DateTime(), nullable=False), sa.Column('expires_at', sa.DateTime(), nullable=False), sa.Column('reason', sa.String(255), nullable=True), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('jti') ) # Create indexes on revoked_tokens op.create_index('idx_revoked_tokens_jti', 'revoked_tokens', ['jti']) op.create_index('idx_revoked_tokens_user_id', 'revoked_tokens', ['user_id']) op.create_index('idx_revoked_tokens_expires', 'revoked_tokens', ['expires_at']) # Update translations table to add user_id with proper foreign key # First, check if user_id column exists try: op.add_column('translations', sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=True)) op.create_foreign_key('fk_translations_user_id', 'translations', 'users', ['user_id'], ['id'], ondelete='SET NULL') op.create_index('idx_translations_user_id', 'translations', ['user_id']) except: pass # Column might already exist # Update transcriptions table to add user_id with proper foreign key try: op.add_column('transcriptions', sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=True)) op.create_foreign_key('fk_transcriptions_user_id', 'transcriptions', 'users', ['user_id'], ['id'], ondelete='SET NULL') op.create_index('idx_transcriptions_user_id', 'transcriptions', ['user_id']) except: pass # Column might already exist # Update user_preferences table to add proper foreign key if not exists try: op.create_foreign_key('fk_user_preferences_user_id', 'user_preferences', 'users', ['user_id'], ['id'], ondelete='CASCADE') except: pass # Foreign key might already exist # Update api_keys table to add proper foreign key if not exists try: op.add_column('api_keys', sa.Column('user_id_new', postgresql.UUID(as_uuid=True), nullable=True)) op.create_foreign_key('fk_api_keys_user_id', 'api_keys', 'users', ['user_id_new'], ['id'], ondelete='CASCADE') except: pass # Column/FK might already exist # Create function for updating updated_at timestamp op.execute(""" CREATE OR REPLACE FUNCTION update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ language 'plpgsql'; """) # Drop existing trigger if it exists and recreate it op.execute(""" DROP TRIGGER IF EXISTS update_users_updated_at ON users; """) # Create trigger for users table op.execute(""" CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); """) def downgrade(): # Drop triggers op.execute("DROP TRIGGER IF EXISTS update_users_updated_at ON users") op.execute("DROP FUNCTION IF EXISTS update_updated_at_column()") # Drop foreign keys try: op.drop_constraint('fk_translations_user_id', 'translations', type_='foreignkey') op.drop_constraint('fk_transcriptions_user_id', 'transcriptions', type_='foreignkey') op.drop_constraint('fk_user_preferences_user_id', 'user_preferences', type_='foreignkey') op.drop_constraint('fk_api_keys_user_id', 'api_keys', type_='foreignkey') except: pass # Drop columns try: op.drop_column('translations', 'user_id') op.drop_column('transcriptions', 'user_id') op.drop_column('api_keys', 'user_id_new') except: pass # Drop tables op.drop_table('revoked_tokens') op.drop_table('user_sessions') op.drop_table('login_history') op.drop_table('users')