Add inventory domain foundation

This commit is contained in:
lussierj committed 2026-07-28 16:18:11 +00:00
1 parent d4df20b740
commit d1d02962da
8 files changed
+1135

No files matched your search

+290
View File
@@ -0,0 +1,290 @@
"""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 (
InventoryCreateRequest,
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 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_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)
duplicate = self.repository.get_miner_by_asset_id(asset_id)
if duplicate and duplicate["id"] != miner_id:
raise DuplicateAssetID(asset_id)
values["asset_tag"] = asset_id
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."""