feat: implement dynamic sidebar with server-side menu translation
- Add server-side translation for menu labels using user's preferred_language - Replace hardcoded sidebar template with dynamic rendering from menu API - Remove hardcoded section/page mappings in favor of menu discovery - Fix locale file structure (move menu keys to top level to avoid double-nesting) - Sidebar now fully driven by /admin/menu-config/render/admin API endpoint Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,7 @@ from app.api.deps import (
|
||||
from app.modules.core.services.menu_service import MenuItemConfig, menu_service
|
||||
from app.modules.tenancy.services.platform_service import platform_service
|
||||
from app.modules.enums import FrontendType # noqa: API-007 - Enum for type safety
|
||||
from app.utils.i18n import translate, DEFAULT_LANGUAGE
|
||||
from models.schema.auth import UserContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -111,15 +112,39 @@ class MenuActionResponse(BaseModel):
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _build_menu_item_response(item: MenuItemConfig) -> MenuItemResponse:
|
||||
"""Convert MenuItemConfig to API response."""
|
||||
def _translate_label(label_key: str | None, language: str) -> str | None:
|
||||
"""
|
||||
Translate a label key to the user's language.
|
||||
|
||||
Falls back to a readable version of the key if translation not found.
|
||||
"""
|
||||
if not label_key:
|
||||
return None
|
||||
|
||||
translated = translate(label_key, language=language)
|
||||
|
||||
# If translation returned the key itself, it wasn't found
|
||||
# Create a readable fallback from the last part of the key
|
||||
if translated == label_key:
|
||||
parts = label_key.split(".")
|
||||
last_part = parts[-1]
|
||||
# Convert snake_case to Title Case
|
||||
return last_part.replace("_", " ").title()
|
||||
|
||||
return translated
|
||||
|
||||
|
||||
def _build_menu_item_response(
|
||||
item: MenuItemConfig, language: str = DEFAULT_LANGUAGE
|
||||
) -> MenuItemResponse:
|
||||
"""Convert MenuItemConfig to API response with translated labels."""
|
||||
return MenuItemResponse(
|
||||
id=item.id,
|
||||
label=item.label,
|
||||
label=_translate_label(item.label, language),
|
||||
icon=item.icon,
|
||||
url=item.url,
|
||||
section_id=item.section_id,
|
||||
section_label=item.section_label,
|
||||
section_label=_translate_label(item.section_label, language),
|
||||
is_visible=item.is_visible,
|
||||
is_mandatory=item.is_mandatory,
|
||||
is_super_admin_only=item.is_super_admin_only,
|
||||
@@ -129,11 +154,12 @@ def _build_menu_item_response(item: MenuItemConfig) -> MenuItemResponse:
|
||||
def _build_menu_config_response(
|
||||
items: list[MenuItemConfig],
|
||||
frontend_type: FrontendType,
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
platform_id: int | None = None,
|
||||
user_id: int | None = None,
|
||||
) -> MenuConfigResponse:
|
||||
"""Build menu configuration response."""
|
||||
item_responses = [_build_menu_item_response(item) for item in items]
|
||||
"""Build menu configuration response with translated labels."""
|
||||
item_responses = [_build_menu_item_response(item, language) for item in items]
|
||||
visible_count = sum(1 for item in items if item.is_visible)
|
||||
|
||||
return MenuConfigResponse(
|
||||
@@ -177,7 +203,12 @@ async def get_platform_menu_config(
|
||||
f"for platform {platform.code} ({frontend_type.value})"
|
||||
)
|
||||
|
||||
return _build_menu_config_response(items, frontend_type, platform_id=platform_id)
|
||||
# Use user's preferred language, falling back to default
|
||||
language = current_user.preferred_language or DEFAULT_LANGUAGE
|
||||
|
||||
return _build_menu_config_response(
|
||||
items, frontend_type, language=language, platform_id=platform_id
|
||||
)
|
||||
|
||||
|
||||
@router.put("/platforms/{platform_id}")
|
||||
@@ -300,8 +331,11 @@ async def get_user_menu_config(
|
||||
f"[MENU_CONFIG] Super admin {current_user.email} fetched their personal menu config"
|
||||
)
|
||||
|
||||
# Use user's preferred language, falling back to default
|
||||
language = current_user.preferred_language or DEFAULT_LANGUAGE
|
||||
|
||||
return _build_menu_config_response(
|
||||
items, FrontendType.ADMIN, user_id=current_user.id
|
||||
items, FrontendType.ADMIN, language=language, user_id=current_user.id
|
||||
)
|
||||
|
||||
|
||||
@@ -448,14 +482,26 @@ async def get_rendered_admin_menu(
|
||||
is_super_admin=False,
|
||||
)
|
||||
|
||||
sections = [
|
||||
MenuSectionResponse(
|
||||
id=section["id"],
|
||||
label=section.get("label"),
|
||||
items=section["items"],
|
||||
# Use user's preferred language, falling back to default
|
||||
language = current_user.preferred_language or DEFAULT_LANGUAGE
|
||||
|
||||
# Translate section and item labels
|
||||
sections = []
|
||||
for section in menu.get("sections", []):
|
||||
# Translate item labels
|
||||
translated_items = []
|
||||
for item in section.get("items", []):
|
||||
translated_item = item.copy()
|
||||
translated_item["label"] = _translate_label(item.get("label"), language)
|
||||
translated_items.append(translated_item)
|
||||
|
||||
sections.append(
|
||||
MenuSectionResponse(
|
||||
id=section["id"],
|
||||
label=_translate_label(section.get("label"), language),
|
||||
items=translated_items,
|
||||
)
|
||||
)
|
||||
for section in menu.get("sections", [])
|
||||
]
|
||||
|
||||
return RenderedMenuResponse(
|
||||
frontend_type=FrontendType.ADMIN.value,
|
||||
|
||||
Reference in New Issue
Block a user