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