feat: dynamic merchant sidebar with module-driven menus
Replace the hardcoded merchant sidebar with a dynamic menu system driven by module definitions, matching the existing admin frontend pattern. Modules declare FrontendType.MERCHANT menus in their definition.py, and a new API endpoint unions enabled modules across all platforms the merchant is subscribed to — so loyalty only appears when enabled. - Add MERCHANT menu definitions to core, billing, tenancy, loyalty modules - Extend MenuDiscoveryService with enabled_module_codes parameter - Create GET /merchants/core/menu/render/merchant endpoint - Update merchant Alpine.js with loadMenuConfig() and dynamic section state - Replace hardcoded sidebar.html with x-for rendering + loading skeleton + fallback - Add 36 unit and integration tests for menu discovery, service, and endpoint Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -123,6 +123,7 @@ class MenuDiscoveryService:
|
||||
db: Session,
|
||||
frontend_type: FrontendType,
|
||||
platform_id: int | None = None,
|
||||
enabled_module_codes: set[str] | None = None,
|
||||
) -> list[DiscoveredMenuSection]:
|
||||
"""
|
||||
Get aggregated menu sections for a frontend type.
|
||||
@@ -134,6 +135,9 @@ class MenuDiscoveryService:
|
||||
db: Database session
|
||||
frontend_type: Frontend type to get menus for
|
||||
platform_id: Platform ID for module enablement filtering
|
||||
enabled_module_codes: If provided, overrides single-platform lookup.
|
||||
A module is considered enabled if its code is in this set.
|
||||
Useful for merchant portal where a merchant may span multiple platforms.
|
||||
|
||||
Returns:
|
||||
List of DiscoveredMenuSection sorted by order
|
||||
@@ -144,12 +148,15 @@ class MenuDiscoveryService:
|
||||
sections_map: dict[str, DiscoveredMenuSection] = {}
|
||||
|
||||
for module_code, module_def in MODULES.items():
|
||||
# Check if module is enabled for this platform
|
||||
is_module_enabled = True
|
||||
if platform_id:
|
||||
# Check if module is enabled
|
||||
if enabled_module_codes is not None:
|
||||
is_module_enabled = module_code in enabled_module_codes
|
||||
elif platform_id:
|
||||
is_module_enabled = module_service.is_module_enabled(
|
||||
db, platform_id, module_code
|
||||
)
|
||||
else:
|
||||
is_module_enabled = True
|
||||
|
||||
# Get menu sections for this frontend type
|
||||
module_sections = module_def.menus.get(frontend_type, [])
|
||||
@@ -204,6 +211,7 @@ class MenuDiscoveryService:
|
||||
user_id: int | None = None,
|
||||
is_super_admin: bool = False,
|
||||
store_code: str | None = None,
|
||||
enabled_module_codes: set[str] | None = None,
|
||||
) -> list[DiscoveredMenuSection]:
|
||||
"""
|
||||
Get filtered menu structure for frontend rendering.
|
||||
@@ -221,12 +229,16 @@ class MenuDiscoveryService:
|
||||
user_id: User ID for user-specific visibility (super admins only)
|
||||
is_super_admin: Whether the user is a super admin
|
||||
store_code: Store code for route placeholder replacement
|
||||
enabled_module_codes: If provided, overrides single-platform lookup
|
||||
for module enablement. Passed through to get_menu_sections_for_frontend.
|
||||
|
||||
Returns:
|
||||
List of DiscoveredMenuSection with filtered and sorted items
|
||||
"""
|
||||
# Get all sections with module enablement filtering
|
||||
sections = self.get_menu_sections_for_frontend(db, frontend_type, platform_id)
|
||||
sections = self.get_menu_sections_for_frontend(
|
||||
db, frontend_type, platform_id, enabled_module_codes=enabled_module_codes
|
||||
)
|
||||
|
||||
# Get visibility configuration
|
||||
visible_item_ids = self._get_visible_item_ids(
|
||||
|
||||
@@ -228,6 +228,7 @@ class MenuService:
|
||||
user_id: int | None = None,
|
||||
is_super_admin: bool = False,
|
||||
store_code: str | None = None,
|
||||
enabled_module_codes: set[str] | None = None,
|
||||
) -> list:
|
||||
"""
|
||||
Get filtered menu structure for frontend rendering.
|
||||
@@ -241,11 +242,14 @@ class MenuService:
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
frontend_type: Which frontend (admin or store)
|
||||
frontend_type: Which frontend (admin, store, or merchant)
|
||||
platform_id: Platform ID (for platform admins and stores)
|
||||
user_id: User ID (for super admins only)
|
||||
is_super_admin: Whether user is super admin (affects admin-only sections)
|
||||
store_code: Store code for URL placeholder replacement (store frontend)
|
||||
enabled_module_codes: If provided, overrides single-platform lookup
|
||||
for module enablement. Used by merchant portal where a merchant
|
||||
may have subscriptions across multiple platforms.
|
||||
|
||||
Returns:
|
||||
List of DiscoveredMenuSection ready for rendering
|
||||
@@ -257,6 +261,95 @@ class MenuService:
|
||||
user_id=user_id,
|
||||
is_super_admin=is_super_admin,
|
||||
store_code=store_code,
|
||||
enabled_module_codes=enabled_module_codes,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Merchant Menu
|
||||
# =========================================================================
|
||||
|
||||
def get_merchant_enabled_module_codes(
|
||||
self,
|
||||
db: Session,
|
||||
merchant_id: int,
|
||||
) -> set[str]:
|
||||
"""
|
||||
Get the union of enabled module codes across all platforms the merchant
|
||||
has an active subscription on.
|
||||
|
||||
Core modules (those with is_core=True) are always included.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
merchant_id: Merchant ID
|
||||
|
||||
Returns:
|
||||
Set of enabled module codes
|
||||
"""
|
||||
from app.modules.billing.models.merchant_subscription import (
|
||||
MerchantSubscription,
|
||||
)
|
||||
from app.modules.billing.models.subscription import SubscriptionStatus
|
||||
from app.modules.registry import MODULES
|
||||
|
||||
# Always include core modules
|
||||
core_codes = {code for code, mod in MODULES.items() if mod.is_core}
|
||||
|
||||
# Find all platform IDs where merchant has active/trial subscriptions
|
||||
active_statuses = [
|
||||
SubscriptionStatus.TRIAL.value,
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.PAST_DUE.value,
|
||||
SubscriptionStatus.CANCELLED.value,
|
||||
]
|
||||
|
||||
subscriptions = (
|
||||
db.query(MerchantSubscription.platform_id)
|
||||
.filter(
|
||||
MerchantSubscription.merchant_id == merchant_id,
|
||||
MerchantSubscription.status.in_(active_statuses),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
platform_ids = {sub.platform_id for sub in subscriptions}
|
||||
|
||||
if not platform_ids:
|
||||
return core_codes
|
||||
|
||||
# Union enabled module codes across all subscribed platforms
|
||||
all_enabled = set(core_codes)
|
||||
for platform_id in platform_ids:
|
||||
platform_codes = module_service.get_enabled_module_codes(db, platform_id)
|
||||
all_enabled |= platform_codes
|
||||
|
||||
return all_enabled
|
||||
|
||||
def get_merchant_for_menu(
|
||||
self,
|
||||
db: Session,
|
||||
user_id: int,
|
||||
):
|
||||
"""
|
||||
Get the active merchant owned by a user, for menu rendering.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user_id: Owner user ID
|
||||
|
||||
Returns:
|
||||
Merchant ORM object or None
|
||||
"""
|
||||
from app.modules.tenancy.models import Merchant
|
||||
|
||||
return (
|
||||
db.query(Merchant)
|
||||
.filter(
|
||||
Merchant.owner_user_id == user_id,
|
||||
Merchant.is_active == True, # noqa: E712
|
||||
)
|
||||
.order_by(Merchant.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
|
||||
Reference in New Issue
Block a user