feat: add inventory metadata updates
This commit is contained in:
1 parent
51aec1b404
commit
a87ab2709f
11 files changed
+1048
-12
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
Reference in new issue
Block a user