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>
This commit is contained in:
2026-01-25 21:42:44 +01:00
parent 899935ab13
commit 5be42c5907
8 changed files with 1985 additions and 0 deletions

47
app/modules/__init__.py Normal file
View File

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

112
app/modules/base.py Normal file
View File

@@ -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"<Module({self.code}, core={self.is_core})>"

391
app/modules/registry.py Normal file
View File

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

523
app/modules/service.py Normal file
View File

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

View File

@@ -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."""

View File

@@ -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."""

View File

@@ -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]:
"""

View File

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