icon toggle
This commit is contained in:
parent
513ded84d5
commit
51b789b5aa
14 changed files with 2227 additions and 1261 deletions
3
app.py
3
app.py
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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=(
|
||||||
|
|
|
||||||
|
|
@ -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})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
229
modules/routes/routeUdb.py
Normal 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
|
||||||
|
|
@ -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
|
|
@ -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),
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
1055
modules/serviceCenter/services/serviceKnowledge/udbNodes.py
Normal file
1055
modules/serviceCenter/services/serviceKnowledge/udbNodes.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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__":
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
449
tests/unit/services/test_udbNodes.py
Normal file
449
tests/unit/services/test_udbNodes.py
Normal 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()
|
||||||
Loading…
Reference in a new issue