From 77832b271d582d898c1c62b4b9d5ec4ba65fef63 Mon Sep 17 00:00:00 2001 From: oliver Date: Fri, 7 Aug 2026 14:48:22 +0800 Subject: [PATCH] Align folder permissions: roots deletable, root maps rename-only, world map locked. Co-authored-by: Cursor --- netx_api/topology_views_tree.py | 42 ++++++++++++++++++++++++++------- tests/test_topology.py | 25 ++++++++++++++++++++ tests/test_ume_hierarchy.py | 6 +++++ web/src/pages/TopologyPage.tsx | 25 ++++++++++++++++---- 4 files changed, 85 insertions(+), 13 deletions(-) diff --git a/netx_api/topology_views_tree.py b/netx_api/topology_views_tree.py index e0a9481..789b484 100644 --- a/netx_api/topology_views_tree.py +++ b/netx_api/topology_views_tree.py @@ -519,9 +519,27 @@ def update_folder(db: Session, folder_id: str, body: TopologyFolderUpdate) -> To 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": + 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] @@ -547,15 +565,17 @@ def update_folder(db: Session, folder_id: str, body: TopologyFolderUpdate) -> To def delete_folder(db: Session, folder_id: str, *, force: bool = False) -> dict[str, Any]: - """Delete a region and cascade-delete nested regions + maps. + """Delete a folder and cascade nested regions + maps. - Not user-deletable (structure / auto-managed): - - kind=root - - manual system「根图」(is_system, no UME external_ref) - - UME World container and World drill (ume:world / ume:world:drill) + User-deletable: + - Manual「根」(nav region under the hidden tree root) — cascades its「根图」 + - Manual / UME-synced sub-regions under a root map or World drill - User-deletable: manual sub-regions and UME-synced SBN folders under World. - World map view is never a folder — it is auto created/suppressed by reconcile. + 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": @@ -566,7 +586,7 @@ def delete_folder(db: Session, folder_id: str, *, force: bool = False) -> dict[s 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「根图」and other non-UME system folders stay protected except via parent cascade. + # 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 @@ -838,6 +858,10 @@ def create_view(db: Session, body: TopologyViewCreate) -> TopologyViewOut: 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") diff --git a/tests/test_topology.py b/tests/test_topology.py index 4df487c..2442f27 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -666,6 +666,31 @@ class FabricTopologyTests(unittest.TestCase): child_ids = {c.id for c in root_map.children} self.assertEqual(child_ids, {a.id, b.id}) + def test_root_map_rename_ok_but_not_direct_delete(self) -> None: + from fastapi import HTTPException + + from netx_api.topology_schemas import TopologyFolderUpdate + + top = svc.create_folder( + self.db, TopologyFolderCreate(name="North", kind="region") + ) + tree = svc.get_topology_tree(self.db) + assert tree.root is not None + north = next(c for c in tree.root.children if c.id == top.id) + root_map = north.children[0] + self.assertTrue(root_map.is_system) + renamed = svc.update_folder( + self.db, root_map.id, TopologyFolderUpdate(name="主画布") + ) + self.assertEqual(renamed.name, "主画布") + with self.assertRaises(HTTPException) as ctx: + svc.delete_folder(self.db, root_map.id) + self.assertEqual(ctx.exception.detail, "cannot_delete_system_folder") + # Deleting the manual「根」cascades the「根图」. + out = svc.delete_folder(self.db, top.id) + self.assertTrue(out.get("deleted")) + self.assertIsNone(self.db.get(TopoFolder, root_map.id)) + def test_classify_role_region_and_slices(self) -> None: from netx_api import topology_classify as clf from netx_api.topology_schemas import ( diff --git a/tests/test_ume_hierarchy.py b/tests/test_ume_hierarchy.py index bdff75a..704d662 100644 --- a/tests/test_ume_hierarchy.py +++ b/tests/test_ume_hierarchy.py @@ -450,6 +450,12 @@ class UmeHierarchyTests(unittest.TestCase): with self.assertRaises(HTTPException) as ctx2: delete_view(self.db, flat.id) self.assertEqual(ctx2.exception.detail, "cannot_delete_world_map_view") + from netx_api.topology_schemas import TopologyViewUpdate + from netx_api.topology_views_tree import update_view + + with self.assertRaises(HTTPException) as ctx3: + update_view(self.db, flat.id, TopologyViewUpdate(name="Nope")) + self.assertEqual(ctx3.exception.detail, "cannot_rename_world_map_view") def test_flat_slots_do_not_overlap(self): self._seed_tree() diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index f953c07..9864fb8 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -196,6 +196,12 @@ function isUmeStructuralFolder(folder: TopologyTreeFolderItem | null | undefined return ext === "ume:world" || ext === "ume:world:drill"; } +/** Auto「根图」under a manual 根 — rename OK, delete only with parent 根. */ +function isManualRootMapFolder(folder: TopologyTreeFolderItem | null | undefined): boolean { + if (!folder || isUmeStructuralFolder(folder)) return false; + return Boolean(folder.is_system) && !String(folder.external_ref || "").trim(); +} + /** UME-synced SBN under World — deletable like a manual sub-region. */ function isUmeSyncedSubRegion(folder: TopologyTreeFolderItem | null | undefined): boolean { if (!folder || isUmeStructuralFolder(folder)) return false; @@ -2414,10 +2420,18 @@ export function TopologyPage() { - {!isUmeStructuralFolder(folder) && - (!folder.is_system || isUmeSyncedSubRegion(folder)) ? ( + {(() => { + const canRenameFolder = + !isUmeStructuralFolder(folder) && + (!folder.is_system || isManualRootMapFolder(folder)); + const canDeleteFolder = + !isUmeStructuralFolder(folder) && + !isManualRootMapFolder(folder) && + (!folder.is_system || isUmeSyncedSubRegion(folder)); + if (!canRenameFolder && !canDeleteFolder) return null; + return (
- {!folder.is_system ? ( + {canRenameFolder ? ( ) : null} + {canDeleteFolder ? ( + ) : null}
- ) : null} + ); + })()} {open ? (