# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ Feature management routes for the backend API. Implements endpoints for Feature and FeatureInstance management. Multi-Tenant Design: - Feature definitions are global (SysAdmin can manage) - FeatureInstances belong to mandates (Mandate Admin can manage) - Template roles are copied on instance creation """ from fastapi import APIRouter, HTTPException, Depends, Request, Query from typing import List, Dict, Any, Optional, Union from fastapi import status import logging import json import math from pydantic import BaseModel, Field from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict from modules.routes.routeHelpers import applyFiltersAndSort, handleFilterValuesInMemory, handleIdsInMemory from modules.auth import limiter, getRequestContext, RequestContext, requirePlatformAdmin from modules.datamodels.datamodelUam import User, UserInDB from modules.datamodels.datamodelFeatures import Feature, FeatureInstance from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceFeatures import getFeatureInterface from modules.security.rbacCatalog import getCatalogService from modules.routes.routeNotifications import create_access_change_notification from modules.shared.i18nRegistry import apiRouteContext, resolveText routeApiMsg = apiRouteContext("routeAdminFeatures") logger = logging.getLogger(__name__) def _feature_instance_display_name(instance: Any) -> str: if instance is None: return "" if isinstance(instance, dict): return str(instance.get("label") or instance.get("uiLabel") or instance.get("id", "")) return str(getattr(instance, "label", None) or getattr(instance, "uiLabel", None) or getattr(instance, "id", "")) router = APIRouter( prefix="/api/features", tags=["Features"], responses={404: {"description": "Not found"}} ) # ============================================================================= # Request/Response Models # ============================================================================= class FeatureInstanceCreate(BaseModel): """Request model for creating a feature instance""" featureCode: str = Field(..., description="Feature code (e.g., 'trustee', 'chatbot')") label: str = Field(..., description="Instance label (e.g., 'Buchhaltung 2025')") enabled: bool = Field(True, description="Whether this feature instance is enabled") copyTemplateRoles: bool = Field(True, description="Whether to copy template roles on creation") config: Optional[Dict[str, Any]] = Field(None, description="Instance-specific configuration (JSONB). Structure depends on featureCode.") class FeatureInstanceResponse(BaseModel): """Response model for feature instance""" id: str featureCode: str mandateId: str label: str enabled: bool class SyncRolesResult(BaseModel): """Response model for role synchronization""" added: int removed: int unchanged: int # ============================================================================= # Feature Endpoints (Global - mostly read-only for non-SysAdmin) # ============================================================================= @router.get("/", response_model=List[Dict[str, Any]]) @limiter.limit("60/minute") def list_features( request: Request, context: RequestContext = Depends(getRequestContext) ) -> List[Dict[str, Any]]: """ List all available features. Returns global feature definitions from the RBAC Catalog. Features are automatically registered at startup from feature containers. Any authenticated user can see available features. """ try: # Features come from the RBAC Catalog (registered at startup from feature containers) # NOT from the database - features are code-defined, not user-created. # Hide meta-features (instantiable=False, e.g. ``system``) and soft- # disabled features (enabled=False) so they don't appear in selection # dropdowns like Admin > Feature-Instanzen > Neue Instanz. catalogService = getCatalogService() features = catalogService.getFeatureDefinitions() features = [ f for f in features if f.get("instantiable", True) and f.get("enabled", True) ] return features except Exception as e: logger.error(f"Error listing features: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to list features: {str(e)}" ) # ============================================================================= # My Feature Instances (No mandate context needed) # IMPORTANT: Must be before /{featureCode} to avoid route matching conflict # ============================================================================= class FeaturesMyResponse(BaseModel): """Hierarchical response for GET /features/my""" mandates: List[Dict[str, Any]] @router.get("/my", response_model=FeaturesMyResponse) @limiter.limit("60/minute") def get_my_feature_instances( request: Request, context: RequestContext = Depends(getRequestContext) ) -> FeaturesMyResponse: """ Get all feature instances the current user has access to. Returns hierarchical structure: mandates -> features -> instances -> permissions This endpoint does not require X-Mandate-Id header. """ try: rootInterface = getRootInterface() featureInterface = getFeatureInterface(rootInterface.db) # Get all feature accesses for this user featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id)) if not featureAccesses: return FeaturesMyResponse(mandates=[]) # Build hierarchical structure: mandate -> feature -> instances mandatesMap: Dict[str, Dict[str, Any]] = {} featuresMap: Dict[str, Dict[str, Any]] = {} # key: mandateId_featureCode catalogService = getCatalogService() for access in featureAccesses: if not access.enabled: continue instance = featureInterface.getFeatureInstance(str(access.featureInstanceId)) if not instance or not instance.enabled: continue # Only show features that exist in this app's catalog (e.g. PowerOn vs Actan share DB) if not catalogService.getFeatureDefinition(instance.featureCode): continue # Get mandate info mandateId = str(instance.mandateId) if mandateId not in mandatesMap: mandate = rootInterface.getMandate(mandateId) if mandate and not getattr(mandate, "enabled", True): continue if mandate: mandateName = mandate.name if hasattr(mandate, 'name') else mandateId mandateLabel = ( mandate.label if hasattr(mandate, 'label') and mandate.label else mandateName ) mandatesMap[mandateId] = { "id": mandateId, "name": mandateName, "label": mandateLabel, "code": mandate.code if hasattr(mandate, 'code') else None, "features": [] } else: mandatesMap[mandateId] = { "id": mandateId, "name": mandateId, "label": mandateId, "code": None, "features": [] } # Get feature info from catalog (features are code-defined) featureKey = f"{mandateId}_{instance.featureCode}" if featureKey not in featuresMap: catalogService = getCatalogService() featureDef = catalogService.getFeatureDefinition(instance.featureCode) featuresMap[featureKey] = { "code": instance.featureCode, "label": resolveText(featureDef.get("label") if featureDef else None), "icon": featureDef.get("icon", "folder") if featureDef else "folder", "instances": [], "_mandateId": mandateId # Temporary for grouping } # Get user's roles in this instance (can have multiple) userRoles = _getUserRolesInInstance(rootInterface, str(context.user.id), str(instance.id)) # Get permissions for this instance permissions = _getInstancePermissions(rootInterface, str(context.user.id), str(instance.id)) # Add instance to feature featuresMap[featureKey]["instances"].append({ "id": str(instance.id), "featureCode": instance.featureCode, "mandateId": mandateId, "mandateName": mandatesMap[mandateId]["name"], "mandateLabel": mandatesMap[mandateId]["label"], "instanceLabel": instance.label, "userRoles": userRoles, "permissions": permissions }) # Build final structure for featureKey, featureData in featuresMap.items(): mandateId = featureData.pop("_mandateId") mandatesMap[mandateId]["features"].append(featureData) return FeaturesMyResponse(mandates=list(mandatesMap.values())) except Exception as e: logger.error(f"Error getting user's feature instances: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to get feature instances: {str(e)}" ) def _getUserRolesInInstance(rootInterface, userId: str, instanceId: str) -> List[str]: """Get all role labels for a user in a feature instance.""" try: # Get FeatureAccess for this user and instance (Pydantic model) featureAccess = rootInterface.getFeatureAccess(userId, instanceId) if featureAccess: # Get role IDs via interface method roleIds = rootInterface.getRoleIdsForFeatureAccess(str(featureAccess.id)) if roleIds: # Get ALL roles and extract labels roleLabels = [] for roleId in roleIds: role = rootInterface.getRole(roleId) if role: roleLabels.append(role.roleLabel) return roleLabels if roleLabels else ["user"] return ["user"] # Default - no access means basic user level except Exception as e: logger.debug(f"Error getting user roles: {e}") return ["user"] # Fail-safe: default to basic user def _getInstancePermissions(rootInterface, userId: str, instanceId: str) -> Dict[str, Any]: """Get summarized permissions for a user in an instance.""" # Default permissions structure permissions = { "tables": {}, "views": {}, "fields": {}, "isAdmin": False # Flag if user has admin role } try: from modules.datamodels.datamodelRbac import AccessRuleContext # Get FeatureAccess for this user and instance (Pydantic model) featureAccess = rootInterface.getFeatureAccess(userId, instanceId) if not featureAccess: return permissions # Get role IDs via interface method roleIds = rootInterface.getRoleIdsForFeatureAccess(str(featureAccess.id)) if not roleIds: return permissions # Check if user has admin role for roleId in roleIds: role = rootInterface.getRole(roleId) if role and "admin" in role.roleLabel.lower(): permissions["isAdmin"] = True break # Get permissions (AccessRules) for all roles for roleId in roleIds: # Get all rules for this role (returns Pydantic models) accessRules = rootInterface.getAccessRules(roleId=roleId) for rule in accessRules: context = rule.context item = rule.item or "" # Handle DATA context (tables/fields) if context == AccessRuleContext.DATA or context == "DATA": if item: # Check if it's a field (table.field) or table if "." in item: tableName, fieldName = item.split(".", 1) if fieldName not in permissions["fields"]: permissions["fields"][fieldName] = {"view": False} permissions["fields"][fieldName]["view"] = permissions["fields"][fieldName]["view"] or rule.view else: tableName = item if tableName not in permissions["tables"]: permissions["tables"][tableName] = { "view": False, "read": "n", "create": "n", "update": "n", "delete": "n" } # Merge permissions (highest wins) current = permissions["tables"][tableName] current["view"] = current["view"] or rule.view current["read"] = _mergeAccessLevel(current["read"], rule.read or "n") current["create"] = _mergeAccessLevel(current["create"], rule.create or "n") current["update"] = _mergeAccessLevel(current["update"], rule.update or "n") current["delete"] = _mergeAccessLevel(current["delete"], rule.delete or "n") # Handle UI context (views) elif context == AccessRuleContext.UI or context == "UI": if item: # Store with full objectKey as per Navigation-API-Konzept permissions["views"][item] = permissions["views"].get(item, False) or rule.view elif rule.view: # item=None means all views - set a wildcard flag permissions["views"]["_all"] = True return permissions except Exception as e: logger.debug(f"Error getting instance permissions: {e}") return permissions # Fail-safe: no permissions on error def _mergeAccessLevel(current: str, new: str) -> str: """Merge two access levels, returning the highest.""" levels = {"n": 0, "m": 1, "g": 2, "a": 3} currentLevel = levels.get(current, 0) newLevel = levels.get(new, 0) if newLevel > currentLevel: return new return current @router.post("/", response_model=Dict[str, Any]) @limiter.limit("10/minute") def create_feature( request: Request, code: str = Query(..., description="Unique feature code"), label: Dict[str, str] = None, icon: str = Query("mdi-puzzle", description="Icon identifier"), sysAdmin: User = Depends(requirePlatformAdmin) ) -> Dict[str, Any]: """ Create a new feature definition. SysAdmin only - creates a global feature that can be activated for mandates. Args: code: Unique feature code (e.g., 'trustee') label: I18n labels (e.g., {"en": "Trustee", "de": "Treuhand"}) icon: Icon identifier """ try: rootInterface = getRootInterface() featureInterface = getFeatureInterface(rootInterface.db) # Check if feature already exists in catalog (features are code-defined) catalogService = getCatalogService() existing = catalogService.getFeatureDefinition(code) if existing: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Feature '{code}' already exists" ) feature = featureInterface.createFeature( code=code, label=label or {"en": code.title(), "de": code.title()}, icon=icon ) logger.info(f"SysAdmin {sysAdmin.id} created feature '{code}'") return feature.model_dump() except HTTPException: raise except Exception as e: logger.error(f"Error creating feature: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to create feature: {str(e)}" ) # ============================================================================= # Feature Instance Endpoints (Mandate-scoped) # ============================================================================= @router.get("/instances") @limiter.limit("60/minute") def list_feature_instances( request: Request, featureCode: Optional[str] = Query(None, description="Filter by feature code"), pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"), column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"), context: RequestContext = Depends(getRequestContext) ): """ List feature instances. With X-Mandate-Id: returns instances for that mandate. Without X-Mandate-Id: returns all instances the user has access to (via FeatureAccess records). Used for FK resolution in tables. Args: featureCode: Optional filter by feature code pagination: JSON-encoded PaginationParams (page, pageSize, sort, filters) """ try: paginationParams = None if pagination: try: paginationDict = json.loads(pagination) if paginationDict: paginationDict = normalize_pagination_dict(paginationDict) paginationParams = PaginationParams(**paginationDict) except (json.JSONDecodeError, ValueError) as e: raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}") rootInterface = getRootInterface() featureInterface = getFeatureInterface(rootInterface.db) if context.mandateId: instances = featureInterface.getFeatureInstancesForMandate( mandateId=str(context.mandateId), featureCode=featureCode ) else: featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id)) seen = set() instances = [] for fa in featureAccesses: instId = str(fa.featureInstanceId) if instId in seen: continue seen.add(instId) inst = featureInterface.getFeatureInstance(instId) if inst and inst.enabled: if featureCode and inst.featureCode != featureCode: continue instances.append(inst) items = [inst.model_dump() for inst in instances] if mode == "filterValues": if not column: raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") from modules.routes.routeHelpers import enrichRowsWithFkLabels from modules.datamodels.datamodelFeatures import FeatureInstance enrichRowsWithFkLabels(items, FeatureInstance) return handleFilterValuesInMemory(items, column, pagination) if mode == "ids": return handleIdsInMemory(items, pagination) if paginationParams: filtered = applyFiltersAndSort(items, paginationParams) totalItems = len(filtered) totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0 startIdx = (paginationParams.page - 1) * paginationParams.pageSize endIdx = startIdx + paginationParams.pageSize return { "items": filtered[startIdx:endIdx], "pagination": PaginationMetadata( currentPage=paginationParams.page, pageSize=paginationParams.pageSize, totalItems=totalItems, totalPages=totalPages, sort=paginationParams.sort, filters=paginationParams.filters, ).model_dump(), } else: return items except HTTPException: raise except Exception as e: logger.error(f"Error listing feature instances: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to list feature instances: {str(e)}" ) @router.get("/instances/{instanceId}", response_model=Dict[str, Any]) @limiter.limit("60/minute") def get_feature_instance( request: Request, instanceId: str, context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """ Get a specific feature instance. Args: instanceId: FeatureInstance ID """ try: rootInterface = getRootInterface() featureInterface = getFeatureInterface(rootInterface.db) instance = featureInterface.getFeatureInstance(instanceId) if not instance: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Feature instance '{instanceId}' not found" ) # Verify mandate access (unless SysAdmin) if context.mandateId and str(instance.mandateId) != str(context.mandateId): if not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Access denied to this feature instance") ) return instance.model_dump() except HTTPException: raise except Exception as e: logger.error(f"Error getting feature instance {instanceId}: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to get feature instance: {str(e)}" ) @router.post("/instances", response_model=Dict[str, Any]) @limiter.limit("10/minute") def create_feature_instance( request: Request, data: FeatureInstanceCreate, context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """ Create a new feature instance for the current mandate. Requires Mandate-Admin role. Template roles are optionally copied. Args: data: Feature instance creation data """ if not context.mandateId: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=routeApiMsg("X-Mandate-Id header is required") ) # Check mandate admin permission if not _hasMandateAdminRole(context): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Mandate-Admin role required to create feature instances") ) try: rootInterface = getRootInterface() featureInterface = getFeatureInterface(rootInterface.db) # Verify feature exists in catalog (features are code-defined, not DB-stored) catalogService = getCatalogService() featureDef = catalogService.getFeatureDefinition(data.featureCode) if not featureDef: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Feature '{data.featureCode}' not found" ) # Subscription capacity check mandateIdStr = str(context.mandateId) try: from modules.interfaces.interfaceDbSubscription import getInterface as _getSubIf from modules.security.rootAccess import getRootUser _subIf = _getSubIf(getRootUser(), mandateIdStr) _subIf.assertCapacity(mandateIdStr, "featureInstances", delta=1) except HTTPException: raise except Exception as capErr: if "SubscriptionCapacityException" in type(capErr).__name__: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=str(capErr), ) instance = featureInterface.createFeatureInstance( featureCode=data.featureCode, mandateId=mandateIdStr, label=data.label, enabled=data.enabled, copyTemplateRoles=data.copyTemplateRoles, config=data.config ) try: from modules.interfaces.interfaceDbSubscription import getInterface as _getSubIf2 from modules.security.rootAccess import getRootUser as _getRU _subIf2 = _getSubIf2(_getRU(), mandateIdStr) _operative = _subIf2.getOperativeForMandate(mandateIdStr) if _operative: _subIf2.syncQuantityToStripe(_operative["id"], raiseOnError=True) except Exception as e: logger.error("Stripe quantity sync failed for admin feature creation in mandate %s: %s", mandateIdStr, e) logger.info( f"User {context.user.id} created feature instance '{data.label}' " f"for feature '{data.featureCode}' in mandate {context.mandateId}" ) return instance.model_dump() except HTTPException: raise except Exception as e: logger.error(f"Error creating feature instance: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to create feature instance: {str(e)}" ) @router.delete("/instances/{instanceId}", response_model=Dict[str, str]) @limiter.limit("10/minute") def delete_feature_instance( request: Request, instanceId: str, context: RequestContext = Depends(getRequestContext) ) -> Dict[str, str]: """ Delete a feature instance. Requires Mandate-Admin role. CASCADE will delete associated roles and access records. Args: instanceId: FeatureInstance ID """ try: rootInterface = getRootInterface() featureInterface = getFeatureInterface(rootInterface.db) # Get instance to verify access instance = featureInterface.getFeatureInstance(instanceId) if not instance: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Feature instance '{instanceId}' not found" ) # Verify mandate access if context.mandateId and str(instance.mandateId) != str(context.mandateId): if not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Access denied to this feature instance") ) # Check mandate admin permission if not _hasMandateAdminRole(context) and not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Mandate-Admin role required to delete feature instances") ) featureInterface.deleteFeatureInstance(instanceId) logger.info(f"User {context.user.id} deleted feature instance {instanceId}") return {"message": "Feature instance deleted", "instanceId": instanceId} except HTTPException: raise except Exception as e: logger.error(f"Error deleting feature instance {instanceId}: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to delete feature instance: {str(e)}" ) class FeatureInstanceUpdate(BaseModel): """Request model for updating a feature instance.""" label: Optional[str] = Field(None, description="New label for the instance") enabled: Optional[bool] = Field(None, description="Enable/disable the instance") config: Optional[Dict[str, Any]] = Field(None, description="Instance-specific configuration (JSONB). Structure depends on featureCode.") @router.put("/instances/{instanceId}", response_model=Dict[str, Any]) @limiter.limit("30/minute") def updateFeatureInstance( request: Request, instanceId: str, data: FeatureInstanceUpdate, context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """ Update a feature instance (label, enabled). Requires Mandate-Admin role. Args: instanceId: FeatureInstance ID data: Fields to update (label, enabled) """ try: rootInterface = getRootInterface() featureInterface = getFeatureInterface(rootInterface.db) # Verify instance exists instance = featureInterface.getFeatureInstance(instanceId) if not instance: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Feature instance '{instanceId}' not found" ) # Verify mandate access if context.mandateId and str(instance.mandateId) != str(context.mandateId): if not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Access denied to this feature instance") ) # Check mandate admin permission if not _hasMandateAdminRole(context) and not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Mandate-Admin role required to update feature instances") ) # Build update data (only non-None values) updateData = {} if data.label is not None: updateData["label"] = data.label if data.enabled is not None: updateData["enabled"] = data.enabled if data.config is not None: updateData["config"] = data.config if not updateData: return instance.model_dump() updated = featureInterface.updateFeatureInstance(instanceId, updateData) if not updated: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=routeApiMsg("Failed to update feature instance") ) # Clear chatbot config cache when config was updated for chatbot instances if "config" in updateData and instance.featureCode == "chatbot": from modules.features.chatbot.config import clear_config_cache clear_config_cache(instanceId) logger.info(f"User {context.user.id} updated feature instance {instanceId}: {updateData}") return updated.model_dump() except HTTPException: raise except Exception as e: logger.error(f"Error updating feature instance {instanceId}: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to update feature instance: {str(e)}" ) @router.post("/instances/{instanceId}/sync-roles", response_model=SyncRolesResult) @limiter.limit("10/minute") def sync_instance_roles( request: Request, instanceId: str, addOnly: bool = Query(True, description="Only add missing roles, don't remove extras"), context: RequestContext = Depends(getRequestContext) ) -> SyncRolesResult: """ Synchronize roles of a feature instance with current templates. IMPORTANT: Templates are only copied when a FeatureInstance is created. This sync function is for manual re-synchronization, not automatic propagation. Args: instanceId: FeatureInstance ID addOnly: If True, only add missing roles. If False, also remove extras. """ try: rootInterface = getRootInterface() featureInterface = getFeatureInterface(rootInterface.db) # Get instance to verify access instance = featureInterface.getFeatureInstance(instanceId) if not instance: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Feature instance '{instanceId}' not found" ) # Verify mandate access if context.mandateId and str(instance.mandateId) != str(context.mandateId): if not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Access denied to this feature instance") ) # Check admin permission (Mandate-Admin or Feature-Admin) if not _hasMandateAdminRole(context) and not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Admin role required to sync roles") ) result = featureInterface.syncRolesFromTemplate(instanceId, addOnly) logger.info( f"User {context.user.id} synced roles for instance {instanceId}: {result}" ) return SyncRolesResult(**result) except HTTPException: raise except Exception as e: logger.error(f"Error syncing roles for instance {instanceId}: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to sync roles: {str(e)}" ) class SyncWorkflowsResult(BaseModel): """Response model for workflow synchronization""" added: int skipped: int total: int @router.post("/instances/{instanceId}/sync-workflows", response_model=SyncWorkflowsResult) @limiter.limit("10/minute") def _syncInstanceWorkflows( request: Request, instanceId: str, context: RequestContext = Depends(getRequestContext) ) -> SyncWorkflowsResult: """ Synchronize template workflows for a feature instance. Copies missing template workflows to the instance. Workflows that already exist (matched by templateSourceId) are skipped. This is useful for instances created before template workflows were defined, or when the initial copy failed silently. PlatformAdmin only. """ try: if not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Platform admin privileges required", ) rootInterface = getRootInterface() featureInterface = getFeatureInterface(rootInterface.db) instance = featureInterface.getFeatureInstance(instanceId) if not instance: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Feature instance '{instanceId}' not found" ) featureCode = instance.get("featureCode") if isinstance(instance, dict) else instance.featureCode mandateId = instance.get("mandateId") if isinstance(instance, dict) else instance.mandateId from modules.system.registry import loadFeatureMainModules mainModules = loadFeatureMainModules() featureModule = mainModules.get(featureCode) if not featureModule: return SyncWorkflowsResult(added=0, skipped=0, total=0) getTemplateWorkflows = getattr(featureModule, "getTemplateWorkflows", None) if not getTemplateWorkflows: return SyncWorkflowsResult(added=0, skipped=0, total=0) templateWorkflows = getTemplateWorkflows() if not templateWorkflows: return SyncWorkflowsResult(added=0, skipped=0, total=0) from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface from modules.security.rootAccess import getRootUser rootUser = getRootUser() geInterface = getGraphicalEditorInterface(rootUser, mandateId, instanceId) existingWorkflows = geInterface.getWorkflows() or [] existingSourceIds = set() for w in existingWorkflows: sourceId = w.get("templateSourceId") if isinstance(w, dict) else getattr(w, "templateSourceId", None) if sourceId: existingSourceIds.add(sourceId) added = 0 skipped = 0 for template in templateWorkflows: if template["id"] in existingSourceIds: skipped += 1 continue import json as _json 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, }) added += 1 logger.info( f"User {context.user.id} synced workflows for instance {instanceId} " f"({featureCode}): added={added}, skipped={skipped}" ) return SyncWorkflowsResult(added=added, skipped=skipped, total=len(templateWorkflows)) except HTTPException: raise except Exception as e: logger.error(f"Error syncing workflows for instance {instanceId}: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to sync workflows: {str(e)}" ) # ============================================================================= # Template Role Endpoints (SysAdmin only) # ============================================================================= def _buildTemplateRolesList(featureCode: Optional[str] = None) -> List[Dict[str, Any]]: """Build the full template roles list.""" rootInterface = getRootInterface() featureInterface = getFeatureInterface(rootInterface.db) roles = featureInterface.getTemplateRoles(featureCode) result = [] for r in roles: d = r.model_dump() d["description"] = resolveText(r.description) result.append(d) return result @router.get("/templates/roles") @limiter.limit("60/minute") def list_template_roles( request: Request, featureCode: Optional[str] = Query(None, description="Filter by feature code"), pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"), mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"), column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"), sysAdmin: User = Depends(requirePlatformAdmin), ): """List global template roles with pagination support.""" try: paginationParams: Optional[PaginationParams] = None if pagination: try: paginationDict = json.loads(pagination) if paginationDict: paginationDict = normalize_pagination_dict(paginationDict) paginationParams = PaginationParams(**paginationDict) except (json.JSONDecodeError, ValueError) as e: raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}") enriched = _buildTemplateRolesList(featureCode) if mode == "filterValues": if not column: raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") return handleFilterValuesInMemory(enriched, column, pagination) if mode == "ids": return handleIdsInMemory(enriched, pagination) filtered = applyFiltersAndSort(enriched, paginationParams) if paginationParams: totalItems = len(filtered) totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0 startIdx = (paginationParams.page - 1) * paginationParams.pageSize endIdx = startIdx + paginationParams.pageSize return { "items": filtered[startIdx:endIdx], "pagination": PaginationMetadata( currentPage=paginationParams.page, pageSize=paginationParams.pageSize, totalItems=totalItems, totalPages=totalPages, sort=paginationParams.sort, filters=paginationParams.filters, ).model_dump(), } return {"items": enriched, "pagination": None} except Exception as e: logger.error(f"Error listing template roles: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to list template roles: {str(e)}" ) @router.post("/templates/roles", response_model=Dict[str, Any]) @limiter.limit("10/minute") def create_template_role( request: Request, roleLabel: str = Query(..., description="Role label (e.g., 'admin', 'viewer')"), featureCode: str = Query(..., description="Feature code this role belongs to"), description: Dict[str, str] = None, sysAdmin: User = Depends(requirePlatformAdmin) ) -> Dict[str, Any]: """ Create a global template role for a feature. SysAdmin only - new template roles are NOT automatically propagated to existing instances. Use the sync-roles endpoint to manually synchronize. Args: roleLabel: Role label featureCode: Feature code description: I18n descriptions """ try: rootInterface = getRootInterface() featureInterface = getFeatureInterface(rootInterface.db) # Verify feature exists in catalog (features are code-defined) catalogService = getCatalogService() featureDef = catalogService.getFeatureDefinition(featureCode) if not featureDef: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Feature '{featureCode}' not found" ) role = featureInterface.createTemplateRole( roleLabel=roleLabel, featureCode=featureCode, description=description ) logger.info( f"SysAdmin {sysAdmin.id} created template role '{roleLabel}' " f"for feature '{featureCode}'" ) return role.model_dump() except HTTPException: raise except Exception as e: logger.error(f"Error creating template role: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to create template role: {str(e)}" ) # ============================================================================= # Feature Instance Users Endpoints # Manage which users have access to a specific feature instance # ============================================================================= class FeatureInstanceUserCreate(BaseModel): """Request model for adding a user to a feature instance""" userId: str = Field(..., description="User ID to add") roleIds: List[str] = Field(default_factory=list, description="Role IDs to assign") class FeatureInstanceUserResponse(BaseModel): """Response model for a user in a feature instance""" id: str # Use the FeatureAccess ID as primary key userId: str username: str email: Optional[str] fullName: Optional[str] roleIds: List[str] roleLabels: List[str] enabled: bool class FeatureInstanceUserUpdate(BaseModel): """Request model for updating a feature instance user (roles and active flag)""" roleIds: List[str] = Field(..., description="Role IDs to assign") enabled: Optional[bool] = Field(None, description="Whether this user's access is active (omit to leave unchanged)") @router.get("/instances/{instanceId}/users") @limiter.limit("60/minute") def list_feature_instance_users( request: Request, instanceId: str, pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"), column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"), context: RequestContext = Depends(getRequestContext) ): """ List all users with access to a specific feature instance. Returns users and their roles for the given instance. Args: instanceId: FeatureInstance ID """ try: rootInterface = getRootInterface() featureInterface = getFeatureInterface(rootInterface.db) # Verify instance exists instance = featureInterface.getFeatureInstance(instanceId) if not instance: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Feature instance '{instanceId}' not found" ) # Verify mandate access (unless SysAdmin) if context.mandateId and str(instance.mandateId) != str(context.mandateId): if not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Access denied to this feature instance") ) # Get all FeatureAccess records for this instance (Pydantic models) featureAccesses = rootInterface.getFeatureAccessesByInstance(instanceId) result = [] for fa in featureAccesses: # Get user info (Pydantic model) user = rootInterface.getUser(str(fa.userId)) if not user: continue # Get role IDs via interface method roleIds = rootInterface.getRoleIdsForFeatureAccess(str(fa.id)) # Get role labels roleLabels = [] for roleId in roleIds: role = rootInterface.getRole(roleId) if role: roleLabels.append(role.roleLabel) result.append(FeatureInstanceUserResponse( id=str(fa.id), # FeatureAccess ID as primary key userId=str(fa.userId), username=user.username, email=user.email, fullName=user.fullName, roleIds=roleIds, roleLabels=roleLabels, enabled=fa.enabled )) items = [r.model_dump() for r in result] if mode == "filterValues": if not column: raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") return handleFilterValuesInMemory(items, column, pagination) if mode == "ids": return handleIdsInMemory(items, pagination) paginationParams = None if pagination: try: paginationDict = json.loads(pagination) if paginationDict: paginationDict = normalize_pagination_dict(paginationDict) paginationParams = PaginationParams(**paginationDict) except (json.JSONDecodeError, ValueError) as e: raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}") if paginationParams: filtered = applyFiltersAndSort(items, paginationParams) totalItems = len(filtered) totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0 startIdx = (paginationParams.page - 1) * paginationParams.pageSize endIdx = startIdx + paginationParams.pageSize return { "items": filtered[startIdx:endIdx], "pagination": PaginationMetadata( currentPage=paginationParams.page, pageSize=paginationParams.pageSize, totalItems=totalItems, totalPages=totalPages, sort=paginationParams.sort, filters=paginationParams.filters, ).model_dump(), } return items except HTTPException: raise except Exception as e: logger.error(f"Error listing feature instance users: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to list feature instance users: {str(e)}" ) @router.post("/instances/{instanceId}/users", response_model=Dict[str, Any]) @limiter.limit("30/minute") def add_user_to_feature_instance( request: Request, instanceId: str, data: FeatureInstanceUserCreate, context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """ Add a user to a feature instance with specified roles. Creates a FeatureAccess record and associated FeatureAccessRole records. Args: instanceId: FeatureInstance ID data: User and role data """ try: rootInterface = getRootInterface() featureInterface = getFeatureInterface(rootInterface.db) # Verify instance exists instance = featureInterface.getFeatureInstance(instanceId) if not instance: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Feature instance '{instanceId}' not found" ) # Verify mandate access if context.mandateId and str(instance.mandateId) != str(context.mandateId): if not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Access denied to this feature instance") ) # Check admin permission if not _hasMandateAdminRole(context) and not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Admin role required to add users to feature instances") ) # Verify user exists user = rootInterface.getUser(data.userId) if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"User '{data.userId}' not found" ) if not data.roleIds: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=routeApiMsg("At least one role is required to grant feature access") ) from modules.datamodels.datamodelRbac import Role instanceRoles = rootInterface.db.getRecordset(Role, recordFilter={"featureInstanceId": instanceId}) validRoleIds = {r.get("id") for r in instanceRoles} invalidRoles = [rid for rid in data.roleIds if rid not in validRoleIds] if invalidRoles: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Role IDs {invalidRoles} do not belong to feature instance {instanceId}. " f"Only instance-scoped roles are allowed, never mandate roles." ) featureAccess = rootInterface.createFeatureAccess( userId=data.userId, featureInstanceId=instanceId, roleIds=data.roleIds ) featureAccessId = str(featureAccess.id) logger.info( f"User {context.user.id} added user {data.userId} to feature instance {instanceId} " f"with roles {data.roleIds}" ) iname = _feature_instance_display_name(instance) create_access_change_notification( data.userId, "Feature-Zugriff", f"Sie haben Zugriff auf die Feature-Instanz «{iname}» erhalten.", "feature_access", instanceId, ) return { "featureAccessId": featureAccessId, "userId": data.userId, "featureInstanceId": instanceId, "roleIds": data.roleIds, "enabled": True } except HTTPException: raise except Exception as e: logger.error(f"Error adding user to feature instance: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to add user to feature instance: {str(e)}" ) @router.delete("/instances/{instanceId}/users/{userId}", response_model=Dict[str, str]) @limiter.limit("30/minute") def remove_user_from_feature_instance( request: Request, instanceId: str, userId: str, context: RequestContext = Depends(getRequestContext) ) -> Dict[str, str]: """ Remove a user's access from a feature instance. Deletes the FeatureAccess record (CASCADE will delete FeatureAccessRole records). Args: instanceId: FeatureInstance ID userId: User ID to remove """ try: rootInterface = getRootInterface() featureInterface = getFeatureInterface(rootInterface.db) # Verify instance exists instance = featureInterface.getFeatureInstance(instanceId) if not instance: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Feature instance '{instanceId}' not found" ) # Verify mandate access if context.mandateId and str(instance.mandateId) != str(context.mandateId): if not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Access denied to this feature instance") ) # Check admin permission if not _hasMandateAdminRole(context) and not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Admin role required to remove users from feature instances") ) # Find FeatureAccess record from modules.datamodels.datamodelMembership import FeatureAccess existingAccess = rootInterface.getFeatureAccess(userId, instanceId) if not existingAccess: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=routeApiMsg("User does not have access to this feature instance") ) featureAccessId = str(existingAccess.id) # Delete FeatureAccess (CASCADE will delete FeatureAccessRole records) rootInterface.db.recordDelete(FeatureAccess, featureAccessId) logger.info( f"User {context.user.id} removed user {userId} from feature instance {instanceId}" ) iname = _feature_instance_display_name(instance) create_access_change_notification( userId, "Feature-Zugriff", f"Ihr Zugriff auf die Feature-Instanz «{iname}» wurde entfernt.", "feature_access", instanceId, ) return { "message": "User access removed", "userId": userId, "featureInstanceId": instanceId } except HTTPException: raise except Exception as e: logger.error(f"Error removing user from feature instance: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to remove user from feature instance: {str(e)}" ) @router.put("/instances/{instanceId}/users/{userId}/roles", response_model=Dict[str, Any]) @limiter.limit("30/minute") def update_feature_instance_user_roles( request: Request, instanceId: str, userId: str, data: FeatureInstanceUserUpdate, context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """ Update a user's roles and active flag in a feature instance. Replaces all existing FeatureAccessRole records with new ones. If enabled is provided, updates the FeatureAccess.enabled flag. Args: instanceId: FeatureInstance ID userId: User ID to update data: roleIds and optional enabled """ try: rootInterface = getRootInterface() featureInterface = getFeatureInterface(rootInterface.db) # Verify instance exists instance = featureInterface.getFeatureInstance(instanceId) if not instance: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Feature instance '{instanceId}' not found" ) # Verify mandate access if context.mandateId and str(instance.mandateId) != str(context.mandateId): if not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Access denied to this feature instance") ) # Check admin permission if not _hasMandateAdminRole(context) and not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Admin role required to update user roles") ) # Find FeatureAccess record from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole existingAccess = rootInterface.getFeatureAccess(userId, instanceId) if not existingAccess: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=routeApiMsg("User does not have access to this feature instance") ) featureAccessId = str(existingAccess.id) # Update enabled flag if provided if data.enabled is not None: rootInterface.db.recordModify(FeatureAccess, featureAccessId, {"enabled": data.enabled}) from modules.datamodels.datamodelRbac import Role instanceRoles = rootInterface.db.getRecordset(Role, recordFilter={"featureInstanceId": instanceId}) validRoleIds = {r.get("id") for r in instanceRoles} invalidRoles = [rid for rid in data.roleIds if rid not in validRoleIds] if invalidRoles: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Role IDs {invalidRoles} do not belong to feature instance {instanceId}. " f"Only instance-scoped roles are allowed, never mandate roles." ) rootInterface.deleteFeatureAccessRoles(featureAccessId) for roleId in data.roleIds: featureAccessRole = FeatureAccessRole( featureAccessId=featureAccessId, roleId=roleId ) rootInterface.db.recordCreate(FeatureAccessRole, featureAccessRole.model_dump()) logger.info( f"User {context.user.id} updated roles for user {userId} in feature instance {instanceId}: {data.roleIds}" ) iname = _feature_instance_display_name(instance) create_access_change_notification( userId, "Feature-Rollen geändert", f"Ihre Rollen in der Feature-Instanz «{iname}» wurden angepasst.", "feature_access", instanceId, ) return { "featureAccessId": featureAccessId, "userId": userId, "featureInstanceId": instanceId, "roleIds": data.roleIds, "enabled": data.enabled if data.enabled is not None else bool(existingAccess.enabled), } except HTTPException: raise except Exception as e: logger.error(f"Error updating user roles in feature instance: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to update user roles: {str(e)}" ) @router.get("/instances/{instanceId}/available-roles", response_model=List[Dict[str, Any]]) @limiter.limit("60/minute") def get_feature_instance_available_roles( request: Request, instanceId: str, context: RequestContext = Depends(getRequestContext) ) -> List[Dict[str, Any]]: """ Get available roles for a feature instance. Returns instance-specific roles (copied from templates) that can be assigned to users. Args: instanceId: FeatureInstance ID """ try: rootInterface = getRootInterface() featureInterface = getFeatureInterface(rootInterface.db) # Verify instance exists instance = featureInterface.getFeatureInstance(instanceId) if not instance: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Feature instance '{instanceId}' not found" ) # Verify mandate access if context.mandateId and str(instance.mandateId) != str(context.mandateId): if not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Access denied to this feature instance") ) # Get roles for this instance using interface method instanceRoles = rootInterface.getRolesByFeatureInstance(instanceId) result = [] for role in instanceRoles: result.append({ "id": role.id, "roleLabel": role.roleLabel, "description": resolveText(role.description), "featureCode": role.featureCode, "isSystemRole": role.isSystemRole }) return result except HTTPException: raise except Exception as e: logger.error(f"Error getting available roles for feature instance: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to get available roles: {str(e)}" ) # ============================================================================= # Dynamic Feature Route (MUST be last to avoid catching /instances, /my, etc.) # ============================================================================= @router.get("/{featureCode}", response_model=Dict[str, Any]) @limiter.limit("60/minute") def get_feature( request: Request, featureCode: str, context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """ Get a specific feature by code. IMPORTANT: This route must be defined LAST to avoid catching paths like /instances, /my, /templates, etc. Args: featureCode: Feature code (e.g., 'trustee', 'chatbot') """ try: # Features come from the RBAC Catalog (code-defined, not DB-stored) catalogService = getCatalogService() featureDef = catalogService.getFeatureDefinition(featureCode) if not featureDef: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Feature '{featureCode}' not found" ) return featureDef except HTTPException: raise except Exception as e: logger.error(f"Error getting feature {featureCode}: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to get feature: {str(e)}" ) # ============================================================================= # Instance Rename (for instance admins, used by navigation tree) # ============================================================================= class FeatureInstanceRenameRequest(BaseModel): """Request model for renaming a feature instance""" label: str = Field(..., min_length=1, max_length=200, description="New label for the instance") @router.patch("/instances/{instanceId}/rename", response_model=Dict[str, Any]) @limiter.limit("30/minute") def _renameFeatureInstance( request: Request, instanceId: str, data: FeatureInstanceRenameRequest, context: RequestContext = Depends(getRequestContext), ) -> Dict[str, Any]: """ Rename a feature instance. Requires instance admin role. """ try: rootInterface = getRootInterface() featureInterface = getFeatureInterface(rootInterface.db) instance = featureInterface.getFeatureInstance(instanceId) if not instance: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=routeApiMsg("Feature instance not found")) userId = str(context.user.id) isInstanceAdmin = False if context.isPlatformAdmin: isInstanceAdmin = True else: from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole fa = rootInterface.getFeatureAccess(userId, instanceId) if fa: faRoleIds = rootInterface.getRoleIdsForFeatureAccess(str(fa.id)) for rid in faRoleIds: role = rootInterface.getRole(rid) if role and (role.roleLabel or "").lower().endswith("-admin"): isInstanceAdmin = True break if not isInstanceAdmin: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Instance admin role required to rename")) updated = featureInterface.updateFeatureInstance(instanceId, {"label": data.label.strip()}) if not updated: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=routeApiMsg("Failed to update instance")) return {"id": instanceId, "label": updated.label} except HTTPException: raise except Exception as e: logger.error(f"Error renaming feature instance {instanceId}: {e}") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) # ============================================================================= # Helper Functions # ============================================================================= def _hasMandateAdminRole(context: RequestContext) -> bool: """ Check if the user has mandate admin role in the current context. A user is mandate admin if they have the 'admin' role at mandate level. """ if context.isPlatformAdmin: return True if not context.roleIds: return False # Check if any of the user's roles is an admin role try: rootInterface = getRootInterface() for roleId in context.roleIds: role = rootInterface.getRole(roleId) if role: roleLabel = role.roleLabel # Admin role at mandate level (not feature-instance level) if roleLabel == "admin" and not role.featureInstanceId: return True return False except Exception as e: logger.error(f"Error checking mandate admin role: {e}") return False