"""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."""