platform-core/modules/routes/routeUdb.py
ValueOn AG bc7c6fe27c
Some checks failed
Deploy Plattform-Core (Int) / test (push) Failing after 13s
Deploy Plattform-Core (Int) / deploy (push) Has been skipped
elimination of technical issues (imports)
2026-06-06 00:32:45 +02:00

216 lines
8 KiB
Python

# 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 json
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"},
},
)
# ---------------------------------------------------------------------------
# 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)
"""
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 | 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", "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)
from modules.dbHelpers.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
# 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.datamodelFeatures 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