"""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 the administrator's shared default plus optional per-user snapshots 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). Admins replace the shared default document; ordinary users replace a complete personal snapshot. A personal snapshot wins by ``node_id`` while default rows for node ids absent from the snapshot flow through, so administrator- added menus reach existing users without overwriting their other choices. Routes (prefix ``/api/menu-overrides``): GET "" read the caller's effective/default/personal configuration PUT "" replace the caller's editable scope (default or personal); admin writes also carry the full effective hierarchy for validation DELETE "" reset the caller's editable scope """ from __future__ import annotations from datetime import datetime from typing import Literal from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, Field from app.gateway.auth.disabled_mode import is_auth_disabled from app.gateway.catalog_cache import cached_catalog, catalog_key, invalidate_catalog from app.gateway.deps import 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] default_overrides: list[MenuOverrideResponse] = Field(default_factory=list) user_overrides: list[MenuOverrideResponse] = Field(default_factory=list) has_user_override: bool = False editable_scope: Literal["default", "user"] = "default" class MenuHierarchyNode(BaseModel): """Frontend-owned effective parent relation used only for depth validation.""" node_id: str = Field(..., min_length=1, max_length=128) parent_id: str | None = Field(default=None, max_length=128) class MenuOverrideReplaceRequest(BaseModel): overrides: list[MenuOverride] = Field(default_factory=list) hierarchy: list[MenuHierarchyNode] | None = None # ── 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 _actor(request: Request) -> tuple[str | None, bool]: """Return ``(user_id, is_admin)`` while preserving auth-disabled mode.""" user = await get_optional_user_from_request(request) if user is None: # Auth-disabled deployments historically treat menu writes as the # shared administrator document. Keep that compatibility behavior. if is_auth_disabled(): return None, True raise HTTPException(status_code=401, detail="Not authenticated") return str(user.id), getattr(user, "system_role", None) == "admin" def _merge_overrides(default_rows: list[dict], user_rows: list[dict] | None) -> list[dict]: """Overlay personal rows and retain default rows for new node ids.""" if user_rows is None: return [dict(row) for row in default_rows] merged: list[dict] = [] seen: set[str] = set() for row in user_rows: node_id = str(row.get("node_id") or "") if not node_id or node_id in seen: continue merged.append(dict(row)) seen.add(node_id) for row in default_rows: node_id = str(row.get("node_id") or "") if node_id and node_id not in seen: merged.append(dict(row)) seen.add(node_id) return merged async def _read_response( request: Request, store: MenuOverrideStore, *, actor: tuple[str | None, bool] | None = None, ) -> MenuOverrideListResponse: user_id, is_admin = actor if actor is not None else await _actor(request) default_rows = await store.list_overrides() if is_admin or user_id is None: return MenuOverrideListResponse( overrides=[MenuOverrideResponse(**row) for row in default_rows], default_overrides=[MenuOverrideResponse(**row) for row in default_rows], user_overrides=[], has_user_override=False, editable_scope="default", ) user_rows = await store.list_user_overrides(user_id) effective = _merge_overrides(default_rows, user_rows) return MenuOverrideListResponse( overrides=[MenuOverrideResponse(**row) for row in effective], default_overrides=[MenuOverrideResponse(**row) for row in default_rows], user_overrides=[MenuOverrideResponse(**row) for row in (user_rows or [])], has_user_override=user_rows is not None, editable_scope="user", ) def _validate_depth( overrides: list[MenuOverride], *, require_known_parents: bool = False, hierarchy: list[MenuHierarchyNode] | None = None, ) -> 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. """ graph_rows: list[MenuOverride | MenuHierarchyNode] = hierarchy if hierarchy is not None else overrides parent_of = {o.node_id: o.parent_id for o in graph_rows} if len(parent_of) != len(graph_rows): raise HTTPException(status_code=400, detail="菜单层级快照包含重复节点") if hierarchy is not None: # The administrator persists a sparse delta, but the static hierarchy # is frontend-owned. Require the effective full hierarchy alongside the # delta and make sure every stored parent relation matches it. for override in overrides: if override.node_id not in parent_of: raise HTTPException(status_code=400, detail=f"菜单「{override.node_id}」不在层级快照中") if parent_of[override.node_id] != override.parent_id: raise HTTPException(status_code=400, detail=f"菜单「{override.node_id}」的父级与层级快照不一致") require_complete_graph = require_known_parents or hierarchy is not None 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) if require_complete_graph and cursor not in parent_of: raise HTTPException(status_code=400, detail=f"菜单「{node_id}」的父级不在完整菜单层级快照中") # 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: actor = await _actor(request) store = _get_store(request) return await cached_catalog( request, "menu_overrides", catalog_key(*actor), lambda: _read_response(request, store, actor=actor), MenuOverrideListResponse, ) @router.put("", response_model=MenuOverrideListResponse) async def replace_menu_overrides(request: Request, body: MenuOverrideReplaceRequest) -> MenuOverrideListResponse: store = _get_store(request) user_id, is_admin = await _actor(request) # Personal payloads are complete snapshots, so every non-root parent must # also be present. This prevents a crafted sparse payload from attaching a # node below an unknown code-level parent and bypassing the three-level cap. if is_admin and body.overrides and not body.hierarchy: raise HTTPException(status_code=400, detail="管理员保存默认菜单时缺少完整层级快照") _validate_depth( body.overrides, require_known_parents=not is_admin, hierarchy=body.hierarchy if is_admin else None, ) payload = [o.model_dump() for o in body.overrides] if is_admin or user_id is None: await store.replace_all(payload, updated_by=user_id) else: await store.replace_user_overrides(user_id, payload, updated_by=user_id) await invalidate_catalog(request, "menu_overrides") return await _read_response(request, store) @router.delete("", response_model=MenuOverrideListResponse) async def reset_menu_overrides(request: Request) -> MenuOverrideListResponse: """Clear the caller's personal layout (or the default in auth-disabled mode).""" store = _get_store(request) user_id, is_admin = await _actor(request) if is_admin or user_id is None: await store.replace_all([], updated_by=user_id) else: await store.replace_user_overrides(user_id, [], updated_by=user_id) await invalidate_catalog(request, "menu_overrides") return await _read_response(request, store)