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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user