Files
minerops/backend/app/repairs/service.py
T

161 lines
5.3 KiB
Python

"""Business rules and orchestration for repair workflows."""
from uuid import UUID
from app.repairs.exceptions import (
ActiveRepairConflict,
IncompatibleMinerLifecycle,
InconsistentRepairState,
RepairActorRequired,
RepairMinerNotFound,
RepairNotFound,
)
from app.repairs.models import (
BeginRepairRequest,
BeginRepairResult,
InitialRepairNotePayload,
RepairActor,
RepairDetail,
RepairTimeline,
RepairTimelineEvent,
RepairStartedPayload,
)
from app.repairs.repository import RepairRepository
ELIGIBLE_LIFECYCLE_STATUSES = frozenset({"active", "spare"})
REPAIR_LIFECYCLE_STATUS = "in_repair"
class RepairService:
"""Explicit operational repair workflows independent of FastAPI."""
def __init__(self, repository: RepairRepository):
self.repository = repository
@staticmethod
def normalize_note(note: str | None) -> str | None:
if note is None:
return None
normalized = str(note).strip()
return normalized or None
def begin_repair(
self,
request: BeginRepairRequest,
*,
actor_user_id: UUID | None,
) -> BeginRepairResult:
if actor_user_id is None:
raise RepairActorRequired()
note = self.normalize_note(request.notes)
with self.repository.transaction() as repository:
miner = repository.lock_miner_for_update(request.miner_id)
if not miner:
raise RepairMinerNotFound(request.miner_id)
lifecycle_status = str(miner["lifecycle_status"])
active_repair = repository.get_active_repair(request.miner_id)
if active_repair:
raise ActiveRepairConflict(request.miner_id)
if lifecycle_status == REPAIR_LIFECYCLE_STATUS:
raise InconsistentRepairState(request.miner_id)
if lifecycle_status not in ELIGIBLE_LIFECYCLE_STATUSES:
raise IncompatibleMinerLifecycle(
request.miner_id,
lifecycle_status,
)
repair = repository.create_repair(
request.miner_id,
request.reason,
actor_user_id,
)
started_payload = RepairStartedPayload(
reason=request.reason,
from_lifecycle=lifecycle_status,
)
repository.create_event(
repair["id"],
sequence=1,
event_type="REPAIR_STARTED",
created_by=actor_user_id,
payload=started_payload.model_dump(mode="json"),
)
if note is not None:
note_payload = InitialRepairNotePayload(note=note)
repository.create_event(
repair["id"],
sequence=2,
event_type="INITIAL_NOTE",
created_by=actor_user_id,
payload=note_payload.model_dump(mode="json"),
)
repository.set_miner_lifecycle(
request.miner_id,
REPAIR_LIFECYCLE_STATUS,
)
return BeginRepairResult(
repair_id=repair["id"],
miner_id=repair["miner_id"],
status=repair["status"],
reason=repair["reason"],
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
],
)