429 lines
19 KiB
Python
429 lines
19 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""
|
|
Interface to CommCoach database.
|
|
Uses the PostgreSQL connector for data access with strict user ownership.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from typing import Dict, Any, List, Optional
|
|
|
|
from modules.datamodels.datamodelUam import User
|
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
|
from modules.shared.timeUtils import getIsoTimestamp
|
|
from modules.shared.configuration import APP_CONFIG
|
|
from modules.shared.i18nRegistry import t
|
|
|
|
from .datamodelCommcoach import (
|
|
CoachingContext, CoachingContextStatus,
|
|
CoachingSession, CoachingSessionStatus,
|
|
CoachingMessage,
|
|
CoachingTask, CoachingTaskStatus,
|
|
CoachingScore,
|
|
CoachingUserProfile,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_interfaces = {}
|
|
|
|
|
|
def getInterface(currentUser: User, mandateId: str = None, featureInstanceId: str = None):
|
|
"""Factory: get or create a CommcoachObjects interface instance."""
|
|
key = f"{currentUser.id}_{mandateId}_{featureInstanceId}"
|
|
if key not in _interfaces:
|
|
_interfaces[key] = CommcoachObjects(currentUser, mandateId, featureInstanceId)
|
|
else:
|
|
_interfaces[key].currentUser = currentUser
|
|
_interfaces[key].mandateId = mandateId
|
|
_interfaces[key].featureInstanceId = featureInstanceId
|
|
return _interfaces[key]
|
|
|
|
|
|
class CommcoachObjects:
|
|
"""Database interface for CommCoach feature. All reads enforce strict userId ownership."""
|
|
|
|
def __init__(self, currentUser: User, mandateId: str = None, featureInstanceId: str = None):
|
|
self.currentUser = currentUser
|
|
self.mandateId = mandateId
|
|
self.featureInstanceId = featureInstanceId
|
|
self.userId = str(currentUser.id) if currentUser else "system"
|
|
|
|
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
|
dbDatabase = "poweron_commcoach"
|
|
dbUser = APP_CONFIG.get("DB_USER")
|
|
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
|
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
|
|
|
self.db = DatabaseConnector(
|
|
dbHost=dbHost,
|
|
dbDatabase=dbDatabase,
|
|
dbUser=dbUser,
|
|
dbPassword=dbPassword,
|
|
dbPort=dbPort,
|
|
userId=self.userId,
|
|
)
|
|
|
|
# =========================================================================
|
|
# Contexts
|
|
# =========================================================================
|
|
|
|
def getContexts(self, instanceId: str, userId: str, includeArchived: bool = False) -> List[Dict[str, Any]]:
|
|
"""Get all coaching contexts for a user. Strict ownership."""
|
|
records = self.db.getRecordset(
|
|
CoachingContext,
|
|
recordFilter={"instanceId": instanceId, "userId": userId},
|
|
)
|
|
if not includeArchived:
|
|
records = [r for r in records if r.get("status") != CoachingContextStatus.ARCHIVED.value]
|
|
records.sort(key=lambda r: r.get("updatedAt") or r.get("createdAt") or "", reverse=True)
|
|
return records
|
|
|
|
def getContext(self, contextId: str) -> Optional[Dict[str, Any]]:
|
|
records = self.db.getRecordset(CoachingContext, recordFilter={"id": contextId})
|
|
return records[0] if records else None
|
|
|
|
def createContext(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
data["createdAt"] = getIsoTimestamp()
|
|
data["updatedAt"] = getIsoTimestamp()
|
|
return self.db.recordCreate(CoachingContext, data)
|
|
|
|
def updateContext(self, contextId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
updates["updatedAt"] = getIsoTimestamp()
|
|
return self.db.recordModify(CoachingContext, contextId, updates)
|
|
|
|
def deleteContext(self, contextId: str) -> bool:
|
|
self._deleteSessionsByContext(contextId)
|
|
self._deleteTasksByContext(contextId)
|
|
self._deleteScoresByContext(contextId)
|
|
return self.db.recordDelete(CoachingContext, contextId)
|
|
|
|
# =========================================================================
|
|
# Sessions
|
|
# =========================================================================
|
|
|
|
def getSessions(self, contextId: str, userId: str) -> List[Dict[str, Any]]:
|
|
records = self.db.getRecordset(
|
|
CoachingSession,
|
|
recordFilter={"contextId": contextId, "userId": userId},
|
|
)
|
|
records.sort(key=lambda r: r.get("startedAt") or r.get("createdAt") or "", reverse=True)
|
|
return records
|
|
|
|
def getSession(self, sessionId: str) -> Optional[Dict[str, Any]]:
|
|
records = self.db.getRecordset(CoachingSession, recordFilter={"id": sessionId})
|
|
return records[0] if records else None
|
|
|
|
def getActiveSession(self, contextId: str, userId: str) -> Optional[Dict[str, Any]]:
|
|
records = self.db.getRecordset(
|
|
CoachingSession,
|
|
recordFilter={"contextId": contextId, "userId": userId, "status": CoachingSessionStatus.ACTIVE.value},
|
|
)
|
|
return records[0] if records else None
|
|
|
|
def createSession(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
data["createdAt"] = getIsoTimestamp()
|
|
data["updatedAt"] = getIsoTimestamp()
|
|
data["startedAt"] = getIsoTimestamp()
|
|
return self.db.recordCreate(CoachingSession, data)
|
|
|
|
def updateSession(self, sessionId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
updates["updatedAt"] = getIsoTimestamp()
|
|
return self.db.recordModify(CoachingSession, sessionId, updates)
|
|
|
|
def _deleteSessionsByContext(self, contextId: str) -> int:
|
|
records = self.db.getRecordset(CoachingSession, recordFilter={"contextId": contextId})
|
|
count = 0
|
|
for record in records:
|
|
self._deleteMessagesBySession(record.get("id"))
|
|
self.db.recordDelete(CoachingSession, record.get("id"))
|
|
count += 1
|
|
return count
|
|
|
|
# =========================================================================
|
|
# Messages
|
|
# =========================================================================
|
|
|
|
def getMessages(self, sessionId: str) -> List[Dict[str, Any]]:
|
|
records = self.db.getRecordset(CoachingMessage, recordFilter={"sessionId": sessionId})
|
|
records.sort(key=lambda r: r.get("createdAt") or "")
|
|
return records
|
|
|
|
def getRecentMessages(self, sessionId: str, count: int = 20) -> List[Dict[str, Any]]:
|
|
records = self.getMessages(sessionId)
|
|
return records[-count:]
|
|
|
|
def createMessage(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
data["createdAt"] = getIsoTimestamp()
|
|
return self.db.recordCreate(CoachingMessage, data)
|
|
|
|
def _deleteMessagesBySession(self, sessionId: str) -> int:
|
|
records = self.db.getRecordset(CoachingMessage, recordFilter={"sessionId": sessionId})
|
|
count = 0
|
|
for record in records:
|
|
self.db.recordDelete(CoachingMessage, record.get("id"))
|
|
count += 1
|
|
return count
|
|
|
|
# =========================================================================
|
|
# Tasks
|
|
# =========================================================================
|
|
|
|
def getTasks(self, contextId: str, userId: str) -> List[Dict[str, Any]]:
|
|
records = self.db.getRecordset(
|
|
CoachingTask,
|
|
recordFilter={"contextId": contextId, "userId": userId},
|
|
)
|
|
records.sort(key=lambda r: r.get("createdAt") or "", reverse=True)
|
|
return records
|
|
|
|
def getTask(self, taskId: str) -> Optional[Dict[str, Any]]:
|
|
records = self.db.getRecordset(CoachingTask, recordFilter={"id": taskId})
|
|
return records[0] if records else None
|
|
|
|
def createTask(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
data["createdAt"] = getIsoTimestamp()
|
|
data["updatedAt"] = getIsoTimestamp()
|
|
return self.db.recordCreate(CoachingTask, data)
|
|
|
|
def updateTask(self, taskId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
updates["updatedAt"] = getIsoTimestamp()
|
|
return self.db.recordModify(CoachingTask, taskId, updates)
|
|
|
|
def deleteTask(self, taskId: str) -> bool:
|
|
return self.db.recordDelete(CoachingTask, taskId)
|
|
|
|
def _deleteTasksByContext(self, contextId: str) -> int:
|
|
records = self.db.getRecordset(CoachingTask, recordFilter={"contextId": contextId})
|
|
count = 0
|
|
for record in records:
|
|
self.db.recordDelete(CoachingTask, record.get("id"))
|
|
count += 1
|
|
return count
|
|
|
|
def getOpenTaskCount(self, userId: str, instanceId: str) -> int:
|
|
records = self.db.getRecordset(CoachingTask, recordFilter={"userId": userId})
|
|
return len([r for r in records if r.get("status") in (CoachingTaskStatus.OPEN.value, CoachingTaskStatus.IN_PROGRESS.value)])
|
|
|
|
def getCompletedTaskCount(self, userId: str, instanceId: str) -> int:
|
|
records = self.db.getRecordset(CoachingTask, recordFilter={"userId": userId})
|
|
return len([r for r in records if r.get("status") == CoachingTaskStatus.DONE.value])
|
|
|
|
# =========================================================================
|
|
# Scores
|
|
# =========================================================================
|
|
|
|
def getScores(self, contextId: str, userId: str) -> List[Dict[str, Any]]:
|
|
records = self.db.getRecordset(
|
|
CoachingScore,
|
|
recordFilter={"contextId": contextId, "userId": userId},
|
|
)
|
|
records.sort(key=lambda r: r.get("createdAt") or "")
|
|
return records
|
|
|
|
def getRecentScores(self, userId: str, limit: int = 20) -> List[Dict[str, Any]]:
|
|
records = self.db.getRecordset(CoachingScore, recordFilter={"userId": userId})
|
|
records.sort(key=lambda r: r.get("createdAt") or "", reverse=True)
|
|
return records[:limit]
|
|
|
|
def createScore(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
data["createdAt"] = getIsoTimestamp()
|
|
return self.db.recordCreate(CoachingScore, data)
|
|
|
|
def _deleteScoresByContext(self, contextId: str) -> int:
|
|
records = self.db.getRecordset(CoachingScore, recordFilter={"contextId": contextId})
|
|
count = 0
|
|
for record in records:
|
|
self.db.recordDelete(CoachingScore, record.get("id"))
|
|
count += 1
|
|
return count
|
|
|
|
# =========================================================================
|
|
# Personas
|
|
# =========================================================================
|
|
|
|
def getPersonas(self, userId: str, instanceId: str) -> List[Dict[str, Any]]:
|
|
from .datamodelCommcoach import CoachingPersona
|
|
builtins = self.db.getRecordset(CoachingPersona, recordFilter={"userId": "system"})
|
|
custom = self.db.getRecordset(CoachingPersona, recordFilter={"userId": userId, "instanceId": instanceId})
|
|
all = builtins + custom
|
|
return [p for p in all if p.get("isActive", True)]
|
|
|
|
def getPersona(self, personaId: str) -> Optional[Dict[str, Any]]:
|
|
from .datamodelCommcoach import CoachingPersona
|
|
records = self.db.getRecordset(CoachingPersona, recordFilter={"id": personaId})
|
|
return records[0] if records else None
|
|
|
|
def createPersona(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
from .datamodelCommcoach import CoachingPersona
|
|
data["createdAt"] = getIsoTimestamp()
|
|
data["updatedAt"] = getIsoTimestamp()
|
|
return self.db.recordCreate(CoachingPersona, data)
|
|
|
|
def updatePersona(self, personaId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
from .datamodelCommcoach import CoachingPersona
|
|
updates["updatedAt"] = getIsoTimestamp()
|
|
return self.db.recordModify(CoachingPersona, personaId, updates)
|
|
|
|
def deletePersona(self, personaId: str) -> bool:
|
|
from .datamodelCommcoach import CoachingPersona
|
|
return self.db.recordDelete(CoachingPersona, personaId)
|
|
|
|
# =========================================================================
|
|
# Badges
|
|
# =========================================================================
|
|
|
|
def getBadges(self, userId: str, instanceId: str) -> List[Dict[str, Any]]:
|
|
from .datamodelCommcoach import CoachingBadge
|
|
records = self.db.getRecordset(CoachingBadge, recordFilter={"userId": userId, "instanceId": instanceId})
|
|
records.sort(key=lambda r: r.get("awardedAt") or "", reverse=True)
|
|
return records
|
|
|
|
def hasBadge(self, userId: str, instanceId: str, badgeKey: str) -> bool:
|
|
from .datamodelCommcoach import CoachingBadge
|
|
records = self.db.getRecordset(CoachingBadge, recordFilter={"userId": userId, "instanceId": instanceId, "badgeKey": badgeKey})
|
|
return len(records) > 0
|
|
|
|
def awardBadge(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
from .datamodelCommcoach import CoachingBadge
|
|
data["awardedAt"] = getIsoTimestamp()
|
|
data["createdAt"] = getIsoTimestamp()
|
|
return self.db.recordCreate(CoachingBadge, data)
|
|
|
|
# =========================================================================
|
|
# Score History
|
|
# =========================================================================
|
|
|
|
def getScoreHistory(self, contextId: str, userId: str) -> Dict[str, List[Dict[str, Any]]]:
|
|
scores = self.getScores(contextId, userId)
|
|
history: Dict[str, List[Dict[str, Any]]] = {}
|
|
for s in scores:
|
|
dim = s.get("dimension", "unknown")
|
|
if dim not in history:
|
|
history[dim] = []
|
|
history[dim].append({"score": s.get("score"), "trend": s.get("trend"), "evidence": s.get("evidence"), "createdAt": s.get("createdAt"), "sessionId": s.get("sessionId")})
|
|
for dim in history:
|
|
history[dim].sort(key=lambda x: x.get("createdAt") or "")
|
|
return history
|
|
|
|
# =========================================================================
|
|
# User Profile
|
|
# =========================================================================
|
|
|
|
def getProfile(self, userId: str, instanceId: str) -> Optional[Dict[str, Any]]:
|
|
records = self.db.getRecordset(
|
|
CoachingUserProfile,
|
|
recordFilter={"userId": userId, "instanceId": instanceId},
|
|
)
|
|
return records[0] if records else None
|
|
|
|
def getOrCreateProfile(self, userId: str, mandateId: str, instanceId: str) -> Dict[str, Any]:
|
|
existing = self.getProfile(userId, instanceId)
|
|
if existing:
|
|
return existing
|
|
data = CoachingUserProfile(
|
|
userId=userId,
|
|
mandateId=mandateId,
|
|
instanceId=instanceId,
|
|
).model_dump()
|
|
data["createdAt"] = getIsoTimestamp()
|
|
data["updatedAt"] = getIsoTimestamp()
|
|
return self.db.recordCreate(CoachingUserProfile, data)
|
|
|
|
def updateProfile(self, profileId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
updates["updatedAt"] = getIsoTimestamp()
|
|
return self.db.recordModify(CoachingUserProfile, profileId, updates)
|
|
|
|
# =========================================================================
|
|
# Dashboard Aggregation
|
|
# =========================================================================
|
|
|
|
def getDashboardData(self, userId: str, instanceId: str) -> Dict[str, Any]:
|
|
contexts = self.db.getRecordset(CoachingContext, recordFilter={"userId": userId, "instanceId": instanceId})
|
|
sessions = self.db.getRecordset(CoachingSession, recordFilter={"userId": userId, "instanceId": instanceId})
|
|
profile = self.getProfile(userId, instanceId)
|
|
|
|
activeContexts = [c for c in contexts if c.get("status") == CoachingContextStatus.ACTIVE.value]
|
|
completedSessions = [s for s in sessions if s.get("status") == CoachingSessionStatus.COMPLETED.value]
|
|
|
|
totalMinutes = sum(s.get("durationSeconds", 0) for s in completedSessions) // 60
|
|
scores = []
|
|
for s in completedSessions:
|
|
raw = s.get("competenceScore")
|
|
if raw is not None:
|
|
try:
|
|
scores.append(float(raw))
|
|
except (ValueError, TypeError):
|
|
pass
|
|
avgScore = sum(scores) / len(scores) if scores else None
|
|
|
|
recentScores = self.getRecentScores(userId, limit=10)
|
|
|
|
contextSummaries = []
|
|
for ctx in activeContexts:
|
|
goalProgress = _calcGoalProgress(ctx.get("goals"))
|
|
contextSummaries.append({
|
|
"id": ctx.get("id"),
|
|
"title": ctx.get("title"),
|
|
"category": ctx.get("category"),
|
|
"sessionCount": ctx.get("sessionCount", 0),
|
|
"lastSessionAt": ctx.get("lastSessionAt"),
|
|
"goalProgress": goalProgress,
|
|
})
|
|
|
|
allGoalProgress = []
|
|
for ctx in activeContexts:
|
|
gp = _calcGoalProgress(ctx.get("goals"))
|
|
if gp is not None:
|
|
allGoalProgress.append(gp)
|
|
overallGoalProgress = round(sum(allGoalProgress) / len(allGoalProgress)) if allGoalProgress else None
|
|
|
|
return {
|
|
"totalContexts": len(contexts),
|
|
"activeContexts": len(activeContexts),
|
|
"totalSessions": len(completedSessions),
|
|
"totalMinutes": totalMinutes,
|
|
"streakDays": profile.get("streakDays", 0) if profile else 0,
|
|
"longestStreak": profile.get("longestStreak", 0) if profile else 0,
|
|
"averageScore": round(avgScore, 1) if avgScore else None,
|
|
"recentScores": recentScores,
|
|
"openTasks": self.getOpenTaskCount(userId, instanceId),
|
|
"completedTasks": self.getCompletedTaskCount(userId, instanceId),
|
|
"contexts": contextSummaries,
|
|
"goalProgress": overallGoalProgress,
|
|
"badges": self.getBadges(userId, instanceId),
|
|
"level": _calcLevel(profile.get("totalSessions", 0) if profile else 0),
|
|
}
|
|
|
|
|
|
def _calcGoalProgress(goalsRaw) -> Optional[int]:
|
|
"""Calculate goal completion percentage from a context's goals JSON field."""
|
|
if not goalsRaw:
|
|
return None
|
|
goals = goalsRaw
|
|
if isinstance(goalsRaw, str):
|
|
try:
|
|
goals = json.loads(goalsRaw)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return None
|
|
if not isinstance(goals, list) or len(goals) == 0:
|
|
return None
|
|
done = sum(1 for g in goals if isinstance(g, dict) and g.get("status") in ("done", "completed"))
|
|
return round(done / len(goals) * 100)
|
|
|
|
|
|
_LEVELS = [
|
|
(50, 5, "master", t("Meister")),
|
|
(25, 4, "expert", t("Experte")),
|
|
(10, 3, "advanced", t("Fortgeschritten")),
|
|
(3, 2, "engaged", t("Engagiert")),
|
|
]
|
|
t("Einsteiger")
|
|
|
|
|
|
def _calcLevel(totalSessions: int) -> Dict[str, Any]:
|
|
for threshold, number, code, _label in _LEVELS:
|
|
if totalSessions >= threshold:
|
|
return {"number": number, "code": code, "label": t(_label), "totalSessions": totalSessions}
|
|
return {"number": 1, "code": "beginner", "label": t("Einsteiger"), "totalSessions": totalSessions}
|