Files
orion/app/api/v1/admin/menu_config.py
Samir Boulahtit c419090531 feat: complete modular platform architecture (Phases 1-5)
Phase 1 - Vendor Router Integration:
- Wire up vendor module routers in app/api/v1/vendor/__init__.py
- Use lazy imports via __getattr__ to avoid circular dependencies

Phase 2 - Extract Remaining Modules:
- Create 6 new module directories: customers, cms, analytics, messaging,
  dev_tools, monitoring
- Each module has definition.py and route wrappers
- Update registry to import from extracted modules

Phase 3 - Database Table Migration:
- Add PlatformModule junction table for auditable module tracking
- Add migration zc2m3n4o5p6q7_add_platform_modules_table.py
- Add modules relationship to Platform model
- Update ModuleService with JSON-to-junction-table migration

Phase 4 - Module-Specific Configuration UI:
- Add /api/v1/admin/module-config/* endpoints
- Add module-config.html template and JS

Phase 5 - Integration Tests:
- Add tests/fixtures/module_fixtures.py
- Add tests/integration/api/v1/admin/test_modules.py
- Add tests/integration/api/v1/modules/test_module_access.py

Architecture fixes:
- Fix JS-003 errors: use ...data() directly in Alpine components
- Fix JS-005 warnings: add init() guards to prevent duplicate init
- Fix API-001 errors: add MenuActionResponse Pydantic model
- Add FE-008 noqa for dynamic number input in template

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 18:19:00 +01:00

464 lines
14 KiB
Python

# app/api/v1/admin/menu_config.py
"""
Admin API endpoints for Platform Menu Configuration.
Provides menu visibility configuration for admin and vendor frontends:
- GET /menu-config/platforms/{platform_id} - Get menu config for a platform
- PUT /menu-config/platforms/{platform_id} - Update menu visibility for a platform
- POST /menu-config/platforms/{platform_id}/reset - Reset to defaults
- GET /menu-config/user - Get current user's menu config (super admins)
- PUT /menu-config/user - Update current user's menu config (super admins)
- GET /menu/admin - Get rendered admin menu for current user
- GET /menu/vendor - Get rendered vendor menu for current platform
All configuration endpoints require super admin access.
Menu rendering endpoints require authenticated admin/vendor access.
"""
import logging
from typing import Any
from fastapi import APIRouter, Depends, Path, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.deps import (
get_current_admin_from_cookie_or_header,
get_current_super_admin,
get_db,
)
from app.services.menu_service import MenuItemConfig, menu_service
from app.services.platform_service import platform_service
from models.database.admin_menu_config import FrontendType
from models.database.user import User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/menu-config")
# =============================================================================
# Pydantic Schemas
# =============================================================================
class MenuItemResponse(BaseModel):
"""Menu item configuration response."""
id: str
label: str
icon: str
url: str
section_id: str
section_label: str | None = None
is_visible: bool = True
is_mandatory: bool = False
is_super_admin_only: bool = False
class MenuConfigResponse(BaseModel):
"""Menu configuration response for a platform or user."""
frontend_type: str
platform_id: int | None = None
user_id: int | None = None
items: list[MenuItemResponse]
total_items: int
visible_items: int
hidden_items: int
class MenuVisibilityUpdateRequest(BaseModel):
"""Request to update menu item visibility."""
menu_item_id: str = Field(..., description="Menu item ID to update")
is_visible: bool = Field(..., description="Whether the item should be visible")
class BulkMenuVisibilityUpdateRequest(BaseModel):
"""Request to update multiple menu items at once."""
visibility: dict[str, bool] = Field(
...,
description="Map of menu_item_id to is_visible",
examples=[{"inventory": False, "orders": True}],
)
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]
class MenuActionResponse(BaseModel):
"""Response for menu action operations (reset, show-all, etc.)."""
success: bool
message: str
# =============================================================================
# Helper Functions
# =============================================================================
def _build_menu_item_response(item: MenuItemConfig) -> MenuItemResponse:
"""Convert MenuItemConfig to API response."""
return MenuItemResponse(
id=item.id,
label=item.label,
icon=item.icon,
url=item.url,
section_id=item.section_id,
section_label=item.section_label,
is_visible=item.is_visible,
is_mandatory=item.is_mandatory,
is_super_admin_only=item.is_super_admin_only,
)
def _build_menu_config_response(
items: list[MenuItemConfig],
frontend_type: FrontendType,
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]
visible_count = sum(1 for item in items if item.is_visible)
return MenuConfigResponse(
frontend_type=frontend_type.value,
platform_id=platform_id,
user_id=user_id,
items=item_responses,
total_items=len(items),
visible_items=visible_count,
hidden_items=len(items) - visible_count,
)
# =============================================================================
# Platform Menu Configuration (Super Admin Only)
# =============================================================================
@router.get("/platforms/{platform_id}", response_model=MenuConfigResponse)
async def get_platform_menu_config(
platform_id: int = Path(..., description="Platform ID"),
frontend_type: FrontendType = Query(
FrontendType.ADMIN, description="Frontend type (admin or vendor)"
),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_super_admin),
):
"""
Get menu configuration for a platform.
Returns all menu items with their visibility status for the specified
platform and frontend type. Super admin only.
"""
# Verify platform exists
platform = platform_service.get_platform_by_id(db, platform_id)
items = menu_service.get_platform_menu_config(db, frontend_type, platform_id)
logger.info(
f"[MENU_CONFIG] Super admin {current_user.email} fetched menu config "
f"for platform {platform.code} ({frontend_type.value})"
)
return _build_menu_config_response(items, frontend_type, platform_id=platform_id)
@router.put("/platforms/{platform_id}")
async def update_platform_menu_visibility(
update_data: MenuVisibilityUpdateRequest,
platform_id: int = Path(..., description="Platform ID"),
frontend_type: FrontendType = Query(
FrontendType.ADMIN, description="Frontend type (admin or vendor)"
),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_super_admin),
):
"""
Update visibility for a single menu item for a platform.
Super admin only. Cannot hide mandatory items.
"""
# Verify platform exists
platform = platform_service.get_platform_by_id(db, platform_id)
menu_service.update_menu_visibility(
db=db,
frontend_type=frontend_type,
menu_item_id=update_data.menu_item_id,
is_visible=update_data.is_visible,
platform_id=platform_id,
)
db.commit()
logger.info(
f"[MENU_CONFIG] Super admin {current_user.email} updated menu visibility: "
f"{update_data.menu_item_id}={update_data.is_visible} "
f"for platform {platform.code} ({frontend_type.value})"
)
return {"success": True, "message": "Menu visibility updated"}
@router.put("/platforms/{platform_id}/bulk")
async def bulk_update_platform_menu_visibility(
update_data: BulkMenuVisibilityUpdateRequest,
platform_id: int = Path(..., description="Platform ID"),
frontend_type: FrontendType = Query(
FrontendType.ADMIN, description="Frontend type (admin or vendor)"
),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_super_admin),
):
"""
Update visibility for multiple menu items at once.
Super admin only. Skips mandatory items silently.
"""
# Verify platform exists
platform = platform_service.get_platform_by_id(db, platform_id)
menu_service.bulk_update_menu_visibility(
db=db,
frontend_type=frontend_type,
visibility_map=update_data.visibility,
platform_id=platform_id,
)
db.commit()
logger.info(
f"[MENU_CONFIG] Super admin {current_user.email} bulk updated menu visibility: "
f"{len(update_data.visibility)} items for platform {platform.code} ({frontend_type.value})"
)
return {"success": True, "message": f"Updated {len(update_data.visibility)} menu items"}
@router.post("/platforms/{platform_id}/reset")
async def reset_platform_menu_config(
platform_id: int = Path(..., description="Platform ID"),
frontend_type: FrontendType = Query(
FrontendType.ADMIN, description="Frontend type (admin or vendor)"
),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_super_admin),
):
"""
Reset menu configuration for a platform to defaults.
Removes all visibility overrides, making all items visible.
Super admin only.
"""
# Verify platform exists
platform = platform_service.get_platform_by_id(db, platform_id)
menu_service.reset_platform_menu_config(db, frontend_type, platform_id)
db.commit()
logger.info(
f"[MENU_CONFIG] Super admin {current_user.email} reset menu config "
f"for platform {platform.code} ({frontend_type.value})"
)
return {"success": True, "message": "Menu configuration reset to defaults"}
# =============================================================================
# User Menu Configuration (Super Admin Only)
# =============================================================================
@router.get("/user", response_model=MenuConfigResponse)
async def get_user_menu_config(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_super_admin),
):
"""
Get the current super admin's personal menu configuration.
Only super admins can configure their own admin menu.
"""
items = menu_service.get_user_menu_config(db, current_user.id)
logger.info(
f"[MENU_CONFIG] Super admin {current_user.email} fetched their personal menu config"
)
return _build_menu_config_response(
items, FrontendType.ADMIN, user_id=current_user.id
)
@router.put("/user")
async def update_user_menu_visibility(
update_data: MenuVisibilityUpdateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_super_admin),
):
"""
Update visibility for a single menu item for the current super admin.
Super admin only. Cannot hide mandatory items.
"""
menu_service.update_menu_visibility(
db=db,
frontend_type=FrontendType.ADMIN,
menu_item_id=update_data.menu_item_id,
is_visible=update_data.is_visible,
user_id=current_user.id,
)
db.commit()
logger.info(
f"[MENU_CONFIG] Super admin {current_user.email} updated personal menu: "
f"{update_data.menu_item_id}={update_data.is_visible}"
)
return {"success": True, "message": "Menu visibility updated"}
@router.post("/user/reset", response_model=MenuActionResponse)
async def reset_user_menu_config(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_super_admin),
):
"""
Reset the current super admin's menu configuration (hide all except mandatory).
Super admin only.
"""
menu_service.reset_user_menu_config(db, current_user.id)
db.commit()
logger.info(
f"[MENU_CONFIG] Super admin {current_user.email} reset their personal menu config (hide all)"
)
return MenuActionResponse(success=True, message="Menu configuration reset - all items hidden")
@router.post("/user/show-all", response_model=MenuActionResponse)
async def show_all_user_menu_config(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_super_admin),
):
"""
Show all menu items for the current super admin.
Super admin only.
"""
menu_service.show_all_user_menu_config(db, current_user.id)
db.commit()
logger.info(
f"[MENU_CONFIG] Super admin {current_user.email} enabled all menu items"
)
return MenuActionResponse(success=True, message="All menu items are now visible")
@router.post("/platforms/{platform_id}/show-all")
async def show_all_platform_menu_config(
platform_id: int = Path(..., description="Platform ID"),
frontend_type: FrontendType = Query(
FrontendType.ADMIN, description="Frontend type (admin or vendor)"
),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_super_admin),
):
"""
Show all menu items for a platform.
Super admin only.
"""
# Verify platform exists
platform = platform_service.get_platform_by_id(db, platform_id)
menu_service.show_all_platform_menu_config(db, frontend_type, platform_id)
db.commit()
logger.info(
f"[MENU_CONFIG] Super admin {current_user.email} enabled all menu items "
f"for platform {platform.code} ({frontend_type.value})"
)
return {"success": True, "message": "All menu items are now visible"}
# =============================================================================
# Menu Rendering (For Sidebar)
# =============================================================================
@router.get("/render/admin", response_model=RenderedMenuResponse)
async def get_rendered_admin_menu(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_from_cookie_or_header),
):
"""
Get the rendered admin menu for the current user.
Returns the filtered menu structure based on:
- Super admins: user-level config
- Platform admins: platform-level config
Used by the frontend to render the sidebar.
"""
if current_user.is_super_admin:
# Super admin: use user-level config
menu = menu_service.get_menu_for_rendering(
db=db,
frontend_type=FrontendType.ADMIN,
user_id=current_user.id,
is_super_admin=True,
)
else:
# Platform admin: use platform-level config
# Get the selected platform from the JWT token
platform_id = getattr(current_user, "token_platform_id", None)
# Fallback to first platform if no platform in token (shouldn't happen)
if platform_id is None and current_user.admin_platforms:
platform_id = current_user.admin_platforms[0].id
logger.warning(
f"[MENU_CONFIG] No platform_id in token for {current_user.email}, "
f"falling back to first platform: {platform_id}"
)
menu = menu_service.get_menu_for_rendering(
db=db,
frontend_type=FrontendType.ADMIN,
platform_id=platform_id,
is_super_admin=False,
)
sections = [
MenuSectionResponse(
id=section["id"],
label=section.get("label"),
items=section["items"],
)
for section in menu.get("sections", [])
]
return RenderedMenuResponse(
frontend_type=FrontendType.ADMIN.value,
sections=sections,
)