# 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", ]