From 5be42c5907e2de805d6600a22cc701b88245950a Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Sun, 25 Jan 2026 21:42:44 +0100 Subject: [PATCH] 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 --- app/modules/__init__.py | 47 ++ app/modules/base.py | 112 ++++ app/modules/registry.py | 391 +++++++++++++ app/modules/service.py | 523 +++++++++++++++++ app/platforms/loyalty/config.py | 34 ++ app/platforms/oms/config.py | 35 ++ app/platforms/shared/base_platform.py | 32 + app/services/menu_service.py | 811 ++++++++++++++++++++++++++ 8 files changed, 1985 insertions(+) create mode 100644 app/modules/__init__.py create mode 100644 app/modules/base.py create mode 100644 app/modules/registry.py create mode 100644 app/modules/service.py create mode 100644 app/services/menu_service.py diff --git a/app/modules/__init__.py b/app/modules/__init__.py new file mode 100644 index 00000000..85b116af --- /dev/null +++ b/app/modules/__init__.py @@ -0,0 +1,47 @@ +# app/modules/__init__.py +""" +Modular Platform Architecture. + +This package provides a module system for enabling/disabling feature bundles per platform. + +Module Hierarchy: + Global (SaaS Provider) + └── Platform (Business Product - OMS, Loyalty, etc.) + └── Modules (Enabled features - Billing, Marketplace, Inventory, etc.) + ├── Routes (API + Page routes) + ├── Services (Business logic) + ├── Menu Items (Sidebar entries) + └── Templates (UI components) + +Modules vs Features: +- Features: Granular capabilities (e.g., analytics_dashboard, letzshop_sync) + Assigned to subscription tiers, gated at API route level. +- Modules: Cohesive feature bundles (e.g., billing, marketplace, inventory) + Enabled/disabled per platform, contains multiple features and menu items. + +Usage: + from app.modules import module_service + from app.modules.base import ModuleDefinition + from app.modules.registry import MODULES + + # Check if module is enabled for platform + if module_service.is_module_enabled(platform_id, "billing"): + ... + + # Get menu items for enabled modules + menu_items = module_service.get_module_menu_items(platform_id, FrontendType.ADMIN) + + # Get all enabled modules for platform + modules = module_service.get_platform_modules(platform_id) +""" + +from app.modules.base import ModuleDefinition +from app.modules.registry import MODULES +from app.modules.service import ModuleService, module_service + +__all__ = [ + "ModuleDefinition", + "MODULES", + "ModuleService", + "module_service", +] diff --git a/app/modules/base.py b/app/modules/base.py new file mode 100644 index 00000000..fc13c549 --- /dev/null +++ b/app/modules/base.py @@ -0,0 +1,112 @@ +# app/modules/base.py +""" +Base module definition class. + +A Module is a self-contained unit of functionality that can be enabled/disabled +per platform. Each module contains: +- Features: Granular capabilities for tier-based access control +- Menu items: Sidebar entries per frontend type +- Routes: API and page routes (future: dynamically registered) +""" + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from fastapi import APIRouter + +from models.database.admin_menu_config import FrontendType + + +@dataclass +class ModuleDefinition: + """ + Definition of a platform module. + + A module groups related functionality that can be enabled/disabled per platform. + Core modules cannot be disabled and are always available. + + Attributes: + code: Unique identifier (e.g., "billing", "marketplace") + name: Display name (e.g., "Billing & Subscriptions") + description: Description of what this module provides + requires: List of module codes this module depends on + features: List of feature codes this module provides + menu_items: Dict mapping FrontendType to list of menu item IDs + is_core: Core modules cannot be disabled + admin_router: FastAPI router for admin routes (future) + vendor_router: FastAPI router for vendor routes (future) + + Example: + billing_module = ModuleDefinition( + code="billing", + name="Billing & Subscriptions", + description="Subscription tiers, billing history, and payment processing", + features=["subscription_management", "billing_history", "stripe_integration"], + menu_items={ + FrontendType.ADMIN: ["subscription-tiers", "subscriptions", "billing-history"], + FrontendType.VENDOR: ["billing"], + }, + ) + """ + + # Identity + code: str + name: str + description: str = "" + + # Dependencies + requires: list[str] = field(default_factory=list) + + # Components + features: list[str] = field(default_factory=list) + menu_items: dict[FrontendType, list[str]] = field(default_factory=dict) + + # Status + is_core: bool = False + + # Routes (registered dynamically) - Future implementation + admin_router: "APIRouter | None" = None + vendor_router: "APIRouter | None" = None + + def get_menu_items(self, frontend_type: FrontendType) -> list[str]: + """Get menu item IDs for a specific frontend type.""" + return self.menu_items.get(frontend_type, []) + + def get_all_menu_items(self) -> set[str]: + """Get all menu item IDs across all frontend types.""" + all_items = set() + for items in self.menu_items.values(): + all_items.update(items) + return all_items + + def has_feature(self, feature_code: str) -> bool: + """Check if this module provides a specific feature.""" + return feature_code in self.features + + def has_menu_item(self, menu_item_id: str) -> bool: + """Check if this module provides a specific menu item.""" + return menu_item_id in self.get_all_menu_items() + + def check_dependencies(self, enabled_modules: set[str]) -> list[str]: + """ + Check if all required modules are enabled. + + Args: + enabled_modules: Set of enabled module codes + + Returns: + List of missing required module codes + """ + return [req for req in self.requires if req not in enabled_modules] + + def __hash__(self) -> int: + return hash(self.code) + + def __eq__(self, other: object) -> bool: + if isinstance(other, ModuleDefinition): + return self.code == other.code + return False + + def __repr__(self) -> str: + return f"" diff --git a/app/modules/registry.py b/app/modules/registry.py new file mode 100644 index 00000000..7275b565 --- /dev/null +++ b/app/modules/registry.py @@ -0,0 +1,391 @@ +# app/modules/registry.py +""" +Module registry defining all available platform modules. + +Each module bundles related features and menu items that can be +enabled/disabled per platform. Core modules cannot be disabled. + +Module Granularity (Medium - ~12 modules): +Matches menu sections for intuitive mapping between modules and UI. +""" + +from app.modules.base import ModuleDefinition +from models.database.admin_menu_config import FrontendType + + +# ============================================================================= +# Module Definitions +# ============================================================================= + +MODULES: dict[str, ModuleDefinition] = { + # ========================================================================= + # Core Modules (Always Enabled) + # ========================================================================= + "core": ModuleDefinition( + code="core", + name="Core Platform", + description="Dashboard, settings, and profile management. Required for basic operation.", + is_core=True, + features=[ + "dashboard", + "settings", + "profile", + ], + menu_items={ + FrontendType.ADMIN: [ + "dashboard", + "settings", + "email-templates", + "my-menu", + ], + FrontendType.VENDOR: [ + "dashboard", + "profile", + "settings", + "email-templates", + ], + }, + ), + "platform-admin": ModuleDefinition( + code="platform-admin", + name="Platform Administration", + description="Company, vendor, and admin user management. Required for multi-tenant operation.", + is_core=True, + features=[ + "company_management", + "vendor_management", + "admin_user_management", + "platform_management", + ], + menu_items={ + FrontendType.ADMIN: [ + "admin-users", + "companies", + "vendors", + "platforms", + ], + FrontendType.VENDOR: [ + "team", + ], + }, + ), + # ========================================================================= + # Optional Modules + # ========================================================================= + "billing": ModuleDefinition( + code="billing", + name="Billing & Subscriptions", + description="Subscription tiers, billing history, and payment processing.", + features=[ + "subscription_management", + "billing_history", + "stripe_integration", + "invoice_generation", + ], + menu_items={ + FrontendType.ADMIN: [ + "subscription-tiers", + "subscriptions", + "billing-history", + ], + FrontendType.VENDOR: [ + "billing", + "invoices", + ], + }, + ), + "inventory": ModuleDefinition( + code="inventory", + name="Inventory Management", + description="Stock levels, locations, and low stock alerts.", + features=[ + "inventory_basic", + "inventory_locations", + "low_stock_alerts", + "inventory_purchase_orders", + "product_management", + ], + menu_items={ + FrontendType.ADMIN: [ + "inventory", + "vendor-products", + ], + FrontendType.VENDOR: [ + "products", + "inventory", + ], + }, + ), + "orders": ModuleDefinition( + code="orders", + name="Order Management", + description="Order processing, fulfillment, and tracking.", + features=[ + "order_management", + "order_bulk_actions", + "order_export", + "automation_rules", + "fulfillment_tracking", + "shipping_management", + ], + menu_items={ + FrontendType.ADMIN: [ + "orders", + ], + FrontendType.VENDOR: [ + "orders", + ], + }, + ), + "marketplace": ModuleDefinition( + code="marketplace", + name="Marketplace (Letzshop)", + description="Letzshop integration for product sync and order import.", + requires=["inventory"], # Depends on inventory module + features=[ + "letzshop_sync", + "marketplace_import", + "product_sync", + ], + menu_items={ + FrontendType.ADMIN: [ + "marketplace-letzshop", + ], + FrontendType.VENDOR: [ + "marketplace", + "letzshop", + ], + }, + ), + "customers": ModuleDefinition( + code="customers", + name="Customer Management", + description="Customer database, profiles, and segmentation.", + features=[ + "customer_view", + "customer_export", + "customer_profiles", + "customer_segmentation", + ], + menu_items={ + FrontendType.ADMIN: [ + "customers", + ], + FrontendType.VENDOR: [ + "customers", + ], + }, + ), + "cms": ModuleDefinition( + code="cms", + name="Content Management", + description="Content pages, media library, and vendor themes.", + features=[ + "cms_basic", + "cms_custom_pages", + "cms_unlimited_pages", + "cms_templates", + "cms_seo", + "media_library", + ], + menu_items={ + FrontendType.ADMIN: [ + "content-pages", + "vendor-themes", + ], + FrontendType.VENDOR: [ + "content-pages", + "media", + ], + }, + ), + "analytics": ModuleDefinition( + code="analytics", + name="Analytics & Reporting", + description="Dashboard analytics, custom reports, and data exports.", + features=[ + "basic_reports", + "analytics_dashboard", + "custom_reports", + "export_reports", + ], + menu_items={ + FrontendType.ADMIN: [ + # Analytics appears in dashboard for admin + ], + FrontendType.VENDOR: [ + "analytics", + ], + }, + ), + "messaging": ModuleDefinition( + code="messaging", + name="Messaging & Notifications", + description="Internal messages, customer communication, and notifications.", + features=[ + "customer_messaging", + "internal_messages", + "notification_center", + ], + menu_items={ + FrontendType.ADMIN: [ + "messages", + "notifications", + ], + FrontendType.VENDOR: [ + "messages", + "notifications", + ], + }, + ), + "dev-tools": ModuleDefinition( + code="dev-tools", + name="Developer Tools", + description="Component library and icon browser for development.", + features=[ + "component_library", + "icon_browser", + ], + menu_items={ + FrontendType.ADMIN: [ + "components", + "icons", + ], + FrontendType.VENDOR: [], + }, + ), + "monitoring": ModuleDefinition( + code="monitoring", + name="Platform Monitoring", + description="Logs, background tasks, imports, and system health.", + features=[ + "application_logs", + "background_tasks", + "import_jobs", + "capacity_monitoring", + "testing_hub", + "code_quality", + ], + menu_items={ + FrontendType.ADMIN: [ + "imports", + "background-tasks", + "logs", + "platform-health", + "testing", + "code-quality", + ], + FrontendType.VENDOR: [], + }, + ), +} + + +# ============================================================================= +# Helper Functions +# ============================================================================= + + +def get_module(code: str) -> ModuleDefinition | None: + """Get a module definition by code.""" + return MODULES.get(code) + + +def get_core_modules() -> list[ModuleDefinition]: + """Get all core modules (cannot be disabled).""" + return [m for m in MODULES.values() if m.is_core] + + +def get_core_module_codes() -> set[str]: + """Get codes of all core modules.""" + return {m.code for m in MODULES.values() if m.is_core} + + +def get_optional_modules() -> list[ModuleDefinition]: + """Get all optional modules (can be enabled/disabled).""" + return [m for m in MODULES.values() if not m.is_core] + + +def get_all_module_codes() -> set[str]: + """Get all module codes.""" + return set(MODULES.keys()) + + +def get_menu_item_module(menu_item_id: str, frontend_type: FrontendType) -> str | None: + """ + Find which module provides a specific menu item. + + Args: + menu_item_id: The menu item ID to find + frontend_type: The frontend type to search in + + Returns: + Module code if found, None otherwise + """ + for module in MODULES.values(): + if menu_item_id in module.get_menu_items(frontend_type): + return module.code + return None + + +def get_feature_module(feature_code: str) -> str | None: + """ + Find which module provides a specific feature. + + Args: + feature_code: The feature code to find + + Returns: + Module code if found, None otherwise + """ + for module in MODULES.values(): + if module.has_feature(feature_code): + return module.code + return None + + +def validate_module_dependencies() -> list[str]: + """ + Validate that all module dependencies are valid. + + Returns: + List of error messages for invalid dependencies + """ + errors = [] + all_codes = get_all_module_codes() + + for module in MODULES.values(): + for required in module.requires: + if required not in all_codes: + errors.append( + f"Module '{module.code}' requires unknown module '{required}'" + ) + # Core modules should not depend on optional modules + if module.is_core and required not in get_core_module_codes(): + errors.append( + f"Core module '{module.code}' depends on optional module '{required}'" + ) + + return errors + + +# Validate dependencies on import (development check) +_validation_errors = validate_module_dependencies() +if _validation_errors: + import warnings + + for error in _validation_errors: + warnings.warn(f"Module registry validation: {error}", stacklevel=2) + + +__all__ = [ + "MODULES", + "get_module", + "get_core_modules", + "get_core_module_codes", + "get_optional_modules", + "get_all_module_codes", + "get_menu_item_module", + "get_feature_module", + "validate_module_dependencies", +] diff --git a/app/modules/service.py b/app/modules/service.py new file mode 100644 index 00000000..65c1c172 --- /dev/null +++ b/app/modules/service.py @@ -0,0 +1,523 @@ +# 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 Platform.settings["enabled_modules"]. +If not configured, all modules are enabled (backwards compatibility). +""" + +import logging +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 + +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 Platform.settings["enabled_modules"]: + - If key exists: Only listed modules (plus core) are enabled + - If key missing: All modules are enabled (backwards compatibility) + + Example Platform.settings: + { + "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 from platform settings. + + Internal method that reads Platform.settings["enabled_modules"]. + If not 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()) + + 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()) + + # Always include core modules + core_codes = get_core_module_codes() + enabled_set = set(enabled_modules) | core_codes + + # Resolve dependencies - add required modules + enabled_set = self._resolve_dependencies(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 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. + + Args: + db: Database session + platform_id: Platform ID + module_code: Module code + + Returns: + Module configuration dict (empty if not configured) + """ + 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_enabled_modules( + self, + db: Session, + platform_id: int, + module_codes: list[str], + ) -> bool: + """ + Set the enabled modules for a platform. + + Core modules are automatically included. + Dependencies are automatically resolved. + + Args: + db: Database session + platform_id: Platform ID + module_codes: List of module codes to enable + + 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) + + # Update platform settings + settings = platform.settings or {} + settings["enabled_modules"] = list(enabled_set) + platform.settings = settings + + 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, + ) -> bool: + """ + Enable a single module for a platform. + + Also enables required dependencies. + + Args: + db: Database session + platform_id: Platform ID + module_code: Module code to enable + + 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 + + settings = platform.settings or {} + enabled = set(settings.get("enabled_modules", list(MODULES.keys()))) + enabled.add(module_code) + + # Resolve dependencies + enabled = self._resolve_dependencies(enabled) + + settings["enabled_modules"] = list(enabled) + platform.settings = settings + + 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, + ) -> bool: + """ + Disable a single module for a platform. + + Core modules cannot be disabled. + Also disables modules that depend on this one. + + Args: + db: Database session + platform_id: Platform ID + module_code: Module code to disable + + 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 + + settings = platform.settings or {} + enabled = set(settings.get("enabled_modules", list(MODULES.keys()))) + + # Remove this module + enabled.discard(module_code) + + # Remove modules that depend on this one + dependents = self._get_dependent_modules(module_code) + for dependent in dependents: + if dependent in enabled: + enabled.discard(dependent) + logger.info( + f"Also disabled '{dependent}' (depends on '{module_code}')" + ) + + settings["enabled_modules"] = list(enabled) + platform.settings = settings + + 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", +] diff --git a/app/platforms/loyalty/config.py b/app/platforms/loyalty/config.py index be46b718..09c1b7d6 100644 --- a/app/platforms/loyalty/config.py +++ b/app/platforms/loyalty/config.py @@ -3,6 +3,18 @@ Loyalty Platform Configuration Configuration for the Loyalty/Rewards platform. + +Loyalty is a focused customer rewards platform with: +- Customer management and segmentation +- Analytics and reporting +- Content management (for rewards pages) +- Messaging and notifications + +It does NOT include: +- Inventory management (no physical products) +- Order processing (rewards are claimed, not purchased) +- Marketplace integration (internal program only) +- Billing (typically internal/free programs) """ from app.platforms.shared.base_platform import BasePlatformConfig @@ -33,6 +45,28 @@ class LoyaltyPlatformConfig(BasePlatformConfig): "referral_program", ] + @property + def enabled_modules(self) -> list[str]: + """ + Loyalty platform has a focused module set. + + Core modules (core, platform-admin) are always included. + Does not include: billing, inventory, orders, marketplace + """ + return [ + # Core modules (always enabled, listed for clarity) + "core", + "platform-admin", + # Customer-focused modules + "customers", + "analytics", + "messaging", + # Content for rewards pages + "cms", + # Internal tools (reduced set) + "monitoring", + ] + @property def vendor_default_page_slugs(self) -> list[str]: """Default pages for Loyalty vendor storefronts.""" diff --git a/app/platforms/oms/config.py b/app/platforms/oms/config.py index 55f087aa..77a76de5 100644 --- a/app/platforms/oms/config.py +++ b/app/platforms/oms/config.py @@ -3,6 +3,15 @@ OMS Platform Configuration Configuration for the Order Management System platform. + +OMS is a full-featured order management system with: +- Inventory and product management +- Order processing and fulfillment +- Letzshop marketplace integration +- Customer management +- Billing and subscriptions +- Content management +- Analytics and reporting """ from app.platforms.shared.base_platform import BasePlatformConfig @@ -34,6 +43,32 @@ class OMSPlatformConfig(BasePlatformConfig): "customer_view", ] + @property + def enabled_modules(self) -> list[str]: + """ + OMS enables all major commerce modules. + + Core modules (core, platform-admin) are always included. + """ + return [ + # Core modules (always enabled, listed for clarity) + "core", + "platform-admin", + # Commerce modules + "billing", + "inventory", + "orders", + "marketplace", + "customers", + # Content & communication + "cms", + "analytics", + "messaging", + # Internal tools + "dev-tools", + "monitoring", + ] + @property def vendor_default_page_slugs(self) -> list[str]: """Default pages for OMS vendor storefronts.""" diff --git a/app/platforms/shared/base_platform.py b/app/platforms/shared/base_platform.py index f8d20c2b..a44f4b8f 100644 --- a/app/platforms/shared/base_platform.py +++ b/app/platforms/shared/base_platform.py @@ -16,6 +16,11 @@ class BasePlatformConfig(ABC): Each platform should create a config.py that extends this class and provides platform-specific settings. + + Module Configuration: + - enabled_modules: List of module codes to enable for this platform + - Core modules (core, platform-admin) are always enabled + - If not specified, all modules are enabled (backwards compatibility) """ @property @@ -59,6 +64,33 @@ class BasePlatformConfig(ABC): """List of feature codes enabled for this platform.""" return [] + @property + def enabled_modules(self) -> list[str]: + """ + List of module codes enabled for this platform. + + Core modules (core, platform-admin) are always enabled regardless. + Override in subclass to customize which modules are available. + + Available modules: + - core (always enabled): Dashboard, settings, profile + - platform-admin (always enabled): Companies, vendors, admin users + - billing: Subscription tiers, billing history + - inventory: Stock management, products + - orders: Order processing, fulfillment + - marketplace: Letzshop integration + - customers: Customer management + - cms: Content pages, media library + - analytics: Reports, dashboard analytics + - messaging: Messages, notifications + - dev-tools: Component library (internal) + - monitoring: Logs, background tasks (internal) + + Returns: + List of module codes. Empty list means all modules enabled. + """ + return [] # Empty = all modules enabled (backwards compatibility) + @property def marketing_page_slugs(self) -> list[str]: """ diff --git a/app/services/menu_service.py b/app/services/menu_service.py new file mode 100644 index 00000000..d4412c71 --- /dev/null +++ b/app/services/menu_service.py @@ -0,0 +1,811 @@ +# 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", +]