feat: add inventory metadata updates

This commit is contained in:
lussierj committed 2026-07-28 19:30:51 +00:00
1 parent 51aec1b404
commit a87ab2709f
11 files changed
+1048 -12

No files matched your search

+2
View File
@@ -8,6 +8,7 @@ from app.inventory.exceptions import (
)
from app.inventory.models import (
InventoryCreateRequest,
InventoryMetadataUpdateRequest,
InventoryMinerDetail,
InventoryMinerSummary,
InventorySearchFilters,
@@ -21,6 +22,7 @@ __all__ = [
"DuplicateAssetID",
"InventoryCreateRequest",
"InventoryError",
"InventoryMetadataUpdateRequest",
"InventoryMinerDetail",
"InventoryMinerSummary",
"InventoryRepository",
+11 -1
View File
@@ -4,7 +4,7 @@ from datetime import datetime
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, Field, IPvAnyAddress
from pydantic import BaseModel, ConfigDict, Field, IPvAnyAddress
LifecycleStatus = Literal["active", "spare", "retired"]
@@ -60,6 +60,16 @@ class InventoryCreateRequest(BaseModel):
tags: list[str] = Field(default_factory=list)
class InventoryMetadataUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
asset_id: str | None = Field(default=None, max_length=120)
miner_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)
notes: str | None = None
class InventoryUpdateRequest(BaseModel):
asset_id: str | None = Field(default=None, max_length=120)
current_mac: str | None = Field(default=None, max_length=64)
+72 -2
View File
@@ -145,8 +145,18 @@ class InventoryRepository:
connection.close()
@staticmethod
def _fetch_miner(cur, where_sql: str, params: Sequence[Any]) -> dict | None:
cur.execute(f"{MINER_SELECT} WHERE {where_sql}", params)
def _fetch_miner(
cur,
where_sql: str,
params: Sequence[Any],
*,
for_update: bool = False,
) -> dict | None:
lock_sql = " FOR UPDATE OF miners" if for_update else ""
cur.execute(
f"{MINER_SELECT} WHERE {where_sql}{lock_sql}",
params,
)
row = cur.fetchone()
return dict(row) if row else None
@@ -155,6 +165,16 @@ class InventoryRepository:
with connection.cursor(row_factory=dict_row) as cur:
return self._fetch_miner(cur, "miners.id = %s", (miner_id,))
def get_miner_by_id_for_update(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,),
for_update=True,
)
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:
@@ -291,6 +311,56 @@ class InventoryRepository:
return None
return self._fetch_miner(cur, "miners.id = %s", (miner_id,))
def add_asset_id_history(
self,
miner_id: UUID,
old_asset_id: str | None,
new_asset_id: str,
changed_by: UUID,
) -> dict:
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
INSERT INTO miner_asset_id_history (
miner_id,
old_asset_id,
new_asset_id,
changed_by
)
VALUES (%s, %s, %s, %s)
RETURNING
id,
miner_id,
old_asset_id,
new_asset_id,
changed_at,
changed_by
""",
(miner_id, old_asset_id, new_asset_id, changed_by),
)
return dict(cur.fetchone())
def list_asset_id_history(self, miner_id: UUID) -> list[dict]:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT
id,
miner_id,
old_asset_id,
new_asset_id,
changed_at,
changed_by
FROM miner_asset_id_history
WHERE miner_id = %s
ORDER BY changed_at, id
""",
(miner_id,),
)
return [dict(row) for row in cur.fetchall()]
def list_tags(self) -> list[dict]:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
+68 -1
View File
@@ -7,9 +7,15 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.identity import require_permission
from app.inventory.dependencies import get_inventory_service
from app.inventory.exceptions import InventoryValidationError, MinerNotFound
from app.inventory.exceptions import (
DuplicateAssetID,
InventoryValidationError,
MinerNotFound,
)
from app.inventory.models import (
InventoryCreateRequest,
InventoryListMeta,
InventoryMetadataUpdateRequest,
InventoryMinerDetailResponse,
InventoryMinerListResponse,
InventorySearchFilters,
@@ -19,15 +25,76 @@ from app.inventory.service import InventoryService
router = APIRouter(prefix="/api/inventory/miners", tags=["inventory"])
require_inventory_read = require_permission("miners.view")
require_inventory_write = require_permission("miners.update")
InventoryServiceDependency = Annotated[
InventoryService,
Depends(get_inventory_service),
]
InventoryReadUser = Annotated[dict, Depends(require_inventory_read)]
InventoryWriteUser = Annotated[dict, Depends(require_inventory_write)]
InventoryQuery = Annotated[InventorySearchFilters, Query()]
@router.post(
"",
response_model=InventoryMinerDetailResponse,
status_code=status.HTTP_201_CREATED,
)
def create_inventory_miner(
request: InventoryCreateRequest,
service: InventoryServiceDependency,
current_user: InventoryWriteUser,
) -> InventoryMinerDetailResponse:
del current_user
try:
miner = service.create_miner(request)
except DuplicateAssetID as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except InventoryValidationError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
return InventoryMinerDetailResponse(data=miner)
@router.patch("/{miner_id}", response_model=InventoryMinerDetailResponse)
def update_inventory_miner_metadata(
miner_id: UUID,
request: InventoryMetadataUpdateRequest,
service: InventoryServiceDependency,
current_user: InventoryWriteUser,
) -> InventoryMinerDetailResponse:
try:
miner = service.update_metadata(
miner_id,
request,
actor_user_id=UUID(str(current_user["id"])),
)
except MinerNotFound as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Miner not found",
) from exc
except DuplicateAssetID as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except InventoryValidationError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
return InventoryMinerDetailResponse(data=miner)
@router.get("", response_model=InventoryMinerListResponse)
def list_inventory_miners(
filters: InventoryQuery,
+87 -4
View File
@@ -13,6 +13,7 @@ from app.inventory.exceptions import (
)
from app.inventory.models import (
InventoryCreateRequest,
InventoryMetadataUpdateRequest,
InventoryMinerDetail,
InventoryMinerSummary,
InventorySearchFilters,
@@ -197,6 +198,85 @@ class InventoryService:
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,
@@ -213,10 +293,13 @@ class InventoryService:
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
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"]