"""phase47 — inspection schedule end date Ports single-tenant phase44 onto MT's `inspection_schedules` table. Adds: end_date DATE NULL -- last date this schedule may produce an occurrence Separates the two ideas that `next_run_at` was carrying at once. `next_run_at` is *mutable state* — InspectionSchedule.advance_due_date() rewrites it after every occurrence, whether the inspector completed a plan-mode schedule or the cron materialised an auto-mode one — whereas `end_date` is a *fixed boundary* set by the manager and never touched by the app. NULL means "repeat indefinitely", which is the behaviour every existing row has today, so there is no backfill and no schedule changes cadence on deploy. Applies to BOTH modes. Scoping it to plan mode would leave an auto schedule materialising inspections past its boundary forever, which is the exact failure the column exists to prevent. Only meaningful for recurring schedules; the create/edit routes force it to NULL when frequency == 'once' (a one-time schedule already ends by deactivating on completion) and reject an end date submitted against one. Uses an INFORMATION_SCHEMA column-existence check — safe to re-run on every tenant DB. Additive only: no existing column is renamed, retyped or dropped. In particular `next_run_at` keeps its name and its index — it is the API payload key (`next_due_date`) the iPad decodes and drives the cron. """ revision = 'phase47_schedule_end_date' down_revision = 'phase46_schedule_recurrence' branch_labels = None depends_on = None from alembic import op import sqlalchemy as sa _TABLE = 'inspection_schedules' def _table_exists(conn, table): return conn.execute(sa.text( "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES " "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t" ), {"t": table}).scalar() > 0 def _column_exists(conn, table, column): return conn.execute(sa.text( "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " "WHERE TABLE_SCHEMA = DATABASE() " "AND TABLE_NAME = :t AND COLUMN_NAME = :c" ), {"t": table, "c": column}).scalar() > 0 def upgrade(): bind = op.get_bind() if not _table_exists(bind, _TABLE): return if not _column_exists(bind, _TABLE, 'end_date'): op.execute(sa.text( f"ALTER TABLE {_TABLE} ADD COLUMN end_date DATE NULL AFTER next_run_at" )) def downgrade(): bind = op.get_bind() if not _table_exists(bind, _TABLE): return if _column_exists(bind, _TABLE, 'end_date'): op.execute(sa.text( f"ALTER TABLE {_TABLE} DROP COLUMN end_date" ))