338 lines
13 KiB
Python
338 lines
13 KiB
Python
"""岗位 (position) management API — admin only.
|
|
|
|
A position aggregates, per resource type, the tags whose resources are granted
|
|
to its members. Assigning a user to a position (1:1) materializes those grants
|
|
into the user's "我的" tables via the sync engine; recommended-question and
|
|
skill visibility are filtered live elsewhere.
|
|
|
|
All endpoints require ``system_role == "admin"`` (auth-disabled mode lets calls
|
|
through, matching the other admin routers).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import uuid
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.gateway.deps import get_optional_user_from_request, get_position_store, get_position_sync, get_tag_store
|
|
from deerflow.persistence.positions import POSITION_RESOURCE_TYPES, PositionStore
|
|
from deerflow.persistence.tags import TagStore
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/positions", tags=["positions"])
|
|
|
|
|
|
async def _require_admin(request: Request) -> None:
|
|
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_bindings(bindings: dict[str, list[str]]) -> dict[str, list[str]]:
|
|
out: dict[str, list[str]] = {}
|
|
for resource_type, tag_ids in bindings.items():
|
|
if resource_type not in POSITION_RESOURCE_TYPES:
|
|
raise HTTPException(status_code=422, detail=f"Invalid resource type '{resource_type}'. Allowed: {', '.join(POSITION_RESOURCE_TYPES)}")
|
|
out[resource_type] = list(tag_ids or [])
|
|
return out
|
|
|
|
|
|
# ------------------------------------------------------------------ schemas
|
|
|
|
|
|
class PositionResponse(BaseModel):
|
|
id: str
|
|
name: str
|
|
description: str = ""
|
|
is_default: bool = False
|
|
member_count: int = 0
|
|
created_at: str | None = None
|
|
updated_at: str | None = None
|
|
|
|
|
|
class PositionListResponse(BaseModel):
|
|
positions: list[PositionResponse]
|
|
resource_types: list[str] = Field(default_factory=lambda: list(POSITION_RESOURCE_TYPES))
|
|
|
|
|
|
class PositionCreateRequest(BaseModel):
|
|
name: str = Field(..., min_length=1, max_length=191)
|
|
description: str = Field(default="", max_length=1024)
|
|
is_default: bool = False
|
|
|
|
|
|
class PositionUpdateRequest(BaseModel):
|
|
name: str | None = Field(default=None, min_length=1, max_length=191)
|
|
description: str | None = Field(default=None, max_length=1024)
|
|
is_default: bool | None = None
|
|
|
|
|
|
class TagBindingsResponse(BaseModel):
|
|
position_id: str
|
|
bindings: dict[str, list[str]]
|
|
|
|
|
|
class TagBindingsRequest(BaseModel):
|
|
bindings: dict[str, list[str]] = Field(default_factory=dict, description="resource_type -> [tag_id]; omitted/empty type = default set")
|
|
|
|
|
|
class MemberResponse(BaseModel):
|
|
id: str
|
|
email: str = ""
|
|
|
|
|
|
class MembersResponse(BaseModel):
|
|
members: list[MemberResponse]
|
|
|
|
|
|
class MembersMutationRequest(BaseModel):
|
|
user_ids: list[str] = Field(..., min_length=1)
|
|
|
|
|
|
class UserPositionRequest(BaseModel):
|
|
position_id: str | None = Field(default=None, description="null clears the user's position")
|
|
|
|
|
|
class PositionUserResponse(BaseModel):
|
|
id: str
|
|
email: str = ""
|
|
system_role: str = "user"
|
|
position_id: str | None = None
|
|
|
|
|
|
class PositionUsersResponse(BaseModel):
|
|
users: list[PositionUserResponse]
|
|
|
|
|
|
class MyPositionResponse(BaseModel):
|
|
position: PositionResponse | None = None
|
|
|
|
|
|
# ----------------------------------------------------------------- position CRUD
|
|
|
|
|
|
@router.get("", response_model=PositionListResponse, summary="List Positions")
|
|
async def list_positions(request: Request, store: PositionStore = Depends(get_position_store)) -> PositionListResponse:
|
|
await _require_admin(request)
|
|
records = await store.list_positions()
|
|
return PositionListResponse(positions=[PositionResponse(**r) for r in records])
|
|
|
|
|
|
@router.get("/users", response_model=PositionUsersResponse, summary="List All Users + Their Position")
|
|
async def list_position_users(request: Request, store: PositionStore = Depends(get_position_store)) -> PositionUsersResponse:
|
|
await _require_admin(request)
|
|
users = await store.list_all_users()
|
|
return PositionUsersResponse(users=[PositionUserResponse(**u) for u in users])
|
|
|
|
|
|
@router.get("/me", response_model=MyPositionResponse, summary="Get My Effective Position")
|
|
async def get_my_position(request: Request, store: PositionStore = Depends(get_position_store)) -> MyPositionResponse:
|
|
"""The caller's own position — open to any authenticated user (not admin).
|
|
|
|
Returns the explicitly-assigned position, falling back to the default
|
|
position (which unassigned users inherit). ``null`` when neither exists or
|
|
the caller is anonymous. Powers the top-bar 岗位 label.
|
|
"""
|
|
user = await get_optional_user_from_request(request)
|
|
if user is None:
|
|
return MyPositionResponse(position=None)
|
|
record = None
|
|
pid = await store.get_user_position_id(str(user.id))
|
|
if pid:
|
|
record = await store.get_position(pid)
|
|
if record is None:
|
|
default_id = await store.get_default_position_id()
|
|
if default_id:
|
|
record = await store.get_position(default_id)
|
|
return MyPositionResponse(position=PositionResponse(**record) if record else None)
|
|
|
|
|
|
@router.post("", response_model=PositionResponse, status_code=201, summary="Create Position")
|
|
async def create_position(request: Request, body: PositionCreateRequest, store: PositionStore = Depends(get_position_store)) -> PositionResponse:
|
|
await _require_admin(request)
|
|
try:
|
|
record = await store.create_position({"id": uuid.uuid4().hex, "name": body.name.strip(), "description": body.description, "is_default": body.is_default})
|
|
return PositionResponse(**record)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
|
|
|
|
@router.put("/{position_id}", response_model=PositionResponse, summary="Update Position")
|
|
async def update_position(
|
|
request: Request,
|
|
position_id: str,
|
|
body: PositionUpdateRequest,
|
|
store: PositionStore = Depends(get_position_store),
|
|
sync=Depends(get_position_sync),
|
|
) -> PositionResponse:
|
|
await _require_admin(request)
|
|
update_data = body.model_dump(exclude_unset=True)
|
|
if update_data.get("name"):
|
|
update_data["name"] = update_data["name"].strip()
|
|
try:
|
|
record = await store.update_position(position_id, update_data)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
if record is None:
|
|
raise HTTPException(status_code=404, detail="Position not found")
|
|
# Becoming the default changes what unassigned users inherit.
|
|
if update_data.get("is_default"):
|
|
await sync.sync_default_members()
|
|
return PositionResponse(**record)
|
|
|
|
|
|
@router.delete("/{position_id}", status_code=204, summary="Delete Position")
|
|
async def delete_position(
|
|
request: Request,
|
|
position_id: str,
|
|
store: PositionStore = Depends(get_position_store),
|
|
sync=Depends(get_position_sync),
|
|
) -> None:
|
|
await _require_admin(request)
|
|
member_ids = await store.list_member_ids(position_id)
|
|
deleted = await store.delete_position(position_id)
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail="Position not found")
|
|
# Former members now fall back to the default position; re-sync them.
|
|
for uid in member_ids:
|
|
await sync.sync_user(uid)
|
|
|
|
|
|
# ----------------------------------------------------------------- tag bindings
|
|
|
|
|
|
@router.get("/{position_id}/tag-bindings", response_model=TagBindingsResponse, summary="Get Tag Bindings")
|
|
async def get_tag_bindings(request: Request, position_id: str, store: PositionStore = Depends(get_position_store)) -> TagBindingsResponse:
|
|
await _require_admin(request)
|
|
if await store.get_position(position_id) is None:
|
|
raise HTTPException(status_code=404, detail="Position not found")
|
|
bindings = await store.get_tag_bindings(position_id)
|
|
return TagBindingsResponse(position_id=position_id, bindings=bindings)
|
|
|
|
|
|
@router.put("/{position_id}/tag-bindings", response_model=TagBindingsResponse, summary="Set Tag Bindings")
|
|
async def set_tag_bindings(
|
|
request: Request,
|
|
position_id: str,
|
|
body: TagBindingsRequest,
|
|
store: PositionStore = Depends(get_position_store),
|
|
tag_store: TagStore = Depends(get_tag_store),
|
|
sync=Depends(get_position_sync),
|
|
) -> TagBindingsResponse:
|
|
await _require_admin(request)
|
|
if await store.get_position(position_id) is None:
|
|
raise HTTPException(status_code=404, detail="Position not found")
|
|
cleaned = _validate_bindings(body.bindings)
|
|
# Validate every tag id exists and matches its resource type.
|
|
for resource_type, tag_ids in cleaned.items():
|
|
if not tag_ids:
|
|
continue
|
|
known = {t["id"] for t in await tag_store.list_tags(resource_type)}
|
|
for tid in tag_ids:
|
|
if tid not in known:
|
|
raise HTTPException(status_code=422, detail=f"Tag '{tid}' is not a '{resource_type}' tag")
|
|
bindings = await store.set_tag_bindings(position_id, cleaned)
|
|
await sync.sync_position(position_id)
|
|
return TagBindingsResponse(position_id=position_id, bindings=bindings)
|
|
|
|
|
|
# --------------------------------------------------------------------- members
|
|
|
|
|
|
@router.get("/{position_id}/members", response_model=MembersResponse, summary="List Position Members")
|
|
async def list_members(request: Request, position_id: str, store: PositionStore = Depends(get_position_store)) -> MembersResponse:
|
|
await _require_admin(request)
|
|
if await store.get_position(position_id) is None:
|
|
raise HTTPException(status_code=404, detail="Position not found")
|
|
members = await store.list_members(position_id)
|
|
return MembersResponse(members=[MemberResponse(**m) for m in members])
|
|
|
|
|
|
@router.post("/{position_id}/members", response_model=MembersResponse, summary="Add Position Members")
|
|
async def add_members(
|
|
request: Request,
|
|
position_id: str,
|
|
body: MembersMutationRequest,
|
|
store: PositionStore = Depends(get_position_store),
|
|
sync=Depends(get_position_sync),
|
|
) -> MembersResponse:
|
|
await _require_admin(request)
|
|
if await store.get_position(position_id) is None:
|
|
raise HTTPException(status_code=404, detail="Position not found")
|
|
# Re-sync users leaving their previous position too, so its grants are dropped.
|
|
prev_positions = {uid: await store.get_user_position_id(uid) for uid in body.user_ids}
|
|
await store.assign_users(position_id, body.user_ids)
|
|
for uid in body.user_ids:
|
|
await sync.sync_user(uid)
|
|
prev = prev_positions.get(uid)
|
|
if prev and prev != position_id:
|
|
logger.debug("user %s moved from position %s to %s", uid, prev, position_id)
|
|
members = await store.list_members(position_id)
|
|
return MembersResponse(members=[MemberResponse(**m) for m in members])
|
|
|
|
|
|
@router.delete("/{position_id}/members", response_model=MembersResponse, summary="Remove Position Members")
|
|
async def remove_members(
|
|
request: Request,
|
|
position_id: str,
|
|
body: MembersMutationRequest,
|
|
store: PositionStore = Depends(get_position_store),
|
|
sync=Depends(get_position_sync),
|
|
) -> MembersResponse:
|
|
await _require_admin(request)
|
|
if await store.get_position(position_id) is None:
|
|
raise HTTPException(status_code=404, detail="Position not found")
|
|
# Only unassign users that are actually in this position.
|
|
member_ids = set(await store.list_member_ids(position_id))
|
|
victims = [uid for uid in body.user_ids if uid in member_ids]
|
|
await store.unassign_users(victims)
|
|
for uid in victims:
|
|
await sync.sync_user(uid)
|
|
members = await store.list_members(position_id)
|
|
return MembersResponse(members=[MemberResponse(**m) for m in members])
|
|
|
|
|
|
@router.post("/{position_id}/resync", summary="Re-sync Position Members")
|
|
async def resync_position(
|
|
request: Request,
|
|
position_id: str,
|
|
store: PositionStore = Depends(get_position_store),
|
|
sync=Depends(get_position_sync),
|
|
) -> dict:
|
|
await _require_admin(request)
|
|
if await store.get_position(position_id) is None:
|
|
raise HTTPException(status_code=404, detail="Position not found")
|
|
count = await sync.sync_position(position_id)
|
|
return {"synced": count}
|
|
|
|
|
|
# --------------------------------------------------- per-user position (1:1)
|
|
|
|
|
|
@router.put("/users/{user_id}", response_model=PositionResponse | None, summary="Set User Position")
|
|
async def set_user_position(
|
|
request: Request,
|
|
user_id: str,
|
|
body: UserPositionRequest,
|
|
store: PositionStore = Depends(get_position_store),
|
|
sync=Depends(get_position_sync),
|
|
) -> PositionResponse | None:
|
|
await _require_admin(request)
|
|
if body.position_id:
|
|
if await store.get_position(body.position_id) is None:
|
|
raise HTTPException(status_code=404, detail="Position not found")
|
|
await store.assign_users(body.position_id, [user_id])
|
|
else:
|
|
await store.unassign_users([user_id])
|
|
await sync.sync_user(user_id)
|
|
if not body.position_id:
|
|
return None
|
|
record = await store.get_position(body.position_id)
|
|
return PositionResponse(**record) if record else None
|