# 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 can be stored in two places: 1. PlatformModule junction table (preferred, auditable) 2. Platform.settings["enabled_modules"] (fallback, legacy) If neither is configured, all modules are enabled (backwards compatibility). """ import logging from datetime import datetime, timezone from functools import lru_cache from sqlalchemy.orm import Session from app.modules.base import ModuleDefinition from app.modules.registry import ( MODULES, get_core_module_codes, get_menu_item_module, get_module, ) from models.database.admin_menu_config import FrontendType from models.database.platform import Platform from models.database.platform_module import 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 two places (with fallback): 1. PlatformModule junction table (preferred, auditable) 2. Platform.settings["enabled_modules"] (legacy fallback) The service checks the junction table first. If no records exist, it falls back to the JSON settings for backwards compatibility. If neither is configured, all modules are enabled (backwards compatibility). 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}) Legacy Platform.settings (fallback): { "enabled_modules": ["core", "billing", "inventory", "orders"], "module_config": { "billing": {"stripe_mode": "live"}, "inventory": {"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. Checks two sources with fallback: 1. PlatformModule junction table (preferred, auditable) 2. Platform.settings["enabled_modules"] (legacy fallback) If neither is configured, returns all module codes (backwards compatibility). 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 all modules") return set(MODULES.keys()) # Try junction table first (preferred) platform_modules = ( db.query(PlatformModule) .filter(PlatformModule.platform_id == platform_id) .all() ) if platform_modules: # Use junction table data enabled_set = {pm.module_code for pm in platform_modules if pm.is_enabled} else: # Fallback to JSON settings (legacy) settings = platform.settings or {} enabled_modules = settings.get("enabled_modules") # If not configured, enable all modules (backwards compatibility) if enabled_modules is None: return set(MODULES.keys()) enabled_set = set(enabled_modules) # 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) return enabled_set def _migrate_json_to_junction_table( self, db: Session, platform_id: int, user_id: int | None = None, ) -> None: """ Migrate JSON settings to junction table records. Called when first creating a junction table record for a platform that previously used JSON settings. This ensures consistency when mixing junction table and JSON approaches. Args: db: Database session platform_id: Platform ID user_id: ID of user performing the migration (for audit) """ # Check if any junction table records exist existing_count = ( db.query(PlatformModule) .filter(PlatformModule.platform_id == platform_id) .count() ) if existing_count > 0: # Already using junction table return platform = db.query(Platform).filter(Platform.id == platform_id).first() if not platform: return settings = platform.settings or {} enabled_modules = settings.get("enabled_modules") if enabled_modules is None: # No JSON settings, start fresh with all modules enabled enabled_codes = set(MODULES.keys()) else: enabled_codes = set(enabled_modules) | get_core_module_codes() now = datetime.now(timezone.utc) # Create junction table records for all known modules for code in MODULES.keys(): is_enabled = code in enabled_codes pm = PlatformModule( platform_id=platform_id, module_code=code, is_enabled=is_enabled, enabled_at=now if is_enabled else None, enabled_by_user_id=user_id if is_enabled else None, disabled_at=None if is_enabled else now, disabled_by_user_id=None if is_enabled else user_id, config={}, ) db.add(pm) # Flush to ensure records are visible to subsequent queries db.flush() logger.info( f"Migrated platform {platform_id} from JSON settings to junction table" ) 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 vendor) 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 vendor) 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 vendor) 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. Checks two sources with fallback: 1. PlatformModule.config (preferred, auditable) 2. Platform.settings["module_config"] (legacy fallback) Args: db: Database session platform_id: Platform ID module_code: Module code Returns: Module configuration dict (empty if not configured) """ # Try junction table first (preferred) 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 {} # Fallback to JSON settings (legacy) platform = db.query(Platform).filter(Platform.id == platform_id).first() if not platform: return {} settings = platform.settings or {} module_configs = settings.get("module_config", {}) return module_configs.get(module_code, {}) 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(timezone.utc) # Update junction table for all modules for code in MODULES.keys(): 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) 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 junction table for auditability when available. 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 # Migrate JSON settings to junction table if needed self._migrate_json_to_junction_table(db, platform_id, user_id) now = datetime.now(timezone.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) 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 junction table for auditability when available. 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 # Migrate JSON settings to junction table if needed self._migrate_json_to_junction_table(db, platform_id, user_id) now = datetime.now(timezone.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) 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 all modules") return list(MODULES.values()) 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", ]