"""tenant-scope Hermes profiles, reports, and scheduled work Revision ID: 20260717_0028 Revises: 20260717_0027 Create Date: 2026-07-17 """ from __future__ import annotations from collections.abc import Sequence import sqlalchemy as sa from alembic import op revision: str = "20260717_0028" down_revision: str | None = "20260717_0027" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def _require_postgresql() -> None: dialect_name = op.get_bind().dialect.name if dialect_name != "postgresql": raise RuntimeError( "20260717_0028 only supports PostgreSQL; " f"refusing to mutate {dialect_name} without transactional constraint DDL" ) def _inspector() -> sa.Inspector: return sa.inspect(op.get_bind()) def _has_table(table_name: str) -> bool: return _inspector().has_table(table_name) def _has_column(table_name: str, column_name: str) -> bool: if not _has_table(table_name): return False return column_name in {str(item["name"]) for item in _inspector().get_columns(table_name)} def _constraint_exists(table_name: str, constraint_name: str) -> bool: if not _has_table(table_name): return False inspector = _inspector() names = { str(item.get("name") or "") for item in ( *inspector.get_unique_constraints(table_name), *inspector.get_foreign_keys(table_name), *inspector.get_check_constraints(table_name), ) } return constraint_name in names def _index_exists(table_name: str, index_name: str) -> bool: if not _has_table(table_name): return False return index_name in { str(item.get("name") or "") for item in _inspector().get_indexes(table_name) } def _drop_foreign_keys_for_columns( table_name: str, columns: tuple[str, ...], ) -> None: if not _has_table(table_name): return for item in _inspector().get_foreign_keys(table_name): if tuple(item.get("constrained_columns") or ()) != columns: continue name = str(item.get("name") or "") if name: op.drop_constraint(name, table_name, type_="foreignkey") def upgrade() -> None: _require_postgresql() _scope_profile_snapshots() _scope_hermes_task_tables() _scope_hermes_risk_reports() _create_finance_report_tables() def downgrade() -> None: _require_postgresql() _assert_safe_downgrade() if _has_table("tenant_finance_report_runs"): op.drop_table("tenant_finance_report_runs") if _has_table("tenant_finance_report_configs"): op.drop_table("tenant_finance_report_configs") _downgrade_risk_reports() _downgrade_task_tables() _downgrade_profile_snapshots() def _scope_profile_snapshots() -> None: table = "employee_behavior_profile_snapshots" if not _has_table(table): return op.add_column( table, sa.Column("tenant_id", sa.String(length=64), nullable=False, server_default="default"), ) op.create_foreign_key( "fk_employee_behavior_profiles_tenant", table, "tenants", ["tenant_id"], ["tenant_id"], ondelete="RESTRICT", ) op.create_unique_constraint( "uq_employee_behavior_profiles_tenant_id", table, ["tenant_id", "id"], ) if _has_table("employees"): op.create_foreign_key( "fk_employee_behavior_profiles_tenant_employee", table, "employees", ["tenant_id", "subject_id"], ["tenant_id", "id"], ondelete="CASCADE", ) if _index_exists(table, "ix_employee_behavior_profile_latest"): op.drop_index("ix_employee_behavior_profile_latest", table_name=table) op.create_index( "ix_employee_behavior_profile_latest", table, [ "tenant_id", "subject_id", "profile_type", "window_days", "expense_type_scope", "calculated_at", ], ) op.alter_column(table, "tenant_id", server_default=None) def _scope_hermes_task_tables() -> None: config_table = "hermes_task_configs" log_table = "hermes_task_execution_logs" for table, fk_name in ( (config_table, "fk_hermes_task_configs_tenant"), (log_table, "fk_hermes_task_execution_logs_tenant"), ): if not _has_table(table): continue op.add_column( table, sa.Column( "tenant_id", sa.String(length=64), nullable=False, server_default="default", ), ) op.create_foreign_key( fk_name, table, "tenants", ["tenant_id"], ["tenant_id"], ondelete="RESTRICT", ) op.alter_column(table, "tenant_id", server_default=None) if _has_table(config_table): op.create_unique_constraint( "uq_hermes_task_configs_tenant_id", config_table, ["tenant_id", "id"], ) op.create_index( "ix_hermes_task_configs_tenant_enabled", config_table, ["tenant_id", "is_enabled"], ) if _has_table(log_table): op.create_unique_constraint( "uq_hermes_task_execution_logs_tenant_id", log_table, ["tenant_id", "id"], ) if _has_table(config_table): _drop_foreign_keys_for_columns(log_table, ("config_id",)) op.create_foreign_key( "fk_hermes_task_logs_tenant_config", log_table, config_table, ["tenant_id", "config_id"], ["tenant_id", "id"], ondelete="CASCADE", ) op.create_index( "ix_hermes_task_logs_tenant_started", log_table, ["tenant_id", "started_at"], ) def _scope_hermes_risk_reports() -> None: table = "hermes_risk_reports" if not _has_table(table): return op.add_column( table, sa.Column("tenant_id", sa.String(length=64), nullable=False, server_default="default"), ) op.create_foreign_key( "fk_hermes_risk_reports_tenant", table, "tenants", ["tenant_id"], ["tenant_id"], ondelete="RESTRICT", ) op.create_unique_constraint( "uq_hermes_risk_reports_tenant_id", table, ["tenant_id", "id"], ) if _has_table("expense_claims"): _drop_foreign_keys_for_columns(table, ("claim_id",)) op.create_foreign_key( "fk_hermes_risk_reports_tenant_claim", table, "expense_claims", ["tenant_id", "claim_id"], ["tenant_id", "id"], ondelete="CASCADE", ) if _has_table("hermes_task_execution_logs"): _drop_foreign_keys_for_columns(table, ("execution_log_id",)) op.create_foreign_key( "fk_hermes_risk_reports_tenant_log", table, "hermes_task_execution_logs", ["tenant_id", "execution_log_id"], ["tenant_id", "id"], ondelete="CASCADE", ) op.create_index( "ix_hermes_risk_reports_tenant_status", table, ["tenant_id", "status"], ) op.alter_column(table, "tenant_id", server_default=None) def _create_finance_report_tables() -> None: if not _has_table("tenant_finance_report_configs"): op.create_table( "tenant_finance_report_configs", sa.Column("tenant_id", sa.String(length=64), nullable=False), sa.Column("status", sa.String(length=20), nullable=False, server_default="disabled"), sa.Column("delivery_enabled", sa.Boolean(), nullable=False, server_default=sa.false()), sa.Column("recipients_json", sa.JSON(), nullable=False, server_default="[]"), sa.Column("updated_by", sa.String(length=100), nullable=False, server_default=""), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), sa.Column( "updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), sa.CheckConstraint( "status IN ('active', 'disabled')", name="ck_tenant_finance_report_configs_status", ), sa.ForeignKeyConstraint( ["tenant_id"], ["tenants.tenant_id"], ondelete="CASCADE", ), sa.PrimaryKeyConstraint("tenant_id"), ) if not _has_table("tenant_finance_report_runs"): op.create_table( "tenant_finance_report_runs", sa.Column("id", sa.String(length=36), nullable=False), sa.Column("tenant_id", sa.String(length=64), nullable=False), sa.Column("report_type", sa.String(length=20), nullable=False), sa.Column("period_start", sa.Date(), nullable=False), sa.Column("period_end", sa.Date(), nullable=False), sa.Column("idempotency_key", sa.String(length=180), nullable=False), sa.Column("status", sa.String(length=20), nullable=False, server_default="running"), sa.Column("agent_run_id", sa.String(length=50), nullable=True), sa.Column("storage_key", sa.String(length=512), nullable=False, server_default=""), sa.Column("result_json", sa.JSON(), nullable=False, server_default="{}"), sa.Column("error_message", sa.Text(), nullable=True), sa.Column( "started_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True), sa.CheckConstraint( "report_type IN ('weekly', 'quarterly', 'annual')", name="ck_tenant_finance_report_runs_type", ), sa.CheckConstraint( "status IN ('running', 'succeeded', 'failed')", name="ck_tenant_finance_report_runs_status", ), sa.ForeignKeyConstraint( ["tenant_id"], ["tenants.tenant_id"], ondelete="CASCADE", ), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint( "tenant_id", "idempotency_key", name="uq_tenant_finance_report_runs_idempotency", ), ) op.create_index( "ix_tenant_finance_report_runs_period", "tenant_finance_report_runs", ["tenant_id", "report_type", "period_start", "period_end"], ) op.create_index( "ix_tenant_finance_report_runs_agent_run_id", "tenant_finance_report_runs", ["agent_run_id"], ) def _assert_safe_downgrade() -> None: connection = op.get_bind() for table in ( "employee_behavior_profile_snapshots", "hermes_task_configs", "hermes_task_execution_logs", "hermes_risk_reports", ): if not _has_column(table, "tenant_id"): continue count = connection.execute( sa.text(f"SELECT COUNT(*) FROM {table} WHERE tenant_id <> 'default'") ).scalar_one() if int(count or 0) > 0: raise RuntimeError(f"Refusing downgrade: {table} contains non-default tenant rows.") for table in ("tenant_finance_report_configs", "tenant_finance_report_runs"): if not _has_table(table): continue count = connection.execute(sa.text(f"SELECT COUNT(*) FROM {table}")).scalar_one() if int(count or 0) > 0: raise RuntimeError(f"Refusing downgrade: {table} contains data.") def _drop_constraint_if_exists(table: str, name: str, type_: str) -> None: if _constraint_exists(table, name): op.drop_constraint(name, table, type_=type_) def _drop_index_if_exists(table: str, name: str) -> None: if _index_exists(table, name): op.drop_index(name, table_name=table) def _downgrade_risk_reports() -> None: table = "hermes_risk_reports" if not _has_column(table, "tenant_id"): return _drop_index_if_exists(table, "ix_hermes_risk_reports_tenant_status") _drop_constraint_if_exists(table, "fk_hermes_risk_reports_tenant_log", "foreignkey") _drop_constraint_if_exists(table, "fk_hermes_risk_reports_tenant_claim", "foreignkey") _drop_constraint_if_exists(table, "uq_hermes_risk_reports_tenant_id", "unique") _drop_constraint_if_exists(table, "fk_hermes_risk_reports_tenant", "foreignkey") if _has_table("expense_claims"): op.create_foreign_key( "hermes_risk_reports_claim_id_fkey", table, "expense_claims", ["claim_id"], ["id"], ondelete="CASCADE", ) if _has_table("hermes_task_execution_logs"): op.create_foreign_key( "hermes_risk_reports_execution_log_id_fkey", table, "hermes_task_execution_logs", ["execution_log_id"], ["id"], ondelete="CASCADE", ) op.drop_column(table, "tenant_id") def _downgrade_task_tables() -> None: config_table = "hermes_task_configs" log_table = "hermes_task_execution_logs" if _has_column(log_table, "tenant_id"): _drop_index_if_exists(log_table, "ix_hermes_task_logs_tenant_started") _drop_constraint_if_exists( log_table, "fk_hermes_task_logs_tenant_config", "foreignkey", ) _drop_constraint_if_exists( log_table, "uq_hermes_task_execution_logs_tenant_id", "unique", ) _drop_constraint_if_exists( log_table, "fk_hermes_task_execution_logs_tenant", "foreignkey", ) if _has_table(config_table): op.create_foreign_key( "hermes_task_execution_logs_config_id_fkey", log_table, config_table, ["config_id"], ["id"], ) op.drop_column(log_table, "tenant_id") if _has_column(config_table, "tenant_id"): _drop_index_if_exists(config_table, "ix_hermes_task_configs_tenant_enabled") _drop_constraint_if_exists( config_table, "uq_hermes_task_configs_tenant_id", "unique", ) _drop_constraint_if_exists( config_table, "fk_hermes_task_configs_tenant", "foreignkey", ) op.drop_column(config_table, "tenant_id") def _downgrade_profile_snapshots() -> None: table = "employee_behavior_profile_snapshots" if not _has_column(table, "tenant_id"): return _drop_index_if_exists(table, "ix_employee_behavior_profile_latest") _drop_constraint_if_exists( table, "fk_employee_behavior_profiles_tenant_employee", "foreignkey", ) _drop_constraint_if_exists( table, "uq_employee_behavior_profiles_tenant_id", "unique", ) _drop_constraint_if_exists( table, "fk_employee_behavior_profiles_tenant", "foreignkey", ) op.drop_column(table, "tenant_id") op.create_index( "ix_employee_behavior_profile_latest", table, [ "subject_id", "profile_type", "window_days", "expense_type_scope", "calculated_at", ], )