From 35a3980dc0b198780d3a88c32faa3117c917b6f2 Mon Sep 17 00:00:00 2001 From: oliver Date: Fri, 7 Aug 2026 09:31:23 +0800 Subject: [PATCH] =?UTF-8?q?Heal=20legacy=20single-map=20roots=20into=20?= =?UTF-8?q?=E6=A0=B9/=E6=A0=B9=E5=9B=BE=20on=20tree=20bootstrap.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Old installs hung the canvas on the top-level region; migrate those views under a system root map so tree load and create-root keep working. Co-authored-by: Cursor --- netx_api/topology_views_tree.py | 222 ++++++++++++++++++++++++-------- tests/test_topology.py | 58 +++++++++ 2 files changed, 227 insertions(+), 53 deletions(-) diff --git a/netx_api/topology_views_tree.py b/netx_api/topology_views_tree.py index 1f150b9..99e8903 100644 --- a/netx_api/topology_views_tree.py +++ b/netx_api/topology_views_tree.py @@ -164,8 +164,133 @@ def ensure_region_physical_view(db: Session, folder_id: str, *, commit: bool = T return row +def _ensure_manual_root_map( + db: Session, top: TopoFolder, *, now: Any | 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 + 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 str(k.name or "") == MANUAL_ROOT_MAP_NAME), None) + if root_map is None: + root_map = TopoFolder( + id=uuid4().hex, + parent_id=top.id, + kind="region", + name=MANUAL_ROOT_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=MANUAL_ROOT_MAP_NAME, + 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(str(k.name or "") == MANUAL_ROOT_MAP_NAME 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 str(k.name or "") == MANUAL_ROOT_MAP_NAME) + if 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; flatten legacy nesting (once when needed).""" + """Ensure hidden system root; heal legacy Unassigned / single-map roots when needed.""" now = _utcnow() root = ( db.query(TopoFolder) @@ -189,7 +314,7 @@ def bootstrap_topology_tree(db: Session) -> dict[str, str]: db.commit() return {"root_id": root.id} - # Hot path: root already exists. Only touch legacy Unassigned rows when present. + dirty = False legacy = ( db.query(TopoFolder) .filter( @@ -198,19 +323,29 @@ def bootstrap_topology_tree(db: Session) -> dict[str, str]: ) .all() ) - if not legacy: - return {"root_id": root.id} - 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 - # One-time-ish normalize for views hanging under cleaned legacy folders. - for v in db.query(TopoView).all(): + # 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 @@ -230,8 +365,13 @@ def bootstrap_topology_tree(db: Session) -> dict[str, str]: changed = True if changed: v.updated_at = now + dirty = True - db.commit() + if _heal_manual_root_canvases(db, root): + dirty = True + + if dirty: + db.commit() return {"root_id": root.id} @@ -271,49 +411,14 @@ def create_folder(db: Session, body: TopologyFolderCreate) -> TopologyFolderOut: 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): - existing_l2 = ( - db.query(TopoFolder) - .filter(TopoFolder.parent_id == parent.id, TopoFolder.kind == "region") - .order_by(TopoFolder.sort_order.asc(), TopoFolder.created_at.asc()) - .all() - ) - if existing_l2: - parent = existing_l2[0] - parent_kind = "region" - else: - # Legacy 根 without 根图 — heal then remount. - heal_now = _utcnow() - root_map = TopoFolder( - id=uuid4().hex, - parent_id=parent.id, - kind="region", - name=MANUAL_ROOT_MAP_NAME, - sort_order=0, - is_system=True, - created_at=heal_now, - updated_at=heal_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=MANUAL_ROOT_MAP_NAME, - remark="", - sort_order=0, - filter={}, - viewport={}, - created_at=heal_now, - updated_at=heal_now, - ) - ) - db.flush() - parent = root_map - parent_kind = "region" + 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) + parent = root_map + parent_kind = "region" now = _utcnow() row = TopoFolder( id=uuid4().hex, @@ -390,7 +495,15 @@ def create_folder(db: Session, body: TopologyFolderCreate) -> TopologyFolderOut: place_child_region_on_parent_canvas(db, parent, row) db.flush() - reconcile_world_flat_view(db) + 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) @@ -598,7 +711,10 @@ def get_topology_tree(db: Session) -> TopologyTreeOut: # 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) + try: + show_flat = world_map_should_exist(db) + except Exception: + show_flat = False # physical first, then custom; stable by sort_order/name. ordered = sorted( diff --git a/tests/test_topology.py b/tests/test_topology.py index bb6eb79..4df487c 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -391,6 +391,64 @@ class FabricTopologyTests(unittest.TestCase): self.assertEqual(ne.ip_address, "") self.assertEqual(ne.device_type, "generic") + def test_legacy_single_map_heals_into_root_map(self) -> None: + """Pre-architecture: physical view hung on top-level region (no「根图」).""" + from uuid import uuid4 + + from netx_api.models import TopoFolder, TopoView + from netx_api.topology_common import _utcnow + from netx_api.topology_membership import VIEW_KIND_PHYSICAL + + now = _utcnow() + boot = svc.bootstrap_topology_tree(self.db) + root = self.db.get(TopoFolder, boot["root_id"]) + self.assertIsNotNone(root) + assert root is not None + legacy = TopoFolder( + id=uuid4().hex, + parent_id=root.id, + kind="region", + name="旧单图站点", + sort_order=0, + is_system=False, + created_at=now, + updated_at=now, + ) + old_view = TopoView( + id=uuid4().hex, + folder_id=legacy.id, + parent_view_id=None, + kind=VIEW_KIND_PHYSICAL, + role="core", + name="拓扑图", + remark="", + sort_order=0, + filter={}, + viewport={}, + created_at=now, + updated_at=now, + ) + self.db.add(legacy) + self.db.add(old_view) + self.db.commit() + + tree = svc.get_topology_tree(self.db) + assert tree.root is not None + site = next(c for c in tree.root.children if c.id == legacy.id) + self.assertEqual(site.views, []) + self.assertEqual(len(site.children), 1) + root_map = site.children[0] + self.assertEqual(root_map.name, "根图") + self.assertTrue(any(v.id == old_view.id for v in root_map.views)) + # Creating another top-level root still works beside the healed legacy site. + created = svc.create_folder( + self.db, TopologyFolderCreate(name="新根", kind="region") + ) + tree2 = svc.get_topology_tree(self.db) + assert tree2.root is not None + self.assertTrue(any(c.id == created.id for c in tree2.root.children)) + self.assertTrue(any(c.id == legacy.id for c in tree2.root.children)) + def test_topology_tree_root_region_and_leaf(self) -> None: tree = svc.get_topology_tree(self.db) self.assertIsNotNone(tree.root)