From 670ae1e0ea204e691858f7a2f308adc42970a57f Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 14 Apr 2026 22:56:25 +0200
Subject: [PATCH] fixes from demo1: compliance ui fgtable issues, nodes
vertical, nodes editting logic to edit in all editors of a mmandate based on
highest level of role
---
.../commcoach/serviceCommcoachPersonas.py | 45 +++++
.../interfaceFeatureGraphicalEditor.py | 6 +-
modules/routes/routeAudit.py | 179 +++++++++++++++++-
3 files changed, 220 insertions(+), 10 deletions(-)
diff --git a/modules/features/commcoach/serviceCommcoachPersonas.py b/modules/features/commcoach/serviceCommcoachPersonas.py
index db14363c..f5c8254e 100644
--- a/modules/features/commcoach/serviceCommcoachPersonas.py
+++ b/modules/features/commcoach/serviceCommcoachPersonas.py
@@ -101,6 +101,51 @@ BUILTIN_PERSONAS: List[Dict[str, Any]] = [
"gender": "m",
"category": "builtin",
},
+ # --- Immobilien / Liegenschaftsverwaltung (PWG-Kontext) ---
+ {
+ "key": "tenant_payment_arrears_m",
+ "label": "Mieter mit Zahlungsrückstand",
+ "description": "René Bachmann, Mieter einer 3.5-Zimmer-Wohnung. Seit drei Monaten im Mietrückstand, hat zwei Mahnungen "
+ "erhalten und ist genervt vom Druck. Fühlt sich ungerecht behandelt, verweist auf persönliche Schwierigkeiten "
+ "(Jobverlust, Scheidung). Reagiert defensiv und gereizt auf Forderungen. Braucht empathisches Gegenüber, "
+ "das gleichzeitig klar die Zahlungspflicht kommuniziert. Kann sich auf eine Ratenzahlung einlassen, "
+ "wenn er sich respektiert fühlt und einen konkreten Plan sieht.",
+ "gender": "m",
+ "category": "builtin",
+ },
+ {
+ "key": "tenant_utility_costs_f",
+ "label": "Mieterin mit Nebenkostenfragen",
+ "description": "Fatima El-Amin, Mieterin seit vier Jahren. Hat die jährliche Nebenkostenabrechnung erhalten und versteht "
+ "mehrere Positionen nicht (Hauswartung, Allgemeinstrom, Verwaltungskosten). Emotional aufgebracht, weil die "
+ "Nachzahlung unerwartet hoch ist. Vermutet Fehler oder unfaire Verteilung. Spricht schnell und unterbricht. "
+ "Braucht geduldige, verständliche Erklärungen ohne Fachjargon. Beruhigt sich, wenn man Positionen einzeln "
+ "durchgeht und auf die Rechtsgrundlage (Mietvertrag, Nebenkosten-Verordnung) verweist.",
+ "gender": "f",
+ "category": "builtin",
+ },
+ {
+ "key": "new_tenant_move_in_m",
+ "label": "Neuer Mieter (Einzug)",
+ "description": "Luca Steiner, zieht nächste Woche in seine erste eigene Wohnung ein. Aufgeregt aber unsicher — hat viele "
+ "Fragen zu Wohnungsübergabe, Schlüsselabholung, Hausordnung, Kautionseinzahlung und Anmeldung bei Werken "
+ "(Strom, Internet). Höflich und kooperativ, braucht aber klare, schrittweise Informationen. Fragt mehrfach "
+ "nach, wenn etwas unklar ist. Reagiert sehr positiv auf eine willkommene, strukturierte Begleitung.",
+ "gender": "m",
+ "category": "builtin",
+ },
+ {
+ "key": "difficult_neighbor_noise_m",
+ "label": "Nachbar mit Lärmbeschwerde",
+ "description": "Kurt Zürcher, langjähriger Mieter im Erdgeschoss. Beschwert sich massiv über Lärm aus der Wohnung darüber "
+ "(Musik abends, Kindergetrampel, Waschmaschine nach 22 Uhr). Hat bereits ein Lärmprotokoll geführt und "
+ "droht mit Mietminderung und Anwalt. Spricht laut, ist aufgebracht und fühlt sich von der Verwaltung "
+ "nicht ernst genommen. Erwartet sofortige Massnahmen. Kann deeskaliert werden, wenn man sein Anliegen "
+ "ernst nimmt, konkrete nächste Schritte aufzeigt (Gespräch mit Nachbar, schriftliche Verwarnung) und "
+ "auf die Hausordnung sowie seine Rechte und Pflichten verweist.",
+ "gender": "m",
+ "category": "builtin",
+ },
]
diff --git a/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py b/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py
index c98135ad..fabd5c42 100644
--- a/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py
+++ b/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py
@@ -174,12 +174,11 @@ class GraphicalEditorObjects:
# -------------------------------------------------------------------------
def getWorkflows(self, active: Optional[bool] = None) -> List[Dict[str, Any]]:
- """Get all workflows for this mandate and feature instance."""
+ """Get all workflows for this mandate (cross-instance)."""
if not self.db._ensureTableExists(Automation2Workflow):
return []
rf: Dict[str, Any] = {
"mandateId": self.mandateId,
- "featureInstanceId": self.featureInstanceId,
}
if active is not None:
rf["active"] = active
@@ -193,7 +192,7 @@ class GraphicalEditorObjects:
return rows
def getWorkflow(self, workflowId: str) -> Optional[Dict[str, Any]]:
- """Get a single workflow by ID."""
+ """Get a single workflow by ID (mandate-scoped, cross-instance)."""
if not self.db._ensureTableExists(Automation2Workflow):
return None
records = self.db.getRecordset(
@@ -201,7 +200,6 @@ class GraphicalEditorObjects:
recordFilter={
"id": workflowId,
"mandateId": self.mandateId,
- "featureInstanceId": self.featureInstanceId,
},
)
if not records:
diff --git a/modules/routes/routeAudit.py b/modules/routes/routeAudit.py
index 7effc1fb..86fbcea3 100644
--- a/modules/routes/routeAudit.py
+++ b/modules/routes/routeAudit.py
@@ -10,9 +10,10 @@ Provides three views:
RBAC: mandate-admin or compliance-viewer role required.
"""
+import json
import logging
import re
-from typing import Optional
+from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
from starlette.requests import Request
@@ -27,6 +28,107 @@ routeApiMsg = apiRouteContext("routeAudit")
router = APIRouter(prefix="/api/audit", tags=["Audit"])
+def _applySortFilterSearch(
+ items: List[Dict[str, Any]],
+ *,
+ sortJson: Optional[str] = None,
+ filtersJson: Optional[str] = None,
+ search: Optional[str] = None,
+ searchableKeys: Optional[List[str]] = None,
+) -> List[Dict[str, Any]]:
+ """Apply sort, filter and search to a list of dicts in-memory."""
+ if filtersJson:
+ try:
+ filters = json.loads(filtersJson) if isinstance(filtersJson, str) else filtersJson
+ if isinstance(filters, dict):
+ for key, val in filters.items():
+ if val is None or val == "":
+ continue
+ if isinstance(val, list):
+ items = [r for r in items if str(r.get(key, "")) in [str(v) for v in val]]
+ else:
+ items = [r for r in items if str(r.get(key, "")).lower() == str(val).lower()]
+ except (json.JSONDecodeError, TypeError):
+ pass
+
+ if search and searchableKeys:
+ needle = search.lower()
+ items = [r for r in items if any(needle in str(r.get(k, "")).lower() for k in searchableKeys)]
+
+ if sortJson:
+ try:
+ sortList = json.loads(sortJson) if isinstance(sortJson, str) else sortJson
+ if isinstance(sortList, list):
+ for sortDef in reversed(sortList):
+ field = sortDef.get("field", "")
+ desc = sortDef.get("direction", "asc") == "desc"
+ items.sort(key=lambda r, f=field: (r.get(f) is None, r.get(f, "")), reverse=desc)
+ except (json.JSONDecodeError, TypeError):
+ pass
+
+ return items
+
+
+def _distinctColumnValues(items: List[Dict[str, Any]], column: str) -> List[str]:
+ """Extract sorted distinct non-empty string values for a column."""
+ vals = set()
+ for r in items:
+ v = r.get(column)
+ if v is not None and v != "":
+ vals.add(str(v))
+ return sorted(vals)
+
+
+def _enrichUserAndInstanceLabels(
+ items: List[Dict[str, Any]],
+ context: "RequestContext",
+ userKey: str = "userId",
+ usernameKey: str = "username",
+ instanceKey: str = "featureInstanceId",
+ instanceLabelKey: str = "instanceLabel",
+) -> None:
+ """Resolve userId → username and featureInstanceId → label in-place."""
+ userIds = set()
+ instanceIds = set()
+ for r in items:
+ uid = r.get(userKey)
+ if uid and not r.get(usernameKey):
+ userIds.add(uid)
+ iid = r.get(instanceKey)
+ if iid:
+ instanceIds.add(iid)
+
+ userMap: Dict[str, str] = {}
+ instanceMap: Dict[str, str] = {}
+
+ try:
+ from modules.interfaces.interfaceDbApp import getInterface
+ appIf = getInterface(
+ context.user,
+ mandateId=str(context.mandateId) if context.mandateId else None,
+ )
+ if userIds:
+ users = appIf.getUsersByIds(list(userIds))
+ for uid, u in users.items():
+ name = getattr(u, "displayName", None) or getattr(u, "email", None) or uid
+ userMap[uid] = name
+ if instanceIds:
+ for iid in instanceIds:
+ fi = appIf.getFeatureInstance(iid)
+ if fi:
+ instanceMap[iid] = getattr(fi, "label", None) or getattr(fi, "featureCode", None) or iid
+ except Exception as e:
+ logger.debug("_enrichUserAndInstanceLabels: %s", e)
+
+ for r in items:
+ uid = r.get(userKey)
+ if uid and not r.get(usernameKey) and uid in userMap:
+ r[usernameKey] = userMap[uid]
+ iid = r.get(instanceKey)
+ if iid and iid in instanceMap:
+ r[instanceLabelKey] = instanceMap[iid]
+
+
def _requireAuditAccess(context: RequestContext):
"""Raise 403 unless user has mandate-admin or compliance-viewer access."""
from modules.auth.authentication import _hasSysAdminRole
@@ -62,6 +164,11 @@ async def getAiAuditLog(
dateTo: Optional[float] = Query(None, description="UTC epoch seconds"),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
+ sort: Optional[str] = Query(None, description='JSON array, e.g. [{"field":"timestamp","direction":"desc"}]'),
+ filters: Optional[str] = Query(None, description='JSON object, e.g. {"aiModel":"gpt-4o"}'),
+ search: Optional[str] = Query(None),
+ mode: Optional[str] = Query(None, description="'filterValues' to get distinct values for a column"),
+ column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
):
_requireAuditAccess(context)
mandateId = str(context.mandateId) if context.mandateId else ""
@@ -69,16 +176,35 @@ async def getAiAuditLog(
raise HTTPException(status_code=400, detail=routeApiMsg("Mandanten-ID erforderlich"))
from modules.shared.aiAuditLogger import aiAuditLogger
- return aiAuditLogger.getAiAuditLogs(
+ result = aiAuditLogger.getAiAuditLogs(
mandateId,
userId=userId,
featureInstanceId=featureInstanceId,
aiModel=aiModel,
fromTimestamp=dateFrom,
toTimestamp=dateTo,
- limit=limit,
- offset=offset,
+ limit=9999,
+ offset=0,
)
+ items = result.get("items", [])
+
+ _enrichUserAndInstanceLabels(items, context)
+
+ if mode == "filterValues" and column:
+ items = _applySortFilterSearch(items, filtersJson=filters)
+ return _distinctColumnValues(items, column)
+
+ items = _applySortFilterSearch(
+ items,
+ sortJson=sort,
+ filtersJson=filters,
+ search=search,
+ searchableKeys=["username", "aiModel", "instanceLabel", "aiProvider", "operationType"],
+ )
+
+ totalItems = len(items)
+ page = items[offset: offset + limit]
+ return {"items": page, "totalItems": totalItems}
@router.get("/ai-log/{entryId}/content")
@@ -134,6 +260,11 @@ async def getAuditLog(
dateTo: Optional[float] = Query(None),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
+ sort: Optional[str] = Query(None),
+ filters: Optional[str] = Query(None),
+ search: Optional[str] = Query(None),
+ mode: Optional[str] = Query(None),
+ column: Optional[str] = Query(None),
):
_requireAuditAccess(context)
mandateId = str(context.mandateId) if context.mandateId else None
@@ -146,8 +277,23 @@ async def getAuditLog(
action=action,
fromTimestamp=dateFrom,
toTimestamp=dateTo,
- limit=limit + offset + 1,
+ limit=9999,
)
+
+ _enrichUserAndInstanceLabels(records, context)
+
+ if mode == "filterValues" and column:
+ records = _applySortFilterSearch(records, filtersJson=filters)
+ return _distinctColumnValues(records, column)
+
+ records = _applySortFilterSearch(
+ records,
+ sortJson=sort,
+ filtersJson=filters,
+ search=search,
+ searchableKeys=["username", "action", "resourceType", "category"],
+ )
+
totalItems = len(records)
page = records[offset: offset + limit]
return {"items": page, "totalItems": totalItems}
@@ -181,6 +327,11 @@ async def getNeutralizationMappings(
context: RequestContext = Depends(getRequestContext),
limit: int = Query(200, ge=1, le=2000),
offset: int = Query(0, ge=0),
+ sort: Optional[str] = Query(None),
+ filters: Optional[str] = Query(None),
+ search: Optional[str] = Query(None),
+ mode: Optional[str] = Query(None),
+ column: Optional[str] = Query(None),
):
_requireAuditAccess(context)
mandateId = str(context.mandateId) if context.mandateId else ""
@@ -196,7 +347,23 @@ async def getNeutralizationMappings(
pType = item.get("patternType", "")
uid = item.get("id", "")
item["placeholder"] = f"[{pType}.{uid}]" if pType and uid else uid
- items.sort(key=lambda r: (r.get("patternType", ""), r.get("originalText", "")))
+
+ _enrichUserAndInstanceLabels(items, context)
+
+ if mode == "filterValues" and column:
+ items = _applySortFilterSearch(items, filtersJson=filters)
+ return _distinctColumnValues(items, column)
+
+ items = _applySortFilterSearch(
+ items,
+ sortJson=sort,
+ filtersJson=filters,
+ search=search,
+ searchableKeys=["placeholder", "originalText", "patternType"],
+ )
+ if not sort:
+ items.sort(key=lambda r: (r.get("patternType", ""), r.get("originalText", "")))
+
totalItems = len(items)
page = items[offset: offset + limit]
return {"items": page, "totalItems": totalItems}