icon toggle
All checks were successful
Deploy Plattform-Core / test (push) Successful in 53s
Deploy Plattform-Core / deploy (push) Successful in 4s

This commit is contained in:
ValueOn AG 2026-05-27 15:36:03 +02:00
parent 513ded84d5
commit 51b789b5aa
14 changed files with 2227 additions and 1261 deletions

3
app.py
View file

@ -616,6 +616,9 @@ app.include_router(fileRouter)
from modules.routes.routeDataSources import router as dataSourceRouter from modules.routes.routeDataSources import router as dataSourceRouter
app.include_router(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 from modules.routes.routeDataPrompts import router as promptRouter
app.include_router(promptRouter) app.include_router(promptRouter)

View file

@ -43,31 +43,9 @@ class FeatureDataSource(PowerOnModel):
) )
mandateId: str = Field( mandateId: str = Field(
default="", 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"}}, 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( neutralize: Optional[bool] = Field(
default=None, default=None,
description=( description=(

View file

@ -986,7 +986,11 @@ async def listWorkspaceWorkflows(
"startedAt": getattr(wf, "startedAt", None), "startedAt": getattr(wf, "startedAt", None),
"lastActivity": getattr(wf, "lastActivity", None), "lastActivity": getattr(wf, "lastActivity", None),
"featureInstanceId": getattr(wf, "featureInstanceId", instanceId), "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": if not includeArchived and item.get("status") == "archived":
continue continue
fiId = item.get("featureInstanceId") or instanceId fiId = item.get("featureInstanceId") or instanceId
@ -1311,73 +1315,6 @@ async def listWorkspaceDataSources(
return JSONResponse({"dataSources": []}) 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): class CreateDataSourceRequest(BaseModel):
"""Request body for creating a DataSource.""" """Request body for creating a DataSource."""
connectionId: str = Field(description="Connection ID") connectionId: str = Field(description="Connection ID")
@ -1458,19 +1395,15 @@ async def createFeatureDataSource(
body: CreateFeatureDataSourceRequest = Body(...), body: CreateFeatureDataSourceRequest = Body(...),
context: RequestContext = Depends(getRequestContext), 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 The FDS belongs to the FEATURE-INSTANCE (not to a workspace). Flag editing
matches how the tree (`allFds = recordset where workspaceInstanceId = is governed by feature-admin RBAC on that feature instance (see the
instanceId`) and the PATCH endpoints scope these records by workspace, UDB reference page for the polymorphic node model). The `instanceId`
not by feature mandate. The user can legitimately reference a feature in the URL path is the calling consumer's feature instance and is used
from another mandate they have access to (via the UDB mandate-group only for access validation, not for FDS scoping.
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.
""" """
wsMandateId, _ = _validateInstanceAccess(instanceId, context) _validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
@ -1478,8 +1411,10 @@ async def createFeatureDataSource(
if not rootIf.getFeatureAccess(str(context.user.id), body.featureInstanceId): if not rootIf.getFeatureAccess(str(context.user.id), body.featureInstanceId):
raise HTTPException(status_code=403, detail=routeApiMsg("Access denied to this feature instance")) 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={ existing = rootIf.db.getRecordset(FeatureDataSource, recordFilter={
"workspaceInstanceId": instanceId,
"featureInstanceId": body.featureInstanceId, "featureInstanceId": body.featureInstanceId,
"tableName": body.tableName, "tableName": body.tableName,
}) or [] }) or []
@ -1494,9 +1429,7 @@ async def createFeatureDataSource(
tableName=body.tableName, tableName=body.tableName,
objectKey=body.objectKey, objectKey=body.objectKey,
label=body.label, label=body.label,
mandateId=wsMandateId or "", mandateId=fiMandateId,
userId=str(context.user.id),
workspaceInstanceId=instanceId,
recordFilter=body.recordFilter, recordFilter=body.recordFilter,
) )
created = rootIf.db.recordCreate(FeatureDataSource, fds.model_dump()) created = rootIf.db.recordCreate(FeatureDataSource, fds.model_dump())
@ -1510,27 +1443,28 @@ async def listFeatureDataSources(
instanceId: str = Path(...), instanceId: str = Path(...),
context: RequestContext = Depends(getRequestContext), 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) wsMandateId, _ = _validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import buildEffectiveByWorkspaceFds from modules.serviceCenter.services.serviceKnowledge._inheritFlags import buildEffectiveByWorkspaceFds
rootIf = getRootInterface() rootIf = getRootInterface()
recordFilter: dict = {"workspaceInstanceId": instanceId} recordFilter: dict = {}
if wsMandateId: if wsMandateId:
recordFilter["mandateId"] = wsMandateId recordFilter["mandateId"] = wsMandateId
records = rootIf.db.getRecordset(FeatureDataSource, recordFilter=recordFilter) records = rootIf.db.getRecordset(FeatureDataSource, recordFilter=recordFilter) or []
if not records: if not records:
return JSONResponse({"featureDataSources": []}) return JSONResponse({"featureDataSources": []})
effNeutralize = buildEffectiveByWorkspaceFds(records, "neutralize", mode="aggregate") effNeutralize = buildEffectiveByWorkspaceFds(records, "neutralize", mode="aggregate")
effScope = buildEffectiveByWorkspaceFds(records, "scope", mode="aggregate")
effRag = buildEffectiveByWorkspaceFds(records, "ragIndexEnabled", mode="aggregate") effRag = buildEffectiveByWorkspaceFds(records, "ragIndexEnabled", mode="aggregate")
for fds in records: for fds in records:
fdsId = fds.get("id", "") fdsId = fds.get("id", "")
fds["effectiveNeutralize"] = effNeutralize.get(fdsId, False) fds["effectiveNeutralize"] = effNeutralize.get(fdsId, False)
fds["effectiveScope"] = effScope.get(fdsId, "personal")
fds["effectiveRagIndexEnabled"] = effRag.get(fdsId, False) fds["effectiveRagIndexEnabled"] = effRag.get(fdsId, False)
return JSONResponse({"featureDataSources": records}) return JSONResponse({"featureDataSources": records})

View file

@ -1,6 +1,11 @@
# Copyright (c) 2025 Patrick Motsch # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # 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 import logging
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@ -43,49 +48,6 @@ def _ensureConnectionKnowledgeFlag(rootIf, connectionId: str) -> None:
except Exception as e: except Exception as e:
logger.warning("Could not auto-enable knowledgeIngestionEnabled for connection %s: %s", connectionId, 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( router = APIRouter(
prefix="/api/datasources", prefix="/api/datasources",
tags=["Data Sources"], tags=["Data Sources"],
@ -98,9 +60,6 @@ router = APIRouter(
}, },
) )
_VALID_SCOPES = {"personal", "featureInstance", "mandate", "global"}
def _findSourceRecord(db, sourceId: str): def _findSourceRecord(db, sourceId: str):
"""Look up a source by ID, checking DataSource first, then FeatureDataSource.""" """Look up a source by ID, checking DataSource first, then FeatureDataSource."""
rec = db.getRecord(DataSource, sourceId) rec = db.getRecord(DataSource, sourceId)
@ -112,250 +71,6 @@ def _findSourceRecord(db, sourceId: str):
return None, None 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"} _CLICKUP_SOURCE_TYPES = {"clickup", "clickupList", "clickupSpace", "clickupFolder"}
_ALLOWED_RAG_LIMIT_KEYS = { _ALLOWED_RAG_LIMIT_KEYS = {
"files": {"maxItems", "maxBytes", "maxFileSize", "maxDepth"}, "files": {"maxItems", "maxBytes", "maxFileSize", "maxDepth"},
@ -412,8 +127,9 @@ def _updateDataSourceSettings(
Currently supports `ragLimits` only. Unknown top-level keys in the body are Currently supports `ragLimits` only. Unknown top-level keys in the body are
rejected to avoid silently storing garbage that no consumer reads. rejected to avoid silently storing garbage that no consumer reads.
Owner-only for personal DataSources; mandate/feature scopes additionally DataSource: owner-only (or sysadmin). For mandate/feature scopes the
accept the mandate or workspace admins of that scope. 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): if not isinstance(settings, dict):
raise HTTPException(status_code=400, detail="settings must be an object") raise HTTPException(status_code=400, detail="settings must be an object")
@ -428,23 +144,22 @@ def _updateDataSourceSettings(
if not rec: if not rec:
raise HTTPException(status_code=404, detail=f"DataSource {sourceId} not found") raise HTTPException(status_code=404, detail=f"DataSource {sourceId} not found")
ownerId = str(rec.get("userId") or "")
currentUserId = str(context.user.id) currentUserId = str(context.user.id)
if ownerId and ownerId != currentUserId and not context.isSysAdmin: if model is DataSource:
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlag ownerId = str(rec.get("userId") or "")
if model is DataSource: if ownerId and ownerId != currentUserId and not context.isSysAdmin:
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlag
connectionId = rec.get("connectionId", "") connectionId = rec.get("connectionId", "")
allDs = rootIf.db.getRecordset(DataSource, recordFilter={"connectionId": connectionId}) allDs = rootIf.db.getRecordset(DataSource, recordFilter={"connectionId": connectionId})
scope = str(getEffectiveFlag(rec, "scope", allDs, mode="walk")) scope = str(getEffectiveFlag(rec, "scope", allDs, mode="walk"))
else: isMandateAdmin = getattr(context, "isMandateAdmin", False)
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource as FDS if scope == "personal" or not isMandateAdmin:
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlagFds raise HTTPException(status_code=403, detail="Not allowed to modify this DataSource's settings")
wsId = rec.get("workspaceInstanceId", "") else:
allFds = rootIf.db.getRecordset(FDS, recordFilter={"workspaceInstanceId": wsId}) from modules.serviceCenter.services.serviceKnowledge.udbNodes import _isFeatureAdmin
scope = str(getEffectiveFlagFds(rec, "scope", allFds, mode="walk")) featureInstanceId = str(rec.get("featureInstanceId") or "")
isMandateAdmin = getattr(context, "isMandateAdmin", False) if not (context.isSysAdmin or _isFeatureAdmin(rootIf, currentUserId, featureInstanceId)):
if scope == "personal" or not isMandateAdmin: raise HTTPException(status_code=403, detail="Not allowed to modify this FeatureDataSource's settings")
raise HTTPException(status_code=403, detail="Not allowed to modify this DataSource's settings")
kind = _kindForSource(rec, model) kind = _kindForSource(rec, model)

View file

@ -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" st = (r.get("status") if isinstance(r, dict) else getattr(r, "status", "unknown")) or "unknown"
statusCounts[st] = statusCounts.get(st, 0) + 1 statusCounts[st] = statusCounts.get(st, 0) + 1
allFds = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"workspaceInstanceId": fiId}) allFds = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"featureInstanceId": fiId})
dsItems = [] dsItems = []
anyRagEnabled = False anyRagEnabled = False
for fds in allFds: for fds in allFds:
@ -287,7 +287,7 @@ def _buildFeatureInstanceInventory(featureInstanceIds, rootIf, knowledgeIf) -> L
fiJobs = [ fiJobs = [
j for j in allFeatureJobs j for j in allFeatureJobs
if (j.get("payload") or {}).get("workspaceInstanceId") == fiId if (j.get("payload") or {}).get("featureInstanceId") == fiId
] ]
runningJobs = [ runningJobs = [
{ {
@ -572,17 +572,18 @@ async def _reindexConnection(
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/reindex-feature/{workspaceInstanceId}") @router.post("/reindex-feature/{featureInstanceId}")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def _reindexFeature( async def _reindexFeature(
request: Request, request: Request,
workspaceInstanceId: str, featureInstanceId: str,
currentUser: User = Depends(getCurrentUser), currentUser: User = Depends(getCurrentUser),
) -> Dict[str, Any]: ) -> 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. Indexes all RAG-enabled FeatureDataSource rows owned by this feature
Must be ``async def`` so ``await startJob(...)`` registers in the main loop. instance into the knowledge store. Must be ``async def`` so
``await startJob(...)`` registers in the main loop.
""" """
try: try:
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
@ -592,7 +593,7 @@ async def _reindexFeature(
rootIf = getRootInterface() rootIf = getRootInterface()
featureAccesses = rootIf.getFeatureAccessesForUser(str(currentUser.id)) featureAccesses = rootIf.getFeatureAccessesForUser(str(currentUser.id))
hasAccess = any( hasAccess = any(
str(fa.featureInstanceId) == workspaceInstanceId and fa.enabled str(fa.featureInstanceId) == featureInstanceId and fa.enabled
for fa in featureAccesses for fa in featureAccesses
) )
if not hasAccess and not getattr(currentUser, "isSysAdmin", False): if not hasAccess and not getattr(currentUser, "isSysAdmin", False):
@ -600,12 +601,12 @@ async def _reindexFeature(
jobId = await startJob( jobId = await startJob(
FEATURE_BOOTSTRAP_JOB_TYPE, FEATURE_BOOTSTRAP_JOB_TYPE,
{"workspaceInstanceId": workspaceInstanceId}, {"featureInstanceId": featureInstanceId},
triggeredBy=str(currentUser.id), triggeredBy=str(currentUser.id),
) )
logger.info("Feature reindex triggered for workspace %s (jobId=%s)", workspaceInstanceId, jobId) logger.info("Feature reindex triggered for feature %s (jobId=%s)", featureInstanceId, jobId)
return {"status": "queued", "workspaceInstanceId": workspaceInstanceId, "jobId": jobId} return {"status": "queued", "featureInstanceId": featureInstanceId, "jobId": jobId}
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:

229
modules/routes/routeUdb.py Normal file
View file

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

View file

@ -91,7 +91,6 @@ def _registerFeatureSubAgentTools(registry: ToolRegistry, services):
mandateId = instance.mandateId or "" mandateId = instance.mandateId or ""
instanceLabel = instance.label or "" instanceLabel = instance.label or ""
userId = context.get("userId", "") userId = context.get("userId", "")
workspaceInstanceId = context.get("featureInstanceId", "")
requestLang = None requestLang = None
if userId: if userId:
langUser = rootIf.getUser(userId) langUser = rootIf.getUser(userId)
@ -107,7 +106,7 @@ def _registerFeatureSubAgentTools(registry: ToolRegistry, services):
featureDataSources = rootDbConn.getRecordset( featureDataSources = rootDbConn.getRecordset(
FeatureDataSource, FeatureDataSource,
recordFilter={"featureInstanceId": featureInstanceId, "workspaceInstanceId": workspaceInstanceId}, recordFilter={"featureInstanceId": featureInstanceId},
) )
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlagFds from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlagFds

File diff suppressed because it is too large Load diff

View file

@ -28,7 +28,7 @@ from typing import Any, Dict, Iterable, List, Literal, Optional, Tuple
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_INHERITABLE_FLAGS = ("neutralize", "ragIndexEnabled", "scope") _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 # Connection-root DataSources carry the authority as their sourceType
# (e.g. 'msft', 'google'). They sit one level above all service DataSources # (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}") raise ValueError(f"Unknown inheritable FDS flag: {flag}")
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
workspaceInstanceId = _getRecordValue(parentRec, "workspaceInstanceId") featureInstanceId = _getRecordValue(parentRec, "featureInstanceId")
if not workspaceInstanceId: if not featureInstanceId:
return [] return []
siblings = rootIf.db.getRecordset( siblings = rootIf.db.getRecordset(
FeatureDataSource, recordFilter={"workspaceInstanceId": workspaceInstanceId} FeatureDataSource, recordFilter={"featureInstanceId": featureInstanceId}
) )
toReset: List[Tuple[int, str]] = [] toReset: List[Tuple[int, str]] = []
@ -475,7 +475,6 @@ def cascadeResetDescendantsFds(
sibId = _getRecordValue(sib, "id") sibId = _getRecordValue(sib, "id")
toReset.append((_fdsDepth(sib), sibId)) toReset.append((_fdsDepth(sib), sibId))
# Sort deepest first (bottom-up)
toReset.sort(key=lambda x: x[0], reverse=True) toReset.sort(key=lambda x: x[0], reverse=True)
resetIds: List[str] = [] resetIds: List[str] = []
@ -576,9 +575,9 @@ def resolveEffectiveForPath(
"ragIndexEnabled": None, "ragIndexEnabled": None,
} }
return { return {
"effectiveNeutralize": _resolveWalkValue(virtualRec, "neutralize", allDs), "effectiveNeutralize": getEffectiveFlag(virtualRec, "neutralize", allDs, mode=mode),
"effectiveScope": _resolveWalkValue(virtualRec, "scope", allDs), "effectiveScope": getEffectiveFlag(virtualRec, "scope", allDs, mode=mode),
"effectiveRagIndexEnabled": _resolveWalkValue(virtualRec, "ragIndexEnabled", allDs), "effectiveRagIndexEnabled": getEffectiveFlag(virtualRec, "ragIndexEnabled", allDs, mode=mode),
} }
@ -591,11 +590,11 @@ def resolveEffectiveForFds(
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Resolve effective flags for ANY FDS tuple (even without DB record). """Resolve effective flags for ANY FDS tuple (even without DB record).
`allFds` is pre-scoped to a single workspace (loaded with `allFds` is pre-scoped (typically to a mandate). Within that set, the
workspaceInstanceId filter). Within that set, the coordinate is coordinate is featureInstanceId + tableName + recordFilter.
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 exactRecord = None
for fds in allFds: for fds in allFds:
@ -611,7 +610,6 @@ def resolveEffectiveForFds(
if exactRecord: if exactRecord:
return { return {
"effectiveNeutralize": getEffectiveFlagFds(exactRecord, "neutralize", allFds, mode=mode), "effectiveNeutralize": getEffectiveFlagFds(exactRecord, "neutralize", allFds, mode=mode),
"effectiveScope": getEffectiveFlagFds(exactRecord, "scope", allFds, mode=mode),
"effectiveRagIndexEnabled": getEffectiveFlagFds(exactRecord, "ragIndexEnabled", allFds, mode=mode), "effectiveRagIndexEnabled": getEffectiveFlagFds(exactRecord, "ragIndexEnabled", allFds, mode=mode),
} }
@ -621,11 +619,9 @@ def resolveEffectiveForFds(
"tableName": tableName, "tableName": tableName,
"recordFilter": recordFilter, "recordFilter": recordFilter,
"neutralize": None, "neutralize": None,
"scope": None,
"ragIndexEnabled": None, "ragIndexEnabled": None,
} }
return { return {
"effectiveNeutralize": _resolveWalkValueFds(virtualRec, "neutralize", allFds), "effectiveNeutralize": getEffectiveFlagFds(virtualRec, "neutralize", allFds, mode=mode),
"effectiveScope": _resolveWalkValueFds(virtualRec, "scope", allFds), "effectiveRagIndexEnabled": getEffectiveFlagFds(virtualRec, "ragIndexEnabled", allFds, mode=mode),
"effectiveRagIndexEnabled": _resolveWalkValueFds(virtualRec, "ragIndexEnabled", allFds),
} }

View file

@ -9,7 +9,7 @@ text, and feeds it through KnowledgeService.requestIngestion so the data
appears in ContentChunk embeddings for semantic RAG search. appears in ContentChunk embeddings for semantic RAG search.
Job type: ``feature.bootstrap`` Job type: ``feature.bootstrap``
Payload: ``{"workspaceInstanceId": "...", "featureDataSourceIds": [...] (optional)}`` Payload: ``{"featureInstanceId": "...", "featureDataSourceIds": [...] (optional)}``
""" """
from __future__ import annotations from __future__ import annotations
@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
FEATURE_BOOTSTRAP_JOB_TYPE = "feature.bootstrap" 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. """Load FeatureDataSource rows whose effective ragIndexEnabled is True.
Returns dicts with resolved flags so downstream code can read them directly. Returns dicts with resolved flags so downstream code can read them directly.
@ -34,7 +34,7 @@ def _loadRagEnabledFds(workspaceInstanceId: str, featureDataSourceIds: Optional[
rootIf = getRootInterface() rootIf = getRootInterface()
allFds = rootIf.db.getRecordset( allFds = rootIf.db.getRecordset(
FeatureDataSource, recordFilter={"workspaceInstanceId": workspaceInstanceId} FeatureDataSource, recordFilter={"featureInstanceId": featureInstanceId}
) )
resolved = [] resolved = []
for fds in allFds: for fds in allFds:
@ -47,7 +47,6 @@ def _loadRagEnabledFds(workspaceInstanceId: str, featureDataSourceIds: Optional[
continue continue
row = dict(fds) if isinstance(fds, dict) else {**fds.__dict__} row = dict(fds) if isinstance(fds, dict) else {**fds.__dict__}
row["_effectiveNeutralize"] = getEffectiveFlagFds(fds, "neutralize", allFds, mode="aggregate") row["_effectiveNeutralize"] = getEffectiveFlagFds(fds, "neutralize", allFds, mode="aggregate")
row["_effectiveScope"] = getEffectiveFlagFds(fds, "scope", allFds, mode="aggregate") or "featureInstance"
row["ragIndexEnabled"] = True row["ragIndexEnabled"] = True
resolved.append(row) resolved.append(row)
@ -104,20 +103,20 @@ async def _featureBootstrapHandler(
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Walk RAG-enabled FeatureDataSources and index their rows.""" """Walk RAG-enabled FeatureDataSources and index their rows."""
payload = job.get("payload") or {} payload = job.get("payload") or {}
workspaceInstanceId = payload.get("workspaceInstanceId") featureInstanceId = payload.get("featureInstanceId")
featureDataSourceIds = payload.get("featureDataSourceIds") featureDataSourceIds = payload.get("featureDataSourceIds")
if not workspaceInstanceId: if not featureInstanceId:
raise ValueError("feature.bootstrap requires payload.workspaceInstanceId") raise ValueError("feature.bootstrap requires payload.featureInstanceId")
progressCb(5, messageKey="Feature-Datenquellen werden geladen...") progressCb(5, messageKey="Feature-Datenquellen werden geladen...")
fdsList = _loadRagEnabledFds(workspaceInstanceId, featureDataSourceIds) fdsList = _loadRagEnabledFds(featureInstanceId, featureDataSourceIds)
if not fdsList: if not fdsList:
logger.info( logger.info(
"feature.bootstrap.skipped — no rag-enabled FDS for workspace %s", "feature.bootstrap.skipped — no rag-enabled FDS for feature %s",
workspaceInstanceId, 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.serviceAgent.featureDataProvider import FeatureDataProvider
from modules.serviceCenter.services.serviceKnowledge.mainServiceKnowledge import IngestionJob from modules.serviceCenter.services.serviceKnowledge.mainServiceKnowledge import IngestionJob
@ -134,11 +133,10 @@ async def _featureBootstrapHandler(
fdsId = fds.get("id", "") fdsId = fds.get("id", "")
featureCode = fds.get("featureCode", "") featureCode = fds.get("featureCode", "")
tableName = fds.get("tableName", "") tableName = fds.get("tableName", "")
featureInstanceId = fds.get("featureInstanceId", "") fdsFeatureInstanceId = fds.get("featureInstanceId", "")
mandateId = fds.get("mandateId", "") mandateId = fds.get("mandateId", "")
neutralizeFields = fds.get("neutralizeFields") or [] neutralizeFields = fds.get("neutralizeFields") or []
recordFilter = fds.get("recordFilter") or {} recordFilter = fds.get("recordFilter") or {}
effectiveScope = fds.get("_effectiveScope", "featureInstance")
effectiveNeutralize = bool(fds.get("_effectiveNeutralize", False)) effectiveNeutralize = bool(fds.get("_effectiveNeutralize", False))
progressPct = 5 + int(90 * fdsIdx / len(fdsList)) progressPct = 5 + int(90 * fdsIdx / len(fdsList))
@ -148,7 +146,7 @@ async def _featureBootstrapHandler(
messageParams={"table": tableName, "n": fdsIdx + 1, "total": len(fdsList)}, 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) logger.warning("feature.bootstrap: skipping FDS %s — missing featureCode/tableName/fiId", fdsId)
continue continue
@ -160,7 +158,7 @@ async def _featureBootstrapHandler(
ctx = ServiceCenterContext( ctx = ServiceCenterContext(
user=rootUser, user=rootUser,
mandate_id=mandateId, mandate_id=mandateId,
feature_instance_id=workspaceInstanceId, feature_instance_id=fdsFeatureInstanceId,
) )
knowledgeService = getService("knowledge", ctx) knowledgeService = getService("knowledge", ctx)
@ -178,7 +176,7 @@ async def _featureBootstrapHandler(
while True: while True:
result = provider.browseTable( result = provider.browseTable(
tableName=tableName, tableName=tableName,
featureInstanceId=featureInstanceId, featureInstanceId=fdsFeatureInstanceId,
mandateId=mandateId, mandateId=mandateId,
limit=batchSize, limit=batchSize,
offset=offset, offset=offset,
@ -202,11 +200,11 @@ async def _featureBootstrapHandler(
ingestionJob = IngestionJob( ingestionJob = IngestionJob(
sourceKind="feature_record", sourceKind="feature_record",
sourceId=f"{workspaceInstanceId}:{tableName}:{rowId}", sourceId=f"{fdsFeatureInstanceId}:{tableName}:{rowId}",
fileName=f"{tableName}-{rowId}", fileName=f"{tableName}-{rowId}",
mimeType="application/vnd.poweron.feature-record+json", mimeType="application/vnd.poweron.feature-record+json",
userId=fds.get("userId") or "system", userId="system",
featureInstanceId=workspaceInstanceId, featureInstanceId=fdsFeatureInstanceId,
mandateId=mandateId, mandateId=mandateId,
contentObjects=[{ contentObjects=[{
"contentType": "text", "contentType": "text",
@ -214,7 +212,7 @@ async def _featureBootstrapHandler(
"contextRef": { "contextRef": {
"table": tableName, "table": tableName,
"featureCode": featureCode, "featureCode": featureCode,
"featureInstanceId": featureInstanceId, "featureInstanceId": fdsFeatureInstanceId,
"rowId": rowId, "rowId": rowId,
}, },
"contentObjectId": f"{tableName}:{rowId}", "contentObjectId": f"{tableName}:{rowId}",
@ -225,7 +223,7 @@ async def _featureBootstrapHandler(
"featureDataSourceId": fdsId, "featureDataSourceId": fdsId,
"tableName": tableName, "tableName": tableName,
"featureCode": featureCode, "featureCode": featureCode,
"featureInstanceId": featureInstanceId, "featureInstanceId": fdsFeatureInstanceId,
}, },
neutralize=effectiveNeutralize, neutralize=effectiveNeutralize,
) )
@ -281,7 +279,7 @@ async def _featureBootstrapHandler(
progressCb(100, messageKey="Feature-Daten-Sync abgeschlossen.") progressCb(100, messageKey="Feature-Daten-Sync abgeschlossen.")
return { return {
"workspaceInstanceId": workspaceInstanceId, "featureInstanceId": featureInstanceId,
"indexed": totalIndexed, "indexed": totalIndexed,
"skippedDuplicate": totalSkipped, "skippedDuplicate": totalSkipped,
"failed": totalFailed, "failed": totalFailed,

File diff suppressed because it is too large Load diff

View file

@ -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 Most node-level behavior moved into the polymorphic class hierarchy
existing handlers (top-level, conn, mgrp, feat) are produced with the (`udbNodes.py`) and has its own dedicated tests in `test_udbNodes.py`.
correct effective-flag triplet. This file covers the orchestrator (`getChildrenForParents`) and the
remaining lookup helpers.
""" """
from __future__ import annotations 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"]) 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): class TestRecordLookup(unittest.TestCase):
def test_finds_ds_record_by_normalised_path(self): def test_finds_ds_record_by_normalised_path(self):
rec = {"id": "x", "connectionId": "c", "sourceType": "msft", "path": "/folder"} 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")) self.assertIsNone(_buildTree._findDsRecord([rec], "c", "msft", "/other"))
def test_finds_fds_record_with_matching_filter(self): 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.assertEqual(_buildTree._findFdsRecord([rec], "fi1", "Pos", {"id": "5"}).get("id"), "f")
self.assertIsNone(_buildTree._findFdsRecord([rec], "fi1", "Pos", {"id": "99"})) self.assertIsNone(_buildTree._findFdsRecord([rec], "fi1", "Pos", {"id": "99"}))
def test_fds_record_with_none_filter_matches_only_none(self): 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.assertEqual(_buildTree._findFdsRecord([rec], "fi1", "*", None).get("id"), "f")
self.assertIsNone(_buildTree._findFdsRecord([rec], "fi1", "*", {"id": "1"})) 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): 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): def _runAsync(self, coro):
return asyncio.run(coro) return asyncio.run(coro)
@ -92,12 +122,11 @@ class TestGetChildrenForParents(unittest.TestCase):
ctx.mandateId = "m1" ctx.mandateId = "m1"
result = self._runAsync( result = self._runAsync(
_buildTree.getChildrenForParents("inst-1", ["bogus|key"], ctx) _buildTree.getChildrenForParents(["bogus|key"], ctx)
) )
self.assertEqual(result["bogus|key"], []) self.assertEqual(result["bogus|key"], [])
def test_top_level_emits_personal_root_first(self): 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: with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot:
rootIf = MagicMock() rootIf = MagicMock()
rootIf.db.getRecordset.return_value = [] rootIf.db.getRecordset.return_value = []
@ -109,7 +138,7 @@ class TestGetChildrenForParents(unittest.TestCase):
ctx.mandateId = "m1" ctx.mandateId = "m1"
result = self._runAsync( result = self._runAsync(
_buildTree.getChildrenForParents("inst-1", [None], ctx) _buildTree.getChildrenForParents([None], ctx)
) )
children = result["__root__"] children = result["__root__"]
self.assertGreaterEqual(len(children), 1) self.assertGreaterEqual(len(children), 1)
@ -120,92 +149,7 @@ class TestGetChildrenForParents(unittest.TestCase):
self.assertTrue(personalRoot["hasChildren"]) self.assertTrue(personalRoot["hasChildren"])
self.assertTrue(personalRoot["defaultExpanded"]) self.assertTrue(personalRoot["defaultExpanded"])
def test_top_level_emits_mandate_groups_inline(self):
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):
with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot, \ with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot, \
patch("modules.security.rbacCatalog.getCatalogService") as mockCatalog: patch("modules.security.rbacCatalog.getCatalogService") as mockCatalog:
rootIf = MagicMock() rootIf = MagicMock()
@ -218,8 +162,7 @@ class TestTopLevelLayout(unittest.TestCase):
featureInst.featureCode = "trustee" featureInst.featureCode = "trustee"
featureInst.enabled = True featureInst.enabled = True
rootIf.getFeatureInstancesByMandate.return_value = [featureInst] rootIf.getFeatureInstancesByMandate.return_value = [featureInst]
featureAccess = MagicMock() featureAccess = MagicMock(enabled=True)
featureAccess.enabled = True
rootIf.getFeatureAccess.return_value = featureAccess rootIf.getFeatureAccess.return_value = featureAccess
mockRoot.return_value = rootIf mockRoot.return_value = rootIf
@ -231,11 +174,8 @@ class TestTopLevelLayout(unittest.TestCase):
ctx.user.id = "u1" ctx.user.id = "u1"
ctx.mandateId = None ctx.mandateId = None
result = self._runAsync( result = self._runAsync(_buildTree.getChildrenForParents([None], ctx))
_buildTree.getChildrenForParents("inst-1", [None], ctx) byKey = {c["key"]: c for c in result["__root__"]}
)
children = result["__root__"]
byKey = {c["key"]: c for c in children}
self.assertIn("personalRoot", byKey) self.assertIn("personalRoot", byKey)
self.assertIn("mgrp|m1", byKey) self.assertIn("mgrp|m1", byKey)
mgroup = byKey["mgrp|m1"] mgroup = byKey["mgrp|m1"]
@ -243,116 +183,6 @@ class TestTopLevelLayout(unittest.TestCase):
self.assertIsNone(mgroup["parentKey"]) self.assertIsNone(mgroup["parentKey"])
self.assertEqual(mgroup["mandateId"], "m1") self.assertEqual(mgroup["mandateId"], "m1")
self.assertTrue(mgroup["defaultExpanded"]) 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__": if __name__ == "__main__":

View file

@ -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: 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 = { base = {
"id": idVal, "id": idVal,
"workspaceInstanceId": "ws-1",
"featureInstanceId": featureInstanceId, "featureInstanceId": featureInstanceId,
"tableName": tableName, "tableName": tableName,
"recordFilter": recordFilter, "recordFilter": recordFilter,
"neutralize": None, "neutralize": None,
"scope": None, "ragIndexEnabled": None,
} }
base.update(flags) base.update(flags)
return base return base
@ -473,6 +477,7 @@ class TestFdsCascadeReset(unittest.TestCase):
_inheritFlags.cascadeResetDescendantsFds(rootIf, ws, "doesNotExist") _inheritFlags.cascadeResetDescendantsFds(rootIf, ws, "doesNotExist")
# =========================================================================== # ===========================================================================
# FeatureDataSource: collectAncestorChainFds # FeatureDataSource: collectAncestorChainFds
# =========================================================================== # ===========================================================================
@ -572,28 +577,32 @@ class TestResolveEffectiveForPath(unittest.TestCase):
class TestResolveEffectiveForFds(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): def test_with_exact_record(self):
ws = _fds("ws", tableName="*", neutralize=True, scope="mandate") ws = _fds("ws", tableName="*", neutralize=True)
tbl = _fds("t", tableName="Pos", neutralize=False, scope="personal") tbl = _fds("t", tableName="Pos", neutralize=False)
allFds = [ws, tbl] allFds = [ws, tbl]
result = _inheritFlags.resolveEffectiveForFds("fi-1", "Pos", None, allFds) result = _inheritFlags.resolveEffectiveForFds("fi-1", "Pos", None, allFds)
self.assertEqual(result["effectiveNeutralize"], False) self.assertEqual(result["effectiveNeutralize"], False)
self.assertEqual(result["effectiveScope"], "personal")
self.assertEqual(result["effectiveRagIndexEnabled"], False) self.assertEqual(result["effectiveRagIndexEnabled"], False)
self.assertNotIn("effectiveScope", result)
def test_without_record_inherits_from_workspace_wildcard(self): def test_without_record_inherits_from_feature_wildcard(self):
ws = _fds("ws", tableName="*", neutralize=True, scope="mandate", ragIndexEnabled=True) ws = _fds("ws", tableName="*", neutralize=True, ragIndexEnabled=True)
allFds = [ws] allFds = [ws]
result = _inheritFlags.resolveEffectiveForFds("fi-1", "Unknown", None, allFds) result = _inheritFlags.resolveEffectiveForFds("fi-1", "Unknown", None, allFds)
self.assertEqual(result["effectiveNeutralize"], True) self.assertEqual(result["effectiveNeutralize"], True)
self.assertEqual(result["effectiveScope"], "mandate")
self.assertEqual(result["effectiveRagIndexEnabled"], True) self.assertEqual(result["effectiveRagIndexEnabled"], True)
def test_without_record_no_ancestors_returns_defaults(self): def test_without_record_no_ancestors_returns_defaults(self):
allFds: list = [] allFds: list = []
result = _inheritFlags.resolveEffectiveForFds("fi-1", "Pos", None, allFds) result = _inheritFlags.resolveEffectiveForFds("fi-1", "Pos", None, allFds)
self.assertEqual(result["effectiveNeutralize"], False) self.assertEqual(result["effectiveNeutralize"], False)
self.assertEqual(result["effectiveScope"], "personal")
self.assertEqual(result["effectiveRagIndexEnabled"], False) self.assertEqual(result["effectiveRagIndexEnabled"], False)
def test_rag_inherits_when_table_overrides_neutralize_only(self): 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") result = _inheritFlags.resolveEffectiveForFds("fi-1", "*", None, allFds, mode="aggregate")
self.assertEqual(result["effectiveRagIndexEnabled"], "mixed") 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("ragIndexEnabled", _inheritFlags._INHERITABLE_FDS_FLAGS)
self.assertIn("neutralize", _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") 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

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