ui generic rendering - base
This commit is contained in:
parent
d842884ccf
commit
7061dd4303
6 changed files with 138 additions and 84 deletions
|
|
@ -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": []})
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue