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

This commit is contained in:
ValueOn AG 2026-04-14 22:56:25 +02:00
parent 005b794a2a
commit 670ae1e0ea
3 changed files with 220 additions and 10 deletions

View file

@ -101,6 +101,51 @@ BUILTIN_PERSONAS: List[Dict[str, Any]] = [
"gender": "m", "gender": "m",
"category": "builtin", "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",
},
] ]

View file

@ -174,12 +174,11 @@ class GraphicalEditorObjects:
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
def getWorkflows(self, active: Optional[bool] = None) -> List[Dict[str, Any]]: 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): if not self.db._ensureTableExists(Automation2Workflow):
return [] return []
rf: Dict[str, Any] = { rf: Dict[str, Any] = {
"mandateId": self.mandateId, "mandateId": self.mandateId,
"featureInstanceId": self.featureInstanceId,
} }
if active is not None: if active is not None:
rf["active"] = active rf["active"] = active
@ -193,7 +192,7 @@ class GraphicalEditorObjects:
return rows return rows
def getWorkflow(self, workflowId: str) -> Optional[Dict[str, Any]]: 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): if not self.db._ensureTableExists(Automation2Workflow):
return None return None
records = self.db.getRecordset( records = self.db.getRecordset(
@ -201,7 +200,6 @@ class GraphicalEditorObjects:
recordFilter={ recordFilter={
"id": workflowId, "id": workflowId,
"mandateId": self.mandateId, "mandateId": self.mandateId,
"featureInstanceId": self.featureInstanceId,
}, },
) )
if not records: if not records:

View file

@ -10,9 +10,10 @@ Provides three views:
RBAC: mandate-admin or compliance-viewer role required. RBAC: mandate-admin or compliance-viewer role required.
""" """
import json
import logging import logging
import re import re
from typing import Optional from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
from starlette.requests import Request from starlette.requests import Request
@ -27,6 +28,107 @@ routeApiMsg = apiRouteContext("routeAudit")
router = APIRouter(prefix="/api/audit", tags=["Audit"]) 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): def _requireAuditAccess(context: RequestContext):
"""Raise 403 unless user has mandate-admin or compliance-viewer access.""" """Raise 403 unless user has mandate-admin or compliance-viewer access."""
from modules.auth.authentication import _hasSysAdminRole from modules.auth.authentication import _hasSysAdminRole
@ -62,6 +164,11 @@ async def getAiAuditLog(
dateTo: Optional[float] = Query(None, description="UTC epoch seconds"), dateTo: Optional[float] = Query(None, description="UTC epoch seconds"),
limit: int = Query(50, ge=1, le=500), limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0), 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) _requireAuditAccess(context)
mandateId = str(context.mandateId) if context.mandateId else "" 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")) raise HTTPException(status_code=400, detail=routeApiMsg("Mandanten-ID erforderlich"))
from modules.shared.aiAuditLogger import aiAuditLogger from modules.shared.aiAuditLogger import aiAuditLogger
return aiAuditLogger.getAiAuditLogs( result = aiAuditLogger.getAiAuditLogs(
mandateId, mandateId,
userId=userId, userId=userId,
featureInstanceId=featureInstanceId, featureInstanceId=featureInstanceId,
aiModel=aiModel, aiModel=aiModel,
fromTimestamp=dateFrom, fromTimestamp=dateFrom,
toTimestamp=dateTo, toTimestamp=dateTo,
limit=limit, limit=9999,
offset=offset, 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") @router.get("/ai-log/{entryId}/content")
@ -134,6 +260,11 @@ async def getAuditLog(
dateTo: Optional[float] = Query(None), dateTo: Optional[float] = Query(None),
limit: int = Query(100, ge=1, le=500), limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0), 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) _requireAuditAccess(context)
mandateId = str(context.mandateId) if context.mandateId else None mandateId = str(context.mandateId) if context.mandateId else None
@ -146,8 +277,23 @@ async def getAuditLog(
action=action, action=action,
fromTimestamp=dateFrom, fromTimestamp=dateFrom,
toTimestamp=dateTo, 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) totalItems = len(records)
page = records[offset: offset + limit] page = records[offset: offset + limit]
return {"items": page, "totalItems": totalItems} return {"items": page, "totalItems": totalItems}
@ -181,6 +327,11 @@ async def getNeutralizationMappings(
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
limit: int = Query(200, ge=1, le=2000), limit: int = Query(200, ge=1, le=2000),
offset: int = Query(0, ge=0), 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) _requireAuditAccess(context)
mandateId = str(context.mandateId) if context.mandateId else "" mandateId = str(context.mandateId) if context.mandateId else ""
@ -196,7 +347,23 @@ async def getNeutralizationMappings(
pType = item.get("patternType", "") pType = item.get("patternType", "")
uid = item.get("id", "") uid = item.get("id", "")
item["placeholder"] = f"[{pType}.{uid}]" if pType and uid else uid 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) totalItems = len(items)
page = items[offset: offset + limit] page = items[offset: offset + limit]
return {"items": page, "totalItems": totalItems} return {"items": page, "totalItems": totalItems}