"""add begin repair workflow Revision ID: 0010_begin_repair Revises: 0009_inventory_asset_id_history Create Date: 2026-07-29 """ from typing import Sequence, Union import sqlalchemy as sa from alembic import op from sqlalchemy.dialects import postgresql revision: str = "0010_begin_repair" down_revision: Union[str, None] = "0009_inventory_asset_id_history" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None REPAIR_STATUSES = ( "OPEN", "IN_PROGRESS", "WAITING_FOR_PARTS", "READY_FOR_TEST", "COMPLETED", "CANCELLED", ) ACTIVE_REPAIR_STATUSES = ( "OPEN", "IN_PROGRESS", "WAITING_FOR_PARTS", "READY_FOR_TEST", ) REPAIR_REASONS = ( "HARDWARE_FAILURE", "LOW_HASHRATE", "CONTROL_BOARD", "HASHBOARD", "PSU", "FAN", "OVERHEATING", "NETWORKING", "PREVENTIVE_MAINTENANCE", "INSPECTION", "OTHER", ) REPAIR_EVENT_TYPES = ("REPAIR_STARTED", "INITIAL_NOTE") def _quoted(values: Sequence[str]) -> str: return ", ".join(f"'{value}'" for value in values) def upgrade() -> None: connection = op.get_bind() op.drop_constraint("ck_miners_lifecycle_status", "miners", type_="check") op.create_check_constraint( "ck_miners_lifecycle_status", "miners", "lifecycle_status IN ('active', 'spare', 'retired', 'in_repair')", ) connection.exec_driver_sql( """ INSERT INTO permissions (key, description) VALUES ('repairs.create', 'Begin repairs for miner assets') ON CONFLICT (key) DO UPDATE SET description = EXCLUDED.description """ ) connection.exec_driver_sql( """ INSERT INTO role_permissions (role_id, permission_id) SELECT roles.id, permissions.id FROM roles JOIN permissions ON permissions.key = 'repairs.create' WHERE roles.name IN ('Admin', 'Manager', 'Technician') ON CONFLICT DO NOTHING """ ) op.create_table( "repairs", sa.Column( "id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()"), ), sa.Column( "miner_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("miners.id", ondelete="RESTRICT"), nullable=False, ), sa.Column( "status", sa.Text(), nullable=False, server_default=sa.text("'OPEN'"), ), sa.Column("reason", sa.Text(), nullable=False), sa.Column( "opened_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()"), ), sa.Column( "opened_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, ), sa.Column( "completed_at", sa.DateTime(timezone=True), nullable=True, ), sa.Column( "completed_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=True, ), sa.CheckConstraint( f"status IN ({_quoted(REPAIR_STATUSES)})", name="ck_repairs_status", ), sa.CheckConstraint( f"reason IN ({_quoted(REPAIR_REASONS)})", name="ck_repairs_reason", ), sa.CheckConstraint( "(completed_at IS NULL AND completed_by IS NULL) OR " "(completed_at IS NOT NULL AND completed_by IS NOT NULL)", name="ck_repairs_completion_pair", ), sa.CheckConstraint( "(status = 'COMPLETED') = (completed_at IS NOT NULL)", name="ck_repairs_completed_state", ), ) op.create_index( "idx_repairs_miner_opened_at", "repairs", ["miner_id", "opened_at", "id"], ) op.create_index("idx_repairs_opened_by", "repairs", ["opened_by"]) op.create_index("idx_repairs_completed_by", "repairs", ["completed_by"]) op.create_index( "uq_repairs_one_active_per_miner", "repairs", ["miner_id"], unique=True, postgresql_where=sa.text( f"status IN ({_quoted(ACTIVE_REPAIR_STATUSES)})" ), ) op.create_table( "repair_events", sa.Column( "id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()"), ), sa.Column( "repair_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("repairs.id", ondelete="RESTRICT"), nullable=False, ), sa.Column("sequence", sa.Integer(), nullable=False), sa.Column("event_type", sa.Text(), nullable=False), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()"), ), sa.Column( "created_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, ), sa.Column( "payload", postgresql.JSONB(), nullable=False, ), sa.CheckConstraint( f"event_type IN ({_quoted(REPAIR_EVENT_TYPES)})", name="ck_repair_events_type", ), sa.CheckConstraint( "sequence > 0", name="ck_repair_events_sequence_positive", ), sa.CheckConstraint( "jsonb_typeof(payload) = 'object'", name="ck_repair_events_payload_object", ), sa.CheckConstraint( "(" "event_type = 'REPAIR_STARTED' " "AND payload ?& ARRAY['reason', 'from_lifecycle', 'to_lifecycle'] " f"AND payload ->> 'reason' IN ({_quoted(REPAIR_REASONS)}) " "AND payload ->> 'from_lifecycle' IN ('active', 'spare') " "AND payload ->> 'to_lifecycle' = 'in_repair'" ") OR (" "event_type = 'INITIAL_NOTE' " "AND jsonb_typeof(payload -> 'note') = 'string' " "AND trim(payload ->> 'note') <> ''" ")", name="ck_repair_events_payload_shape", ), sa.UniqueConstraint( "repair_id", "sequence", name="uq_repair_events_repair_sequence", ), ) op.create_index( "idx_repair_events_repair_created", "repair_events", ["repair_id", "created_at", "sequence", "id"], ) op.create_index( "idx_repair_events_created_by", "repair_events", ["created_by"], ) def downgrade() -> None: connection = op.get_bind() connection.exec_driver_sql( f""" DO $$ DECLARE invalid_miner_ids TEXT; BEGIN SELECT string_agg(miner.id::text, ', ' ORDER BY miner.id::text) INTO invalid_miner_ids FROM miners AS miner LEFT JOIN LATERAL ( SELECT count(*) AS started_event_count FROM repairs AS repair JOIN repair_events AS event ON event.repair_id = repair.id AND event.event_type = 'REPAIR_STARTED' WHERE repair.miner_id = miner.id AND repair.status IN ({_quoted(ACTIVE_REPAIR_STATUSES)}) ) AS history ON TRUE WHERE miner.lifecycle_status = 'in_repair' AND history.started_event_count <> 1; IF invalid_miner_ids IS NOT NULL THEN RAISE EXCEPTION USING MESSAGE = format( 'Cannot downgrade Begin Repair: miners %s do not have exactly one active REPAIR_STARTED event', invalid_miner_ids ), HINT = 'Repair the affected lifecycle history before downgrading; MinerOps will not invent a prior lifecycle value.'; END IF; END $$; """ ) connection.exec_driver_sql( f""" UPDATE miners AS miner SET lifecycle_status = history.from_lifecycle, updated_at = now() FROM ( SELECT repair.miner_id, event.payload ->> 'from_lifecycle' AS from_lifecycle FROM repairs AS repair JOIN repair_events AS event ON event.repair_id = repair.id AND event.event_type = 'REPAIR_STARTED' WHERE repair.status IN ({_quoted(ACTIVE_REPAIR_STATUSES)}) ) AS history WHERE miner.id = history.miner_id AND miner.lifecycle_status = 'in_repair' """ ) op.drop_index("idx_repair_events_created_by", table_name="repair_events") op.drop_index("idx_repair_events_repair_created", table_name="repair_events") op.drop_table("repair_events") op.drop_index("uq_repairs_one_active_per_miner", table_name="repairs") op.drop_index("idx_repairs_completed_by", table_name="repairs") op.drop_index("idx_repairs_opened_by", table_name="repairs") op.drop_index("idx_repairs_miner_opened_at", table_name="repairs") op.drop_table("repairs") connection.exec_driver_sql( """ DELETE FROM role_permissions WHERE permission_id = ( SELECT id FROM permissions WHERE key = 'repairs.create' ) """ ) connection.exec_driver_sql( "DELETE FROM permissions WHERE key = 'repairs.create'" ) op.drop_constraint("ck_miners_lifecycle_status", "miners", type_="check") op.create_check_constraint( "ck_miners_lifecycle_status", "miners", "lifecycle_status IN ('active', 'spare', 'retired')", )