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: except Exception as e:
logger.debug(f"getWorkspaceMessages: cannot read attachments for {workflowId}: {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] = {} attachedDsLabels: Dict[str, str] = {}
attachedFdsLabels: Dict[str, str] = {} attachedFdsLabels: Dict[str, str] = {}
if attachedDsIds or attachedFdsIds: if attachedDsIds or attachedFdsIds:
@ -1153,27 +1157,39 @@ async def getWorkspaceMessages(
rootIf = getRootInterface() rootIf = getRootInterface()
if attachedDsIds: if attachedDsIds:
from modules.datamodels.datamodelDataSource import DataSource from modules.datamodels.datamodelDataSource import DataSource
resolvedDsIds: List[str] = []
for dsId in attachedDsIds: for dsId in attachedDsIds:
try: try:
records = rootIf.db.getRecordset(DataSource, recordFilter={"id": dsId}) records = rootIf.db.getRecordset(DataSource, recordFilter={"id": dsId})
if records: 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 "" lbl = records[0].get("label") or records[0].get("path") or ""
if lbl: attachedDsLabels[dsId] = str(lbl) if lbl else dsId
attachedDsLabels[dsId] = str(lbl) attachedDsIds = resolvedDsIds
except Exception:
pass
if attachedFdsIds: if attachedFdsIds:
from modules.datamodels.datamodelFeatures import FeatureDataSource from modules.datamodels.datamodelFeatures import FeatureDataSource
resolvedFdsIds: List[str] = []
for fdsId in attachedFdsIds: for fdsId in attachedFdsIds:
try: try:
records = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"id": fdsId}) records = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"id": fdsId})
if records: 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 "" tbl = records[0].get("tableName") or ""
lbl = records[0].get("label") or tbl lbl = records[0].get("label") or tbl
if lbl: attachedFdsLabels[fdsId] = str(lbl) if lbl else fdsId
attachedFdsLabels[fdsId] = str(lbl) attachedFdsIds = resolvedFdsIds
except Exception:
pass
return JSONResponse({ return JSONResponse({
"messages": items, "messages": items,
@ -1467,9 +1483,9 @@ async def listFeatureDataSources(
from modules.serviceCenter.core.flagResolution import buildEffectiveByWorkspaceFds from modules.serviceCenter.core.flagResolution import buildEffectiveByWorkspaceFds
rootIf = getRootInterface() rootIf = getRootInterface()
recordFilter: dict = {} if not wsMandateId:
if wsMandateId: return JSONResponse({"featureDataSources": []})
recordFilter["mandateId"] = wsMandateId recordFilter: dict = {"mandateId": wsMandateId}
records = rootIf.db.getRecordset(FeatureDataSource, recordFilter=recordFilter) or [] records = rootIf.db.getRecordset(FeatureDataSource, recordFilter=recordFilter) or []
if not records: if not records:
return JSONResponse({"featureDataSources": []}) return JSONResponse({"featureDataSources": []})

View file

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

View file

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

View file

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

View file

@ -2,33 +2,33 @@
Automated tests for the investor demo configuration. Automated tests for the investor demo configuration.
## Prerequisites ## SAFETY RULE (critical)
1. Gateway DB must be running and accessible Tests in this suite MUST be strictly read-only towards the database.
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` 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 ## Run
```bash ```bash
cd gateway/ cd platform-core/
# All demo tests (structural, no AI calls):
pytest tests/demo/ -v 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 ## Test files
| File | What it tests | | File | What it tests |
|------|--------------| |------|--------------|
| `test_demo_bootstrap.py` | Idempotent load/remove, mandates, user, features, RMA, neutralization | | `test_demo_api.py` | Config auto-discovery (read-only), list endpoint rejects unauthenticated |
| `test_demo_uc1_trustee.py` | Trustee instances, RMA config, system workflow templates | | `test_demo_data_files.py` | Demo data files exist in `demoData/` (filesystem only) |
| `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 |

View file

@ -44,7 +44,13 @@ class TestDemoConfigDiscovery:
class TestDemoConfigApiEndpoints: 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") @pytest.fixture(scope="class")
def client(self): def client(self):
@ -55,11 +61,3 @@ class TestDemoConfigApiEndpoints:
def test_listEndpointRejectsUnauthenticated(self, client): def test_listEndpointRejectsUnauthenticated(self, client):
response = client.get("/api/admin/demo-config") response = client.get("/api/admin/demo-config")
assert response.status_code in (401, 403) 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)