The Device Inventory was a read-only discovery log: rows arrived from a
scan or an import and went stale. Everything a user curates — properties,
services, notes, hardware, check method — could only be edited on a canvas
node, and never came back.
Give the inventory row the fields a node carries and a way to write them:
- device_inventory gains label, type, notes, cpu_count/cpu_model/ram_gb/
disk_gb, show_hardware, check_method/check_target, last_seen, last_scan,
response_time_ms and updated_at. Live reachability goes in a new
status_live: `status` already means the pending/approved/hidden lifecycle
and the two must not be conflated.
- PATCH /api/v1/scan/pending/{id} applies only what the client sends, so
editing one field never clears the rest. Lifecycle and discovery
bookkeeping stay owned by the approve/hide routes and the importers.
- InventoryDeviceCreate carries the same fields, so a hand-made entry needs
no create-then-PATCH round trip.
- InventoryDeviceModal becomes show *and* edit, reusing the canvas editors
(PropertyList, ServiceModal) rather than growing a second implementation.
- InventoryEntry moves to types/index.ts, its home; the modal re-exports it
so existing call sites are untouched. NODE_TYPE_GROUPS moves to
utils/nodeTypeGroups so both type pickers share one vocabulary.
ha-relevant: maybe
166 lines
5.8 KiB
Python
166 lines
5.8 KiB
Python
"""Editing an inventory row: PATCH /scan/pending/{id} and the widened create.
|
|
|
|
The inventory row owns the device facts, so it has to be editable on its own —
|
|
not only through a canvas node.
|
|
"""
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
from app.db.models import InventoryDevice
|
|
|
|
|
|
async def _seed(db_session, **kwargs) -> InventoryDevice:
|
|
device = InventoryDevice(
|
|
ip="192.168.1.10",
|
|
mac="aa:bb:cc:11:22:33",
|
|
hostname="nas",
|
|
services=[{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
|
|
suggested_type="nas",
|
|
status="pending",
|
|
discovery_source="arp",
|
|
discovery_sources=["arp"],
|
|
properties=[{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
|
|
**kwargs,
|
|
)
|
|
db_session.add(device)
|
|
await db_session.commit()
|
|
return device
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_pending_requires_auth(client: AsyncClient, db_session):
|
|
device = await _seed(db_session)
|
|
res = await client.patch(f"/api/v1/scan/pending/{device.id}", json={"hostname": "x"})
|
|
assert res.status_code == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_pending_edits_curated_fields(client: AsyncClient, headers, db_session):
|
|
device = await _seed(db_session)
|
|
res = await client.patch(
|
|
f"/api/v1/scan/pending/{device.id}",
|
|
headers=headers,
|
|
json={
|
|
"label": "Big NAS",
|
|
"type": "nas",
|
|
"notes": "in the garage rack",
|
|
"cpu_count": 4,
|
|
"cpu_model": "N5105",
|
|
"ram_gb": 16.0,
|
|
"disk_gb": 4000.0,
|
|
"show_hardware": True,
|
|
"check_method": "http",
|
|
"check_target": "http://192.168.1.10:5000",
|
|
"properties": [
|
|
{"key": "Rack", "value": "B2", "icon": None, "visible": True},
|
|
{"key": "Owner", "value": "me", "icon": None, "visible": False},
|
|
],
|
|
"services": [
|
|
{"port": 22, "protocol": "tcp", "service_name": "ssh"},
|
|
{"port": 5000, "protocol": "tcp", "service_name": "http"},
|
|
],
|
|
},
|
|
)
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert data["label"] == "Big NAS"
|
|
assert data["notes"] == "in the garage rack"
|
|
assert data["cpu_count"] == 4
|
|
assert data["ram_gb"] == 16.0
|
|
assert data["show_hardware"] is True
|
|
assert data["check_method"] == "http"
|
|
assert len(data["properties"]) == 2
|
|
assert len(data["services"]) == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_pending_is_partial(client: AsyncClient, headers, db_session):
|
|
"""Sending one field must not clear the others."""
|
|
device = await _seed(db_session)
|
|
res = await client.patch(
|
|
f"/api/v1/scan/pending/{device.id}", headers=headers, json={"notes": "only this"}
|
|
)
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert data["notes"] == "only this"
|
|
assert data["hostname"] == "nas"
|
|
assert data["ip"] == "192.168.1.10"
|
|
assert data["mac"] == "aa:bb:cc:11:22:33"
|
|
assert data["suggested_type"] == "nas"
|
|
assert len(data["services"]) == 1
|
|
assert data["properties"][0]["key"] == "Rack"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_pending_normalizes_mac(client: AsyncClient, headers, db_session):
|
|
device = await _seed(db_session)
|
|
res = await client.patch(
|
|
f"/api/v1/scan/pending/{device.id}", headers=headers, json={"mac": "AA-BB-CC-99-88-77"}
|
|
)
|
|
assert res.status_code == 200
|
|
assert res.json()["mac"] == "aa:bb:cc:99:88:77"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_pending_leaves_lifecycle_alone(client: AsyncClient, headers, db_session):
|
|
"""`status` is owned by approve/hide, not by the edit modal."""
|
|
device = await _seed(db_session)
|
|
res = await client.patch(
|
|
f"/api/v1/scan/pending/{device.id}",
|
|
headers=headers,
|
|
json={"label": "x", "status": "approved", "discovery_source": "manual"},
|
|
)
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert data["label"] == "x"
|
|
assert data["status"] == "pending"
|
|
assert data["discovery_source"] == "arp"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_pending_unknown_id(client: AsyncClient, headers):
|
|
res = await client.patch("/api/v1/scan/pending/nope", headers=headers, json={"notes": "x"})
|
|
assert res.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_pending_accepts_curated_fields(client: AsyncClient, headers):
|
|
"""A hand-made entry carries everything the modal shows, no PATCH round trip."""
|
|
res = await client.post(
|
|
"/api/v1/scan/pending",
|
|
headers=headers,
|
|
json={
|
|
"hostname": "patch-panel",
|
|
"os": "n/a",
|
|
"label": "Patch panel 24p",
|
|
"type": "switch",
|
|
"notes": "passive",
|
|
"services": [{"port": 80, "protocol": "tcp", "service_name": "http"}],
|
|
"friendly_name": "PP-1",
|
|
"device_subtype": "passive",
|
|
"check_method": "none",
|
|
"discovery_source": "manual",
|
|
},
|
|
)
|
|
assert res.status_code == 201
|
|
data = res.json()
|
|
assert data["label"] == "Patch panel 24p"
|
|
assert data["type"] == "switch"
|
|
assert data["os"] == "n/a"
|
|
assert data["notes"] == "passive"
|
|
assert data["friendly_name"] == "PP-1"
|
|
assert data["services"][0]["port"] == 80
|
|
assert data["status"] == "pending"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_legacy_row_defaults_are_coerced(client: AsyncClient, headers, db_session):
|
|
"""Rows predating the 3.3.0 columns read back with defaults, not nulls."""
|
|
device = await _seed(db_session)
|
|
res = await client.get("/api/v1/scan/pending", headers=headers)
|
|
assert res.status_code == 200
|
|
row = next(d for d in res.json() if d["id"] == device.id)
|
|
assert row["status_live"] == "unknown"
|
|
assert row["show_hardware"] is False
|
|
assert row["label"] is None
|