Add inventory relations model and API structure
CI / basic-check (push) Has been cancelled

This commit is contained in:
Perplexity Bot
2026-08-04 19:20:19 +00:00
parent 69124b31f1
commit 4466e59684
6 changed files with 252 additions and 1 deletions
+4
View File
@@ -3,8 +3,10 @@ from fastapi.middleware.cors import CORSMiddleware
from app.routers.discoveries import router as discoveries_router from app.routers.discoveries import router as discoveries_router
from app.routers.health import router as health_router from app.routers.health import router as health_router
from app.routers.inventory import router as inventory_router
from app.routers.jobs import router as jobs_router from app.routers.jobs import router as jobs_router
from app.routers.objects import router as objects_router from app.routers.objects import router as objects_router
from app.routers.relations import router as relations_router
app = FastAPI(title='NetMapper Backend', version='0.1.0') app = FastAPI(title='NetMapper Backend', version='0.1.0')
@@ -20,3 +22,5 @@ app.include_router(health_router)
app.include_router(objects_router) app.include_router(objects_router)
app.include_router(jobs_router) app.include_router(jobs_router)
app.include_router(discoveries_router) app.include_router(discoveries_router)
app.include_router(inventory_router)
app.include_router(relations_router)
+48
View File
@@ -0,0 +1,48 @@
from fastapi import APIRouter, HTTPException, Query
from app.schemas import EntityResponse, InventoryTreeNode, ListResponse, NodeContext, Relation
from app.services.inventory import build_inventory_tree, build_node_context, get_badges, get_node_relations
router = APIRouter(prefix='/inventory', tags=['inventory'])
@router.get('/tree', response_model=ListResponse[InventoryTreeNode])
def inventory_tree(
root_id: str | None = None,
depth: int | None = Query(default=None, ge=0),
) -> ListResponse[InventoryTreeNode]:
items = build_inventory_tree(root_id=root_id, depth=depth)
return ListResponse(count=len(items), items=items)
@router.get('/tree/{item_id}', response_model=EntityResponse[InventoryTreeNode])
def inventory_subtree(item_id: str, depth: int | None = Query(default=None, ge=0)) -> EntityResponse[InventoryTreeNode]:
items = build_inventory_tree(root_id=item_id, depth=depth)
if not items:
raise HTTPException(status_code=404, detail='Inventory node not found')
return EntityResponse(item=items[0])
@router.get('/nodes/{item_id}/context', response_model=EntityResponse[NodeContext])
def node_context(item_id: str) -> EntityResponse[NodeContext]:
item = build_node_context(item_id)
if not item:
raise HTTPException(status_code=404, detail='Inventory node not found')
return EntityResponse(item=item)
@router.get('/nodes/{item_id}/badges', response_model=ListResponse[str])
def node_badges(item_id: str) -> ListResponse[str]:
badges = get_badges(item_id)
return ListResponse(count=len(badges), items=badges)
@router.get('/nodes/{item_id}/relations', response_model=ListResponse[Relation])
def node_relations(
item_id: str,
layer: str | None = None,
type: str | None = Query(default=None),
direction: str = Query(default='both', pattern='^(incoming|outgoing|both)$'),
) -> ListResponse[Relation]:
items = get_node_relations(item_id, layer=layer, rel_type=type, direction=direction)
return ListResponse(count=len(items), items=items)
+37
View File
@@ -0,0 +1,37 @@
from fastapi import APIRouter, HTTPException, Query
from app.schemas import EntityResponse, ListResponse, Relation
from app.services.inventory import get_node_relations
from app.services.store import RELATIONS
router = APIRouter(prefix='/relations', tags=['relations'])
@router.get('', response_model=ListResponse[Relation])
def list_relations(
source_id: str | None = None,
target_id: str | None = None,
layer: str | None = None,
type: str | None = Query(default=None),
min_confidence: float | None = None,
) -> ListResponse[Relation]:
items = RELATIONS
if source_id:
items = [r for r in items if r.source_id == source_id]
if target_id:
items = [r for r in items if r.target_id == target_id]
if layer:
items = [r for r in items if r.layer == layer]
if type:
items = [r for r in items if r.type == type]
if min_confidence is not None:
items = [r for r in items if r.confidence >= min_confidence]
return ListResponse(count=len(items), items=items)
@router.get('/{relation_id}', response_model=EntityResponse[Relation])
def get_relation(relation_id: str) -> EntityResponse[Relation]:
for item in RELATIONS:
if item.id == relation_id:
return EntityResponse(item=item)
raise HTTPException(status_code=404, detail='Relation not found')
+48
View File
@@ -11,6 +11,14 @@ ObjectType = Literal[
'site', 'area', 'rack', 'physical_host', 'vm', 'lxc', 'container', 'site', 'area', 'rack', 'physical_host', 'vm', 'lxc', 'container',
'app', 'switch', 'firewall', 'router', 'ap', 'camera', 'storage', 'other' 'app', 'switch', 'firewall', 'router', 'ap', 'camera', 'storage', 'other'
] ]
RelationType = Literal[
'contains', 'mounted_in', 'attached_to', 'connects_to', 'uplink_to',
'routes_via', 'bridges_to', 'depends_on', 'hosts_service', 'managed_by'
]
RelationLayer = Literal['physical', 'network', 'logical', 'service']
RelationSource = Literal['manual', 'snmp', 'lldp', 'arp', 'nmap', 'inference', 'import']
RelationStatus = Literal['active', 'stale', 'planned', 'deleted']
RelationDirection = Literal['forward']
class HealthCheck(BaseModel): class HealthCheck(BaseModel):
@@ -75,6 +83,43 @@ class Link(BaseModel):
source: Literal['manual', 'snmp', 'lldp', 'arp', 'import', 'inference'] source: Literal['manual', 'snmp', 'lldp', 'arp', 'import', 'inference']
class Relation(BaseModel):
id: str
type: RelationType
layer: RelationLayer
source_id: str
target_id: str
direction: RelationDirection = 'forward'
source: RelationSource
confidence: float = Field(ge=0, le=1, default=1.0)
status: RelationStatus = 'active'
attributes: Dict[str, Any] = Field(default_factory=dict)
created_at: datetime
updated_at: datetime
class InventoryTreeNode(BaseModel):
id: str
name: str
type: ObjectType
status: ObjectStatus
badges: List[str] = Field(default_factory=list)
children_count: int = 0
children: List['InventoryTreeNode'] = Field(default_factory=list)
class NodeContextRelations(BaseModel):
incoming: List[Relation] = Field(default_factory=list)
outgoing: List[Relation] = Field(default_factory=list)
class NodeContext(BaseModel):
item: NetObject
parent: Optional[NetObject] = None
children: List[NetObject] = Field(default_factory=list)
relations: NodeContextRelations = Field(default_factory=NodeContextRelations)
class Job(BaseModel): class Job(BaseModel):
id: str id: str
type: str type: str
@@ -123,3 +168,6 @@ class ListResponse(BaseModel, Generic[T]):
class EntityResponse(BaseModel, Generic[T]): class EntityResponse(BaseModel, Generic[T]):
item: T item: T
InventoryTreeNode.model_rebuild()
+103
View File
@@ -0,0 +1,103 @@
from collections import defaultdict
from app.schemas import InventoryTreeNode, NetObject, NodeContext, NodeContextRelations, Relation
from app.services.store import OBJECTS, RELATIONS
def get_object_map() -> dict[str, NetObject]:
return {item.id: item for item in OBJECTS}
def get_children_map() -> dict[str, list[NetObject]]:
object_map = get_object_map()
children: dict[str, list[NetObject]] = defaultdict(list)
for rel in RELATIONS:
if rel.type == 'contains' and rel.status == 'active' and rel.target_id in object_map:
children[rel.source_id].append(object_map[rel.target_id])
return children
def get_badges(object_id: str) -> list[str]:
obj = get_object_map()[object_id]
badges: list[str] = []
if obj.type == 'physical_host':
badges.append('virtualization-host')
if obj.type in {'vm', 'router', 'firewall'}:
badges.append('network-node')
outgoing = [r for r in RELATIONS if r.source_id == object_id and r.status == 'active']
if any(r.type == 'routes_via' for r in outgoing):
badges.append('gateway-dependent')
if any(r.type in {'routes_via', 'bridges_to'} for r in outgoing) or obj.name.lower().startswith('openwrt'):
badges.append('gateway')
return badges
def build_tree_node(object_id: str, depth: int | None = None) -> InventoryTreeNode:
object_map = get_object_map()
children_map = get_children_map()
obj = object_map[object_id]
child_objects = children_map.get(object_id, [])
child_nodes = []
if depth is None or depth > 0:
next_depth = None if depth is None else depth - 1
child_nodes = [build_tree_node(child.id, next_depth) for child in child_objects]
return InventoryTreeNode(
id=obj.id,
name=obj.name,
type=obj.type,
status=obj.status,
badges=get_badges(obj.id),
children_count=len(child_objects),
children=child_nodes,
)
def build_inventory_tree(root_id: str | None = None, depth: int | None = None) -> list[InventoryTreeNode]:
object_map = get_object_map()
if root_id:
return [build_tree_node(root_id, depth)] if root_id in object_map else []
roots = [obj for obj in OBJECTS if obj.parent_id is None]
return [build_tree_node(obj.id, depth) for obj in roots]
def get_parent(item: NetObject) -> NetObject | None:
object_map = get_object_map()
if not item.parent_id:
return None
return object_map.get(item.parent_id)
def get_children(item_id: str) -> list[NetObject]:
return get_children_map().get(item_id, [])
def get_relations(item_id: str) -> NodeContextRelations:
incoming = [rel for rel in RELATIONS if rel.target_id == item_id and rel.status == 'active']
outgoing = [rel for rel in RELATIONS if rel.source_id == item_id and rel.status == 'active']
return NodeContextRelations(incoming=incoming, outgoing=outgoing)
def build_node_context(item_id: str) -> NodeContext | None:
object_map = get_object_map()
item = object_map.get(item_id)
if not item:
return None
return NodeContext(
item=item,
parent=get_parent(item),
children=get_children(item_id),
relations=get_relations(item_id),
)
def get_node_relations(item_id: str, layer: str | None = None, rel_type: str | None = None, direction: str = 'both') -> list[Relation]:
result: list[Relation] = []
if direction in {'incoming', 'both'}:
result.extend([r for r in RELATIONS if r.target_id == item_id and r.status == 'active'])
if direction in {'outgoing', 'both'}:
result.extend([r for r in RELATIONS if r.source_id == item_id and r.status == 'active'])
if layer:
result = [r for r in result if r.layer == layer]
if rel_type:
result = [r for r in result if r.type == rel_type]
return result
+12 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from app.schemas import DiscoveryProfile, DiscoveryRun, Job, NetObject from app.schemas import DiscoveryProfile, DiscoveryRun, Job, NetObject, Relation
NOW = datetime.now(timezone.utc) NOW = datetime.now(timezone.utc)
@@ -8,6 +8,17 @@ OBJECTS = [
NetObject(id='obj_1', name='Site HQ', slug='site-hq', type='site', status='active', created_at=NOW, updated_at=NOW), NetObject(id='obj_1', name='Site HQ', slug='site-hq', type='site', status='active', created_at=NOW, updated_at=NOW),
NetObject(id='obj_2', name='Rack A', slug='rack-a', type='rack', parent_id='obj_1', status='active', created_at=NOW, updated_at=NOW), NetObject(id='obj_2', name='Rack A', slug='rack-a', type='rack', parent_id='obj_1', status='active', created_at=NOW, updated_at=NOW),
NetObject(id='obj_3', name='Proxmox Host', slug='proxmox-host', type='physical_host', parent_id='obj_2', status='active', vendor='Lenovo', model='ThinkCentre', created_at=NOW, updated_at=NOW), NetObject(id='obj_3', name='Proxmox Host', slug='proxmox-host', type='physical_host', parent_id='obj_2', status='active', vendor='Lenovo', model='ThinkCentre', created_at=NOW, updated_at=NOW),
NetObject(id='obj_4', name='OpenWRT VM', slug='openwrt-vm', type='vm', parent_id='obj_3', status='active', created_at=NOW, updated_at=NOW),
NetObject(id='obj_5', name='Core Switch', slug='core-switch', type='switch', parent_id='obj_2', status='active', created_at=NOW, updated_at=NOW),
]
RELATIONS = [
Relation(id='rel_1', type='contains', layer='physical', source_id='obj_1', target_id='obj_2', source='manual', confidence=1.0, created_at=NOW, updated_at=NOW),
Relation(id='rel_2', type='contains', layer='physical', source_id='obj_2', target_id='obj_3', source='manual', confidence=1.0, created_at=NOW, updated_at=NOW),
Relation(id='rel_3', type='contains', layer='physical', source_id='obj_3', target_id='obj_4', source='manual', confidence=1.0, created_at=NOW, updated_at=NOW),
Relation(id='rel_4', type='contains', layer='physical', source_id='obj_2', target_id='obj_5', source='manual', confidence=1.0, created_at=NOW, updated_at=NOW),
Relation(id='rel_5', type='routes_via', layer='network', source_id='obj_3', target_id='obj_4', source='manual', confidence=1.0, created_at=NOW, updated_at=NOW),
Relation(id='rel_6', type='connects_to', layer='network', source_id='obj_4', target_id='obj_5', source='manual', confidence=0.9, created_at=NOW, updated_at=NOW),
] ]
JOBS = [ JOBS = [