netx/netx_api/topology_views_tree.py
oliver 75d03f5f9b Raise physical canvas capacity and keep region==canvas rules consistent.
Pass view kind into membership so metro maps are not capped at core=80, add region-scoped neighbor projection, and align UI/tests with one physical canvas per region.

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

976 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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,
view_filter_dict,
)
# Manual top-level「根」auto-spawns this unique L2 canvas (mirrors UME World / World).
MANUAL_ROOT_MAP_NAME = "根图"
MANUAL_ROOT_MAP_NAME_EN = "Root map"
MANUAL_ROOT_MAP_NAMES = frozenset({MANUAL_ROOT_MAP_NAME, MANUAL_ROOT_MAP_NAME_EN})
def manual_root_map_name(locale: str | None = None) -> str:
loc = str(locale or "").strip().lower()
if loc.startswith("en"):
return MANUAL_ROOT_MAP_NAME_EN
return MANUAL_ROOT_MAP_NAME
def is_manual_root_map_name(name: str | None) -> bool:
return str(name or "").strip() in MANUAL_ROOT_MAP_NAMES
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=view_filter_dict(v.filter),
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 _ensure_manual_root_map(
db: Session,
top: TopoFolder,
*,
now: Any | None = None,
locale: str | None = None,
) -> tuple[TopoFolder, bool]:
"""Ensure a top-level manual「根」has unique L2「根图」; migrate legacy L1 canvas onto it.
Pre-architecture shape: region === canvas (physical view hangs on the L1 folder).
Current shape: L1 nav-only + system L2「根图」holds the canvas (and any prior L2 kids).
"""
stamp = now or _utcnow()
changed = False
map_name = manual_root_map_name(locale)
kids = (
db.query(TopoFolder)
.filter(TopoFolder.parent_id == top.id, TopoFolder.kind == "region")
.order_by(TopoFolder.sort_order.asc(), TopoFolder.created_at.asc())
.all()
)
root_map = next((k for k in kids if is_manual_root_map_name(k.name)), None)
if root_map is None:
# Prefer an existing system child (renamed root map) before creating another.
root_map = next((k for k in kids if bool(k.is_system)), None)
if root_map is None:
root_map = TopoFolder(
id=uuid4().hex,
parent_id=top.id,
kind="region",
name=map_name,
sort_order=0,
is_system=True,
created_at=stamp,
updated_at=stamp,
)
db.add(root_map)
db.flush()
changed = True
for kid in kids:
kid.parent_id = root_map.id
kid.updated_at = stamp
changed = True
elif not bool(root_map.is_system):
root_map.is_system = True
root_map.updated_at = stamp
changed = True
l1_views = db.query(TopoView).filter(TopoView.folder_id == top.id).all()
for v in l1_views:
v.folder_id = root_map.id
v.updated_at = stamp
changed = True
if l1_views:
db.flush()
has_phys = (
db.query(TopoView.id)
.filter(TopoView.folder_id == root_map.id, TopoView.kind == VIEW_KIND_PHYSICAL)
.first()
is not None
)
if not has_phys:
db.add(
TopoView(
id=uuid4().hex,
folder_id=root_map.id,
kind=VIEW_KIND_PHYSICAL,
role="core",
name=str(root_map.name or map_name)[:256],
remark="",
sort_order=0,
filter={},
viewport={},
created_at=stamp,
updated_at=stamp,
)
)
changed = True
return root_map, changed
def _heal_manual_root_canvases(db: Session, root: TopoFolder) -> bool:
"""Adopt orphan regions and upgrade legacy single-map roots to 根/根图."""
from .ume_topology_world import is_ume_world_container
now = _utcnow()
changed = False
# Floating regions (no parent) from older installs — hang under system root.
for orphan in (
db.query(TopoFolder)
.filter(
TopoFolder.kind == "region",
or_(TopoFolder.parent_id.is_(None), TopoFolder.parent_id == ""),
)
.all()
):
orphan.parent_id = root.id
orphan.updated_at = now
changed = True
tops = (
db.query(TopoFolder)
.filter(TopoFolder.parent_id == root.id, TopoFolder.kind == "region")
.order_by(TopoFolder.sort_order.asc(), TopoFolder.created_at.asc())
.all()
)
for top in tops:
if is_ume_world_container(top):
continue
kids = (
db.query(TopoFolder)
.filter(TopoFolder.parent_id == top.id, TopoFolder.kind == "region")
.all()
)
has_root_map = any(is_manual_root_map_name(k.name) or bool(k.is_system) for k in kids)
l1_view_cnt = (
db.query(func.count(TopoView.id)).filter(TopoView.folder_id == top.id).scalar() or 0
)
# Already correct: unique 根图, no stray L1 views.
if has_root_map and int(l1_view_cnt) == 0:
# Still mark 根图 system if needed.
rm = next(
(k for k in kids if is_manual_root_map_name(k.name) or bool(k.is_system)),
None,
)
if rm is not None and not bool(rm.is_system):
rm.is_system = True
rm.updated_at = now
changed = True
continue
_, did = _ensure_manual_root_map(db, top, now=now)
changed = changed or did
return changed
def bootstrap_topology_tree(db: Session) -> dict[str, str]:
"""Ensure hidden system root; heal legacy Unassigned / single-map roots when needed."""
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()
db.commit()
return {"root_id": root.id}
dirty = False
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)
dirty = True
elif bool(folder.is_system):
folder.is_system = False
folder.updated_at = now
dirty = True
# Cheap normalize only for rows that still look pre-migration.
stale_views = (
db.query(TopoView)
.filter(
or_(
TopoView.parent_view_id.isnot(None),
TopoView.role.is_(None),
TopoView.role == "",
)
)
.all()
)
for v in stale_views:
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 = view_filter_dict(v.filter)
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
dirty = True
if _heal_manual_root_canvases(db, root):
dirty = True
if dirty:
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"
# Manual 根 (top-level nav): unique L2「根图」only — further creates remount under it.
if (
parent_kind == "region"
and str(parent.parent_id or "") == str(root.id)
and not is_ume_world_container(parent)
):
root_map, _ = _ensure_manual_root_map(db, parent, locale=body.locale)
parent = root_map
parent_kind = "region"
now = _utcnow()
map_name = manual_root_map_name(body.locale)
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()
if parent_kind == "root":
# 「根」nav container + auto unique「根图」canvas (UME World / World pattern).
root_map = TopoFolder(
id=uuid4().hex,
parent_id=row.id,
kind="region",
name=map_name,
sort_order=0,
is_system=True,
created_at=now,
updated_at=now,
)
db.add(root_map)
db.flush()
db.add(
TopoView(
id=uuid4().hex,
folder_id=root_map.id,
kind=VIEW_KIND_PHYSICAL,
role="core",
name=map_name,
remark="",
sort_order=0,
filter={},
viewport={},
created_at=now,
updated_at=now,
)
)
elif parent_kind == "region":
# Nested under 根图 / deeper canvas: region === canvas + icon on parent.
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)
db.flush()
parent_has_phys = (
db.query(TopoView.id)
.filter(TopoView.folder_id == parent.id, TopoView.kind == VIEW_KIND_PHYSICAL)
.first()
is not None
)
if parent_has_phys:
from .topology_region_canvas import place_child_region_on_parent_canvas
place_child_region_on_parent_canvas(db, parent, row)
else:
ensure_region_physical_view(db, parent.id, commit=False)
from .topology_region_canvas import place_child_region_on_parent_canvas
place_child_region_on_parent_canvas(db, parent, row)
db.flush()
try:
reconcile_world_flat_view(db)
except Exception:
# Creating a manual root must not fail because UME world reconcile hiccups.
import logging
logging.getLogger("netx.topology").exception(
"reconcile_world_flat_view failed after create_folder id=%s", row.id
)
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")
ext = str(getattr(row, "external_ref", None) or "").strip()
# Manual「根图」is auto-created but user-renamable (still not directly deletable).
is_manual_root_map = (
bool(row.is_system)
and not ext
and str(row.kind or "") == "region"
)
if str(row.kind or "") == "root":
row.name = name[:256]
elif is_manual_root_map:
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()
elif bool(row.is_system):
# UME World / World drill / synced SBN folders: not free-rename.
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 folder and cascade nested regions + maps.
User-deletable:
- Manual「根」(nav region under the hidden tree root) — cascades its「根图」
- Manual / UME-synced sub-regions under a root map or World drill
Not directly deletable (auto-managed):
- Hidden tree ``kind=root``
- Manual system「根图」(deleted only with parent「根」)
- UME World / World drill
- World map view (auto create/suppress via reconcile; see ``delete_view``)
"""
row = _get_folder_or_404(db, folder_id)
if str(row.kind or "") == "root":
raise HTTPException(status_code=400, detail="cannot_delete_system_folder")
from .ume_topology_world import WORLD_DRILL_REF, WORLD_FOLDER_REF, is_ume_world_container
ext = str(getattr(row, "external_ref", None) or "").strip()
if is_ume_world_container(row) or ext in (WORLD_FOLDER_REF, WORLD_DRILL_REF):
raise HTTPException(status_code=400, detail="cannot_delete_system_folder")
# Manual「根图」: only removable when cascading from parent「根」.
if bool(row.is_system) and not ext:
raise HTTPException(status_code=400, detail="cannot_delete_system_folder")
_ = force
from .topology_region_canvas import remove_region_canvas_placements
def _purge_folder(folder: TopoFolder) -> None:
kids = (
db.query(TopoFolder)
.filter(TopoFolder.parent_id == folder.id)
.all()
)
for kid in kids:
_purge_folder(kid)
remove_region_canvas_placements(db, folder.id)
views = db.query(TopoView).filter(TopoView.folder_id == folder.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(folder)
_purge_folder(row)
from .ume_topology_world import reconcile_world_flat_view
try:
reconcile_world_flat_view(db)
except Exception:
import logging
logging.getLogger("netx.topology").exception(
"reconcile_world_flat_view failed after delete_folder id=%s", folder_id
)
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()
# --- NE badge: source-agnostic distinct fabric ids (membership ∪ region_folder_id) ---
# Cheap aggregates only — never hydrate attrs JSON on tree load.
membership_by_view: dict[str, set[str]] = {}
for vid, fid in (
db.query(TopoViewNode.view_id, TopoViewNode.fabric_node_id)
.filter(~TopoViewNode.fabric_node_id.like("region:%"))
.all()
):
membership_by_view.setdefault(str(vid), set()).add(str(fid))
fabric_by_region: dict[str, set[str]] = {}
for fid, nid in (
db.query(TopoFabricNode.region_folder_id, TopoFabricNode.id)
.filter(
TopoFabricNode.region_folder_id.isnot(None),
TopoFabricNode.region_folder_id != "",
or_(
and_(TopoFabricNode.ume_ne_id.isnot(None), TopoFabricNode.ume_ne_id != ""),
and_(
TopoFabricNode.managed_ne_id.isnot(None),
TopoFabricNode.managed_ne_id != "",
),
),
)
.all()
):
key = str(fid or "").strip()
if key:
fabric_by_region.setdefault(key, set()).add(str(nid))
fabric_inventory_total = int(
db.query(func.count(TopoFabricNode.id))
.filter(
or_(
and_(TopoFabricNode.ume_ne_id.isnot(None), TopoFabricNode.ume_ne_id != ""),
and_(
TopoFabricNode.managed_ne_id.isnot(None),
TopoFabricNode.managed_ne_id != "",
),
)
)
.scalar()
or 0
)
by_parent: dict[str, list[TopoFolder]] = {}
folder_by_id: dict[str, TopoFolder] = {}
root: TopoFolder | None = None
for f in folders:
folder_by_id[str(f.id)] = f
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 _is_virtual_full_network_folder(folder: TopoFolder) -> bool:
"""UME World / World — alternate presentation of full fabric inventory."""
from .ume_topology_world import is_ume_world_container, is_world_drill_folder
return is_ume_world_container(folder) or is_world_drill_folder(folder)
def _is_virtual_full_network_view(v: TopoView) -> bool:
filt = view_filter_dict(v.filter)
if filt.get("world_flat") or (
bool(filt.get("world")) and not filt.get("ume_level")
):
return True
if filt.get("ume_level") and str(filt.get("parent") or "") == "md":
return True
folder = folder_by_id.get(str(v.folder_id or ""))
return bool(folder and _is_virtual_full_network_folder(folder))
def _own_ne_ids(folder: TopoFolder) -> set[str]:
"""NEs on this folder's canvases ∪ fabric default-home on this folder."""
ids: set[str] = set(fabric_by_region.get(str(folder.id), set()))
for v in views_by_folder.get(str(folder.id), []):
ids |= membership_by_view.get(str(v.id), set())
return ids
# Bottom-up distinct union: folder_id -> ne id set (cached while building).
subtree_ne_ids: dict[str, set[str]] = {}
def _subtree_ne_ids(folder: TopoFolder) -> set[str]:
fid = str(folder.id)
if fid in subtree_ne_ids:
return subtree_ne_ids[fid]
if _is_virtual_full_network_folder(folder):
# Don't expand full inventory into a giant set; count handled separately.
subtree_ne_ids[fid] = set()
return subtree_ne_ids[fid]
ids = set(_own_ne_ids(folder))
for child in by_parent.get(fid, []):
ids |= _subtree_ne_ids(child)
subtree_ne_ids[fid] = ids
return ids
def _folder_ne_count(folder: TopoFolder) -> int:
if _is_virtual_full_network_folder(folder):
return fabric_inventory_total
return len(_subtree_ne_ids(folder))
def _view_node_count(v: TopoView) -> int:
if _is_virtual_full_network_view(v):
return fabric_inventory_total
return len(membership_by_view.get(str(v.id), set()))
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):
try:
show_flat = world_map_should_exist(db)
except Exception:
show_flat = False
# 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 ""),
ne_count=_folder_ne_count(folder),
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")
existing_physical = (
db.query(TopoView)
.filter(TopoView.folder_id == folder.id, TopoView.kind == VIEW_KIND_PHYSICAL)
.first()
)
if kind == VIEW_KIND_PHYSICAL and existing_physical is not None:
raise HTTPException(status_code=400, detail="region_already_has_physical_view")
# One canvas per region/根图: no sibling custom maps — nest sub-regions instead.
if kind == VIEW_KIND_CUSTOM and existing_physical is not None:
raise HTTPException(
status_code=400,
detail="use_create_subregion_folder",
)
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:
from .ume_topology_world import is_world_flat_view
if is_world_flat_view(row):
raise HTTPException(status_code=400, detail="cannot_rename_world_map_view")
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")
if new_kind == VIEW_KIND_CUSTOM and normalize_view_kind(row.kind) != VIEW_KIND_CUSTOM:
# Do not demote/add custom beside an existing physical canvas.
phys = (
db.query(TopoView)
.filter(
TopoView.folder_id == row.folder_id,
TopoView.kind == VIEW_KIND_PHYSICAL,
)
.first()
)
if phys is not None:
raise HTTPException(status_code=400, detail="use_create_subregion_folder")
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)
from .ume_topology_world import is_world_flat_view
if is_world_flat_view(row):
raise HTTPException(status_code=400, detail="cannot_delete_world_map_view")
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}