"""Add ``api_key_assignments.notify_recipients`` JSON column. Revision ID: 146 Revises: 145 Create Date: 2026-05-11 Why: * Budget-alert recipients today are fully derived: the ``apikey.budget_check`` cron (PR #240) hard-codes "applicant + their direct manager from HRIS" for every 80% / 100% alert and the in-app notify_outbox row. That works for the common case but is wrong for two real cohorts in prod: 1. Shared / team-owned keys — the applicant is a service- account-style person record (or an early-onboarding stand-in) whose "direct manager" may not be the right escalation target for the real spending team. 2. Hand-off keys — the original applicant has rotated off the project; the new owner / finance partner should get the alerts, not the leaver. * Admins (and the assignment owner via /me) need a way to override who receives the budget alerts on a per-assignment basis without forcing every team to author a CmdbCostAllocationRule (heavier, CMDB-scoped concept). Schema: * ``notify_recipients JSON NULL`` — list[int] of ``identity_persons.id`` values. Semantics: - ``NULL`` → use default (applicant + their HRIS manager). The vast majority of rows stay on this path. - ``[]`` (empty array) → admin override "no recipients"; budget alerts are suppressed entirely for this assignment. Dangerous but legitimate (e.g. a paused project whose owner doesn't want to spam finance during a deliberate burn-down). - ``[, ...]`` → exactly these persons receive alerts. The applicant is NOT auto-included — admins explicitly choose every recipient. * Stored as JSON (not a join table) because: (a) the cron reads this column once per assignment per alert tick, no fan-out query needed; (b) recipients are dereferenced against ``identity_persons`` at notification dispatch time, so a deleted person id silently drops out without a CASCADE step; (c) the list is bounded — admin UX caps it well under 20 in practice. Same JSON column shape lives elsewhere in the schema (``cmdb_cost_allocation_rules.recipients`` had a similar pattern in earlier finops drafts but was dropped — this is the per-assignment equivalent). * No index — the column is only ever READ by primary key (``api_key_assignments.id``) from the cron and write path; never filtered on. Downgrade: drop the column. Stored recipient overrides are lost on downgrade — the cron falls back to the default applicant+manager path, which is the pre-migration behaviour. No data corruption: the column is purely advisory. """ from __future__ import annotations import sqlalchemy as sa from alembic import op revision = "146" down_revision = "145" branch_labels = None depends_on = None def _table_exists(bind: sa.engine.Connection, name: str) -> bool: inspector = sa.inspect(bind) return name in inspector.get_table_names() def _column_exists( bind: sa.engine.Connection, table: str, column: str, ) -> bool: inspector = sa.inspect(bind) if table not in inspector.get_table_names(): return False return any(c["name"] == column for c in inspector.get_columns(table)) def upgrade() -> None: bind = op.get_bind() if not _table_exists(bind, "api_key_assignments"): return if not _column_exists(bind, "api_key_assignments", "notify_recipients"): op.add_column( "api_key_assignments", sa.Column( "notify_recipients", sa.JSON(), nullable=True, ), ) def downgrade() -> None: bind = op.get_bind() if not _table_exists(bind, "api_key_assignments"): return if _column_exists(bind, "api_key_assignments", "notify_recipients"): op.drop_column("api_key_assignments", "notify_recipients")