445 lines
16 KiB
Python
445 lines
16 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,
|
|
InventoryFleetAttentionSummary,
|
|
InventoryFleetSummary,
|
|
InventoryMetadataUpdateRequest,
|
|
InventoryMinerDetail,
|
|
InventoryMinerSummary,
|
|
InventorySearchFilters,
|
|
InventoryTag,
|
|
InventoryUpdateRequest,
|
|
)
|
|
from app.inventory.repository import InventoryRepository
|
|
|
|
|
|
ALLOWED_LIFECYCLE_STATUSES = frozenset(
|
|
{"active", "spare", "retired", "in_repair"}
|
|
)
|
|
|
|
|
|
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:
|
|
active_repair = None
|
|
if row.get("active_repair_id") is not None:
|
|
active_repair = {
|
|
"id": row["active_repair_id"],
|
|
"status": row["active_repair_status"],
|
|
"reason": row["active_repair_reason"],
|
|
}
|
|
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"),
|
|
miner_sn=row.get("miner_sn"),
|
|
lifecycle_status=row["lifecycle_status"],
|
|
runtime_status=row["status"],
|
|
site_id=row.get("site_id"),
|
|
site_name=row.get("site_name"),
|
|
room_id=row.get("room_id"),
|
|
room_name=row.get("room_name"),
|
|
location_position_id=row.get("location_position_id"),
|
|
rack_label=row.get("rack_label"),
|
|
shelf_label=row.get("shelf_label"),
|
|
position_label=row.get("position_label"),
|
|
position_index=row.get("position_index"),
|
|
observed_at=row.get("observed_at"),
|
|
hashrate_ths=row.get("hashrate_ths"),
|
|
power_w=row.get("power_w"),
|
|
chip_temp_max_c=row.get("chip_temp_max_c"),
|
|
hash_stable=row.get("hash_stable"),
|
|
active_repair=active_repair,
|
|
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"),
|
|
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 summarize_fleet(self) -> InventoryFleetSummary:
|
|
row = self.repository.summarize_fleet()
|
|
return InventoryFleetSummary(
|
|
total=row["total"],
|
|
runtime_online=row["runtime_online"],
|
|
runtime_offline=row["runtime_offline"],
|
|
runtime_unknown=row["runtime_unknown"],
|
|
lifecycle_in_repair=row["lifecycle_in_repair"],
|
|
lifecycle_spare=row["lifecycle_spare"],
|
|
lifecycle_retired=row["lifecycle_retired"],
|
|
attention=InventoryFleetAttentionSummary(
|
|
offline=row["attention_offline"],
|
|
unknown=row["attention_unknown"],
|
|
unstable=row["attention_unstable"],
|
|
repair=row["attention_repair"],
|
|
),
|
|
)
|
|
|
|
def search_miners(
|
|
self,
|
|
filters: InventorySearchFilters,
|
|
*,
|
|
include_location_details: bool = False,
|
|
) -> list[InventoryMinerSummary]:
|
|
lifecycle_status = None
|
|
if filters.lifecycle_status:
|
|
lifecycle_status = self.validate_lifecycle_status(
|
|
filters.lifecycle_status
|
|
)
|
|
if (filters.site_id or filters.room_id) and not include_location_details:
|
|
raise InventoryValidationError(
|
|
"Location filters require location viewing permission"
|
|
)
|
|
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,
|
|
model=self._clean_optional_text(filters.model),
|
|
runtime_status=self._clean_optional_text(filters.runtime_status),
|
|
repair=filters.repair,
|
|
operational_state=filters.operational_state,
|
|
problems=filters.problems,
|
|
site_id=filters.site_id,
|
|
room_id=filters.room_id,
|
|
tag_names=tag_names,
|
|
limit=filters.limit,
|
|
offset=filters.offset,
|
|
include_location_details=include_location_details,
|
|
)
|
|
]
|
|
|
|
def list_location_filter_options(self) -> dict[str, list[dict]]:
|
|
return self.repository.list_location_filter_options()
|
|
|
|
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."""
|