From 51b789b5aabcc3ba53a3f0a4ef70ae1feb07f373 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Wed, 27 May 2026 15:36:03 +0200
Subject: [PATCH] icon toggle
---
app.py | 3 +
.../datamodels/datamodelFeatureDataSource.py | 24 +-
.../workspace/routeFeatureWorkspace.py | 108 +-
modules/routes/routeDataSources.py | 327 +----
modules/routes/routeRagInventory.py | 23 +-
modules/routes/routeUdb.py | 229 ++++
.../coreTools/_featureSubAgentTools.py | 3 +-
.../services/serviceKnowledge/_buildTree.py | 769 ++++--------
.../serviceKnowledge/_inheritFlags.py | 30 +-
.../serviceKnowledge/subFeatureBootstrap.py | 42 +-
.../services/serviceKnowledge/udbNodes.py | 1055 +++++++++++++++++
tests/unit/services/test_buildTree.py | 318 ++---
tests/unit/services/test_inheritFlags.py | 108 +-
tests/unit/services/test_udbNodes.py | 449 +++++++
14 files changed, 2227 insertions(+), 1261 deletions(-)
create mode 100644 modules/routes/routeUdb.py
create mode 100644 modules/serviceCenter/services/serviceKnowledge/udbNodes.py
create mode 100644 tests/unit/services/test_udbNodes.py
diff --git a/app.py b/app.py
index b53caebc..74deb617 100644
--- a/app.py
+++ b/app.py
@@ -616,6 +616,9 @@ app.include_router(fileRouter)
from modules.routes.routeDataSources import router as dataSourceRouter
app.include_router(dataSourceRouter)
+from modules.routes.routeUdb import router as udbRouter
+app.include_router(udbRouter)
+
from modules.routes.routeDataPrompts import router as promptRouter
app.include_router(promptRouter)
diff --git a/modules/datamodels/datamodelFeatureDataSource.py b/modules/datamodels/datamodelFeatureDataSource.py
index 10fd76a7..2f234742 100644
--- a/modules/datamodels/datamodelFeatureDataSource.py
+++ b/modules/datamodels/datamodelFeatureDataSource.py
@@ -43,31 +43,9 @@ class FeatureDataSource(PowerOnModel):
)
mandateId: str = Field(
default="",
- description="Mandate scope",
+ description="Mandate scope (set automatically from featureInstance.mandateId on create).",
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
)
- userId: str = Field(
- default="",
- description="Owner user ID",
- json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
- )
- workspaceInstanceId: str = Field(
- description="Workspace feature instance where this source is used",
- json_schema_extra={"label": "Workspace", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
- )
- scope: Optional[str] = Field(
- default=None,
- description=(
- "Data visibility scope with inherit semantics. "
- "None = inherit; values: personal, featureInstance, mandate, global."
- ),
- json_schema_extra={"label": "Sichtbarkeit", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
- {"value": "personal", "label": "Persönlich"},
- {"value": "featureInstance", "label": "Feature-Instanz"},
- {"value": "mandate", "label": "Mandant"},
- {"value": "global", "label": "Global"},
- ]},
- )
neutralize: Optional[bool] = Field(
default=None,
description=(
diff --git a/modules/features/workspace/routeFeatureWorkspace.py b/modules/features/workspace/routeFeatureWorkspace.py
index 5c24c113..e5ba470a 100644
--- a/modules/features/workspace/routeFeatureWorkspace.py
+++ b/modules/features/workspace/routeFeatureWorkspace.py
@@ -986,7 +986,11 @@ async def listWorkspaceWorkflows(
"startedAt": getattr(wf, "startedAt", None),
"lastActivity": getattr(wf, "lastActivity", None),
"featureInstanceId": getattr(wf, "featureInstanceId", instanceId),
+ "workflowMode": getattr(wf, "workflowMode", None),
+ "linkedWorkflowId": getattr(wf, "linkedWorkflowId", None),
}
+ if item.get("workflowMode") == "Automation" or item.get("linkedWorkflowId"):
+ continue
if not includeArchived and item.get("status") == "archived":
continue
fiId = item.get("featureInstanceId") or instanceId
@@ -1311,73 +1315,6 @@ async def listWorkspaceDataSources(
return JSONResponse({"dataSources": []})
-class _TreeChildrenRequest(BaseModel):
- """Request body for the generic tree children endpoint."""
- parents: List[Optional[str]] = Field(
- default_factory=list,
- description="List of parent keys to fetch children for. Use null for top-level.",
- )
-
-
-@router.post("/{instanceId}/tree/children")
-@limiter.limit("300/minute")
-async def getTreeChildren(
- request: Request,
- instanceId: str = Path(...),
- body: _TreeChildrenRequest = Body(...),
- context: RequestContext = Depends(getRequestContext),
-):
- """Generic UDB tree children resolver.
-
- The UI sends a list of parent keys (or null for top-level). The backend
- returns children for each requested parent, with all effective flag
- values pre-computed. The UI builds the visible tree from the resulting
- flat per-parent map.
- """
- _validateInstanceAccess(instanceId, context)
- from modules.serviceCenter.services.serviceKnowledge._buildTree import getChildrenForParents
-
- try:
- nodesByParent = await getChildrenForParents(instanceId, body.parents, context)
- except Exception as exc:
- logger.exception("Tree children build failed: %s", exc)
- raise HTTPException(status_code=500, detail=str(exc))
- return JSONResponse({"nodesByParent": nodesByParent})
-
-
-class _TreeAttributesRequest(BaseModel):
- """Request body for the attribute-refresh endpoint."""
- keys: List[str] = Field(
- default_factory=list,
- description="List of node keys to fetch current attributes for.",
- )
-
-
-@router.post("/{instanceId}/tree/attributes")
-@limiter.limit("300/minute")
-async def getTreeAttributes(
- request: Request,
- instanceId: str = Path(...),
- body: _TreeAttributesRequest = Body(...),
- context: RequestContext = Depends(getRequestContext),
-):
- """Return current effective attribute values (neutralize, scope,
- ragIndexEnabled) for a list of node keys. Used after a toggle action
- to refresh only the visible nodes without reloading tree structure."""
- _validateInstanceAccess(instanceId, context)
- from modules.serviceCenter.services.serviceKnowledge._buildTree import getAttributesForKeys
-
- if len(body.keys) > 500:
- raise HTTPException(status_code=400, detail="Max 500 keys per request")
-
- try:
- attrs = await getAttributesForKeys(instanceId, body.keys, context)
- except Exception as exc:
- logger.exception("Tree attributes failed: %s", exc)
- raise HTTPException(status_code=500, detail=str(exc))
- return JSONResponse({"attributes": attrs})
-
-
class CreateDataSourceRequest(BaseModel):
"""Request body for creating a DataSource."""
connectionId: str = Field(description="Connection ID")
@@ -1458,19 +1395,15 @@ async def createFeatureDataSource(
body: CreateFeatureDataSourceRequest = Body(...),
context: RequestContext = Depends(getRequestContext),
):
- """Create a FeatureDataSource for this workspace instance.
+ """Create a FeatureDataSource for the referenced feature instance.
- The FDS lives under the WORKSPACE's mandate (not the feature's): that
- matches how the tree (`allFds = recordset where workspaceInstanceId =
- instanceId`) and the PATCH endpoints scope these records — by workspace,
- not by feature mandate. The user can legitimately reference a feature
- from another mandate they have access to (via the UDB mandate-group
- nodes), and a hard cross-mandate block here would silently 403 those
- toggles. Access to the referenced feature is verified by the user's
- `FeatureAccess` and the existing tree-children RBAC, which run before
- the user can ever click on this node.
+ The FDS belongs to the FEATURE-INSTANCE (not to a workspace). Flag editing
+ is governed by feature-admin RBAC on that feature instance (see the
+ UDB reference page for the polymorphic node model). The `instanceId`
+ in the URL path is the calling consumer's feature instance and is used
+ only for access validation, not for FDS scoping.
"""
- wsMandateId, _ = _validateInstanceAccess(instanceId, context)
+ _validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
@@ -1478,8 +1411,10 @@ async def createFeatureDataSource(
if not rootIf.getFeatureAccess(str(context.user.id), body.featureInstanceId):
raise HTTPException(status_code=403, detail=routeApiMsg("Access denied to this feature instance"))
+ fi = rootIf.getFeatureInstance(body.featureInstanceId)
+ fiMandateId = str(fi.mandateId) if fi and getattr(fi, "mandateId", None) else ""
+
existing = rootIf.db.getRecordset(FeatureDataSource, recordFilter={
- "workspaceInstanceId": instanceId,
"featureInstanceId": body.featureInstanceId,
"tableName": body.tableName,
}) or []
@@ -1494,9 +1429,7 @@ async def createFeatureDataSource(
tableName=body.tableName,
objectKey=body.objectKey,
label=body.label,
- mandateId=wsMandateId or "",
- userId=str(context.user.id),
- workspaceInstanceId=instanceId,
+ mandateId=fiMandateId,
recordFilter=body.recordFilter,
)
created = rootIf.db.recordCreate(FeatureDataSource, fds.model_dump())
@@ -1510,27 +1443,28 @@ async def listFeatureDataSources(
instanceId: str = Path(...),
context: RequestContext = Depends(getRequestContext),
):
- """List active FeatureDataSources for this workspace instance, scoped to mandate."""
+ """List FeatureDataSources visible to this caller. Filters by mandate of
+ the calling feature-instance (RBAC). FDS records are now feature-owned;
+ visibility is governed by the user's accessible feature instances within
+ the mandate."""
wsMandateId, _ = _validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import buildEffectiveByWorkspaceFds
rootIf = getRootInterface()
- recordFilter: dict = {"workspaceInstanceId": instanceId}
+ recordFilter: dict = {}
if wsMandateId:
recordFilter["mandateId"] = wsMandateId
- records = rootIf.db.getRecordset(FeatureDataSource, recordFilter=recordFilter)
+ records = rootIf.db.getRecordset(FeatureDataSource, recordFilter=recordFilter) or []
if not records:
return JSONResponse({"featureDataSources": []})
effNeutralize = buildEffectiveByWorkspaceFds(records, "neutralize", mode="aggregate")
- effScope = buildEffectiveByWorkspaceFds(records, "scope", mode="aggregate")
effRag = buildEffectiveByWorkspaceFds(records, "ragIndexEnabled", mode="aggregate")
for fds in records:
fdsId = fds.get("id", "")
fds["effectiveNeutralize"] = effNeutralize.get(fdsId, False)
- fds["effectiveScope"] = effScope.get(fdsId, "personal")
fds["effectiveRagIndexEnabled"] = effRag.get(fdsId, False)
return JSONResponse({"featureDataSources": records})
diff --git a/modules/routes/routeDataSources.py b/modules/routes/routeDataSources.py
index 9ffd42ed..eccbf276 100644
--- a/modules/routes/routeDataSources.py
+++ b/modules/routes/routeDataSources.py
@@ -1,6 +1,11 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
-"""PATCH endpoints for DataSource and FeatureDataSource scope/neutralize/rag-index tagging."""
+"""DataSource auxiliary endpoints: settings (ragLimits) and cost estimate.
+
+Flag toggles (neutralize / scope / ragIndexEnabled) have moved to the
+generic UDB router (`POST /api/udb/node/{key}/flag/{flag}`); see
+`modules/routes/routeUdb.py` and the wiki UDB reference page.
+"""
import logging
from typing import Any, Dict, List, Optional
@@ -43,49 +48,6 @@ def _ensureConnectionKnowledgeFlag(rootIf, connectionId: str) -> None:
except Exception as e:
logger.warning("Could not auto-enable knowledgeIngestionEnabled for connection %s: %s", connectionId, e)
-def _computeOwnEffective(rootIf, rec, model, sourceId: str, flag: str) -> Any:
- """Re-load the record after modification and compute its aggregate effective value."""
- from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
- getEffectiveFlag, getEffectiveFlagFds,
- )
- freshRec = rootIf.db.getRecord(model, sourceId)
- if not freshRec:
- return None
- if model is DataSource:
- connectionId = freshRec.get("connectionId", "")
- allDs = rootIf.db.getRecordset(DataSource, recordFilter={"connectionId": connectionId})
- return getEffectiveFlag(freshRec, flag, allDs, mode="aggregate")
- else:
- wsId = freshRec.get("workspaceInstanceId", "")
- allFds = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"workspaceInstanceId": wsId})
- return getEffectiveFlagFds(freshRec, flag, allFds, mode="aggregate")
-
-
-def _computeAncestorEffectives(rootIf, rec, model, flag: str) -> List[Dict[str, Any]]:
- """Compute the aggregate effective value for all ancestors of `rec`."""
- from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
- collectAncestorChain, collectAncestorChainFds,
- getEffectiveFlag, getEffectiveFlagFds,
- )
- effectiveKey = f"effective{flag[0].upper()}{flag[1:]}"
- if model is DataSource:
- connectionId = rec.get("connectionId", "")
- allDs = rootIf.db.getRecordset(DataSource, recordFilter={"connectionId": connectionId})
- ancestors = collectAncestorChain(rec, allDs)
- return [
- {"id": a.get("id") or getattr(a, "id", ""), effectiveKey: getEffectiveFlag(a, flag, allDs, mode="aggregate")}
- for a in ancestors
- ]
- else:
- wsId = rec.get("workspaceInstanceId", "")
- allFds = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"workspaceInstanceId": wsId})
- ancestors = collectAncestorChainFds(rec, allFds)
- return [
- {"id": a.get("id") or getattr(a, "id", ""), effectiveKey: getEffectiveFlagFds(a, flag, allFds, mode="aggregate")}
- for a in ancestors
- ]
-
-
router = APIRouter(
prefix="/api/datasources",
tags=["Data Sources"],
@@ -98,9 +60,6 @@ router = APIRouter(
},
)
-_VALID_SCOPES = {"personal", "featureInstance", "mandate", "global"}
-
-
def _findSourceRecord(db, sourceId: str):
"""Look up a source by ID, checking DataSource first, then FeatureDataSource."""
rec = db.getRecord(DataSource, sourceId)
@@ -112,250 +71,6 @@ def _findSourceRecord(db, sourceId: str):
return None, None
-@router.patch("/{sourceId}/scope")
-@limiter.limit("30/minute")
-def _updateDataSourceScope(
- request: Request,
- sourceId: str = Path(..., description="ID of the DataSource or FeatureDataSource"),
- scope: Optional[str] = Body(None, embed=True),
- context: RequestContext = Depends(getRequestContext),
-) -> Dict[str, Any]:
- """Update the scope of a DataSource. Cascade-resets explicit descendants.
-
- `scope=None` resets this node to inherit (no cascade). Global scope
- requires sysAdmin.
- """
- if scope is not None:
- if scope not in _VALID_SCOPES:
- raise HTTPException(status_code=400, detail=f"Invalid scope: {scope}. Must be one of {_VALID_SCOPES}")
- if scope == "global" and not context.isSysAdmin:
- raise HTTPException(status_code=403, detail=routeApiMsg("Only sysadmins can set global scope"))
-
- try:
- from modules.interfaces.interfaceDbApp import getRootInterface
- from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
- cascadeResetDescendants, cascadeResetDescendantsFds,
- getEffectiveFlag, getEffectiveFlagFds,
- collectAncestorChain, collectAncestorChainFds,
- )
- rootIf = getRootInterface()
- rec, model = _findSourceRecord(rootIf.db, sourceId)
- if not rec:
- raise HTTPException(status_code=404, detail=f"DataSource {sourceId} not found")
-
- # 1. Cascade reset descendants bottom-up (before modifying master)
- resetIds: List[str] = []
- if scope is not None:
- if model is DataSource:
- resetIds = cascadeResetDescendants(rootIf, rec, "scope")
- else:
- resetIds = cascadeResetDescendantsFds(rootIf, rec, "scope")
-
- # 2. Set master value last (crash-safe)
- rootIf.db.recordModify(model, sourceId, {"scope": scope})
-
- # 3. Compute effective + ancestor chain for response
- updatedAncestors = _computeAncestorEffectives(rootIf, rec, model, "scope")
- effectiveScope = _computeOwnEffective(rootIf, rec, model, sourceId, "scope")
-
- logger.info(
- "Updated scope=%s for %s %s (cascade-reset %d descendants)",
- scope, model.__name__, sourceId, len(resetIds),
- )
- return {
- "sourceId": sourceId,
- "scope": scope,
- "effectiveScope": effectiveScope,
- "resetDescendantIds": resetIds,
- "updatedAncestors": updatedAncestors,
- }
- except HTTPException:
- raise
- except Exception as e:
- logger.error("Error updating datasource scope: %s", e)
- raise HTTPException(status_code=500, detail=str(e))
-
-
-@router.patch("/{sourceId}/neutralize")
-@limiter.limit("30/minute")
-def _updateDataSourceNeutralize(
- request: Request,
- sourceId: str = Path(..., description="ID of the DataSource or FeatureDataSource"),
- neutralize: Optional[bool] = Body(None, embed=True),
- context: RequestContext = Depends(getRequestContext),
-) -> Dict[str, Any]:
- """Set neutralize flag on a DataSource. Cascade-resets explicit descendants.
-
- `neutralize=None` resets this node to inherit (no cascade).
- """
- try:
- from modules.interfaces.interfaceDbApp import getRootInterface
- from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
- cascadeResetDescendants, cascadeResetDescendantsFds,
- )
- rootIf = getRootInterface()
- rec, model = _findSourceRecord(rootIf.db, sourceId)
- if not rec:
- raise HTTPException(status_code=404, detail=f"DataSource {sourceId} not found")
-
- # 1. Cascade reset descendants bottom-up (before modifying master)
- resetIds: List[str] = []
- if neutralize is not None:
- if model is DataSource:
- resetIds = cascadeResetDescendants(rootIf, rec, "neutralize")
- else:
- resetIds = cascadeResetDescendantsFds(rootIf, rec, "neutralize")
-
- # 2. Set master value last (crash-safe)
- rootIf.db.recordModify(model, sourceId, {"neutralize": neutralize})
-
- # 3. Compute effective + ancestor chain for response
- updatedAncestors = _computeAncestorEffectives(rootIf, rec, model, "neutralize")
- effectiveNeutralize = _computeOwnEffective(rootIf, rec, model, sourceId, "neutralize")
-
- logger.info(
- "Updated neutralize=%s for %s %s (cascade-reset %d descendants)",
- neutralize, model.__name__, sourceId, len(resetIds),
- )
- return {
- "sourceId": sourceId,
- "neutralize": neutralize,
- "effectiveNeutralize": effectiveNeutralize,
- "resetDescendantIds": resetIds,
- "updatedAncestors": updatedAncestors,
- }
- except HTTPException:
- raise
- except Exception as e:
- logger.error("Error updating datasource neutralize: %s", e)
- raise HTTPException(status_code=500, detail=str(e))
-
-
-@router.patch("/{sourceId}/neutralize-fields")
-@limiter.limit("30/minute")
-def _updateNeutralizeFields(
- request: Request,
- sourceId: str = Path(..., description="ID of the FeatureDataSource"),
- neutralizeFields: List[str] = Body(..., embed=True),
- context: RequestContext = Depends(getRequestContext),
-) -> Dict[str, Any]:
- """Update the list of field names to neutralize on a FeatureDataSource."""
- try:
- from modules.interfaces.interfaceDbApp import getRootInterface
- rootIf = getRootInterface()
- rec = rootIf.db.getRecord(FeatureDataSource, sourceId)
- if not rec:
- raise HTTPException(status_code=404, detail=f"FeatureDataSource {sourceId} not found")
-
- cleanFields = [f for f in neutralizeFields if f and isinstance(f, str)] if neutralizeFields else []
- rootIf.db.recordModify(FeatureDataSource, sourceId, {
- "neutralizeFields": cleanFields if cleanFields else None,
- })
- logger.info("Updated neutralizeFields=%s for FeatureDataSource %s", cleanFields, sourceId)
- return {"sourceId": sourceId, "neutralizeFields": cleanFields, "updated": True}
- except HTTPException:
- raise
- except Exception as e:
- logger.error("Error updating neutralizeFields: %s", e)
- raise HTTPException(status_code=500, detail=str(e))
-
-
-@router.patch("/{sourceId}/rag-index")
-@limiter.limit("30/minute")
-async def _updateDataSourceRagIndex(
- request: Request,
- sourceId: str = Path(..., description="ID of the DataSource"),
- ragIndexEnabled: Optional[bool] = Body(None, embed=True),
- context: RequestContext = Depends(getRequestContext),
-) -> Dict[str, Any]:
- """Set RAG indexing flag on a DataSource. Cascade-resets explicit descendants.
-
- `ragIndexEnabled=None` resets this node to inherit (no cascade, no purge,
- no bootstrap — the node simply follows its ancestor chain afterwards).
- `True` enqueues a mini-bootstrap. `False` synchronously purges chunks.
-
- Must be `async def` so `await startJob(...)` registers `_runJob` in the
- main event loop.
- """
- try:
- from modules.interfaces.interfaceDbApp import getRootInterface
- from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
- cascadeResetDescendants, cascadeResetDescendantsFds,
- )
- rootIf = getRootInterface()
- rec, model = _findSourceRecord(rootIf.db, sourceId)
- if not rec:
- raise HTTPException(status_code=404, detail=f"DataSource {sourceId} not found")
-
- # 1. Cascade reset descendants bottom-up (before modifying master)
- resetIds: List[str] = []
- if ragIndexEnabled is not None:
- if model is DataSource:
- resetIds = cascadeResetDescendants(rootIf, rec, "ragIndexEnabled")
- else:
- resetIds = cascadeResetDescendantsFds(rootIf, rec, "ragIndexEnabled")
-
- # 2. Set master value last (crash-safe)
- rootIf.db.recordModify(model, sourceId, {"ragIndexEnabled": ragIndexEnabled})
-
- logger.info(
- "Updated ragIndexEnabled=%s for %s %s (cascade-reset %d descendants)",
- ragIndexEnabled, model.__name__, sourceId, len(resetIds),
- )
-
- # Bootstrap / purge only for personal DataSource (file/folder-based RAG).
- # FDS RAG is handled by the feature pipeline; the flag alone is enough.
- if model is DataSource:
- connectionId = rec.get("connectionId") or rec.get("connection_id") or ""
- if ragIndexEnabled is True:
- _ensureConnectionKnowledgeFlag(rootIf, connectionId)
- from modules.serviceCenter.services.serviceBackgroundJobs import startJob
-
- conn = rootIf.getUserConnectionById(connectionId) if connectionId else None
- authority = ""
- if conn:
- authority = conn.authority.value if hasattr(conn.authority, "value") else str(conn.authority or "")
-
- await startJob(
- "connection.bootstrap",
- {"connectionId": connectionId, "authority": authority.lower(), "dataSourceIds": [sourceId]},
- triggeredBy=str(context.user.id),
- )
- elif ragIndexEnabled is False:
- from modules.interfaces.interfaceDbKnowledge import getInterface as getKnowledgeInterface
- purgeResult = getKnowledgeInterface(None).deleteFileContentIndexByDataSource(sourceId)
- logger.info("Purged %d index rows / %d chunks for DataSource %s",
- purgeResult.get("indexRows", 0), purgeResult.get("chunks", 0), sourceId)
-
- import json
- from modules.shared.auditLogger import audit_logger
- from modules.datamodels.datamodelAudit import AuditCategory
- audit_logger.logEvent(
- userId=str(context.user.id),
- mandateId=context.mandateId,
- category=AuditCategory.PERMISSION.value,
- action="rag_index_toggled",
- details=json.dumps({"sourceId": sourceId, "ragIndexEnabled": ragIndexEnabled, "resetDescendants": len(resetIds), "model": model.__name__}),
- )
-
- # 3. Compute effective + ancestors for response
- updatedAncestors = _computeAncestorEffectives(rootIf, rec, model, "ragIndexEnabled")
- effectiveRag = _computeOwnEffective(rootIf, rec, model, sourceId, "ragIndexEnabled")
-
- return {
- "sourceId": sourceId,
- "ragIndexEnabled": ragIndexEnabled,
- "effectiveRagIndexEnabled": effectiveRag,
- "resetDescendantIds": resetIds,
- "updatedAncestors": updatedAncestors,
- }
- except HTTPException:
- raise
- except Exception as e:
- logger.error("Error updating datasource ragIndexEnabled: %s", e)
- raise HTTPException(status_code=500, detail=str(e))
-
-
_CLICKUP_SOURCE_TYPES = {"clickup", "clickupList", "clickupSpace", "clickupFolder"}
_ALLOWED_RAG_LIMIT_KEYS = {
"files": {"maxItems", "maxBytes", "maxFileSize", "maxDepth"},
@@ -412,8 +127,9 @@ def _updateDataSourceSettings(
Currently supports `ragLimits` only. Unknown top-level keys in the body are
rejected to avoid silently storing garbage that no consumer reads.
- Owner-only for personal DataSources; mandate/feature scopes additionally
- accept the mandate or workspace admins of that scope.
+ DataSource: owner-only (or sysadmin). For mandate/feature scopes the
+ mandateAdmin also passes. FeatureDataSource has no userId/scope; for
+ those we require a feature-admin role on the FDS's featureInstanceId.
"""
if not isinstance(settings, dict):
raise HTTPException(status_code=400, detail="settings must be an object")
@@ -428,23 +144,22 @@ def _updateDataSourceSettings(
if not rec:
raise HTTPException(status_code=404, detail=f"DataSource {sourceId} not found")
- ownerId = str(rec.get("userId") or "")
currentUserId = str(context.user.id)
- if ownerId and ownerId != currentUserId and not context.isSysAdmin:
- from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlag
- if model is DataSource:
+ if model is DataSource:
+ ownerId = str(rec.get("userId") or "")
+ if ownerId and ownerId != currentUserId and not context.isSysAdmin:
+ from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlag
connectionId = rec.get("connectionId", "")
allDs = rootIf.db.getRecordset(DataSource, recordFilter={"connectionId": connectionId})
scope = str(getEffectiveFlag(rec, "scope", allDs, mode="walk"))
- else:
- from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource as FDS
- from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlagFds
- wsId = rec.get("workspaceInstanceId", "")
- allFds = rootIf.db.getRecordset(FDS, recordFilter={"workspaceInstanceId": wsId})
- scope = str(getEffectiveFlagFds(rec, "scope", allFds, mode="walk"))
- isMandateAdmin = getattr(context, "isMandateAdmin", False)
- if scope == "personal" or not isMandateAdmin:
- raise HTTPException(status_code=403, detail="Not allowed to modify this DataSource's settings")
+ isMandateAdmin = getattr(context, "isMandateAdmin", False)
+ if scope == "personal" or not isMandateAdmin:
+ raise HTTPException(status_code=403, detail="Not allowed to modify this DataSource's settings")
+ else:
+ from modules.serviceCenter.services.serviceKnowledge.udbNodes import _isFeatureAdmin
+ featureInstanceId = str(rec.get("featureInstanceId") or "")
+ if not (context.isSysAdmin or _isFeatureAdmin(rootIf, currentUserId, featureInstanceId)):
+ raise HTTPException(status_code=403, detail="Not allowed to modify this FeatureDataSource's settings")
kind = _kindForSource(rec, model)
diff --git a/modules/routes/routeRagInventory.py b/modules/routes/routeRagInventory.py
index 6a5e9eb5..0ca7fade 100644
--- a/modules/routes/routeRagInventory.py
+++ b/modules/routes/routeRagInventory.py
@@ -265,7 +265,7 @@ def _buildFeatureInstanceInventory(featureInstanceIds, rootIf, knowledgeIf) -> L
st = (r.get("status") if isinstance(r, dict) else getattr(r, "status", "unknown")) or "unknown"
statusCounts[st] = statusCounts.get(st, 0) + 1
- allFds = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"workspaceInstanceId": fiId})
+ allFds = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"featureInstanceId": fiId})
dsItems = []
anyRagEnabled = False
for fds in allFds:
@@ -287,7 +287,7 @@ def _buildFeatureInstanceInventory(featureInstanceIds, rootIf, knowledgeIf) -> L
fiJobs = [
j for j in allFeatureJobs
- if (j.get("payload") or {}).get("workspaceInstanceId") == fiId
+ if (j.get("payload") or {}).get("featureInstanceId") == fiId
]
runningJobs = [
{
@@ -572,17 +572,18 @@ async def _reindexConnection(
raise HTTPException(status_code=500, detail=str(e))
-@router.post("/reindex-feature/{workspaceInstanceId}")
+@router.post("/reindex-feature/{featureInstanceId}")
@limiter.limit("10/minute")
async def _reindexFeature(
request: Request,
- workspaceInstanceId: str,
+ featureInstanceId: str,
currentUser: User = Depends(getCurrentUser),
) -> Dict[str, Any]:
- """Re-trigger feature data bootstrap for a workspace instance.
+ """Re-trigger feature data bootstrap for a feature instance.
- Indexes all RAG-enabled FeatureDataSource rows into the knowledge store.
- Must be ``async def`` so ``await startJob(...)`` registers in the main loop.
+ Indexes all RAG-enabled FeatureDataSource rows owned by this feature
+ instance into the knowledge store. Must be ``async def`` so
+ ``await startJob(...)`` registers in the main loop.
"""
try:
from modules.interfaces.interfaceDbApp import getRootInterface
@@ -592,7 +593,7 @@ async def _reindexFeature(
rootIf = getRootInterface()
featureAccesses = rootIf.getFeatureAccessesForUser(str(currentUser.id))
hasAccess = any(
- str(fa.featureInstanceId) == workspaceInstanceId and fa.enabled
+ str(fa.featureInstanceId) == featureInstanceId and fa.enabled
for fa in featureAccesses
)
if not hasAccess and not getattr(currentUser, "isSysAdmin", False):
@@ -600,12 +601,12 @@ async def _reindexFeature(
jobId = await startJob(
FEATURE_BOOTSTRAP_JOB_TYPE,
- {"workspaceInstanceId": workspaceInstanceId},
+ {"featureInstanceId": featureInstanceId},
triggeredBy=str(currentUser.id),
)
- logger.info("Feature reindex triggered for workspace %s (jobId=%s)", workspaceInstanceId, jobId)
- return {"status": "queued", "workspaceInstanceId": workspaceInstanceId, "jobId": jobId}
+ logger.info("Feature reindex triggered for feature %s (jobId=%s)", featureInstanceId, jobId)
+ return {"status": "queued", "featureInstanceId": featureInstanceId, "jobId": jobId}
except HTTPException:
raise
except Exception as e:
diff --git a/modules/routes/routeUdb.py b/modules/routes/routeUdb.py
new file mode 100644
index 00000000..177778d2
--- /dev/null
+++ b/modules/routes/routeUdb.py
@@ -0,0 +1,229 @@
+# Copyright (c) 2026 Patrick Motsch
+# All rights reserved.
+"""Generic UDB (Unified Data Bar) router.
+
+The UDB is feature-agnostic: it can render the user's accessible data
+sources (personal + feature-owned) without being coupled to a specific
+caller feature instance. This router owns two endpoints:
+
+ POST /api/udb/tree/children
+ Resolve the children for a list of parent tree keys (UI walks).
+
+ POST /api/udb/node/{nodeKey}/flag/{flag}
+ Persist a new value for a single flag on a single node.
+
+Permission policy:
+ - DataSource-family nodes: owner-of-record (rec.userId == user).
+ - FdsRecord / FdsField nodes: feature-admin on the FDS's
+ featureInstanceId (a FeatureAccessRole whose Role.roleLabel ends
+ with '-admin').
+ - Synthetic containers (personalRoot, mgrp): never editable.
+
+See wiki/b-reference/platform/unified-data-bar.md for the full domain
+model and the rationale behind the hard cut from the previous
+feature-instance-scoped endpoints.
+"""
+
+import logging
+from typing import Any, Dict, List, Optional
+
+from fastapi import APIRouter, Body, Depends, HTTPException, Path, Request
+from pydantic import BaseModel, Field
+
+from modules.auth import getRequestContext, limiter, RequestContext
+from modules.shared.i18nRegistry import apiRouteContext
+
+routeApiMsg = apiRouteContext("routeUdb")
+logger = logging.getLogger(__name__)
+
+
+router = APIRouter(
+ prefix="/api/udb",
+ tags=["Unified Data Bar"],
+ responses={
+ 400: {"description": "Bad request"},
+ 401: {"description": "Unauthorized"},
+ 403: {"description": "Forbidden"},
+ 404: {"description": "Not found"},
+ 500: {"description": "Internal server error"},
+ },
+)
+
+
+_VALID_SCOPES = {"personal", "featureInstance", "mandate", "global"}
+
+
+# ---------------------------------------------------------------------------
+# POST /api/udb/tree/children
+# ---------------------------------------------------------------------------
+
+class _UdbTreeChildrenRequest(BaseModel):
+ """Request body for the generic UDB tree children endpoint."""
+ parents: List[Optional[str]] = Field(
+ default_factory=list,
+ description="List of parent keys to fetch children for. Use null for top-level.",
+ )
+
+
+@router.post("/tree/children")
+@limiter.limit("300/minute")
+async def _udbTreeChildren(
+ request: Request,
+ body: _UdbTreeChildrenRequest = Body(...),
+ context: RequestContext = Depends(getRequestContext),
+) -> Dict[str, Any]:
+ """Resolve children for the given parent keys.
+
+ The UDB is feature-agnostic; this endpoint requires only that the
+ user is authenticated. Visibility is driven by the user's accessible
+ mandates and feature instances inside `getChildrenForParents`.
+ """
+ from modules.serviceCenter.services.serviceKnowledge._buildTree import getChildrenForParents
+ try:
+ nodesByParent = await getChildrenForParents(body.parents, context)
+ except Exception as exc:
+ logger.exception("UDB tree children build failed: %s", exc)
+ raise HTTPException(status_code=500, detail=str(exc))
+ return {"nodesByParent": nodesByParent}
+
+
+# ---------------------------------------------------------------------------
+# POST /api/udb/node/{nodeKey}/flag/{flag}
+# ---------------------------------------------------------------------------
+
+class _UdbFlagBody(BaseModel):
+ """Generic flag-mutation body.
+
+ Exactly one of `value` / `neutralizeFields` is expected depending on
+ the flag (see `_extractFlagValue` for the mapping). `value` is typed
+ as Any because the legal type depends on the flag:
+ - neutralize/ragIndexEnabled : bool | null (null = inherit)
+ - scope : str | null (one of _VALID_SCOPES, null = inherit)
+ """
+ value: Any = Field(default=None, description="New flag value or null to reset to inherit.")
+
+
+@router.post("/node/{nodeKey:path}/flag/{flag}")
+@limiter.limit("60/minute")
+async def _udbNodeFlag(
+ request: Request,
+ nodeKey: str = Path(..., description="Tree key of the node to modify"),
+ flag: str = Path(..., description="One of: neutralize | scope | ragIndexEnabled"),
+ body: _UdbFlagBody = Body(default_factory=_UdbFlagBody),
+ context: RequestContext = Depends(getRequestContext),
+) -> Dict[str, Any]:
+ """Persist a new value for `flag` on the node identified by `nodeKey`.
+
+ `value=null` resets the node to inherit from its ancestor chain (no
+ cascade, no purge). `value=true/false` (or a scope string) writes
+ the explicit override and cascade-resets any explicit child
+ descendants so they re-inherit.
+
+ RBAC: `node.canEdit(context, rootIf)` decides; the route never
+ re-implements ownership rules.
+ """
+ if flag not in ("neutralize", "scope", "ragIndexEnabled"):
+ raise HTTPException(status_code=400, detail=f"Unknown flag: {flag}")
+
+ value = _validateFlagValue(flag, body.value, context)
+
+ from modules.interfaces.interfaceDbApp import getRootInterface
+ from modules.serviceCenter.services.serviceKnowledge.udbNodes import buildNodeForKey
+ rootIf = getRootInterface()
+
+ node = buildNodeForKey(nodeKey, context, rootIf)
+ if node is None:
+ raise HTTPException(status_code=404, detail=f"Unknown UDB node key: {nodeKey}")
+
+ if not node.supportsFlag(flag):
+ raise HTTPException(
+ status_code=400,
+ detail=f"{type(node).__name__} does not support flag '{flag}'",
+ )
+ if not node.canEdit(context, rootIf):
+ raise HTTPException(status_code=403, detail=routeApiMsg("Not allowed to edit this UDB node"))
+
+ try:
+ resetIds = node.setFlag(flag, value, rootIf)
+ except NotImplementedError as exc:
+ raise HTTPException(status_code=400, detail=str(exc))
+ except RuntimeError as exc:
+ raise HTTPException(status_code=409, detail=str(exc))
+ except Exception as exc:
+ logger.exception("UDB setFlag failed: key=%s flag=%s: %s", nodeKey, flag, exc)
+ raise HTTPException(status_code=500, detail=str(exc))
+
+ effective = _computeEffectiveAfterWrite(rootIf, context, node, flag)
+
+ import json
+ from modules.shared.auditLogger import audit_logger
+ from modules.datamodels.datamodelAudit import AuditCategory
+ audit_logger.logEvent(
+ userId=str(context.user.id),
+ mandateId=context.mandateId,
+ category=AuditCategory.PERMISSION.value,
+ action="udb_flag_changed",
+ details=json.dumps({
+ "nodeKey": nodeKey,
+ "flag": flag,
+ "value": value,
+ "resetDescendants": len(resetIds),
+ "nodeKind": type(node).__name__,
+ }),
+ )
+
+ return {
+ "nodeKey": nodeKey,
+ "flag": flag,
+ "value": value,
+ "effective": effective,
+ "resetDescendantIds": resetIds,
+ }
+
+
+def _validateFlagValue(flag: str, value: Any, context: RequestContext) -> Any:
+ """Validate the incoming value matches the flag's expected shape.
+
+ Returns the validated value (possibly normalised) or raises HTTPException.
+ """
+ if value is None:
+ return None
+ if flag == "scope":
+ if not isinstance(value, str) or value not in _VALID_SCOPES:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Invalid scope: {value!r}. Must be one of {sorted(_VALID_SCOPES)}",
+ )
+ if value == "global" and not context.isSysAdmin:
+ raise HTTPException(status_code=403, detail=routeApiMsg("Only sysadmins can set global scope"))
+ return value
+ # neutralize / ragIndexEnabled
+ if isinstance(value, bool):
+ return value
+ raise HTTPException(
+ status_code=400,
+ detail=f"Invalid value for flag {flag!r}: expected bool or null, got {type(value).__name__}",
+ )
+
+
+def _computeEffectiveAfterWrite(rootIf: Any, context: RequestContext,
+ node: Any, flag: str) -> Any:
+ """Recompute the node's effective value after the write.
+
+ Re-loads the relevant recordsets so the cascade resets are visible.
+ """
+ from modules.datamodels.datamodelDataSource import DataSource
+ from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
+ userId = str(context.user.id)
+ allDs = rootIf.db.getRecordset(DataSource, recordFilter={"userId": userId}) or []
+ fdsFilter: Dict[str, Any] = {}
+ featureInstanceId = getattr(node, "featureInstanceId", None)
+ if featureInstanceId:
+ fdsFilter["featureInstanceId"] = featureInstanceId
+ allFds = rootIf.db.getRecordset(FeatureDataSource, recordFilter=fdsFilter) or []
+ try:
+ return node.getEffectiveFlag(flag, allDs, allFds, mode="aggregate")
+ except Exception as exc:
+ logger.warning("Effective-after-write failed for %s flag=%s: %s",
+ getattr(node, "key", "?"), flag, exc)
+ return None
diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py
index 2ebc2720..7249e959 100644
--- a/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py
+++ b/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py
@@ -91,7 +91,6 @@ def _registerFeatureSubAgentTools(registry: ToolRegistry, services):
mandateId = instance.mandateId or ""
instanceLabel = instance.label or ""
userId = context.get("userId", "")
- workspaceInstanceId = context.get("featureInstanceId", "")
requestLang = None
if userId:
langUser = rootIf.getUser(userId)
@@ -107,7 +106,7 @@ def _registerFeatureSubAgentTools(registry: ToolRegistry, services):
featureDataSources = rootDbConn.getRecordset(
FeatureDataSource,
- recordFilter={"featureInstanceId": featureInstanceId, "workspaceInstanceId": workspaceInstanceId},
+ recordFilter={"featureInstanceId": featureInstanceId},
)
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlagFds
diff --git a/modules/serviceCenter/services/serviceKnowledge/_buildTree.py b/modules/serviceCenter/services/serviceKnowledge/_buildTree.py
index 9179f3d8..63bcc0f1 100644
--- a/modules/serviceCenter/services/serviceKnowledge/_buildTree.py
+++ b/modules/serviceCenter/services/serviceKnowledge/_buildTree.py
@@ -4,7 +4,7 @@
The UDB shows three logical hierarchies as a single user-facing tree:
1. Personal connections: UserConnection -> Service -> Folder -> File
- 2. Mandate groups -> Feature instances -> FDS Workspace(*) -> FDS Table -> FDS Record
+ 2. Mandate groups -> Feature instances -> FDS Table -> FDS Field
3. (Settings/diagnostics nodes can be added later under the same model.)
For every visible node the UI needs:
@@ -15,9 +15,11 @@ For every visible node the UI needs:
- whether the node has children to expand
This module exposes one function: `getChildrenForParents(parents, ...)`.
-The caller asks for the children of a list of parent keys. The orchestrator
-does NOT decide what to expand; it only returns the children of what was
-asked for. This keeps the contract minimal and predictable.
+Builders construct `UdbNode` instances (see `udbNodes.py`); the entry
+point serializes them via `node.toDict(...)`. The aggregation (whether a
+parent shows 'mixed') is delegated to each node's polymorphic
+`getEffectiveFlag(mode='aggregate')` -- there is no separate
+post-processing pass any more.
"""
from __future__ import annotations
@@ -25,10 +27,17 @@ from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional, Tuple
-from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
- resolveEffectiveForPath,
- resolveEffectiveForFds,
- _normalisePath,
+from modules.serviceCenter.services.serviceKnowledge.udbNodes import (
+ UdbNode,
+ SyntheticContainerNode,
+ MandateGroupNode,
+ ConnectionNode,
+ ServiceNode,
+ FolderNode,
+ FileNode,
+ FdsWorkspaceNode,
+ FdsTableNode,
+ FdsFieldNode,
)
logger = logging.getLogger(__name__)
@@ -41,8 +50,8 @@ logger = logging.getLogger(__name__)
# Synthetic container keys use a single literal token without separator.
#
# Top-level (parent=None) returns:
-# personalRoot (synthetic, groups all UserConnections)
-# mgrp| (one per accessible mandate)
+# personalRoot (synthetic, groups all UserConnections)
+# mgrp| (one per accessible mandate)
#
# Data-bearing:
# conn|
@@ -50,14 +59,10 @@ logger = logging.getLogger(__name__)
# ds|||
# mgrp|
# feat|||
-# fdsws|| (synthetic '*' wildcard)
-# fdstbl||
-# fdsrec|||
+# fdstbl||
+# fdsfld|||
_KEY_SEP = "|"
-
-# Stable, parseable synthetic-container key. Never encoded with `_encode`
-# (no payload parts), always emitted/matched as literal.
_KEY_PERSONAL_ROOT = "personalRoot"
@@ -101,47 +106,13 @@ _SERVICE_LABELS: Dict[str, str] = {
}
-# ---------------------------------------------------------------------------
-# Per-node effective-value helpers
-# ---------------------------------------------------------------------------
-
-def _effectiveTripletDs(
- connectionId: str,
- sourceType: str,
- path: str,
- allDs: List[Dict[str, Any]],
-) -> Dict[str, Any]:
- """Return {effectiveNeutralize, effectiveScope, effectiveRagIndexEnabled}
- for an arbitrary DS coordinate (whether or not a record exists)."""
- out = resolveEffectiveForPath(connectionId, sourceType, path, allDs, mode="aggregate")
- return {
- "effectiveNeutralize": out.get("effectiveNeutralize", False),
- "effectiveScope": out.get("effectiveScope", "personal"),
- "effectiveRagIndexEnabled": out.get("effectiveRagIndexEnabled", False),
- }
-
-
-def _effectiveTripletFds(
- featureInstanceId: str,
- tableName: str,
- recordFilter: Optional[Dict[str, str]],
- allFds: List[Dict[str, Any]],
-) -> Dict[str, Any]:
- """Return effective-triplet for an FDS coordinate."""
- out = resolveEffectiveForFds(featureInstanceId, tableName, recordFilter, allFds, mode="aggregate")
- return {
- "effectiveNeutralize": out.get("effectiveNeutralize", False),
- "effectiveScope": out.get("effectiveScope", "personal"),
- "effectiveRagIndexEnabled": out.get("effectiveRagIndexEnabled", False),
- }
-
-
def _findDsRecord(
allDs: List[Dict[str, Any]],
connectionId: str,
sourceType: str,
path: str,
) -> Optional[Dict[str, Any]]:
+ from modules.serviceCenter.services.serviceKnowledge._inheritFlags import _normalisePath
norm = _normalisePath(path)
for ds in allDs:
if (
@@ -159,12 +130,7 @@ def _findFdsRecord(
tableName: str,
recordFilter: Optional[Dict[str, str]] = None,
) -> Optional[Dict[str, Any]]:
- """Find a FeatureDataSource record by featureInstanceId + tableName.
-
- `allFds` is already scoped to the workspace (loaded with
- recordFilter={'workspaceInstanceId': wsInstanceId}), so the
- distinguishing coordinate is featureInstanceId + tableName.
- """
+ """Find a FeatureDataSource by featureInstanceId + tableName + recordFilter."""
target = recordFilter or None
for fds in allFds:
if (
@@ -176,72 +142,37 @@ def _findFdsRecord(
return None
-# ---------------------------------------------------------------------------
-# Synthetic container helpers
-# ---------------------------------------------------------------------------
-
-def _emptyTriplet() -> Dict[str, Any]:
- """Synthetic container nodes carry no DB record and no inherited flags.
- Backend reports neutral defaults so the UI never reads stale values for them."""
- return {
- "effectiveNeutralize": False,
- "effectiveScope": "personal",
- "effectiveRagIndexEnabled": False,
- }
-
-
-def _syntheticNode(
- key: str,
- parentKey: Optional[str],
- label: str,
- icon: str,
- displayOrder: int,
- defaultExpanded: bool = False,
-) -> Dict[str, Any]:
- """Build a synthetic container node (no DB record, not flag-toggleable)."""
- return {
- "key": key,
- "kind": "synthRoot",
- "parentKey": parentKey,
- "label": label,
- "icon": icon,
- "hasChildren": True,
- "dataSourceId": None,
- "modelType": None,
- **_emptyTriplet(),
- "supportsRag": False,
- "canBeAdded": False,
- "displayOrder": displayOrder,
- "defaultExpanded": defaultExpanded,
- }
-
-
# ---------------------------------------------------------------------------
# Top-level (parent = None) -> personalRoot + mandate groups (flat layout)
# ---------------------------------------------------------------------------
def _topLevel(
- instanceId: str,
context: Any,
rootIf: Any,
- _allDs: List[Dict[str, Any]],
+ allDs: List[Dict[str, Any]],
allFds: List[Dict[str, Any]],
-) -> List[Dict[str, Any]]:
+) -> List[UdbNode]:
"""Return the visible top-level: 'personalRoot' first, then one node per
- accessible mandate group. Both layers are marked `defaultExpanded=True`
- so the UI opens down to the data-source level on first render.
- """
- nodes: List[Dict[str, Any]] = [
- _syntheticNode(
- key=_KEY_PERSONAL_ROOT,
- parentKey=None,
- label=resolveTextSafe("Persönliche Quellen"),
- icon="person",
- displayOrder=0,
- defaultExpanded=True,
- )
- ]
- nodes.extend(_listMandateGroups(instanceId, context, rootIf, allFds))
+ accessible mandate group. Both layers carry their logical children
+ pre-populated so the synthetic containers can compute an aggregate
+ 'mixed' indicator without a second pass."""
+ personalRoot = SyntheticContainerNode(
+ key=_KEY_PERSONAL_ROOT,
+ label=resolveTextSafe("Persönliche Quellen"),
+ icon="person",
+ parentKey=None,
+ displayOrder=0,
+ defaultExpanded=True,
+ )
+ # Populate personalRoot's logical children for aggregate computation.
+ for child in _personalRootChildrenNodes(context, allDs):
+ personalRoot.addLogicalChild(child)
+
+ nodes: List[UdbNode] = [personalRoot]
+ for mgrp in _listMandateGroupNodes(context, rootIf, allFds):
+ for child in _featureConnectionsForMandateNodes(rootIf, context, mgrp.mandateId, allFds):
+ mgrp.addLogicalChild(child)
+ nodes.append(mgrp)
return nodes
@@ -249,11 +180,10 @@ def _topLevel(
# Children of personalRoot -> active UserConnections
# ---------------------------------------------------------------------------
-def _personalRootChildren(
- instanceId: str,
+def _personalRootChildrenNodes(
context: Any,
allDs: List[Dict[str, Any]],
-) -> List[Dict[str, Any]]:
+) -> List[UdbNode]:
"""Return one node per active UserConnection of the current user."""
from modules.serviceCenter import getService
from modules.serviceCenter.context import ServiceCenterContext
@@ -262,12 +192,12 @@ def _personalRootChildren(
ctx = ServiceCenterContext(
user=context.user,
mandate_id=mandateId,
- feature_instance_id=instanceId,
+ feature_instance_id="",
)
chatService = getService("chat", ctx)
connections = chatService.getUserConnections() or []
- nodes: List[Dict[str, Any]] = []
+ nodes: List[UdbNode] = []
for c in connections:
conn = c if isinstance(c, dict) else (c.model_dump() if hasattr(c, "model_dump") else {})
status = conn.get("status")
@@ -280,24 +210,14 @@ def _personalRootChildren(
authority = authority.value
connId = conn.get("id") or ""
label = conn.get("externalEmail") or conn.get("externalUsername") or authority or ""
- # Connection root = path '/' on its authority sourceType.
- triplet = _effectiveTripletDs(connId, str(authority), "/", allDs)
rec = _findDsRecord(allDs, connId, str(authority), "/")
- nodes.append({
- "key": _encode("conn", connId),
- "kind": "connection",
- "parentKey": _KEY_PERSONAL_ROOT,
- "label": label,
- "icon": str(authority),
- "hasChildren": True,
- "dataSourceId": rec.get("id") if rec else None,
- "modelType": "DataSource" if rec else None,
- **triplet,
- "supportsRag": True,
- "canBeAdded": rec is None,
- "authority": authority,
- "connectionId": connId,
- })
+ nodes.append(ConnectionNode(
+ connectionId=connId,
+ authority=str(authority),
+ label=label,
+ parentKey=_KEY_PERSONAL_ROOT,
+ rec=rec,
+ ))
return nodes
@@ -305,19 +225,13 @@ def _personalRootChildren(
# Mandate-group nodes (rendered top-level next to personalRoot)
# ---------------------------------------------------------------------------
-def _listMandateGroups(
- _instanceId: str,
+def _listMandateGroupNodes(
context: Any,
rootIf: Any,
_allFds: List[Dict[str, Any]],
-) -> List[Dict[str, Any]]:
+) -> List[MandateGroupNode]:
"""Return one mandate-group node per accessible mandate that has at least
- one enabled feature instance with registered DATA objects.
-
- Emitted at the top level (parentKey=None). `defaultExpanded=True` so the
- UI shows feature-instance children (= mandate data sources) without a
- second user click.
- """
+ one enabled feature instance with registered DATA objects."""
from modules.security.rbacCatalog import getCatalogService
from modules.datamodels.datamodelUam import Mandate
@@ -343,7 +257,7 @@ def _listMandateGroups(
except Exception:
mandateLabels[um.mandateId] = um.mandateId
- nodes: List[Dict[str, Any]] = []
+ nodes: List[MandateGroupNode] = []
seenMandates: set = set()
for um in userMandates or []:
mid = um.mandateId
@@ -360,21 +274,7 @@ def _listMandateGroups(
break
if not hasFeature:
continue
- nodes.append({
- "key": _encode("mgrp", mid),
- "kind": "mandateGroup",
- "parentKey": None,
- "label": mandateLabels.get(mid, mid),
- "icon": "mandate",
- "hasChildren": True,
- "dataSourceId": None,
- "modelType": None,
- **_emptyTriplet(),
- "supportsRag": False,
- "canBeAdded": False,
- "mandateId": mid,
- "defaultExpanded": True,
- })
+ nodes.append(MandateGroupNode(mandateId=mid, label=mandateLabels.get(mid, mid)))
return nodes
@@ -382,12 +282,12 @@ def _listMandateGroups(
# Children of a connection -> services
# ---------------------------------------------------------------------------
-async def _connectionServices(
+async def _connectionServiceNodes(
instanceId: str,
context: Any,
connectionId: str,
allDs: List[Dict[str, Any]],
-) -> List[Dict[str, Any]]:
+) -> List[UdbNode]:
from modules.connectors.connectorResolver import ConnectorResolver
from modules.serviceCenter import getService
from modules.serviceCenter.context import ServiceCenterContext
@@ -410,28 +310,19 @@ async def _connectionServices(
logger.error("Tree: cannot resolve services for connection %s: %s", connectionId, exc)
return []
- nodes: List[Dict[str, Any]] = []
+ parentKey = _encode("conn", connectionId)
+ nodes: List[UdbNode] = []
for service in services or []:
sourceType = _SERVICE_TO_SOURCE_TYPE.get(service, service)
- triplet = _effectiveTripletDs(connectionId, sourceType, "/", allDs)
rec = _findDsRecord(allDs, connectionId, sourceType, "/")
- nodes.append({
- "key": _encode("svc", connectionId, service),
- "kind": "service",
- "parentKey": _encode("conn", connectionId),
- "label": _SERVICE_LABELS.get(service, service),
- "icon": service,
- "hasChildren": True,
- "dataSourceId": rec.get("id") if rec else None,
- "modelType": "DataSource" if rec else None,
- **triplet,
- "supportsRag": True,
- "canBeAdded": rec is None,
- "connectionId": connectionId,
- "service": service,
- "sourceType": sourceType,
- "path": "/",
- })
+ nodes.append(ServiceNode(
+ connectionId=connectionId,
+ service=service,
+ sourceType=sourceType,
+ label=_SERVICE_LABELS.get(service, service),
+ parentKey=parentKey,
+ rec=rec,
+ ))
return nodes
@@ -439,7 +330,7 @@ async def _connectionServices(
# Children of a folder/service -> next-level folders+files via browse
# ---------------------------------------------------------------------------
-async def _browseChildren(
+async def _browseChildNodes(
instanceId: str,
context: Any,
connectionId: str,
@@ -447,8 +338,8 @@ async def _browseChildren(
sourceType: str,
parentPath: str,
allDs: List[Dict[str, Any]],
- parentKey: Optional[str] = None,
-) -> List[Dict[str, Any]]:
+ parentKey: str,
+) -> List[UdbNode]:
from modules.connectors.connectorResolver import ConnectorResolver
from modules.serviceCenter import getService
from modules.serviceCenter.context import ServiceCenterContext
@@ -471,33 +362,22 @@ async def _browseChildren(
logger.error("Tree: cannot browse %s on connection %s path=%s: %s", service, connectionId, parentPath, exc)
return []
- # Children parentKey must equal the key the caller asked for (= the
- # currently-expanded node in the UI). If the caller doesn't pass an
- # explicit key, fall back to the encoded ds-coordinate.
- effectiveParentKey = parentKey if parentKey is not None else _encode("ds", connectionId, sourceType, parentPath)
- nodes: List[Dict[str, Any]] = []
+ nodes: List[UdbNode] = []
for e in entries or []:
path = getattr(e, "path", "") or ""
- kind = "folder" if getattr(e, "isFolder", False) else "file"
- triplet = _effectiveTripletDs(connectionId, sourceType, path, allDs)
+ isFolder = bool(getattr(e, "isFolder", False))
rec = _findDsRecord(allDs, connectionId, sourceType, path)
- nodes.append({
- "key": _encode("ds", connectionId, sourceType, path),
- "kind": kind,
- "parentKey": effectiveParentKey,
- "label": getattr(e, "name", "") or path,
- "icon": kind,
- "hasChildren": kind == "folder",
- "dataSourceId": rec.get("id") if rec else None,
- "modelType": "DataSource" if rec else None,
- **triplet,
- "supportsRag": True,
- "canBeAdded": rec is None,
- "connectionId": connectionId,
- "service": service,
- "sourceType": sourceType,
- "path": path,
- })
+ cls = FolderNode if isFolder else FileNode
+ nodes.append(cls(
+ connectionId=connectionId,
+ service=service,
+ sourceType=sourceType,
+ path=path,
+ label=getattr(e, "name", "") or path,
+ parentKey=parentKey,
+ rec=rec,
+ hasChildren=isFolder,
+ ))
return nodes
@@ -505,13 +385,12 @@ async def _browseChildren(
# Mandate group -> feature connections
# ---------------------------------------------------------------------------
-def _featureConnectionsForMandate(
- instanceId: str,
- context: Any,
+def _featureConnectionsForMandateNodes(
rootIf: Any,
+ context: Any,
mandateId: str,
allFds: List[Dict[str, Any]],
-) -> List[Dict[str, Any]]:
+) -> List[UdbNode]:
from modules.security.rbacCatalog import getCatalogService
userId = str(context.user.id)
@@ -520,7 +399,7 @@ def _featureConnectionsForMandate(
instances = rootIf.getFeatureInstancesByMandate(mandateId)
parentKey = _encode("mgrp", mandateId)
- nodes: List[Dict[str, Any]] = []
+ nodes: List[UdbNode] = []
for inst in instances or []:
if not inst.enabled:
continue
@@ -529,28 +408,18 @@ def _featureConnectionsForMandate(
fa = rootIf.getFeatureAccess(userId, inst.id)
if not fa or not fa.enabled:
continue
- # Effective values come from the FDS workspace-wildcard for this featureInstanceId
- wsId = inst.id
- triplet = _effectiveTripletFds(wsId, "*", None, allFds)
- rec = _findFdsRecord(allFds, wsId, "*", None)
+ rec = _findFdsRecord(allFds, inst.id, "*", None)
featureDef = catalog.getFeatureDefinition(inst.featureCode) or {}
- nodes.append({
- "key": _encode("feat", mandateId, inst.featureCode, inst.id),
- "kind": "featureNode",
- "parentKey": parentKey,
- "label": inst.label or inst.featureCode,
- "icon": featureDef.get("icon", "mdi-database"),
- "hasChildren": True,
- "dataSourceId": rec.get("id") if rec else None,
- "modelType": "FeatureDataSource" if rec else None,
- **triplet,
- "supportsRag": True,
- "canBeAdded": rec is None,
- "featureInstanceId": wsId,
- "featureCode": inst.featureCode,
- "mandateId": mandateId,
- "tableName": "*",
- })
+ node = FdsWorkspaceNode(
+ mandateId=mandateId,
+ featureCode=inst.featureCode,
+ featureInstanceId=inst.id,
+ label=inst.label or inst.featureCode,
+ icon=featureDef.get("icon", "mdi-database"),
+ parentKey=parentKey,
+ rec=rec,
+ )
+ nodes.append(node)
return nodes
@@ -558,14 +427,14 @@ def _featureConnectionsForMandate(
# Feature node -> tables
# ---------------------------------------------------------------------------
-def _featureTables(
+def _featureTableNodes(
context: Any,
rootIf: Any,
parentKey: str,
featureInstanceId: str,
featureCode: str,
allFds: List[Dict[str, Any]],
-) -> List[Dict[str, Any]]:
+) -> List[UdbNode]:
from modules.security.rbacCatalog import getCatalogService
inst = rootIf.getFeatureInstance(featureInstanceId)
@@ -589,7 +458,7 @@ def _featureTables(
accessibleKeys = {obj.get("objectKey", "") for obj in accessible}
- nodes: List[Dict[str, Any]] = []
+ nodes: List[UdbNode] = []
for obj in catalog.getDataObjects(inst.featureCode):
meta = obj.get("meta", {})
if meta.get("wildcard") or meta.get("isGroup"):
@@ -600,88 +469,100 @@ def _featureTables(
tableName = meta.get("table", "")
if not tableName:
continue
- triplet = _effectiveTripletFds(featureInstanceId, tableName, None, allFds)
rec = _findFdsRecord(allFds, featureInstanceId, tableName, None)
fields = meta.get("fields") if isinstance(meta, dict) else None
hasFields = bool(isinstance(fields, list) and len(fields) > 0)
- # Surface the persisted per-field neutralize list so the UI can
- # render & toggle field-level icons without an extra GET.
- neutralizeFields: List[str] = []
- if rec and isinstance(rec.get("neutralizeFields"), list):
- neutralizeFields = [f for f in rec["neutralizeFields"] if isinstance(f, str)]
- nodes.append({
- "key": _encode("fdstbl", featureInstanceId, tableName),
- "kind": "fdsTable",
- "parentKey": parentKey,
- "label": resolveTextSafe(obj.get("label", "")) or tableName,
- "icon": "table",
- # Children = the per-column field nodes. Only emitted when the
- # data-object metadata declared a non-empty `fields` list.
- "hasChildren": hasFields,
- "dataSourceId": rec.get("id") if rec else None,
- "modelType": "FeatureDataSource" if rec else None,
- **triplet,
- "supportsRag": True,
- "canBeAdded": rec is None,
- "featureInstanceId": featureInstanceId,
- "featureCode": featureCode,
- "tableName": tableName,
- "objectKey": objectKey,
- "neutralizeFields": neutralizeFields,
- })
+ tableNode = FdsTableNode(
+ featureInstanceId=featureInstanceId,
+ featureCode=featureCode,
+ tableName=tableName,
+ objectKey=objectKey,
+ label=resolveTextSafe(obj.get("label", "")) or tableName,
+ parentKey=parentKey,
+ rec=rec,
+ hasFields=hasFields,
+ )
+ # Populate logical children (field-level nodes) so the table's
+ # aggregate flag can show 'mixed' when individual columns diverge
+ # from the table-level neutralize value.
+ if hasFields and isinstance(fields, list):
+ for f in fields:
+ if isinstance(f, str) and f:
+ tableNode_field = FdsFieldNode(
+ featureInstanceId=featureInstanceId,
+ tableName=tableName,
+ fieldName=f,
+ parentKey=tableNode.key,
+ tableRec=rec,
+ featureCode=featureCode,
+ )
+ if not hasattr(tableNode, "_logicalFieldChildren"):
+ tableNode._logicalFieldChildren = [] # type: ignore[attr-defined]
+ tableNode._logicalFieldChildren.append(tableNode_field) # type: ignore[attr-defined]
+ _wireTableFieldsAsLogicalChildren(tableNode)
+ nodes.append(tableNode)
return nodes
-def _featureTableFields(
+def _wireTableFieldsAsLogicalChildren(tableNode: FdsTableNode) -> None:
+ """Make `tableNode.getLogicalChildren` return its field nodes so the
+ aggregate computation can detect divergence between table-level
+ neutralize and individual column overrides."""
+ fields = getattr(tableNode, "_logicalFieldChildren", [])
+
+ def _children(_allDs, _allFds, _rootIf, _context, _f=fields):
+ return list(_f)
+
+ tableNode.getLogicalChildren = _children # type: ignore[assignment]
+
+ # Wrap getEffectiveFlag so the table's neutralize aggregates from its
+ # own value AND its field children. scope/rag stay unaffected.
+ origGetEffective = tableNode.getEffectiveFlag
+
+ def _aggGetEffective(flag, allDs, allFds, mode="aggregate", _orig=origGetEffective, _f=fields, _tbl=tableNode):
+ ownValue = _orig(flag, allDs, allFds, mode)
+ if flag != "neutralize" or mode != "aggregate":
+ return ownValue
+ if not _f:
+ return ownValue
+ seen = set()
+ if isinstance(ownValue, str) and ownValue == "mixed":
+ return "mixed"
+ if isinstance(ownValue, bool):
+ seen.add(int(ownValue))
+ for fld in _f:
+ v = fld.getEffectiveFlag("neutralize", allDs, allFds, "walk")
+ if isinstance(v, bool):
+ seen.add(int(v))
+ if len(seen) > 1:
+ return "mixed"
+ return ownValue
+
+ tableNode.getEffectiveFlag = _aggGetEffective # type: ignore[assignment]
+
+
+def _featureTableFieldNodes(
parentKey: str,
featureInstanceId: str,
tableName: str,
fieldNames: List[str],
allFds: List[Dict[str, Any]],
-) -> List[Dict[str, Any]]:
- """Emit one node per declared column of a feature data table.
-
- Per-field neutralize semantics:
- - The table-level FDS record carries `neutralizeFields: List[str]`.
- - A field is "effectively neutralized" iff its name is in that list
- OR the table's effective `neutralize` is True (blanket).
- - Only `neutralize` is meaningful per-field; `scope` and `ragIndexEnabled`
- are inherited from the parent table and not toggleable here.
- """
+) -> List[UdbNode]:
+ """Emit one node per declared column of a feature data table."""
rec = _findFdsRecord(allFds, featureInstanceId, tableName, None)
- tableNeutralize = bool(rec.get("neutralize")) if rec else False
- neutralizeFields = rec.get("neutralizeFields") if rec else None
- if not isinstance(neutralizeFields, list):
- neutralizeFields = []
-
- nodes: List[Dict[str, Any]] = []
+ featureCode = str(rec.get("featureCode") or "") if rec else ""
+ nodes: List[UdbNode] = []
for field in fieldNames:
if not field:
continue
- fieldNeutralized = bool(tableNeutralize or field in neutralizeFields)
- nodes.append({
- "key": _encode("fdsfld", featureInstanceId, tableName, field),
- "kind": "fdsField",
- "parentKey": parentKey,
- "label": field,
- "icon": "field",
- "hasChildren": False,
- "dataSourceId": rec.get("id") if rec else None,
- "modelType": "FeatureDataSource" if rec else None,
- "effectiveNeutralize": fieldNeutralized,
- # Field-level scope/RAG do not exist as a concept; the FE hides
- # those affordances when supportsRag=False. We still need
- # `effectiveScope` + `effectiveRagIndexEnabled` for the
- # contract; they reflect the parent's effective values so the
- # backend stays single source of truth.
- "effectiveScope": "personal",
- "effectiveRagIndexEnabled": False,
- "supportsRag": False,
- "canBeAdded": rec is None,
- "featureInstanceId": featureInstanceId,
- "tableName": tableName,
- "fieldName": field,
- })
+ nodes.append(FdsFieldNode(
+ featureInstanceId=featureInstanceId,
+ tableName=tableName,
+ fieldName=field,
+ parentKey=parentKey,
+ tableRec=rec,
+ featureCode=featureCode,
+ ))
return nodes
@@ -698,16 +579,19 @@ def resolveTextSafe(label: Any) -> str:
# ---------------------------------------------------------------------------
async def getChildrenForParents(
- instanceId: str,
parents: List[Optional[str]],
context: Any,
) -> Dict[str, List[Dict[str, Any]]]:
- """Return per-parent children lists.
+ """Return per-parent children lists serialised to dicts for the wire.
- `parents` is a list with `None` representing the top-level. Order is preserved.
- Returns a dict keyed by parent key (or '__root__' for None).
+ `parents` is a list with `None` representing the top-level. Order is
+ preserved. Returns a dict keyed by parent key (or '__root__' for
+ None). Each child is a fully-rendered TreeNode dict produced by
+ `UdbNode.toDict(...)`.
- Each child is a fully-rendered TreeNode dict (see module docstring for shape).
+ The UDB is feature-agnostic: there is no `instanceId` parameter. The
+ visible scope is determined entirely by the caller's accessible
+ mandates and feature instances.
"""
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelDataSource import DataSource
@@ -715,17 +599,23 @@ async def getChildrenForParents(
rootIf = getRootInterface()
- # Pre-load DS (per user) and FDS (per workspace) once for the whole request.
userId = str(context.user.id)
allDs = rootIf.db.getRecordset(DataSource, recordFilter={"userId": userId}) or []
- allFds = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"workspaceInstanceId": instanceId}) or []
+ # FDS visibility: all FDS in accessible mandates. Restrict by the
+ # context's mandateId when present (workspace caller), otherwise
+ # union of accessible mandates.
+ fdsFilter: Dict[str, Any] = {}
+ wsMandateId = getattr(context, "mandateId", None)
+ if wsMandateId:
+ fdsFilter["mandateId"] = wsMandateId
+ allFds = rootIf.db.getRecordset(FeatureDataSource, recordFilter=fdsFilter) or []
- out: Dict[str, List[Dict[str, Any]]] = {}
+ out: Dict[str, List[UdbNode]] = {}
for parentKey in parents:
if parentKey is None:
try:
- out["__root__"] = _topLevel(instanceId, context, rootIf, allDs, allFds)
+ out["__root__"] = _topLevel(context, rootIf, allDs, allFds)
except Exception as exc:
logger.exception("Tree top-level failed: %s", exc)
out["__root__"] = []
@@ -739,39 +629,40 @@ async def getChildrenForParents(
try:
if parentKey == _KEY_PERSONAL_ROOT:
- out[parentKey] = _personalRootChildren(instanceId, context, allDs)
+ out[parentKey] = _personalRootChildrenNodes(context, allDs)
elif kind == "conn" and len(parts) == 1:
- out[parentKey] = await _connectionServices(instanceId, context, parts[0], allDs)
+ out[parentKey] = await _connectionServiceNodes(_callerInstanceId(context), context, parts[0], allDs)
elif kind == "svc" and len(parts) == 2:
connId, service = parts
sourceType = _SERVICE_TO_SOURCE_TYPE.get(service, service)
- out[parentKey] = await _browseChildren(
- instanceId, context, connId, service, sourceType, "/", allDs,
+ out[parentKey] = await _browseChildNodes(
+ _callerInstanceId(context), context, connId, service, sourceType, "/", allDs,
parentKey=parentKey,
)
- elif kind == "ds" and len(parts) == 3:
- connId, sourceType, path = parts
- # Determine service from sourceType (reverse map)
+ elif kind == "ds" and len(parts) >= 3:
+ connId = parts[0]
+ sourceType = parts[1]
+ path = _KEY_SEP.join(parts[2:])
service = _reverseService(sourceType)
- out[parentKey] = await _browseChildren(
- instanceId, context, connId, service, sourceType, path, allDs,
+ out[parentKey] = await _browseChildNodes(
+ _callerInstanceId(context), context, connId, service, sourceType, path, allDs,
parentKey=parentKey,
)
elif kind == "mgrp" and len(parts) == 1:
- out[parentKey] = _featureConnectionsForMandate(instanceId, context, rootIf, parts[0], allFds)
+ out[parentKey] = _featureConnectionsForMandateNodes(rootIf, context, parts[0], allFds)
elif kind == "feat" and len(parts) == 3:
_mandateId, featureCode, featureInstanceId = parts
- out[parentKey] = _featureTables(context, rootIf, parentKey, featureInstanceId, featureCode, allFds)
+ out[parentKey] = _featureTableNodes(context, rootIf, parentKey, featureInstanceId, featureCode, allFds)
elif kind == "fdstbl" and len(parts) == 2:
featureInstanceId, tableName = parts
fieldNames = _resolveTableFieldNames(featureInstanceId, tableName, rootIf)
- out[parentKey] = _featureTableFields(
+ out[parentKey] = _featureTableFieldNodes(
parentKey, featureInstanceId, tableName, fieldNames, allFds,
)
@@ -781,7 +672,21 @@ async def getChildrenForParents(
logger.exception("Tree children for %s failed: %s", parentKey, exc)
out[parentKey] = []
- return out
+ # Serialize all nodes to dicts via their polymorphic toDict().
+ serialised: Dict[str, List[Dict[str, Any]]] = {}
+ for parentKey, nodes in out.items():
+ serialised[parentKey] = [n.toDict(allDs, allFds) for n in nodes]
+ return serialised
+
+
+def _callerInstanceId(context: Any) -> str:
+ """The UDB is feature-agnostic, but `_browseChildNodes` and
+ `_connectionServiceNodes` need a feature instance id for the
+ ServiceCenterContext (the underlying connector resolver wants one).
+ Use the caller's current feature_instance_id (workspace) when
+ available, else an empty string. The id is NOT used for FDS scoping."""
+ fid = getattr(context, "feature_instance_id", None) or getattr(context, "featureInstanceId", None)
+ return str(fid) if fid else ""
def _reverseService(sourceType: str) -> str:
@@ -808,213 +713,3 @@ def _resolveTableFieldNames(featureInstanceId: str, tableName: str, rootIf: Any)
return [f for f in fields if isinstance(f, str) and f]
return []
return []
-
-
-# ---------------------------------------------------------------------------
-# Attribute-only refresh: given node keys, return current effective values
-# ---------------------------------------------------------------------------
-
-async def getAttributesForKeys(
- instanceId: str,
- keys: List[str],
- context: Any,
-) -> Dict[str, Dict[str, Any]]:
- """Return effective attribute values for a list of node keys.
-
- Used by the frontend after a toggle to refresh only attributes (neutralize,
- scope, ragIndexEnabled) without reloading the tree structure. For container
- nodes (personalRoot, mgrp), aggregates child values and returns 'mixed'
- when children diverge."""
- from modules.interfaces.interfaceDbApp import getRootInterface
- from modules.datamodels.datamodelDataSource import DataSource
- from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
-
- rootIf = getRootInterface()
- userId = str(context.user.id)
- allDs = rootIf.db.getRecordset(DataSource, recordFilter={"userId": userId}) or []
- allFds = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"workspaceInstanceId": instanceId}) or []
-
- result: Dict[str, Dict[str, Any]] = {}
-
- for key in keys:
- try:
- attrs = _resolveAttrsForKey(key, allDs, allFds, instanceId, context, rootIf)
- if attrs is not None:
- result[key] = attrs
- if "mixed" in str(attrs.values()):
- logger.info("getAttributesForKeys key=%s returned MIXED: %s", key, attrs)
- except Exception as exc:
- logger.warning("getAttributesForKeys failed for key=%s: %s", key, exc)
-
- logger.info("getAttributesForKeys: %d keys requested, %d resolved", len(keys), len(result))
- return result
-
-
-def _resolveAttrsForKey(
- key: str,
- allDs: List[Dict[str, Any]],
- allFds: List[Dict[str, Any]],
- instanceId: str,
- context: Any,
- rootIf: Any,
-) -> Optional[Dict[str, Any]]:
- """Resolve effective attributes for a single node key."""
- if key == _KEY_PERSONAL_ROOT:
- return _aggregatePersonalRoot(allDs)
-
- try:
- kind, parts = _decode(key)
- except Exception:
- return None
-
- if kind == "mgrp" and len(parts) == 1:
- return _aggregateMandateGroup(parts[0], allFds, instanceId, context, rootIf)
-
- if kind == "conn" and len(parts) == 1:
- connId = parts[0]
- return _aggregateConnection(connId, allDs)
-
- if kind == "svc" and len(parts) == 2:
- connId, service = parts
- sourceType = _SERVICE_TO_SOURCE_TYPE.get(service, service)
- return _effectiveTripletDs(connId, sourceType, "/", allDs)
-
- if kind == "ds" and len(parts) == 3:
- connId, sourceType, path = parts
- return _effectiveTripletDs(connId, sourceType, path, allDs)
-
- if kind == "feat" and len(parts) == 3:
- _mandateId, _featureCode, featureInstanceId = parts
- return _effectiveTripletFds(featureInstanceId, "*", None, allFds)
-
- if kind == "fdsws" and len(parts) == 2:
- workspaceInstanceId, _featureCode = parts
- return _effectiveTripletFds(workspaceInstanceId, "*", None, allFds)
-
- if kind == "fdstbl" and len(parts) == 2:
- featureInstanceId, tableName = parts
- return _effectiveTripletFds(featureInstanceId, tableName, None, allFds)
-
- if kind == "fdsrec" and len(parts) == 3:
- featureInstanceId, tableName, recordId = parts
- return _effectiveTripletFds(featureInstanceId, tableName, {"objectKey": recordId}, allFds)
-
- if kind == "fdsfld" and len(parts) >= 3:
- featureInstanceId, tableName = parts[0], parts[1]
- fieldName = parts[2] if len(parts) > 2 else ""
- parentFds = None
- for fds in allFds:
- if (fds.get("featureInstanceId") == featureInstanceId
- and (fds.get("tableName") or "") == tableName
- and fds.get("recordFilter") is None):
- parentFds = fds
- break
- neutralizeFields = (parentFds.get("neutralizeFields") or []) if parentFds else []
- return {"effectiveNeutralize": fieldName in neutralizeFields}
-
- return None
-
-
-def _aggregateConnection(connId: str, allDs: List[Dict[str, Any]]) -> Dict[str, Any]:
- """Aggregate effective values for a connection node.
-
- If the connection has an authority-level DS record (path="/"), use the
- standard aggregate mode on it (which already handles subtree correctly).
- Otherwise compute effective values for each child DS using walk mode and
- aggregate them manually."""
- from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
- getEffectiveFlag, _AUTHORITY_SOURCE_TYPES,
- )
- connRecords = [d for d in allDs if d.get("connectionId") == connId]
- if not connRecords:
- return {"effectiveNeutralize": False, "effectiveScope": "personal", "effectiveRagIndexEnabled": False}
-
- rootRec = None
- for r in connRecords:
- st = r.get("sourceType", "")
- if st in _AUTHORITY_SOURCE_TYPES and _normalisePath(r.get("path", "")) == "/":
- rootRec = r
- break
-
- if rootRec:
- return _effectiveTripletDs(connId, rootRec.get("sourceType", ""), "/", allDs)
-
- neutralizeVals = set()
- scopeVals = set()
- ragVals = set()
- for r in connRecords:
- neutralizeVals.add(getEffectiveFlag(r, "neutralize", allDs, mode="walk"))
- scopeVals.add(getEffectiveFlag(r, "scope", allDs, mode="walk"))
- ragVals.add(getEffectiveFlag(r, "ragIndexEnabled", allDs, mode="walk"))
- return {
- "effectiveNeutralize": "mixed" if len(neutralizeVals) > 1 else (neutralizeVals.pop() if neutralizeVals else False),
- "effectiveScope": "mixed" if len(scopeVals) > 1 else (scopeVals.pop() if scopeVals else "personal"),
- "effectiveRagIndexEnabled": "mixed" if len(ragVals) > 1 else (ragVals.pop() if ragVals else False),
- }
-
-
-def _aggregatePersonalRoot(allDs: List[Dict[str, Any]]) -> Dict[str, Any]:
- """Aggregate effective values across all personal DS records.
-
- Uses getEffectiveFlag in aggregate mode on each connection-root record.
- If no root records exist, aggregates walk-effective values of all records."""
- from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
- getEffectiveFlag, _AUTHORITY_SOURCE_TYPES,
- )
- if not allDs:
- return {"effectiveNeutralize": False, "effectiveScope": "personal", "effectiveRagIndexEnabled": False}
-
- rootRecords = [
- d for d in allDs
- if d.get("sourceType", "") in _AUTHORITY_SOURCE_TYPES
- and _normalisePath(d.get("path", "")) == "/"
- ]
- targets = rootRecords if rootRecords else allDs
-
- neutralizeVals = set()
- scopeVals = set()
- ragVals = set()
- for ds in targets:
- neutralizeVals.add(getEffectiveFlag(ds, "neutralize", allDs, mode="aggregate"))
- scopeVals.add(getEffectiveFlag(ds, "scope", allDs, mode="aggregate"))
- ragVals.add(getEffectiveFlag(ds, "ragIndexEnabled", allDs, mode="aggregate"))
- return {
- "effectiveNeutralize": "mixed" if len(neutralizeVals) > 1 else (neutralizeVals.pop() if neutralizeVals else False),
- "effectiveScope": "mixed" if len(scopeVals) > 1 else (scopeVals.pop() if scopeVals else "personal"),
- "effectiveRagIndexEnabled": "mixed" if len(ragVals) > 1 else (ragVals.pop() if ragVals else False),
- }
-
-
-def _aggregateMandateGroup(
- mandateId: str,
- allFds: List[Dict[str, Any]],
- instanceId: str,
- context: Any,
- rootIf: Any,
-) -> Dict[str, Any]:
- """Aggregate effective values across FDS records belonging to this mandate group.
-
- Uses getEffectiveFlagFds in aggregate mode on each workspace-level FDS
- (tableName="*") that belongs to the given mandateId. This correctly resolves
- inherited values from the full FDS hierarchy."""
- from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlagFds
-
- groupFds = [f for f in allFds if f.get("mandateId") == mandateId]
- workspaceLevelFds = [f for f in groupFds if (f.get("tableName") or "") == "*"]
- targets = workspaceLevelFds if workspaceLevelFds else groupFds
-
- if not targets:
- return {"effectiveNeutralize": False, "effectiveScope": "personal", "effectiveRagIndexEnabled": False}
-
- neutralizeVals = set()
- scopeVals = set()
- ragVals = set()
- for fds in targets:
- neutralizeVals.add(getEffectiveFlagFds(fds, "neutralize", allFds, mode="aggregate"))
- scopeVals.add(getEffectiveFlagFds(fds, "scope", allFds, mode="aggregate"))
- ragVals.add(getEffectiveFlagFds(fds, "ragIndexEnabled", allFds, mode="aggregate"))
- return {
- "effectiveNeutralize": "mixed" if len(neutralizeVals) > 1 else (neutralizeVals.pop() if neutralizeVals else False),
- "effectiveScope": "mixed" if len(scopeVals) > 1 else (scopeVals.pop() if scopeVals else "personal"),
- "effectiveRagIndexEnabled": "mixed" if len(ragVals) > 1 else (ragVals.pop() if ragVals else False),
- }
diff --git a/modules/serviceCenter/services/serviceKnowledge/_inheritFlags.py b/modules/serviceCenter/services/serviceKnowledge/_inheritFlags.py
index 64a0019c..b211839f 100644
--- a/modules/serviceCenter/services/serviceKnowledge/_inheritFlags.py
+++ b/modules/serviceCenter/services/serviceKnowledge/_inheritFlags.py
@@ -28,7 +28,7 @@ from typing import Any, Dict, Iterable, List, Literal, Optional, Tuple
logger = logging.getLogger(__name__)
_INHERITABLE_FLAGS = ("neutralize", "ragIndexEnabled", "scope")
-_INHERITABLE_FDS_FLAGS = ("neutralize", "ragIndexEnabled", "scope")
+_INHERITABLE_FDS_FLAGS = ("neutralize", "ragIndexEnabled")
# Connection-root DataSources carry the authority as their sourceType
# (e.g. 'msft', 'google'). They sit one level above all service DataSources
@@ -458,11 +458,11 @@ def cascadeResetDescendantsFds(
raise ValueError(f"Unknown inheritable FDS flag: {flag}")
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
- workspaceInstanceId = _getRecordValue(parentRec, "workspaceInstanceId")
- if not workspaceInstanceId:
+ featureInstanceId = _getRecordValue(parentRec, "featureInstanceId")
+ if not featureInstanceId:
return []
siblings = rootIf.db.getRecordset(
- FeatureDataSource, recordFilter={"workspaceInstanceId": workspaceInstanceId}
+ FeatureDataSource, recordFilter={"featureInstanceId": featureInstanceId}
)
toReset: List[Tuple[int, str]] = []
@@ -475,7 +475,6 @@ def cascadeResetDescendantsFds(
sibId = _getRecordValue(sib, "id")
toReset.append((_fdsDepth(sib), sibId))
- # Sort deepest first (bottom-up)
toReset.sort(key=lambda x: x[0], reverse=True)
resetIds: List[str] = []
@@ -576,9 +575,9 @@ def resolveEffectiveForPath(
"ragIndexEnabled": None,
}
return {
- "effectiveNeutralize": _resolveWalkValue(virtualRec, "neutralize", allDs),
- "effectiveScope": _resolveWalkValue(virtualRec, "scope", allDs),
- "effectiveRagIndexEnabled": _resolveWalkValue(virtualRec, "ragIndexEnabled", allDs),
+ "effectiveNeutralize": getEffectiveFlag(virtualRec, "neutralize", allDs, mode=mode),
+ "effectiveScope": getEffectiveFlag(virtualRec, "scope", allDs, mode=mode),
+ "effectiveRagIndexEnabled": getEffectiveFlag(virtualRec, "ragIndexEnabled", allDs, mode=mode),
}
@@ -591,11 +590,11 @@ def resolveEffectiveForFds(
) -> Dict[str, Any]:
"""Resolve effective flags for ANY FDS tuple (even without DB record).
- `allFds` is pre-scoped to a single workspace (loaded with
- workspaceInstanceId filter). Within that set, the coordinate is
- featureInstanceId + tableName + recordFilter.
+ `allFds` is pre-scoped (typically to a mandate). Within that set, the
+ coordinate is featureInstanceId + tableName + recordFilter.
- Returns dict with effectiveNeutralize, effectiveScope, effectiveRagIndexEnabled.
+ Returns dict with effectiveNeutralize, effectiveRagIndexEnabled.
+ FDS has no `scope` attribute (visibility is governed by feature RBAC).
"""
exactRecord = None
for fds in allFds:
@@ -611,7 +610,6 @@ def resolveEffectiveForFds(
if exactRecord:
return {
"effectiveNeutralize": getEffectiveFlagFds(exactRecord, "neutralize", allFds, mode=mode),
- "effectiveScope": getEffectiveFlagFds(exactRecord, "scope", allFds, mode=mode),
"effectiveRagIndexEnabled": getEffectiveFlagFds(exactRecord, "ragIndexEnabled", allFds, mode=mode),
}
@@ -621,11 +619,9 @@ def resolveEffectiveForFds(
"tableName": tableName,
"recordFilter": recordFilter,
"neutralize": None,
- "scope": None,
"ragIndexEnabled": None,
}
return {
- "effectiveNeutralize": _resolveWalkValueFds(virtualRec, "neutralize", allFds),
- "effectiveScope": _resolveWalkValueFds(virtualRec, "scope", allFds),
- "effectiveRagIndexEnabled": _resolveWalkValueFds(virtualRec, "ragIndexEnabled", allFds),
+ "effectiveNeutralize": getEffectiveFlagFds(virtualRec, "neutralize", allFds, mode=mode),
+ "effectiveRagIndexEnabled": getEffectiveFlagFds(virtualRec, "ragIndexEnabled", allFds, mode=mode),
}
diff --git a/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py b/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py
index aa81d929..e2aba02b 100644
--- a/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py
+++ b/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py
@@ -9,7 +9,7 @@ text, and feeds it through KnowledgeService.requestIngestion so the data
appears in ContentChunk embeddings for semantic RAG search.
Job type: ``feature.bootstrap``
-Payload: ``{"workspaceInstanceId": "...", "featureDataSourceIds": [...] (optional)}``
+Payload: ``{"featureInstanceId": "...", "featureDataSourceIds": [...] (optional)}``
"""
from __future__ import annotations
@@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
FEATURE_BOOTSTRAP_JOB_TYPE = "feature.bootstrap"
-def _loadRagEnabledFds(workspaceInstanceId: str, featureDataSourceIds: Optional[List[str]] = None):
+def _loadRagEnabledFds(featureInstanceId: str, featureDataSourceIds: Optional[List[str]] = None):
"""Load FeatureDataSource rows whose effective ragIndexEnabled is True.
Returns dicts with resolved flags so downstream code can read them directly.
@@ -34,7 +34,7 @@ def _loadRagEnabledFds(workspaceInstanceId: str, featureDataSourceIds: Optional[
rootIf = getRootInterface()
allFds = rootIf.db.getRecordset(
- FeatureDataSource, recordFilter={"workspaceInstanceId": workspaceInstanceId}
+ FeatureDataSource, recordFilter={"featureInstanceId": featureInstanceId}
)
resolved = []
for fds in allFds:
@@ -47,7 +47,6 @@ def _loadRagEnabledFds(workspaceInstanceId: str, featureDataSourceIds: Optional[
continue
row = dict(fds) if isinstance(fds, dict) else {**fds.__dict__}
row["_effectiveNeutralize"] = getEffectiveFlagFds(fds, "neutralize", allFds, mode="aggregate")
- row["_effectiveScope"] = getEffectiveFlagFds(fds, "scope", allFds, mode="aggregate") or "featureInstance"
row["ragIndexEnabled"] = True
resolved.append(row)
@@ -104,20 +103,20 @@ async def _featureBootstrapHandler(
) -> Dict[str, Any]:
"""Walk RAG-enabled FeatureDataSources and index their rows."""
payload = job.get("payload") or {}
- workspaceInstanceId = payload.get("workspaceInstanceId")
+ featureInstanceId = payload.get("featureInstanceId")
featureDataSourceIds = payload.get("featureDataSourceIds")
- if not workspaceInstanceId:
- raise ValueError("feature.bootstrap requires payload.workspaceInstanceId")
+ if not featureInstanceId:
+ raise ValueError("feature.bootstrap requires payload.featureInstanceId")
progressCb(5, messageKey="Feature-Datenquellen werden geladen...")
- fdsList = _loadRagEnabledFds(workspaceInstanceId, featureDataSourceIds)
+ fdsList = _loadRagEnabledFds(featureInstanceId, featureDataSourceIds)
if not fdsList:
logger.info(
- "feature.bootstrap.skipped — no rag-enabled FDS for workspace %s",
- workspaceInstanceId,
+ "feature.bootstrap.skipped — no rag-enabled FDS for feature %s",
+ featureInstanceId,
)
- return {"workspaceInstanceId": workspaceInstanceId, "skipped": True, "reason": "no_rag_enabled_fds"}
+ return {"featureInstanceId": featureInstanceId, "skipped": True, "reason": "no_rag_enabled_fds"}
from modules.serviceCenter.services.serviceAgent.featureDataProvider import FeatureDataProvider
from modules.serviceCenter.services.serviceKnowledge.mainServiceKnowledge import IngestionJob
@@ -134,11 +133,10 @@ async def _featureBootstrapHandler(
fdsId = fds.get("id", "")
featureCode = fds.get("featureCode", "")
tableName = fds.get("tableName", "")
- featureInstanceId = fds.get("featureInstanceId", "")
+ fdsFeatureInstanceId = fds.get("featureInstanceId", "")
mandateId = fds.get("mandateId", "")
neutralizeFields = fds.get("neutralizeFields") or []
recordFilter = fds.get("recordFilter") or {}
- effectiveScope = fds.get("_effectiveScope", "featureInstance")
effectiveNeutralize = bool(fds.get("_effectiveNeutralize", False))
progressPct = 5 + int(90 * fdsIdx / len(fdsList))
@@ -148,7 +146,7 @@ async def _featureBootstrapHandler(
messageParams={"table": tableName, "n": fdsIdx + 1, "total": len(fdsList)},
)
- if not featureCode or not tableName or not featureInstanceId:
+ if not featureCode or not tableName or not fdsFeatureInstanceId:
logger.warning("feature.bootstrap: skipping FDS %s — missing featureCode/tableName/fiId", fdsId)
continue
@@ -160,7 +158,7 @@ async def _featureBootstrapHandler(
ctx = ServiceCenterContext(
user=rootUser,
mandate_id=mandateId,
- feature_instance_id=workspaceInstanceId,
+ feature_instance_id=fdsFeatureInstanceId,
)
knowledgeService = getService("knowledge", ctx)
@@ -178,7 +176,7 @@ async def _featureBootstrapHandler(
while True:
result = provider.browseTable(
tableName=tableName,
- featureInstanceId=featureInstanceId,
+ featureInstanceId=fdsFeatureInstanceId,
mandateId=mandateId,
limit=batchSize,
offset=offset,
@@ -202,11 +200,11 @@ async def _featureBootstrapHandler(
ingestionJob = IngestionJob(
sourceKind="feature_record",
- sourceId=f"{workspaceInstanceId}:{tableName}:{rowId}",
+ sourceId=f"{fdsFeatureInstanceId}:{tableName}:{rowId}",
fileName=f"{tableName}-{rowId}",
mimeType="application/vnd.poweron.feature-record+json",
- userId=fds.get("userId") or "system",
- featureInstanceId=workspaceInstanceId,
+ userId="system",
+ featureInstanceId=fdsFeatureInstanceId,
mandateId=mandateId,
contentObjects=[{
"contentType": "text",
@@ -214,7 +212,7 @@ async def _featureBootstrapHandler(
"contextRef": {
"table": tableName,
"featureCode": featureCode,
- "featureInstanceId": featureInstanceId,
+ "featureInstanceId": fdsFeatureInstanceId,
"rowId": rowId,
},
"contentObjectId": f"{tableName}:{rowId}",
@@ -225,7 +223,7 @@ async def _featureBootstrapHandler(
"featureDataSourceId": fdsId,
"tableName": tableName,
"featureCode": featureCode,
- "featureInstanceId": featureInstanceId,
+ "featureInstanceId": fdsFeatureInstanceId,
},
neutralize=effectiveNeutralize,
)
@@ -281,7 +279,7 @@ async def _featureBootstrapHandler(
progressCb(100, messageKey="Feature-Daten-Sync abgeschlossen.")
return {
- "workspaceInstanceId": workspaceInstanceId,
+ "featureInstanceId": featureInstanceId,
"indexed": totalIndexed,
"skippedDuplicate": totalSkipped,
"failed": totalFailed,
diff --git a/modules/serviceCenter/services/serviceKnowledge/udbNodes.py b/modules/serviceCenter/services/serviceKnowledge/udbNodes.py
new file mode 100644
index 00000000..2d12554e
--- /dev/null
+++ b/modules/serviceCenter/services/serviceKnowledge/udbNodes.py
@@ -0,0 +1,1055 @@
+# Copyright (c) 2026 Patrick Motsch
+# All rights reserved.
+"""Polymorphic UdbNode hierarchy for the Unified Data Bar.
+
+Each UDB tree node is represented by an `UdbNode` subclass that encapsulates
+its own behavior:
+
+ - which flags it supports (`supportsFlag`)
+ - whether the current user may edit it (`canEdit`)
+ - how to compute an effective flag value (`getEffectiveFlag`)
+ - how to persist a flag change (`setFlag`)
+ - how to enumerate its logical children for aggregation
+ (`getLogicalChildren`)
+ - how to render itself to a JSON dict (`toDict`)
+
+Concrete subclasses fall into four families that mirror the UDB domain
+model (see wiki/b-reference/platform/unified-data-bar.md):
+
+ - SyntheticContainerNode -- structural containers, no DB record
+ - DataSourceNode (+children)-- user-private DataSource records
+ - FdsRecordNode (+children)-- feature-owned FeatureDataSource records
+ - FdsFieldNode -- virtual per-column nodes under fdsTable
+
+The classes use `_inheritFlags.py` as a helper module for the actual
+walk/aggregate/cascade arithmetic, so the inheritance semantics live in
+one place. The classes themselves only express "what does this node type
+DO" -- ownership, RBAC, persistence routing, child enumeration.
+"""
+
+from __future__ import annotations
+
+import logging
+from abc import ABC, abstractmethod
+from typing import Any, Dict, List, Optional, Tuple
+
+logger = logging.getLogger(__name__)
+
+
+_KEY_SEP = "|"
+
+
+def _decode(key: str) -> Tuple[str, List[str]]:
+ """Decode a UDB tree key into (kind, [parts...])."""
+ parts = key.split(_KEY_SEP)
+ return parts[0], parts[1:]
+
+
+def _encode(kind: str, *parts: str) -> str:
+ """Encode kind + parts into a stable tree key."""
+ return _KEY_SEP.join((kind, *parts))
+
+
+# ---------------------------------------------------------------------------
+# Abstract base
+# ---------------------------------------------------------------------------
+
+class UdbNode(ABC):
+ """Polymorphic UDB tree node.
+
+ Subclasses MUST implement the abstract methods. Defaults are kept
+ minimal so each subclass's responsibilities are explicit.
+
+ `parentKey` is set by the builder when the node is emitted; it is the
+ tree key of the directly-rendered parent (which may differ from the
+ semantic ancestor used for flag inheritance).
+ """
+
+ kind: str = "abstract"
+
+ def __init__(self, key: str, label: str, parentKey: Optional[str] = None) -> None:
+ self.key = key
+ self.label = label
+ self.parentKey = parentKey
+
+ # --- domain hooks -------------------------------------------------------
+
+ def supportsFlag(self, flag: str) -> bool:
+ """Whether this node carries a value for `flag` at all.
+
+ Subclasses override to restrict (e.g. FDS has no scope; fdsField
+ only has neutralize).
+ """
+ return flag in ("neutralize", "scope", "ragIndexEnabled")
+
+ @abstractmethod
+ def canEdit(self, context: Any, rootIf: Any) -> bool:
+ """Permission check: may the calling user mutate flags on this node?
+
+ Polymorph rule:
+ - DataSource* nodes: owner-of-record check (rec.userId == user).
+ - FdsRecord* / FdsField nodes: feature-admin check on the FDS's
+ featureInstanceId.
+ - Synthetic containers: never editable (defensive).
+ """
+
+ @abstractmethod
+ def getEffectiveFlag(self, flag: str, allDs: List[Dict[str, Any]],
+ allFds: List[Dict[str, Any]], mode: str = "aggregate") -> Any:
+ """Compute the effective value of `flag` for this node.
+
+ `mode='walk'` returns the concrete inherited value (never 'mixed').
+ `mode='aggregate'` returns 'mixed' iff the logical subtree disagrees.
+ Synthetic containers compute aggregate from their logical children.
+ """
+
+ def setFlag(self, flag: str, value: Any, rootIf: Any) -> List[str]:
+ """Persist a new value for `flag`. Default: not supported.
+
+ Returns the IDs of descendant records reset to None (cascade).
+ Subclasses that own a DB record override; synthetic containers and
+ virtual nodes refuse.
+ """
+ raise NotImplementedError(
+ f"{type(self).__name__} does not support setFlag({flag!r})"
+ )
+
+ def getLogicalChildren(self, allDs: List[Dict[str, Any]],
+ allFds: List[Dict[str, Any]],
+ rootIf: Any, context: Any) -> List["UdbNode"]:
+ """Return the children used for aggregate flag computation.
+
+ These are NOT the rendered tree children -- they are the logical
+ descendants whose effective values determine whether this node is
+ 'mixed'. Default: empty (leaf).
+ """
+ return []
+
+ @abstractmethod
+ def toDict(self, allDs: List[Dict[str, Any]],
+ allFds: List[Dict[str, Any]]) -> Dict[str, Any]:
+ """Serialize to the dict shape consumed by the frontend tree.
+
+ Implementations include effective flag values for the flags this
+ node supports, plus rendering hints (icon, hasChildren, etc.).
+ """
+
+
+# ---------------------------------------------------------------------------
+# Synthetic containers
+# ---------------------------------------------------------------------------
+
+class SyntheticContainerNode(UdbNode):
+ """Structural container with no DB record (personalRoot, mandateGroup).
+
+ Aggregates the effective flag values from its logical children for
+ display only; cannot be edited.
+ """
+
+ kind = "synthRoot"
+
+ def __init__(self, key: str, label: str, icon: str,
+ parentKey: Optional[str] = None,
+ displayOrder: int = 0,
+ defaultExpanded: bool = False,
+ logicalChildren: Optional[List[UdbNode]] = None) -> None:
+ super().__init__(key, label, parentKey)
+ self.icon = icon
+ self.displayOrder = displayOrder
+ self.defaultExpanded = defaultExpanded
+ self._logicalChildren: List[UdbNode] = list(logicalChildren or [])
+
+ def addLogicalChild(self, child: UdbNode) -> None:
+ self._logicalChildren.append(child)
+
+ def supportsFlag(self, flag: str) -> bool:
+ return False
+
+ def canEdit(self, context: Any, rootIf: Any) -> bool:
+ return False
+
+ def getLogicalChildren(self, allDs, allFds, rootIf, context) -> List[UdbNode]:
+ return list(self._logicalChildren)
+
+ def getEffectiveFlag(self, flag, allDs, allFds, mode="aggregate") -> Any:
+ return _aggregateFromChildren(self, flag, allDs, allFds, mode)
+
+ def toDict(self, allDs, allFds) -> Dict[str, Any]:
+ return {
+ "key": self.key,
+ "kind": "synthRoot" if self.key != _KEY_MANDATE_GROUP_PREFIX else self.kind,
+ "parentKey": self.parentKey,
+ "label": self.label,
+ "icon": self.icon,
+ "hasChildren": True,
+ "dataSourceId": None,
+ "modelType": None,
+ "effectiveNeutralize": self.getEffectiveFlag("neutralize", allDs, allFds, "aggregate"),
+ "effectiveScope": self.getEffectiveFlag("scope", allDs, allFds, "aggregate") or "personal",
+ "effectiveRagIndexEnabled": self.getEffectiveFlag("ragIndexEnabled", allDs, allFds, "aggregate"),
+ "supportsRag": False,
+ "canBeAdded": False,
+ "displayOrder": self.displayOrder,
+ "defaultExpanded": self.defaultExpanded,
+ }
+
+
+_KEY_MANDATE_GROUP_PREFIX = "mgrp"
+
+
+class MandateGroupNode(SyntheticContainerNode):
+ """Synthetic top-level container, one per accessible mandate."""
+
+ kind = "mandateGroup"
+
+ def __init__(self, mandateId: str, label: str) -> None:
+ super().__init__(
+ key=_encode("mgrp", mandateId),
+ label=label,
+ icon="mandate",
+ parentKey=None,
+ defaultExpanded=True,
+ )
+ self.mandateId = mandateId
+
+ def toDict(self, allDs, allFds) -> Dict[str, Any]:
+ out = super().toDict(allDs, allFds)
+ out["kind"] = "mandateGroup"
+ out["mandateId"] = self.mandateId
+ return out
+
+
+# ---------------------------------------------------------------------------
+# DataSource family
+# ---------------------------------------------------------------------------
+
+class _DataSourceFamilyNode(UdbNode):
+ """Shared behavior for DS-backed nodes (connection, service, folder, file).
+
+ A node either has an existing DataSource record (`rec`) or is "virtual"
+ -- a coordinate (connectionId/sourceType/path) that the user has not
+ yet pinned. Virtual nodes can still report effective flag values (they
+ walk the ancestor chain) but cannot be edited until the record exists.
+ """
+
+ def __init__(self, key: str, label: str, parentKey: Optional[str],
+ connectionId: str, sourceType: str, path: str,
+ rec: Optional[Dict[str, Any]]) -> None:
+ super().__init__(key, label, parentKey)
+ self.connectionId = connectionId
+ self.sourceType = sourceType
+ self.path = path
+ self.rec = rec
+
+ def canEdit(self, context: Any, rootIf: Any) -> bool:
+ if self.rec:
+ ownerId = str(self.rec.get("userId") or "")
+ return ownerId == str(context.user.id)
+ return _isConnectionOwner(rootIf, str(context.user.id), self.connectionId)
+
+ def getEffectiveFlag(self, flag, allDs, allFds, mode="aggregate") -> Any:
+ from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
+ resolveEffectiveForPath,
+ )
+ out = resolveEffectiveForPath(self.connectionId, self.sourceType, self.path, allDs, mode=mode)
+ key = "effective" + flag[0].upper() + flag[1:]
+ return out.get(key, False if flag != "scope" else "personal")
+
+ def setFlag(self, flag, value, rootIf) -> List[str]:
+ from modules.datamodels.datamodelDataSource import DataSource
+ from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
+ cascadeResetDescendants,
+ )
+ if not self.rec:
+ self.rec = _findOrCreateDs(
+ rootIf, self.connectionId, self.sourceType, self.path,
+ )
+ sourceId = self.rec.get("id")
+ resetIds: List[str] = []
+ if value is not None:
+ resetIds = cascadeResetDescendants(rootIf, self.rec, flag)
+ rootIf.db.recordModify(DataSource, sourceId, {flag: value})
+ return resetIds
+
+
+class ConnectionNode(_DataSourceFamilyNode):
+ """Connection-root DataSource node (path='/', authority sourceType)."""
+
+ kind = "connection"
+
+ def __init__(self, connectionId: str, authority: str, label: str,
+ parentKey: str, rec: Optional[Dict[str, Any]]) -> None:
+ super().__init__(
+ key=_encode("conn", connectionId),
+ label=label,
+ parentKey=parentKey,
+ connectionId=connectionId,
+ sourceType=str(authority),
+ path="/",
+ rec=rec,
+ )
+ self.authority = authority
+
+ def toDict(self, allDs, allFds) -> Dict[str, Any]:
+ return _dsDict(self, allDs)
+
+
+class ServiceNode(_DataSourceFamilyNode):
+ """Service-level node (e.g. sharepoint, drive) under a Connection."""
+
+ kind = "service"
+
+ def __init__(self, connectionId: str, service: str, sourceType: str,
+ label: str, parentKey: str, rec: Optional[Dict[str, Any]]) -> None:
+ super().__init__(
+ key=_encode("svc", connectionId, service),
+ label=label,
+ parentKey=parentKey,
+ connectionId=connectionId,
+ sourceType=sourceType,
+ path="/",
+ rec=rec,
+ )
+ self.service = service
+
+ def toDict(self, allDs, allFds) -> Dict[str, Any]:
+ out = _dsDict(self, allDs)
+ out["service"] = self.service
+ return out
+
+
+class _BrowseNode(_DataSourceFamilyNode):
+ """Folder/File node from a connector browse() result."""
+
+ def __init__(self, kind: str, connectionId: str, service: str,
+ sourceType: str, path: str, label: str,
+ parentKey: str, rec: Optional[Dict[str, Any]],
+ hasChildren: bool) -> None:
+ super().__init__(
+ key=_encode("ds", connectionId, sourceType, path),
+ label=label,
+ parentKey=parentKey,
+ connectionId=connectionId,
+ sourceType=sourceType,
+ path=path,
+ rec=rec,
+ )
+ self.kind = kind
+ self.service = service
+ self._hasChildren = hasChildren
+
+ def toDict(self, allDs, allFds) -> Dict[str, Any]:
+ out = _dsDict(self, allDs)
+ out["service"] = self.service
+ out["hasChildren"] = self._hasChildren
+ return out
+
+
+class FolderNode(_BrowseNode):
+ """Folder DataSource node from connector browse()."""
+
+ def __init__(self, **kwargs) -> None:
+ super().__init__(kind="folder", **kwargs)
+
+
+class FileNode(_BrowseNode):
+ """File DataSource node from connector browse()."""
+
+ def __init__(self, **kwargs) -> None:
+ super().__init__(kind="file", **kwargs)
+
+
+def _dsDict(node: _DataSourceFamilyNode, allDs: List[Dict[str, Any]]) -> Dict[str, Any]:
+ """Shared serialization for DS-family nodes."""
+ return {
+ "key": node.key,
+ "kind": node.kind,
+ "parentKey": node.parentKey,
+ "label": node.label,
+ "icon": getattr(node, "authority", None) or getattr(node, "service", None) or node.kind,
+ "hasChildren": True,
+ "dataSourceId": node.rec.get("id") if node.rec else None,
+ "modelType": "DataSource" if node.rec else None,
+ "effectiveNeutralize": node.getEffectiveFlag("neutralize", allDs, [], "aggregate"),
+ "effectiveScope": node.getEffectiveFlag("scope", allDs, [], "aggregate"),
+ "effectiveRagIndexEnabled": node.getEffectiveFlag("ragIndexEnabled", allDs, [], "aggregate"),
+ "supportsRag": True,
+ "canBeAdded": node.rec is None,
+ "connectionId": node.connectionId,
+ "sourceType": node.sourceType,
+ "path": node.path,
+ "authority": getattr(node, "authority", None),
+ }
+
+
+# ---------------------------------------------------------------------------
+# FDS family
+# ---------------------------------------------------------------------------
+
+class _FdsFamilyNode(UdbNode):
+ """Shared behavior for FDS-backed nodes (featureNode/fdsTable/fdsRecord).
+
+ FDS has no `scope` attribute (visibility is feature RBAC). Edit
+ permission requires the user to hold a feature-admin role on the
+ FDS's `featureInstanceId`.
+ """
+
+ def __init__(self, key: str, label: str, parentKey: Optional[str],
+ featureInstanceId: str, tableName: str,
+ recordFilter: Optional[Dict[str, str]],
+ rec: Optional[Dict[str, Any]]) -> None:
+ super().__init__(key, label, parentKey)
+ self.featureInstanceId = featureInstanceId
+ self.tableName = tableName
+ self.recordFilter = recordFilter
+ self.rec = rec
+
+ def supportsFlag(self, flag: str) -> bool:
+ return flag in ("neutralize", "ragIndexEnabled")
+
+ def canEdit(self, context: Any, rootIf: Any) -> bool:
+ return _isFeatureAdmin(rootIf, str(context.user.id), self.featureInstanceId)
+
+ def getEffectiveFlag(self, flag, allDs, allFds, mode="aggregate") -> Any:
+ if not self.supportsFlag(flag):
+ return None
+ from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
+ resolveEffectiveForFds,
+ )
+ out = resolveEffectiveForFds(self.featureInstanceId, self.tableName,
+ self.recordFilter, allFds, mode=mode)
+ key = "effective" + flag[0].upper() + flag[1:]
+ return out.get(key, False)
+
+ def setFlag(self, flag, value, rootIf) -> List[str]:
+ if not self.supportsFlag(flag):
+ raise ValueError(f"FDS does not support flag {flag!r}")
+ from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
+ from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
+ cascadeResetDescendantsFds,
+ )
+ if not self.rec:
+ raise RuntimeError(
+ f"Cannot setFlag on virtual FDS node {self.key}: "
+ "create the FeatureDataSource record first."
+ )
+ sourceId = self.rec.get("id")
+ resetIds: List[str] = []
+ if value is not None:
+ resetIds = cascadeResetDescendantsFds(rootIf, self.rec, flag)
+ modifyFields: Dict[str, Any] = {flag: value}
+ self._onSetFlag(modifyFields, flag, value, rootIf)
+ rootIf.db.recordModify(FeatureDataSource, sourceId, modifyFields)
+ return resetIds
+
+ def _onSetFlag(self, modifyFields: Dict[str, Any], flag: str,
+ value: Any, rootIf: Any) -> None:
+ """Subclass hook: extend `modifyFields` or perform side-effects."""
+ pass
+
+
+class FdsWorkspaceNode(_FdsFamilyNode):
+ """Synthetic feature-wildcard FDS node (tableName='*').
+
+ Rendered as 'featureNode' in the tree; one per accessible feature
+ instance under its mandate group.
+ """
+
+ kind = "featureNode"
+
+ def __init__(self, mandateId: str, featureCode: str, featureInstanceId: str,
+ label: str, icon: str, parentKey: str,
+ rec: Optional[Dict[str, Any]]) -> None:
+ super().__init__(
+ key=_encode("feat", mandateId, featureCode, featureInstanceId),
+ label=label,
+ parentKey=parentKey,
+ featureInstanceId=featureInstanceId,
+ tableName="*",
+ recordFilter=None,
+ rec=rec,
+ )
+ self.mandateId = mandateId
+ self.featureCode = featureCode
+ self.icon = icon
+
+ def _onSetFlag(self, modifyFields, flag, value, rootIf):
+ if flag == "neutralize" and value is not None:
+ self._clearDescendantNeutralizeFields(rootIf)
+
+ def _clearDescendantNeutralizeFields(self, rootIf):
+ """Wipe `neutralizeFields` on all descendant table FDS records.
+
+ When the workspace sets an explicit neutralize, per-column
+ overrides on descendant tables become obsolete — the workspace
+ value cascades down via inheritance. Without clearing them the
+ table aggregate stays 'mixed' because some field children still
+ read True from the list while others inherit the new value.
+ """
+ from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
+ allFds = rootIf.db.getRecordset(
+ FeatureDataSource,
+ recordFilter={"featureInstanceId": self.featureInstanceId},
+ ) or []
+ ownId = self.rec.get("id") if self.rec else None
+ for fds in allFds:
+ fdsId = fds.get("id")
+ if fdsId == ownId:
+ continue
+ nf = fds.get("neutralizeFields")
+ if isinstance(nf, list) and len(nf) > 0:
+ rootIf.db.recordModify(
+ FeatureDataSource, fdsId, {"neutralizeFields": None},
+ )
+
+ def toDict(self, allDs, allFds) -> Dict[str, Any]:
+ return {
+ "key": self.key,
+ "kind": "featureNode",
+ "parentKey": self.parentKey,
+ "label": self.label,
+ "icon": self.icon,
+ "hasChildren": True,
+ "dataSourceId": self.rec.get("id") if self.rec else None,
+ "modelType": "FeatureDataSource" if self.rec else None,
+ "effectiveNeutralize": self.getEffectiveFlag("neutralize", allDs, allFds, "aggregate"),
+ "effectiveScope": "personal",
+ "effectiveRagIndexEnabled": self.getEffectiveFlag("ragIndexEnabled", allDs, allFds, "aggregate"),
+ "supportsRag": True,
+ "canBeAdded": self.rec is None,
+ "featureInstanceId": self.featureInstanceId,
+ "featureCode": self.featureCode,
+ "mandateId": self.mandateId,
+ "tableName": "*",
+ }
+
+
+class FdsTableNode(_FdsFamilyNode):
+ """Table-level FDS node (concrete tableName, no recordFilter)."""
+
+ kind = "fdsTable"
+
+ def __init__(self, featureInstanceId: str, featureCode: str, tableName: str,
+ objectKey: str, label: str, parentKey: str,
+ rec: Optional[Dict[str, Any]], hasFields: bool) -> None:
+ super().__init__(
+ key=_encode("fdstbl", featureInstanceId, tableName),
+ label=label,
+ parentKey=parentKey,
+ featureInstanceId=featureInstanceId,
+ tableName=tableName,
+ recordFilter=None,
+ rec=rec,
+ )
+ self.featureCode = featureCode
+ self.objectKey = objectKey
+ self._hasFields = hasFields
+
+ def _onSetFlag(self, modifyFields, flag, value, rootIf):
+ if flag == "neutralize" and value is not None:
+ modifyFields["neutralizeFields"] = None
+
+ def toDict(self, allDs, allFds) -> Dict[str, Any]:
+ neutralizeFields: List[str] = []
+ if self.rec and isinstance(self.rec.get("neutralizeFields"), list):
+ neutralizeFields = [f for f in self.rec["neutralizeFields"] if isinstance(f, str)]
+ return {
+ "key": self.key,
+ "kind": "fdsTable",
+ "parentKey": self.parentKey,
+ "label": self.label,
+ "icon": "table",
+ "hasChildren": self._hasFields,
+ "dataSourceId": self.rec.get("id") if self.rec else None,
+ "modelType": "FeatureDataSource" if self.rec else None,
+ "effectiveNeutralize": self.getEffectiveFlag("neutralize", allDs, allFds, "aggregate"),
+ "effectiveScope": "personal",
+ "effectiveRagIndexEnabled": self.getEffectiveFlag("ragIndexEnabled", allDs, allFds, "aggregate"),
+ "supportsRag": True,
+ "canBeAdded": self.rec is None,
+ "featureInstanceId": self.featureInstanceId,
+ "featureCode": self.featureCode,
+ "tableName": self.tableName,
+ "objectKey": self.objectKey,
+ "neutralizeFields": neutralizeFields,
+ }
+
+
+class FdsRowNode(_FdsFamilyNode):
+ """Row-level FDS node (recordFilter pins specific rows)."""
+
+ kind = "fdsRecord"
+
+ def __init__(self, featureInstanceId: str, tableName: str, recordId: str,
+ recordFilter: Dict[str, str], label: str, parentKey: str,
+ rec: Optional[Dict[str, Any]]) -> None:
+ super().__init__(
+ key=_encode("fdsrec", featureInstanceId, tableName, recordId),
+ label=label,
+ parentKey=parentKey,
+ featureInstanceId=featureInstanceId,
+ tableName=tableName,
+ recordFilter=recordFilter,
+ rec=rec,
+ )
+
+ def toDict(self, allDs, allFds) -> Dict[str, Any]:
+ return {
+ "key": self.key,
+ "kind": "fdsRecord",
+ "parentKey": self.parentKey,
+ "label": self.label,
+ "icon": "row",
+ "hasChildren": False,
+ "dataSourceId": self.rec.get("id") if self.rec else None,
+ "modelType": "FeatureDataSource" if self.rec else None,
+ "effectiveNeutralize": self.getEffectiveFlag("neutralize", allDs, allFds, "aggregate"),
+ "effectiveScope": "personal",
+ "effectiveRagIndexEnabled": self.getEffectiveFlag("ragIndexEnabled", allDs, allFds, "aggregate"),
+ "supportsRag": True,
+ "canBeAdded": self.rec is None,
+ "featureInstanceId": self.featureInstanceId,
+ "tableName": self.tableName,
+ }
+
+
+class FdsFieldNode(UdbNode):
+ """Per-column virtual node under an FdsTableNode.
+
+ Has no DB record of its own. The 'neutralize' state is two-source:
+
+ 1. `field in tableRec.neutralizeFields` -> explicit override -> True.
+ 2. otherwise -> field INHERITS the effective neutralize of its
+ table (which itself walks the FDS ancestor chain up to the
+ workspace wildcard).
+
+ Setting the flag toggles the field's membership in
+ `tableRec.neutralizeFields` only; it never writes a dedicated record.
+
+ Supports only the `neutralize` flag; scope and rag do not exist
+ field-level. The per-list mechanic intentionally cannot express
+ "explicit False"; an explicit table value covers all fields equally
+ via inheritance (matching the cascade-reset semantics in
+ `_FdsFamilyNode.setFlag`, which wipes `neutralizeFields` when the
+ table's neutralize is set explicitly).
+ """
+
+ kind = "fdsField"
+
+ def __init__(self, featureInstanceId: str, tableName: str, fieldName: str,
+ parentKey: str, tableRec: Optional[Dict[str, Any]],
+ featureCode: str = "") -> None:
+ super().__init__(
+ key=_encode("fdsfld", featureInstanceId, tableName, fieldName),
+ label=fieldName,
+ parentKey=parentKey,
+ )
+ self.featureInstanceId = featureInstanceId
+ self.tableName = tableName
+ self.fieldName = fieldName
+ self.featureCode = featureCode
+ self.tableRec = tableRec
+
+ def supportsFlag(self, flag: str) -> bool:
+ return flag == "neutralize"
+
+ def canEdit(self, context: Any, rootIf: Any) -> bool:
+ return _isFeatureAdmin(rootIf, str(context.user.id), self.featureInstanceId)
+
+ def getEffectiveFlag(self, flag, allDs, allFds, mode="aggregate") -> Any:
+ if flag != "neutralize":
+ return False
+ rec = self.tableRec or _findFdsByCoord(allFds, self.featureInstanceId, self.tableName, None)
+ fields = rec.get("neutralizeFields") if rec else None
+ if isinstance(fields, list) and self.fieldName in fields:
+ return True
+ # Not explicitly overridden -> inherit from the table's effective
+ # neutralize. Use walk mode so the inherited value is concrete
+ # (never 'mixed'); a single field cannot itself be ambiguous.
+ from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
+ resolveEffectiveForFds,
+ )
+ out = resolveEffectiveForFds(
+ self.featureInstanceId, self.tableName, None, allFds, mode="walk",
+ )
+ value = out.get("effectiveNeutralize", False)
+ return bool(value) if isinstance(value, bool) else False
+
+ def setFlag(self, flag, value, rootIf) -> List[str]:
+ if flag != "neutralize":
+ raise ValueError(f"FdsFieldNode does not support flag {flag!r}")
+ from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
+ # Resolve or auto-create the underlying table-record FDS so we
+ # have somewhere to persist the neutralizeFields entry.
+ rec = self.tableRec
+ if not rec:
+ rec = _findOrCreateTableFds(rootIf, self.featureInstanceId, self.tableName,
+ self.featureCode, self.fieldName)
+ recId = rec.get("id")
+ currentFields = rec.get("neutralizeFields") if isinstance(rec.get("neutralizeFields"), list) else []
+ fields = set(currentFields)
+ if value:
+ fields.add(self.fieldName)
+ else:
+ fields.discard(self.fieldName)
+ newList = sorted(fields)
+ rootIf.db.recordModify(FeatureDataSource, recId, {
+ "neutralizeFields": newList if newList else None,
+ })
+ # Update the in-memory record so subsequent reads see the change.
+ rec["neutralizeFields"] = newList if newList else None
+ self.tableRec = rec
+ return []
+
+ def toDict(self, allDs, allFds) -> Dict[str, Any]:
+ rec = self.tableRec or _findFdsByCoord(allFds, self.featureInstanceId, self.tableName, None)
+ return {
+ "key": self.key,
+ "kind": "fdsField",
+ "parentKey": self.parentKey,
+ "label": self.label,
+ "icon": "field",
+ "hasChildren": False,
+ "dataSourceId": rec.get("id") if rec else None,
+ "modelType": "FeatureDataSource" if rec else None,
+ "effectiveNeutralize": self.getEffectiveFlag("neutralize", allDs, allFds, "aggregate"),
+ "effectiveScope": "personal",
+ "effectiveRagIndexEnabled": False,
+ "supportsRag": False,
+ "canBeAdded": rec is None,
+ "featureInstanceId": self.featureInstanceId,
+ "tableName": self.tableName,
+ "fieldName": self.fieldName,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Cross-cutting helpers
+# ---------------------------------------------------------------------------
+
+def _isConnectionOwner(rootIf: Any, userId: str, connectionId: str) -> bool:
+ """Return True iff the UserConnection belongs to this user."""
+ try:
+ from modules.datamodels.datamodelUam import UserConnection
+ conn = rootIf.db.getRecord(UserConnection, connectionId)
+ if not conn:
+ return False
+ return str(conn.get("userId") or "") == userId
+ except Exception:
+ return False
+
+
+def _findOrCreateDs(rootIf: Any, connectionId: str, sourceType: str,
+ path: str) -> Dict[str, Any]:
+ """Look up a DataSource by coordinate. Create a stub if missing.
+
+ Analogous to `_findOrCreateTableFds` for FDS fields: the user
+ clicks toggle on a browse-discovered folder that has no DataSource
+ record yet. Instead of returning 403, we auto-create the record
+ so the flag can be persisted.
+ """
+ from modules.datamodels.datamodelDataSource import DataSource
+ from modules.datamodels.datamodelUam import UserConnection
+ from modules.serviceCenter.services.serviceKnowledge._inheritFlags import _normalisePath
+
+ normPath = _normalisePath(path)
+
+ existing = rootIf.db.getRecordset(DataSource, recordFilter={
+ "connectionId": connectionId, "sourceType": sourceType,
+ }) or []
+ for rec in existing:
+ if _normalisePath(rec.get("path")) == normPath:
+ return rec
+
+ conn = rootIf.db.getRecord(UserConnection, connectionId)
+ if not conn:
+ raise RuntimeError(f"UserConnection {connectionId} not found")
+ userId = str(conn.get("userId") or "")
+ mandateId = ""
+ for rec in existing:
+ mid = rec.get("mandateId")
+ if mid:
+ mandateId = str(mid)
+ break
+
+ pathLabel = normPath.rsplit("/", 1)[-1] or normPath
+ stub = DataSource(
+ connectionId=connectionId,
+ sourceType=sourceType,
+ path=normPath,
+ label=pathLabel,
+ displayPath=normPath,
+ userId=userId,
+ mandateId=mandateId,
+ )
+ created = rootIf.db.recordCreate(DataSource, stub.model_dump())
+ if isinstance(created, dict):
+ return created
+ return stub.model_dump()
+
+
+def _isFeatureAdmin(rootIf: Any, userId: str, featureInstanceId: str) -> bool:
+ """Return True iff the user holds a `*-admin` role on this feature instance.
+
+ Convention: feature-specific admin role labels end with `-admin`
+ (e.g. `workspace-admin`, `automation-admin`). See `.cursor/rules/
+ rbac-role-separation.mdc`.
+ """
+ try:
+ access = rootIf.getFeatureAccess(userId, featureInstanceId)
+ if not access or not getattr(access, "enabled", True):
+ return False
+ accessId = getattr(access, "id", None) or (access.get("id") if isinstance(access, dict) else None)
+ if not accessId:
+ return False
+ roleIds = rootIf.getRoleIdsForFeatureAccess(accessId) or []
+ if not roleIds:
+ return False
+ from modules.datamodels.datamodelRbac import Role
+ for rid in roleIds:
+ rec = rootIf.db.getRecord(Role, rid)
+ if not rec:
+ continue
+ label = str(rec.get("roleLabel") or "").lower()
+ if label.endswith("-admin"):
+ return True
+ return False
+ except Exception as exc:
+ logger.warning("isFeatureAdmin check failed (user=%s feature=%s): %s",
+ userId, featureInstanceId, exc)
+ return False
+
+
+def _findFdsByCoord(allFds: List[Dict[str, Any]], featureInstanceId: str,
+ tableName: str, recordFilter: Optional[Dict[str, str]]) -> Optional[Dict[str, Any]]:
+ target = recordFilter or None
+ for fds in allFds:
+ if fds.get("featureInstanceId") != featureInstanceId:
+ continue
+ if (fds.get("tableName") or "") != tableName:
+ continue
+ if (fds.get("recordFilter") or None) == target:
+ return fds
+ return None
+
+
+def _findOrCreateTableFds(rootIf: Any, featureInstanceId: str, tableName: str,
+ featureCode: str, anyFieldName: str) -> Dict[str, Any]:
+ """Look up the table-level FDS record. Create a minimal stub if missing.
+
+ Required so an `fdsField` neutralize toggle can persist its state
+ without forcing the user to first 'add' the table via a separate UI
+ affordance. The stub carries the same featureInstanceId/tableName
+ coordinate; its `objectKey`/`label` are filled from the RBAC catalog
+ so list endpoints still render it correctly.
+ """
+ from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
+ existing = rootIf.db.getRecordset(FeatureDataSource, recordFilter={
+ "featureInstanceId": featureInstanceId,
+ "tableName": tableName,
+ }) or []
+ for rec in existing:
+ if (rec.get("recordFilter") or None) is None:
+ return rec
+ # Resolve mandate + objectKey/label via catalog so the stub is well-formed.
+ fi = rootIf.getFeatureInstance(featureInstanceId)
+ mandateId = str(fi.mandateId) if fi and getattr(fi, "mandateId", None) else ""
+ objectKey = ""
+ label = tableName
+ try:
+ from modules.security.rbacCatalog import getCatalogService
+ catalog = getCatalogService()
+ for obj in catalog.getDataObjects(featureCode) or []:
+ meta = obj.get("meta", {}) if isinstance(obj, dict) else {}
+ if meta.get("table") == tableName:
+ objectKey = obj.get("objectKey", "")
+ lbl = obj.get("label")
+ if lbl:
+ try:
+ from modules.shared.i18nRegistry import resolveText
+ label = resolveText(lbl) or tableName
+ except Exception:
+ label = str(lbl) or tableName
+ break
+ except Exception:
+ pass
+ stub = FeatureDataSource(
+ featureInstanceId=featureInstanceId,
+ featureCode=featureCode,
+ tableName=tableName,
+ objectKey=objectKey,
+ label=label,
+ mandateId=mandateId,
+ recordFilter=None,
+ )
+ created = rootIf.db.recordCreate(FeatureDataSource, stub.model_dump())
+ if isinstance(created, dict):
+ return created
+ return stub.model_dump()
+
+
+def _aggregateFromChildren(node: UdbNode, flag: str,
+ allDs: List[Dict[str, Any]],
+ allFds: List[Dict[str, Any]],
+ mode: str) -> Any:
+ """Aggregate `flag` across a node's logical children.
+
+ Returns 'mixed' iff at least two children disagree (in walk-mode),
+ otherwise the agreed value or the default for the flag. Used by
+ synthetic containers that have no DB record of their own.
+ """
+ children = node.getLogicalChildren(allDs, allFds, None, None)
+ if not children:
+ if flag == "scope":
+ return "personal"
+ return False
+ seen = set()
+ last: Any = None
+ for child in children:
+ if not child.supportsFlag(flag):
+ continue
+ val = child.getEffectiveFlag(flag, allDs, allFds, "aggregate")
+ if val == "mixed":
+ return "mixed"
+ norm = int(val) if isinstance(val, bool) else val
+ seen.add(norm)
+ last = val
+ if len(seen) > 1:
+ return "mixed"
+ if not seen:
+ return "personal" if flag == "scope" else False
+ return last
+
+
+# ---------------------------------------------------------------------------
+# Lookup by tree key -> UdbNode
+# ---------------------------------------------------------------------------
+
+def buildNodeForKey(key: str, context: Any, rootIf: Any) -> Optional[UdbNode]:
+ """Materialize the `UdbNode` instance for a given tree key.
+
+ Used by the generic /flag/ endpoint: the caller passes the key it sees
+ in the tree, and we resolve it back to the polymorphic node so we can
+ call canEdit / setFlag without the route knowing the node type.
+
+ Returns None when the key is unknown (caller should 404).
+ """
+ from modules.interfaces.interfaceDbApp import getRootInterface
+ if rootIf is None:
+ rootIf = getRootInterface()
+ from modules.datamodels.datamodelDataSource import DataSource
+ from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
+
+ kind, parts = _decode(key)
+
+ if kind == "conn" and len(parts) == 1:
+ connId = parts[0]
+ rec = _findDsByCoord(rootIf, connId, None, "/")
+ # Authority is whatever sourceType the connection-root record has;
+ # for setFlag we only need rec. ConnectionNode's label is not used.
+ authority = rec.get("sourceType") if rec else ""
+ return ConnectionNode(connId, authority, label="", parentKey="", rec=rec)
+
+ if kind == "svc" and len(parts) == 2:
+ connId, service = parts
+ sourceType = _SERVICE_TO_SOURCE_TYPE.get(service, service)
+ rec = _findDsByCoord(rootIf, connId, sourceType, "/")
+ return ServiceNode(connId, service, sourceType, label="", parentKey="", rec=rec)
+
+ if kind == "ds" and len(parts) >= 3:
+ connId = parts[0]
+ sourceType = parts[1]
+ path = _KEY_SEP.join(parts[2:])
+ rec = _findDsByCoord(rootIf, connId, sourceType, path)
+ # We do not know if it is folder or file at this point; use FolderNode.
+ return FolderNode(
+ connectionId=connId, service=_reverseService(sourceType),
+ sourceType=sourceType, path=path, label="", parentKey="",
+ rec=rec, hasChildren=True,
+ )
+
+ if kind == "feat" and len(parts) == 3:
+ mandateId, featureCode, featureInstanceId = parts
+ rec = _findFdsByCoord(_loadAllFds(rootIf, featureInstanceId), featureInstanceId, "*", None)
+ return FdsWorkspaceNode(mandateId, featureCode, featureInstanceId,
+ label="", icon="", parentKey="", rec=rec)
+
+ if kind == "fdstbl" and len(parts) == 2:
+ featureInstanceId, tableName = parts
+ rec = _findFdsByCoord(_loadAllFds(rootIf, featureInstanceId), featureInstanceId, tableName, None)
+ featureCode = ""
+ if rec:
+ featureCode = str(rec.get("featureCode") or "")
+ else:
+ fi = rootIf.getFeatureInstance(featureInstanceId)
+ featureCode = str(fi.featureCode) if fi else ""
+ return FdsTableNode(featureInstanceId, featureCode, tableName,
+ objectKey="", label="", parentKey="",
+ rec=rec, hasFields=True)
+
+ if kind == "fdsfld" and len(parts) >= 3:
+ featureInstanceId = parts[0]
+ tableName = parts[1]
+ fieldName = _KEY_SEP.join(parts[2:])
+ allFds = _loadAllFds(rootIf, featureInstanceId)
+ tableRec = _findFdsByCoord(allFds, featureInstanceId, tableName, None)
+ featureCode = str(tableRec.get("featureCode") or "") if tableRec else ""
+ if not featureCode:
+ fi = rootIf.getFeatureInstance(featureInstanceId)
+ featureCode = str(fi.featureCode) if fi else ""
+ return FdsFieldNode(featureInstanceId, tableName, fieldName,
+ parentKey="", tableRec=tableRec, featureCode=featureCode)
+
+ return None
+
+
+def _findDsByCoord(rootIf: Any, connectionId: str, sourceType: Optional[str],
+ path: str) -> Optional[Dict[str, Any]]:
+ from modules.datamodels.datamodelDataSource import DataSource
+ from modules.serviceCenter.services.serviceKnowledge._inheritFlags import _normalisePath
+ rf = {"connectionId": connectionId}
+ if sourceType is not None:
+ rf["sourceType"] = sourceType
+ records = rootIf.db.getRecordset(DataSource, recordFilter=rf) or []
+ norm = _normalisePath(path)
+ if sourceType is None:
+ # connection-root: any record with path='/' on this connection
+ for r in records:
+ if _normalisePath(r.get("path")) == "/":
+ return r
+ return None
+ for r in records:
+ if _normalisePath(r.get("path")) == norm:
+ return r
+ return None
+
+
+def _loadAllFds(rootIf: Any, featureInstanceId: str) -> List[Dict[str, Any]]:
+ from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
+ return rootIf.db.getRecordset(
+ FeatureDataSource, recordFilter={"featureInstanceId": featureInstanceId}
+ ) or []
+
+
+# Imported from _buildTree for buildNodeForKey resolution; kept here as a
+# small private mirror to avoid import cycles.
+_SERVICE_TO_SOURCE_TYPE: Dict[str, str] = {
+ "sharepoint": "sharepointFolder",
+ "onedrive": "onedriveFolder",
+ "outlook": "outlookFolder",
+ "drive": "googleDriveFolder",
+ "gmail": "gmailFolder",
+ "files": "ftpFolder",
+ "clickup": "clickup",
+ "kdrive": "kdriveFolder",
+ "mail": "mailFolder",
+ "calendar": "calendarFolder",
+ "contact": "contactFolder",
+}
+
+
+def _reverseService(sourceType: str) -> str:
+ for svc, st in _SERVICE_TO_SOURCE_TYPE.items():
+ if st == sourceType:
+ return svc
+ return sourceType
diff --git a/tests/unit/services/test_buildTree.py b/tests/unit/services/test_buildTree.py
index 8db4cfba..d285e867 100644
--- a/tests/unit/services/test_buildTree.py
+++ b/tests/unit/services/test_buildTree.py
@@ -1,8 +1,9 @@
-"""Unit tests for the generic UDB tree builder.
+"""Unit tests for the generic UDB tree builder (`_buildTree.py`).
-Verifies key encoding/decoding and that children for parent keys with
-existing handlers (top-level, conn, mgrp, feat) are produced with the
-correct effective-flag triplet.
+Most node-level behavior moved into the polymorphic class hierarchy
+(`udbNodes.py`) and has its own dedicated tests in `test_udbNodes.py`.
+This file covers the orchestrator (`getChildrenForParents`) and the
+remaining lookup helpers.
"""
from __future__ import annotations
@@ -27,37 +28,6 @@ class TestKeyCoding(unittest.TestCase):
self.assertEqual(_buildTree._decode("feat|m1|trustee|fi-1")[1], ["m1", "trustee", "fi-1"])
-class TestEffectiveTriplets(unittest.TestCase):
- def test_ds_triplet_no_record_returns_defaults(self):
- result = _buildTree._effectiveTripletDs("c", "msft", "/", [])
- self.assertEqual(result, {
- "effectiveNeutralize": False,
- "effectiveScope": "personal",
- "effectiveRagIndexEnabled": False,
- })
-
- def test_ds_triplet_inherits_from_root(self):
- root = {
- "id": "r", "connectionId": "c", "sourceType": "msft", "path": "/",
- "neutralize": True, "scope": "mandate", "ragIndexEnabled": True,
- }
- result = _buildTree._effectiveTripletDs("c", "sharepointFolder", "/sites/x", [root])
- self.assertEqual(result["effectiveNeutralize"], True)
- self.assertEqual(result["effectiveScope"], "mandate")
- self.assertEqual(result["effectiveRagIndexEnabled"], True)
-
- def test_fds_triplet_inherits_from_workspace_wildcard(self):
- ws = {
- "id": "ws", "workspaceInstanceId": "ws-inst", "featureInstanceId": "fi1",
- "tableName": "*", "recordFilter": None, "neutralize": True,
- "scope": "mandate", "ragIndexEnabled": True,
- }
- result = _buildTree._effectiveTripletFds("fi1", "Pos", None, [ws])
- self.assertEqual(result["effectiveNeutralize"], True)
- self.assertEqual(result["effectiveScope"], "mandate")
- self.assertEqual(result["effectiveRagIndexEnabled"], True)
-
-
class TestRecordLookup(unittest.TestCase):
def test_finds_ds_record_by_normalised_path(self):
rec = {"id": "x", "connectionId": "c", "sourceType": "msft", "path": "/folder"}
@@ -65,18 +35,78 @@ class TestRecordLookup(unittest.TestCase):
self.assertIsNone(_buildTree._findDsRecord([rec], "c", "msft", "/other"))
def test_finds_fds_record_with_matching_filter(self):
- rec = {"id": "f", "workspaceInstanceId": "ws", "featureInstanceId": "fi1", "tableName": "Pos", "recordFilter": {"id": "5"}}
+ rec = {"id": "f", "featureInstanceId": "fi1", "tableName": "Pos",
+ "recordFilter": {"id": "5"}}
self.assertEqual(_buildTree._findFdsRecord([rec], "fi1", "Pos", {"id": "5"}).get("id"), "f")
self.assertIsNone(_buildTree._findFdsRecord([rec], "fi1", "Pos", {"id": "99"}))
def test_fds_record_with_none_filter_matches_only_none(self):
- rec = {"id": "f", "workspaceInstanceId": "ws", "featureInstanceId": "fi1", "tableName": "*", "recordFilter": None}
+ rec = {"id": "f", "featureInstanceId": "fi1", "tableName": "*", "recordFilter": None}
self.assertEqual(_buildTree._findFdsRecord([rec], "fi1", "*", None).get("id"), "f")
self.assertIsNone(_buildTree._findFdsRecord([rec], "fi1", "*", {"id": "1"}))
+class TestWiredTableFieldAggregation(unittest.TestCase):
+ """`_wireTableFieldsAsLogicalChildren` rewraps `FdsTableNode.getEffectiveFlag`
+ so the table aggregates with its declared field children. The aggregate
+ must respect the new FdsField inheritance: if all fields inherit from the
+ table (no list entries), the table stays non-mixed."""
+
+ def _buildTableWithFields(self, *, tableNeutralize, neutralizeFields, fieldNames):
+ from modules.serviceCenter.services.serviceKnowledge.udbNodes import (
+ FdsTableNode, FdsFieldNode,
+ )
+ rec = {
+ "id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
+ "recordFilter": None,
+ "neutralize": tableNeutralize,
+ "neutralizeFields": neutralizeFields,
+ }
+ tableNode = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
+ "feat|m1|trustee|fi1", rec=rec, hasFields=True)
+ fields = [
+ FdsFieldNode("fi1", "Pos", name, tableNode.key,
+ tableRec=rec, featureCode="trustee")
+ for name in fieldNames
+ ]
+ tableNode._logicalFieldChildren = fields # type: ignore[attr-defined]
+ _buildTree._wireTableFieldsAsLogicalChildren(tableNode)
+ return tableNode, [rec]
+
+ def test_table_true_no_overrides_stays_true(self):
+ """Regression: toggling a table to True must NOT leave it 'mixed'
+ because the declared field children should inherit the table value."""
+ tableNode, allFds = self._buildTableWithFields(
+ tableNeutralize=True, neutralizeFields=None,
+ fieldNames=["amount", "currency"],
+ )
+ self.assertTrue(tableNode.getEffectiveFlag("neutralize", [], allFds, "aggregate"))
+
+ def test_table_false_with_override_is_mixed(self):
+ tableNode, allFds = self._buildTableWithFields(
+ tableNeutralize=False, neutralizeFields=["amount"],
+ fieldNames=["amount", "currency"],
+ )
+ self.assertEqual(
+ tableNode.getEffectiveFlag("neutralize", [], allFds, "aggregate"),
+ "mixed",
+ )
+
+ def test_table_inherit_no_overrides_walks_default(self):
+ """Implicit table + no overrides + no workspace -> default False."""
+ tableNode, allFds = self._buildTableWithFields(
+ tableNeutralize=None, neutralizeFields=None,
+ fieldNames=["amount", "currency"],
+ )
+ self.assertFalse(tableNode.getEffectiveFlag("neutralize", [], allFds, "aggregate"))
+
+
class TestGetChildrenForParents(unittest.TestCase):
- """End-to-end orchestrator test with mocked dependencies."""
+ """End-to-end orchestrator tests with mocked dependencies. The
+ orchestrator returns serialised node dicts produced by
+ `UdbNode.toDict(...)`, so the keys/kinds/parentKey wiring is what
+ matters here -- not the inheritance arithmetic (covered in
+ test_udbNodes.py)."""
def _runAsync(self, coro):
return asyncio.run(coro)
@@ -92,12 +122,11 @@ class TestGetChildrenForParents(unittest.TestCase):
ctx.mandateId = "m1"
result = self._runAsync(
- _buildTree.getChildrenForParents("inst-1", ["bogus|key"], ctx)
+ _buildTree.getChildrenForParents(["bogus|key"], ctx)
)
self.assertEqual(result["bogus|key"], [])
def test_top_level_emits_personal_root_first(self):
- """Top-level emits personalRoot first, then mandate-group nodes inline."""
with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot:
rootIf = MagicMock()
rootIf.db.getRecordset.return_value = []
@@ -109,7 +138,7 @@ class TestGetChildrenForParents(unittest.TestCase):
ctx.mandateId = "m1"
result = self._runAsync(
- _buildTree.getChildrenForParents("inst-1", [None], ctx)
+ _buildTree.getChildrenForParents([None], ctx)
)
children = result["__root__"]
self.assertGreaterEqual(len(children), 1)
@@ -120,92 +149,7 @@ class TestGetChildrenForParents(unittest.TestCase):
self.assertTrue(personalRoot["hasChildren"])
self.assertTrue(personalRoot["defaultExpanded"])
-
-class TestTopLevelLayout(unittest.TestCase):
- """Tests for the flat top-level layout (personalRoot + mandate groups)."""
-
- def _runAsync(self, coro):
- return asyncio.run(coro)
-
- def test_personal_root_carries_neutral_default_triplet(self):
- with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot:
- rootIf = MagicMock()
- rootIf.db.getRecordset.return_value = []
- rootIf.getUserMandates.return_value = []
- mockRoot.return_value = rootIf
-
- ctx = MagicMock()
- ctx.user.id = "u1"
- ctx.mandateId = "m1"
-
- result = self._runAsync(
- _buildTree.getChildrenForParents("inst-1", [None], ctx)
- )
- personalRoot = result["__root__"][0]
- self.assertFalse(personalRoot["effectiveNeutralize"])
- self.assertEqual(personalRoot["effectiveScope"], "personal")
- self.assertFalse(personalRoot["effectiveRagIndexEnabled"])
- self.assertFalse(personalRoot["supportsRag"])
- self.assertFalse(personalRoot["canBeAdded"])
- self.assertIsNone(personalRoot["dataSourceId"])
- self.assertIsNone(personalRoot["modelType"])
-
- def test_personal_root_emits_active_connection_with_correct_parent(self):
- with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot, \
- patch("modules.serviceCenter.getService") as mockGetService:
- rootIf = MagicMock()
- rootIf.db.getRecordset.return_value = []
- mockRoot.return_value = rootIf
-
- chatService = MagicMock()
- chatService.getUserConnections.return_value = [{
- "id": "conn-1",
- "status": "active",
- "authority": "msft",
- "externalEmail": "user@example.com",
- }]
- mockGetService.return_value = chatService
-
- ctx = MagicMock()
- ctx.user.id = "u1"
- ctx.mandateId = "m1"
-
- result = self._runAsync(
- _buildTree.getChildrenForParents("inst-1", ["personalRoot"], ctx)
- )
- children = result["personalRoot"]
- self.assertEqual(len(children), 1)
- self.assertEqual(children[0]["key"], "conn|conn-1")
- self.assertEqual(children[0]["kind"], "connection")
- self.assertEqual(children[0]["parentKey"], "personalRoot")
- self.assertEqual(children[0]["label"], "user@example.com")
- self.assertTrue(children[0]["supportsRag"])
-
- def test_personal_root_skips_inactive_connection(self):
- with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot, \
- patch("modules.serviceCenter.getService") as mockGetService:
- rootIf = MagicMock()
- rootIf.db.getRecordset.return_value = []
- mockRoot.return_value = rootIf
-
- chatService = MagicMock()
- chatService.getUserConnections.return_value = [
- {"id": "c1", "status": "active", "authority": "msft", "externalEmail": "a"},
- {"id": "c2", "status": "expired", "authority": "google", "externalEmail": "b"},
- ]
- mockGetService.return_value = chatService
-
- ctx = MagicMock()
- ctx.user.id = "u1"
- ctx.mandateId = "m1"
-
- result = self._runAsync(
- _buildTree.getChildrenForParents("inst-1", ["personalRoot"], ctx)
- )
- self.assertEqual(len(result["personalRoot"]), 1)
- self.assertEqual(result["personalRoot"][0]["connectionId"], "c1")
-
- def test_mandate_groups_emitted_inline_at_top_level(self):
+ def test_top_level_emits_mandate_groups_inline(self):
with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot, \
patch("modules.security.rbacCatalog.getCatalogService") as mockCatalog:
rootIf = MagicMock()
@@ -218,8 +162,7 @@ class TestTopLevelLayout(unittest.TestCase):
featureInst.featureCode = "trustee"
featureInst.enabled = True
rootIf.getFeatureInstancesByMandate.return_value = [featureInst]
- featureAccess = MagicMock()
- featureAccess.enabled = True
+ featureAccess = MagicMock(enabled=True)
rootIf.getFeatureAccess.return_value = featureAccess
mockRoot.return_value = rootIf
@@ -231,11 +174,8 @@ class TestTopLevelLayout(unittest.TestCase):
ctx.user.id = "u1"
ctx.mandateId = None
- result = self._runAsync(
- _buildTree.getChildrenForParents("inst-1", [None], ctx)
- )
- children = result["__root__"]
- byKey = {c["key"]: c for c in children}
+ result = self._runAsync(_buildTree.getChildrenForParents([None], ctx))
+ byKey = {c["key"]: c for c in result["__root__"]}
self.assertIn("personalRoot", byKey)
self.assertIn("mgrp|m1", byKey)
mgroup = byKey["mgrp|m1"]
@@ -243,116 +183,6 @@ class TestTopLevelLayout(unittest.TestCase):
self.assertIsNone(mgroup["parentKey"])
self.assertEqual(mgroup["mandateId"], "m1")
self.assertTrue(mgroup["defaultExpanded"])
- self.assertFalse(mgroup["supportsRag"])
-
- def test_top_level_omits_mandates_without_data_features(self):
- with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot, \
- patch("modules.security.rbacCatalog.getCatalogService") as mockCatalog:
- rootIf = MagicMock()
- rootIf.db.getRecordset.return_value = []
- userMandate = MagicMock()
- userMandate.mandateId = "m1"
- rootIf.getUserMandates.return_value = [userMandate]
- rootIf.getFeatureInstancesByMandate.return_value = []
- mockRoot.return_value = rootIf
-
- catalog = MagicMock()
- catalog.getFeaturesWithDataObjects.return_value = ["trustee"]
- mockCatalog.return_value = catalog
-
- ctx = MagicMock()
- ctx.user.id = "u1"
- ctx.mandateId = None
-
- result = self._runAsync(
- _buildTree.getChildrenForParents("inst-1", [None], ctx)
- )
- keys = [c["key"] for c in result["__root__"]]
- self.assertEqual(keys, ["personalRoot"])
-
- def test_personal_root_listed_first_via_display_order(self):
- with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot, \
- patch("modules.security.rbacCatalog.getCatalogService") as mockCatalog:
- rootIf = MagicMock()
- rootIf.db.getRecordset.return_value = []
- userMandate = MagicMock()
- userMandate.mandateId = "m1"
- rootIf.getUserMandates.return_value = [userMandate]
- featureInst = MagicMock()
- featureInst.id = "fi-1"
- featureInst.featureCode = "trustee"
- featureInst.enabled = True
- rootIf.getFeatureInstancesByMandate.return_value = [featureInst]
- featureAccess = MagicMock()
- featureAccess.enabled = True
- rootIf.getFeatureAccess.return_value = featureAccess
- mockRoot.return_value = rootIf
-
- catalog = MagicMock()
- catalog.getFeaturesWithDataObjects.return_value = ["trustee"]
- mockCatalog.return_value = catalog
-
- ctx = MagicMock()
- ctx.user.id = "u1"
- ctx.mandateId = None
-
- result = self._runAsync(
- _buildTree.getChildrenForParents("inst-1", [None], ctx)
- )
- children = result["__root__"]
- self.assertEqual(children[0]["key"], "personalRoot")
- self.assertEqual(children[0]["displayOrder"], 0)
-
-
-class TestFeatureTableFields(unittest.TestCase):
- """Per-column field expansion under a feature data-source table."""
-
- def test_emits_one_node_per_field(self):
- nodes = _buildTree._featureTableFields(
- parentKey="fdstbl|fi-1|TrusteePosition",
- featureInstanceId="fi-1",
- tableName="TrusteePosition",
- fieldNames=["id", "valuta", "company"],
- allFds=[],
- )
- self.assertEqual(len(nodes), 3)
- self.assertEqual(nodes[0]["kind"], "fdsField")
- self.assertEqual(nodes[0]["fieldName"], "id")
- self.assertEqual(nodes[0]["parentKey"], "fdstbl|fi-1|TrusteePosition")
- self.assertEqual(nodes[0]["key"], "fdsfld|fi-1|TrusteePosition|id")
- self.assertFalse(nodes[0]["hasChildren"])
- self.assertFalse(nodes[0]["supportsRag"])
-
- def test_field_neutralize_inherits_from_table_blanket(self):
- rec = {"id": "f", "workspaceInstanceId": "ws-1", "featureInstanceId": "fi-1",
- "tableName": "TrusteePosition", "recordFilter": None,
- "neutralize": True, "neutralizeFields": None,
- "scope": None, "ragIndexEnabled": False}
- nodes = _buildTree._featureTableFields(
- parentKey="fdstbl|fi-1|TrusteePosition",
- featureInstanceId="fi-1",
- tableName="TrusteePosition",
- fieldNames=["email", "company"],
- allFds=[rec],
- )
- self.assertTrue(nodes[0]["effectiveNeutralize"])
- self.assertTrue(nodes[1]["effectiveNeutralize"])
-
- def test_field_neutralize_explicit_via_neutralize_fields(self):
- rec = {"id": "f", "workspaceInstanceId": "ws-1", "featureInstanceId": "fi-1",
- "tableName": "TrusteePosition", "recordFilter": None,
- "neutralize": False, "neutralizeFields": ["email"],
- "scope": None, "ragIndexEnabled": False}
- nodes = _buildTree._featureTableFields(
- parentKey="fdstbl|fi-1|TrusteePosition",
- featureInstanceId="fi-1",
- tableName="TrusteePosition",
- fieldNames=["email", "company"],
- allFds=[rec],
- )
- byField = {n["fieldName"]: n for n in nodes}
- self.assertTrue(byField["email"]["effectiveNeutralize"])
- self.assertFalse(byField["company"]["effectiveNeutralize"])
if __name__ == "__main__":
diff --git a/tests/unit/services/test_inheritFlags.py b/tests/unit/services/test_inheritFlags.py
index 98e6fb41..40099e56 100644
--- a/tests/unit/services/test_inheritFlags.py
+++ b/tests/unit/services/test_inheritFlags.py
@@ -34,15 +34,19 @@ def _ds(idVal: str, path: str, **flags) -> dict:
def _fds(idVal: str, *, tableName: str, recordFilter=None, featureInstanceId="fi-1", **flags) -> dict:
- """Build a FeatureDataSource dict fixture."""
+ """Build a FeatureDataSource dict fixture.
+
+ FDS records no longer carry `userId`, `workspaceInstanceId`, or
+ `scope`; visibility/edit-permission live on the feature instance
+ via RBAC. Tests should only set neutralize/ragIndexEnabled.
+ """
base = {
"id": idVal,
- "workspaceInstanceId": "ws-1",
"featureInstanceId": featureInstanceId,
"tableName": tableName,
"recordFilter": recordFilter,
"neutralize": None,
- "scope": None,
+ "ragIndexEnabled": None,
}
base.update(flags)
return base
@@ -473,6 +477,7 @@ class TestFdsCascadeReset(unittest.TestCase):
_inheritFlags.cascadeResetDescendantsFds(rootIf, ws, "doesNotExist")
+
# ===========================================================================
# FeatureDataSource: collectAncestorChainFds
# ===========================================================================
@@ -572,28 +577,32 @@ class TestResolveEffectiveForPath(unittest.TestCase):
class TestResolveEffectiveForFds(unittest.TestCase):
+ """FDS records carry only `neutralize` + `ragIndexEnabled`. No scope.
+
+ `resolveEffectiveForFds` therefore returns a two-key dict; tests
+ must not assert anything about `effectiveScope` on FDS results.
+ """
+
def test_with_exact_record(self):
- ws = _fds("ws", tableName="*", neutralize=True, scope="mandate")
- tbl = _fds("t", tableName="Pos", neutralize=False, scope="personal")
+ ws = _fds("ws", tableName="*", neutralize=True)
+ tbl = _fds("t", tableName="Pos", neutralize=False)
allFds = [ws, tbl]
result = _inheritFlags.resolveEffectiveForFds("fi-1", "Pos", None, allFds)
self.assertEqual(result["effectiveNeutralize"], False)
- self.assertEqual(result["effectiveScope"], "personal")
self.assertEqual(result["effectiveRagIndexEnabled"], False)
+ self.assertNotIn("effectiveScope", result)
- def test_without_record_inherits_from_workspace_wildcard(self):
- ws = _fds("ws", tableName="*", neutralize=True, scope="mandate", ragIndexEnabled=True)
+ def test_without_record_inherits_from_feature_wildcard(self):
+ ws = _fds("ws", tableName="*", neutralize=True, ragIndexEnabled=True)
allFds = [ws]
result = _inheritFlags.resolveEffectiveForFds("fi-1", "Unknown", None, allFds)
self.assertEqual(result["effectiveNeutralize"], True)
- self.assertEqual(result["effectiveScope"], "mandate")
self.assertEqual(result["effectiveRagIndexEnabled"], True)
def test_without_record_no_ancestors_returns_defaults(self):
allFds: list = []
result = _inheritFlags.resolveEffectiveForFds("fi-1", "Pos", None, allFds)
self.assertEqual(result["effectiveNeutralize"], False)
- self.assertEqual(result["effectiveScope"], "personal")
self.assertEqual(result["effectiveRagIndexEnabled"], False)
def test_rag_inherits_when_table_overrides_neutralize_only(self):
@@ -611,10 +620,10 @@ class TestResolveEffectiveForFds(unittest.TestCase):
result = _inheritFlags.resolveEffectiveForFds("fi-1", "*", None, allFds, mode="aggregate")
self.assertEqual(result["effectiveRagIndexEnabled"], "mixed")
- def test_inheritable_fds_flags_includes_rag(self):
+ def test_inheritable_fds_flags_excludes_scope(self):
self.assertIn("ragIndexEnabled", _inheritFlags._INHERITABLE_FDS_FLAGS)
self.assertIn("neutralize", _inheritFlags._INHERITABLE_FDS_FLAGS)
- self.assertIn("scope", _inheritFlags._INHERITABLE_FDS_FLAGS)
+ self.assertNotIn("scope", _inheritFlags._INHERITABLE_FDS_FLAGS)
# ===========================================================================
@@ -651,5 +660,80 @@ class TestPathNormalization(unittest.TestCase):
self.assertEqual(_inheritFlags._normalisePath("foo/bar"), "/foo/bar")
+# ===========================================================================
+# Virtual coordinates (no DB record) must support aggregate mode (mixed)
+# ===========================================================================
+
+class TestVirtualCoordAggregate(unittest.TestCase):
+ """After the spec-recovery fix, resolveEffectiveForPath/Fds with
+ mode='aggregate' must return 'mixed' for coordinates that have no DB
+ record but whose descendants in the DB diverge."""
+
+ def test_virtual_folder_mixed_neutralize(self):
+ child1 = _ds("c1", "/virtual/a", neutralize=True)
+ child2 = _ds("c2", "/virtual/b", neutralize=False)
+ allDs = [child1, child2]
+ result = _inheritFlags.resolveEffectiveForPath(
+ "conn-1", "sharepointFolder", "/virtual", allDs, mode="aggregate",
+ )
+ self.assertEqual(result["effectiveNeutralize"], "mixed")
+
+ def test_virtual_folder_mixed_scope(self):
+ child1 = _ds("c1", "/virtual/a", scope="mandate")
+ child2 = _ds("c2", "/virtual/b", scope="personal")
+ allDs = [child1, child2]
+ result = _inheritFlags.resolveEffectiveForPath(
+ "conn-1", "sharepointFolder", "/virtual", allDs, mode="aggregate",
+ )
+ self.assertEqual(result["effectiveScope"], "mixed")
+
+ def test_virtual_folder_mixed_rag(self):
+ child1 = _ds("c1", "/virtual/a", ragIndexEnabled=True)
+ child2 = _ds("c2", "/virtual/b", ragIndexEnabled=False)
+ allDs = [child1, child2]
+ result = _inheritFlags.resolveEffectiveForPath(
+ "conn-1", "sharepointFolder", "/virtual", allDs, mode="aggregate",
+ )
+ self.assertEqual(result["effectiveRagIndexEnabled"], "mixed")
+
+ def test_virtual_folder_uniform_returns_concrete(self):
+ child1 = _ds("c1", "/virtual/a", neutralize=True)
+ child2 = _ds("c2", "/virtual/b", neutralize=True)
+ allDs = [child1, child2]
+ result = _inheritFlags.resolveEffectiveForPath(
+ "conn-1", "sharepointFolder", "/virtual", allDs, mode="aggregate",
+ )
+ self.assertTrue(result["effectiveNeutralize"])
+
+ def test_virtual_fds_workspace_mixed_neutralize(self):
+ tblA = _fds("tA", tableName="A", neutralize=True)
+ tblB = _fds("tB", tableName="B", neutralize=False)
+ allFds = [tblA, tblB]
+ result = _inheritFlags.resolveEffectiveForFds(
+ "fi-1", "*", None, allFds, mode="aggregate",
+ )
+ self.assertEqual(result["effectiveNeutralize"], "mixed")
+
+ def test_virtual_fds_workspace_uniform_returns_concrete(self):
+ tblA = _fds("tA", tableName="A", neutralize=True)
+ tblB = _fds("tB", tableName="B", neutralize=True)
+ allFds = [tblA, tblB]
+ result = _inheritFlags.resolveEffectiveForFds(
+ "fi-1", "*", None, allFds, mode="aggregate",
+ )
+ self.assertTrue(result["effectiveNeutralize"])
+
+ def test_virtual_connection_root_mixed_via_services(self):
+ """Connection root (authority sourceType, path='/') with no DB record
+ but services that diverge must return 'mixed'."""
+ spRecord = _ds("sp", "/", sourceType="sharepointFolder", neutralize=True)
+ olRecord = _ds("ol", "/", sourceType="outlookFolder", neutralize=False)
+ allDs = [spRecord, olRecord]
+ result = _inheritFlags.resolveEffectiveForPath(
+ "conn-1", "msft", "/", allDs, mode="aggregate",
+ )
+ self.assertEqual(result["effectiveNeutralize"], "mixed")
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/unit/services/test_udbNodes.py b/tests/unit/services/test_udbNodes.py
new file mode 100644
index 00000000..8048eee8
--- /dev/null
+++ b/tests/unit/services/test_udbNodes.py
@@ -0,0 +1,449 @@
+"""Unit tests for the polymorphic UDB node hierarchy (udbNodes.py).
+
+Each concrete node class is exercised for:
+ - `supportsFlag` returns the right set per kind
+ - `canEdit` enforces DS-owner vs FDS-feature-admin
+ - `getEffectiveFlag` resolves walk + aggregate correctly
+ - `setFlag` writes the right record and (where applicable) cascades
+ - `toDict` produces the expected wire shape
+"""
+
+from __future__ import annotations
+
+import unittest
+from unittest.mock import MagicMock, patch
+
+from modules.serviceCenter.services.serviceKnowledge.udbNodes import (
+ UdbNode,
+ SyntheticContainerNode,
+ MandateGroupNode,
+ ConnectionNode,
+ ServiceNode,
+ FolderNode,
+ FileNode,
+ FdsWorkspaceNode,
+ FdsTableNode,
+ FdsFieldNode,
+ _isFeatureAdmin,
+)
+
+
+class _FakeUser:
+ def __init__(self, userId: str = "user-1"):
+ self.id = userId
+
+
+class _FakeContext:
+ def __init__(self, userId: str = "user-1"):
+ self.user = _FakeUser(userId)
+ self.mandateId = "m1"
+
+
+class TestSupportsFlag(unittest.TestCase):
+ def test_synthetic_container_supports_nothing(self):
+ n = SyntheticContainerNode("k", "label", icon="x")
+ self.assertFalse(n.supportsFlag("neutralize"))
+ self.assertFalse(n.supportsFlag("scope"))
+ self.assertFalse(n.supportsFlag("ragIndexEnabled"))
+
+ def test_connection_supports_all_three(self):
+ n = ConnectionNode("c1", "msft", label="m", parentKey="personalRoot", rec=None)
+ self.assertTrue(n.supportsFlag("neutralize"))
+ self.assertTrue(n.supportsFlag("scope"))
+ self.assertTrue(n.supportsFlag("ragIndexEnabled"))
+
+ def test_fds_table_supports_neutralize_and_rag_but_not_scope(self):
+ n = FdsTableNode(
+ featureInstanceId="fi1", featureCode="trustee", tableName="Pos",
+ objectKey="data.feature.trustee.Pos", label="Positions",
+ parentKey="feat|m1|trustee|fi1", rec=None, hasFields=False,
+ )
+ self.assertTrue(n.supportsFlag("neutralize"))
+ self.assertTrue(n.supportsFlag("ragIndexEnabled"))
+ self.assertFalse(n.supportsFlag("scope"))
+
+ def test_fds_field_supports_only_neutralize(self):
+ n = FdsFieldNode(
+ featureInstanceId="fi1", tableName="Pos", fieldName="amount",
+ parentKey="fdstbl|fi1|Pos", tableRec=None, featureCode="trustee",
+ )
+ self.assertTrue(n.supportsFlag("neutralize"))
+ self.assertFalse(n.supportsFlag("scope"))
+ self.assertFalse(n.supportsFlag("ragIndexEnabled"))
+
+
+class TestCanEditDataSourceOwner(unittest.TestCase):
+ def test_owner_can_edit(self):
+ rec = {"id": "ds1", "userId": "user-1"}
+ node = ConnectionNode("c1", "msft", "m", "personalRoot", rec=rec)
+ self.assertTrue(node.canEdit(_FakeContext("user-1"), MagicMock()))
+
+ def test_non_owner_cannot_edit(self):
+ rec = {"id": "ds1", "userId": "user-other"}
+ node = ConnectionNode("c1", "msft", "m", "personalRoot", rec=rec)
+ self.assertFalse(node.canEdit(_FakeContext("user-1"), MagicMock()))
+
+ def test_virtual_node_own_connection_can_edit(self):
+ rootIf = MagicMock()
+ rootIf.db.getRecord.return_value = {"id": "c1", "userId": "user-1"}
+ node = ConnectionNode("c1", "msft", "m", "personalRoot", rec=None)
+ self.assertTrue(node.canEdit(_FakeContext("user-1"), rootIf))
+
+ def test_virtual_node_other_connection_cannot_edit(self):
+ rootIf = MagicMock()
+ rootIf.db.getRecord.return_value = {"id": "c1", "userId": "user-other"}
+ node = ConnectionNode("c1", "msft", "m", "personalRoot", rec=None)
+ self.assertFalse(node.canEdit(_FakeContext("user-1"), rootIf))
+
+ def test_virtual_node_missing_connection_cannot_edit(self):
+ rootIf = MagicMock()
+ rootIf.db.getRecord.return_value = None
+ node = ConnectionNode("c1", "msft", "m", "personalRoot", rec=None)
+ self.assertFalse(node.canEdit(_FakeContext("user-1"), rootIf))
+
+
+class TestCanEditFdsFeatureAdmin(unittest.TestCase):
+ def _buildRootIfWithAdminRole(self, hasAdmin: bool):
+ rootIf = MagicMock()
+ access = MagicMock(id="acc1", enabled=True)
+ rootIf.getFeatureAccess.return_value = access
+ rootIf.getRoleIdsForFeatureAccess.return_value = ["role-1"]
+ rootIf.db.getRecord.return_value = {
+ "id": "role-1",
+ "roleLabel": "trustee-admin" if hasAdmin else "trustee-user",
+ }
+ return rootIf
+
+ def test_admin_can_edit_fds_table(self):
+ rootIf = self._buildRootIfWithAdminRole(hasAdmin=True)
+ node = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
+ "feat|m1|trustee|fi1", rec={"id": "fds1"}, hasFields=False)
+ self.assertTrue(node.canEdit(_FakeContext(), rootIf))
+
+ def test_non_admin_cannot_edit_fds_table(self):
+ rootIf = self._buildRootIfWithAdminRole(hasAdmin=False)
+ node = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
+ "feat|m1|trustee|fi1", rec={"id": "fds1"}, hasFields=False)
+ self.assertFalse(node.canEdit(_FakeContext(), rootIf))
+
+ def test_fds_field_uses_feature_admin_check(self):
+ rootIf = self._buildRootIfWithAdminRole(hasAdmin=True)
+ field = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
+ tableRec={"id": "fds1"}, featureCode="trustee")
+ self.assertTrue(field.canEdit(_FakeContext(), rootIf))
+
+
+class TestGetEffectiveFlag(unittest.TestCase):
+ def test_ds_walk_inherits_from_authority_root(self):
+ root = {
+ "id": "r", "connectionId": "c", "sourceType": "msft", "path": "/",
+ "userId": "user-1", "neutralize": True, "scope": None, "ragIndexEnabled": None,
+ }
+ node = FolderNode(
+ connectionId="c", service="sharepoint", sourceType="sharepointFolder",
+ path="/sites/x", label="x", parentKey="svc|c|sharepoint",
+ rec=None, hasChildren=True,
+ )
+ self.assertTrue(node.getEffectiveFlag("neutralize", [root], [], "walk"))
+
+ def test_fds_field_neutralize_from_neutralize_fields(self):
+ rec = {
+ "id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
+ "recordFilter": None, "neutralizeFields": ["amount"],
+ }
+ node = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
+ tableRec=rec, featureCode="trustee")
+ self.assertTrue(node.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
+
+ other = FdsFieldNode("fi1", "Pos", "currency", "fdstbl|fi1|Pos",
+ tableRec=rec, featureCode="trustee")
+ # currency is not in the override list and the table has no
+ # explicit neutralize -> inherits the default (False).
+ self.assertFalse(other.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
+
+ def test_fds_field_inherits_true_from_table(self):
+ """Field without explicit override inherits the table's explicit
+ neutralize. Regression: previously fell through to False, so
+ toggling the table left the field icon unchanged."""
+ rec = {
+ "id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
+ "recordFilter": None, "neutralize": True, "neutralizeFields": None,
+ }
+ node = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
+ tableRec=rec, featureCode="trustee")
+ self.assertTrue(node.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
+
+ def test_fds_field_inherits_from_workspace_via_table(self):
+ """Field walks the whole FDS ancestor chain: table -> workspace."""
+ ws = {
+ "id": "fds-ws", "featureInstanceId": "fi1", "tableName": "*",
+ "recordFilter": None, "neutralize": True,
+ }
+ tbl = {
+ "id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
+ "recordFilter": None, "neutralize": None, "neutralizeFields": None,
+ }
+ node = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
+ tableRec=tbl, featureCode="trustee")
+ self.assertTrue(node.getEffectiveFlag("neutralize", [], [ws, tbl], "aggregate"))
+
+ def test_fds_field_explicit_override_beats_table_false(self):
+ """Per-column override (True via list entry) beats an explicit
+ table False -- this is the one case where the two-source model
+ diverges intentionally and produces the 'mixed' aggregate."""
+ rec = {
+ "id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
+ "recordFilter": None, "neutralize": False, "neutralizeFields": ["amount"],
+ }
+ amount = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
+ tableRec=rec, featureCode="trustee")
+ other = FdsFieldNode("fi1", "Pos", "currency", "fdstbl|fi1|Pos",
+ tableRec=rec, featureCode="trustee")
+ self.assertTrue(amount.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
+ # currency inherits from table -> False
+ self.assertFalse(other.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
+
+ def test_fds_table_mixed_when_field_and_table_disagree(self):
+ # table.neutralize=False, field "amount" is in neutralizeFields => mixed
+ rec = {
+ "id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
+ "recordFilter": None, "neutralize": False, "ragIndexEnabled": None,
+ "neutralizeFields": ["amount"],
+ }
+ table = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
+ "feat|m1|trustee|fi1", rec=rec, hasFields=True)
+ # FdsTableNode.getEffectiveFlag itself only consults FDS records,
+ # not field nodes. The aggregation across field nodes is wired
+ # in _buildTree via `_wireTableFieldsAsLogicalChildren`. So we
+ # exercise the explicit FDS walk here:
+ val = table.getEffectiveFlag("neutralize", [], [rec], "walk")
+ self.assertFalse(val)
+
+
+class TestSetFlag(unittest.TestCase):
+ def test_setflag_writes_value_on_ds(self):
+ rec = {"id": "ds1", "connectionId": "c", "sourceType": "msft", "path": "/",
+ "userId": "user-1"}
+ node = ConnectionNode("c", "msft", "m", "personalRoot", rec=rec)
+ rootIf = MagicMock()
+ rootIf.db.getRecordset.return_value = [] # no siblings -> no cascade
+ node.setFlag("neutralize", True, rootIf)
+ rootIf.db.recordModify.assert_called()
+ args = rootIf.db.recordModify.call_args[0]
+ self.assertEqual(args[1], "ds1")
+ self.assertEqual(args[2], {"neutralize": True})
+
+ def test_setflag_virtual_ds_auto_creates_record(self):
+ """Toggling a flag on a virtual DS node must auto-create the
+ DataSource record so the flag can be persisted."""
+ node = FolderNode(
+ connectionId="c1", service="sharepoint",
+ sourceType="sharepointFolder", path="/sites/x/docs",
+ label="docs", parentKey="svc|c1|sharepoint",
+ rec=None, hasChildren=True,
+ )
+ rootIf = MagicMock()
+ rootIf.db.getRecordset.return_value = []
+ rootIf.db.getRecord.return_value = {"id": "c1", "userId": "user-1"}
+ createdRec = {"id": "ds-new", "connectionId": "c1",
+ "sourceType": "sharepointFolder", "path": "/sites/x/docs",
+ "userId": "user-1"}
+ rootIf.db.recordCreate.return_value = createdRec
+ node.setFlag("neutralize", True, rootIf)
+ rootIf.db.recordCreate.assert_called_once()
+ rootIf.db.recordModify.assert_called()
+ args = rootIf.db.recordModify.call_args[0]
+ self.assertEqual(args[1], "ds-new")
+ self.assertEqual(args[2], {"neutralize": True})
+
+ def test_fds_field_setflag_mutates_neutralize_fields(self):
+ rec = {
+ "id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
+ "recordFilter": None, "neutralize": False, "neutralizeFields": None,
+ }
+ node = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
+ tableRec=rec, featureCode="trustee")
+ rootIf = MagicMock()
+ node.setFlag("neutralize", True, rootIf)
+ rootIf.db.recordModify.assert_called()
+ # last call: set neutralizeFields to ["amount"]
+ args = rootIf.db.recordModify.call_args[0]
+ self.assertEqual(args[1], "fds-tbl")
+ self.assertEqual(args[2], {"neutralizeFields": ["amount"]})
+
+ def test_fds_field_setflag_removes_field_when_toggled_off(self):
+ rec = {
+ "id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
+ "recordFilter": None, "neutralizeFields": ["amount", "currency"],
+ }
+ node = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
+ tableRec=rec, featureCode="trustee")
+ rootIf = MagicMock()
+ node.setFlag("neutralize", False, rootIf)
+ args = rootIf.db.recordModify.call_args[0]
+ self.assertEqual(args[2], {"neutralizeFields": ["currency"]})
+
+ def test_fds_field_setflag_roundtrip(self):
+ rec = {
+ "id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
+ "recordFilter": None, "neutralizeFields": None,
+ }
+ node = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
+ tableRec=rec, featureCode="trustee")
+ rootIf = MagicMock()
+ node.setFlag("neutralize", True, rootIf)
+ self.assertTrue(node.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
+ node.setFlag("neutralize", False, rootIf)
+ self.assertFalse(node.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
+
+ def test_fds_table_explicit_neutralize_wipes_own_neutralize_fields(self):
+ """Setting an explicit neutralize on a table must clear its own
+ `neutralizeFields` list. Otherwise the table's aggregate stays
+ 'mixed' because field children walk to True via that list and the
+ UI shows no change after the toggle."""
+ rec = {
+ "id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
+ "recordFilter": None, "neutralize": None,
+ "neutralizeFields": ["amount", "currency"],
+ }
+ node = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
+ "feat|m1|trustee|fi1", rec=rec, hasFields=True)
+ rootIf = MagicMock()
+ rootIf.db.getRecordset.return_value = [rec] # no descendants
+ node.setFlag("neutralize", False, rootIf)
+ rootIf.db.recordModify.assert_called()
+ args = rootIf.db.recordModify.call_args[0]
+ self.assertEqual(args[1], "fds-tbl")
+ self.assertEqual(
+ args[2], {"neutralize": False, "neutralizeFields": None},
+ )
+
+ def test_fds_table_setflag_inherit_keeps_neutralize_fields(self):
+ """`value=None` (reset to inherit) must NOT cascade and must NOT
+ wipe `neutralizeFields`; that matches the cascade-reset spec
+ (only explicit toggles clear descendants)."""
+ rec = {
+ "id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
+ "recordFilter": None, "neutralize": True,
+ "neutralizeFields": ["amount"],
+ }
+ node = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
+ "feat|m1|trustee|fi1", rec=rec, hasFields=True)
+ rootIf = MagicMock()
+ rootIf.db.getRecordset.return_value = [rec]
+ node.setFlag("neutralize", None, rootIf)
+ args = rootIf.db.recordModify.call_args[0]
+ self.assertEqual(args[2], {"neutralize": None})
+
+ def test_fds_table_setflag_rag_does_not_touch_neutralize_fields(self):
+ """A RAG toggle on the table must leave `neutralizeFields` alone
+ (it is neutralize-only field state)."""
+ rec = {
+ "id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
+ "recordFilter": None, "ragIndexEnabled": None,
+ "neutralizeFields": ["amount"],
+ }
+ node = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
+ "feat|m1|trustee|fi1", rec=rec, hasFields=True)
+ rootIf = MagicMock()
+ rootIf.db.getRecordset.return_value = [rec]
+ node.setFlag("ragIndexEnabled", True, rootIf)
+ args = rootIf.db.recordModify.call_args[0]
+ self.assertEqual(args[2], {"ragIndexEnabled": True})
+
+ def test_fds_workspace_neutralize_clears_descendant_neutralize_fields(self):
+ """Workspace toggle must clear per-column overrides on descendant
+ tables; otherwise the table aggregate stays 'mixed' because some
+ field children still read True from the list."""
+ wsRec = {
+ "id": "fds-ws", "featureInstanceId": "fi1", "tableName": "*",
+ "recordFilter": None, "neutralize": None,
+ }
+ tblRec = {
+ "id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
+ "recordFilter": None, "neutralize": None,
+ "neutralizeFields": ["amount", "currency"],
+ }
+ node = FdsWorkspaceNode("m1", "trustee", "fi1", label="Trustee",
+ icon="trustee", parentKey="mgrp|m1", rec=wsRec)
+ rootIf = MagicMock()
+ rootIf.db.getRecordset.return_value = [wsRec, tblRec]
+ node.setFlag("neutralize", True, rootIf)
+ calls = rootIf.db.recordModify.call_args_list
+ modifyMap = {c[0][1]: c[0][2] for c in calls}
+ self.assertEqual(modifyMap["fds-tbl"], {"neutralizeFields": None})
+ self.assertEqual(modifyMap["fds-ws"], {"neutralize": True})
+
+ def test_fds_workspace_rag_does_not_clear_descendant_neutralize_fields(self):
+ """A RAG toggle on the workspace must not touch descendant
+ `neutralizeFields`."""
+ wsRec = {
+ "id": "fds-ws", "featureInstanceId": "fi1", "tableName": "*",
+ "recordFilter": None, "ragIndexEnabled": None,
+ }
+ tblRec = {
+ "id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
+ "recordFilter": None, "ragIndexEnabled": None,
+ "neutralizeFields": ["amount"],
+ }
+ node = FdsWorkspaceNode("m1", "trustee", "fi1", label="Trustee",
+ icon="trustee", parentKey="mgrp|m1", rec=wsRec)
+ rootIf = MagicMock()
+ rootIf.db.getRecordset.return_value = [wsRec, tblRec]
+ node.setFlag("ragIndexEnabled", True, rootIf)
+ calls = rootIf.db.recordModify.call_args_list
+ modifyIds = [c[0][1] for c in calls]
+ self.assertNotIn("fds-tbl", modifyIds)
+
+
+class TestToDict(unittest.TestCase):
+ def test_fds_table_dict_has_neutralize_fields(self):
+ rec = {
+ "id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
+ "neutralizeFields": ["amount"],
+ }
+ node = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
+ "feat|m1|trustee|fi1", rec=rec, hasFields=True)
+ out = node.toDict([], [rec])
+ self.assertEqual(out["neutralizeFields"], ["amount"])
+ self.assertEqual(out["kind"], "fdsTable")
+ self.assertEqual(out["modelType"], "FeatureDataSource")
+ self.assertEqual(out["effectiveScope"], "personal") # FDS has no scope
+
+ def test_synthetic_container_has_no_dataSourceId(self):
+ n = SyntheticContainerNode("personalRoot", "Personal", icon="person",
+ defaultExpanded=True)
+ d = n.toDict([], [])
+ self.assertIsNone(d["dataSourceId"])
+ self.assertEqual(d["effectiveNeutralize"], False)
+
+
+class TestIsFeatureAdmin(unittest.TestCase):
+ def test_no_access_returns_false(self):
+ rootIf = MagicMock()
+ rootIf.getFeatureAccess.return_value = None
+ self.assertFalse(_isFeatureAdmin(rootIf, "user-1", "fi1"))
+
+ def test_no_roles_returns_false(self):
+ rootIf = MagicMock()
+ rootIf.getFeatureAccess.return_value = MagicMock(id="acc1", enabled=True)
+ rootIf.getRoleIdsForFeatureAccess.return_value = []
+ self.assertFalse(_isFeatureAdmin(rootIf, "user-1", "fi1"))
+
+ def test_non_admin_role_returns_false(self):
+ rootIf = MagicMock()
+ rootIf.getFeatureAccess.return_value = MagicMock(id="acc1", enabled=True)
+ rootIf.getRoleIdsForFeatureAccess.return_value = ["r1"]
+ rootIf.db.getRecord.return_value = {"id": "r1", "roleLabel": "trustee-user"}
+ self.assertFalse(_isFeatureAdmin(rootIf, "user-1", "fi1"))
+
+ def test_admin_role_returns_true(self):
+ rootIf = MagicMock()
+ rootIf.getFeatureAccess.return_value = MagicMock(id="acc1", enabled=True)
+ rootIf.getRoleIdsForFeatureAccess.return_value = ["r1"]
+ rootIf.db.getRecord.return_value = {"id": "r1", "roleLabel": "workspace-admin"}
+ self.assertTrue(_isFeatureAdmin(rootIf, "user-1", "fi1"))
+
+
+if __name__ == "__main__":
+ unittest.main()