608 lines
23 KiB
Python
608 lines
23 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.datamodels.datamodelUtils import coerce_text_multilingual
|
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
|
from modules.shared.i18nRegistry import resolveText
|
|
|
|
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 = dict(records[0])
|
|
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 = dict(record)
|
|
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 = dict(records[0])
|
|
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 = dict(record)
|
|
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)
|
|
|
|
# Copy template workflows (if feature defines TEMPLATE_WORKFLOWS)
|
|
self._copyTemplateWorkflows(featureCode, mandateId, instanceId)
|
|
|
|
cleanedRecord = dict(createdInstance)
|
|
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 _copyTemplateWorkflows(self, featureCode: str, mandateId: str, instanceId: str) -> int:
|
|
"""
|
|
Copy feature-specific template workflows to a new instance.
|
|
|
|
Loads TEMPLATE_WORKFLOWS from the feature module and creates
|
|
AutoWorkflow records in the graphicalEditor DB, scoped to
|
|
(mandateId, instanceId). The placeholder {{featureInstanceId}}
|
|
in graph parameters is replaced with the actual instanceId.
|
|
|
|
Args:
|
|
featureCode: Feature code (e.g. "trustee")
|
|
mandateId: Mandate ID
|
|
instanceId: New FeatureInstance ID
|
|
|
|
Returns:
|
|
Number of workflows copied
|
|
"""
|
|
import json
|
|
import importlib
|
|
|
|
try:
|
|
featureModule = importlib.import_module(f"modules.features.{featureCode}.main{featureCode.capitalize()}")
|
|
getTemplateWorkflows = getattr(featureModule, "getTemplateWorkflows", None)
|
|
if not getTemplateWorkflows:
|
|
return 0
|
|
|
|
templateWorkflows = getTemplateWorkflows()
|
|
if not templateWorkflows:
|
|
return 0
|
|
|
|
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
|
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
|
rootUser = getRootInterface().currentUser
|
|
geInterface = getGraphicalEditorInterface(rootUser, mandateId, instanceId)
|
|
|
|
copied = 0
|
|
for template in templateWorkflows:
|
|
graphJson = json.dumps(template.get("graph", {}))
|
|
graphJson = graphJson.replace("{{featureInstanceId}}", instanceId)
|
|
graph = json.loads(graphJson)
|
|
|
|
label = resolveText(template.get("label"))
|
|
|
|
geInterface.createWorkflow({
|
|
"label": label,
|
|
"graph": graph,
|
|
"tags": template.get("tags", [f"feature:{featureCode}"]),
|
|
"isTemplate": False,
|
|
"templateSourceId": template["id"],
|
|
"templateScope": "instance",
|
|
"active": True,
|
|
})
|
|
copied += 1
|
|
|
|
if copied > 0:
|
|
logger.info(f"Feature '{featureCode}': Copied {copied} template workflows to instance {instanceId}")
|
|
return copied
|
|
|
|
except ImportError:
|
|
logger.debug(f"No feature module found for '{featureCode}' — skipping workflow bootstrap")
|
|
return 0
|
|
except Exception as e:
|
|
logger.warning(f"Error copying template workflows for '{featureCode}' instance {instanceId}: {e}")
|
|
return 0
|
|
|
|
def _copyTemplateRoles(self, featureCode: str, mandateId: str, instanceId: str) -> int:
|
|
"""
|
|
Copy feature-specific template roles to a new instance.
|
|
|
|
INVARIANT: Feature instances MUST receive feature-specific roles
|
|
(e.g. workspace-admin, workspace-user). NEVER generic mandate roles.
|
|
Feature templates have featureCode set and isSystemRole=False.
|
|
|
|
Args:
|
|
featureCode: Feature code
|
|
mandateId: Mandate ID
|
|
instanceId: New FeatureInstance ID
|
|
|
|
Returns:
|
|
Number of roles copied
|
|
|
|
Raises:
|
|
ValueError: If no feature-specific template roles exist
|
|
"""
|
|
try:
|
|
allTemplates = self.db.getRecordset(
|
|
Role,
|
|
recordFilter={"featureCode": featureCode}
|
|
)
|
|
|
|
featureTemplates = [
|
|
r for r in allTemplates
|
|
if r.get("mandateId") is None and r.get("featureInstanceId") is None
|
|
]
|
|
|
|
if not featureTemplates:
|
|
raise ValueError(
|
|
f"No feature-specific template roles found for '{featureCode}'. "
|
|
f"Each feature module must define TEMPLATE_ROLES and sync them to DB on startup."
|
|
)
|
|
|
|
logger.info(f"Found {len(featureTemplates)} feature-specific template roles for '{featureCode}'")
|
|
|
|
templateRoleIds = [r.get("id") for r in featureTemplates]
|
|
|
|
# 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 featureTemplates:
|
|
newRoleId = str(uuid.uuid4())
|
|
|
|
# Create new role for this instance
|
|
newRole = Role(
|
|
id=newRoleId,
|
|
roleLabel=templateRole.get("roleLabel"),
|
|
description=coerce_text_multilingual(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 ValueError:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error copying template roles: {e}")
|
|
raise ValueError(f"Failed to copy template roles for '{featureCode}': {e}")
|
|
|
|
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 feature-specific template roles (mandateId=None, featureInstanceId=None)
|
|
allForFeature = self.db.getRecordset(
|
|
Role,
|
|
recordFilter={"featureCode": featureCode}
|
|
)
|
|
templateRoles = [
|
|
r for r in allForFeature
|
|
if r.get("mandateId") is None and r.get("featureInstanceId") is 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=coerce_text_multilingual(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 = dict(updated)
|
|
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 = dict(record)
|
|
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)
|