diff --git a/netx_api/topology_views_tree.py b/netx_api/topology_views_tree.py index 8faa831..76a5b14 100644 --- a/netx_api/topology_views_tree.py +++ b/netx_api/topology_views_tree.py @@ -549,9 +549,11 @@ 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. - System L2「根图」may be removed only as part of cascading from its parent「根」. - UME World container may be deleted explicitly (cascades all synced SBN folders); - a later UME apply / world ensure can recreate it. + System L2「根图」(manual, no UME external_ref) may be removed only as part of + cascading from its parent「根」. + + UME World and UME-synced SBN folders (``external_ref`` set) are user-deletable — + they are synced inventory, not structural locks; a later UME apply can recreate them. """ row = _get_folder_or_404(db, folder_id) if str(row.kind or "") == "root": @@ -559,9 +561,10 @@ def delete_folder(db: Session, folder_id: str, *, force: bool = False) -> dict[s from .ume_topology_world import is_ume_world_container - # UME World container is system-marked but user-deletable (cascades SBN tree). - # Manual「根图」and other system folders stay protected except via parent cascade. - if bool(row.is_system) and not is_ume_world_container(row): + ext = str(getattr(row, "external_ref", None) or "").strip() + ume_synced = bool(ext) # ume:world / ume:world:drill / SBN id + # Protect manual system folders (根图); allow UME World + synced children. + if bool(row.is_system) and not is_ume_world_container(row) and not ume_synced: raise HTTPException(status_code=400, detail="cannot_delete_system_folder") _ = force from .topology_region_canvas import remove_region_canvas_placements diff --git a/tests/test_ume_hierarchy.py b/tests/test_ume_hierarchy.py index 30e3ba0..f3cca4e 100644 --- a/tests/test_ume_hierarchy.py +++ b/tests/test_ume_hierarchy.py @@ -413,6 +413,34 @@ class UmeHierarchyTests(unittest.TestCase): row = self.db.get(TopoFolder, out.id) self.assertEqual(row.parent_id, drill.id) + def test_ume_synced_sbn_folder_is_deletable(self): + from fastapi import HTTPException + + from netx_api.topology_views_tree import delete_folder + from netx_api.ume_topology_world import get_world_drill_folder + + self._seed_tree() + apply_ume_topology_to_fabric(self.db) + ensure_ume_world_and_sbn_folders(self.db) + drill = get_world_drill_folder(self.db) + self.assertIsNotNone(drill) + sbns = ( + self.db.query(TopoFolder) + .filter(TopoFolder.parent_id == drill.id, TopoFolder.kind == "region") + .all() + ) + self.assertGreater(len(sbns), 0) + target = next((f for f in sbns if str(f.external_ref or "").strip()), sbns[0]) + self.assertTrue(bool(target.is_system)) + self.assertTrue(str(target.external_ref or "").strip()) + out = delete_folder(self.db, target.id) + self.assertTrue(out.get("ok")) + self.assertIsNone(self.db.get(TopoFolder, target.id)) + # Manual system folder without UME ref stays protected. + with self.assertRaises(HTTPException) as ctx: + delete_folder(self.db, "nope-missing") + self.assertEqual(ctx.exception.status_code, 404) + def test_flat_slots_do_not_overlap(self): self._seed_tree() apply_ume_topology_to_fabric(self.db) diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index aaa4851..18350ef 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -2395,7 +2395,7 @@ export function TopologyPage() { - {!umeNav || containerFolder ? ( + {!umeNav || containerFolder || Boolean(String(folder.external_ref || "").trim()) ? (
{!containerFolder ? (