1056 lines
41 KiB
Python
1056 lines
41 KiB
Python
# Copyright (c) 2026 Patrick Motsch
|
|
# All rights reserved.
|
|
"""Polymorphic UdbNode hierarchy for the Unified Data Bar.
|
|
|
|
Each UDB tree node is represented by an `UdbNode` subclass that encapsulates
|
|
its own behavior:
|
|
|
|
- which flags it supports (`supportsFlag`)
|
|
- whether the current user may edit it (`canEdit`)
|
|
- how to compute an effective flag value (`getEffectiveFlag`)
|
|
- how to persist a flag change (`setFlag`)
|
|
- how to enumerate its logical children for aggregation
|
|
(`getLogicalChildren`)
|
|
- how to render itself to a JSON dict (`toDict`)
|
|
|
|
Concrete subclasses fall into four families that mirror the UDB domain
|
|
model (see wiki/b-reference/platform/unified-data-bar.md):
|
|
|
|
- SyntheticContainerNode -- structural containers, no DB record
|
|
- DataSourceNode (+children)-- user-private DataSource records
|
|
- FdsRecordNode (+children)-- feature-owned FeatureDataSource records
|
|
- FdsFieldNode -- virtual per-column nodes under fdsTable
|
|
|
|
The classes use `_inheritFlags.py` as a helper module for the actual
|
|
walk/aggregate/cascade arithmetic, so the inheritance semantics live in
|
|
one place. The classes themselves only express "what does this node type
|
|
DO" -- ownership, RBAC, persistence routing, child enumeration.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from abc import ABC, abstractmethod
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
_KEY_SEP = "|"
|
|
|
|
|
|
def _decode(key: str) -> Tuple[str, List[str]]:
|
|
"""Decode a UDB tree key into (kind, [parts...])."""
|
|
parts = key.split(_KEY_SEP)
|
|
return parts[0], parts[1:]
|
|
|
|
|
|
def _encode(kind: str, *parts: str) -> str:
|
|
"""Encode kind + parts into a stable tree key."""
|
|
return _KEY_SEP.join((kind, *parts))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Abstract base
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class UdbNode(ABC):
|
|
"""Polymorphic UDB tree node.
|
|
|
|
Subclasses MUST implement the abstract methods. Defaults are kept
|
|
minimal so each subclass's responsibilities are explicit.
|
|
|
|
`parentKey` is set by the builder when the node is emitted; it is the
|
|
tree key of the directly-rendered parent (which may differ from the
|
|
semantic ancestor used for flag inheritance).
|
|
"""
|
|
|
|
kind: str = "abstract"
|
|
|
|
def __init__(self, key: str, label: str, parentKey: Optional[str] = None) -> None:
|
|
self.key = key
|
|
self.label = label
|
|
self.parentKey = parentKey
|
|
|
|
# --- domain hooks -------------------------------------------------------
|
|
|
|
def supportsFlag(self, flag: str) -> bool:
|
|
"""Whether this node carries a value for `flag` at all.
|
|
|
|
Subclasses override to restrict (e.g. fdsField only has neutralize).
|
|
Scope was removed from DataSource nodes (privacy, 2026-06) and never
|
|
existed on FDS nodes. Only Files (folder-files) retain scope.
|
|
"""
|
|
return flag in ("neutralize", "ragIndexEnabled")
|
|
|
|
@abstractmethod
|
|
def canEdit(self, context: Any, rootIf: Any) -> bool:
|
|
"""Permission check: may the calling user mutate flags on this node?
|
|
|
|
Polymorph rule:
|
|
- DataSource* nodes: owner-of-record check (rec.userId == user).
|
|
- FdsRecord* / FdsField nodes: feature-admin check on the FDS's
|
|
featureInstanceId.
|
|
- Synthetic containers: never editable (defensive).
|
|
"""
|
|
|
|
@abstractmethod
|
|
def getEffectiveFlag(self, flag: str, allDs: List[Dict[str, Any]],
|
|
allFds: List[Dict[str, Any]], mode: str = "aggregate") -> Any:
|
|
"""Compute the effective value of `flag` for this node.
|
|
|
|
`mode='walk'` returns the concrete inherited value (never 'mixed').
|
|
`mode='aggregate'` returns 'mixed' iff the logical subtree disagrees.
|
|
Synthetic containers compute aggregate from their logical children.
|
|
"""
|
|
|
|
def setFlag(self, flag: str, value: Any, rootIf: Any) -> List[str]:
|
|
"""Persist a new value for `flag`. Default: not supported.
|
|
|
|
Returns the IDs of descendant records reset to None (cascade).
|
|
Subclasses that own a DB record override; synthetic containers and
|
|
virtual nodes refuse.
|
|
"""
|
|
raise NotImplementedError(
|
|
f"{type(self).__name__} does not support setFlag({flag!r})"
|
|
)
|
|
|
|
def getLogicalChildren(self, allDs: List[Dict[str, Any]],
|
|
allFds: List[Dict[str, Any]],
|
|
rootIf: Any, context: Any) -> List["UdbNode"]:
|
|
"""Return the children used for aggregate flag computation.
|
|
|
|
These are NOT the rendered tree children -- they are the logical
|
|
descendants whose effective values determine whether this node is
|
|
'mixed'. Default: empty (leaf).
|
|
"""
|
|
return []
|
|
|
|
@abstractmethod
|
|
def toDict(self, allDs: List[Dict[str, Any]],
|
|
allFds: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
"""Serialize to the dict shape consumed by the frontend tree.
|
|
|
|
Implementations include effective flag values for the flags this
|
|
node supports, plus rendering hints (icon, hasChildren, etc.).
|
|
"""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Synthetic containers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class SyntheticContainerNode(UdbNode):
|
|
"""Structural container with no DB record (personalRoot, mandateGroup).
|
|
|
|
Aggregates the effective flag values from its logical children for
|
|
display only; cannot be edited.
|
|
"""
|
|
|
|
kind = "synthRoot"
|
|
|
|
def __init__(self, key: str, label: str, icon: str,
|
|
parentKey: Optional[str] = None,
|
|
displayOrder: int = 0,
|
|
defaultExpanded: bool = False,
|
|
logicalChildren: Optional[List[UdbNode]] = None) -> None:
|
|
super().__init__(key, label, parentKey)
|
|
self.icon = icon
|
|
self.displayOrder = displayOrder
|
|
self.defaultExpanded = defaultExpanded
|
|
self._logicalChildren: List[UdbNode] = list(logicalChildren or [])
|
|
|
|
def addLogicalChild(self, child: UdbNode) -> None:
|
|
self._logicalChildren.append(child)
|
|
|
|
def supportsFlag(self, flag: str) -> bool:
|
|
return False
|
|
|
|
def canEdit(self, context: Any, rootIf: Any) -> bool:
|
|
return False
|
|
|
|
def getLogicalChildren(self, allDs, allFds, rootIf, context) -> List[UdbNode]:
|
|
return list(self._logicalChildren)
|
|
|
|
def getEffectiveFlag(self, flag, allDs, allFds, mode="aggregate") -> Any:
|
|
return _aggregateFromChildren(self, flag, allDs, allFds, mode)
|
|
|
|
def toDict(self, allDs, allFds) -> Dict[str, Any]:
|
|
return {
|
|
"key": self.key,
|
|
"kind": "synthRoot" if self.key != _KEY_MANDATE_GROUP_PREFIX else self.kind,
|
|
"parentKey": self.parentKey,
|
|
"label": self.label,
|
|
"icon": self.icon,
|
|
"hasChildren": True,
|
|
"dataSourceId": None,
|
|
"modelType": None,
|
|
"effectiveNeutralize": self.getEffectiveFlag("neutralize", allDs, allFds, "aggregate"),
|
|
"effectiveScope": "personal",
|
|
"effectiveRagIndexEnabled": self.getEffectiveFlag("ragIndexEnabled", allDs, allFds, "aggregate"),
|
|
"supportsRag": False,
|
|
"canBeAdded": False,
|
|
"displayOrder": self.displayOrder,
|
|
"defaultExpanded": self.defaultExpanded,
|
|
}
|
|
|
|
|
|
_KEY_MANDATE_GROUP_PREFIX = "mgrp"
|
|
|
|
|
|
class MandateGroupNode(SyntheticContainerNode):
|
|
"""Synthetic top-level container, one per accessible mandate."""
|
|
|
|
kind = "mandateGroup"
|
|
|
|
def __init__(self, mandateId: str, label: str) -> None:
|
|
super().__init__(
|
|
key=_encode("mgrp", mandateId),
|
|
label=label,
|
|
icon="mandate",
|
|
parentKey=None,
|
|
defaultExpanded=True,
|
|
)
|
|
self.mandateId = mandateId
|
|
|
|
def toDict(self, allDs, allFds) -> Dict[str, Any]:
|
|
out = super().toDict(allDs, allFds)
|
|
out["kind"] = "mandateGroup"
|
|
out["mandateId"] = self.mandateId
|
|
return out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DataSource family
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _DataSourceFamilyNode(UdbNode):
|
|
"""Shared behavior for DS-backed nodes (connection, service, folder, file).
|
|
|
|
A node either has an existing DataSource record (`rec`) or is "virtual"
|
|
-- a coordinate (connectionId/sourceType/path) that the user has not
|
|
yet pinned. Virtual nodes can still report effective flag values (they
|
|
walk the ancestor chain) but cannot be edited until the record exists.
|
|
"""
|
|
|
|
def __init__(self, key: str, label: str, parentKey: Optional[str],
|
|
connectionId: str, sourceType: str, path: str,
|
|
rec: Optional[Dict[str, Any]]) -> None:
|
|
super().__init__(key, label, parentKey)
|
|
self.connectionId = connectionId
|
|
self.sourceType = sourceType
|
|
self.path = path
|
|
self.rec = rec
|
|
|
|
def canEdit(self, context: Any, rootIf: Any) -> bool:
|
|
if self.rec:
|
|
ownerId = str(self.rec.get("userId") or "")
|
|
return ownerId == str(context.user.id)
|
|
return _isConnectionOwner(rootIf, str(context.user.id), self.connectionId)
|
|
|
|
def getEffectiveFlag(self, flag, allDs, allFds, mode="aggregate") -> Any:
|
|
if not self.supportsFlag(flag):
|
|
return False
|
|
from modules.serviceCenter.core.flagResolution import (
|
|
resolveEffectiveForPath,
|
|
)
|
|
out = resolveEffectiveForPath(self.connectionId, self.sourceType, self.path, allDs, mode=mode)
|
|
key = "effective" + flag[0].upper() + flag[1:]
|
|
return out.get(key, False)
|
|
|
|
def setFlag(self, flag, value, rootIf) -> List[str]:
|
|
from modules.datamodels.datamodelDataSource import DataSource
|
|
from modules.serviceCenter.core.flagResolution import (
|
|
cascadeResetDescendants,
|
|
)
|
|
if not self.rec:
|
|
self.rec = _findOrCreateDs(
|
|
rootIf, self.connectionId, self.sourceType, self.path,
|
|
)
|
|
sourceId = self.rec.get("id")
|
|
resetIds: List[str] = []
|
|
if value is not None:
|
|
resetIds = cascadeResetDescendants(rootIf, self.rec, flag)
|
|
rootIf.db.recordModify(DataSource, sourceId, {flag: value})
|
|
return resetIds
|
|
|
|
|
|
class ConnectionNode(_DataSourceFamilyNode):
|
|
"""Connection-root DataSource node (path='/', authority sourceType)."""
|
|
|
|
kind = "connection"
|
|
|
|
def __init__(self, connectionId: str, authority: str, label: str,
|
|
parentKey: str, rec: Optional[Dict[str, Any]]) -> None:
|
|
super().__init__(
|
|
key=_encode("conn", connectionId),
|
|
label=label,
|
|
parentKey=parentKey,
|
|
connectionId=connectionId,
|
|
sourceType=str(authority),
|
|
path="/",
|
|
rec=rec,
|
|
)
|
|
self.authority = authority
|
|
|
|
def toDict(self, allDs, allFds) -> Dict[str, Any]:
|
|
return _dsDict(self, allDs)
|
|
|
|
|
|
class ServiceNode(_DataSourceFamilyNode):
|
|
"""Service-level node (e.g. sharepoint, drive) under a Connection."""
|
|
|
|
kind = "service"
|
|
|
|
def __init__(self, connectionId: str, service: str, sourceType: str,
|
|
label: str, parentKey: str, rec: Optional[Dict[str, Any]]) -> None:
|
|
super().__init__(
|
|
key=_encode("svc", connectionId, service),
|
|
label=label,
|
|
parentKey=parentKey,
|
|
connectionId=connectionId,
|
|
sourceType=sourceType,
|
|
path="/",
|
|
rec=rec,
|
|
)
|
|
self.service = service
|
|
|
|
def toDict(self, allDs, allFds) -> Dict[str, Any]:
|
|
out = _dsDict(self, allDs)
|
|
out["service"] = self.service
|
|
return out
|
|
|
|
|
|
class _BrowseNode(_DataSourceFamilyNode):
|
|
"""Folder/File node from a connector browse() result."""
|
|
|
|
def __init__(self, kind: str, connectionId: str, service: str,
|
|
sourceType: str, path: str, label: str,
|
|
parentKey: str, rec: Optional[Dict[str, Any]],
|
|
hasChildren: bool) -> None:
|
|
super().__init__(
|
|
key=_encode("ds", connectionId, sourceType, path),
|
|
label=label,
|
|
parentKey=parentKey,
|
|
connectionId=connectionId,
|
|
sourceType=sourceType,
|
|
path=path,
|
|
rec=rec,
|
|
)
|
|
self.kind = kind
|
|
self.service = service
|
|
self._hasChildren = hasChildren
|
|
|
|
def toDict(self, allDs, allFds) -> Dict[str, Any]:
|
|
out = _dsDict(self, allDs)
|
|
out["service"] = self.service
|
|
out["hasChildren"] = self._hasChildren
|
|
return out
|
|
|
|
|
|
class FolderNode(_BrowseNode):
|
|
"""Folder DataSource node from connector browse()."""
|
|
|
|
def __init__(self, **kwargs) -> None:
|
|
super().__init__(kind="folder", **kwargs)
|
|
|
|
|
|
class FileNode(_BrowseNode):
|
|
"""File DataSource node from connector browse()."""
|
|
|
|
def __init__(self, **kwargs) -> None:
|
|
super().__init__(kind="file", **kwargs)
|
|
|
|
|
|
def _dsDict(node: _DataSourceFamilyNode, allDs: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
"""Shared serialization for DS-family nodes."""
|
|
return {
|
|
"key": node.key,
|
|
"kind": node.kind,
|
|
"parentKey": node.parentKey,
|
|
"label": node.label,
|
|
"icon": getattr(node, "authority", None) or getattr(node, "service", None) or node.kind,
|
|
"hasChildren": True,
|
|
"dataSourceId": node.rec.get("id") if node.rec else None,
|
|
"modelType": "DataSource" if node.rec else None,
|
|
"effectiveNeutralize": node.getEffectiveFlag("neutralize", allDs, [], "aggregate"),
|
|
"effectiveScope": "personal",
|
|
"effectiveRagIndexEnabled": node.getEffectiveFlag("ragIndexEnabled", allDs, [], "aggregate"),
|
|
"supportsRag": True,
|
|
"canBeAdded": node.rec is None,
|
|
"connectionId": node.connectionId,
|
|
"sourceType": node.sourceType,
|
|
"path": node.path,
|
|
"authority": getattr(node, "authority", None),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# FDS family
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _FdsFamilyNode(UdbNode):
|
|
"""Shared behavior for FDS-backed nodes (featureNode/fdsTable/fdsRecord).
|
|
|
|
FDS has no `scope` attribute (visibility is feature RBAC). Edit
|
|
permission requires the user to hold a feature-admin role on the
|
|
FDS's `featureInstanceId`.
|
|
"""
|
|
|
|
def __init__(self, key: str, label: str, parentKey: Optional[str],
|
|
featureInstanceId: str, tableName: str,
|
|
recordFilter: Optional[Dict[str, str]],
|
|
rec: Optional[Dict[str, Any]]) -> None:
|
|
super().__init__(key, label, parentKey)
|
|
self.featureInstanceId = featureInstanceId
|
|
self.tableName = tableName
|
|
self.recordFilter = recordFilter
|
|
self.rec = rec
|
|
|
|
def supportsFlag(self, flag: str) -> bool:
|
|
return flag in ("neutralize", "ragIndexEnabled")
|
|
|
|
def canEdit(self, context: Any, rootIf: Any) -> bool:
|
|
return isFeatureAdmin(rootIf, str(context.user.id), self.featureInstanceId)
|
|
|
|
def getEffectiveFlag(self, flag, allDs, allFds, mode="aggregate") -> Any:
|
|
if not self.supportsFlag(flag):
|
|
return None
|
|
from modules.serviceCenter.core.flagResolution import (
|
|
resolveEffectiveForFds,
|
|
)
|
|
out = resolveEffectiveForFds(self.featureInstanceId, self.tableName,
|
|
self.recordFilter, allFds, mode=mode)
|
|
key = "effective" + flag[0].upper() + flag[1:]
|
|
return out.get(key, False)
|
|
|
|
def setFlag(self, flag, value, rootIf) -> List[str]:
|
|
if not self.supportsFlag(flag):
|
|
raise ValueError(f"FDS does not support flag {flag!r}")
|
|
from modules.datamodels.datamodelFeatures import FeatureDataSource
|
|
from modules.serviceCenter.core.flagResolution import (
|
|
cascadeResetDescendantsFds,
|
|
)
|
|
if not self.rec:
|
|
raise RuntimeError(
|
|
f"Cannot setFlag on virtual FDS node {self.key}: "
|
|
"create the FeatureDataSource record first."
|
|
)
|
|
sourceId = self.rec.get("id")
|
|
resetIds: List[str] = []
|
|
if value is not None:
|
|
resetIds = cascadeResetDescendantsFds(rootIf, self.rec, flag)
|
|
modifyFields: Dict[str, Any] = {flag: value}
|
|
self._onSetFlag(modifyFields, flag, value, rootIf)
|
|
rootIf.db.recordModify(FeatureDataSource, sourceId, modifyFields)
|
|
return resetIds
|
|
|
|
def _onSetFlag(self, modifyFields: Dict[str, Any], flag: str,
|
|
value: Any, rootIf: Any) -> None:
|
|
"""Subclass hook: extend `modifyFields` or perform side-effects."""
|
|
pass
|
|
|
|
|
|
class FdsWorkspaceNode(_FdsFamilyNode):
|
|
"""Synthetic feature-wildcard FDS node (tableName='*').
|
|
|
|
Rendered as 'featureNode' in the tree; one per accessible feature
|
|
instance under its mandate group.
|
|
"""
|
|
|
|
kind = "featureNode"
|
|
|
|
def __init__(self, mandateId: str, featureCode: str, featureInstanceId: str,
|
|
label: str, icon: str, parentKey: str,
|
|
rec: Optional[Dict[str, Any]]) -> None:
|
|
super().__init__(
|
|
key=_encode("feat", mandateId, featureCode, featureInstanceId),
|
|
label=label,
|
|
parentKey=parentKey,
|
|
featureInstanceId=featureInstanceId,
|
|
tableName="*",
|
|
recordFilter=None,
|
|
rec=rec,
|
|
)
|
|
self.mandateId = mandateId
|
|
self.featureCode = featureCode
|
|
self.icon = icon
|
|
|
|
def _onSetFlag(self, modifyFields, flag, value, rootIf):
|
|
if flag == "neutralize" and value is not None:
|
|
self._clearDescendantNeutralizeFields(rootIf)
|
|
|
|
def _clearDescendantNeutralizeFields(self, rootIf):
|
|
"""Wipe `neutralizeFields` on all descendant table FDS records.
|
|
|
|
When the workspace sets an explicit neutralize, per-column
|
|
overrides on descendant tables become obsolete — the workspace
|
|
value cascades down via inheritance. Without clearing them the
|
|
table aggregate stays 'mixed' because some field children still
|
|
read True from the list while others inherit the new value.
|
|
"""
|
|
from modules.datamodels.datamodelFeatures import FeatureDataSource
|
|
allFds = rootIf.db.getRecordset(
|
|
FeatureDataSource,
|
|
recordFilter={"featureInstanceId": self.featureInstanceId},
|
|
) or []
|
|
ownId = self.rec.get("id") if self.rec else None
|
|
for fds in allFds:
|
|
fdsId = fds.get("id")
|
|
if fdsId == ownId:
|
|
continue
|
|
nf = fds.get("neutralizeFields")
|
|
if isinstance(nf, list) and len(nf) > 0:
|
|
rootIf.db.recordModify(
|
|
FeatureDataSource, fdsId, {"neutralizeFields": None},
|
|
)
|
|
|
|
def toDict(self, allDs, allFds) -> Dict[str, Any]:
|
|
return {
|
|
"key": self.key,
|
|
"kind": "featureNode",
|
|
"parentKey": self.parentKey,
|
|
"label": self.label,
|
|
"icon": self.icon,
|
|
"hasChildren": True,
|
|
"dataSourceId": self.rec.get("id") if self.rec else None,
|
|
"modelType": "FeatureDataSource" if self.rec else None,
|
|
"effectiveNeutralize": self.getEffectiveFlag("neutralize", allDs, allFds, "aggregate"),
|
|
"effectiveScope": "personal",
|
|
"effectiveRagIndexEnabled": self.getEffectiveFlag("ragIndexEnabled", allDs, allFds, "aggregate"),
|
|
"supportsRag": True,
|
|
"canBeAdded": self.rec is None,
|
|
"featureInstanceId": self.featureInstanceId,
|
|
"featureCode": self.featureCode,
|
|
"mandateId": self.mandateId,
|
|
"tableName": "*",
|
|
}
|
|
|
|
|
|
class FdsTableNode(_FdsFamilyNode):
|
|
"""Table-level FDS node (concrete tableName, no recordFilter)."""
|
|
|
|
kind = "fdsTable"
|
|
|
|
def __init__(self, featureInstanceId: str, featureCode: str, tableName: str,
|
|
objectKey: str, label: str, parentKey: str,
|
|
rec: Optional[Dict[str, Any]], hasFields: bool) -> None:
|
|
super().__init__(
|
|
key=_encode("fdstbl", featureInstanceId, tableName),
|
|
label=label,
|
|
parentKey=parentKey,
|
|
featureInstanceId=featureInstanceId,
|
|
tableName=tableName,
|
|
recordFilter=None,
|
|
rec=rec,
|
|
)
|
|
self.featureCode = featureCode
|
|
self.objectKey = objectKey
|
|
self._hasFields = hasFields
|
|
|
|
def _onSetFlag(self, modifyFields, flag, value, rootIf):
|
|
if flag == "neutralize" and value is not None:
|
|
modifyFields["neutralizeFields"] = None
|
|
|
|
def toDict(self, allDs, allFds) -> Dict[str, Any]:
|
|
neutralizeFields: List[str] = []
|
|
if self.rec and isinstance(self.rec.get("neutralizeFields"), list):
|
|
neutralizeFields = [f for f in self.rec["neutralizeFields"] if isinstance(f, str)]
|
|
return {
|
|
"key": self.key,
|
|
"kind": "fdsTable",
|
|
"parentKey": self.parentKey,
|
|
"label": self.label,
|
|
"icon": "table",
|
|
"hasChildren": self._hasFields,
|
|
"dataSourceId": self.rec.get("id") if self.rec else None,
|
|
"modelType": "FeatureDataSource" if self.rec else None,
|
|
"effectiveNeutralize": self.getEffectiveFlag("neutralize", allDs, allFds, "aggregate"),
|
|
"effectiveScope": "personal",
|
|
"effectiveRagIndexEnabled": self.getEffectiveFlag("ragIndexEnabled", allDs, allFds, "aggregate"),
|
|
"supportsRag": True,
|
|
"canBeAdded": self.rec is None,
|
|
"featureInstanceId": self.featureInstanceId,
|
|
"featureCode": self.featureCode,
|
|
"tableName": self.tableName,
|
|
"objectKey": self.objectKey,
|
|
"neutralizeFields": neutralizeFields,
|
|
}
|
|
|
|
|
|
class FdsRowNode(_FdsFamilyNode):
|
|
"""Row-level FDS node (recordFilter pins specific rows)."""
|
|
|
|
kind = "fdsRecord"
|
|
|
|
def __init__(self, featureInstanceId: str, tableName: str, recordId: str,
|
|
recordFilter: Dict[str, str], label: str, parentKey: str,
|
|
rec: Optional[Dict[str, Any]]) -> None:
|
|
super().__init__(
|
|
key=_encode("fdsrec", featureInstanceId, tableName, recordId),
|
|
label=label,
|
|
parentKey=parentKey,
|
|
featureInstanceId=featureInstanceId,
|
|
tableName=tableName,
|
|
recordFilter=recordFilter,
|
|
rec=rec,
|
|
)
|
|
|
|
def toDict(self, allDs, allFds) -> Dict[str, Any]:
|
|
return {
|
|
"key": self.key,
|
|
"kind": "fdsRecord",
|
|
"parentKey": self.parentKey,
|
|
"label": self.label,
|
|
"icon": "row",
|
|
"hasChildren": False,
|
|
"dataSourceId": self.rec.get("id") if self.rec else None,
|
|
"modelType": "FeatureDataSource" if self.rec else None,
|
|
"effectiveNeutralize": self.getEffectiveFlag("neutralize", allDs, allFds, "aggregate"),
|
|
"effectiveScope": "personal",
|
|
"effectiveRagIndexEnabled": self.getEffectiveFlag("ragIndexEnabled", allDs, allFds, "aggregate"),
|
|
"supportsRag": True,
|
|
"canBeAdded": self.rec is None,
|
|
"featureInstanceId": self.featureInstanceId,
|
|
"tableName": self.tableName,
|
|
}
|
|
|
|
|
|
class FdsFieldNode(UdbNode):
|
|
"""Per-column virtual node under an FdsTableNode.
|
|
|
|
Has no DB record of its own. The 'neutralize' state is two-source:
|
|
|
|
1. `field in tableRec.neutralizeFields` -> explicit override -> True.
|
|
2. otherwise -> field INHERITS the effective neutralize of its
|
|
table (which itself walks the FDS ancestor chain up to the
|
|
workspace wildcard).
|
|
|
|
Setting the flag toggles the field's membership in
|
|
`tableRec.neutralizeFields` only; it never writes a dedicated record.
|
|
|
|
Supports only the `neutralize` flag; scope and rag do not exist
|
|
field-level. The per-list mechanic intentionally cannot express
|
|
"explicit False"; an explicit table value covers all fields equally
|
|
via inheritance (matching the cascade-reset semantics in
|
|
`_FdsFamilyNode.setFlag`, which wipes `neutralizeFields` when the
|
|
table's neutralize is set explicitly).
|
|
"""
|
|
|
|
kind = "fdsField"
|
|
|
|
def __init__(self, featureInstanceId: str, tableName: str, fieldName: str,
|
|
parentKey: str, tableRec: Optional[Dict[str, Any]],
|
|
featureCode: str = "") -> None:
|
|
super().__init__(
|
|
key=_encode("fdsfld", featureInstanceId, tableName, fieldName),
|
|
label=fieldName,
|
|
parentKey=parentKey,
|
|
)
|
|
self.featureInstanceId = featureInstanceId
|
|
self.tableName = tableName
|
|
self.fieldName = fieldName
|
|
self.featureCode = featureCode
|
|
self.tableRec = tableRec
|
|
|
|
def supportsFlag(self, flag: str) -> bool:
|
|
return flag == "neutralize"
|
|
|
|
def canEdit(self, context: Any, rootIf: Any) -> bool:
|
|
return isFeatureAdmin(rootIf, str(context.user.id), self.featureInstanceId)
|
|
|
|
def getEffectiveFlag(self, flag, allDs, allFds, mode="aggregate") -> Any:
|
|
if flag != "neutralize":
|
|
return False
|
|
rec = self.tableRec or _findFdsByCoord(allFds, self.featureInstanceId, self.tableName, None)
|
|
fields = rec.get("neutralizeFields") if rec else None
|
|
if isinstance(fields, list) and self.fieldName in fields:
|
|
return True
|
|
# Not explicitly overridden -> inherit from the table's effective
|
|
# neutralize. Use walk mode so the inherited value is concrete
|
|
# (never 'mixed'); a single field cannot itself be ambiguous.
|
|
from modules.serviceCenter.core.flagResolution import (
|
|
resolveEffectiveForFds,
|
|
)
|
|
out = resolveEffectiveForFds(
|
|
self.featureInstanceId, self.tableName, None, allFds, mode="walk",
|
|
)
|
|
value = out.get("effectiveNeutralize", False)
|
|
return bool(value) if isinstance(value, bool) else False
|
|
|
|
def setFlag(self, flag, value, rootIf) -> List[str]:
|
|
if flag != "neutralize":
|
|
raise ValueError(f"FdsFieldNode does not support flag {flag!r}")
|
|
from modules.datamodels.datamodelFeatures import FeatureDataSource
|
|
# Resolve or auto-create the underlying table-record FDS so we
|
|
# have somewhere to persist the neutralizeFields entry.
|
|
rec = self.tableRec
|
|
if not rec:
|
|
rec = _findOrCreateTableFds(rootIf, self.featureInstanceId, self.tableName,
|
|
self.featureCode, self.fieldName)
|
|
recId = rec.get("id")
|
|
currentFields = rec.get("neutralizeFields") if isinstance(rec.get("neutralizeFields"), list) else []
|
|
fields = set(currentFields)
|
|
if value:
|
|
fields.add(self.fieldName)
|
|
else:
|
|
fields.discard(self.fieldName)
|
|
newList = sorted(fields)
|
|
rootIf.db.recordModify(FeatureDataSource, recId, {
|
|
"neutralizeFields": newList if newList else None,
|
|
})
|
|
# Update the in-memory record so subsequent reads see the change.
|
|
rec["neutralizeFields"] = newList if newList else None
|
|
self.tableRec = rec
|
|
return []
|
|
|
|
def toDict(self, allDs, allFds) -> Dict[str, Any]:
|
|
rec = self.tableRec or _findFdsByCoord(allFds, self.featureInstanceId, self.tableName, None)
|
|
return {
|
|
"key": self.key,
|
|
"kind": "fdsField",
|
|
"parentKey": self.parentKey,
|
|
"label": self.label,
|
|
"icon": "field",
|
|
"hasChildren": False,
|
|
"dataSourceId": rec.get("id") if rec else None,
|
|
"modelType": "FeatureDataSource" if rec else None,
|
|
"effectiveNeutralize": self.getEffectiveFlag("neutralize", allDs, allFds, "aggregate"),
|
|
"effectiveScope": "personal",
|
|
"effectiveRagIndexEnabled": False,
|
|
"supportsRag": False,
|
|
"canBeAdded": rec is None,
|
|
"featureInstanceId": self.featureInstanceId,
|
|
"tableName": self.tableName,
|
|
"fieldName": self.fieldName,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cross-cutting helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _isConnectionOwner(rootIf: Any, userId: str, connectionId: str) -> bool:
|
|
"""Return True iff the UserConnection belongs to this user."""
|
|
try:
|
|
from modules.datamodels.datamodelUam import UserConnection
|
|
conn = rootIf.db.getRecord(UserConnection, connectionId)
|
|
if not conn:
|
|
return False
|
|
return str(conn.get("userId") or "") == userId
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _findOrCreateDs(rootIf: Any, connectionId: str, sourceType: str,
|
|
path: str) -> Dict[str, Any]:
|
|
"""Look up a DataSource by coordinate. Create a stub if missing.
|
|
|
|
Analogous to `_findOrCreateTableFds` for FDS fields: the user
|
|
clicks toggle on a browse-discovered folder that has no DataSource
|
|
record yet. Instead of returning 403, we auto-create the record
|
|
so the flag can be persisted.
|
|
"""
|
|
from modules.datamodels.datamodelDataSource import DataSource
|
|
from modules.datamodels.datamodelUam import UserConnection
|
|
from modules.serviceCenter.core.flagResolution import normalisePath
|
|
|
|
normPath = normalisePath(path)
|
|
|
|
existing = rootIf.db.getRecordset(DataSource, recordFilter={
|
|
"connectionId": connectionId, "sourceType": sourceType,
|
|
}) or []
|
|
for rec in existing:
|
|
if normalisePath(rec.get("path")) == normPath:
|
|
return rec
|
|
|
|
conn = rootIf.db.getRecord(UserConnection, connectionId)
|
|
if not conn:
|
|
raise RuntimeError(f"UserConnection {connectionId} not found")
|
|
userId = str(conn.get("userId") or "")
|
|
mandateId = ""
|
|
for rec in existing:
|
|
mid = rec.get("mandateId")
|
|
if mid:
|
|
mandateId = str(mid)
|
|
break
|
|
|
|
pathLabel = normPath.rsplit("/", 1)[-1] or normPath
|
|
stub = DataSource(
|
|
connectionId=connectionId,
|
|
sourceType=sourceType,
|
|
path=normPath,
|
|
label=pathLabel,
|
|
displayPath=normPath,
|
|
userId=userId,
|
|
mandateId=mandateId,
|
|
)
|
|
created = rootIf.db.recordCreate(DataSource, stub.model_dump())
|
|
if isinstance(created, dict):
|
|
return created
|
|
return stub.model_dump()
|
|
|
|
|
|
def isFeatureAdmin(rootIf: Any, userId: str, featureInstanceId: str) -> bool:
|
|
"""Return True iff the user holds a `*-admin` role on this feature instance.
|
|
|
|
Convention: feature-specific admin role labels end with `-admin`
|
|
(e.g. `workspace-admin`, `automation-admin`). See `.cursor/rules/
|
|
rbac-role-separation.mdc`.
|
|
"""
|
|
try:
|
|
access = rootIf.getFeatureAccess(userId, featureInstanceId)
|
|
if not access or not getattr(access, "enabled", True):
|
|
return False
|
|
accessId = getattr(access, "id", None) or (access.get("id") if isinstance(access, dict) else None)
|
|
if not accessId:
|
|
return False
|
|
roleIds = rootIf.getRoleIdsForFeatureAccess(accessId) or []
|
|
if not roleIds:
|
|
return False
|
|
from modules.datamodels.datamodelRbac import Role
|
|
for rid in roleIds:
|
|
rec = rootIf.db.getRecord(Role, rid)
|
|
if not rec:
|
|
continue
|
|
label = str(rec.get("roleLabel") or "").lower()
|
|
if label.endswith("-admin"):
|
|
return True
|
|
return False
|
|
except Exception as exc:
|
|
logger.warning("isFeatureAdmin check failed (user=%s feature=%s): %s",
|
|
userId, featureInstanceId, exc)
|
|
return False
|
|
|
|
|
|
def _findFdsByCoord(allFds: List[Dict[str, Any]], featureInstanceId: str,
|
|
tableName: str, recordFilter: Optional[Dict[str, str]]) -> Optional[Dict[str, Any]]:
|
|
target = recordFilter or None
|
|
for fds in allFds:
|
|
if fds.get("featureInstanceId") != featureInstanceId:
|
|
continue
|
|
if (fds.get("tableName") or "") != tableName:
|
|
continue
|
|
if (fds.get("recordFilter") or None) == target:
|
|
return fds
|
|
return None
|
|
|
|
|
|
def _findOrCreateTableFds(rootIf: Any, featureInstanceId: str, tableName: str,
|
|
featureCode: str, anyFieldName: str) -> Dict[str, Any]:
|
|
"""Look up the table-level FDS record. Create a minimal stub if missing.
|
|
|
|
Required so an `fdsField` neutralize toggle can persist its state
|
|
without forcing the user to first 'add' the table via a separate UI
|
|
affordance. The stub carries the same featureInstanceId/tableName
|
|
coordinate; its `objectKey`/`label` are filled from the RBAC catalog
|
|
so list endpoints still render it correctly.
|
|
"""
|
|
from modules.datamodels.datamodelFeatures import FeatureDataSource
|
|
existing = rootIf.db.getRecordset(FeatureDataSource, recordFilter={
|
|
"featureInstanceId": featureInstanceId,
|
|
"tableName": tableName,
|
|
}) or []
|
|
for rec in existing:
|
|
if (rec.get("recordFilter") or None) is None:
|
|
return rec
|
|
# Resolve mandate + objectKey/label via catalog so the stub is well-formed.
|
|
fi = rootIf.getFeatureInstance(featureInstanceId)
|
|
mandateId = str(fi.mandateId) if fi and getattr(fi, "mandateId", None) else ""
|
|
objectKey = ""
|
|
label = tableName
|
|
try:
|
|
from modules.security.rbacCatalog import getCatalogService
|
|
catalog = getCatalogService()
|
|
for obj in catalog.getDataObjects(featureCode) or []:
|
|
meta = obj.get("meta", {}) if isinstance(obj, dict) else {}
|
|
if meta.get("table") == tableName:
|
|
objectKey = obj.get("objectKey", "")
|
|
lbl = obj.get("label")
|
|
if lbl:
|
|
try:
|
|
from modules.shared.i18nRegistry import resolveText
|
|
label = resolveText(lbl) or tableName
|
|
except Exception:
|
|
label = str(lbl) or tableName
|
|
break
|
|
except Exception:
|
|
pass
|
|
stub = FeatureDataSource(
|
|
featureInstanceId=featureInstanceId,
|
|
featureCode=featureCode,
|
|
tableName=tableName,
|
|
objectKey=objectKey,
|
|
label=label,
|
|
mandateId=mandateId,
|
|
recordFilter=None,
|
|
)
|
|
created = rootIf.db.recordCreate(FeatureDataSource, stub.model_dump())
|
|
if isinstance(created, dict):
|
|
return created
|
|
return stub.model_dump()
|
|
|
|
|
|
def _aggregateFromChildren(node: UdbNode, flag: str,
|
|
allDs: List[Dict[str, Any]],
|
|
allFds: List[Dict[str, Any]],
|
|
mode: str) -> Any:
|
|
"""Aggregate `flag` across a node's logical children.
|
|
|
|
Returns 'mixed' iff at least two children disagree (in walk-mode),
|
|
otherwise the agreed value or the default for the flag. Used by
|
|
synthetic containers that have no DB record of their own.
|
|
"""
|
|
children = node.getLogicalChildren(allDs, allFds, None, None)
|
|
if not children:
|
|
return False
|
|
seen = set()
|
|
last: Any = None
|
|
for child in children:
|
|
if not child.supportsFlag(flag):
|
|
continue
|
|
val = child.getEffectiveFlag(flag, allDs, allFds, "aggregate")
|
|
if val == "mixed":
|
|
return "mixed"
|
|
norm = int(val) if isinstance(val, bool) else val
|
|
seen.add(norm)
|
|
last = val
|
|
if len(seen) > 1:
|
|
return "mixed"
|
|
if not seen:
|
|
return False
|
|
return last
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lookup by tree key -> UdbNode
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def buildNodeForKey(key: str, context: Any, rootIf: Any) -> Optional[UdbNode]:
|
|
"""Materialize the `UdbNode` instance for a given tree key.
|
|
|
|
Used by the generic /flag/ endpoint: the caller passes the key it sees
|
|
in the tree, and we resolve it back to the polymorphic node so we can
|
|
call canEdit / setFlag without the route knowing the node type.
|
|
|
|
Returns None when the key is unknown (caller should 404).
|
|
"""
|
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
|
if rootIf is None:
|
|
rootIf = getRootInterface()
|
|
from modules.datamodels.datamodelDataSource import DataSource
|
|
from modules.datamodels.datamodelFeatures import FeatureDataSource
|
|
|
|
kind, parts = _decode(key)
|
|
|
|
if kind == "conn" and len(parts) == 1:
|
|
connId = parts[0]
|
|
rec = _findDsByCoord(rootIf, connId, None, "/")
|
|
# Authority is whatever sourceType the connection-root record has;
|
|
# for setFlag we only need rec. ConnectionNode's label is not used.
|
|
authority = rec.get("sourceType") if rec else ""
|
|
return ConnectionNode(connId, authority, label="", parentKey="", rec=rec)
|
|
|
|
if kind == "svc" and len(parts) == 2:
|
|
connId, service = parts
|
|
sourceType = _SERVICE_TO_SOURCE_TYPE.get(service, service)
|
|
rec = _findDsByCoord(rootIf, connId, sourceType, "/")
|
|
return ServiceNode(connId, service, sourceType, label="", parentKey="", rec=rec)
|
|
|
|
if kind == "ds" and len(parts) >= 3:
|
|
connId = parts[0]
|
|
sourceType = parts[1]
|
|
path = _KEY_SEP.join(parts[2:])
|
|
rec = _findDsByCoord(rootIf, connId, sourceType, path)
|
|
# We do not know if it is folder or file at this point; use FolderNode.
|
|
return FolderNode(
|
|
connectionId=connId, service=_reverseService(sourceType),
|
|
sourceType=sourceType, path=path, label="", parentKey="",
|
|
rec=rec, hasChildren=True,
|
|
)
|
|
|
|
if kind == "feat" and len(parts) == 3:
|
|
mandateId, featureCode, featureInstanceId = parts
|
|
rec = _findFdsByCoord(_loadAllFds(rootIf, featureInstanceId), featureInstanceId, "*", None)
|
|
return FdsWorkspaceNode(mandateId, featureCode, featureInstanceId,
|
|
label="", icon="", parentKey="", rec=rec)
|
|
|
|
if kind == "fdstbl" and len(parts) == 2:
|
|
featureInstanceId, tableName = parts
|
|
rec = _findFdsByCoord(_loadAllFds(rootIf, featureInstanceId), featureInstanceId, tableName, None)
|
|
featureCode = ""
|
|
if rec:
|
|
featureCode = str(rec.get("featureCode") or "")
|
|
else:
|
|
fi = rootIf.getFeatureInstance(featureInstanceId)
|
|
featureCode = str(fi.featureCode) if fi else ""
|
|
return FdsTableNode(featureInstanceId, featureCode, tableName,
|
|
objectKey="", label="", parentKey="",
|
|
rec=rec, hasFields=True)
|
|
|
|
if kind == "fdsfld" and len(parts) >= 3:
|
|
featureInstanceId = parts[0]
|
|
tableName = parts[1]
|
|
fieldName = _KEY_SEP.join(parts[2:])
|
|
allFds = _loadAllFds(rootIf, featureInstanceId)
|
|
tableRec = _findFdsByCoord(allFds, featureInstanceId, tableName, None)
|
|
featureCode = str(tableRec.get("featureCode") or "") if tableRec else ""
|
|
if not featureCode:
|
|
fi = rootIf.getFeatureInstance(featureInstanceId)
|
|
featureCode = str(fi.featureCode) if fi else ""
|
|
return FdsFieldNode(featureInstanceId, tableName, fieldName,
|
|
parentKey="", tableRec=tableRec, featureCode=featureCode)
|
|
|
|
return None
|
|
|
|
|
|
def _findDsByCoord(rootIf: Any, connectionId: str, sourceType: Optional[str],
|
|
path: str) -> Optional[Dict[str, Any]]:
|
|
from modules.datamodels.datamodelDataSource import DataSource
|
|
from modules.serviceCenter.core.flagResolution import normalisePath
|
|
rf = {"connectionId": connectionId}
|
|
if sourceType is not None:
|
|
rf["sourceType"] = sourceType
|
|
records = rootIf.db.getRecordset(DataSource, recordFilter=rf) or []
|
|
norm = normalisePath(path)
|
|
if sourceType is None:
|
|
# connection-root: any record with path='/' on this connection
|
|
for r in records:
|
|
if normalisePath(r.get("path")) == "/":
|
|
return r
|
|
return None
|
|
for r in records:
|
|
if normalisePath(r.get("path")) == norm:
|
|
return r
|
|
return None
|
|
|
|
|
|
def _loadAllFds(rootIf: Any, featureInstanceId: str) -> List[Dict[str, Any]]:
|
|
from modules.datamodels.datamodelFeatures import FeatureDataSource
|
|
return rootIf.db.getRecordset(
|
|
FeatureDataSource, recordFilter={"featureInstanceId": featureInstanceId}
|
|
) or []
|
|
|
|
|
|
# Imported from _buildTree for buildNodeForKey resolution; kept here as a
|
|
# small private mirror to avoid import cycles.
|
|
_SERVICE_TO_SOURCE_TYPE: Dict[str, str] = {
|
|
"sharepoint": "sharepointFolder",
|
|
"onedrive": "onedriveFolder",
|
|
"outlook": "outlookFolder",
|
|
"drive": "googleDriveFolder",
|
|
"gmail": "gmailFolder",
|
|
"files": "ftpFolder",
|
|
"clickup": "clickup",
|
|
"kdrive": "kdriveFolder",
|
|
"mail": "mailFolder",
|
|
"calendar": "calendarFolder",
|
|
"contact": "contactFolder",
|
|
}
|
|
|
|
|
|
def _reverseService(sourceType: str) -> str:
|
|
for svc, st in _SERVICE_TO_SOURCE_TYPE.items():
|
|
if st == sourceType:
|
|
return svc
|
|
return sourceType
|