# 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.services.serviceKnowledge._inheritFlags import ( resolveEffectiveForPath, ) out = resolveEffectiveForPath(self.connectionId, self.sourceType, self.path, allDs, mode=mode) key = "effective" + flag[0].upper() + flag[1:] return out.get(key, False) def setFlag(self, flag, value, rootIf) -> List[str]: from modules.datamodels.datamodelDataSource import DataSource from modules.serviceCenter.services.serviceKnowledge._inheritFlags import ( cascadeResetDescendants, ) if not self.rec: self.rec = _findOrCreateDs( rootIf, self.connectionId, self.sourceType, self.path, ) sourceId = self.rec.get("id") resetIds: List[str] = [] if value is not None: resetIds = cascadeResetDescendants(rootIf, self.rec, flag) rootIf.db.recordModify(DataSource, sourceId, {flag: value}) return resetIds class ConnectionNode(_DataSourceFamilyNode): """Connection-root DataSource node (path='/', authority sourceType).""" kind = "connection" def __init__(self, connectionId: str, authority: str, label: str, parentKey: str, rec: Optional[Dict[str, Any]]) -> None: super().__init__( key=_encode("conn", connectionId), label=label, parentKey=parentKey, connectionId=connectionId, sourceType=str(authority), path="/", rec=rec, ) self.authority = authority def toDict(self, allDs, allFds) -> Dict[str, Any]: return _dsDict(self, allDs) class ServiceNode(_DataSourceFamilyNode): """Service-level node (e.g. sharepoint, drive) under a Connection.""" kind = "service" def __init__(self, connectionId: str, service: str, sourceType: str, label: str, parentKey: str, rec: Optional[Dict[str, Any]]) -> None: super().__init__( key=_encode("svc", connectionId, service), label=label, parentKey=parentKey, connectionId=connectionId, sourceType=sourceType, path="/", rec=rec, ) self.service = service def toDict(self, allDs, allFds) -> Dict[str, Any]: out = _dsDict(self, allDs) out["service"] = self.service return out class _BrowseNode(_DataSourceFamilyNode): """Folder/File node from a connector browse() result.""" def __init__(self, kind: str, connectionId: str, service: str, sourceType: str, path: str, label: str, parentKey: str, rec: Optional[Dict[str, Any]], hasChildren: bool) -> None: super().__init__( key=_encode("ds", connectionId, sourceType, path), label=label, parentKey=parentKey, connectionId=connectionId, sourceType=sourceType, path=path, rec=rec, ) self.kind = kind self.service = service self._hasChildren = hasChildren def toDict(self, allDs, allFds) -> Dict[str, Any]: out = _dsDict(self, allDs) out["service"] = self.service out["hasChildren"] = self._hasChildren return out class FolderNode(_BrowseNode): """Folder DataSource node from connector browse().""" def __init__(self, **kwargs) -> None: super().__init__(kind="folder", **kwargs) class FileNode(_BrowseNode): """File DataSource node from connector browse().""" def __init__(self, **kwargs) -> None: super().__init__(kind="file", **kwargs) def _dsDict(node: _DataSourceFamilyNode, allDs: List[Dict[str, Any]]) -> Dict[str, Any]: """Shared serialization for DS-family nodes.""" return { "key": node.key, "kind": node.kind, "parentKey": node.parentKey, "label": node.label, "icon": getattr(node, "authority", None) or getattr(node, "service", None) or node.kind, "hasChildren": True, "dataSourceId": node.rec.get("id") if node.rec else None, "modelType": "DataSource" if node.rec else None, "effectiveNeutralize": node.getEffectiveFlag("neutralize", allDs, [], "aggregate"), "effectiveScope": "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.services.serviceKnowledge._inheritFlags import ( resolveEffectiveForFds, ) out = resolveEffectiveForFds(self.featureInstanceId, self.tableName, self.recordFilter, allFds, mode=mode) key = "effective" + flag[0].upper() + flag[1:] return out.get(key, False) def setFlag(self, flag, value, rootIf) -> List[str]: if not self.supportsFlag(flag): raise ValueError(f"FDS does not support flag {flag!r}") from modules.datamodels.datamodelFeatures import FeatureDataSource from modules.serviceCenter.services.serviceKnowledge._inheritFlags import ( cascadeResetDescendantsFds, ) if not self.rec: raise RuntimeError( f"Cannot setFlag on virtual FDS node {self.key}: " "create the FeatureDataSource record first." ) sourceId = self.rec.get("id") resetIds: List[str] = [] if value is not None: resetIds = cascadeResetDescendantsFds(rootIf, self.rec, flag) modifyFields: Dict[str, Any] = {flag: value} self._onSetFlag(modifyFields, flag, value, rootIf) rootIf.db.recordModify(FeatureDataSource, sourceId, modifyFields) return resetIds def _onSetFlag(self, modifyFields: Dict[str, Any], flag: str, value: Any, rootIf: Any) -> None: """Subclass hook: extend `modifyFields` or perform side-effects.""" pass class FdsWorkspaceNode(_FdsFamilyNode): """Synthetic feature-wildcard FDS node (tableName='*'). Rendered as 'featureNode' in the tree; one per accessible feature instance under its mandate group. """ kind = "featureNode" def __init__(self, mandateId: str, featureCode: str, featureInstanceId: str, label: str, icon: str, parentKey: str, rec: Optional[Dict[str, Any]]) -> None: super().__init__( key=_encode("feat", mandateId, featureCode, featureInstanceId), label=label, parentKey=parentKey, featureInstanceId=featureInstanceId, tableName="*", recordFilter=None, rec=rec, ) self.mandateId = mandateId self.featureCode = featureCode self.icon = icon def _onSetFlag(self, modifyFields, flag, value, rootIf): if flag == "neutralize" and value is not None: self._clearDescendantNeutralizeFields(rootIf) def _clearDescendantNeutralizeFields(self, rootIf): """Wipe `neutralizeFields` on all descendant table FDS records. When the workspace sets an explicit neutralize, per-column overrides on descendant tables become obsolete — the workspace value cascades down via inheritance. Without clearing them the table aggregate stays 'mixed' because some field children still read True from the list while others inherit the new value. """ from modules.datamodels.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.services.serviceKnowledge._inheritFlags import ( resolveEffectiveForFds, ) out = resolveEffectiveForFds( self.featureInstanceId, self.tableName, None, allFds, mode="walk", ) value = out.get("effectiveNeutralize", False) return bool(value) if isinstance(value, bool) else False def setFlag(self, flag, value, rootIf) -> List[str]: if flag != "neutralize": raise ValueError(f"FdsFieldNode does not support flag {flag!r}") from modules.datamodels.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.services.serviceKnowledge._inheritFlags import normalisePath normPath = normalisePath(path) existing = rootIf.db.getRecordset(DataSource, recordFilter={ "connectionId": connectionId, "sourceType": sourceType, }) or [] for rec in existing: if normalisePath(rec.get("path")) == normPath: return rec conn = rootIf.db.getRecord(UserConnection, connectionId) if not conn: raise RuntimeError(f"UserConnection {connectionId} not found") userId = str(conn.get("userId") or "") mandateId = "" for rec in existing: mid = rec.get("mandateId") if mid: mandateId = str(mid) break pathLabel = normPath.rsplit("/", 1)[-1] or normPath stub = DataSource( connectionId=connectionId, sourceType=sourceType, path=normPath, label=pathLabel, displayPath=normPath, userId=userId, mandateId=mandateId, ) created = rootIf.db.recordCreate(DataSource, stub.model_dump()) if isinstance(created, dict): return created return stub.model_dump() def isFeatureAdmin(rootIf: Any, userId: str, featureInstanceId: str) -> bool: """Return True iff the user holds a `*-admin` role on this feature instance. Convention: feature-specific admin role labels end with `-admin` (e.g. `workspace-admin`, `automation-admin`). See `.cursor/rules/ rbac-role-separation.mdc`. """ try: access = rootIf.getFeatureAccess(userId, featureInstanceId) if not access or not getattr(access, "enabled", True): return False accessId = getattr(access, "id", None) or (access.get("id") if isinstance(access, dict) else None) if not accessId: return False roleIds = rootIf.getRoleIdsForFeatureAccess(accessId) or [] if not roleIds: return False from modules.datamodels.datamodelRbac import Role for rid in roleIds: rec = rootIf.db.getRecord(Role, rid) if not rec: continue label = str(rec.get("roleLabel") or "").lower() if label.endswith("-admin"): return True return False except Exception as exc: logger.warning("isFeatureAdmin check failed (user=%s feature=%s): %s", userId, featureInstanceId, exc) return False def _findFdsByCoord(allFds: List[Dict[str, Any]], featureInstanceId: str, tableName: str, recordFilter: Optional[Dict[str, str]]) -> Optional[Dict[str, Any]]: target = recordFilter or None for fds in allFds: if fds.get("featureInstanceId") != featureInstanceId: continue if (fds.get("tableName") or "") != tableName: continue if (fds.get("recordFilter") or None) == target: return fds return None def _findOrCreateTableFds(rootIf: Any, featureInstanceId: str, tableName: str, featureCode: str, anyFieldName: str) -> Dict[str, Any]: """Look up the table-level FDS record. Create a minimal stub if missing. Required so an `fdsField` neutralize toggle can persist its state without forcing the user to first 'add' the table via a separate UI affordance. The stub carries the same featureInstanceId/tableName coordinate; its `objectKey`/`label` are filled from the RBAC catalog so list endpoints still render it correctly. """ from modules.datamodels.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.services.serviceKnowledge._inheritFlags import normalisePath rf = {"connectionId": connectionId} if sourceType is not None: rf["sourceType"] = sourceType records = rootIf.db.getRecordset(DataSource, recordFilter=rf) or [] norm = normalisePath(path) if sourceType is None: # connection-root: any record with path='/' on this connection for r in records: if normalisePath(r.get("path")) == "/": return r return None for r in records: if normalisePath(r.get("path")) == norm: return r return None def _loadAllFds(rootIf: Any, featureInstanceId: str) -> List[Dict[str, Any]]: from modules.datamodels.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