# 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