Files
orion/app/modules/service.py
Samir Boulahtit 7fefab1508 feat: add detailed logging for module loading and context providers
Add INFO-level logging to help diagnose module loading issues:

- discovery.py: Log summary of discovered modules by tier (core/optional/internal)
- service.py: Log which modules are enabled for each platform (DEBUG level)
- page_context.py: Log context building with platform info and which
  modules contributed context with key counts

Example log output:
  [MODULES] Auto-discovered 18 modules: 5 core, 11 optional, 2 internal
  [CONTEXT] Building PLATFORM context for platform 'main' with 5 enabled modules
  [CONTEXT] Context providers called: cms(3 keys), billing(3 keys)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 19:32:32 +01:00

797 lines
25 KiB
Python

# 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 app.modules.enums import FrontendType
from app.modules.tenancy.models import Platform
from app.modules.tenancy.models 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)
logger.debug(
f"[MODULES] Platform '{platform.code}' (id={platform_id}) has "
f"{len(enabled_set)} modules enabled: {sorted(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",
]