"""Allow indefinite policy action holds (policyactionholds.expiresat nullable). 0005 created expiresat NOT NULL because every hold was time-boxed: a hold that nobody remembered to clear would expire on its own and policy would resume. That self-healing property was the point of the column. Admins now need to hold an action for work that cannot be bounded up front, so a null expiresat is permitted and means indefinite - the hold stands until it is released by hand. Everything reading this table must test `expiresat IS NULL OR expiresat > now`; testing only the comparison silently drops indefinite holds and lets policy resume against an admin who asked it not to. No backfill: existing rows all carry a real expiry and keep it. Widening NOT NULL to NULL rewrites no data. Idempotent: the alter is skipped when the column is already nullable, so a test DB built by db.create_all() from the current model is left alone. Revision ID: computers0006holdindefinite Revises: computers0005policyactionholds The id is kept short deliberately: alembic_version.version_num is VARCHAR(32), and a longer id applies the DDL and then fails on the stamp, leaving the DB migrated but recorded as un-migrated. """ from alembic import op import sqlalchemy as sa revision = 'computers0006holdindefinite' down_revision = 'computers0005policyactionholds' branch_labels = None depends_on = None def _expiresat_is_nullable(bind): """True when policyactionholds.expiresat already permits null. Returns None when the table is absent entirely, which lets both directions no-op rather than raising on a DB where the computers chain has not built the table yet. """ insp = sa.inspect(bind) if 'policyactionholds' not in set(insp.get_table_names()): return None for column in insp.get_columns('policyactionholds'): if column['name'] == 'expiresat': return bool(column.get('nullable')) return None def upgrade(): bind = op.get_bind() nullable = _expiresat_is_nullable(bind) if nullable is None or nullable: return op.alter_column('policyactionholds', 'expiresat', existing_type=sa.DateTime(), nullable=True) def downgrade(): """Re-impose NOT NULL, giving any indefinite hold a bounded expiry. A straight alter would fail on the rows this feature exists to create, so indefinite holds are first stamped with the current time - expiring them rather than destroying them. That is the conservative direction: on downgrade, policy resuming is safer than reconciliation staying suppressed by a row the older code cannot represent. The audit row survives either way. """ bind = op.get_bind() nullable = _expiresat_is_nullable(bind) if nullable is None or not nullable: return op.execute(sa.text( 'UPDATE policyactionholds ' 'SET expiresat = CURRENT_TIMESTAMP ' 'WHERE expiresat IS NULL')) op.alter_column('policyactionholds', 'expiresat', existing_type=sa.DateTime(), nullable=False)