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:
parent
005b794a2a
commit
670ae1e0ea
3 changed files with 220 additions and 10 deletions
|
|
@ -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",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue