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

386 lines
14 KiB
Python

"""Business rules and orchestration for manual miner inventory."""
from collections.abc import Iterable
from typing import Any
from uuid import UUID
from psycopg.errors import UniqueViolation
from app.inventory.exceptions import (
DuplicateAssetID,
InventoryValidationError,
MinerNotFound,
)
from app.inventory.models import (
InventoryAssetIDHistoryEntry,
InventoryCreateRequest,
InventoryMetadataUpdateRequest,
InventoryMinerDetail,
InventoryMinerSummary,
InventorySearchFilters,
InventoryTag,
InventoryUpdateRequest,
)
from app.inventory.repository import InventoryRepository
ALLOWED_LIFECYCLE_STATUSES = frozenset({"active", "spare", "retired"})
class InventoryService:
"""Inventory business rules independent of FastAPI and other transports."""
def __init__(self, repository: InventoryRepository):
self.repository = repository
@staticmethod
def normalize_asset_id(asset_id: str) -> str:
normalized = str(asset_id or "").strip()
if not normalized:
raise InventoryValidationError("Asset ID cannot be blank")
return normalized
@staticmethod
def validate_lifecycle_status(lifecycle_status: str) -> str:
normalized = str(lifecycle_status or "").strip().lower()
if normalized not in ALLOWED_LIFECYCLE_STATUSES:
allowed = ", ".join(sorted(ALLOWED_LIFECYCLE_STATUSES))
raise InventoryValidationError(
f"Lifecycle status must be one of: {allowed}"
)
return normalized
@staticmethod
def normalize_tags(tags: Iterable[str]) -> list[str]:
normalized: list[str] = []
seen: set[str] = set()
for raw_tag in tags:
tag = str(raw_tag or "").strip()
if not tag:
raise InventoryValidationError("Tag names cannot be blank")
key = tag.casefold()
if key not in seen:
normalized.append(tag)
seen.add(key)
return normalized
@staticmethod
def _clean_optional_text(value: str | None) -> str | None:
if value is None:
return None
cleaned = str(value).strip()
return cleaned or None
@staticmethod
def _tag_model(row: dict) -> InventoryTag:
return InventoryTag.model_validate(row)
@classmethod
def _summary_model(cls, row: dict) -> InventoryMinerSummary:
return InventoryMinerSummary(
id=row["id"],
asset_id=row.get("asset_tag"),
current_mac=row.get("current_mac"),
current_ip=row.get("current_ip"),
model=row.get("model"),
lifecycle_status=row["lifecycle_status"],
runtime_status=row["status"],
site_id=row.get("site_id"),
room_id=row.get("room_id"),
tags=[cls._tag_model(tag) for tag in row.get("tags") or []],
created_at=row["created_at"],
updated_at=row["updated_at"],
last_seen_at=row.get("last_seen_at"),
)
@classmethod
def _detail_model(cls, row: dict) -> InventoryMinerDetail:
summary = cls._summary_model(row)
return InventoryMinerDetail(
**summary.model_dump(),
hostname=row.get("hostname"),
miner_sn=row.get("miner_sn"),
control_board_sn=row.get("control_board_sn"),
psu_serial=row.get("power_sn"),
firmware_version=row.get("firmware_version"),
notes=row.get("notes"),
first_seen_at=row.get("first_seen_at"),
)
def get_miner(self, miner_id: UUID) -> InventoryMinerDetail:
row = self.repository.get_miner_by_id(miner_id)
if not row:
raise MinerNotFound(miner_id)
return self._detail_model(row)
def get_asset_id_history(
self,
miner_id: UUID,
) -> list[InventoryAssetIDHistoryEntry]:
if not self.repository.get_miner_by_id(miner_id):
raise MinerNotFound(miner_id)
return [
InventoryAssetIDHistoryEntry.model_validate(row)
for row in self.repository.list_asset_id_history(miner_id)
]
def list_miners(
self,
*,
limit: int = 100,
offset: int = 0,
) -> list[InventoryMinerSummary]:
return [
self._summary_model(row)
for row in self.repository.list_miners(limit=limit, offset=offset)
]
def search_miners(
self,
filters: InventorySearchFilters,
) -> list[InventoryMinerSummary]:
lifecycle_status = None
if filters.lifecycle_status:
lifecycle_status = self.validate_lifecycle_status(
filters.lifecycle_status
)
asset_id = None
if filters.asset_id:
asset_id = self.normalize_asset_id(filters.asset_id)
tag_names = [
tag.casefold() for tag in self.normalize_tags(filters.tags)
]
return [
self._summary_model(row)
for row in self.repository.search_miners(
query=self._clean_optional_text(filters.query),
asset_id=asset_id,
lifecycle_status=lifecycle_status,
tag_names=tag_names,
limit=filters.limit,
offset=filters.offset,
)
]
def create_miner(
self,
request: InventoryCreateRequest,
) -> InventoryMinerDetail:
asset_id = self.normalize_asset_id(request.asset_id)
lifecycle_status = self.validate_lifecycle_status(
request.lifecycle_status
)
tags = self.normalize_tags(request.tags)
if self.repository.get_miner_by_asset_id(asset_id):
raise DuplicateAssetID(asset_id)
values: dict[str, Any] = {
"asset_tag": asset_id,
"current_mac": self._clean_optional_text(request.current_mac),
"current_ip": str(request.current_ip) if request.current_ip else None,
"hostname": self._clean_optional_text(request.hostname),
"miner_sn": self._clean_optional_text(request.miner_sn),
"control_board_sn": self._clean_optional_text(
request.control_board_sn
),
"power_sn": self._clean_optional_text(request.psu_serial),
"model": self._clean_optional_text(request.model),
"firmware_version": self._clean_optional_text(
request.firmware_version
),
"lifecycle_status": lifecycle_status,
"site_id": request.site_id,
"room_id": request.room_id,
"notes": self._clean_optional_text(request.notes),
"status": "unknown",
"first_seen_at": None,
"last_seen_at": None,
}
try:
with self.repository.transaction() as repository:
row = repository.create_miner(values)
for tag in tags:
repository.add_tag(row["id"], tag)
row = repository.get_miner_by_id(row["id"])
self._after_create(row)
except UniqueViolation as exc:
raise DuplicateAssetID(asset_id) from exc
return self._detail_model(row)
def update_metadata(
self,
miner_id: UUID,
request: InventoryMetadataUpdateRequest,
*,
actor_user_id: UUID,
) -> InventoryMinerDetail:
fields_set = request.model_fields_set
if not fields_set:
raise InventoryValidationError(
"At least one metadata field must be provided"
)
requested_asset_id = None
if "asset_id" in fields_set:
if request.asset_id is None:
raise InventoryValidationError("Asset ID cannot be cleared")
requested_asset_id = self.normalize_asset_id(request.asset_id)
requested_values: dict[str, Any] = {}
field_map = {
"miner_sn": "miner_sn",
"psu_serial": "power_sn",
"model": "model",
"notes": "notes",
}
for model_field, database_column in field_map.items():
if model_field in fields_set:
requested_values[database_column] = self._clean_optional_text(
getattr(request, model_field)
)
try:
with self.repository.transaction() as repository:
existing = repository.get_miner_by_id_for_update(miner_id)
if not existing:
raise MinerNotFound(miner_id)
previous_asset_id = (
str(existing.get("asset_tag") or "").strip() or None
)
asset_id_changed = (
requested_asset_id is not None
and requested_asset_id != previous_asset_id
)
values = {
key: value
for key, value in requested_values.items()
if existing.get(key) != value
}
if asset_id_changed:
duplicate = repository.get_miner_by_asset_id(
requested_asset_id
)
if duplicate and duplicate["id"] != miner_id:
raise DuplicateAssetID(requested_asset_id)
values["asset_tag"] = requested_asset_id
if not values:
return self._detail_model(existing)
self._before_update(existing, values)
row = repository.update_miner(miner_id, values)
if asset_id_changed:
repository.add_asset_id_history(
miner_id,
previous_asset_id,
requested_asset_id,
actor_user_id,
)
row = repository.get_miner_by_id(miner_id)
self._after_update(row)
except UniqueViolation as exc:
asset_id = requested_asset_id or "unknown"
raise DuplicateAssetID(asset_id) from exc
return self._detail_model(row)
def update_miner(
self,
miner_id: UUID,
request: InventoryUpdateRequest,
) -> InventoryMinerDetail:
existing = self.repository.get_miner_by_id(miner_id)
if not existing:
raise MinerNotFound(miner_id)
values: dict[str, Any] = {}
fields_set = request.model_fields_set
if "asset_id" in fields_set:
if request.asset_id is None:
raise InventoryValidationError("Asset ID cannot be cleared")
asset_id = self.normalize_asset_id(request.asset_id)
existing_asset_id = (
str(existing.get("asset_tag") or "").strip() or None
)
if asset_id != existing_asset_id:
raise InventoryValidationError(
"Asset ID changes require actor-attributed metadata update"
)
else:
asset_id = existing["asset_tag"]
if "lifecycle_status" in fields_set:
if request.lifecycle_status is None:
raise InventoryValidationError(
"Lifecycle status cannot be cleared"
)
values["lifecycle_status"] = self.validate_lifecycle_status(
request.lifecycle_status
)
field_map = {
"current_mac": "current_mac",
"current_ip": "current_ip",
"hostname": "hostname",
"miner_sn": "miner_sn",
"control_board_sn": "control_board_sn",
"psu_serial": "power_sn",
"model": "model",
"firmware_version": "firmware_version",
"site_id": "site_id",
"room_id": "room_id",
"notes": "notes",
}
for model_field, database_column in field_map.items():
if model_field not in fields_set:
continue
value = getattr(request, model_field)
if model_field == "current_ip":
values[database_column] = str(value) if value else None
elif isinstance(value, str) or value is None:
values[database_column] = self._clean_optional_text(value)
else:
values[database_column] = value
desired_tags = None
if "tags" in fields_set:
desired_tags = self.normalize_tags(request.tags or [])
try:
with self.repository.transaction() as repository:
self._before_update(existing, values)
row = repository.update_miner(miner_id, values)
if desired_tags is not None:
existing_tags = {
str(tag["name"]).strip().casefold(): tag
for tag in row.get("tags") or []
}
desired_keys = {tag.casefold() for tag in desired_tags}
for key, tag in existing_tags.items():
if key not in desired_keys:
repository.remove_tag(miner_id, tag["id"])
for tag in desired_tags:
if tag.casefold() not in existing_tags:
repository.add_tag(miner_id, tag)
row = repository.get_miner_by_id(miner_id)
self._after_update(row)
except UniqueViolation as exc:
raise DuplicateAssetID(asset_id) from exc
return self._detail_model(row)
def _after_create(self, miner: dict) -> None:
"""Extension point for later inventory integrations."""
def _before_update(self, existing: dict, changes: dict[str, Any]) -> None:
"""Extension point for later inventory integrations."""
def _after_update(self, miner: dict) -> None:
"""Extension point for later inventory integrations."""