"""phase43 — plan semantics for inspection_schedules Extends MT's `inspection_schedules` (phase34) with the single-tenant "plan" model (ST phase36_scheduled_inspections), rather than adding a second, competing scheduler table. Adds: notes TEXT — free-text brief for the inspector last_completed_at DATETIME — when the last occurrence was fulfilled advance_notified BOOL — per-occurrence reminder de-dup flags, reset due_notified BOOL when a recurring schedule rolls forward overdue_notified BOOL mode ENUM — 'auto' = cron materialises the Inspection 'plan' = inspector clicks Start (ST behaviour) inspections.inspection_schedule_id — FK back to the originating schedule `next_run_at` (phase34) is reused as the due datetime — ST's `next_due_date` by another name. No duplicate column, no renames (rule 7). Existing rows keep working exactly as before: `mode` defaults to 'auto', which is the only behaviour that has ever existed in MT. Nothing flips to 'plan' unless someone chooses it in the form. INFORMATION_SCHEMA-guarded throughout — safe to re-run on every tenant DB. """ revision = 'phase43_schedule_plan_fields' down_revision = 'phase42_area_qr_token' branch_labels = None depends_on = None from alembic import op import sqlalchemy as sa 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 _fk_exists(conn, table, name): return conn.execute(sa.text( "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS " "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t " "AND CONSTRAINT_NAME = :n AND CONSTRAINT_TYPE = 'FOREIGN KEY'" ), {"t": table, "n": name}).scalar() > 0 def _index_exists(conn, table, index): return conn.execute(sa.text( "SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS " "WHERE TABLE_SCHEMA = DATABASE() " "AND TABLE_NAME = :t AND INDEX_NAME = :i" ), {"t": table, "i": index}).scalar() > 0 _NEW_COLUMNS = ( ('notes', 'TEXT NULL'), ('last_completed_at', 'DATETIME NULL'), ('advance_notified', 'TINYINT(1) NOT NULL DEFAULT 0'), ('due_notified', 'TINYINT(1) NOT NULL DEFAULT 0'), ('overdue_notified', 'TINYINT(1) NOT NULL DEFAULT 0'), ('mode', "ENUM('auto','plan') NOT NULL DEFAULT 'auto'"), ) def upgrade(): bind = op.get_bind() for name, ddl in _NEW_COLUMNS: if not _column_exists(bind, 'inspection_schedules', name): op.execute(sa.text( f"ALTER TABLE inspection_schedules ADD COLUMN {name} {ddl}" )) # Link a materialised/started Inspection back to its schedule. if not _column_exists(bind, 'inspections', 'inspection_schedule_id'): op.execute(sa.text( "ALTER TABLE inspections ADD COLUMN inspection_schedule_id INT NULL" )) if not _index_exists(bind, 'inspections', 'ix_inspections_inspection_schedule_id'): op.execute(sa.text( "CREATE INDEX ix_inspections_inspection_schedule_id " "ON inspections (inspection_schedule_id)" )) if not _fk_exists(bind, 'inspections', 'fk_inspections_inspection_schedule'): # ON DELETE SET NULL: deleting a schedule must never delete completed # inspection history. op.execute(sa.text( "ALTER TABLE inspections " "ADD CONSTRAINT fk_inspections_inspection_schedule " "FOREIGN KEY (inspection_schedule_id) " "REFERENCES inspection_schedules (id) ON DELETE SET NULL" )) def downgrade(): bind = op.get_bind() if _fk_exists(bind, 'inspections', 'fk_inspections_inspection_schedule'): op.execute(sa.text( "ALTER TABLE inspections DROP FOREIGN KEY fk_inspections_inspection_schedule" )) if _index_exists(bind, 'inspections', 'ix_inspections_inspection_schedule_id'): op.execute(sa.text( "DROP INDEX ix_inspections_inspection_schedule_id ON inspections" )) if _column_exists(bind, 'inspections', 'inspection_schedule_id'): op.execute(sa.text( "ALTER TABLE inspections DROP COLUMN inspection_schedule_id" )) for name, _ddl in reversed(_NEW_COLUMNS): if _column_exists(bind, 'inspection_schedules', name): op.execute(sa.text( f"ALTER TABLE inspection_schedules DROP COLUMN {name}" ))