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:
47
app/modules/__init__.py
Normal file
47
app/modules/__init__.py
Normal 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
112
app/modules/base.py
Normal 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
391
app/modules/registry.py
Normal 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
523
app/modules/service.py
Normal 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",
|
||||||
|
]
|
||||||
@@ -3,6 +3,18 @@
|
|||||||
Loyalty Platform Configuration
|
Loyalty Platform Configuration
|
||||||
|
|
||||||
Configuration for the Loyalty/Rewards platform.
|
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
|
from app.platforms.shared.base_platform import BasePlatformConfig
|
||||||
@@ -33,6 +45,28 @@ class LoyaltyPlatformConfig(BasePlatformConfig):
|
|||||||
"referral_program",
|
"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
|
@property
|
||||||
def vendor_default_page_slugs(self) -> list[str]:
|
def vendor_default_page_slugs(self) -> list[str]:
|
||||||
"""Default pages for Loyalty vendor storefronts."""
|
"""Default pages for Loyalty vendor storefronts."""
|
||||||
|
|||||||
@@ -3,6 +3,15 @@
|
|||||||
OMS Platform Configuration
|
OMS Platform Configuration
|
||||||
|
|
||||||
Configuration for the Order Management System platform.
|
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
|
from app.platforms.shared.base_platform import BasePlatformConfig
|
||||||
@@ -34,6 +43,32 @@ class OMSPlatformConfig(BasePlatformConfig):
|
|||||||
"customer_view",
|
"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
|
@property
|
||||||
def vendor_default_page_slugs(self) -> list[str]:
|
def vendor_default_page_slugs(self) -> list[str]:
|
||||||
"""Default pages for OMS vendor storefronts."""
|
"""Default pages for OMS vendor storefronts."""
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ class BasePlatformConfig(ABC):
|
|||||||
|
|
||||||
Each platform should create a config.py that extends this class
|
Each platform should create a config.py that extends this class
|
||||||
and provides platform-specific settings.
|
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
|
@property
|
||||||
@@ -59,6 +64,33 @@ class BasePlatformConfig(ABC):
|
|||||||
"""List of feature codes enabled for this platform."""
|
"""List of feature codes enabled for this platform."""
|
||||||
return []
|
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
|
@property
|
||||||
def marketing_page_slugs(self) -> list[str]:
|
def marketing_page_slugs(self) -> list[str]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
811
app/services/menu_service.py
Normal file
811
app/services/menu_service.py
Normal 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",
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user