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."""
|
||||
Reference in new issue
Block a user