428 lines
17 KiB
Python
428 lines
17 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""
|
|
Teamsbot Feature Container - Main Module.
|
|
Handles feature initialization and RBAC catalog registration.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Dict, List, Any
|
|
|
|
from modules.shared.i18nRegistry import t
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Feature metadata
|
|
FEATURE_CODE = "teamsbot"
|
|
FEATURE_LABEL = t("Teams Bot", context="UI")
|
|
FEATURE_ICON = "mdi-headset"
|
|
|
|
# UI Objects for RBAC catalog
|
|
UI_OBJECTS = [
|
|
{
|
|
"objectKey": "ui.feature.teamsbot.dashboard",
|
|
"label": t("Dashboard", context="UI"),
|
|
"meta": {"area": "dashboard"}
|
|
},
|
|
{
|
|
"objectKey": "ui.feature.teamsbot.assistant",
|
|
"label": t("Assistent", context="UI"),
|
|
"meta": {"area": "assistant"}
|
|
},
|
|
{
|
|
"objectKey": "ui.feature.teamsbot.modules",
|
|
"label": t("Module", context="UI"),
|
|
"meta": {"area": "modules"}
|
|
},
|
|
{
|
|
"objectKey": "ui.feature.teamsbot.sessions",
|
|
"label": t("Sitzungen", context="UI"),
|
|
"meta": {"area": "sessions"}
|
|
},
|
|
{
|
|
"objectKey": "ui.feature.teamsbot.settings",
|
|
"label": t("Einstellungen", context="UI"),
|
|
"meta": {"area": "settings", "admin_only": True}
|
|
},
|
|
]
|
|
|
|
# DATA Objects for RBAC catalog (tables/entities)
|
|
DATA_OBJECTS = [
|
|
{
|
|
"objectKey": "data.feature.teamsbot.TeamsbotMeetingModule",
|
|
"label": t("Meeting-Modul", context="UI"),
|
|
"meta": {
|
|
"table": "TeamsbotMeetingModule",
|
|
"fields": ["id", "title", "seriesType", "status", "ownerUserId"],
|
|
"isParent": True,
|
|
"displayFields": ["title", "seriesType", "status"],
|
|
}
|
|
},
|
|
{
|
|
"objectKey": "data.feature.teamsbot.TeamsbotSession",
|
|
"label": t("Sitzung", context="UI"),
|
|
"meta": {
|
|
"table": "TeamsbotSession",
|
|
"fields": ["id", "meetingLink", "botName", "status", "startedAt", "endedAt"],
|
|
"parentTable": "TeamsbotMeetingModule",
|
|
"parentKey": "moduleId",
|
|
"displayFields": ["botName", "status", "startedAt"],
|
|
}
|
|
},
|
|
{
|
|
"objectKey": "data.feature.teamsbot.TeamsbotTranscript",
|
|
"label": t("Transkript", context="UI"),
|
|
"meta": {
|
|
"table": "TeamsbotTranscript",
|
|
"fields": ["id", "sessionId", "speaker", "text", "timestamp"],
|
|
"parentTable": "TeamsbotSession",
|
|
"parentKey": "sessionId",
|
|
}
|
|
},
|
|
{
|
|
"objectKey": "data.feature.teamsbot.TeamsbotBotResponse",
|
|
"label": t("Bot-Antwort", context="UI"),
|
|
"meta": {
|
|
"table": "TeamsbotBotResponse",
|
|
"fields": ["id", "sessionId", "responseText", "detectedIntent"],
|
|
"parentTable": "TeamsbotSession",
|
|
"parentKey": "sessionId",
|
|
}
|
|
},
|
|
{
|
|
"objectKey": "data.feature.teamsbot.*",
|
|
"label": t("Alle Teams Bot Daten", context="UI"),
|
|
"meta": {"wildcard": True, "description": "Wildcard for all teamsbot data tables"}
|
|
},
|
|
]
|
|
|
|
# Resource Objects for RBAC catalog
|
|
RESOURCE_OBJECTS = [
|
|
{
|
|
"objectKey": "resource.feature.teamsbot.session.start",
|
|
"label": t("Sitzung starten", context="UI"),
|
|
"meta": {"endpoint": "/api/teamsbot/{instanceId}/sessions", "method": "POST"}
|
|
},
|
|
{
|
|
"objectKey": "resource.feature.teamsbot.session.stop",
|
|
"label": t("Sitzung beenden", context="UI"),
|
|
"meta": {"endpoint": "/api/teamsbot/{instanceId}/sessions/{sessionId}/stop", "method": "POST"}
|
|
},
|
|
{
|
|
"objectKey": "resource.feature.teamsbot.session.delete",
|
|
"label": t("Sitzung löschen", context="UI"),
|
|
"meta": {"endpoint": "/api/teamsbot/{instanceId}/sessions/{sessionId}", "method": "DELETE"}
|
|
},
|
|
{
|
|
"objectKey": "resource.feature.teamsbot.config.edit",
|
|
"label": t("Konfiguration bearbeiten", context="UI"),
|
|
"meta": {"endpoint": "/api/teamsbot/{instanceId}/config", "method": "PUT", "admin_only": True}
|
|
},
|
|
{
|
|
"objectKey": "resource.feature.teamsbot.module.create",
|
|
"label": t("Meeting-Modul erstellen", context="UI"),
|
|
"meta": {"endpoint": "/api/teamsbot/{instanceId}/modules", "method": "POST"}
|
|
},
|
|
{
|
|
"objectKey": "resource.feature.teamsbot.module.delete",
|
|
"label": t("Meeting-Modul loeschen", context="UI"),
|
|
"meta": {"endpoint": "/api/teamsbot/{instanceId}/modules/{moduleId}", "method": "DELETE"}
|
|
},
|
|
]
|
|
|
|
# Template roles for this feature with AccessRules
|
|
TEMPLATE_ROLES = [
|
|
{
|
|
"roleLabel": "teamsbot-admin",
|
|
"description": "Teams Bot Administrator - Vollzugriff auf alle Sitzungen und Einstellungen",
|
|
"accessRules": [
|
|
# Full UI access (all views including settings)
|
|
{"context": "UI", "item": None, "view": True},
|
|
# Full DATA access
|
|
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
|
|
# All resources
|
|
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.start", "view": True},
|
|
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.stop", "view": True},
|
|
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.delete", "view": True},
|
|
{"context": "RESOURCE", "item": "resource.feature.teamsbot.config.edit", "view": True},
|
|
{"context": "RESOURCE", "item": "resource.feature.teamsbot.module.create", "view": True},
|
|
{"context": "RESOURCE", "item": "resource.feature.teamsbot.module.delete", "view": True},
|
|
]
|
|
},
|
|
{
|
|
"roleLabel": "teamsbot-viewer",
|
|
"description": "Teams Bot Betrachter - Sitzungen und Transkripte ansehen (nur lesen)",
|
|
"accessRules": [
|
|
{"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True},
|
|
{"context": "UI", "item": "ui.feature.teamsbot.assistant", "view": True},
|
|
{"context": "UI", "item": "ui.feature.teamsbot.modules", "view": True},
|
|
{"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True},
|
|
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
|
],
|
|
},
|
|
{
|
|
"roleLabel": "teamsbot-user",
|
|
"description": "Teams Bot Benutzer - Kann Sitzungen starten/stoppen und Transkripte einsehen",
|
|
"accessRules": [
|
|
{"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True},
|
|
{"context": "UI", "item": "ui.feature.teamsbot.assistant", "view": True},
|
|
{"context": "UI", "item": "ui.feature.teamsbot.modules", "view": True},
|
|
{"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True},
|
|
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotMeetingModule", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
|
|
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotSession", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
|
|
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotTranscript", "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
|
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotBotResponse", "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
|
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.start", "view": True},
|
|
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.stop", "view": True},
|
|
{"context": "RESOURCE", "item": "resource.feature.teamsbot.module.create", "view": True},
|
|
],
|
|
},
|
|
]
|
|
|
|
|
|
def getFeatureDefinition() -> Dict[str, Any]:
|
|
"""Return the feature definition for registration."""
|
|
return {
|
|
"code": FEATURE_CODE,
|
|
"label": FEATURE_LABEL,
|
|
"icon": FEATURE_ICON,
|
|
"autoCreateInstance": False,
|
|
}
|
|
|
|
|
|
def getUiObjects() -> List[Dict[str, Any]]:
|
|
"""Return UI objects for RBAC catalog registration."""
|
|
return UI_OBJECTS
|
|
|
|
|
|
def getResourceObjects() -> List[Dict[str, Any]]:
|
|
"""Return resource objects for RBAC catalog registration."""
|
|
return RESOURCE_OBJECTS
|
|
|
|
|
|
def getTemplateRoles() -> List[Dict[str, Any]]:
|
|
"""Return template roles for this feature."""
|
|
return TEMPLATE_ROLES
|
|
|
|
|
|
def getDataObjects() -> List[Dict[str, Any]]:
|
|
"""Return DATA objects for RBAC catalog registration."""
|
|
return DATA_OBJECTS
|
|
|
|
|
|
def registerFeature(catalogService) -> bool:
|
|
"""Register this feature's RBAC objects in the catalog."""
|
|
try:
|
|
for uiObj in UI_OBJECTS:
|
|
catalogService.registerUiObject(
|
|
featureCode=FEATURE_CODE,
|
|
objectKey=uiObj["objectKey"],
|
|
label=uiObj["label"],
|
|
meta=uiObj.get("meta")
|
|
)
|
|
|
|
for resObj in RESOURCE_OBJECTS:
|
|
catalogService.registerResourceObject(
|
|
featureCode=FEATURE_CODE,
|
|
objectKey=resObj["objectKey"],
|
|
label=resObj["label"],
|
|
meta=resObj.get("meta")
|
|
)
|
|
|
|
for dataObj in DATA_OBJECTS:
|
|
catalogService.registerDataObject(
|
|
featureCode=FEATURE_CODE,
|
|
objectKey=dataObj["objectKey"],
|
|
label=dataObj["label"],
|
|
meta=dataObj.get("meta")
|
|
)
|
|
|
|
_runMigrations()
|
|
_syncTemplateRolesToDb()
|
|
|
|
logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI, {len(RESOURCE_OBJECTS)} resource, {len(DATA_OBJECTS)} data objects")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to register feature '{FEATURE_CODE}': {e}")
|
|
return False
|
|
|
|
|
|
def _runMigrations():
|
|
"""Idempotent DB migrations for TeamsBot feature.
|
|
Runs on every bootstrap; each step checks preconditions before executing.
|
|
The TeamsbotMeetingModule table and TeamsbotSession.moduleId column are
|
|
auto-created by the DB connector from the Pydantic model. This migration
|
|
handles data backfill: creating default Adhoc modules for existing sessions.
|
|
"""
|
|
try:
|
|
from .interfaceFeatureTeamsbot import teamsbotDatabase
|
|
from .datamodelTeamsbot import TeamsbotMeetingModule, TeamsbotSession
|
|
from modules.shared.configuration import APP_CONFIG
|
|
import psycopg2
|
|
from psycopg2.extras import RealDictCursor
|
|
import uuid
|
|
|
|
conn = psycopg2.connect(
|
|
host=APP_CONFIG.get("DB_HOST", "localhost"),
|
|
database=teamsbotDatabase,
|
|
user=APP_CONFIG.get("DB_USER"),
|
|
password=APP_CONFIG.get("DB_PASSWORD_SECRET"),
|
|
port=int(APP_CONFIG.get("DB_PORT", 5432)),
|
|
cursor_factory=RealDictCursor,
|
|
)
|
|
conn.autocommit = False
|
|
cur = conn.cursor()
|
|
|
|
def _tableExists(name):
|
|
cur.execute(
|
|
"SELECT 1 FROM information_schema.tables WHERE LOWER(table_name) = LOWER(%s) AND table_schema = 'public'",
|
|
(name,),
|
|
)
|
|
return cur.fetchone() is not None
|
|
|
|
def _columnExists(table, column):
|
|
cur.execute(
|
|
"SELECT 1 FROM information_schema.columns WHERE LOWER(table_name) = LOWER(%s) AND LOWER(column_name) = LOWER(%s) AND table_schema = 'public'",
|
|
(table, column),
|
|
)
|
|
return cur.fetchone() is not None
|
|
|
|
migrated = False
|
|
|
|
# M1: Create default Adhoc modules for orphaned sessions
|
|
# (only runs if TeamsbotSession table exists with moduleId column
|
|
# and there are sessions without a moduleId)
|
|
if _tableExists("TeamsbotSession") and _columnExists("TeamsbotSession", "moduleId"):
|
|
cur.execute("""
|
|
SELECT DISTINCT "instanceId", "mandateId"
|
|
FROM "TeamsbotSession"
|
|
WHERE "moduleId" IS NULL AND "instanceId" IS NOT NULL
|
|
""")
|
|
orphanGroups = cur.fetchall()
|
|
for group in orphanGroups:
|
|
instId = group["instanceId"]
|
|
mandId = group["mandateId"]
|
|
if not instId:
|
|
continue
|
|
|
|
adhocId = str(uuid.uuid4())
|
|
import time as _time
|
|
now = _time.time()
|
|
cur.execute("""
|
|
INSERT INTO "TeamsbotMeetingModule" (id, "instanceId", "mandateId", "ownerUserId", title, "seriesType", status, "sysCreatedAt")
|
|
VALUES (%s, %s, %s, 'system', 'Adhoc', 'adhoc', 'active', %s)
|
|
""", (adhocId, instId, mandId, now))
|
|
cur.execute("""
|
|
UPDATE "TeamsbotSession"
|
|
SET "moduleId" = %s
|
|
WHERE "instanceId" = %s AND "moduleId" IS NULL
|
|
""", (adhocId, instId))
|
|
sessionCount = cur.rowcount
|
|
logger.info(f"Migration M1: Created Adhoc module for instanceId={instId}, assigned {sessionCount} sessions")
|
|
migrated = True
|
|
|
|
if migrated:
|
|
conn.commit()
|
|
logger.info("TeamsBot DB migrations committed")
|
|
else:
|
|
conn.rollback()
|
|
|
|
cur.close()
|
|
conn.close()
|
|
|
|
except ImportError:
|
|
logger.debug("psycopg2 not available, skipping TeamsBot DB migrations")
|
|
except Exception as e:
|
|
logger.warning(f"TeamsBot DB migration failed (non-fatal): {e}")
|
|
|
|
|
|
def _syncTemplateRolesToDb() -> int:
|
|
"""Sync template roles and their AccessRules to the database."""
|
|
try:
|
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
|
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
|
|
from modules.datamodels.datamodelUtils import coerce_text_multilingual
|
|
|
|
rootInterface = getRootInterface()
|
|
existingRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE)
|
|
templateRoles = [r for r in existingRoles if r.mandateId is None]
|
|
existingRoleLabels = {r.roleLabel: str(r.id) for r in templateRoles}
|
|
|
|
createdCount = 0
|
|
for roleTemplate in TEMPLATE_ROLES:
|
|
roleLabel = roleTemplate["roleLabel"]
|
|
|
|
if roleLabel in existingRoleLabels:
|
|
roleId = existingRoleLabels[roleLabel]
|
|
_ensureAccessRulesForRole(rootInterface, roleId, roleTemplate.get("accessRules", []))
|
|
else:
|
|
newRole = Role(
|
|
roleLabel=roleLabel,
|
|
description=coerce_text_multilingual(roleTemplate.get("description", {})),
|
|
featureCode=FEATURE_CODE,
|
|
mandateId=None,
|
|
featureInstanceId=None,
|
|
isSystemRole=False
|
|
)
|
|
createdRole = rootInterface.db.recordCreate(Role, newRole.model_dump())
|
|
roleId = createdRole.get("id")
|
|
_ensureAccessRulesForRole(rootInterface, roleId, roleTemplate.get("accessRules", []))
|
|
logger.info(f"Created template role '{roleLabel}' with ID {roleId}")
|
|
createdCount += 1
|
|
|
|
if createdCount > 0:
|
|
logger.info(f"Feature '{FEATURE_CODE}': Created {createdCount} template roles")
|
|
|
|
return createdCount
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error syncing template roles for feature '{FEATURE_CODE}': {e}")
|
|
return 0
|
|
|
|
|
|
def _ensureAccessRulesForRole(rootInterface, roleId: str, ruleTemplates: List[Dict[str, Any]]) -> int:
|
|
"""Ensure AccessRules exist for a role based on templates."""
|
|
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext
|
|
|
|
existingRules = rootInterface.getAccessRulesByRole(roleId)
|
|
existingSignatures = set()
|
|
for rule in existingRules:
|
|
sig = (rule.context.value if rule.context else None, rule.item)
|
|
existingSignatures.add(sig)
|
|
|
|
createdCount = 0
|
|
for template in ruleTemplates:
|
|
context = template.get("context", "UI")
|
|
item = template.get("item")
|
|
sig = (context, item)
|
|
|
|
if sig in existingSignatures:
|
|
continue
|
|
|
|
if context == "UI":
|
|
contextEnum = AccessRuleContext.UI
|
|
elif context == "DATA":
|
|
contextEnum = AccessRuleContext.DATA
|
|
elif context == "RESOURCE":
|
|
contextEnum = AccessRuleContext.RESOURCE
|
|
else:
|
|
contextEnum = context
|
|
|
|
newRule = AccessRule(
|
|
roleId=roleId,
|
|
context=contextEnum,
|
|
item=item,
|
|
view=template.get("view", False),
|
|
read=template.get("read"),
|
|
create=template.get("create"),
|
|
update=template.get("update"),
|
|
delete=template.get("delete"),
|
|
)
|
|
rootInterface.db.recordCreate(AccessRule, newRule.model_dump())
|
|
createdCount += 1
|
|
|
|
if createdCount > 0:
|
|
logger.debug(f"Created {createdCount} AccessRules for role {roleId}")
|
|
|
|
return createdCount
|