ui generic rendering - base
All checks were successful
Deploy Plattform-Core (Int) / test (push) Successful in 1m3s
Deploy Plattform-Core (Int) / deploy (push) Successful in 9s

This commit is contained in:
ValueOn AG 2026-06-10 16:32:45 +02:00
parent d842884ccf
commit 7061dd4303
6 changed files with 138 additions and 84 deletions

View file

@ -1146,6 +1146,10 @@ async def getWorkspaceMessages(
except Exception as e:
logger.debug(f"getWorkspaceMessages: cannot read attachments for {workflowId}: {e}")
# Resolve labels server-side as the single source of truth: every returned
# ID carries a label, and IDs whose record no longer exists are dropped
# here. The client renders what it gets without any timing-dependent
# reconciliation against its own (asynchronously loaded) source lists.
attachedDsLabels: Dict[str, str] = {}
attachedFdsLabels: Dict[str, str] = {}
if attachedDsIds or attachedFdsIds:
@ -1153,27 +1157,39 @@ async def getWorkspaceMessages(
rootIf = getRootInterface()
if attachedDsIds:
from modules.datamodels.datamodelDataSource import DataSource
resolvedDsIds: List[str] = []
for dsId in attachedDsIds:
try:
records = rootIf.db.getRecordset(DataSource, recordFilter={"id": dsId})
if records:
lbl = records[0].get("label") or records[0].get("path") or ""
if lbl:
attachedDsLabels[dsId] = str(lbl)
except Exception:
pass
except Exception as e:
# Transient DB error: keep the id; the client falls back to
# its own dataSources list for the label.
logger.warning(f"getWorkspaceMessages: label lookup failed for DataSource {dsId}: {e}")
resolvedDsIds.append(dsId)
continue
if not records:
continue # source was deleted -- drop the stale attachment
resolvedDsIds.append(dsId)
lbl = records[0].get("label") or records[0].get("path") or ""
attachedDsLabels[dsId] = str(lbl) if lbl else dsId
attachedDsIds = resolvedDsIds
if attachedFdsIds:
from modules.datamodels.datamodelFeatures import FeatureDataSource
resolvedFdsIds: List[str] = []
for fdsId in attachedFdsIds:
try:
records = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"id": fdsId})
if records:
tbl = records[0].get("tableName") or ""
lbl = records[0].get("label") or tbl
if lbl:
attachedFdsLabels[fdsId] = str(lbl)
except Exception:
pass
except Exception as e:
logger.warning(f"getWorkspaceMessages: label lookup failed for FeatureDataSource {fdsId}: {e}")
resolvedFdsIds.append(fdsId)
continue
if not records:
continue # source was deleted -- drop the stale attachment
resolvedFdsIds.append(fdsId)
tbl = records[0].get("tableName") or ""
lbl = records[0].get("label") or tbl
attachedFdsLabels[fdsId] = str(lbl) if lbl else fdsId
attachedFdsIds = resolvedFdsIds
return JSONResponse({
"messages": items,
@ -1467,9 +1483,9 @@ async def listFeatureDataSources(
from modules.serviceCenter.core.flagResolution import buildEffectiveByWorkspaceFds
rootIf = getRootInterface()
recordFilter: dict = {}
if wsMandateId:
recordFilter["mandateId"] = wsMandateId
if not wsMandateId:
return JSONResponse({"featureDataSources": []})
recordFilter: dict = {"mandateId": wsMandateId}
records = rootIf.db.getRecordset(FeatureDataSource, recordFilter=recordFilter) or []
if not records:
return JSONResponse({"featureDataSources": []})

View file

@ -375,11 +375,11 @@ class WorkflowAutomationObjects:
return []
records = self.db.getRecordset(
AutoRun,
recordFilter={},
recordFilter={"workflowId": wf_ids},
)
if not records:
return []
runs = [dict(r) for r in records if r.get("workflowId") in wf_ids]
runs = [dict(r) for r in records]
wf_by_id = {w["id"]: w for w in workflows}
for r in runs:
wf = wf_by_id.get(r.get("workflowId"), {})
@ -652,7 +652,7 @@ class WorkflowAutomationObjects:
def copyTemplateToUser(self, templateId: str) -> Optional[Dict[str, Any]]:
"""Copy a template to a new user-owned workflow with templateScope='user'."""
template = self.getWorkflow(templateId)
template = self.db.getRecord(AutoWorkflow, templateId)
if not template or not template.get("isTemplate"):
return None
data = {
@ -672,7 +672,7 @@ class WorkflowAutomationObjects:
def shareTemplate(self, templateId: str, scope: str) -> Optional[Dict[str, Any]]:
"""Change a template's scope. Sets sharedReadOnly=True for shared scopes, False for user scope."""
template = self.getWorkflow(templateId)
template = self.db.getRecord(AutoWorkflow, templateId)
if not template or not template.get("isTemplate"):
return None
updated = self.db.recordModify(AutoWorkflow, templateId, {

View file

@ -69,12 +69,13 @@ async def _listWorkflows(
try:
db._ensureTableExists(AutoWorkflow)
scopeFilter = _scopedWorkflowFilter(request)
if mandateId and scopeFilter is not None:
if mandateId not in (scopeFilter.get("mandateId") or []):
if mandateId:
scopeMandates = scopeFilter.get("mandateId") or []
if isinstance(scopeMandates, str):
scopeMandates = [scopeMandates]
if mandateId not in scopeMandates:
return {"items": [], "total": 0}
scopeFilter = {"mandateId": mandateId}
elif mandateId and scopeFilter is None:
scopeFilter = {"mandateId": mandateId}
params = _parsePaginationOr400(pagination)
records = db.getRecordset(AutoWorkflow, recordFilter=scopeFilter)
@ -178,13 +179,13 @@ async def _listRuns(
try:
db._ensureTableExists(AutoRun)
scopeFilter = _scopedRunFilter(request)
if mandateId:
if scopeFilter is None:
scopeFilter = {"mandateId": mandateId}
elif "mandateId" in scopeFilter:
if mandateId not in scopeFilter["mandateId"]:
return {"items": [], "total": 0}
scopeFilter = {"mandateId": mandateId}
if mandateId and scopeFilter and "mandateId" in scopeFilter:
scopeMandates = scopeFilter["mandateId"]
if isinstance(scopeMandates, str):
scopeMandates = [scopeMandates]
if mandateId not in scopeMandates:
return {"items": [], "total": 0}
scopeFilter = {"mandateId": mandateId}
if workflowId:
scopeFilter = {**(scopeFilter or {}), "workflowId": workflowId}
@ -242,16 +243,21 @@ async def _listTasks(
db = _getWorkflowAutomationDb()
try:
db._ensureTableExists(AutoTask)
scopeFilter: Optional[Dict[str, Any]] = None
userId = str(request.user.id) if request.user else None
if not userId:
return {"items": [], "total": 0}
userMandateIds = _getUserMandateIds(userId)
if not userMandateIds:
return {"items": [], "total": 0}
if not request.isPlatformAdmin:
userId = str(request.user.id) if request.user else None
if not userId:
return {"items": [], "total": 0}
scopeFilter = {"assigneeId": userId}
wfFilter = {"mandateId": userMandateIds, "isTemplate": False}
db._ensureTableExists(AutoWorkflow)
wfs = db.getRecordset(AutoWorkflow, recordFilter=wfFilter) or []
wfIds = [w.get("id") for w in wfs]
scopeFilter: Dict[str, Any] = {"workflowId": wfIds} if wfIds else {"workflowId": "__none__"}
if status:
scopeFilter = {**(scopeFilter or {}), "status": status}
scopeFilter["status"] = status
params = _parsePaginationOr400(pagination)
records = db.getRecordset(AutoTask, recordFilter=scopeFilter)
@ -286,6 +292,27 @@ async def _listVersions(
db.close()
# ---------------------------------------------------------------------------
# Editor chat history (stub — persistence not yet implemented)
# ---------------------------------------------------------------------------
@router.get("/{workflowId}/chat/messages")
async def _getEditorChatMessages(
workflowId: str,
request: RequestContext = Depends(getRequestContext),
):
db = _getWorkflowAutomationDb()
try:
db._ensureTableExists(AutoWorkflow)
wf = db.getRecord(AutoWorkflow, workflowId)
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
_validateWorkflowAccess(request, wf, "read")
return {"messages": []}
finally:
db.close()
# ---------------------------------------------------------------------------
# Step logs
# ---------------------------------------------------------------------------
@ -424,9 +451,11 @@ def _listTemplates(
raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required"))
userId = str(context.user.id)
userMandateIds = _getUserMandateIds(userId) if not context.isPlatformAdmin else []
userMandateIds = _getUserMandateIds(userId)
if mandateId and mandateId not in userMandateIds:
raise HTTPException(status_code=403, detail=routeApiMsg("Access denied"))
effectiveMandateId = mandateId or (userMandateIds[0] if userMandateIds else None)
if not effectiveMandateId and not context.isPlatformAdmin:
if not effectiveMandateId:
return {"templates": []}
instanceId = None
@ -535,8 +564,10 @@ def _copyTemplate(
mandateId = body.get("mandateId") if isinstance(body, dict) else None
userId = str(context.user.id)
userMandateIds = _getUserMandateIds(userId)
if mandateId and mandateId not in userMandateIds:
raise HTTPException(status_code=403, detail=routeApiMsg("Access denied"))
if not mandateId:
userMandateIds = _getUserMandateIds(userId)
mandateId = userMandateIds[0] if userMandateIds else ""
db = _getWorkflowAutomationDb()
@ -577,8 +608,10 @@ def _shareTemplate(
mandateId = body.get("mandateId", "")
userId = str(context.user.id)
userMandateIds = _getUserMandateIds(userId)
if mandateId and mandateId not in userMandateIds:
raise HTTPException(status_code=403, detail=routeApiMsg("Access denied"))
if not mandateId:
userMandateIds = _getUserMandateIds(userId)
mandateId = userMandateIds[0] if userMandateIds else ""
db = _getWorkflowAutomationDb()
@ -984,14 +1017,12 @@ def _getMetrics(
raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required"))
userId = str(context.user.id)
userMandateIds = _getUserMandateIds(userId) if not context.isPlatformAdmin else []
userMandateIds = _getUserMandateIds(userId)
if mandateId:
if not context.isPlatformAdmin and mandateId not in userMandateIds:
if mandateId not in userMandateIds:
raise HTTPException(status_code=403, detail=routeApiMsg("Access denied"))
scopeFilter: Dict[str, Any] = {"mandateId": mandateId, "isTemplate": False}
elif context.isPlatformAdmin:
scopeFilter = {"isTemplate": False}
elif userMandateIds:
scopeFilter = {"mandateId": userMandateIds, "isTemplate": False}
else:
@ -1001,13 +1032,21 @@ def _getMetrics(
"totalTokens": 0, "totalCredits": 0.0,
}
runScope = _scopedRunFilter(context)
if mandateId and runScope:
if "mandateId" in runScope:
if mandateId not in (runScope["mandateId"] if isinstance(runScope["mandateId"], list) else [runScope["mandateId"]]):
runScope = {"mandateId": "__none__"}
else:
runScope = {"mandateId": mandateId}
db = _getWorkflowAutomationDb()
try:
workflows = db.getRecordset(AutoWorkflow, recordFilter=scopeFilter) or [] if db._ensureTableExists(AutoWorkflow) else []
wfIds = [w.get("id") for w in workflows]
runFilter = {"workflowId": wfIds} if wfIds else {"workflowId": "__none__"}
runs = db.getRecordset(AutoRun, recordFilter=runFilter) or [] if db._ensureTableExists(AutoRun) else []
tasks = db.getRecordset(AutoTask, recordFilter=runFilter) or [] if db._ensureTableExists(AutoTask) else []
workflows = (db.getRecordset(AutoWorkflow, recordFilter=scopeFilter) or []) if db._ensureTableExists(AutoWorkflow) else []
runs = (db.getRecordset(AutoRun, recordFilter=runScope) or []) if db._ensureTableExists(AutoRun) else []
runIds = [r.get("id") for r in runs]
taskFilter = {"runId": runIds} if runIds else {"runId": "__none__"}
tasks = (db.getRecordset(AutoTask, recordFilter=taskFilter) or []) if db._ensureTableExists(AutoTask) else []
finally:
db.close()

View file

@ -126,9 +126,7 @@ def _isUserMandateAdmin(userId: str, mandateId: str) -> bool:
def _scopedWorkflowFilter(context: RequestContext) -> Optional[Dict[str, Any]]:
"""Build DB filter for listing workflows: mandate-scoped for members, None for sysadmin."""
if context.isPlatformAdmin:
return None
"""Build DB filter for listing workflows: always mandate-scoped by membership."""
userId = str(context.user.id) if context.user else None
if not userId:
return {"mandateId": "__impossible__"}
@ -139,14 +137,17 @@ def _scopedWorkflowFilter(context: RequestContext) -> Optional[Dict[str, Any]]:
def _scopedRunFilter(context: RequestContext) -> Optional[Dict[str, Any]]:
"""Build DB filter for listing runs: admin sees mandate runs, user sees own."""
if context.isPlatformAdmin:
return None
"""Build DB filter for listing runs: always mandate-scoped by membership.
Mandate admins see all runs in their mandates, regular members see own."""
userId = str(context.user.id) if context.user else None
if not userId:
return {"ownerId": "__impossible__"}
mandateIds = _getUserMandateIds(userId)
if not mandateIds:
return {"ownerId": "__impossible__"}
adminMandateIds = _getAdminMandateIds(userId, mandateIds)
if context.isPlatformAdmin:
return {"mandateId": mandateIds}
if adminMandateIds:
return {"mandateId": adminMandateIds}
return {"ownerId": userId}

View file

@ -2,33 +2,33 @@
Automated tests for the investor demo configuration.
## Prerequisites
## SAFETY RULE (critical)
1. Gateway DB must be running and accessible
2. Demo config must be loaded first: Admin UI → `/admin/demo-config` → Load "Investor Demo April 2026"
3. RMA credentials must be set in `gateway/config.ini`
Tests in this suite MUST be strictly read-only towards the database.
Pytest runs against the **real dev database** (there is no separate test DB).
Tests must NEVER load or remove demo configs — neither via direct module
calls (`cfg.load()` / `cfg.remove()`) nor via HTTP calls to
`/api/admin/demo-config/<code>/load|remove` (not even to assert 401/403).
Background: on 2026-06-09 a demo test reloaded `investor-demo-2026` during a
pytest run. The demo mandates (HappyLife AG, Alpina Treuhand AG) were deleted
and recreated with new UUIDs, orphaning all real feature data (trustee
accounting incl. RMA connection, workflows, documents). The data had to be
recovered by remapping ~15'000 rows across 9 databases.
Demo configs are loaded/removed manually via Admin UI → `/admin/demo-config`.
## Run
```bash
cd gateway/
# All demo tests (structural, no AI calls):
cd platform-core/
pytest tests/demo/ -v
# Only bootstrap tests:
pytest tests/demo/test_demo_bootstrap.py -v
# Only UC1 trustee:
pytest tests/demo/test_demo_uc1_trustee.py -v
```
## Test files
| File | What it tests |
|------|--------------|
| `test_demo_bootstrap.py` | Idempotent load/remove, mandates, user, features, RMA, neutralization |
| `test_demo_uc1_trustee.py` | Trustee instances, RMA config, system workflow templates |
| `test_demo_uc2_realestate.py` | Workspace instances for agent demo |
| `test_demo_uc4_i18n.py` | i18n readiness, Spanish not pre-installed |
| `test_demo_neutralization.py` | Neutralization config enabled, test PDF exists |
| `test_demo_api.py` | Config auto-discovery (read-only), list endpoint rejects unauthenticated |
| `test_demo_data_files.py` | Demo data files exist in `demoData/` (filesystem only) |

View file

@ -44,7 +44,13 @@ class TestDemoConfigDiscovery:
class TestDemoConfigApiEndpoints:
"""Test API endpoints via TestClient."""
"""Test API endpoints via TestClient.
SAFETY: Never call the load/remove endpoints here - not even to assert
401/403. Tests run against the real dev database; if auth/CSRF ever lets
a request through, demo mandates get deleted and recreated with new UUIDs,
orphaning all real feature data (happened on 2026-06-09).
"""
@pytest.fixture(scope="class")
def client(self):
@ -55,11 +61,3 @@ class TestDemoConfigApiEndpoints:
def test_listEndpointRejectsUnauthenticated(self, client):
response = client.get("/api/admin/demo-config")
assert response.status_code in (401, 403)
def test_loadEndpointRejectsUnauthenticated(self, client):
response = client.post("/api/admin/demo-config/investor-demo-2026/load")
assert response.status_code in (401, 403)
def test_removeEndpointRejectsUnauthenticated(self, client):
response = client.post("/api/admin/demo-config/investor-demo-2026/remove")
assert response.status_code in (401, 403)