# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ Feature Registry for Plug&Play Feature Container Loading. Dynamically discovers and loads feature containers from the features directory. Note: This module is in modules/system/ but manages modules/features/. """ import os import glob import importlib import logging from typing import List, Dict, Any from fastapi import FastAPI logger = logging.getLogger(__name__) # Path to the features directory (relative to this file's location) FEATURES_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "features") def discoverFeatureContainers() -> List[str]: """ Discover all feature container directories by filename pattern. A valid feature container has a routeFeature*.py file. """ containers = [] pattern = os.path.join(FEATURES_DIR, "*", "routeFeature*.py") for filepath in glob.glob(pattern): featureDir = os.path.basename(os.path.dirname(filepath)) if featureDir not in containers and not featureDir.startswith("_"): containers.append(featureDir) return sorted(containers) def loadFeatureRouters(app: FastAPI) -> Dict[str, Any]: """ Dynamically load and register routers from all discovered feature containers. Also registers feature template roles and AccessRules in the database. Searches for: - 'router' (main feature router) - 'templateRouter' (additional router for templates) - Any other *Router named exports """ results = {} pattern = os.path.join(FEATURES_DIR, "*", "routeFeature*.py") for filepath in glob.glob(pattern): featureDir = os.path.basename(os.path.dirname(filepath)) routerFile = os.path.basename(filepath)[:-3] # Remove .py if featureDir.startswith("_"): continue try: modulePath = f"modules.features.{featureDir}.{routerFile}" module = importlib.import_module(modulePath) loadedRouters = [] # Load main router if hasattr(module, "router"): app.include_router(module.router) loadedRouters.append("router") # Load additional named routers (e.g., templateRouter) for attrName in dir(module): if attrName.endswith("Router") and attrName != "APIRouter": routerObj = getattr(module, attrName) if hasattr(routerObj, "routes"): # Check if it's a FastAPI router app.include_router(routerObj) loadedRouters.append(attrName) if loadedRouters: logger.info(f"Loaded routers from {featureDir}: {loadedRouters}") results[featureDir] = {"status": "loaded", "module": modulePath, "routers": loadedRouters} else: logger.warning(f"No routers found in {modulePath}") results[featureDir] = {"status": "no_router_object"} except Exception as e: logger.error(f"Failed to load router from {featureDir}: {e}") results[featureDir] = {"status": "error", "error": str(e)} return results _cachedMainModules = None def loadFeatureMainModules() -> Dict[str, Any]: """ Dynamically load main modules from all discovered feature containers. Results are cached after the first call. """ global _cachedMainModules if _cachedMainModules is not None: return _cachedMainModules mainModules = {} pattern = os.path.join(FEATURES_DIR, "*", "main*.py") for filepath in glob.glob(pattern): filename = os.path.basename(filepath) if filename == "__init__.py": continue featureDir = os.path.basename(os.path.dirname(filepath)) if featureDir.startswith("_"): continue # Skip if this feature already has a main module loaded (avoid duplicates) if featureDir in mainModules: continue mainFile = filename[:-3] # Remove .py try: modulePath = f"modules.features.{featureDir}.{mainFile}" module = importlib.import_module(modulePath) mainModules[featureDir] = module logger.debug(f"Loaded main module: {featureDir}") except Exception as e: logger.error(f"Failed to load main module from {featureDir}: {e}") _cachedMainModules = mainModules return mainModules def registerAllFeaturesInCatalog(catalogService) -> Dict[str, bool]: """ Register all features' RBAC objects in the catalog. Also registers system-level RBAC objects and feature definitions. """ results = {} # Register system-level RBAC objects first try: from modules.system.mainSystem import registerFeature as registerSystemFeature success = registerSystemFeature(catalogService) results["system"] = success if success: logger.info("Registered RBAC objects: system") except ImportError as e: logger.warning(f"System module not found, skipping system RBAC registration: {e}") except Exception as e: logger.error(f"Error registering system RBAC objects: {e}") results["system"] = False # Register service center RBAC objects (service.web, service.extraction, etc.) try: from modules.serviceCenter import registerServiceObjects success = registerServiceObjects(catalogService) results["servicecenter"] = success except ImportError as e: logger.warning(f"Service center not found, skipping service RBAC registration: {e}") except Exception as e: logger.error(f"Error registering service RBAC objects: {e}") results["servicecenter"] = False # Register feature modules mainModules = loadFeatureMainModules() for featureName, module in mainModules.items(): # Register feature definition in catalog (for /api/features/ endpoint) if hasattr(module, "getFeatureDefinition"): try: featureDef = module.getFeatureDefinition() catalogService.registerFeatureDefinition( featureCode=featureDef.get("code", featureName), label=featureDef.get("label", {"en": featureName, "de": featureName}), icon=featureDef.get("icon", "mdi-puzzle"), instantiable=featureDef.get("instantiable", True), enabled=featureDef.get("enabled", True), ) logger.info(f"Registered feature definition: {featureDef.get('code', featureName)}") except Exception as e: logger.error(f"Error registering feature definition for {featureName}: {e}") # Register RBAC objects (UI, RESOURCE, DATA) if hasattr(module, "registerFeature"): try: success = module.registerFeature(catalogService) results[featureName] = success if success: logger.info(f"Registered RBAC objects: {featureName}") except Exception as e: logger.error(f"Error registering {featureName}: {e}") results[featureName] = False return results def syncCatalogFeaturesToDb(catalogService) -> Dict[str, str]: """Persist all in-memory feature definitions into the ``Feature`` DB table. PowerOn discovers Features as Python modules at boot time and registers them only in the in-memory ``RbacCatalog._featureDefinitions`` dict (see ``registerAllFeaturesInCatalog`` above). However, the ``FeatureInstance`` Pydantic model declares ``featureCode`` as an FK into the ``Feature`` DB table. If the ``Feature`` table is not kept in sync with the code-side registry, every ``FeatureInstance`` row appears as a foreign-key orphan in the SysAdmin DB-health scan -- even though the feature very much exists at runtime. This function bridges that gap: after the catalog has been built it walks every registered feature definition and idempotently upserts it into the ``Feature`` table (insert-if-missing, update label/icon if drifted). Returns: Dict ``{featureCode: action}`` with action in ``{"created", "updated", "unchanged", "error"}``. """ actions: Dict[str, str] = {} try: from modules.security.rootAccess import getRootDbAppConnector from modules.interfaces.interfaceFeatures import getFeatureInterface except Exception as e: logger.error(f"syncCatalogFeaturesToDb: dependency import failed: {e}") return actions try: rootDb = getRootDbAppConnector() featuresIf = getFeatureInterface(rootDb) except Exception as e: logger.error(f"syncCatalogFeaturesToDb: cannot obtain feature interface: {e}") return actions try: definitions = catalogService.getFeatureDefinitions() or [] except Exception as e: logger.error(f"syncCatalogFeaturesToDb: cannot list feature definitions: {e}") return actions for defn in definitions: code = (defn or {}).get("code") if not code: continue try: action = featuresIf.upsertFeature( code=code, label=defn.get("label") or code, icon=defn.get("icon") or "", ) actions[code] = action except Exception as e: logger.error(f"syncCatalogFeaturesToDb: failed to upsert {code}: {e}") actions[code] = "error" created = sum(1 for v in actions.values() if v == "created") updated = sum(1 for v in actions.values() if v == "updated") errors = sum(1 for v in actions.values() if v == "error") logger.info( f"Feature DB sync: {len(actions)} definitions processed, " f"{created} created, {updated} updated, {errors} errors" ) return actions