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",
|
||||
"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]]:
|
||||
"""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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
_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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue