feat: add repair timeline read APIs

This commit is contained in:
lussierj committed 2026-07-29 16:28:44 +00:00
1 parent f6a2512e23
commit 36a0caecd8
16 files changed
+1562 -9

No files matched your search

+13
View File
@@ -671,11 +671,24 @@ def machine_agent_request(path: str) -> bool:
return path in exact
def repair_timeline_request(path: str) -> bool:
parts = path.split("/")
if len(parts) == 4 and parts[:3] == ["", "api", "repairs"]:
return parts[3] not in {"queue", "parts-summary"}
return (
len(parts) == 5
and parts[:3] == ["", "api", "repairs"]
and parts[4] == "events"
)
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 == "GET" and repair_timeline_request(path):
return "repairs.read"
if method == "GET":
if path.startswith("/api/system/"):
return "system_health.view"
+4 -1
View File
@@ -24,7 +24,6 @@ from app.repairs.router import router as repairs_router
app = FastAPI(title=settings.app_name)
app.include_router(identity_router)
app.include_router(inventory_router)
app.include_router(repairs_router)
install_identity_middleware(app)
@@ -35671,3 +35670,7 @@ def minerops_repair_parts_summary_api(
# MINEROPS_REPAIR_HISTORY_API_HELPERS_SCOPE_FIX_V1
# MINEROPS_REPAIR_HISTORY_DELEGATED_ACTIONS_V1
# Register UUID-based Repair routes after legacy static Repair paths.
app.include_router(repairs_router)
+10
View File
@@ -8,13 +8,18 @@ from app.repairs.exceptions import (
RepairConflict,
RepairError,
RepairMinerNotFound,
RepairNotFound,
RepairPersistenceError,
)
from app.repairs.models import (
BeginRepairRequest,
BeginRepairResult,
RepairActor,
RepairDetail,
RepairReason,
RepairStatus,
RepairTimeline,
RepairTimelineEvent,
)
from app.repairs.repository import RepairRepository
from app.repairs.service import RepairService
@@ -25,13 +30,18 @@ __all__ = [
"BeginRepairResult",
"IncompatibleMinerLifecycle",
"InconsistentRepairState",
"RepairActor",
"RepairActorRequired",
"RepairConflict",
"RepairDetail",
"RepairError",
"RepairMinerNotFound",
"RepairNotFound",
"RepairPersistenceError",
"RepairReason",
"RepairRepository",
"RepairService",
"RepairStatus",
"RepairTimeline",
"RepairTimelineEvent",
]
+8
View File
@@ -13,6 +13,14 @@ class RepairMinerNotFound(RepairError):
super().__init__(f"Miner not found: {miner_id}")
class RepairNotFound(RepairError):
"""Raised when the requested repair does not exist."""
def __init__(self, repair_id: object):
self.repair_id = repair_id
super().__init__(f"Repair not found: {repair_id}")
class RepairConflict(RepairError):
"""Base exception for a conflicting repair state."""
+48 -2
View File
@@ -1,10 +1,10 @@
"""Typed contracts for the Repairs domain."""
from datetime import datetime
from typing import Literal
from typing import Any, Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
RepairReason = Literal[
@@ -30,6 +30,7 @@ RepairStatus = Literal[
]
RepairEventType = Literal["REPAIR_STARTED", "INITIAL_NOTE"]
RepairEligibleLifecycle = Literal["active", "spare"]
RepairMinerLifecycle = Literal["active", "spare", "retired", "in_repair"]
class BeginRepairRequest(BaseModel):
@@ -63,3 +64,48 @@ class BeginRepairResponse(BaseModel):
data: BeginRepairResult
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
class RepairActor(BaseModel):
user_id: UUID
display_name: str
class RepairDetail(BaseModel):
repair_id: UUID
miner_id: UUID
miner_asset_id: str | None = None
status: RepairStatus
reason: RepairReason
opened_at: AwareDatetime
opened_by: RepairActor
closed_at: AwareDatetime | None = None
closed_by: RepairActor | None = None
miner_lifecycle: RepairMinerLifecycle
latest_event_sequence: int | None = Field(default=None, ge=1)
class RepairTimelineEvent(BaseModel):
event_id: UUID
sequence: int = Field(ge=1)
event_type: RepairEventType
actor: RepairActor
occurred_at: AwareDatetime
payload: dict[str, Any]
class RepairTimeline(BaseModel):
repair_id: UUID
events: list[RepairTimelineEvent] = Field(default_factory=list)
class RepairDetailResponse(BaseModel):
data: RepairDetail
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
class RepairTimelineResponse(BaseModel):
data: RepairTimeline
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
+70
View File
@@ -223,3 +223,73 @@ class RepairRepository:
raise RepairPersistenceError(
"Miner lifecycle transition did not update one record"
)
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:
cur.execute(
"""
SELECT
repair.id AS repair_id,
repair.miner_id,
miner.asset_tag AS miner_asset_id,
repair.status,
repair.reason,
repair.opened_at,
opened_actor.id AS opened_by_user_id,
opened_actor.display_name AS opened_by_display_name,
repair.completed_at AS closed_at,
closed_actor.id AS closed_by_user_id,
closed_actor.display_name AS closed_by_display_name,
miner.lifecycle_status AS miner_lifecycle,
(
SELECT max(event.sequence)
FROM repair_events AS event
WHERE event.repair_id = repair.id
) AS latest_event_sequence
FROM repairs AS repair
JOIN miners AS miner ON miner.id = repair.miner_id
JOIN users AS opened_actor
ON opened_actor.id = repair.opened_by
LEFT JOIN users AS closed_actor
ON closed_actor.id = repair.completed_by
WHERE repair.id = %s
""",
(repair_id,),
)
row = cur.fetchone()
return dict(row) if row else None
def get_repair_events(
self,
repair_id: UUID,
) -> list[dict] | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT
repair.id AS repair_id,
event.id AS event_id,
event.sequence,
event.event_type,
actor.id AS actor_user_id,
actor.display_name AS actor_display_name,
event.created_at AS occurred_at,
event.payload
FROM repairs AS repair
LEFT JOIN (
repair_events AS event
JOIN users AS actor
ON actor.id = event.created_by
) ON event.repair_id = repair.id
WHERE repair.id = %s
ORDER BY event.sequence ASC
""",
(repair_id,),
)
rows = [dict(row) for row in cur.fetchall()]
if not rows:
return None
return [row for row in rows if row["event_id"] is not None]
+49 -1
View File
@@ -11,19 +11,27 @@ from app.repairs.exceptions import (
RepairActorRequired,
RepairConflict,
RepairMinerNotFound,
RepairNotFound,
)
from app.repairs.models import (
BeginRepairRequest,
BeginRepairResponse,
RepairDetailResponse,
RepairTimelineResponse,
)
from app.repairs.models import BeginRepairRequest, BeginRepairResponse
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")
RepairServiceDependency = Annotated[
RepairService,
Depends(get_repair_service),
]
RepairCreateUser = Annotated[dict, Depends(require_repair_create)]
RepairReadUser = Annotated[dict, Depends(require_repair_read)]
@router.post(
@@ -58,3 +66,43 @@ def begin_repair(
) from exc
return BeginRepairResponse(data=repair)
@router.get(
"/{repair_id}",
response_model=RepairDetailResponse,
)
def get_repair(
repair_id: UUID,
service: RepairServiceDependency,
current_user: RepairReadUser,
) -> RepairDetailResponse:
del current_user
try:
repair = service.get_repair(repair_id)
except RepairNotFound as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Repair not found",
) from exc
return RepairDetailResponse(data=repair)
@router.get(
"/{repair_id}/events",
response_model=RepairTimelineResponse,
)
def get_repair_events(
repair_id: UUID,
service: RepairServiceDependency,
current_user: RepairReadUser,
) -> RepairTimelineResponse:
del current_user
try:
timeline = service.get_repair_timeline(repair_id)
except RepairNotFound as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Repair not found",
) from exc
return RepairTimelineResponse(data=timeline)
+57
View File
@@ -8,11 +8,16 @@ from app.repairs.exceptions import (
InconsistentRepairState,
RepairActorRequired,
RepairMinerNotFound,
RepairNotFound,
)
from app.repairs.models import (
BeginRepairRequest,
BeginRepairResult,
InitialRepairNotePayload,
RepairActor,
RepairDetail,
RepairTimeline,
RepairTimelineEvent,
RepairStartedPayload,
)
from app.repairs.repository import RepairRepository
@@ -101,3 +106,55 @@ class RepairService:
opened_at=repair["opened_at"],
opened_by=repair["opened_by"],
)
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"],
)
def get_repair_timeline(self, repair_id: UUID) -> RepairTimeline:
events = self.repository.get_repair_events(repair_id)
if events is None:
raise RepairNotFound(repair_id)
return RepairTimeline(
repair_id=repair_id,
events=[
RepairTimelineEvent(
event_id=event["event_id"],
sequence=event["sequence"],
event_type=event["event_type"],
actor=RepairActor(
user_id=event["actor_user_id"],
display_name=event["actor_display_name"],
),
occurred_at=event["occurred_at"],
payload=event["payload"],
)
for event in events
],
)