feat: add return to service workflow
This commit is contained in:
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'"
|
||||
)
|
||||
@@ -682,11 +682,22 @@ def repair_timeline_request(path: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def repair_completion_request(path: str) -> bool:
|
||||
parts = path.split("/")
|
||||
return (
|
||||
len(parts) == 5
|
||||
and parts[:3] == ["", "api", "repairs"]
|
||||
and parts[4] == "complete"
|
||||
)
|
||||
|
||||
|
||||
def required_api_permission(method: str, path: str) -> str | None:
|
||||
if path.startswith("/api/admin/"):
|
||||
return "users.manage"
|
||||
if method == "POST" and path == "/api/repairs":
|
||||
return "repairs.create"
|
||||
if method == "POST" and repair_completion_request(path):
|
||||
return "repairs.complete"
|
||||
if method == "GET" and repair_timeline_request(path):
|
||||
return "repairs.read"
|
||||
if method == "GET":
|
||||
|
||||
@@ -4,10 +4,14 @@ from app.repairs.exceptions import (
|
||||
ActiveRepairConflict,
|
||||
IncompatibleMinerLifecycle,
|
||||
InconsistentRepairState,
|
||||
RepairActiveMismatch,
|
||||
RepairActorRequired,
|
||||
RepairCompletionActorRequired,
|
||||
RepairConflict,
|
||||
RepairError,
|
||||
RepairMinerNotFound,
|
||||
RepairMinerNotInRepair,
|
||||
RepairNotActive,
|
||||
RepairNotFound,
|
||||
RepairPersistenceError,
|
||||
)
|
||||
@@ -15,7 +19,10 @@ from app.repairs.models import (
|
||||
BeginRepairRequest,
|
||||
BeginRepairResult,
|
||||
RepairActor,
|
||||
RepairCompleteRequest,
|
||||
RepairCompletedPayload,
|
||||
RepairDetail,
|
||||
RepairPostLifecycle,
|
||||
RepairReason,
|
||||
RepairStatus,
|
||||
RepairTimeline,
|
||||
@@ -30,14 +37,21 @@ __all__ = [
|
||||
"BeginRepairResult",
|
||||
"IncompatibleMinerLifecycle",
|
||||
"InconsistentRepairState",
|
||||
"RepairActiveMismatch",
|
||||
"RepairActor",
|
||||
"RepairActorRequired",
|
||||
"RepairCompleteRequest",
|
||||
"RepairCompletedPayload",
|
||||
"RepairCompletionActorRequired",
|
||||
"RepairConflict",
|
||||
"RepairDetail",
|
||||
"RepairError",
|
||||
"RepairMinerNotFound",
|
||||
"RepairMinerNotInRepair",
|
||||
"RepairNotActive",
|
||||
"RepairNotFound",
|
||||
"RepairPersistenceError",
|
||||
"RepairPostLifecycle",
|
||||
"RepairReason",
|
||||
"RepairRepository",
|
||||
"RepairService",
|
||||
|
||||
@@ -62,5 +62,48 @@ class RepairActorRequired(RepairError):
|
||||
super().__init__("An authenticated actor is required to begin repair")
|
||||
|
||||
|
||||
class RepairCompletionActorRequired(RepairError):
|
||||
"""Raised when Return to Service lacks an authenticated actor."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
"An authenticated actor is required to return a miner to service"
|
||||
)
|
||||
|
||||
|
||||
class RepairNotActive(RepairConflict):
|
||||
"""Raised when a repair is no longer active."""
|
||||
|
||||
def __init__(self, repair_id: object, repair_status: object):
|
||||
self.repair_id = repair_id
|
||||
self.repair_status = repair_status
|
||||
super().__init__(
|
||||
f"Repair is not active: {repair_id} ({repair_status})"
|
||||
)
|
||||
|
||||
|
||||
class RepairMinerNotInRepair(RepairConflict):
|
||||
"""Raised when a repair's miner has already left repair lifecycle."""
|
||||
|
||||
def __init__(self, miner_id: object, lifecycle_status: object):
|
||||
self.miner_id = miner_id
|
||||
self.lifecycle_status = lifecycle_status
|
||||
super().__init__(
|
||||
"Miner is not in repair lifecycle: "
|
||||
f"{miner_id} ({lifecycle_status})"
|
||||
)
|
||||
|
||||
|
||||
class RepairActiveMismatch(RepairConflict):
|
||||
"""Raised when the requested repair is not the miner's active repair."""
|
||||
|
||||
def __init__(self, repair_id: object, miner_id: object):
|
||||
self.repair_id = repair_id
|
||||
self.miner_id = miner_id
|
||||
super().__init__(
|
||||
f"Repair is not the active repair for miner: {repair_id}"
|
||||
)
|
||||
|
||||
|
||||
class RepairPersistenceError(RepairError):
|
||||
"""Raised when a required repair persistence operation fails."""
|
||||
@@ -28,8 +28,13 @@ RepairStatus = Literal[
|
||||
"COMPLETED",
|
||||
"CANCELLED",
|
||||
]
|
||||
RepairEventType = Literal["REPAIR_STARTED", "INITIAL_NOTE"]
|
||||
RepairEventType = Literal[
|
||||
"REPAIR_STARTED",
|
||||
"INITIAL_NOTE",
|
||||
"REPAIR_COMPLETED",
|
||||
]
|
||||
RepairEligibleLifecycle = Literal["active", "spare"]
|
||||
RepairPostLifecycle = Literal["active", "spare"]
|
||||
RepairMinerLifecycle = Literal["active", "spare", "retired", "in_repair"]
|
||||
|
||||
|
||||
@@ -41,6 +46,13 @@ class BeginRepairRequest(BaseModel):
|
||||
notes: str | None = Field(default=None, max_length=4000)
|
||||
|
||||
|
||||
class RepairCompleteRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
target_lifecycle: RepairPostLifecycle
|
||||
completion_summary: str | None = Field(default=None, max_length=4000)
|
||||
|
||||
|
||||
class RepairStartedPayload(BaseModel):
|
||||
reason: RepairReason
|
||||
from_lifecycle: RepairEligibleLifecycle
|
||||
@@ -51,6 +63,13 @@ class InitialRepairNotePayload(BaseModel):
|
||||
note: str = Field(min_length=1, max_length=4000)
|
||||
|
||||
|
||||
class RepairCompletedPayload(BaseModel):
|
||||
previous_miner_lifecycle: Literal["in_repair"] = "in_repair"
|
||||
resulting_miner_lifecycle: RepairPostLifecycle
|
||||
pre_repair_lifecycle: RepairEligibleLifecycle | None = None
|
||||
completion_summary: str | None = Field(default=None, max_length=4000)
|
||||
|
||||
|
||||
class BeginRepairResult(BaseModel):
|
||||
repair_id: UUID
|
||||
miner_id: UUID
|
||||
|
||||
@@ -39,7 +39,7 @@ def default_connection_factory() -> psycopg.Connection:
|
||||
|
||||
|
||||
class RepairRepository:
|
||||
"""Persistence boundary for the atomic Begin Repair workflow."""
|
||||
"""Persistence boundary for explicit atomic repair workflows."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -224,6 +224,124 @@ class RepairRepository:
|
||||
"Miner lifecycle transition did not update one record"
|
||||
)
|
||||
|
||||
def get_repair_identity(self, repair_id: UUID) -> dict | None:
|
||||
with self._connection_scope() as connection:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, miner_id
|
||||
FROM repairs
|
||||
WHERE id = %s
|
||||
""",
|
||||
(repair_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def lock_repair_for_update(self, repair_id: UUID) -> dict | None:
|
||||
with self._connection_scope() as connection:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
miner_id,
|
||||
status,
|
||||
reason,
|
||||
opened_at,
|
||||
opened_by,
|
||||
completed_at,
|
||||
completed_by,
|
||||
completion_summary,
|
||||
resulting_lifecycle
|
||||
FROM repairs
|
||||
WHERE id = %s
|
||||
FOR UPDATE
|
||||
""",
|
||||
(repair_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def get_next_event_sequence(self, repair_id: UUID) -> int:
|
||||
with self._connection_scope() as connection:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COALESCE(max(sequence), 0) + 1
|
||||
FROM repair_events
|
||||
WHERE repair_id = %s
|
||||
""",
|
||||
(repair_id,),
|
||||
)
|
||||
return int(cur.fetchone()[0])
|
||||
|
||||
def get_pre_repair_lifecycle(self, repair_id: UUID) -> str | None:
|
||||
with self._connection_scope() as connection:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT event.payload ->> 'from_lifecycle'
|
||||
FROM repair_events AS event
|
||||
WHERE event.repair_id = %s
|
||||
AND event.event_type = 'REPAIR_STARTED'
|
||||
ORDER BY event.sequence
|
||||
LIMIT 2
|
||||
""",
|
||||
(repair_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
if len(rows) != 1:
|
||||
return None
|
||||
lifecycle_status = rows[0][0]
|
||||
if lifecycle_status not in {"active", "spare"}:
|
||||
return None
|
||||
return str(lifecycle_status)
|
||||
|
||||
def complete_repair(
|
||||
self,
|
||||
repair_id: UUID,
|
||||
*,
|
||||
completed_by: UUID,
|
||||
completion_summary: str | None,
|
||||
resulting_lifecycle: str,
|
||||
) -> dict | None:
|
||||
with self._connection_scope(write=True) as connection:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE repairs
|
||||
SET status = 'COMPLETED',
|
||||
completed_at = now(),
|
||||
completed_by = %s,
|
||||
completion_summary = %s,
|
||||
resulting_lifecycle = %s
|
||||
WHERE id = %s
|
||||
AND status = ANY(%s)
|
||||
RETURNING
|
||||
id,
|
||||
miner_id,
|
||||
status,
|
||||
reason,
|
||||
opened_at,
|
||||
opened_by,
|
||||
completed_at,
|
||||
completed_by,
|
||||
completion_summary,
|
||||
resulting_lifecycle
|
||||
""",
|
||||
(
|
||||
completed_by,
|
||||
completion_summary,
|
||||
resulting_lifecycle,
|
||||
repair_id,
|
||||
list(ACTIVE_REPAIR_STATUSES),
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def get_repair_detail(self, repair_id: UUID) -> dict | None:
|
||||
with self._connection_scope() as connection:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.identity import require_permission
|
||||
from app.repairs.dependencies import get_repair_service
|
||||
from app.repairs.exceptions import (
|
||||
RepairActorRequired,
|
||||
RepairCompletionActorRequired,
|
||||
RepairConflict,
|
||||
RepairMinerNotFound,
|
||||
RepairNotFound,
|
||||
@@ -16,6 +17,7 @@ from app.repairs.exceptions import (
|
||||
from app.repairs.models import (
|
||||
BeginRepairRequest,
|
||||
BeginRepairResponse,
|
||||
RepairCompleteRequest,
|
||||
RepairDetailResponse,
|
||||
RepairTimelineResponse,
|
||||
)
|
||||
@@ -25,6 +27,7 @@ from app.repairs.service import RepairService
|
||||
router = APIRouter(prefix="/api/repairs", tags=["repairs"])
|
||||
require_repair_create = require_permission("repairs.create")
|
||||
require_repair_read = require_permission("repairs.read")
|
||||
require_repair_complete = require_permission("repairs.complete")
|
||||
|
||||
RepairServiceDependency = Annotated[
|
||||
RepairService,
|
||||
@@ -32,6 +35,7 @@ RepairServiceDependency = Annotated[
|
||||
]
|
||||
RepairCreateUser = Annotated[dict, Depends(require_repair_create)]
|
||||
RepairReadUser = Annotated[dict, Depends(require_repair_read)]
|
||||
RepairCompleteUser = Annotated[dict, Depends(require_repair_complete)]
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -68,6 +72,46 @@ def begin_repair(
|
||||
return BeginRepairResponse(data=repair)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{repair_id}/complete",
|
||||
response_model=RepairDetailResponse,
|
||||
)
|
||||
def complete_repair(
|
||||
repair_id: UUID,
|
||||
request: RepairCompleteRequest,
|
||||
service: RepairServiceDependency,
|
||||
current_user: RepairCompleteUser,
|
||||
) -> RepairDetailResponse:
|
||||
try:
|
||||
repair = service.complete_repair(
|
||||
repair_id,
|
||||
request,
|
||||
actor_user_id=UUID(str(current_user["id"])),
|
||||
)
|
||||
except RepairNotFound as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Repair not found",
|
||||
) from exc
|
||||
except RepairMinerNotFound as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Repair miner no longer exists",
|
||||
) from exc
|
||||
except RepairConflict as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except RepairCompletionActorRequired as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
return RepairDetailResponse(data=repair)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{repair_id}",
|
||||
response_model=RepairDetailResponse,
|
||||
|
||||
+115
-24
@@ -6,15 +6,22 @@ from app.repairs.exceptions import (
|
||||
ActiveRepairConflict,
|
||||
IncompatibleMinerLifecycle,
|
||||
InconsistentRepairState,
|
||||
RepairActiveMismatch,
|
||||
RepairActorRequired,
|
||||
RepairCompletionActorRequired,
|
||||
RepairMinerNotFound,
|
||||
RepairMinerNotInRepair,
|
||||
RepairNotActive,
|
||||
RepairNotFound,
|
||||
RepairPersistenceError,
|
||||
)
|
||||
from app.repairs.models import (
|
||||
BeginRepairRequest,
|
||||
BeginRepairResult,
|
||||
InitialRepairNotePayload,
|
||||
RepairActor,
|
||||
RepairCompleteRequest,
|
||||
RepairCompletedPayload,
|
||||
RepairDetail,
|
||||
RepairTimeline,
|
||||
RepairTimelineEvent,
|
||||
@@ -24,6 +31,9 @@ from app.repairs.repository import RepairRepository
|
||||
|
||||
|
||||
ELIGIBLE_LIFECYCLE_STATUSES = frozenset({"active", "spare"})
|
||||
ACTIVE_REPAIR_STATUSES = frozenset(
|
||||
{"OPEN", "IN_PROGRESS", "WAITING_FOR_PARTS", "READY_FOR_TEST"}
|
||||
)
|
||||
REPAIR_LIFECYCLE_STATUS = "in_repair"
|
||||
|
||||
|
||||
@@ -40,6 +50,32 @@ class RepairService:
|
||||
normalized = str(note).strip()
|
||||
return normalized or None
|
||||
|
||||
@staticmethod
|
||||
def _map_repair_detail(repair: dict) -> RepairDetail:
|
||||
closed_by = None
|
||||
if repair["closed_by_user_id"] is not None:
|
||||
closed_by = RepairActor(
|
||||
user_id=repair["closed_by_user_id"],
|
||||
display_name=repair["closed_by_display_name"],
|
||||
)
|
||||
|
||||
return RepairDetail(
|
||||
repair_id=repair["repair_id"],
|
||||
miner_id=repair["miner_id"],
|
||||
miner_asset_id=repair["miner_asset_id"],
|
||||
status=repair["status"],
|
||||
reason=repair["reason"],
|
||||
opened_at=repair["opened_at"],
|
||||
opened_by=RepairActor(
|
||||
user_id=repair["opened_by_user_id"],
|
||||
display_name=repair["opened_by_display_name"],
|
||||
),
|
||||
closed_at=repair["closed_at"],
|
||||
closed_by=closed_by,
|
||||
miner_lifecycle=repair["miner_lifecycle"],
|
||||
latest_event_sequence=repair["latest_event_sequence"],
|
||||
)
|
||||
|
||||
def begin_repair(
|
||||
self,
|
||||
request: BeginRepairRequest,
|
||||
@@ -107,34 +143,89 @@ class RepairService:
|
||||
opened_by=repair["opened_by"],
|
||||
)
|
||||
|
||||
def complete_repair(
|
||||
self,
|
||||
repair_id: UUID,
|
||||
request: RepairCompleteRequest,
|
||||
*,
|
||||
actor_user_id: UUID | None,
|
||||
) -> RepairDetail:
|
||||
if actor_user_id is None:
|
||||
raise RepairCompletionActorRequired()
|
||||
|
||||
completion_summary = self.normalize_note(request.completion_summary)
|
||||
|
||||
with self.repository.transaction() as repository:
|
||||
repair_identity = repository.get_repair_identity(repair_id)
|
||||
if repair_identity is None:
|
||||
raise RepairNotFound(repair_id)
|
||||
|
||||
miner_id = repair_identity["miner_id"]
|
||||
# Begin Repair also locks the miner first. Keeping this order makes
|
||||
# competing lifecycle workflows serialize on the durable asset.
|
||||
miner = repository.lock_miner_for_update(miner_id)
|
||||
if miner is None:
|
||||
raise RepairMinerNotFound(miner_id)
|
||||
|
||||
repair = repository.lock_repair_for_update(repair_id)
|
||||
if repair is None:
|
||||
raise RepairNotFound(repair_id)
|
||||
if repair["miner_id"] != miner_id:
|
||||
raise RepairActiveMismatch(repair_id, miner_id)
|
||||
if repair["status"] not in ACTIVE_REPAIR_STATUSES:
|
||||
raise RepairNotActive(repair_id, repair["status"])
|
||||
|
||||
active_repair = repository.get_active_repair(miner_id)
|
||||
if active_repair is None or active_repair["id"] != repair_id:
|
||||
raise RepairActiveMismatch(repair_id, miner_id)
|
||||
|
||||
lifecycle_status = str(miner["lifecycle_status"])
|
||||
if lifecycle_status != REPAIR_LIFECYCLE_STATUS:
|
||||
raise RepairMinerNotInRepair(miner_id, lifecycle_status)
|
||||
|
||||
next_sequence = repository.get_next_event_sequence(repair_id)
|
||||
pre_repair_lifecycle = repository.get_pre_repair_lifecycle(
|
||||
repair_id
|
||||
)
|
||||
completed = repository.complete_repair(
|
||||
repair_id,
|
||||
completed_by=actor_user_id,
|
||||
completion_summary=completion_summary,
|
||||
resulting_lifecycle=request.target_lifecycle,
|
||||
)
|
||||
if completed is None:
|
||||
raise RepairNotActive(repair_id, repair["status"])
|
||||
|
||||
repository.set_miner_lifecycle(
|
||||
miner_id,
|
||||
request.target_lifecycle,
|
||||
)
|
||||
payload = RepairCompletedPayload(
|
||||
resulting_miner_lifecycle=request.target_lifecycle,
|
||||
pre_repair_lifecycle=pre_repair_lifecycle,
|
||||
completion_summary=completion_summary,
|
||||
)
|
||||
repository.create_event(
|
||||
repair_id,
|
||||
sequence=next_sequence,
|
||||
event_type="REPAIR_COMPLETED",
|
||||
created_by=actor_user_id,
|
||||
payload=payload.model_dump(mode="json", exclude_none=True),
|
||||
)
|
||||
|
||||
detail = repository.get_repair_detail(repair_id)
|
||||
if detail is None:
|
||||
raise RepairPersistenceError(
|
||||
"Completed repair could not be read in its transaction"
|
||||
)
|
||||
|
||||
return self._map_repair_detail(detail)
|
||||
|
||||
def get_repair(self, repair_id: UUID) -> RepairDetail:
|
||||
repair = self.repository.get_repair_detail(repair_id)
|
||||
if repair is None:
|
||||
raise RepairNotFound(repair_id)
|
||||
|
||||
closed_by = None
|
||||
if repair["closed_by_user_id"] is not None:
|
||||
closed_by = RepairActor(
|
||||
user_id=repair["closed_by_user_id"],
|
||||
display_name=repair["closed_by_display_name"],
|
||||
)
|
||||
|
||||
return RepairDetail(
|
||||
repair_id=repair["repair_id"],
|
||||
miner_id=repair["miner_id"],
|
||||
miner_asset_id=repair["miner_asset_id"],
|
||||
status=repair["status"],
|
||||
reason=repair["reason"],
|
||||
opened_at=repair["opened_at"],
|
||||
opened_by=RepairActor(
|
||||
user_id=repair["opened_by_user_id"],
|
||||
display_name=repair["opened_by_display_name"],
|
||||
),
|
||||
closed_at=repair["closed_at"],
|
||||
closed_by=closed_by,
|
||||
miner_lifecycle=repair["miner_lifecycle"],
|
||||
latest_event_sequence=repair["latest_event_sequence"],
|
||||
)
|
||||
return self._map_repair_detail(repair)
|
||||
|
||||
def get_repair_timeline(self, repair_id: UUID) -> RepairTimeline:
|
||||
events = self.repository.get_repair_events(repair_id)
|
||||
|
||||
@@ -715,6 +715,11 @@ class RepairTimelineArchitectureTests(unittest.TestCase):
|
||||
"minerops_repair_parts_summary_api",
|
||||
),
|
||||
("POST", "/api/repairs", "begin_repair"),
|
||||
(
|
||||
"POST",
|
||||
"/api/repairs/{repair_id}/complete",
|
||||
"complete_repair",
|
||||
),
|
||||
("GET", "/api/repairs/{repair_id}", "get_repair"),
|
||||
(
|
||||
"GET",
|
||||
|
||||
@@ -232,11 +232,13 @@ class RepairRouterTests(unittest.TestCase):
|
||||
self.assertEqual(response.status_code, 422)
|
||||
self.assertIsNone(self.service.request)
|
||||
|
||||
def test_router_registers_exactly_one_post_route(self):
|
||||
def test_router_registers_exact_begin_repair_route(self):
|
||||
routes = [
|
||||
(route.path, route.methods)
|
||||
for route in router.routes
|
||||
if hasattr(route, "methods") and "POST" in route.methods
|
||||
if hasattr(route, "methods")
|
||||
and "POST" in route.methods
|
||||
and route.path == "/api/repairs"
|
||||
]
|
||||
self.assertEqual(routes, [("/api/repairs", {"POST"})])
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user