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}