Files
orion/app/services/menu_service.py
Samir Boulahtit 5be42c5907 feat: implement modular platform architecture (Phase 1)
Add module system for enabling/disabling feature bundles per platform.

Module System:
- ModuleDefinition dataclass for defining modules
- 12 modules: core, platform-admin, billing, inventory, orders,
  marketplace, customers, cms, analytics, messaging, dev-tools, monitoring
- Core modules (core, platform-admin) cannot be disabled
- Module dependencies (e.g., marketplace requires inventory)

MenuService Integration:
- Menu items filtered by module enablement
- MenuItemConfig includes is_module_enabled and module_code fields
- Module-disabled items hidden from sidebar

Platform Configuration:
- BasePlatformConfig.enabled_modules property
- OMS: all modules enabled (full commerce)
- Loyalty: focused subset (no billing/inventory/orders/marketplace)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 21:42:44 +01:00

812 lines
28 KiB
Python

# app/services/menu_service.py
"""
Menu service for platform-specific menu configuration.
Provides:
- Menu visibility checking based on platform/user configuration
- Module-based filtering (menu items only shown if module is enabled)
- Filtered menu rendering for frontends
- Menu configuration management (super admin only)
- Mandatory item enforcement
Menu Resolution Order:
1. Module enablement: Is the module providing this item enabled?
2. Visibility config: Is this item explicitly shown/hidden?
3. Mandatory status: Is this item mandatory (always visible)?
Usage:
from app.services.menu_service import menu_service
# Check if menu item is accessible
if menu_service.can_access_menu_item(db, FrontendType.ADMIN, "inventory", platform_id=1):
...
# Get filtered menu for rendering
menu = menu_service.get_menu_for_rendering(db, FrontendType.ADMIN, platform_id=1)
# Update menu visibility (super admin)
menu_service.update_menu_visibility(db, FrontendType.ADMIN, "inventory", False, platform_id=1)
"""
import logging
from copy import deepcopy
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.config.menu_registry import (
ADMIN_MENU_REGISTRY,
VENDOR_MENU_REGISTRY,
get_all_menu_item_ids,
get_menu_item,
is_super_admin_only_item,
)
from app.modules.service import module_service
from models.database.admin_menu_config import (
AdminMenuConfig,
FrontendType,
MANDATORY_MENU_ITEMS,
)
logger = logging.getLogger(__name__)
@dataclass
class MenuItemConfig:
"""Menu item configuration for admin UI."""
id: str
label: str
icon: str
url: str
section_id: str
section_label: str | None
is_visible: bool
is_mandatory: bool
is_super_admin_only: bool
is_module_enabled: bool = True # Whether the module providing this item is enabled
module_code: str | None = None # Module that provides this item
class MenuService:
"""
Service for menu visibility configuration and rendering.
Menu visibility is an opt-in model:
- All items are hidden by default (except mandatory)
- Database stores explicitly shown items (is_visible=True)
- Mandatory items are always visible and cannot be hidden
"""
# =========================================================================
# Menu Access Checking
# =========================================================================
def can_access_menu_item(
self,
db: Session,
frontend_type: FrontendType,
menu_item_id: str,
platform_id: int | None = None,
user_id: int | None = None,
) -> bool:
"""
Check if a menu item is accessible for a given scope.
Checks in order:
1. Menu item exists in registry
2. Module providing this item is enabled (if platform_id given)
3. Mandatory status
4. Visibility configuration
Args:
db: Database session
frontend_type: Which frontend (admin or vendor)
menu_item_id: Menu item identifier
platform_id: Platform ID (for platform admins and vendors)
user_id: User ID (for super admins only)
Returns:
True if menu item is visible/accessible
"""
# Validate menu item exists in registry
all_items = get_all_menu_item_ids(frontend_type)
if menu_item_id not in all_items:
logger.warning(f"Unknown menu item: {menu_item_id} for {frontend_type.value}")
return False
# Check module enablement if platform is specified
if platform_id:
if not module_service.is_menu_item_module_enabled(
db, platform_id, menu_item_id, frontend_type
):
return False
# Mandatory items are always accessible (if module is enabled)
if menu_item_id in MANDATORY_MENU_ITEMS.get(frontend_type, set()):
return True
# No scope specified - show all by default (fallback for unconfigured)
if not platform_id and not user_id:
return True
# Get visibility from database (opt-in: must be explicitly shown)
shown_items = self._get_shown_items(db, frontend_type, platform_id, user_id)
# If no configuration exists, show all items (first-time setup)
if shown_items is None:
return True
return menu_item_id in shown_items
def get_visible_menu_items(
self,
db: Session,
frontend_type: FrontendType,
platform_id: int | None = None,
user_id: int | None = None,
) -> set[str]:
"""
Get set of visible menu item IDs for a scope.
Filters by:
1. Module enablement (if platform_id given)
2. Visibility configuration
3. Mandatory status
Args:
db: Database session
frontend_type: Which frontend (admin or vendor)
platform_id: Platform ID (for platform admins and vendors)
user_id: User ID (for super admins only)
Returns:
Set of visible menu item IDs
"""
all_items = get_all_menu_item_ids(frontend_type)
mandatory_items = MANDATORY_MENU_ITEMS.get(frontend_type, set())
# Filter by module enablement if platform is specified
if platform_id:
module_available_items = module_service.get_module_menu_items(
db, platform_id, frontend_type
)
# Only keep items from enabled modules (or items not associated with any module)
all_items = module_service.filter_menu_items_by_modules(
db, platform_id, all_items, frontend_type
)
# Mandatory items from enabled modules only
mandatory_items = mandatory_items & all_items
# No scope specified - return all items (fallback)
if not platform_id and not user_id:
return all_items
shown_items = self._get_shown_items(db, frontend_type, platform_id, user_id)
# If no configuration exists yet, show all items (first-time setup)
if shown_items is None:
return all_items
# Shown items plus mandatory (mandatory are always visible)
# But only if module is enabled
visible = (shown_items | mandatory_items) & all_items
return visible
def _get_shown_items(
self,
db: Session,
frontend_type: FrontendType,
platform_id: int | None = None,
user_id: int | None = None,
) -> set[str] | None:
"""
Get set of shown menu item IDs from database.
Returns:
Set of shown item IDs, or None if no configuration exists.
"""
query = db.query(AdminMenuConfig).filter(
AdminMenuConfig.frontend_type == frontend_type,
)
if platform_id:
query = query.filter(AdminMenuConfig.platform_id == platform_id)
elif user_id:
query = query.filter(AdminMenuConfig.user_id == user_id)
else:
return None
# Check if any config exists for this scope
configs = query.all()
if not configs:
return None # No config = use defaults (all visible)
# Return only items marked as visible
return {c.menu_item_id for c in configs if c.is_visible}
# =========================================================================
# Menu Rendering
# =========================================================================
def get_menu_for_rendering(
self,
db: Session,
frontend_type: FrontendType,
platform_id: int | None = None,
user_id: int | None = None,
is_super_admin: bool = False,
) -> dict:
"""
Get filtered menu structure for frontend rendering.
Filters by:
1. Module enablement (items from disabled modules are removed)
2. Visibility configuration
3. Super admin status
Args:
db: Database session
frontend_type: Which frontend (admin or vendor)
platform_id: Platform ID (for platform admins and vendors)
user_id: User ID (for super admins only)
is_super_admin: Whether user is super admin (affects admin-only sections)
Returns:
Filtered menu structure ready for rendering
"""
registry = (
ADMIN_MENU_REGISTRY if frontend_type == FrontendType.ADMIN else VENDOR_MENU_REGISTRY
)
visible_items = self.get_visible_menu_items(db, frontend_type, platform_id, user_id)
# Deep copy to avoid modifying the registry
filtered_menu = deepcopy(registry)
filtered_sections = []
for section in filtered_menu["sections"]:
# Skip super_admin_only sections if user is not super admin
if section.get("super_admin_only") and not is_super_admin:
continue
# Filter items to only visible ones
# Also skip super_admin_only items if user is not super admin
filtered_items = [
item for item in section["items"]
if item["id"] in visible_items
and (not item.get("super_admin_only") or is_super_admin)
]
# Only include section if it has visible items
if filtered_items:
section["items"] = filtered_items
filtered_sections.append(section)
filtered_menu["sections"] = filtered_sections
return filtered_menu
# =========================================================================
# Menu Configuration (Super Admin)
# =========================================================================
def get_platform_menu_config(
self,
db: Session,
frontend_type: FrontendType,
platform_id: int,
) -> list[MenuItemConfig]:
"""
Get full menu configuration for a platform (for admin UI).
Returns all menu items with their visibility status and module info.
Items from disabled modules are marked with is_module_enabled=False.
Args:
db: Database session
frontend_type: Which frontend (admin or vendor)
platform_id: Platform ID
Returns:
List of MenuItemConfig with current visibility state and module info
"""
from app.modules.registry import get_menu_item_module
registry = (
ADMIN_MENU_REGISTRY if frontend_type == FrontendType.ADMIN else VENDOR_MENU_REGISTRY
)
shown_items = self._get_shown_items(db, frontend_type, platform_id=platform_id)
mandatory_items = MANDATORY_MENU_ITEMS.get(frontend_type, set())
# Get module-available items
module_available_items = module_service.filter_menu_items_by_modules(
db, platform_id, get_all_menu_item_ids(frontend_type), frontend_type
)
result = []
for section in registry["sections"]:
section_id = section["id"]
section_label = section.get("label")
is_super_admin_section = section.get("super_admin_only", False)
for item in section["items"]:
item_id = item["id"]
# Check if module is enabled for this item
is_module_enabled = item_id in module_available_items
module_code = get_menu_item_module(item_id, frontend_type)
# If no config exists (shown_items is None), show all by default
# Otherwise, item is visible if in shown_items or mandatory
# Note: visibility config is independent of module enablement
is_visible = (
shown_items is None
or item_id in shown_items
or item_id in mandatory_items
)
# Item is super admin only if section or item is marked as such
is_item_super_admin_only = is_super_admin_section or item.get("super_admin_only", False)
result.append(
MenuItemConfig(
id=item_id,
label=item["label"],
icon=item["icon"],
url=item["url"],
section_id=section_id,
section_label=section_label,
is_visible=is_visible,
is_mandatory=item_id in mandatory_items,
is_super_admin_only=is_item_super_admin_only,
is_module_enabled=is_module_enabled,
module_code=module_code,
)
)
return result
def get_user_menu_config(
self,
db: Session,
user_id: int,
) -> list[MenuItemConfig]:
"""
Get admin menu configuration for a super admin user.
Super admins don't have platform context, so all modules are shown.
Module enablement is always True for super admin menu config.
Args:
db: Database session
user_id: Super admin user ID
Returns:
List of MenuItemConfig with current visibility state
"""
from app.modules.registry import get_menu_item_module
shown_items = self._get_shown_items(
db, FrontendType.ADMIN, user_id=user_id
)
mandatory_items = MANDATORY_MENU_ITEMS.get(FrontendType.ADMIN, set())
result = []
for section in ADMIN_MENU_REGISTRY["sections"]:
section_id = section["id"]
section_label = section.get("label")
is_super_admin_section = section.get("super_admin_only", False)
for item in section["items"]:
item_id = item["id"]
module_code = get_menu_item_module(item_id, FrontendType.ADMIN)
# If no config exists (shown_items is None), show all by default
# Otherwise, item is visible if in shown_items or mandatory
is_visible = (
shown_items is None
or item_id in shown_items
or item_id in mandatory_items
)
# Item is super admin only if section or item is marked as such
is_item_super_admin_only = is_super_admin_section or item.get("super_admin_only", False)
result.append(
MenuItemConfig(
id=item_id,
label=item["label"],
icon=item["icon"],
url=item["url"],
section_id=section_id,
section_label=section_label,
is_visible=is_visible,
is_mandatory=item_id in mandatory_items,
is_super_admin_only=is_item_super_admin_only,
is_module_enabled=True, # Super admins see all modules
module_code=module_code,
)
)
return result
def update_menu_visibility(
self,
db: Session,
frontend_type: FrontendType,
menu_item_id: str,
is_visible: bool,
platform_id: int | None = None,
user_id: int | None = None,
) -> None:
"""
Update visibility for a menu item (opt-in model).
In the opt-in model:
- is_visible=True: Create/update record to show item
- is_visible=False: Remove record (item hidden by default)
Args:
db: Database session
frontend_type: Which frontend (admin or vendor)
menu_item_id: Menu item identifier
is_visible: Whether the item should be visible
platform_id: Platform ID (for platform-scoped config)
user_id: User ID (for user-scoped config, admin frontend only)
Raises:
ValueError: If menu item is mandatory or doesn't exist
ValueError: If neither platform_id nor user_id is provided
ValueError: If user_id is provided for vendor frontend
"""
# Validate menu item exists
all_items = get_all_menu_item_ids(frontend_type)
if menu_item_id not in all_items:
raise ValueError(f"Unknown menu item: {menu_item_id}")
# Check if mandatory - mandatory items are always visible, no need to store
mandatory = MANDATORY_MENU_ITEMS.get(frontend_type, set())
if menu_item_id in mandatory:
if not is_visible:
raise ValueError(f"Cannot hide mandatory menu item: {menu_item_id}")
# Mandatory items don't need explicit config, they're always visible
return
# Validate scope
if not platform_id and not user_id:
raise ValueError("Either platform_id or user_id must be provided")
if user_id and frontend_type == FrontendType.VENDOR:
raise ValueError("User-scoped config not supported for vendor frontend")
# Find existing config
query = db.query(AdminMenuConfig).filter(
AdminMenuConfig.frontend_type == frontend_type,
AdminMenuConfig.menu_item_id == menu_item_id,
)
if platform_id:
query = query.filter(AdminMenuConfig.platform_id == platform_id)
else:
query = query.filter(AdminMenuConfig.user_id == user_id)
config = query.first()
if is_visible:
# Opt-in: Create or update config to visible (explicitly show)
if config:
config.is_visible = True
else:
config = AdminMenuConfig(
frontend_type=frontend_type,
platform_id=platform_id,
user_id=user_id,
menu_item_id=menu_item_id,
is_visible=True,
)
db.add(config)
logger.info(
f"Set menu config visible: {frontend_type.value}/{menu_item_id} "
f"(platform_id={platform_id}, user_id={user_id})"
)
else:
# Opt-in: Remove config to hide (hidden is default)
if config:
db.delete(config)
logger.info(
f"Removed menu config (hidden): {frontend_type.value}/{menu_item_id} "
f"(platform_id={platform_id}, user_id={user_id})"
)
def bulk_update_menu_visibility(
self,
db: Session,
frontend_type: FrontendType,
visibility_map: dict[str, bool],
platform_id: int | None = None,
user_id: int | None = None,
) -> None:
"""
Update visibility for multiple menu items at once.
Args:
db: Database session
frontend_type: Which frontend (admin or vendor)
visibility_map: Dict of menu_item_id -> is_visible
platform_id: Platform ID (for platform-scoped config)
user_id: User ID (for user-scoped config, admin frontend only)
"""
for menu_item_id, is_visible in visibility_map.items():
try:
self.update_menu_visibility(
db, frontend_type, menu_item_id, is_visible, platform_id, user_id
)
except ValueError as e:
logger.warning(f"Skipping {menu_item_id}: {e}")
def reset_platform_menu_config(
self,
db: Session,
frontend_type: FrontendType,
platform_id: int,
) -> None:
"""
Reset menu configuration for a platform to defaults (all hidden except mandatory).
In opt-in model, reset means hide everything so user can opt-in to what they want.
Args:
db: Database session
frontend_type: Which frontend (admin or vendor)
platform_id: Platform ID
"""
# Delete all existing records
deleted = (
db.query(AdminMenuConfig)
.filter(
AdminMenuConfig.frontend_type == frontend_type,
AdminMenuConfig.platform_id == platform_id,
)
.delete()
)
logger.info(
f"Reset menu config for platform {platform_id} ({frontend_type.value}): "
f"deleted {deleted} rows"
)
# Create records with is_visible=False for all non-mandatory items
# This makes "reset" mean "hide everything except mandatory"
all_items = get_all_menu_item_ids(frontend_type)
mandatory_items = MANDATORY_MENU_ITEMS.get(frontend_type, set())
for item_id in all_items:
if item_id not in mandatory_items:
config = AdminMenuConfig(
frontend_type=frontend_type,
platform_id=platform_id,
user_id=None,
menu_item_id=item_id,
is_visible=False,
)
db.add(config)
logger.info(
f"Created {len(all_items) - len(mandatory_items)} hidden records for platform {platform_id}"
)
def reset_user_menu_config(
self,
db: Session,
user_id: int,
) -> None:
"""
Reset menu configuration for a super admin user to defaults (all hidden except mandatory).
In opt-in model, reset means hide everything so user can opt-in to what they want.
Args:
db: Database session
user_id: Super admin user ID
"""
# Delete all existing records
deleted = (
db.query(AdminMenuConfig)
.filter(
AdminMenuConfig.frontend_type == FrontendType.ADMIN,
AdminMenuConfig.user_id == user_id,
)
.delete()
)
logger.info(f"Reset menu config for user {user_id}: deleted {deleted} rows")
# Create records with is_visible=False for all non-mandatory items
all_items = get_all_menu_item_ids(FrontendType.ADMIN)
mandatory_items = MANDATORY_MENU_ITEMS.get(FrontendType.ADMIN, set())
for item_id in all_items:
if item_id not in mandatory_items:
config = AdminMenuConfig(
frontend_type=FrontendType.ADMIN,
platform_id=None,
user_id=user_id,
menu_item_id=item_id,
is_visible=False,
)
db.add(config)
logger.info(
f"Created {len(all_items) - len(mandatory_items)} hidden records for user {user_id}"
)
def show_all_platform_menu_config(
self,
db: Session,
frontend_type: FrontendType,
platform_id: int,
) -> None:
"""
Show all menu items for a platform.
Args:
db: Database session
frontend_type: Which frontend (admin or vendor)
platform_id: Platform ID
"""
# Delete all existing records
deleted = (
db.query(AdminMenuConfig)
.filter(
AdminMenuConfig.frontend_type == frontend_type,
AdminMenuConfig.platform_id == platform_id,
)
.delete()
)
logger.info(
f"Show all menu config for platform {platform_id} ({frontend_type.value}): "
f"deleted {deleted} rows"
)
# Create records with is_visible=True for all non-mandatory items
all_items = get_all_menu_item_ids(frontend_type)
mandatory_items = MANDATORY_MENU_ITEMS.get(frontend_type, set())
for item_id in all_items:
if item_id not in mandatory_items:
config = AdminMenuConfig(
frontend_type=frontend_type,
platform_id=platform_id,
user_id=None,
menu_item_id=item_id,
is_visible=True,
)
db.add(config)
logger.info(
f"Created {len(all_items) - len(mandatory_items)} visible records for platform {platform_id}"
)
def show_all_user_menu_config(
self,
db: Session,
user_id: int,
) -> None:
"""
Show all menu items for a super admin user.
Args:
db: Database session
user_id: Super admin user ID
"""
# Delete all existing records
deleted = (
db.query(AdminMenuConfig)
.filter(
AdminMenuConfig.frontend_type == FrontendType.ADMIN,
AdminMenuConfig.user_id == user_id,
)
.delete()
)
logger.info(f"Show all menu config for user {user_id}: deleted {deleted} rows")
# Create records with is_visible=True for all non-mandatory items
all_items = get_all_menu_item_ids(FrontendType.ADMIN)
mandatory_items = MANDATORY_MENU_ITEMS.get(FrontendType.ADMIN, set())
for item_id in all_items:
if item_id not in mandatory_items:
config = AdminMenuConfig(
frontend_type=FrontendType.ADMIN,
platform_id=None,
user_id=user_id,
menu_item_id=item_id,
is_visible=True,
)
db.add(config)
logger.info(
f"Created {len(all_items) - len(mandatory_items)} visible records for user {user_id}"
)
def initialize_menu_config(
self,
db: Session,
frontend_type: FrontendType,
platform_id: int | None = None,
user_id: int | None = None,
) -> bool:
"""
Initialize menu configuration with all items visible.
Called when first customizing a menu. Creates records for all items
so the user can then toggle individual items off.
Args:
db: Database session
frontend_type: Which frontend (admin or vendor)
platform_id: Platform ID (for platform-scoped config)
user_id: User ID (for user-scoped config)
Returns:
True if initialized, False if config already exists with visible items
"""
if not platform_id and not user_id:
return False # No scope specified
# Helper to build a fresh query for this scope
def scope_query():
q = db.query(AdminMenuConfig).filter(
AdminMenuConfig.frontend_type == frontend_type,
)
if platform_id:
return q.filter(AdminMenuConfig.platform_id == platform_id)
else:
return q.filter(AdminMenuConfig.user_id == user_id)
# Check if any visible records exist (valid opt-in config)
visible_count = scope_query().filter(
AdminMenuConfig.is_visible == True # noqa: E712
).count()
if visible_count > 0:
logger.debug(f"Config already exists with {visible_count} visible items, skipping init")
return False # Already initialized
# Check if ANY records exist (even is_visible=False from old opt-out model)
total_count = scope_query().count()
if total_count > 0:
# Clean up old records first
deleted = scope_query().delete(synchronize_session='fetch')
db.flush() # Ensure deletes are applied before inserts
logger.info(f"Cleaned up {deleted} old menu config records before initialization")
# Get all menu items for this frontend
all_items = get_all_menu_item_ids(frontend_type)
mandatory_items = MANDATORY_MENU_ITEMS.get(frontend_type, set())
# Create visible records for all non-mandatory items
for item_id in all_items:
if item_id not in mandatory_items:
config = AdminMenuConfig(
frontend_type=frontend_type,
platform_id=platform_id,
user_id=user_id,
menu_item_id=item_id,
is_visible=True,
)
db.add(config)
logger.info(
f"Initialized menu config with {len(all_items) - len(mandatory_items)} items "
f"(platform_id={platform_id}, user_id={user_id})"
)
return True
# Singleton instance
menu_service = MenuService()
__all__ = [
"menu_service",
"MenuService",
"MenuItemConfig",
]