"""phase44 — issue internal_handler_name / internal_handler_contact, and convergence of handler_type to NOT NULL DEFAULT 'internal' Ports single-tenant phase41 + phase42 into the multi-tenant chain, and closes the last divergence in the handler model. 1. Adds `internal_handler_name` and `internal_handler_contact` to `issues`, capturing the janitorial staff member who will handle an issue when handler_type == 'internal'. Distinct from `assigned_to` (the JQC User who owns follow-up) — the actual crew member may not be a system user. Parallels the existing facility_handler_* and vendor_* contact pairs. 2. Converges `handler_type` from MT's `NULL`-able column onto the single-tenant definition, `NOT NULL DEFAULT 'internal'`. MT's application code already treats NULL and 'internal' as the same thing (`handler_type or 'internal'` appears in the model property, the API payload and the templates), so this only removes a redundant second representation of "internal". Existing NULL rows are backfilled to 'internal' BEFORE the MODIFY, otherwise the ALTER fails on a table containing NULLs. RE-RUNNABLE. Step 1 uses an INFORMATION_SCHEMA column-existence check. Step 2 cannot use one — the column already exists; what changes is its nullability — so it checks IS_NULLABLE instead and skips when the column is already NOT NULL. Checking only for existence there would silently make this migration a no-op on re-run against a half-applied schema. No batch_alter_table (MySQL). """ revision = 'phase44_internal_handler' down_revision = 'phase43_schedule_plan_fields' branch_labels = None depends_on = None from alembic import op import sqlalchemy as sa def _column_exists(conn, table, column): result = 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}) return result.scalar() > 0 def _column_is_nullable(conn, table, column): """True when the column exists AND is declared NULL-able. Returns False for a missing column so callers never attempt to MODIFY something that isn't there. """ result = conn.execute(sa.text( "SELECT IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " "WHERE TABLE_SCHEMA = DATABASE() " "AND TABLE_NAME = :t AND COLUMN_NAME = :c" ), {"t": table, "c": column}) row = result.first() return bool(row) and row[0] == 'YES' def upgrade(): bind = op.get_bind() # ── 1. internal_handler_name ────────────────────────────────────────── if not _column_exists(bind, 'issues', 'internal_handler_name'): op.execute(sa.text( "ALTER TABLE issues ADD COLUMN internal_handler_name VARCHAR(100) NULL" )) # ── 2. internal_handler_contact ─────────────────────────────────────── if not _column_exists(bind, 'issues', 'internal_handler_contact'): op.execute(sa.text( "ALTER TABLE issues ADD COLUMN internal_handler_contact VARCHAR(200) NULL" )) # ── 3. handler_type -> NOT NULL DEFAULT 'internal' ──────────────────── # Backfill first: MODIFY ... NOT NULL fails outright if any row holds NULL. # This runs unconditionally (it is itself idempotent — a second run matches # zero rows) so the data is correct even if a previous attempt aborted # between the UPDATE and the ALTER. if _column_is_nullable(bind, 'issues', 'handler_type'): op.execute(sa.text( "UPDATE issues SET handler_type = 'internal' WHERE handler_type IS NULL" )) op.execute(sa.text( "ALTER TABLE issues MODIFY COLUMN handler_type " "ENUM('internal','facility','vendor') NOT NULL DEFAULT 'internal'" )) def downgrade(): bind = op.get_bind() # Revert handler_type to nullable with no default. Stored values are left # as-is: rows that were NULL before the upgrade are now 'internal', which is # semantically identical under the application's `handler_type or 'internal'` # reads, so there is nothing to undo in the data. if not _column_is_nullable(bind, 'issues', 'handler_type'): op.execute(sa.text( "ALTER TABLE issues MODIFY COLUMN handler_type " "ENUM('internal','facility','vendor') NULL" )) if _column_exists(bind, 'issues', 'internal_handler_contact'): op.execute(sa.text("ALTER TABLE issues DROP COLUMN internal_handler_contact")) if _column_exists(bind, 'issues', 'internal_handler_name'): op.execute(sa.text("ALTER TABLE issues DROP COLUMN internal_handler_name"))