# app/modules/service.py """ Module service for platform module operations. Provides methods to check module enablement, get enabled modules, and filter menu items based on module configuration. Module configuration is stored in the PlatformModule junction table, which provides auditability, per-module config, and explicit state tracking. """ import logging from datetime import UTC, datetime from sqlalchemy.orm import Session from app.modules.base import ModuleDefinition from app.modules.enums import FrontendType from app.modules.registry import ( MODULES, get_core_module_codes, get_menu_item_module, get_module, ) from app.modules.tenancy.models import Platform, PlatformModule logger = logging.getLogger(__name__) class ModuleService: """ Service for platform module operations. Handles module enablement checking, module listing, and menu item filtering based on enabled modules. Module configuration is stored in the PlatformModule junction table, which provides auditability, per-module config, and explicit state tracking. If no PlatformModule records exist for a platform, no optional modules are enabled (only core modules). Use seed scripts or the admin API to configure module enablement for each platform. Example PlatformModule records: PlatformModule(platform_id=1, module_code="billing", is_enabled=True, config={"stripe_mode": "live"}) PlatformModule(platform_id=1, module_code="inventory", is_enabled=True, config={"low_stock_threshold": 10}) """ # ========================================================================= # Module Enablement # ========================================================================= def get_platform_modules( self, db: Session, platform_id: int, ) -> list[ModuleDefinition]: """ Get all enabled modules for a platform. Args: db: Database session platform_id: Platform ID Returns: List of enabled ModuleDefinition objects (always includes core) """ enabled_codes = self._get_enabled_module_codes(db, platform_id) return [MODULES[code] for code in enabled_codes if code in MODULES] def get_enabled_module_codes( self, db: Session, platform_id: int, ) -> set[str]: """ Get set of enabled module codes for a platform. Args: db: Database session platform_id: Platform ID Returns: Set of enabled module codes (always includes core modules) """ return self._get_enabled_module_codes(db, platform_id) def is_module_enabled( self, db: Session, platform_id: int, module_code: str, ) -> bool: """ Check if a specific module is enabled for a platform. Core modules are always enabled. Args: db: Database session platform_id: Platform ID module_code: Module code to check Returns: True if module is enabled """ # Core modules are always enabled if module_code in get_core_module_codes(): return True enabled_codes = self._get_enabled_module_codes(db, platform_id) return module_code in enabled_codes def _get_enabled_module_codes( self, db: Session, platform_id: int, ) -> set[str]: """ Get enabled module codes for a platform. Uses the PlatformModule junction table exclusively. If no records exist, returns only core modules (empty set of optional modules). Always includes core modules. Args: db: Database session platform_id: Platform ID Returns: Set of enabled module codes """ platform = db.query(Platform).filter(Platform.id == platform_id).first() if not platform: logger.warning(f"Platform {platform_id} not found, returning core modules only") return get_core_module_codes() # Query junction table for enabled modules platform_modules = ( db.query(PlatformModule) .filter(PlatformModule.platform_id == platform_id) .all() ) enabled_set = {pm.module_code for pm in platform_modules if pm.is_enabled} # Always include core modules core_codes = get_core_module_codes() enabled_set = enabled_set | core_codes # Resolve dependencies - add required modules enabled_set = self._resolve_dependencies(enabled_set) logger.debug( f"[MODULES] Platform '{platform.code}' (id={platform_id}) has " f"{len(enabled_set)} modules enabled: {sorted(enabled_set)}" ) return enabled_set def _resolve_dependencies(self, enabled_codes: set[str]) -> set[str]: """ Resolve module dependencies by adding required modules. If module A requires module B, and A is enabled, B must also be enabled. Args: enabled_codes: Set of explicitly enabled module codes Returns: Set of enabled module codes including dependencies """ resolved = set(enabled_codes) changed = True while changed: changed = False for code in list(resolved): module = get_module(code) if module: for required in module.requires: if required not in resolved: resolved.add(required) changed = True logger.debug( f"Module '{code}' requires '{required}', auto-enabling" ) return resolved # ========================================================================= # Menu Item Filtering # ========================================================================= def get_module_menu_items( self, db: Session, platform_id: int, frontend_type: FrontendType, ) -> set[str]: """ Get all menu item IDs available for enabled modules. Args: db: Database session platform_id: Platform ID frontend_type: Which frontend (admin or store) Returns: Set of menu item IDs from enabled modules """ enabled_modules = self.get_platform_modules(db, platform_id) menu_items = set() for module in enabled_modules: menu_items.update(module.get_menu_items(frontend_type)) return menu_items def is_menu_item_module_enabled( self, db: Session, platform_id: int, menu_item_id: str, frontend_type: FrontendType, ) -> bool: """ Check if the module providing a menu item is enabled. Args: db: Database session platform_id: Platform ID menu_item_id: Menu item ID frontend_type: Which frontend (admin or store) Returns: True if the module providing this menu item is enabled, or if menu item is not associated with any module. """ module_code = get_menu_item_module(menu_item_id, frontend_type) # If menu item isn't associated with any module, allow it if module_code is None: return True return self.is_module_enabled(db, platform_id, module_code) def filter_menu_items_by_modules( self, db: Session, platform_id: int, menu_item_ids: set[str], frontend_type: FrontendType, ) -> set[str]: """ Filter menu items to only those from enabled modules. Args: db: Database session platform_id: Platform ID menu_item_ids: Set of menu item IDs to filter frontend_type: Which frontend (admin or store) Returns: Filtered set of menu item IDs """ available_items = self.get_module_menu_items(db, platform_id, frontend_type) # Items that are in available_items, OR items not associated with any module filtered = set() for item_id in menu_item_ids: module_code = get_menu_item_module(item_id, frontend_type) if module_code is None or item_id in available_items: filtered.add(item_id) return filtered # ========================================================================= # Module Configuration # ========================================================================= def get_module_config( self, db: Session, platform_id: int, module_code: str, ) -> dict: """ Get module-specific configuration for a platform. Uses the PlatformModule junction table for configuration storage. Args: db: Database session platform_id: Platform ID module_code: Module code Returns: Module configuration dict (empty if not configured) """ platform_module = ( db.query(PlatformModule) .filter( PlatformModule.platform_id == platform_id, PlatformModule.module_code == module_code, ) .first() ) if platform_module: return platform_module.config or {} return {} def set_module_config( self, db: Session, platform_id: int, module_code: str, config: dict, ) -> bool: """ Set module-specific configuration for a platform. Uses junction table for persistence. Creates record if doesn't exist. Args: db: Database session platform_id: Platform ID module_code: Module code config: Configuration dict to set Returns: True if successful """ if module_code not in MODULES: logger.error(f"Unknown module: {module_code}") return False platform = db.query(Platform).filter(Platform.id == platform_id).first() if not platform: logger.error(f"Platform {platform_id} not found") return False # Get or create junction table record platform_module = ( db.query(PlatformModule) .filter( PlatformModule.platform_id == platform_id, PlatformModule.module_code == module_code, ) .first() ) if platform_module: platform_module.config = config else: # Create new record with config platform_module = PlatformModule( platform_id=platform_id, module_code=module_code, is_enabled=True, # Default to enabled config=config, ) db.add(platform_module) logger.info(f"Updated config for module '{module_code}' on platform {platform_id}") return True def set_enabled_modules( self, db: Session, platform_id: int, module_codes: list[str], user_id: int | None = None, ) -> bool: """ Set the enabled modules for a platform. Core modules are automatically included. Dependencies are automatically resolved. Uses junction table for auditability. Args: db: Database session platform_id: Platform ID module_codes: List of module codes to enable user_id: ID of user making the change (for audit) Returns: True if successful, False if platform not found """ platform = db.query(Platform).filter(Platform.id == platform_id).first() if not platform: logger.error(f"Platform {platform_id} not found") return False # Validate module codes valid_codes = set(MODULES.keys()) invalid = [code for code in module_codes if code not in valid_codes] if invalid: logger.warning(f"Invalid module codes ignored: {invalid}") module_codes = [code for code in module_codes if code in valid_codes] # Always include core modules core_codes = get_core_module_codes() enabled_set = set(module_codes) | core_codes # Resolve dependencies enabled_set = self._resolve_dependencies(enabled_set) now = datetime.now(UTC) # Update junction table for all modules for code in MODULES: platform_module = ( db.query(PlatformModule) .filter( PlatformModule.platform_id == platform_id, PlatformModule.module_code == code, ) .first() ) should_enable = code in enabled_set if platform_module: # Update existing record if should_enable and not platform_module.is_enabled: platform_module.is_enabled = True platform_module.enabled_at = now platform_module.enabled_by_user_id = user_id elif not should_enable and platform_module.is_enabled: platform_module.is_enabled = False platform_module.disabled_at = now platform_module.disabled_by_user_id = user_id else: # Create new record platform_module = PlatformModule( platform_id=platform_id, module_code=code, is_enabled=should_enable, enabled_at=now if should_enable else None, enabled_by_user_id=user_id if should_enable else None, disabled_at=None if should_enable else now, disabled_by_user_id=None if should_enable else user_id, config={}, ) db.add(platform_module) # noqa: PERF006 logger.info( f"Updated enabled modules for platform {platform_id}: {sorted(enabled_set)}" ) return True def enable_module( self, db: Session, platform_id: int, module_code: str, user_id: int | None = None, ) -> bool: """ Enable a single module for a platform. Also enables required dependencies. Uses the PlatformModule junction table for auditability. Args: db: Database session platform_id: Platform ID module_code: Module code to enable user_id: ID of user enabling the module (for audit) Returns: True if successful """ if module_code not in MODULES: logger.error(f"Unknown module: {module_code}") return False platform = db.query(Platform).filter(Platform.id == platform_id).first() if not platform: logger.error(f"Platform {platform_id} not found") return False now = datetime.now(UTC) # Enable this module and its dependencies modules_to_enable = {module_code} module = get_module(module_code) if module: for required in module.requires: modules_to_enable.add(required) for code in modules_to_enable: # Check if junction table record exists platform_module = ( db.query(PlatformModule) .filter( PlatformModule.platform_id == platform_id, PlatformModule.module_code == code, ) .first() ) if platform_module: # Update existing record platform_module.is_enabled = True platform_module.enabled_at = now platform_module.enabled_by_user_id = user_id else: # Create new record platform_module = PlatformModule( platform_id=platform_id, module_code=code, is_enabled=True, enabled_at=now, enabled_by_user_id=user_id, config={}, ) db.add(platform_module) # noqa: PERF006 logger.info(f"Enabled module '{module_code}' for platform {platform_id}") return True def disable_module( self, db: Session, platform_id: int, module_code: str, user_id: int | None = None, ) -> bool: """ Disable a single module for a platform. Core modules cannot be disabled. Also disables modules that depend on this one. Uses the PlatformModule junction table for auditability. Args: db: Database session platform_id: Platform ID module_code: Module code to disable user_id: ID of user disabling the module (for audit) Returns: True if successful, False if core or not found """ if module_code not in MODULES: logger.error(f"Unknown module: {module_code}") return False module = MODULES[module_code] if module.is_core: logger.warning(f"Cannot disable core module: {module_code}") return False platform = db.query(Platform).filter(Platform.id == platform_id).first() if not platform: logger.error(f"Platform {platform_id} not found") return False now = datetime.now(UTC) # Get modules to disable (this one + dependents) modules_to_disable = {module_code} dependents = self._get_dependent_modules(module_code) modules_to_disable.update(dependents) for code in modules_to_disable: # Check if junction table record exists platform_module = ( db.query(PlatformModule) .filter( PlatformModule.platform_id == platform_id, PlatformModule.module_code == code, ) .first() ) if platform_module: # Update existing record platform_module.is_enabled = False platform_module.disabled_at = now platform_module.disabled_by_user_id = user_id else: # Create disabled record for tracking platform_module = PlatformModule( platform_id=platform_id, module_code=code, is_enabled=False, disabled_at=now, disabled_by_user_id=user_id, config={}, ) db.add(platform_module) # noqa: PERF006 if code != module_code: logger.info( f"Also disabled '{code}' (depends on '{module_code}')" ) logger.info(f"Disabled module '{module_code}' for platform {platform_id}") return True def _get_dependent_modules(self, module_code: str) -> set[str]: """ Get modules that depend on a given module. Args: module_code: Module code to find dependents for Returns: Set of module codes that require the given module """ dependents = set() for code, module in MODULES.items(): if module_code in module.requires: dependents.add(code) # Recursively find dependents of dependents dependents.update(self._get_dependent_modules(code)) return dependents # ========================================================================= # Platform Code Helpers # ========================================================================= def get_platform_modules_by_code( self, db: Session, platform_code: str, ) -> list[ModuleDefinition]: """ Get enabled modules for a platform by code. Args: db: Database session platform_code: Platform code (e.g., "oms", "loyalty") Returns: List of enabled ModuleDefinition objects """ platform = db.query(Platform).filter(Platform.code == platform_code).first() if not platform: logger.warning(f"Platform '{platform_code}' not found, returning core modules only") core_codes = get_core_module_codes() return [MODULES[code] for code in core_codes if code in MODULES] return self.get_platform_modules(db, platform.id) def is_module_enabled_by_code( self, db: Session, platform_code: str, module_code: str, ) -> bool: """ Check if a module is enabled for a platform by code. Args: db: Database session platform_code: Platform code (e.g., "oms", "loyalty") module_code: Module code to check Returns: True if module is enabled """ platform = db.query(Platform).filter(Platform.code == platform_code).first() if not platform: logger.warning(f"Platform '{platform_code}' not found, assuming enabled") return True return self.is_module_enabled(db, platform.id, module_code) # Singleton instance module_service = ModuleService() __all__ = [ "ModuleService", "module_service", ]