Protect root maps and auto world map; only sub-regions are deletable.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-07 14:41:06 +08:00
parent 8fa4b2e4ea
commit 1b592490a5
6 changed files with 70 additions and 34 deletions

View file

@ -549,22 +549,25 @@ 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「根图」(manual, no UME external_ref) may be removed only as part of
cascading from its parent「根」.
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)
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.
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.
"""
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 is_ume_world_container
from .ume_topology_world import WORLD_DRILL_REF, WORLD_FOLDER_REF, is_ume_world_container
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:
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.
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
@ -884,6 +887,10 @@ def update_view(db: Session, view_id: str, body: TopologyViewUpdate) -> Topology
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")

View file

@ -21,16 +21,19 @@ WORLD_FOLDER_REF = "ume:world"
WORLD_DRILL_REF = "ume:world:drill"
WORLD_VIEW_NAME = "World"
WORLD_FLAT_VIEW_NAME = "世界地图"
# Pre-rename label kept so existing TopoView rows still match.
# Pre-rename / locale labels kept so existing TopoView rows still match.
WORLD_FLAT_VIEW_NAME_LEGACY = "完整世界地图"
WORLD_FLAT_VIEW_NAME_EN = "World map"
WORLD_FLAT_VIEW_NAMES = frozenset(
{WORLD_FLAT_VIEW_NAME, WORLD_FLAT_VIEW_NAME_LEGACY, WORLD_FLAT_VIEW_NAME_EN}
)
# Drill-down root: children of MD (root SBNs + rare direct MEs).
WORLD_LEVEL_FILTER = {"ume_level": True, "parent": "md"}
WORLD_FLAT_FILTER = {"world_flat": True}
def is_world_flat_view_name(name: str | None) -> bool:
n = str(name or "").strip()
return n == WORLD_FLAT_VIEW_NAME or n == WORLD_FLAT_VIEW_NAME_LEGACY
return str(name or "").strip() in WORLD_FLAT_VIEW_NAMES
def _filt(view: TopoView | None) -> dict[str, Any]:

View file

@ -277,6 +277,7 @@ class UmeHierarchyTests(unittest.TestCase):
self.assertEqual(WORLD_FLAT_VIEW_NAME, "世界地图")
self.assertTrue(is_world_flat_view_name(WORLD_FLAT_VIEW_NAME))
self.assertTrue(is_world_flat_view_name(WORLD_FLAT_VIEW_NAME_LEGACY))
self.assertTrue(is_world_flat_view_name("World map"))
self.assertFalse(is_world_flat_view_name("World"))
def test_world_map_forbids_add_nes(self):
@ -436,10 +437,19 @@ class UmeHierarchyTests(unittest.TestCase):
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.
# Structural UME folders stay protected.
with self.assertRaises(HTTPException) as ctx:
delete_folder(self.db, "nope-missing")
self.assertEqual(ctx.exception.status_code, 404)
delete_folder(self.db, drill.id)
self.assertEqual(ctx.exception.status_code, 400)
self.assertEqual(ctx.exception.detail, "cannot_delete_system_folder")
from netx_api.topology_views_tree import delete_view
from netx_api.ume_topology_world import get_world_flat_view
flat = get_world_flat_view(self.db)
self.assertIsNotNone(flat)
with self.assertRaises(HTTPException) as ctx2:
delete_view(self.db, flat.id)
self.assertEqual(ctx2.exception.detail, "cannot_delete_world_map_view")
def test_flat_slots_do_not_overlap(self):
self._seed_tree()

View file

@ -1442,6 +1442,7 @@ const en = {
newSubRegionPrompt: "Sub-region name",
subRegionNotOnFlat: "The world map has no regions. Create sub-regions on World or a lower-level canvas.",
worldMapNoDirectNes: "Cannot add NEs on the world map — add them on a sub-region canvas.",
worldMapName: "World map",
worldHud: "World {{total}} · close-up {{cached}} · z{{zoom}} · {{lod}}",
worldEmptyCoords: "World map has no coordinates yet",
worldEmptyCoordsHint:

View file

@ -1432,6 +1432,7 @@ const zh = {
newSubRegionPrompt: "子区域名称",
subRegionNotOnFlat: "世界地图不支持子区域,请在 World 或下级区域画布上创建。",
worldMapNoDirectNes: "世界地图不能直接添加网元,请到某个子区域画布上添加。",
worldMapName: "世界地图",
worldHud: "世界 {{total}} · 近景 {{cached}} · z{{zoom}} · {{lod}}",
worldEmptyCoords: "世界地图还没有坐标",
worldEmptyCoordsHint: "码头已有 {{dock}} 个网元,但尚未灌入 Fabric / 世界坐标。可一键应用(不拉 UME)。",

View file

@ -180,7 +180,26 @@ function isWorldDrillFolder(folder: TopologyTreeFolderItem | null | undefined):
function isWorldFlatViewName(name: string | undefined | null): boolean {
const n = String(name || "").trim();
return n === "世界地图" || n === "完整世界地图";
return n === "世界地图" || n === "完整世界地图" || n === "World map";
}
function displayViewName(name: string | undefined | null, t: (key: string) => string): string {
if (isWorldFlatViewName(name)) return t("topology.worldMapName");
return String(name || "").trim();
}
/** UME structural folders (not user-deletable): container + World drill. */
function isUmeStructuralFolder(folder: TopologyTreeFolderItem | null | undefined): boolean {
if (!folder) return false;
if (isUmeWorldContainer(folder) || isWorldDrillFolder(folder)) return true;
const ext = String(folder.external_ref || "").trim();
return ext === "ume:world" || ext === "ume:world:drill";
}
/** UME-synced SBN under World — deletable like a manual sub-region. */
function isUmeSyncedSubRegion(folder: TopologyTreeFolderItem | null | undefined): boolean {
if (!folder || isUmeStructuralFolder(folder)) return false;
return Boolean(String(folder.external_ref || "").trim());
}
/** Visual LOD for 1:1 world coords (~1e5 span). fitView overview is ~0.01. */
@ -2285,7 +2304,7 @@ export function TopologyPage() {
/>
</span>
<span className="topo-map-list__title">
{v.name}
{displayViewName(v.name, t)}
{viewActive && dirty ? " *" : ""}
</span>
{!umeNav ? (
@ -2395,15 +2414,16 @@ export function TopologyPage() {
</span>
</span>
</button>
{!umeNav || containerFolder || Boolean(String(folder.external_ref || "").trim()) ? (
{!isUmeStructuralFolder(folder) &&
(!folder.is_system || isUmeSyncedSubRegion(folder)) ? (
<div className="topo-map-list__actions">
{!containerFolder ? (
{!folder.is_system ? (
<button
type="button"
className="topo-map-list__icon"
title={t("topology.renameRegion")}
aria-label={t("topology.renameRegion")}
disabled={!!folder.is_system || renameRegionMut.isPending}
disabled={renameRegionMut.isPending}
onClick={() => promptRenameRegion(folder.id, folder.name)}
>
<PencilIcon />
@ -2414,18 +2434,12 @@ export function TopologyPage() {
className="topo-map-list__icon"
title={t("topology.deleteRegion")}
aria-label={t("topology.deleteRegion")}
disabled={
deleteFolderMut.isPending ||
(!!folder.is_system &&
!containerFolder &&
!String(folder.external_ref || "").trim())
}
disabled={deleteFolderMut.isPending}
onClick={() => {
const msg = (
containerFolder
? t("topology.deleteUmeWorldConfirm")
: t("topology.deleteRegionConfirm")
).replace("{{name}}", folder.name);
const msg = t("topology.deleteRegionConfirm").replace(
"{{name}}",
folder.name,
);
if (window.confirm(msg)) {
deleteFolderMut.mutate(folder.id);
}
@ -3991,9 +4005,9 @@ export function TopologyPage() {
}, [graphTruncated, truncateReason, activeView?.name, graphQuery.data?.view?.filter?.world_flat, t]);
const activeLeafName = useMemo(() => {
if (!mapId) return "";
if (activeView?.name) return activeView.name;
return graphQuery.data?.view?.name || "";
}, [mapId, activeView, graphQuery.data?.view?.name]);
if (activeView?.name) return displayViewName(activeView.name, t);
return displayViewName(graphQuery.data?.view?.name, t);
}, [mapId, activeView, graphQuery.data?.view?.name, t]);
const titleText = useMemo(() => {
if (canvasMode) return activeLeafName || t("topology.selectMap");
@ -4798,7 +4812,7 @@ export function TopologyPage() {
<LayerGlyph role={isPhysical ? "core" : "aggregation"} size={22} />
</span>
<span className="topo-region-hex__title">
<span className="topo-region-hex__name">{v.name}</span>
<span className="topo-region-hex__name">{displayViewName(v.name, t)}</span>
<span className="topo-region-hex__meta">{v.node_count || 0}N</span>
</span>
</button>