"""Sidebar-menu override API (菜单管理). The static sidebar menu tree (icons / routes / adminOnly) is defined in the frontend code; iframe light-apps are injected into it at render time. This router persists a **global / shared** set of per-node overrides that the frontend merges onto that tree: rename (``label``), 停用/启用 (``disabled``), reorder (``sort_order``) and re-parent (``parent_id``, moving a node's whole subtree). A node with no override renders exactly as the code defines it. Reads are open to any authenticated user (the sidebar needs them); the whole override set is replaced atomically and only by an admin. Routes (prefix ``/api/menu-overrides``): GET "" list all overrides PUT "" replace the entire override set (admin) """ from __future__ import annotations from datetime import datetime from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, Field from app.gateway.deps import get_current_user, get_optional_user_from_request from deerflow.persistence.menu_overrides import MenuOverrideStore router = APIRouter(prefix="/api/menu-overrides", tags=["menu-overrides"]) # Hard cap mirroring the sidebar's 一级 → 二级 → 三级 rendering depth. _MAX_DEPTH = 3 # ── schemas ────────────────────────────────────────────────────────────── class MenuOverride(BaseModel): node_id: str = Field(..., min_length=1, max_length=128) parent_id: str | None = Field(default=None, max_length=128) sort_order: int = 0 label: str | None = Field(default=None, max_length=255) disabled: bool = False class MenuOverrideResponse(MenuOverride): updated_by: str | None = None updated_at: datetime | str | None = None class MenuOverrideListResponse(BaseModel): overrides: list[MenuOverrideResponse] class MenuOverrideReplaceRequest(BaseModel): overrides: list[MenuOverride] = Field(default_factory=list) # ── helpers ────────────────────────────────────────────────────────────── def _get_store(request: Request) -> MenuOverrideStore: store = getattr(request.app.state, "menu_override_store", None) if store is None: raise HTTPException(status_code=503, detail="Menu override store not available") return store async def _require_admin(request: Request) -> None: """Reject non-admins. Lets the call through when auth is disabled.""" user = await get_optional_user_from_request(request) if user is None: return if getattr(user, "system_role", None) != "admin": raise HTTPException(status_code=403, detail="菜单管理仅限管理员") def _validate_depth(overrides: list[MenuOverride]) -> None: """Reject cycles and any node whose effective depth exceeds 3 levels. Only nodes that appear in the override set carry an explicit ``parent_id``; a node absent from the set keeps its code-defined parent (which is at most 3 levels deep by construction). So validating the override graph alone is sufficient to stop an admin from pushing a 4th level via re-parenting. """ parent_of = {o.node_id: o.parent_id for o in overrides} for node_id in parent_of: depth = 1 seen = {node_id} cursor = parent_of[node_id] while cursor: depth += 1 if depth > _MAX_DEPTH: raise HTTPException(status_code=400, detail=f"菜单「{node_id}」层级超过 {_MAX_DEPTH} 级") if cursor in seen: raise HTTPException(status_code=400, detail=f"菜单「{node_id}」存在循环引用") seen.add(cursor) # A parent that is not itself in the override set is a code node at a # known depth ≤ 3, so the chain terminates there. cursor = parent_of.get(cursor) # ── routes ─────────────────────────────────────────────────────────────── @router.get("", response_model=MenuOverrideListResponse) async def list_menu_overrides(request: Request) -> MenuOverrideListResponse: store = _get_store(request) rows = await store.list_overrides() return MenuOverrideListResponse(overrides=[MenuOverrideResponse(**r) for r in rows]) @router.put("", response_model=MenuOverrideListResponse) async def replace_menu_overrides(request: Request, body: MenuOverrideReplaceRequest) -> MenuOverrideListResponse: await _require_admin(request) _validate_depth(body.overrides) store = _get_store(request) user_id = await get_current_user(request) rows = await store.replace_all( [o.model_dump() for o in body.overrides], updated_by=user_id, ) return MenuOverrideListResponse(overrides=[MenuOverrideResponse(**r) for r in rows])