Align folder permissions: roots deletable, root maps rename-only, world map locked.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-07 14:48:22 +08:00
parent 1b592490a5
commit 77832b271d
4 changed files with 85 additions and 13 deletions

View file

@ -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")

View file

@ -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 (

View file

@ -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()

View file

@ -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() {
</span>
</span>
</button>
{!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 (
<div className="topo-map-list__actions">
{!folder.is_system ? (
{canRenameFolder ? (
<button
type="button"
className="topo-map-list__icon"
@ -2429,6 +2443,7 @@ export function TopologyPage() {
<PencilIcon />
</button>
) : null}
{canDeleteFolder ? (
<button
type="button"
className="topo-map-list__icon"
@ -2447,8 +2462,10 @@ export function TopologyPage() {
>
<CloseIcon />
</button>
) : null}
</div>
) : null}
);
})()}
</div>
{open ? (
<ul className="topo-map-list topo-region-list__maps">