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

+33
View File
@@ -0,0 +1,33 @@
"""Inventory domain models, persistence, and business services."""
from app.inventory.exceptions import (
DuplicateAssetID,
InventoryError,
InventoryValidationError,
MinerNotFound,
)
from app.inventory.models import (
InventoryCreateRequest,
InventoryMinerDetail,
InventoryMinerSummary,
InventorySearchFilters,
InventoryTag,
InventoryUpdateRequest,
)
from app.inventory.repository import InventoryRepository
from app.inventory.service import InventoryService
__all__ = [
"DuplicateAssetID",
"InventoryCreateRequest",
"InventoryError",
"InventoryMinerDetail",
"InventoryMinerSummary",
"InventoryRepository",
"InventorySearchFilters",
"InventoryService",
"InventoryTag",
"InventoryUpdateRequest",
"InventoryValidationError",
"MinerNotFound",
]
+25
View File
@@ -0,0 +1,25 @@
"""Inventory domain exceptions."""
class InventoryError(Exception):
"""Base exception for inventory operations."""
class InventoryValidationError(InventoryError):
"""Raised when an inventory business rule is violated."""
class DuplicateAssetID(InventoryError):
"""Raised when an Asset ID is already assigned to another miner."""
def __init__(self, asset_id: str):
self.asset_id = asset_id
super().__init__(f"Asset ID already exists: {asset_id}")
class MinerNotFound(InventoryError):
"""Raised when an inventory miner cannot be found."""
def __init__(self, miner_id: object):
self.miner_id = miner_id
super().__init__(f"Miner not found: {miner_id}")
+86
View File
@@ -0,0 +1,86 @@
"""Pydantic contracts for the inventory domain."""
from datetime import datetime
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, Field, IPvAnyAddress
LifecycleStatus = Literal["active", "spare", "retired"]
class InventoryTag(BaseModel):
id: UUID
name: str
created_at: datetime
updated_at: datetime
class InventoryMinerSummary(BaseModel):
id: UUID
asset_id: str | None = None
current_mac: str | None = None
current_ip: str | None = None
model: str | None = None
lifecycle_status: LifecycleStatus
runtime_status: str
site_id: UUID | None = None
room_id: UUID | None = None
tags: list[InventoryTag] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
last_seen_at: datetime | None = None
class InventoryMinerDetail(InventoryMinerSummary):
hostname: str | None = None
miner_sn: str | None = None
control_board_sn: str | None = None
psu_serial: str | None = None
firmware_version: str | None = None
notes: str | None = None
first_seen_at: datetime | None = None
class InventoryCreateRequest(BaseModel):
asset_id: str = Field(min_length=1, max_length=120)
current_mac: str | None = Field(default=None, max_length=64)
current_ip: IPvAnyAddress | None = None
hostname: str | None = Field(default=None, max_length=255)
miner_sn: str | None = Field(default=None, max_length=255)
control_board_sn: str | None = Field(default=None, max_length=255)
psu_serial: str | None = Field(default=None, max_length=255)
model: str | None = Field(default=None, max_length=255)
firmware_version: str | None = Field(default=None, max_length=255)
lifecycle_status: str = "active"
site_id: UUID | None = None
room_id: UUID | None = None
notes: str | None = None
tags: list[str] = Field(default_factory=list)
class InventoryUpdateRequest(BaseModel):
asset_id: str | None = Field(default=None, max_length=120)
current_mac: str | None = Field(default=None, max_length=64)
current_ip: IPvAnyAddress | None = None
hostname: str | None = Field(default=None, max_length=255)
miner_sn: str | None = Field(default=None, max_length=255)
control_board_sn: str | None = Field(default=None, max_length=255)
psu_serial: str | None = Field(default=None, max_length=255)
model: str | None = Field(default=None, max_length=255)
firmware_version: str | None = Field(default=None, max_length=255)
lifecycle_status: str | None = None
site_id: UUID | None = None
room_id: UUID | None = None
notes: str | None = None
tags: list[str] | None = None
class InventorySearchFilters(BaseModel):
query: str | None = None
asset_id: str | None = None
lifecycle_status: str | None = None
tags: list[str] = Field(default_factory=list)
limit: int = Field(default=100, ge=1, le=500)
offset: int = Field(default=0, ge=0)
+337
View File
@@ -0,0 +1,337 @@
"""PostgreSQL persistence for the inventory domain."""
from collections.abc import Callable, Iterator, Mapping, Sequence
from contextlib import contextmanager
from typing import Any
from uuid import UUID
import psycopg
from psycopg.rows import dict_row
from app.config import settings
ConnectionFactory = Callable[[], psycopg.Connection]
TAG_TRIMMED_NAME_SQL = (
"regexp_replace(name, '^[[:space:]]+|[[:space:]]+$', '', 'g')"
)
MINER_SELECT = f"""
SELECT
miners.id,
miners.site_id,
miners.room_id,
miners.asset_tag,
miners.current_mac,
host(miners.current_ip) AS current_ip,
miners.hostname,
miners.miner_sn,
miners.control_board_sn,
miners.power_sn,
miners.model,
miners.firmware_version,
miners.lifecycle_status,
miners.status,
miners.notes,
miners.first_seen_at,
miners.last_seen_at,
miners.created_at,
miners.updated_at,
tag_data.tags
FROM miners
LEFT JOIN LATERAL (
SELECT COALESCE(
jsonb_agg(
jsonb_build_object(
'id', tags.id,
'name', tags.name,
'created_at', tags.created_at,
'updated_at', tags.updated_at
)
ORDER BY lower({TAG_TRIMMED_NAME_SQL}), tags.id
),
'[]'::jsonb
) AS tags
FROM miner_tags
JOIN tags ON tags.id = miner_tags.tag_id
WHERE miner_tags.miner_id = miners.id
) tag_data ON true
"""
MINER_WRITE_COLUMNS = {
"site_id",
"room_id",
"asset_tag",
"current_mac",
"current_ip",
"hostname",
"miner_sn",
"control_board_sn",
"power_sn",
"model",
"firmware_version",
"lifecycle_status",
"status",
"notes",
"first_seen_at",
"last_seen_at",
}
def default_connection_factory() -> psycopg.Connection:
return psycopg.connect(
host=settings.postgres_host,
port=settings.postgres_port,
dbname=settings.postgres_db,
user=settings.postgres_user,
password=settings.postgres_password,
connect_timeout=5,
)
class InventoryRepository:
"""Database-only inventory persistence with optional transaction binding."""
def __init__(
self,
connection_factory: ConnectionFactory | None = None,
*,
connection: psycopg.Connection | None = None,
):
self._connection_factory = connection_factory or default_connection_factory
self._connection = connection
@contextmanager
def transaction(self) -> Iterator["InventoryRepository"]:
if self._connection is not None:
yield self
return
connection = self._connection_factory()
transaction_repository = InventoryRepository(
self._connection_factory,
connection=connection,
)
try:
yield transaction_repository
connection.commit()
except Exception:
connection.rollback()
raise
finally:
connection.close()
@contextmanager
def _connection_scope(
self,
*,
write: bool = False,
) -> Iterator[psycopg.Connection]:
if self._connection is not None:
yield self._connection
return
connection = self._connection_factory()
try:
yield connection
if write:
connection.commit()
except Exception:
if write:
connection.rollback()
raise
finally:
connection.close()
@staticmethod
def _fetch_miner(cur, where_sql: str, params: Sequence[Any]) -> dict | None:
cur.execute(f"{MINER_SELECT} WHERE {where_sql}", params)
row = cur.fetchone()
return dict(row) if row else None
def get_miner_by_id(self, miner_id: UUID) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
return self._fetch_miner(cur, "miners.id = %s", (miner_id,))
def get_miner_by_asset_id(self, asset_id: str) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
return self._fetch_miner(
cur,
"lower(trim(miners.asset_tag)) = lower(trim(%s))",
(asset_id,),
)
def list_miners(self, *, limit: int = 100, offset: int = 0) -> list[dict]:
return self.search_miners(limit=limit, offset=offset)
def search_miners(
self,
*,
query: str | None = None,
asset_id: str | None = None,
lifecycle_status: str | None = None,
tag_names: Sequence[str] = (),
limit: int = 100,
offset: int = 0,
) -> list[dict]:
conditions: list[str] = []
params: list[Any] = []
if query:
pattern = f"%{query}%"
conditions.append(
"""
(
miners.asset_tag ILIKE %s
OR miners.current_mac ILIKE %s
OR host(miners.current_ip) ILIKE %s
OR miners.model ILIKE %s
OR miners.miner_sn ILIKE %s
OR miners.control_board_sn ILIKE %s
OR miners.power_sn ILIKE %s
OR miners.notes ILIKE %s
)
"""
)
params.extend([pattern] * 8)
if asset_id:
conditions.append("lower(trim(miners.asset_tag)) = lower(trim(%s))")
params.append(asset_id)
if lifecycle_status:
conditions.append("miners.lifecycle_status = %s")
params.append(lifecycle_status)
for tag_name in tag_names:
conditions.append(
f"""
EXISTS (
SELECT 1
FROM miner_tags search_miner_tags
JOIN tags search_tags ON search_tags.id = search_miner_tags.tag_id
WHERE search_miner_tags.miner_id = miners.id
AND lower({TAG_TRIMMED_NAME_SQL.replace("name", "search_tags.name")})
= %s
)
"""
)
params.append(tag_name)
where_sql = " AND ".join(conditions) if conditions else "true"
params.extend([limit, offset])
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
f"""
{MINER_SELECT}
WHERE {where_sql}
ORDER BY
lower(trim(miners.asset_tag)) NULLS LAST,
miners.created_at,
miners.id
LIMIT %s OFFSET %s
""",
params,
)
return [dict(row) for row in cur.fetchall()]
def create_miner(self, values: Mapping[str, Any]) -> dict:
clean_values = {
key: value for key, value in values.items() if key in MINER_WRITE_COLUMNS
}
columns = list(clean_values)
params = [clean_values[column] for column in columns]
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
if columns:
placeholders = ", ".join(["%s"] * len(columns))
cur.execute(
f"""
INSERT INTO miners ({", ".join(columns)})
VALUES ({placeholders})
RETURNING id
""",
params,
)
else:
cur.execute("INSERT INTO miners DEFAULT VALUES RETURNING id")
miner_id = cur.fetchone()["id"]
return self._fetch_miner(cur, "miners.id = %s", (miner_id,))
def update_miner(self, miner_id: UUID, values: Mapping[str, Any]) -> dict | None:
clean_values = {
key: value for key, value in values.items() if key in MINER_WRITE_COLUMNS
}
if not clean_values:
return self.get_miner_by_id(miner_id)
assignments = [f"{column} = %s" for column in clean_values]
params = list(clean_values.values())
params.append(miner_id)
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
f"""
UPDATE miners
SET {", ".join(assignments)}, updated_at = now()
WHERE id = %s
RETURNING id
""",
params,
)
row = cur.fetchone()
if not row:
return None
return self._fetch_miner(cur, "miners.id = %s", (miner_id,))
def list_tags(self) -> list[dict]:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
f"""
SELECT id, name, created_at, updated_at
FROM tags
ORDER BY lower({TAG_TRIMMED_NAME_SQL}), id
"""
)
return [dict(row) for row in cur.fetchall()]
def add_tag(self, miner_id: UUID, tag_name: str) -> dict:
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
f"""
INSERT INTO tags (name)
VALUES (%s)
ON CONFLICT (lower({TAG_TRIMMED_NAME_SQL}))
DO UPDATE SET updated_at = tags.updated_at
RETURNING id, name, created_at, updated_at
""",
(tag_name,),
)
tag = dict(cur.fetchone())
cur.execute(
"""
INSERT INTO miner_tags (miner_id, tag_id)
VALUES (%s, %s)
ON CONFLICT (miner_id, tag_id) DO NOTHING
""",
(miner_id, tag["id"]),
)
return tag
def remove_tag(self, miner_id: UUID, tag_id: UUID) -> bool:
with self._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(
"DELETE FROM miner_tags WHERE miner_id = %s AND tag_id = %s",
(miner_id, tag_id),
)
return cur.rowcount > 0
+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."""