diff --git a/app/__init__.py b/app/__init__.py index 4219fa9..8a8a505 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -68,6 +68,56 @@ } +def _bootstrap_alembic_version(app): + """Stamp alembic_version for databases that predate Flask-Migrate. + + Early versions of May created tables via db.create_all() without + Flask-Migrate. When those users upgrade, `alembic_version` is either + missing or empty, so `flask db upgrade` tries to replay migrations + from scratch and fails on `duplicate column` errors. Columns that + later migrations add therefore never land, and the app crashes + at startup (see issues #132, #136). + + If we detect an established schema with no alembic revision recorded, + stamp the most recent migration whose columns already exist so that + subsequent `flask db upgrade` runs only apply genuinely new changes. + """ + from sqlalchemy import text, inspect + from flask_migrate import stamp + + inspector = inspect(db.engine) + table_names = inspector.get_table_names() + if 'users' not in table_names or 'vehicles' not in table_names: + return + + with db.engine.begin() as conn: + if 'alembic_version' in table_names: + current = conn.execute( + text('SELECT version_num FROM alembic_version') + ).scalar() + if current: + return + + user_cols = {c['name'] for c in inspector.get_columns('users')} + vehicle_cols = {c['name'] for c in inspector.get_columns('vehicles')} + + if 'default_vehicle_id' in user_cols: + target = 'a1b2c3d4e5f6' + elif 'odometer_unit' in vehicle_cols: + target = '613be8af4376' + else: + target = None + + if target is None: + return + + try: + stamp(revision=target) + app.logger.info(f'Stamped alembic_version to {target} for pre-migration database') + except Exception as e: + app.logger.warning(f'Could not stamp alembic_version: {e}') + + def _run_schema_migrations(app): """Add missing columns to existing tables. @@ -91,6 +141,7 @@ def _run_schema_migrations(app): ('date_format', "VARCHAR(20) DEFAULT 'DD/MM/YYYY'"), ('password_reset_token', 'VARCHAR(100)'), ('password_reset_expires', 'DATETIME'), + ('default_vehicle_id', 'INTEGER REFERENCES vehicles(id)'), ], 'charging_sessions': [ ('tessie_charge_id', 'VARCHAR(50)'), @@ -248,6 +299,9 @@ def add_security_headers(response): with app.app_context(): db.create_all() + # Stamp alembic_version for pre-Flask-Migrate databases so future + # `flask db upgrade` runs apply only pending migrations. + _bootstrap_alembic_version(app) # Run schema migrations for new columns on existing tables _run_schema_migrations(app) # Create default admin user if no users exist diff --git a/config.py b/config.py index 77ddd6f..73957e3 100644 --- a/config.py +++ b/config.py @@ -4,7 +4,7 @@ basedir = Path(__file__).parent.absolute() -APP_VERSION = '0.18.0' +APP_VERSION = '0.18.1' RELEASE_CHANNEL = os.environ.get('RELEASE_CHANNEL', 'stable') GIT_SHA = os.environ.get('GIT_SHA', '')[:7] # Short SHA GITHUB_REPO = 'dannymcc/may' diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 5a38c7a..f5ac56f 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -11,8 +11,11 @@ fi mkdir -p /app/data/uploads chown -R may:may /app/data -# Run database migrations as the may user -gosu may flask db upgrade 2>/dev/null || true +# Run database migrations as the may user. Failures are logged rather than +# silently swallowed so upgrade problems are visible in container logs. +if ! gosu may flask db upgrade; then + echo "[entrypoint] flask db upgrade failed — the app will attempt schema recovery on startup." >&2 +fi # Drop to 'may' user and run the application exec gosu may "$@" diff --git a/migrations/versions/613be8af4376_allow_nullable_amount_on_recurring_.py b/migrations/versions/613be8af4376_allow_nullable_amount_on_recurring_.py index 7bf643c..fcb284e 100644 --- a/migrations/versions/613be8af4376_allow_nullable_amount_on_recurring_.py +++ b/migrations/versions/613be8af4376_allow_nullable_amount_on_recurring_.py @@ -7,6 +7,7 @@ """ from alembic import op import sqlalchemy as sa +from sqlalchemy import inspect # revision identifiers, used by Alembic. @@ -17,6 +18,17 @@ def upgrade(): + bind = op.get_bind() + inspector = inspect(bind) + if 'recurring_expenses' not in inspector.get_table_names(): + return + amount_col = next( + (c for c in inspector.get_columns('recurring_expenses') if c['name'] == 'amount'), + None, + ) + if amount_col is not None and amount_col.get('nullable') is True: + return + with op.batch_alter_table('recurring_expenses', schema=None) as batch_op: batch_op.alter_column('amount', existing_type=sa.FLOAT(), diff --git a/migrations/versions/998cdb1497c6_add_odometer_unit_to_vehicles.py b/migrations/versions/998cdb1497c6_add_odometer_unit_to_vehicles.py index bbccddc..cd4d3b8 100644 --- a/migrations/versions/998cdb1497c6_add_odometer_unit_to_vehicles.py +++ b/migrations/versions/998cdb1497c6_add_odometer_unit_to_vehicles.py @@ -7,6 +7,7 @@ """ from alembic import op import sqlalchemy as sa +from sqlalchemy import inspect # revision identifiers, used by Alembic. @@ -17,6 +18,14 @@ def upgrade(): + bind = op.get_bind() + inspector = inspect(bind) + if 'vehicles' not in inspector.get_table_names(): + return + existing_cols = [col['name'] for col in inspector.get_columns('vehicles')] + if 'odometer_unit' in existing_cols: + return + with op.batch_alter_table('vehicles', schema=None) as batch_op: batch_op.add_column(sa.Column('odometer_unit', sa.String(length=10), nullable=True)) diff --git a/migrations/versions/a1b2c3d4e5f6_add_default_vehicle_nullable_trip_end_odometer.py b/migrations/versions/a1b2c3d4e5f6_add_default_vehicle_nullable_trip_end_odometer.py index 1e5f30a..bae8e93 100644 --- a/migrations/versions/a1b2c3d4e5f6_add_default_vehicle_nullable_trip_end_odometer.py +++ b/migrations/versions/a1b2c3d4e5f6_add_default_vehicle_nullable_trip_end_odometer.py @@ -7,6 +7,7 @@ """ from alembic import op import sqlalchemy as sa +from sqlalchemy import inspect # revision identifiers, used by Alembic. @@ -17,12 +18,24 @@ def upgrade(): - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.add_column(sa.Column('default_vehicle_id', sa.Integer(), nullable=True)) - batch_op.create_foreign_key('fk_users_default_vehicle', 'vehicles', ['default_vehicle_id'], ['id']) - - with op.batch_alter_table('trips', schema=None) as batch_op: - batch_op.alter_column('end_odometer', existing_type=sa.Float(), nullable=True) + bind = op.get_bind() + inspector = inspect(bind) + + if 'users' in inspector.get_table_names(): + user_cols = [col['name'] for col in inspector.get_columns('users')] + if 'default_vehicle_id' not in user_cols: + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('default_vehicle_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key('fk_users_default_vehicle', 'vehicles', ['default_vehicle_id'], ['id']) + + if 'trips' in inspector.get_table_names(): + end_odo = next( + (c for c in inspector.get_columns('trips') if c['name'] == 'end_odometer'), + None, + ) + if end_odo is not None and end_odo.get('nullable') is not True: + with op.batch_alter_table('trips', schema=None) as batch_op: + batch_op.alter_column('end_odometer', existing_type=sa.Float(), nullable=True) def downgrade():