gateway/modules/interfaces/interfaceFeatures.py
patrick-motsch e24ef42617 feat(teamsbot): AI voice test, config save, camelCase mapping, default voices
- Voice test endpoint generates sample text dynamically via AI in selected language
- Fixed config save: added "config" to allowed update fields in interfaceFeatures
- Clean camelCase mapping in interfaceVoiceObjects (audio_content -> audioContent)
- Default TTS voices for common languages in connectorVoiceGoogle
- Fixed updateFeatureInstanceConfig -> updateFeatureInstance with config field

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-15 10:08:50 +01:00

517 lines
19 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Feature Instance Management Interface.
Multi-Tenant Design:
- Feature-Instanzen gehören zu Mandanten
- Template-Rollen werden bei Erstellung kopiert
- Synchronisation von Templates ist explizit (nicht automatisch)
"""
import logging
import uuid
from typing import List, Dict, Any, Optional
from modules.datamodels.datamodelFeatures import Feature, FeatureInstance
from modules.datamodels.datamodelRbac import Role, AccessRule
from modules.connectors.connectorDbPostgre import DatabaseConnector
logger = logging.getLogger(__name__)
class FeatureInterface:
"""
Interface for Feature and FeatureInstance management.
Responsibilities:
- CRUD operations for Features and FeatureInstances
- Template role copying on instance creation
- Template synchronization for existing instances
"""
def __init__(self, db: DatabaseConnector):
"""
Initialize Feature interface.
Args:
db: DatabaseConnector instance (DbApp database)
"""
self.db = db
# ============================================
# Feature Methods (Global Feature Definitions)
# ============================================
def getFeature(self, featureCode: str) -> Optional[Feature]:
"""
Get a feature by code.
Args:
featureCode: Feature code (e.g., "trustee", "chatbot")
Returns:
Feature object or None
"""
try:
records = self.db.getRecordset(Feature, recordFilter={"code": featureCode})
if not records:
return None
cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")}
return Feature(**cleanedRecord)
except Exception as e:
logger.error(f"Error getting feature {featureCode}: {e}")
return None
def getAllFeatures(self) -> List[Feature]:
"""
Get all available features.
Returns:
List of Feature objects
"""
try:
records = self.db.getRecordset(Feature)
result = []
for record in records:
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
result.append(Feature(**cleanedRecord))
return result
except Exception as e:
logger.error(f"Error getting all features: {e}")
return []
def createFeature(self, code: str, label: Dict[str, str], icon: str = "mdi-puzzle") -> Feature:
"""
Create a new feature definition.
Args:
code: Unique feature code (e.g., "trustee")
label: I18n labels (e.g., {"en": "Trustee", "de": "Treuhand"})
icon: Icon identifier
Returns:
Created Feature object
"""
try:
feature = Feature(code=code, label=label, icon=icon)
createdRecord = self.db.recordCreate(Feature, feature.model_dump())
cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")}
return Feature(**cleanedRecord)
except Exception as e:
logger.error(f"Error creating feature {code}: {e}")
raise ValueError(f"Failed to create feature: {e}")
# ============================================
# Feature Instance Methods
# ============================================
def getFeatureInstance(self, instanceId: str) -> Optional[FeatureInstance]:
"""
Get a feature instance by ID.
Args:
instanceId: FeatureInstance ID
Returns:
FeatureInstance object or None
"""
try:
records = self.db.getRecordset(FeatureInstance, recordFilter={"id": instanceId})
if not records:
return None
cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")}
return FeatureInstance(**cleanedRecord)
except Exception as e:
logger.error(f"Error getting feature instance {instanceId}: {e}")
return None
def getFeatureInstancesForMandate(self, mandateId: str, featureCode: Optional[str] = None) -> List[FeatureInstance]:
"""
Get all feature instances for a mandate.
Args:
mandateId: Mandate ID
featureCode: Optional filter by feature code
Returns:
List of FeatureInstance objects
"""
try:
recordFilter = {"mandateId": mandateId}
if featureCode:
recordFilter["featureCode"] = featureCode
records = self.db.getRecordset(FeatureInstance, recordFilter=recordFilter)
result = []
for record in records:
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
result.append(FeatureInstance(**cleanedRecord))
return result
except Exception as e:
logger.error(f"Error getting feature instances for mandate {mandateId}: {e}")
return []
def createFeatureInstance(
self,
featureCode: str,
mandateId: str,
label: str,
enabled: bool = True,
copyTemplateRoles: bool = True,
config: Optional[Dict[str, Any]] = None
) -> FeatureInstance:
"""
Create a new feature instance for a mandate.
Optionally copies global template roles for this feature.
WICHTIG: Templates werden NUR bei Erstellung kopiert.
Spätere Template-Änderungen werden NICHT automatisch propagiert.
Für manuelle Nachsynchronisation siehe syncRolesFromTemplate().
Args:
featureCode: Feature code (e.g., "trustee")
mandateId: Mandate ID
label: Instance label (e.g., "Buchhaltung 2025")
enabled: Whether the instance is enabled
copyTemplateRoles: Whether to copy template roles
Returns:
Created FeatureInstance object
"""
try:
# Create instance
instance = FeatureInstance(
featureCode=featureCode,
mandateId=mandateId,
label=label,
enabled=enabled,
config=config
)
createdInstance = self.db.recordCreate(FeatureInstance, instance.model_dump())
if not createdInstance:
raise ValueError("Failed to create feature instance record")
instanceId = createdInstance.get("id")
# Copy template roles if requested
if copyTemplateRoles:
self._copyTemplateRoles(featureCode, mandateId, instanceId)
cleanedRecord = {k: v for k, v in createdInstance.items() if not k.startswith("_")}
return FeatureInstance(**cleanedRecord)
except Exception as e:
logger.error(f"Error creating feature instance: {e}")
raise ValueError(f"Failed to create feature instance: {e}")
def _copyTemplateRoles(self, featureCode: str, mandateId: str, instanceId: str) -> int:
"""
Copy global template roles for a feature to a new instance.
Args:
featureCode: Feature code
mandateId: Mandate ID
instanceId: New FeatureInstance ID
Returns:
Number of roles copied
"""
try:
# Find global template roles for this feature (mandateId=None)
globalRoles = self.db.getRecordset(
Role,
recordFilter={"featureCode": featureCode, "mandateId": None}
)
if not globalRoles:
logger.debug(f"No template roles found for feature {featureCode}")
return 0
templateRoleIds = [r.get("id") for r in globalRoles]
# BULK: Load all template AccessRules in one query
allTemplateRules = []
for roleId in templateRoleIds:
rules = self.db.getRecordset(AccessRule, recordFilter={"roleId": roleId})
allTemplateRules.extend([(roleId, r) for r in rules])
# Index for fast lookup: roleId -> rules
rulesByRoleId = {}
for roleId, rule in allTemplateRules:
if roleId not in rulesByRoleId:
rulesByRoleId[roleId] = []
rulesByRoleId[roleId].append(rule)
# Copy roles and their AccessRules
copiedCount = 0
for templateRole in globalRoles:
newRoleId = str(uuid.uuid4())
# Create new role for this instance
newRole = Role(
id=newRoleId,
roleLabel=templateRole.get("roleLabel"),
description=templateRole.get("description", {}),
featureCode=featureCode,
mandateId=mandateId,
featureInstanceId=instanceId,
isSystemRole=False
)
self.db.recordCreate(Role, newRole.model_dump())
# Copy AccessRules for this role
templateRulesForRole = rulesByRoleId.get(templateRole.get("id"), [])
for rule in templateRulesForRole:
newRule = AccessRule(
id=str(uuid.uuid4()),
roleId=newRoleId,
context=rule.get("context"),
item=rule.get("item"),
view=rule.get("view", False),
read=rule.get("read"),
create=rule.get("create"),
update=rule.get("update"),
delete=rule.get("delete")
)
self.db.recordCreate(AccessRule, newRule.model_dump())
copiedCount += 1
logger.info(f"Copied {copiedCount} template roles for instance {instanceId}")
return copiedCount
except Exception as e:
logger.error(f"Error copying template roles: {e}")
return 0
def syncRolesFromTemplate(self, featureInstanceId: str, addOnly: bool = True) -> Dict[str, int]:
"""
Synchronize roles of a feature instance with current templates.
WICHTIG: Templates werden NUR bei Erstellung einer neuen FeatureInstance kopiert.
Diese Sync-Funktion ist für manuelle Nachsynchronisation gedacht, nicht für
automatische Propagation von Template-Änderungen.
Args:
featureInstanceId: ID of the instance to sync
addOnly: If True, only add missing roles. If False, also remove extras.
Returns:
Dict with added/removed/unchanged counts
"""
try:
instance = self.getFeatureInstance(featureInstanceId)
if not instance:
raise ValueError(f"FeatureInstance {featureInstanceId} not found")
featureCode = instance.featureCode
mandateId = instance.mandateId
# Get current template roles
templateRoles = self.db.getRecordset(
Role,
recordFilter={"featureCode": featureCode, "mandateId": None}
)
templateLabels = {r.get("roleLabel") for r in templateRoles}
# Get current instance roles
instanceRoles = self.db.getRecordset(
Role,
recordFilter={"featureInstanceId": featureInstanceId}
)
instanceLabels = {r.get("roleLabel") for r in instanceRoles}
result = {"added": 0, "removed": 0, "unchanged": 0}
# Add missing roles
for templateRole in templateRoles:
if templateRole.get("roleLabel") not in instanceLabels:
# Copy this role
newRoleId = str(uuid.uuid4())
newRole = Role(
id=newRoleId,
roleLabel=templateRole.get("roleLabel"),
description=templateRole.get("description", {}),
featureCode=featureCode,
mandateId=mandateId,
featureInstanceId=featureInstanceId,
isSystemRole=False
)
self.db.recordCreate(Role, newRole.model_dump())
# Copy AccessRules
templateRules = self.db.getRecordset(
AccessRule,
recordFilter={"roleId": templateRole.get("id")}
)
for rule in templateRules:
newRule = AccessRule(
id=str(uuid.uuid4()),
roleId=newRoleId,
context=rule.get("context"),
item=rule.get("item"),
view=rule.get("view", False),
read=rule.get("read"),
create=rule.get("create"),
update=rule.get("update"),
delete=rule.get("delete")
)
self.db.recordCreate(AccessRule, newRule.model_dump())
result["added"] += 1
else:
result["unchanged"] += 1
# Remove extra roles (optional)
if not addOnly:
from modules.datamodels.datamodelMembership import FeatureAccessRole
for instanceRole in instanceRoles:
if instanceRole.get("roleLabel") not in templateLabels:
# Check if role is still in use
usages = self.db.getRecordset(
FeatureAccessRole,
recordFilter={"roleId": instanceRole.get("id")}
)
if not usages:
self.db.recordDelete(Role, instanceRole.get("id"))
result["removed"] += 1
logger.info(f"Synced roles for instance {featureInstanceId}: {result}")
return result
except Exception as e:
logger.error(f"Error syncing roles from template: {e}")
raise ValueError(f"Failed to sync roles: {e}")
def updateFeatureInstance(self, instanceId: str, updateData: Dict[str, Any]) -> Optional[FeatureInstance]:
"""
Update a feature instance.
Only label and enabled fields can be updated.
featureCode and mandateId are immutable.
Args:
instanceId: FeatureInstance ID
updateData: Dictionary with fields to update (label, enabled)
Returns:
Updated FeatureInstance object or None if not found
"""
try:
instance = self.getFeatureInstance(instanceId)
if not instance:
return None
# Only allow updating specific fields
allowedFields = {"label", "enabled", "config"}
filteredData = {k: v for k, v in updateData.items() if k in allowedFields}
if not filteredData:
return instance
updated = self.db.recordModify(FeatureInstance, instanceId, filteredData)
if updated:
cleanedRecord = {k: v for k, v in updated.items() if not k.startswith("_")}
return FeatureInstance(**cleanedRecord)
return None
except Exception as e:
logger.error(f"Error updating feature instance {instanceId}: {e}")
raise ValueError(f"Failed to update feature instance: {e}")
def deleteFeatureInstance(self, instanceId: str) -> bool:
"""
Delete a feature instance.
CASCADE will delete associated roles and access records.
Args:
instanceId: FeatureInstance ID
Returns:
True if deleted
"""
try:
instance = self.getFeatureInstance(instanceId)
if not instance:
return False
return self.db.recordDelete(FeatureInstance, instanceId)
except Exception as e:
logger.error(f"Error deleting feature instance {instanceId}: {e}")
raise ValueError(f"Failed to delete feature instance: {e}")
# ============================================
# Template Role Methods (Global)
# ============================================
def getTemplateRoles(self, featureCode: Optional[str] = None) -> List[Role]:
"""
Get global template roles (mandateId=None).
Args:
featureCode: Optional filter by feature code
Returns:
List of Role objects
"""
try:
recordFilter = {"mandateId": None}
if featureCode:
recordFilter["featureCode"] = featureCode
records = self.db.getRecordset(Role, recordFilter=recordFilter)
result = []
for record in records:
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
result.append(Role(**cleanedRecord))
return result
except Exception as e:
logger.error(f"Error getting template roles: {e}")
return []
def createTemplateRole(
self,
roleLabel: str,
featureCode: str,
description: Dict[str, str] = None
) -> Role:
"""
Create a global template role for a feature.
Args:
roleLabel: Role label (e.g., "admin", "viewer")
featureCode: Feature code this role belongs to
description: I18n descriptions
Returns:
Created Role object
"""
try:
role = Role(
roleLabel=roleLabel,
description=description or {},
featureCode=featureCode,
mandateId=None, # Global template
featureInstanceId=None,
isSystemRole=False
)
createdRecord = self.db.recordCreate(Role, role.model_dump())
cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")}
return Role(**cleanedRecord)
except Exception as e:
logger.error(f"Error creating template role: {e}")
raise ValueError(f"Failed to create template role: {e}")
def getFeatureInterface(db: DatabaseConnector) -> FeatureInterface:
"""
Factory function to get a FeatureInterface instance.
Args:
db: DatabaseConnector instance (DbApp database)
Returns:
FeatureInterface instance
"""
return FeatureInterface(db)