@@ -120,79 +120,139 @@ def _bootstrap_alembic_version(app):
120120 app .logger .warning (f'Could not stamp alembic_version: { e } ' )
121121
122122
123- def _run_schema_migrations (app ):
124- """Add missing columns to existing tables .
123+ def _log_startup_banner (app ):
124+ """Log version + alembic state once on boot .
125125
126- SQLite doesn't support adding columns via db.create_all() for existing tables,
127- so we manually add any missing columns here.
126+ A short banner means a copy-paste of the container logs is enough to
127+ triage version-related upgrade bugs without having to ask the reporter
128+ for ``docker exec ... cat config.py``.
128129 """
129- from sqlalchemy import text , inspect
130+ from sqlalchemy import inspect , text
131+ try :
132+ from config import Config
133+ version = getattr (Config , 'DISPLAY_VERSION' , None ) or getattr (Config , 'APP_VERSION' , 'unknown' )
134+ except Exception :
135+ version = 'unknown'
130136
131- # Define schema migrations: table -> [(column_name, column_type), ...]
132- migrations = {
133- 'vehicles' : [
134- ('tessie_vin' , 'VARCHAR(20)' ),
135- ('tessie_enabled' , 'BOOLEAN DEFAULT 0' ),
136- ('tessie_last_odometer' , 'FLOAT' ),
137- ('tessie_battery_level' , 'INTEGER' ),
138- ('tessie_battery_range' , 'FLOAT' ),
139- ('tessie_last_updated' , 'DATETIME' ),
140- ('tracking_unit' , "VARCHAR(20) DEFAULT 'mileage'" ),
141- ('annual_mileage_limit' , 'FLOAT' ),
142- ('annual_mileage_start_date' , 'DATE' ),
143- ],
144- 'users' : [
145- ('date_format' , "VARCHAR(20) DEFAULT 'DD/MM/YYYY'" ),
146- ('password_reset_token' , 'VARCHAR(100)' ),
147- ('password_reset_expires' , 'DATETIME' ),
148- ('default_vehicle_id' , 'INTEGER REFERENCES vehicles(id)' ),
149- ],
150- 'charging_sessions' : [
151- ('tessie_charge_id' , 'VARCHAR(50)' ),
152- ],
153- }
154-
155- # Define unique indexes to create after adding columns
156- # SQLite doesn't support adding UNIQUE columns directly via ALTER TABLE
157- unique_indexes = [
158- ('charging_sessions' , 'tessie_charge_id' , 'ix_charging_sessions_tessie_charge_id' ),
159- ('users' , 'password_reset_token' , 'ix_users_password_reset_token' ),
160- ]
161-
162- with db .engine .connect () as conn :
137+ alembic_rev = 'unset'
138+ try :
163139 inspector = inspect (db .engine )
140+ if 'alembic_version' in inspector .get_table_names ():
141+ with db .engine .begin () as conn :
142+ alembic_rev = conn .execute (
143+ text ('SELECT version_num FROM alembic_version' )
144+ ).scalar () or 'empty'
145+ except Exception as e :
146+ alembic_rev = f'error: { e } '
147+
148+ app .logger .warning (f'May { version } starting (alembic_version={ alembic_rev } )' )
149+
150+
151+ def _scalar_default_sql (column ):
152+ """Render a SQLAlchemy column's Python-side default as a SQL literal.
153+
154+ Returns None when no scalar default is set, when the default is callable
155+ (e.g. ``datetime.utcnow``), or when the value is a type we cannot safely
156+ embed in DDL. Callable defaults are intentionally skipped: ``ALTER TABLE``
157+ fills existing rows once at column-creation time, so the captured value
158+ would be misleading anyway.
159+ """
160+ default = column .default
161+ if default is None or not getattr (default , 'is_scalar' , False ):
162+ return None
163+ arg = default .arg
164+ if isinstance (arg , bool ):
165+ return '1' if arg else '0'
166+ if isinstance (arg , (int , float )):
167+ return str (arg )
168+ if isinstance (arg , str ):
169+ return "'" + arg .replace ("'" , "''" ) + "'"
170+ return None
171+
172+
173+ def _add_column_clause (column , dialect ):
174+ """Build the body of an ``ALTER TABLE ... ADD COLUMN`` from a SQLAlchemy column.
175+
176+ Drops any UNIQUE flag — SQLite refuses inline UNIQUE on ``ADD COLUMN`` and
177+ we recreate uniqueness as a separate index. ``NOT NULL`` is only emitted
178+ when there is also a scalar default; otherwise existing rows would
179+ instantly violate the constraint.
180+ """
181+ parts = [column .name , column .type .compile (dialect = dialect )]
182+ default_sql = _scalar_default_sql (column )
183+ if default_sql is not None :
184+ parts .append (f'DEFAULT { default_sql } ' )
185+ if not column .nullable :
186+ parts .append ('NOT NULL' )
187+ for fk in column .foreign_keys :
188+ ref_table , ref_col = fk .target_fullname .split ('.' , 1 )
189+ parts .append (f'REFERENCES { ref_table } ({ ref_col } )' )
190+ return ' ' .join (parts )
164191
165- for table_name , columns in migrations .items ():
166- # Check if table exists
167- if table_name not in inspector .get_table_names ():
168- continue
169192
170- # Get existing columns
171- existing_cols = [col ['name' ] for col in inspector .get_columns (table_name )]
172-
173- # Add missing columns
174- for col_name , col_type in columns :
175- if col_name not in existing_cols :
176- try :
177- conn .execute (text (f'ALTER TABLE { table_name } ADD COLUMN { col_name } { col_type } ' ))
178- app .logger .info (f'Added column { col_name } to { table_name } ' )
179- except Exception as e :
180- app .logger .warning (f'Could not add column { col_name } to { table_name } : { e } ' )
181-
182- # Create unique indexes
183- for table_name , col_name , index_name in unique_indexes :
184- if table_name not in inspector .get_table_names ():
193+ def _run_schema_migrations (app ):
194+ """Add columns defined on the SQLAlchemy models but missing from existing tables.
195+
196+ ``db.create_all()`` only creates missing tables — never alters existing
197+ ones — so when a model gains a new column the database it is pointed at
198+ keeps the old shape. This routine walks every table+column in
199+ ``db.metadata`` and issues an ``ALTER TABLE ... ADD COLUMN`` for each
200+ column the live database is missing. Unique constraints are added via a
201+ separate ``CREATE UNIQUE INDEX`` because SQLite rejects inline ``UNIQUE``
202+ on ``ALTER TABLE ADD COLUMN``.
203+
204+ Failures are logged rather than raised: a single column we cannot apply
205+ cleanly (for example ``NOT NULL`` without a default on a populated table)
206+ must not block the rest of the schema from catching up. Issue #166 was a
207+ direct consequence of the previous hardcoded list missing newly-added
208+ User columns; the model-driven walk keeps recovery in lockstep with the
209+ models automatically.
210+ """
211+ from sqlalchemy import text , inspect
212+
213+ inspector = inspect (db .engine )
214+ existing_tables = set (inspector .get_table_names ())
215+ dialect = db .engine .dialect
216+
217+ with db .engine .begin () as conn :
218+ for table in db .metadata .tables .values ():
219+ if table .name not in existing_tables :
185220 continue
186- # Check if index already exists
187- existing_indexes = [idx ['name' ] for idx in inspector .get_indexes (table_name )]
188- if index_name not in existing_indexes :
221+ existing_cols = {col ['name' ] for col in inspector .get_columns (table .name )}
222+ for column in table .columns :
223+ if column .name in existing_cols :
224+ continue
225+ clause = _add_column_clause (column , dialect )
189226 try :
190- conn .execute (text (f'CREATE UNIQUE INDEX { index_name } ON { table_name } ( { col_name } ) ' ))
191- app .logger .info (f'Created unique index { index_name } on { table_name } . { col_name } ' )
227+ conn .execute (text (f'ALTER TABLE { table . name } ADD COLUMN { clause } ' ))
228+ app .logger .info (f'Added column { column . name } to { table . name } ' )
192229 except Exception as e :
193- app .logger .warning (f'Could not create index { index_name } : { e } ' )
230+ app .logger .warning (
231+ f'Could not add column { column .name } to { table .name } : { e } '
232+ )
194233
195- conn .commit ()
234+ # Re-inspect so we see indexes on freshly-added columns.
235+ inspector = inspect (db .engine )
236+ for table in db .metadata .tables .values ():
237+ if table .name not in existing_tables :
238+ continue
239+ existing_indexes = {idx ['name' ] for idx in inspector .get_indexes (table .name )}
240+ for column in table .columns :
241+ if not (column .unique or column .index ):
242+ continue
243+ index_name = f'ix_{ table .name } _{ column .name } '
244+ if index_name in existing_indexes :
245+ continue
246+ kind = 'UNIQUE INDEX' if column .unique else 'INDEX'
247+ try :
248+ conn .execute (text (
249+ f'CREATE { kind } { index_name } ON { table .name } ({ column .name } )'
250+ ))
251+ app .logger .info (f'Created { kind .lower ()} { index_name } ' )
252+ except Exception as e :
253+ app .logger .warning (
254+ f'Could not create { kind .lower ()} { index_name } : { e } '
255+ )
196256
197257
198258def get_locale ():
@@ -302,6 +362,11 @@ def add_security_headers(response):
302362 return response
303363
304364 with app .app_context ():
365+ # Surface the running version up front so anyone reading the logs
366+ # for a startup failure can immediately tell what they're looking
367+ # at — issue #166 stalled on triage because nothing in the worker
368+ # boot trace identified the image version.
369+ _log_startup_banner (app )
305370 db .create_all ()
306371 # Stamp alembic_version for pre-Flask-Migrate databases so future
307372 # `flask db upgrade` runs apply only pending migrations.
0 commit comments