feat: complete dynamic menu system across all frontends
All checks were successful
CI / ruff (push) Successful in 11s
CI / pytest (push) Successful in 44m40s
CI / validate (push) Successful in 22s
CI / dependency-scanning (push) Successful in 28s
CI / docs (push) Successful in 39s
CI / deploy (push) Successful in 49s

- Add "Merchant Frontend" tab to admin menu-config page
- Merchant render endpoint now respects AdminMenuConfig visibility
  via get_merchant_primary_platform_id() platform resolution
- New store menu render endpoint (GET /store/core/menu/render/store)
  with platform-scoped visibility and store_code interpolation
- Store sidebar migrated from hardcoded Jinja2 macros to dynamic
  Alpine.js x-for rendering with loading skeleton and fallback
- Store init-alpine.js: add loadMenuConfig(), expandSectionForCurrentPage()
- Include store page route fixes, login template updates, and tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 02:14:42 +01:00
parent be248222bc
commit 506171503d
14 changed files with 1364 additions and 158 deletions

View File

@@ -101,10 +101,16 @@ async def get_rendered_merchant_menu(
# Get union of enabled module codes across all subscribed platforms
enabled_codes = menu_service.get_merchant_enabled_module_codes(db, merchant.id)
# Get filtered menu using enabled_module_codes override
# Resolve primary platform for AdminMenuConfig visibility lookup
primary_platform_id = menu_service.get_merchant_primary_platform_id(
db, merchant.id
)
# Get filtered menu using enabled_module_codes override + platform visibility
menu = menu_service.get_menu_for_rendering(
db=db,
frontend_type=FrontendType.MERCHANT,
platform_id=primary_platform_id,
enabled_module_codes=enabled_codes,
)

View File

@@ -5,15 +5,18 @@ Core module store API routes.
Aggregates:
- /dashboard/* - Dashboard statistics
- /settings/* - Store settings management
- /menu/* - Store menu rendering
"""
from fastapi import APIRouter
from .store_dashboard import store_dashboard_router
from .store_menu import store_menu_router
from .store_settings import store_settings_router
store_router = APIRouter()
# Aggregate sub-routers
store_router.include_router(store_dashboard_router, tags=["store-dashboard"])
store_router.include_router(store_menu_router, tags=["store-menu"])
store_router.include_router(store_settings_router, tags=["store-settings"])

View File

@@ -0,0 +1,139 @@
# app/modules/core/routes/api/store_menu.py
"""
Store menu rendering endpoint.
Provides the dynamic sidebar menu for the store portal:
- GET /menu/render/store - Get rendered store menu for current user
Menu sections are driven by module definitions (FrontendType.STORE).
Only modules enabled on the store's platform will appear in the sidebar.
Visibility is controlled by AdminMenuConfig records for the platform.
"""
import logging
from typing import Any
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_store_api
from app.core.database import get_db
from app.modules.core.services.menu_service import menu_service
from app.modules.enums import FrontendType
from app.modules.tenancy.services.store_service import store_service
from app.utils.i18n import translate
from models.schema.auth import UserContext
logger = logging.getLogger(__name__)
store_menu_router = APIRouter(prefix="/menu")
# =============================================================================
# Schemas
# =============================================================================
class MenuSectionResponse(BaseModel):
"""Menu section for rendering."""
id: str
label: str | None = None
items: list[dict[str, Any]]
class RenderedMenuResponse(BaseModel):
"""Rendered menu for frontend."""
frontend_type: str
sections: list[MenuSectionResponse]
# =============================================================================
# Helpers
# =============================================================================
def _translate_label(label_key: str | None, language: str) -> str | None:
"""Translate a label key, falling back to a readable version of the key."""
if not label_key:
return None
translated = translate(label_key, language=language)
# If translation returned the key itself, create a readable fallback
if translated == label_key:
parts = label_key.split(".")
last_part = parts[-1]
return last_part.replace("_", " ").title()
return translated
# =============================================================================
# Endpoint
# =============================================================================
@store_menu_router.get("/render/store", response_model=RenderedMenuResponse)
async def get_rendered_store_menu(
request: Request,
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_store_api),
):
"""
Get the rendered store menu for the current user.
Returns the filtered menu structure based on:
- Modules enabled on the store's platform
- AdminMenuConfig visibility for the platform
- Store code for URL placeholder replacement
Used by the store frontend to render the sidebar dynamically.
"""
# Get the store from the JWT token's store context
store = store_service.get_store_by_id(db, current_user.token_store_id)
# Resolve the store's platform via service layer
platform_id = menu_service.get_store_primary_platform_id(db, store.id)
# Get filtered menu with platform visibility and store_code interpolation
menu = menu_service.get_menu_for_rendering(
db=db,
frontend_type=FrontendType.STORE,
platform_id=platform_id,
store_code=store.subdomain,
)
# Resolve language
language = current_user.preferred_language or getattr(
request.state, "language", "en"
)
# Translate section and item labels
sections = []
for section in menu:
translated_items = []
for item in section.items:
translated_items.append(
{
"id": item.id,
"label": _translate_label(item.label_key, language),
"icon": item.icon,
"url": item.route,
}
)
sections.append(
MenuSectionResponse(
id=section.id,
label=_translate_label(section.label_key, language),
items=translated_items,
)
)
return RenderedMenuResponse(
frontend_type=FrontendType.STORE.value,
sections=sections,
)