From 7061dd4303afa7ca1cd9bf0082b75e45c09202ec Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Wed, 10 Jun 2026 16:32:45 +0200
Subject: [PATCH] ui generic rendering - base
---
.../workspace/routeFeatureWorkspace.py | 48 ++++++---
.../interfaces/interfaceWorkflowAutomation.py | 8 +-
modules/routes/routeWorkflowAutomation.py | 101 ++++++++++++------
modules/workflowAutomation/helpers.py | 13 +--
tests/demo/README.md | 36 +++----
tests/demo/test_demo_api.py | 16 ++-
6 files changed, 138 insertions(+), 84 deletions(-)
diff --git a/modules/features/workspace/routeFeatureWorkspace.py b/modules/features/workspace/routeFeatureWorkspace.py
index 66ed4966..df2603c4 100644
--- a/modules/features/workspace/routeFeatureWorkspace.py
+++ b/modules/features/workspace/routeFeatureWorkspace.py
@@ -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": []})
diff --git a/modules/interfaces/interfaceWorkflowAutomation.py b/modules/interfaces/interfaceWorkflowAutomation.py
index 84efe40a..c72c0b1e 100644
--- a/modules/interfaces/interfaceWorkflowAutomation.py
+++ b/modules/interfaces/interfaceWorkflowAutomation.py
@@ -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, {
diff --git a/modules/routes/routeWorkflowAutomation.py b/modules/routes/routeWorkflowAutomation.py
index fe3e8853..1ea2ba1b 100644
--- a/modules/routes/routeWorkflowAutomation.py
+++ b/modules/routes/routeWorkflowAutomation.py
@@ -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()
diff --git a/modules/workflowAutomation/helpers.py b/modules/workflowAutomation/helpers.py
index b2ba3f9f..9f28f274 100644
--- a/modules/workflowAutomation/helpers.py
+++ b/modules/workflowAutomation/helpers.py
@@ -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}
diff --git a/tests/demo/README.md b/tests/demo/README.md
index 9d68586f..b7ecd779 100644
--- a/tests/demo/README.md
+++ b/tests/demo/README.md
@@ -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//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) |
diff --git a/tests/demo/test_demo_api.py b/tests/demo/test_demo_api.py
index 303b049d..b4a12ae6 100644
--- a/tests/demo/test_demo_api.py
+++ b/tests/demo/test_demo_api.py
@@ -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)