netx/netx_api/topology_views_tree.py
oliver 1ef43fdcd4 Unify UME World as hex nav with conditional flat world map.
Seed a unique L2 World canvas under the UME World container, show the flat map only when nested regions have NEs, pack blocks without overlap, allow drag overrides on the world map while forbidding direct NE adds, and hide the world-map truncation banner.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 00:51:08 +08:00

617 lines
21 KiB
Python

"""Topology folder tree and view CRUD."""
from __future__ import annotations
import re
from typing import Any
from uuid import uuid4
from fastapi import HTTPException
from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from .models import (
ManagedNE,
TopoFabricEdge,
TopoFabricNode,
TopoFolder,
TopoView,
TopoViewEdgeStyle,
TopoViewNode,
UmeInventoryNE,
)
from .topology_common import (
PHYSICAL_VIEW_NAME,
ROOT_FOLDER_NAME,
VIEW_GRAPH_EDGE_HARD_CAP,
VIEW_GRAPH_NODE_HARD_CAP,
_LEGACY_UNASSIGNED_NAME,
_normalize_edge_status,
_utcnow,
)
from .topology_fabric import (
_edge_out,
_fabric_match_score,
_is_inventory_node,
_node_out,
_nodes_by_ids,
ensure_fabric_node_for_managed,
ensure_fabric_node_for_ume,
merge_duplicate_fabric_nodes,
)
from .cli_resolve import get_default_profile, infer_device_type_vendor
from .topology_membership import (
VIEW_KIND_CUSTOM,
VIEW_KIND_PHYSICAL,
has_hard_scope,
merge_filter_with_membership,
normalize_view_kind,
normalize_view_role,
parse_membership,
)
from .topology_schemas import (
TopologyFolderCreate,
TopologyFolderOut,
TopologyFolderUpdate,
TopologyTreeFolderOut,
TopologyTreeOut,
TopologyTreeViewOut,
TopologyViewCreate,
TopologyViewGraphOut,
TopologyViewOut,
TopologyViewUpdate,
ViewEdgeOut,
ViewEdgeStylePatch,
ViewNodeIn,
ViewNodeOut,
ViewNodesAdd,
ViewPopulateOut,
ViewPopulateRequest,
ViewPositionsPatch,
)
# ---------------------------------------------------------------------------
# Folders (tree) + Views (leaf canvases)
# ---------------------------------------------------------------------------
def _folder_out(f: TopoFolder) -> TopologyFolderOut:
return TopologyFolderOut(
id=f.id,
parent_id=str(f.parent_id or ""),
kind=str(f.kind or "region"),
name=f.name or "",
sort_order=int(f.sort_order or 0),
is_system=bool(f.is_system),
external_ref=str(getattr(f, "external_ref", None) or ""),
created_at=f.created_at,
updated_at=f.updated_at,
)
def _view_out(v: TopoView, *, node_count: int = 0) -> TopologyViewOut:
return TopologyViewOut(
id=v.id,
name=v.name,
remark=v.remark or "",
folder_id=str(v.folder_id or ""),
kind=normalize_view_kind(getattr(v, "kind", None)),
role=normalize_view_role(v.role),
sort_order=int(v.sort_order or 0),
filter=dict(v.filter or {}),
viewport=dict(v.viewport or {}),
node_count=node_count,
created_at=v.created_at,
updated_at=v.updated_at,
)
def _get_view_or_404(db: Session, view_id: str) -> TopoView:
vid = str(view_id or "").strip()
row = db.get(TopoView, vid) if vid else None
if row is None:
raise HTTPException(status_code=404, detail="topology_view_not_found")
return row
def _get_folder_or_404(db: Session, folder_id: str) -> TopoFolder:
fid = str(folder_id or "").strip()
row = db.get(TopoFolder, fid) if fid else None
if row is None:
raise HTTPException(status_code=404, detail="topology_folder_not_found")
return row
def ensure_region_physical_view(db: Session, folder_id: str, *, commit: bool = True) -> TopoView:
"""Ensure a site has exactly one default physical topology map."""
fid = str(folder_id or "").strip()
folder = _get_folder_or_404(db, fid)
if str(folder.kind or "") == "root":
raise HTTPException(status_code=400, detail="view_must_hang_under_region")
existing = (
db.query(TopoView)
.filter(TopoView.folder_id == folder.id, TopoView.kind == VIEW_KIND_PHYSICAL)
.order_by(TopoView.sort_order.asc(), TopoView.created_at.asc())
.first()
)
if existing is not None:
return existing
now = _utcnow()
role = "core"
row = TopoView(
id=uuid4().hex,
folder_id=folder.id,
parent_view_id=None,
kind=VIEW_KIND_PHYSICAL,
role=role,
name=PHYSICAL_VIEW_NAME,
remark="",
sort_order=0,
filter=merge_filter_with_membership({}, role=role, kind=VIEW_KIND_PHYSICAL),
viewport={},
created_at=now,
updated_at=now,
)
db.add(row)
if commit:
db.commit()
db.refresh(row)
else:
db.flush()
return row
def bootstrap_topology_tree(db: Session) -> dict[str, str]:
"""Ensure hidden system root; flatten legacy nesting."""
now = _utcnow()
root = (
db.query(TopoFolder)
.filter(TopoFolder.kind == "root")
.order_by(TopoFolder.created_at.asc())
.first()
)
if root is None:
root = TopoFolder(
id=uuid4().hex,
parent_id=None,
kind="root",
name=ROOT_FOLDER_NAME,
sort_order=0,
is_system=True,
created_at=now,
updated_at=now,
)
db.add(root)
db.flush()
# Drop legacy auto-created Unassigned region when empty; otherwise demote to normal region.
legacy = (
db.query(TopoFolder)
.filter(
TopoFolder.kind == "region",
TopoFolder.name == _LEGACY_UNASSIGNED_NAME,
)
.all()
)
for folder in legacy:
view_cnt = db.query(TopoView).filter(TopoView.folder_id == folder.id).count()
if view_cnt == 0:
db.delete(folder)
elif bool(folder.is_system):
folder.is_system = False
folder.updated_at = now
# Flatten nesting + normalize kind for all views.
for v in db.query(TopoView).all():
changed = False
if v.parent_view_id:
v.parent_view_id = None
changed = True
kind = normalize_view_kind(getattr(v, "kind", None))
if str(getattr(v, "kind", "") or "") != kind:
v.kind = kind
changed = True
if not str(v.role or "").strip():
v.role = "core"
changed = True
filt = dict(v.filter or {})
if "membership" not in filt:
v.filter = merge_filter_with_membership(
filt, role=normalize_view_role(v.role), kind=kind
)
changed = True
if changed:
v.updated_at = now
db.commit()
return {"root_id": root.id}
def create_folder(db: Session, body: TopologyFolderCreate) -> TopologyFolderOut:
bootstrap_topology_tree(db)
name = str(body.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name_required")
kind = str(body.kind or "region").strip().lower()
if kind != "region":
raise HTTPException(status_code=400, detail="folder_kind_must_be_region")
root = db.query(TopoFolder).filter(TopoFolder.kind == "root").first()
if root is None:
raise HTTPException(status_code=500, detail="topology_root_missing")
parent_id = str(body.parent_id or "").strip() or root.id
parent = _get_folder_or_404(db, parent_id)
parent_kind = str(parent.kind or "")
if parent_kind not in ("root", "region"):
raise HTTPException(status_code=400, detail="invalid_parent_folder")
# UME World container: only one L2 (system drill). New regions remount under drill.
from .ume_topology_world import (
ensure_ume_world_and_sbn_folders,
get_world_drill_folder,
is_ume_world_container,
reconcile_world_flat_view,
)
if is_ume_world_container(parent):
drill = get_world_drill_folder(db)
if drill is None:
ensure_ume_world_and_sbn_folders(db)
drill = get_world_drill_folder(db)
if drill is None:
raise HTTPException(status_code=500, detail="world_drill_missing")
parent = drill
parent_kind = "region"
now = _utcnow()
row = TopoFolder(
id=uuid4().hex,
parent_id=parent.id,
kind="region",
name=name[:256],
sort_order=int(body.sort_order or 0),
is_system=False,
created_at=now,
updated_at=now,
)
db.add(row)
db.flush()
# Nested / any new region gets a blank physical canvas so the tree can open it.
phys = TopoView(
id=uuid4().hex,
folder_id=row.id,
kind=VIEW_KIND_PHYSICAL,
role="core",
name=name[:200] or "Topology",
remark="",
sort_order=0,
filter={},
viewport={},
created_at=now,
updated_at=now,
)
db.add(phys)
# Nested region: place a building icon on the parent's canvas (region === canvas).
if parent_kind == "region":
from .topology_region_canvas import place_child_region_on_parent_canvas
place_child_region_on_parent_canvas(db, parent, row)
db.flush()
reconcile_world_flat_view(db)
db.commit()
db.refresh(row)
return _folder_out(row)
def update_folder(db: Session, folder_id: str, body: TopologyFolderUpdate) -> TopologyFolderOut:
row = _get_folder_or_404(db, folder_id)
if str(row.kind or "") == "root":
if body.parent_id is not None:
raise HTTPException(status_code=400, detail="cannot_reparent_root")
if body.name is not None:
name = str(body.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name_required")
if bool(row.is_system) and str(row.kind or "") == "root":
row.name = name[:256]
elif bool(row.is_system):
raise HTTPException(status_code=400, detail="cannot_rename_system_folder")
else:
row.name = name[:256]
from .topology_region_canvas import region_canvas_node_id
nid = region_canvas_node_id(row.id)
for vn in (
db.query(TopoViewNode).filter(TopoViewNode.fabric_node_id == nid).all()
):
vn.label = name[:256]
vn.updated_at = _utcnow()
if body.sort_order is not None:
row.sort_order = int(body.sort_order)
if body.parent_id is not None and str(row.kind or "") == "region":
parent = _get_folder_or_404(db, body.parent_id)
if str(parent.kind or "") not in ("root", "region"):
raise HTTPException(status_code=400, detail="invalid_parent_folder")
row.parent_id = parent.id
row.updated_at = _utcnow()
db.commit()
db.refresh(row)
return _folder_out(row)
def delete_folder(db: Session, folder_id: str, *, force: bool = False) -> dict[str, Any]:
"""Delete a region and cascade-delete its maps.
``force`` is accepted for API compatibility; cascade always runs.
"""
row = _get_folder_or_404(db, folder_id)
if str(row.kind or "") == "root" or bool(row.is_system):
raise HTTPException(status_code=400, detail="cannot_delete_system_folder")
_ = force
from .topology_region_canvas import remove_region_canvas_placements
remove_region_canvas_placements(db, row.id)
views = db.query(TopoView).filter(TopoView.folder_id == row.id).all()
for v in views:
db.query(TopoViewEdgeStyle).filter(TopoViewEdgeStyle.view_id == v.id).delete(
synchronize_session=False
)
db.query(TopoViewNode).filter(TopoViewNode.view_id == v.id).delete(
synchronize_session=False
)
db.delete(v)
db.flush()
db.delete(row)
from .ume_topology_world import reconcile_world_flat_view
reconcile_world_flat_view(db)
db.commit()
return {"ok": True, "folder_id": folder_id, "deleted": True}
def get_topology_tree(db: Session) -> TopologyTreeOut:
bootstrap_topology_tree(db)
folders = db.query(TopoFolder).order_by(TopoFolder.sort_order.asc(), TopoFolder.name.asc()).all()
views = db.query(TopoView).order_by(TopoView.sort_order.asc(), TopoView.name.asc()).all()
nc_map: dict[str, int] = {}
for vid, cnt in (
db.query(TopoViewNode.view_id, func.count(TopoViewNode.id))
.group_by(TopoViewNode.view_id)
.all()
):
nc_map[str(vid)] = int(cnt or 0)
region_icon_map: dict[str, int] = {}
for vid, cnt in (
db.query(TopoViewNode.view_id, func.count(TopoViewNode.id))
.filter(TopoViewNode.fabric_node_id.like("region:%"))
.group_by(TopoViewNode.view_id)
.all()
):
region_icon_map[str(vid)] = int(cnt or 0)
# UME canvases are virtual (no TopoViewNode membership) — count fabric MEs instead.
ume_ne_by_sbn: dict[str, int] = {}
ume_ne_by_folder: dict[str, int] = {}
ume_ne_total = 0
for fn in (
db.query(TopoFabricNode)
.filter(TopoFabricNode.ume_ne_id.isnot(None), TopoFabricNode.ume_ne_id != "")
.all()
):
ume_ne_total += 1
fid = str(fn.region_folder_id or "").strip()
if fid:
ume_ne_by_folder[fid] = ume_ne_by_folder.get(fid, 0) + 1
sid = str((fn.attrs or {}).get("ume_sbn_id") or "").strip()
if sid:
ume_ne_by_sbn[sid] = ume_ne_by_sbn.get(sid, 0) + 1
def _view_node_count(v: TopoView) -> int:
raw = int(nc_map.get(v.id, 0) or 0)
ne_nc = max(0, raw - int(region_icon_map.get(v.id, 0) or 0))
if ne_nc:
return ne_nc
filt = dict(v.filter or {})
if filt.get("world_flat") or (
bool(filt.get("world")) and not filt.get("ume_level")
):
return ume_ne_total
if filt.get("ume_level"):
sid = str(filt.get("sbn_id") or "").strip()
if sid:
return int(ume_ne_by_sbn.get(sid, 0))
# World drill root: all UME MEs.
if str(filt.get("parent") or "") == "md":
return ume_ne_total
if v.folder_id:
return int(ume_ne_by_folder.get(str(v.folder_id), 0))
if v.folder_id:
return int(ume_ne_by_folder.get(str(v.folder_id), 0))
return 0
by_parent: dict[str, list[TopoFolder]] = {}
root: TopoFolder | None = None
for f in folders:
if str(f.kind or "") == "root":
root = f
continue
pid = str(f.parent_id or "")
by_parent.setdefault(pid, []).append(f)
views_by_folder: dict[str, list[TopoView]] = {}
for v in views:
views_by_folder.setdefault(str(v.folder_id or ""), []).append(v)
def _flat_views(folder: TopoFolder, folder_views: list[TopoView]) -> list[TopologyTreeViewOut]:
from .ume_topology_world import (
is_ume_world_container,
is_world_flat_view,
is_world_flat_visible,
world_map_should_exist,
)
# Soft-hide world map on UME World when rule 2A is not met.
show_flat = True
if is_ume_world_container(folder):
show_flat = world_map_should_exist(db)
# physical first, then custom; stable by sort_order/name.
ordered = sorted(
folder_views,
key=lambda x: (
0 if normalize_view_kind(getattr(x, "kind", None)) == VIEW_KIND_PHYSICAL else 1,
int(x.sort_order or 0),
x.name or "",
x.id,
),
)
out: list[TopologyTreeViewOut] = []
for v in ordered:
if is_world_flat_view(v):
if not show_flat or not is_world_flat_visible(v):
continue
out.append(
TopologyTreeViewOut(
id=v.id,
name=v.name or "",
kind=normalize_view_kind(getattr(v, "kind", None)),
role=normalize_view_role(v.role),
sort_order=int(v.sort_order or 0),
node_count=_view_node_count(v),
updated_at=v.updated_at,
)
)
return out
def _build(folder: TopoFolder) -> TopologyTreeFolderOut:
kids = [_build(c) for c in by_parent.get(folder.id, [])]
return TopologyTreeFolderOut(
id=folder.id,
parent_id=str(folder.parent_id or ""),
kind=str(folder.kind or "region"),
name=folder.name or "",
sort_order=int(folder.sort_order or 0),
is_system=bool(folder.is_system),
external_ref=str(getattr(folder, "external_ref", None) or ""),
views=_flat_views(folder, views_by_folder.get(folder.id, [])),
children=kids,
)
if root is None:
return TopologyTreeOut(root=None)
return TopologyTreeOut(root=_build(root))
def list_views(db: Session) -> dict[str, Any]:
bootstrap_topology_tree(db)
rows = db.query(TopoView).order_by(TopoView.updated_at.desc()).all()
items = []
for v in rows:
nc = db.query(TopoViewNode).filter(TopoViewNode.view_id == v.id).count()
items.append(_view_out(v, node_count=nc).model_dump())
return {"total": len(items), "items": items}
def create_view(db: Session, body: TopologyViewCreate) -> TopologyViewOut:
name = str(body.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name_required")
role = normalize_view_role(body.role)
kind = normalize_view_kind(body.kind)
folder_id = str(body.folder_id or "").strip()
if not folder_id:
raise HTTPException(status_code=400, detail="folder_id_required")
folder = _get_folder_or_404(db, folder_id)
if str(folder.kind or "") == "root":
raise HTTPException(status_code=400, detail="view_must_hang_under_region")
if kind == VIEW_KIND_PHYSICAL:
existing = (
db.query(TopoView)
.filter(TopoView.folder_id == folder.id, TopoView.kind == VIEW_KIND_PHYSICAL)
.first()
)
if existing is not None:
raise HTTPException(status_code=400, detail="region_already_has_physical_view")
filt = merge_filter_with_membership(dict(body.filter or {}), role=role, kind=kind)
now = _utcnow()
row = TopoView(
id=uuid4().hex,
folder_id=folder_id,
parent_view_id=None,
kind=kind,
role=role,
name=name[:256],
remark=str(body.remark or "")[:1024],
sort_order=int(body.sort_order or 0),
filter=filt,
viewport={},
created_at=now,
updated_at=now,
)
db.add(row)
db.commit()
db.refresh(row)
return _view_out(row)
def update_view(db: Session, view_id: str, body: TopologyViewUpdate) -> TopologyViewOut:
row = _get_view_or_404(db, view_id)
if body.name is not None:
name = str(body.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name_required")
row.name = name[:256]
if body.remark is not None:
row.remark = str(body.remark or "")[:1024]
if body.role is not None:
row.role = normalize_view_role(body.role)
if body.sort_order is not None:
row.sort_order = int(body.sort_order)
if body.folder_id is not None:
fid = str(body.folder_id or "").strip()
folder = _get_folder_or_404(db, fid)
if str(folder.kind or "") == "root":
raise HTTPException(status_code=400, detail="view_must_hang_under_region")
row.folder_id = folder.id
if body.kind is not None:
new_kind = normalize_view_kind(body.kind)
if new_kind == VIEW_KIND_PHYSICAL and normalize_view_kind(row.kind) != VIEW_KIND_PHYSICAL:
clash = (
db.query(TopoView)
.filter(
TopoView.folder_id == row.folder_id,
TopoView.kind == VIEW_KIND_PHYSICAL,
TopoView.id != row.id,
)
.first()
)
if clash is not None:
raise HTTPException(status_code=400, detail="region_already_has_physical_view")
row.kind = new_kind
row.parent_view_id = None
if body.filter is not None:
row.filter = merge_filter_with_membership(
dict(body.filter or {}),
role=normalize_view_role(row.role),
kind=normalize_view_kind(row.kind),
)
if body.viewport is not None:
row.viewport = dict(body.viewport or {})
row.updated_at = _utcnow()
db.commit()
db.refresh(row)
nc = db.query(TopoViewNode).filter(TopoViewNode.view_id == row.id).count()
return _view_out(row, node_count=nc)
def delete_view(db: Session, view_id: str, *, force: bool = False) -> dict[str, Any]:
row = _get_view_or_404(db, view_id)
is_physical = normalize_view_kind(row.kind) == VIEW_KIND_PHYSICAL
if is_physical and not force:
raise HTTPException(status_code=400, detail="cannot_delete_physical_view")
db.query(TopoViewEdgeStyle).filter(TopoViewEdgeStyle.view_id == row.id).delete(
synchronize_session=False
)
db.query(TopoViewNode).filter(TopoViewNode.view_id == row.id).delete(synchronize_session=False)
db.delete(row)
db.commit()
return {"ok": True, "view_id": view_id, "deleted": True}