"""phase48 — scheduled follow-up: link a schedule back to its parent inspection Ports single-tenant phase45 onto MT's `inspection_schedules` table. Adds: parent_inspection_id INT NULL -- inspection this schedule is a follow-up of Lets a follow-up be *planned for a later date* rather than started immediately — the deferred twin of "Re-inspect Now". A one-time schedule carrying this column is created via POST /api/v1/scheduled-inspections/follow-up; when the inspector eventually starts it, the resulting inspection inherits `parent_inspection_id`, so it lands as a true linked re-inspection: pre-filled from the parent, and clearing the parent's `follow_up_required` on submit. Without the column the scheduled run would be an ordinary inspection — no link, no prefill, and the parent's follow-up flag would stay set forever. Depends on phase45's `once` frequency: a follow-up is a single planned visit, not a recurrence. NULL means "not a follow-up", which is what every existing row is, so there is no backfill and no schedule changes behaviour on deploy. ON DELETE SET NULL: deleting the parent inspection must not cascade away a schedule the inspector still has to perform — it just stops being a follow-up. This mirrors `inspections.inspection_schedule_id`, which points the other way with the same rule, so neither side of the pair can delete the other's history. Uses INFORMATION_SCHEMA existence checks — safe to re-run on every tenant DB. Additive only: nothing is renamed, retyped or dropped. Revision id note ---------------- `alembic_version.version_num` is VARCHAR(32). The descriptive form 'phase48_schedule_parent_inspection' is 34 characters and fails the stamp UPDATE after the DDL has already run. The id is abbreviated to fit, matching the single-tenant chain's own 'phase45_sched_parent_insp'. The FILENAME stays descriptive — Alembic keys on the `revision` string, not the filename. """ revision = 'phase48_sched_parent_insp' down_revision = 'phase47_schedule_end_date' branch_labels = None depends_on = None from alembic import op import sqlalchemy as sa _TABLE = 'inspection_schedules' _COLUMN = 'parent_inspection_id' _FK = 'fk_inspection_schedules_parent_inspection' _INDEX = 'ix_inspection_schedules_parent_inspection_id' 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 _constraint_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" ), {"t": table, "n": name}).scalar() > 0 def _index_exists(conn, table, name): return conn.execute(sa.text( "SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS " "WHERE TABLE_SCHEMA = DATABASE() " "AND TABLE_NAME = :t AND INDEX_NAME = :n" ), {"t": table, "n": name}).scalar() > 0 def upgrade(): bind = op.get_bind() if not _table_exists(bind, _TABLE): return if not _column_exists(bind, _TABLE, _COLUMN): op.execute(sa.text( f"ALTER TABLE {_TABLE} ADD COLUMN {_COLUMN} INT NULL AFTER notes" )) # Named explicitly so the downgrade and the re-run check can find it. MySQL # auto-creates an index for a foreign key, but only when no usable index # exists; creating it first means the name is ours and predictable. if not _index_exists(bind, _TABLE, _INDEX): op.execute(sa.text( f"CREATE INDEX {_INDEX} ON {_TABLE} ({_COLUMN})" )) if not _constraint_exists(bind, _TABLE, _FK): op.execute(sa.text( f"ALTER TABLE {_TABLE} ADD CONSTRAINT {_FK} " f"FOREIGN KEY ({_COLUMN}) REFERENCES inspections(id) " f"ON DELETE SET NULL" )) def downgrade(): bind = op.get_bind() if not _table_exists(bind, _TABLE): return # FK first — MySQL refuses to drop a column or index still referenced by one. if _constraint_exists(bind, _TABLE, _FK): op.execute(sa.text(f"ALTER TABLE {_TABLE} DROP FOREIGN KEY {_FK}")) if _index_exists(bind, _TABLE, _INDEX): op.execute(sa.text(f"DROP INDEX {_INDEX} ON {_TABLE}")) if _column_exists(bind, _TABLE, _COLUMN): op.execute(sa.text(f"ALTER TABLE {_TABLE} DROP COLUMN {_COLUMN}"))