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)