323 lines
9.8 KiB
Python
323 lines
9.8 KiB
Python
"""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')",
|
|
)
|