gateway/modules/interfaces/interfaceFeatures.py
2026-04-29 21:27:08 +02:00

702 lines
27 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}")
def upsertFeature(self, code: str, label: Any, icon: str = "mdi-puzzle") -> str:
"""Insert or update a Feature row for ``code``.
Idempotent counterpart to :meth:`createFeature` used by the boot-time
sync (see ``modules.system.registry.syncCatalogFeaturesToDb``) so the
``Feature`` DB-table stays consistent with the in-memory feature
registry built from the code modules. Without this sync the
``FeatureInstance.featureCode`` FK would be dangling for every
feature whose definition lives only in code (the user-reported
false-positive orphans).
Args:
code: Unique feature code (e.g. ``"trustee"``).
label: Either a string (the source label, will be wrapped as
``{"xx": label}``), a dict ``{"xx": ..., "de": ..., ...}``
or an existing TextMultilingual instance.
icon: Icon identifier.
Returns:
One of ``"created"``, ``"updated"``, ``"unchanged"``.
"""
try:
normalizedLabel = coerce_text_multilingual(label) if not isinstance(label, dict) else label
existing = self.getFeature(code)
if existing is None:
self.createFeature(code, normalizedLabel.model_dump() if hasattr(normalizedLabel, "model_dump") else normalizedLabel, icon)
return "created"
existingLabel = existing.label.model_dump() if hasattr(existing.label, "model_dump") else existing.label
desiredLabel = normalizedLabel.model_dump() if hasattr(normalizedLabel, "model_dump") else normalizedLabel
updateData: Dict[str, Any] = {}
if existingLabel != desiredLabel:
updateData["label"] = desiredLabel
if (existing.icon or "") != (icon or ""):
updateData["icon"] = icon or ""
if not updateData:
return "unchanged"
self.db.recordModify(Feature, code, updateData)
return "updated"
except Exception as e:
logger.error(f"Error upserting feature {code}: {e}")
raise ValueError(f"Failed to upsert 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).
# WICHTIG: Workflow-Bootstrap darf die Instanz-Erstellung NICHT killen
# (Instanz + Rollen sind primaer; Workflows kann Admin via Sync nachladen).
# Fehler werden aber laut geloggt, damit sie nicht unbemerkt bleiben.
try:
self._copyTemplateWorkflows(featureCode, mandateId, instanceId)
except Exception as wfErr:
logger.error(
f"createFeatureInstance: workflow bootstrap FAILED for feature "
f"'{featureCode}' instance {instanceId} — instance was created but "
f"workflows are missing. Use POST /api/features/instances/{instanceId}"
f"/sync-workflows to recover. Reason: {wfErr}",
exc_info=True,
)
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
Raises:
RuntimeError: If templates exist but cannot be copied.
Caller decides whether to swallow or re-raise.
"""
import json
from modules.system.registry import loadFeatureMainModules
mainModules = loadFeatureMainModules()
featureModule = mainModules.get(featureCode)
if not featureModule:
logger.debug(
f"_copyTemplateWorkflows: no main module loaded for feature '{featureCode}' — nothing to copy"
)
return 0
getTemplateWorkflows = getattr(featureModule, "getTemplateWorkflows", None)
if not getTemplateWorkflows:
logger.debug(
f"_copyTemplateWorkflows: feature '{featureCode}' has no getTemplateWorkflows() — nothing to copy"
)
return 0
try:
templateWorkflows = getTemplateWorkflows() or []
except Exception as e:
logger.error(
f"_copyTemplateWorkflows: getTemplateWorkflows() raised for feature '{featureCode}': {e}",
exc_info=True,
)
raise RuntimeError(
f"Feature '{featureCode}' getTemplateWorkflows() failed: {e}"
)
if not templateWorkflows:
return 0
logger.info(
f"_copyTemplateWorkflows: copying {len(templateWorkflows)} template workflow(s) "
f"for feature '{featureCode}' to instance {instanceId} (mandate={mandateId})"
)
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
from modules.security.rootAccess import getRootUser
rootUser = getRootUser()
geInterface = getGraphicalEditorInterface(rootUser, mandateId, instanceId)
copied = 0
failed = 0
for template in templateWorkflows:
templateId = template.get("id", "<no-id>")
try:
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": templateId,
"templateScope": "instance",
"active": True,
"targetFeatureInstanceId": instanceId,
})
copied += 1
except Exception as e:
failed += 1
logger.error(
f"_copyTemplateWorkflows: failed to create workflow '{templateId}' for "
f"feature '{featureCode}' instance {instanceId}: {e}",
exc_info=True,
)
if copied:
logger.info(
f"_copyTemplateWorkflows: copied {copied}/{len(templateWorkflows)} workflow(s) "
f"for feature '{featureCode}' instance {instanceId} (failed={failed})"
)
if failed:
raise RuntimeError(
f"_copyTemplateWorkflows: {failed}/{len(templateWorkflows)} workflow(s) failed "
f"for feature '{featureCode}' instance {instanceId}"
)
return copied
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)