Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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)'),
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
7 changes: 5 additions & 2 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 "$@"
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect


# revision identifiers, used by Alembic.
Expand All @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect


# revision identifiers, used by Alembic.
Expand All @@ -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))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect


# revision identifiers, used by Alembic.
Expand All @@ -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():
Expand Down
Loading