From ebaf6d39eacbe9e19bbe8a07837161e8551c80bd Mon Sep 17 00:00:00 2001 From: Oronemu Date: Fri, 19 Dec 2025 02:23:50 +0700 Subject: [PATCH] Fix migrations --- .../alembic/versions/001_add_roles_system.py | 68 +++++++++++++------ .../alembic/versions/002_marathon_settings.py | 20 ++++-- .../versions/010_add_telegram_profile.py | 26 +++++-- .../versions/011_add_challenge_proposals.py | 20 ++++-- .../alembic/versions/012_add_user_banned.py | 32 ++++++--- .../017_admin_logs_nullable_admin_id.py | 27 ++++++-- 6 files changed, 146 insertions(+), 47 deletions(-) diff --git a/backend/alembic/versions/001_add_roles_system.py b/backend/alembic/versions/001_add_roles_system.py index 421d299..33772b6 100644 --- a/backend/alembic/versions/001_add_roles_system.py +++ b/backend/alembic/versions/001_add_roles_system.py @@ -9,6 +9,7 @@ from typing import Sequence, Union from alembic import op import sqlalchemy as sa +from sqlalchemy import inspect # revision identifiers, used by Alembic. revision: str = '001_add_roles' @@ -17,17 +18,35 @@ branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None +def column_exists(table_name: str, column_name: str) -> bool: + bind = op.get_bind() + inspector = inspect(bind) + columns = [col['name'] for col in inspector.get_columns(table_name)] + return column_name in columns + + +def constraint_exists(table_name: str, constraint_name: str) -> bool: + bind = op.get_bind() + inspector = inspect(bind) + fks = inspector.get_foreign_keys(table_name) + return any(fk['name'] == constraint_name for fk in fks) + + def upgrade() -> None: # Add role column to users table - op.add_column('users', sa.Column('role', sa.String(20), nullable=False, server_default='user')) + if not column_exists('users', 'role'): + op.add_column('users', sa.Column('role', sa.String(20), nullable=False, server_default='user')) # Add role column to participants table - op.add_column('participants', sa.Column('role', sa.String(20), nullable=False, server_default='participant')) + if not column_exists('participants', 'role'): + op.add_column('participants', sa.Column('role', sa.String(20), nullable=False, server_default='participant')) # Rename organizer_id to creator_id in marathons table - op.alter_column('marathons', 'organizer_id', new_column_name='creator_id') + if column_exists('marathons', 'organizer_id') and not column_exists('marathons', 'creator_id'): + op.alter_column('marathons', 'organizer_id', new_column_name='creator_id') # Update existing participants: set role='organizer' for marathon creators + # This is idempotent - running multiple times is safe op.execute(""" UPDATE participants p SET role = 'organizer' @@ -36,37 +55,48 @@ def upgrade() -> None: """) # Add status column to games table - op.add_column('games', sa.Column('status', sa.String(20), nullable=False, server_default='approved')) + if not column_exists('games', 'status'): + op.add_column('games', sa.Column('status', sa.String(20), nullable=False, server_default='approved')) # Rename added_by_id to proposed_by_id in games table - op.alter_column('games', 'added_by_id', new_column_name='proposed_by_id') + if column_exists('games', 'added_by_id') and not column_exists('games', 'proposed_by_id'): + op.alter_column('games', 'added_by_id', new_column_name='proposed_by_id') # Add approved_by_id column to games table - op.add_column('games', sa.Column('approved_by_id', sa.Integer(), nullable=True)) - op.create_foreign_key( - 'fk_games_approved_by_id', - 'games', 'users', - ['approved_by_id'], ['id'], - ondelete='SET NULL' - ) + if not column_exists('games', 'approved_by_id'): + op.add_column('games', sa.Column('approved_by_id', sa.Integer(), nullable=True)) + if not constraint_exists('games', 'fk_games_approved_by_id'): + op.create_foreign_key( + 'fk_games_approved_by_id', + 'games', 'users', + ['approved_by_id'], ['id'], + ondelete='SET NULL' + ) def downgrade() -> None: # Remove approved_by_id from games - op.drop_constraint('fk_games_approved_by_id', 'games', type_='foreignkey') - op.drop_column('games', 'approved_by_id') + if constraint_exists('games', 'fk_games_approved_by_id'): + op.drop_constraint('fk_games_approved_by_id', 'games', type_='foreignkey') + if column_exists('games', 'approved_by_id'): + op.drop_column('games', 'approved_by_id') # Rename proposed_by_id back to added_by_id - op.alter_column('games', 'proposed_by_id', new_column_name='added_by_id') + if column_exists('games', 'proposed_by_id'): + op.alter_column('games', 'proposed_by_id', new_column_name='added_by_id') # Remove status from games - op.drop_column('games', 'status') + if column_exists('games', 'status'): + op.drop_column('games', 'status') # Rename creator_id back to organizer_id - op.alter_column('marathons', 'creator_id', new_column_name='organizer_id') + if column_exists('marathons', 'creator_id'): + op.alter_column('marathons', 'creator_id', new_column_name='organizer_id') # Remove role from participants - op.drop_column('participants', 'role') + if column_exists('participants', 'role'): + op.drop_column('participants', 'role') # Remove role from users - op.drop_column('users', 'role') + if column_exists('users', 'role'): + op.drop_column('users', 'role') diff --git a/backend/alembic/versions/002_marathon_settings.py b/backend/alembic/versions/002_marathon_settings.py index 8b4fddd..aae03bb 100644 --- a/backend/alembic/versions/002_marathon_settings.py +++ b/backend/alembic/versions/002_marathon_settings.py @@ -9,6 +9,7 @@ from typing import Sequence, Union from alembic import op import sqlalchemy as sa +from sqlalchemy import inspect # revision identifiers, used by Alembic. revision: str = '002_marathon_settings' @@ -17,16 +18,27 @@ branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None +def column_exists(table_name: str, column_name: str) -> bool: + bind = op.get_bind() + inspector = inspect(bind) + columns = [col['name'] for col in inspector.get_columns(table_name)] + return column_name in columns + + def upgrade() -> None: # Add is_public column to marathons table (default False = private) - op.add_column('marathons', sa.Column('is_public', sa.Boolean(), nullable=False, server_default='false')) + if not column_exists('marathons', 'is_public'): + op.add_column('marathons', sa.Column('is_public', sa.Boolean(), nullable=False, server_default='false')) # Add game_proposal_mode column to marathons table # 'all_participants' - anyone can propose games (with moderation) # 'organizer_only' - only organizers can add games - op.add_column('marathons', sa.Column('game_proposal_mode', sa.String(20), nullable=False, server_default='all_participants')) + if not column_exists('marathons', 'game_proposal_mode'): + op.add_column('marathons', sa.Column('game_proposal_mode', sa.String(20), nullable=False, server_default='all_participants')) def downgrade() -> None: - op.drop_column('marathons', 'game_proposal_mode') - op.drop_column('marathons', 'is_public') + if column_exists('marathons', 'game_proposal_mode'): + op.drop_column('marathons', 'game_proposal_mode') + if column_exists('marathons', 'is_public'): + op.drop_column('marathons', 'is_public') diff --git a/backend/alembic/versions/010_add_telegram_profile.py b/backend/alembic/versions/010_add_telegram_profile.py index 2feeaf5..a77993b 100644 --- a/backend/alembic/versions/010_add_telegram_profile.py +++ b/backend/alembic/versions/010_add_telegram_profile.py @@ -9,6 +9,7 @@ from typing import Sequence, Union from alembic import op import sqlalchemy as sa +from sqlalchemy import inspect # revision identifiers, used by Alembic. @@ -18,13 +19,26 @@ branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None +def column_exists(table_name: str, column_name: str) -> bool: + bind = op.get_bind() + inspector = inspect(bind) + columns = [col['name'] for col in inspector.get_columns(table_name)] + return column_name in columns + + def upgrade() -> None: - op.add_column('users', sa.Column('telegram_first_name', sa.String(100), nullable=True)) - op.add_column('users', sa.Column('telegram_last_name', sa.String(100), nullable=True)) - op.add_column('users', sa.Column('telegram_avatar_url', sa.String(500), nullable=True)) + if not column_exists('users', 'telegram_first_name'): + op.add_column('users', sa.Column('telegram_first_name', sa.String(100), nullable=True)) + if not column_exists('users', 'telegram_last_name'): + op.add_column('users', sa.Column('telegram_last_name', sa.String(100), nullable=True)) + if not column_exists('users', 'telegram_avatar_url'): + op.add_column('users', sa.Column('telegram_avatar_url', sa.String(500), nullable=True)) def downgrade() -> None: - op.drop_column('users', 'telegram_avatar_url') - op.drop_column('users', 'telegram_last_name') - op.drop_column('users', 'telegram_first_name') + if column_exists('users', 'telegram_avatar_url'): + op.drop_column('users', 'telegram_avatar_url') + if column_exists('users', 'telegram_last_name'): + op.drop_column('users', 'telegram_last_name') + if column_exists('users', 'telegram_first_name'): + op.drop_column('users', 'telegram_first_name') diff --git a/backend/alembic/versions/011_add_challenge_proposals.py b/backend/alembic/versions/011_add_challenge_proposals.py index 52311fa..6b248ae 100644 --- a/backend/alembic/versions/011_add_challenge_proposals.py +++ b/backend/alembic/versions/011_add_challenge_proposals.py @@ -9,6 +9,7 @@ from typing import Sequence, Union from alembic import op import sqlalchemy as sa +from sqlalchemy import inspect # revision identifiers, used by Alembic. @@ -18,11 +19,22 @@ branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None +def column_exists(table_name: str, column_name: str) -> bool: + bind = op.get_bind() + inspector = inspect(bind) + columns = [col['name'] for col in inspector.get_columns(table_name)] + return column_name in columns + + def upgrade() -> None: - op.add_column('challenges', sa.Column('proposed_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True)) - op.add_column('challenges', sa.Column('status', sa.String(20), server_default='approved', nullable=False)) + if not column_exists('challenges', 'proposed_by_id'): + op.add_column('challenges', sa.Column('proposed_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True)) + if not column_exists('challenges', 'status'): + op.add_column('challenges', sa.Column('status', sa.String(20), server_default='approved', nullable=False)) def downgrade() -> None: - op.drop_column('challenges', 'status') - op.drop_column('challenges', 'proposed_by_id') + if column_exists('challenges', 'status'): + op.drop_column('challenges', 'status') + if column_exists('challenges', 'proposed_by_id'): + op.drop_column('challenges', 'proposed_by_id') diff --git a/backend/alembic/versions/012_add_user_banned.py b/backend/alembic/versions/012_add_user_banned.py index f23e59a..bbff37a 100644 --- a/backend/alembic/versions/012_add_user_banned.py +++ b/backend/alembic/versions/012_add_user_banned.py @@ -9,6 +9,7 @@ from typing import Sequence, Union from alembic import op import sqlalchemy as sa +from sqlalchemy import inspect # revision identifiers, used by Alembic. @@ -18,15 +19,30 @@ branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None +def column_exists(table_name: str, column_name: str) -> bool: + bind = op.get_bind() + inspector = inspect(bind) + columns = [col['name'] for col in inspector.get_columns(table_name)] + return column_name in columns + + def upgrade() -> None: - op.add_column('users', sa.Column('is_banned', sa.Boolean(), server_default='false', nullable=False)) - op.add_column('users', sa.Column('banned_at', sa.DateTime(), nullable=True)) - op.add_column('users', sa.Column('banned_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True)) - op.add_column('users', sa.Column('ban_reason', sa.String(500), nullable=True)) + if not column_exists('users', 'is_banned'): + op.add_column('users', sa.Column('is_banned', sa.Boolean(), server_default='false', nullable=False)) + if not column_exists('users', 'banned_at'): + op.add_column('users', sa.Column('banned_at', sa.DateTime(), nullable=True)) + if not column_exists('users', 'banned_by_id'): + op.add_column('users', sa.Column('banned_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True)) + if not column_exists('users', 'ban_reason'): + op.add_column('users', sa.Column('ban_reason', sa.String(500), nullable=True)) def downgrade() -> None: - op.drop_column('users', 'ban_reason') - op.drop_column('users', 'banned_by_id') - op.drop_column('users', 'banned_at') - op.drop_column('users', 'is_banned') + if column_exists('users', 'ban_reason'): + op.drop_column('users', 'ban_reason') + if column_exists('users', 'banned_by_id'): + op.drop_column('users', 'banned_by_id') + if column_exists('users', 'banned_at'): + op.drop_column('users', 'banned_at') + if column_exists('users', 'is_banned'): + op.drop_column('users', 'is_banned') diff --git a/backend/alembic/versions/017_admin_logs_nullable_admin_id.py b/backend/alembic/versions/017_admin_logs_nullable_admin_id.py index 32c4dc8..57fb030 100644 --- a/backend/alembic/versions/017_admin_logs_nullable_admin_id.py +++ b/backend/alembic/versions/017_admin_logs_nullable_admin_id.py @@ -9,6 +9,7 @@ from typing import Sequence, Union from alembic import op import sqlalchemy as sa +from sqlalchemy import inspect # revision identifiers, used by Alembic. @@ -18,15 +19,29 @@ branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None +def is_column_nullable(table_name: str, column_name: str) -> bool: + """Check if a column is nullable.""" + bind = op.get_bind() + inspector = inspect(bind) + columns = inspector.get_columns(table_name) + for col in columns: + if col['name'] == column_name: + return col.get('nullable', True) + return True + + def upgrade() -> None: # Make admin_id nullable for system actions (like auto-unban) - op.alter_column('admin_logs', 'admin_id', - existing_type=sa.Integer(), - nullable=True) + # Only alter if currently not nullable + if not is_column_nullable('admin_logs', 'admin_id'): + op.alter_column('admin_logs', 'admin_id', + existing_type=sa.Integer(), + nullable=True) def downgrade() -> None: # Revert to not nullable (will fail if there are NULL values) - op.alter_column('admin_logs', 'admin_id', - existing_type=sa.Integer(), - nullable=False) + if is_column_nullable('admin_logs', 'admin_id'): + op.alter_column('admin_logs', 'admin_id', + existing_type=sa.Integer(), + nullable=False)