Files

230 lines
10 KiB
Python

"""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.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) -> MenuOverrideListResponse:
default_rows = await store.list_overrides()
user_id, is_admin = await _actor(request)
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:
store = _get_store(request)
return await _read_response(request, store)
@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)
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)
return await _read_response(request, store)