feat: add return to service workflow

This commit is contained in:
lussierj committed 2026-07-29 19:02:23 +00:00
1 parent 72ad19a071
commit f2b736b08e
19 files changed
+2883 -46

No files matched your search

@@ -0,0 +1,253 @@
"""add return to service workflow
Revision ID: 0012_return_to_service
Revises: 0011_repair_timeline
Create Date: 2026-07-29
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0012_return_to_service"
down_revision: Union[str, None] = "0011_repair_timeline"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
REPAIR_REASONS = (
"HARDWARE_FAILURE",
"LOW_HASHRATE",
"CONTROL_BOARD",
"HASHBOARD",
"PSU",
"FAN",
"OVERHEATING",
"NETWORKING",
"PREVENTIVE_MAINTENANCE",
"INSPECTION",
"OTHER",
)
def _quoted(values: Sequence[str]) -> str:
return ", ".join(f"'{value}'" for value in values)
def _create_event_constraints() -> None:
op.create_check_constraint(
"ck_repair_events_type",
"repair_events",
"event_type IN ('REPAIR_STARTED', 'INITIAL_NOTE', "
"'REPAIR_COMPLETED')",
)
op.create_check_constraint(
"ck_repair_events_payload_shape",
"repair_events",
"("
"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') <> ''"
") OR ("
"event_type = 'REPAIR_COMPLETED' "
"AND payload ?& ARRAY["
"'previous_miner_lifecycle', 'resulting_miner_lifecycle'"
"] "
"AND payload ->> 'previous_miner_lifecycle' = 'in_repair' "
"AND payload ->> 'resulting_miner_lifecycle' "
"IN ('active', 'spare') "
"AND (NOT payload ? 'pre_repair_lifecycle' "
"OR payload ->> 'pre_repair_lifecycle' IN ('active', 'spare')) "
"AND (NOT payload ? 'completion_summary' OR ("
"jsonb_typeof(payload -> 'completion_summary') = 'string' "
"AND trim(payload ->> 'completion_summary') <> '' "
"AND char_length(payload ->> 'completion_summary') <= 4000"
"))"
")",
)
def _create_previous_event_constraints() -> None:
op.create_check_constraint(
"ck_repair_events_type",
"repair_events",
"event_type IN ('REPAIR_STARTED', 'INITIAL_NOTE')",
)
op.create_check_constraint(
"ck_repair_events_payload_shape",
"repair_events",
"("
"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') <> ''"
")",
)
def upgrade() -> None:
connection = op.get_bind()
connection.exec_driver_sql(
"""
INSERT INTO permissions (key, description)
VALUES ('repairs.complete', 'Return repaired miners to service')
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.complete'
WHERE roles.name IN ('Admin', 'Manager', 'Technician')
ON CONFLICT DO NOTHING
"""
)
op.add_column(
"repairs",
sa.Column("completion_summary", sa.Text(), nullable=True),
)
op.add_column(
"repairs",
sa.Column("resulting_lifecycle", sa.Text(), nullable=True),
)
op.create_check_constraint(
"ck_repairs_resulting_lifecycle",
"repairs",
"resulting_lifecycle IS NULL "
"OR resulting_lifecycle IN ('active', 'spare')",
)
op.create_check_constraint(
"ck_repairs_completion_summary",
"repairs",
"completion_summary IS NULL OR ("
"trim(completion_summary) <> '' "
"AND char_length(completion_summary) <= 4000"
")",
)
# NOT VALID preserves truthful legacy COMPLETED rows whose destination was
# never recorded, while PostgreSQL still enforces the check for every new
# or updated row after this migration.
connection.exec_driver_sql(
"""
ALTER TABLE repairs
ADD CONSTRAINT ck_repairs_completion_metadata_state
CHECK (
(
status = 'COMPLETED'
AND resulting_lifecycle IS NOT NULL
)
OR (
status <> 'COMPLETED'
AND completion_summary IS NULL
AND resulting_lifecycle IS NULL
)
)
NOT VALID
"""
)
op.drop_constraint(
"ck_repair_events_payload_shape",
"repair_events",
type_="check",
)
op.drop_constraint(
"ck_repair_events_type",
"repair_events",
type_="check",
)
_create_event_constraints()
def downgrade() -> None:
connection = op.get_bind()
connection.exec_driver_sql(
"""
DO $$
DECLARE
completion_count BIGINT;
BEGIN
SELECT count(*)
INTO completion_count
FROM repairs
WHERE resulting_lifecycle IS NOT NULL
OR completion_summary IS NOT NULL;
completion_count := completion_count + (
SELECT count(*)
FROM repair_events
WHERE event_type = 'REPAIR_COMPLETED'
);
IF completion_count > 0 THEN
RAISE EXCEPTION USING
MESSAGE = format(
'Cannot downgrade Return to Service: %s completion records or events would lose authoritative history',
completion_count
),
HINT = 'Preserve or explicitly migrate Return to Service history before downgrading; MinerOps will not invent or discard lifecycle, actor, summary, or event values.';
END IF;
END
$$;
"""
)
op.drop_constraint(
"ck_repair_events_payload_shape",
"repair_events",
type_="check",
)
op.drop_constraint(
"ck_repair_events_type",
"repair_events",
type_="check",
)
_create_previous_event_constraints()
op.drop_constraint(
"ck_repairs_completion_metadata_state",
"repairs",
type_="check",
)
op.drop_constraint(
"ck_repairs_completion_summary",
"repairs",
type_="check",
)
op.drop_constraint(
"ck_repairs_resulting_lifecycle",
"repairs",
type_="check",
)
op.drop_column("repairs", "resulting_lifecycle")
op.drop_column("repairs", "completion_summary")
connection.exec_driver_sql(
"""
DELETE FROM role_permissions
WHERE permission_id = (
SELECT id FROM permissions WHERE key = 'repairs.complete'
)
"""
)
connection.exec_driver_sql(
"DELETE FROM permissions WHERE key = 'repairs.complete'"
)