Add inventory domain foundation
This commit is contained in:
1 parent
d4df20b740
commit
d1d02962da
8 files changed
+1135
No files matched your search
@@ -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",
|
||||
]
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
@@ -0,0 +1 @@
|
||||
"""MinerOps backend tests."""
|
||||
@@ -0,0 +1,305 @@
|
||||
import ast
|
||||
import unittest
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app.inventory import (
|
||||
DuplicateAssetID,
|
||||
InventoryCreateRequest,
|
||||
InventoryRepository,
|
||||
InventorySearchFilters,
|
||||
InventoryService,
|
||||
InventoryUpdateRequest,
|
||||
InventoryValidationError,
|
||||
)
|
||||
|
||||
|
||||
def inventory_row(**overrides):
|
||||
now = datetime.now(timezone.utc)
|
||||
row = {
|
||||
"id": uuid4(),
|
||||
"site_id": None,
|
||||
"room_id": None,
|
||||
"asset_tag": "ASSET-001",
|
||||
"current_mac": None,
|
||||
"current_ip": None,
|
||||
"hostname": None,
|
||||
"miner_sn": None,
|
||||
"control_board_sn": None,
|
||||
"power_sn": None,
|
||||
"model": None,
|
||||
"firmware_version": None,
|
||||
"lifecycle_status": "active",
|
||||
"status": "unknown",
|
||||
"notes": None,
|
||||
"first_seen_at": None,
|
||||
"last_seen_at": None,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"tags": [],
|
||||
}
|
||||
row.update(overrides)
|
||||
return row
|
||||
|
||||
|
||||
class FakeInventoryRepository:
|
||||
def __init__(self):
|
||||
self.rows = {}
|
||||
self.created_values = None
|
||||
|
||||
@contextmanager
|
||||
def transaction(self):
|
||||
yield self
|
||||
|
||||
def get_miner_by_id(self, miner_id):
|
||||
return self.rows.get(miner_id)
|
||||
|
||||
def get_miner_by_asset_id(self, asset_id):
|
||||
key = asset_id.strip().casefold()
|
||||
return next(
|
||||
(
|
||||
row
|
||||
for row in self.rows.values()
|
||||
if str(row.get("asset_tag") or "").strip().casefold() == key
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def list_miners(self, *, limit=100, offset=0):
|
||||
return list(self.rows.values())[offset : offset + limit]
|
||||
|
||||
def search_miners(self, **kwargs):
|
||||
rows = list(self.rows.values())
|
||||
lifecycle = kwargs.get("lifecycle_status")
|
||||
if lifecycle:
|
||||
rows = [
|
||||
row for row in rows if row["lifecycle_status"] == lifecycle
|
||||
]
|
||||
return rows[kwargs.get("offset", 0) : kwargs.get("limit", 100)]
|
||||
|
||||
def create_miner(self, values):
|
||||
self.created_values = dict(values)
|
||||
row = inventory_row(**values)
|
||||
self.rows[row["id"]] = row
|
||||
return row
|
||||
|
||||
def update_miner(self, miner_id, values):
|
||||
row = self.rows.get(miner_id)
|
||||
if not row:
|
||||
return None
|
||||
row.update(values)
|
||||
row["updated_at"] = datetime.now(timezone.utc)
|
||||
return row
|
||||
|
||||
def add_tag(self, miner_id, tag_name):
|
||||
now = datetime.now(timezone.utc)
|
||||
tag = {
|
||||
"id": uuid4(),
|
||||
"name": tag_name,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
existing = {
|
||||
item["name"].strip().casefold()
|
||||
for item in self.rows[miner_id]["tags"]
|
||||
}
|
||||
if tag_name.casefold() not in existing:
|
||||
self.rows[miner_id]["tags"].append(tag)
|
||||
return tag
|
||||
|
||||
def remove_tag(self, miner_id, tag_id):
|
||||
before = len(self.rows[miner_id]["tags"])
|
||||
self.rows[miner_id]["tags"] = [
|
||||
tag for tag in self.rows[miner_id]["tags"] if tag["id"] != tag_id
|
||||
]
|
||||
return len(self.rows[miner_id]["tags"]) != before
|
||||
|
||||
|
||||
class InventoryServiceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.repository = FakeInventoryRepository()
|
||||
self.service = InventoryService(self.repository)
|
||||
|
||||
def test_create_normalizes_asset_id_tags_and_never_seen(self):
|
||||
created = self.service.create_miner(
|
||||
InventoryCreateRequest(
|
||||
asset_id=" Mine-001 ",
|
||||
psu_serial=" PSU-9 ",
|
||||
tags=[" Priority ", "priority", "\tRepair\n"],
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(created.asset_id, "Mine-001")
|
||||
self.assertEqual(created.psu_serial, "PSU-9")
|
||||
self.assertEqual([tag.name for tag in created.tags], ["Priority", "Repair"])
|
||||
self.assertIsNone(created.first_seen_at)
|
||||
self.assertIsNone(created.last_seen_at)
|
||||
self.assertIsNone(self.repository.created_values["first_seen_at"])
|
||||
self.assertIsNone(self.repository.created_values["last_seen_at"])
|
||||
|
||||
def test_duplicate_asset_id_is_case_and_whitespace_insensitive(self):
|
||||
row = inventory_row(asset_tag="Mine-001")
|
||||
self.repository.rows[row["id"]] = row
|
||||
|
||||
with self.assertRaises(DuplicateAssetID):
|
||||
self.service.create_miner(
|
||||
InventoryCreateRequest(asset_id=" mine-001 ")
|
||||
)
|
||||
|
||||
def test_lifecycle_validation_is_owned_by_service(self):
|
||||
self.assertEqual(
|
||||
self.service.validate_lifecycle_status(" SPARE "),
|
||||
"spare",
|
||||
)
|
||||
with self.assertRaises(InventoryValidationError):
|
||||
self.service.create_miner(
|
||||
InventoryCreateRequest(
|
||||
asset_id="ASSET-BAD",
|
||||
lifecycle_status="warehouse",
|
||||
)
|
||||
)
|
||||
|
||||
def test_blank_asset_and_tags_are_rejected(self):
|
||||
with self.assertRaises(InventoryValidationError):
|
||||
self.service.normalize_asset_id(" ")
|
||||
with self.assertRaises(InventoryValidationError):
|
||||
self.service.normalize_tags(["valid", "\t\n"])
|
||||
|
||||
def test_update_maps_product_names_without_repository_rules(self):
|
||||
row = inventory_row(asset_tag="ASSET-OLD", power_sn="PSU-OLD")
|
||||
self.repository.rows[row["id"]] = row
|
||||
|
||||
updated = self.service.update_miner(
|
||||
row["id"],
|
||||
InventoryUpdateRequest(
|
||||
asset_id=" ASSET-NEW ",
|
||||
psu_serial=" PSU-NEW ",
|
||||
lifecycle_status="retired",
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(updated.asset_id, "ASSET-NEW")
|
||||
self.assertEqual(updated.psu_serial, "PSU-NEW")
|
||||
self.assertEqual(updated.lifecycle_status, "retired")
|
||||
|
||||
def test_search_validation_precedes_repository(self):
|
||||
with self.assertRaises(InventoryValidationError):
|
||||
self.service.search_miners(
|
||||
InventorySearchFilters(lifecycle_status="broken")
|
||||
)
|
||||
|
||||
|
||||
class InventoryRepositoryIntegrationTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.repository = InventoryRepository()
|
||||
|
||||
def setUp(self):
|
||||
with self.repository.transaction() as repository:
|
||||
with repository._connection.cursor() as cur:
|
||||
cur.execute("DELETE FROM miner_tags")
|
||||
cur.execute("DELETE FROM tags")
|
||||
cur.execute("DELETE FROM miners")
|
||||
|
||||
def test_repository_crud_search_and_tags(self):
|
||||
created = self.repository.create_miner(
|
||||
{
|
||||
"asset_tag": "REPO-001",
|
||||
"current_ip": "192.0.2.10",
|
||||
"model": "M50",
|
||||
"miner_sn": "MINER-001",
|
||||
"control_board_sn": "CB-001",
|
||||
"power_sn": "PSU-001",
|
||||
"lifecycle_status": "active",
|
||||
"status": "unknown",
|
||||
"first_seen_at": None,
|
||||
"last_seen_at": None,
|
||||
}
|
||||
)
|
||||
miner_id = created["id"]
|
||||
|
||||
self.assertIsInstance(miner_id, UUID)
|
||||
self.assertIsNone(created["first_seen_at"])
|
||||
self.assertIsNone(created["last_seen_at"])
|
||||
self.assertEqual(
|
||||
self.repository.get_miner_by_asset_id(" repo-001 ")["id"],
|
||||
miner_id,
|
||||
)
|
||||
self.assertEqual(self.repository.get_miner_by_id(miner_id)["model"], "M50")
|
||||
self.assertEqual(len(self.repository.list_miners()), 1)
|
||||
self.assertEqual(
|
||||
self.repository.search_miners(query="CB-001")[0]["id"],
|
||||
miner_id,
|
||||
)
|
||||
|
||||
updated = self.repository.update_miner(
|
||||
miner_id,
|
||||
{"lifecycle_status": "spare", "notes": "Bench stock"},
|
||||
)
|
||||
self.assertEqual(updated["lifecycle_status"], "spare")
|
||||
self.assertEqual(
|
||||
self.repository.search_miners(lifecycle_status="spare")[0]["id"],
|
||||
miner_id,
|
||||
)
|
||||
|
||||
tag = self.repository.add_tag(miner_id, "Priority")
|
||||
repeated = self.repository.add_tag(miner_id, " priority ")
|
||||
self.assertEqual(tag["id"], repeated["id"])
|
||||
self.assertEqual(len(self.repository.list_tags()), 1)
|
||||
self.assertEqual(
|
||||
self.repository.search_miners(tag_names=["priority"])[0]["id"],
|
||||
miner_id,
|
||||
)
|
||||
self.assertEqual(
|
||||
len(self.repository.get_miner_by_id(miner_id)["tags"]),
|
||||
1,
|
||||
)
|
||||
self.assertTrue(self.repository.remove_tag(miner_id, tag["id"]))
|
||||
self.assertEqual(
|
||||
self.repository.get_miner_by_id(miner_id)["tags"],
|
||||
[],
|
||||
)
|
||||
|
||||
def test_transaction_rolls_back_repository_writes(self):
|
||||
with self.assertRaises(RuntimeError):
|
||||
with self.repository.transaction() as repository:
|
||||
repository.create_miner(
|
||||
{
|
||||
"asset_tag": "ROLLBACK-001",
|
||||
"status": "unknown",
|
||||
"first_seen_at": None,
|
||||
"last_seen_at": None,
|
||||
}
|
||||
)
|
||||
raise RuntimeError("rollback")
|
||||
|
||||
self.assertIsNone(
|
||||
self.repository.get_miner_by_asset_id("ROLLBACK-001")
|
||||
)
|
||||
|
||||
|
||||
class InventoryArchitectureTests(unittest.TestCase):
|
||||
def test_inventory_package_has_no_http_layer_or_router(self):
|
||||
import app.inventory as inventory
|
||||
|
||||
inventory_path = Path(__file__).parents[1] / "app" / "inventory"
|
||||
for source_path in inventory_path.glob("*.py"):
|
||||
tree = ast.parse(source_path.read_text(encoding="utf-8"))
|
||||
imported_modules = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
imported_modules.extend(alias.name for alias in node.names)
|
||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||
imported_modules.append(node.module)
|
||||
self.assertFalse(
|
||||
any(module.startswith("fastapi") for module in imported_modules),
|
||||
source_path,
|
||||
)
|
||||
|
||||
self.assertFalse(hasattr(inventory, "router"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${repo_root}"
|
||||
|
||||
project_name="${MINEROPS_INVENTORY_TEST_PROJECT:-minerops-inventory-domain-$$}"
|
||||
compose=(
|
||||
docker compose
|
||||
--project-name "${project_name}"
|
||||
--env-file .env.dev
|
||||
-f compose.dev.yaml
|
||||
)
|
||||
|
||||
cleanup() {
|
||||
status=$?
|
||||
if (( status != 0 )); then
|
||||
echo
|
||||
echo "Inventory domain tests failed. Recent PostgreSQL logs:"
|
||||
"${compose[@]}" logs --no-color --tail 120 postgres || true
|
||||
fi
|
||||
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||
exit "${status}"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
if [[ ! -f .env.dev ]]; then
|
||||
echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Starting disposable inventory database..."
|
||||
"${compose[@]}" up --detach postgres
|
||||
for attempt in $(seq 1 60); do
|
||||
if "${compose[@]}" exec -T postgres sh -ec \
|
||||
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
if (( attempt == 60 )); then
|
||||
echo "PostgreSQL did not become ready." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "Building inventory test image..."
|
||||
"${compose[@]}" build backend
|
||||
|
||||
echo "Migrating disposable database..."
|
||||
"${compose[@]}" run --rm --no-deps backend alembic upgrade head
|
||||
|
||||
echo "Running inventory repository and service tests..."
|
||||
"${compose[@]}" run --rm --no-deps \
|
||||
-v "${repo_root}/backend/tests:/app/tests:ro" \
|
||||
backend python -m unittest discover -s /app/tests -v
|
||||
|
||||
echo "Inventory domain tests passed."
|
||||
Reference in new issue
Block a user