"""岗位 (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