feat: add repair timeline read APIs
This commit is contained in:
1 parent
f6a2512e23
commit
36a0caecd8
16 files changed
+1562
-9
No files matched your search
@@ -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
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
],
|
||||
)
|
||||
Reference in new issue
Block a user