import uuid import json from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import get_current_user, get_db_session, require_study_member, require_api_permission, require_study_not_locked from app.core.decorators import register_api_endpoint from app.crud import audit as audit_crud from app.crud import member as member_crud from app.crud import study as study_crud from app.crud import user as user_crud from app.schemas.member import StudyMemberCreate, StudyMemberRead, StudyMemberReadWithUser, StudyMemberUpdate from app.schemas.user import UserResponse router = APIRouter() PROJECT_ROLE_RANK = { "ADMIN": 100, "PM": 80, "QA": 60, "PV": 50, "MEDICAL_REVIEW": 50, "CRA": 40, "IMP": 40, } async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): study = await study_crud.get(db, study_id) if not study: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") return study def _role_value(user) -> str: return user.role.value if hasattr(user.role, "value") else str(user.role) def _role_rank(role: str | None) -> int: return PROJECT_ROLE_RANK.get(role or "", 0) async def _ensure_member_mutation_allowed( db: AsyncSession, study_id: uuid.UUID, current_user, *, target_member=None, target_role: str | None = None, ) -> None: if _role_value(current_user) == "ADMIN": return actor_member = await member_crud.get_member(db, study_id, current_user.id) actor_role = actor_member.role_in_study if actor_member else None actor_rank = _role_rank(actor_role) if target_member and target_member.user_id == current_user.id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能修改自己的项目成员权限") if target_member: target_user = await user_crud.get_by_id(db, target_member.user_id) if _role_value(target_user) == "ADMIN": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能修改系统管理员账号的项目权限") if target_member and _role_rank(target_member.role_in_study) >= actor_rank: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只能修改下属项目角色成员") if target_role and _role_rank(target_role) >= actor_rank: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只能授予下属项目角色") @router.post( "/", response_model=StudyMemberRead, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_api_permission("project_members:create")), Depends(require_study_not_locked())], ) @register_api_endpoint( endpoint_key="project_members:create", module="project_members", action="write", description="添加项目成员", default_roles=["PM"], ) async def add_member( study_id: uuid.UUID, member_in: StudyMemberCreate, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> StudyMemberRead: await _ensure_study_exists(db, study_id) await _ensure_member_mutation_allowed(db, study_id, current_user, target_role=member_in.role_in_study) existing = await member_crud.get_member(db, study_id, member_in.user_id) if existing: await _ensure_member_mutation_allowed( db, study_id, current_user, target_member=existing, target_role=member_in.role_in_study, ) if not existing.is_active: updated = await member_crud.update_member( db, existing, StudyMemberUpdate(is_active=True, role_in_study=member_in.role_in_study), ) await audit_crud.log_action( db, study_id=study_id, entity_type="study_member", entity_id=updated.id, action="PROJECT_MEMBER_UPDATED", detail=json.dumps({ "targetName": str(updated.user_id), "before": {"is_active": existing.is_active, "role_in_study": existing.role_in_study}, "after": {"is_active": updated.is_active, "role_in_study": updated.role_in_study}, }, ensure_ascii=False), operator_id=current_user.id, operator_role=current_user.role, ) return updated raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="成员已存在") member = await member_crud.add_member(db, study_id, member_in) await audit_crud.log_action( db, study_id=study_id, entity_type="study_member", entity_id=member.id, action="PROJECT_MEMBER_ADDED", detail=json.dumps({"targetName": str(member.user_id), "after": {"role_in_study": member.role_in_study, "is_active": member.is_active}}, ensure_ascii=False), operator_id=current_user.id, operator_role=current_user.role, ) return member @router.get( "/", response_model=list[StudyMemberReadWithUser], dependencies=[Depends(require_api_permission("project_members:list"))], ) @register_api_endpoint( endpoint_key="project_members:list", module="project_members", action="read", description="查询项目成员列表", default_roles=["PM"], ) async def list_members( study_id: uuid.UUID, skip: int = 0, limit: int = 100, include_inactive: bool = False, db: AsyncSession = Depends(get_db_session), ) -> list[StudyMemberReadWithUser]: await _ensure_study_exists(db, study_id) members = await member_crud.list_members(db, study_id, skip=skip, limit=limit) user_ids = {m.user_id for m in members} users_map = await user_crud.get_users_by_ids(db, user_ids) result: list[StudyMemberReadWithUser] = [] for m in members: user = users_map.get(m.user_id) if not include_inactive: # 仅返回项目内启用的成员 + 账号启用的用户 if not m.is_active: continue if not user or not user.is_active: continue result.append( StudyMemberReadWithUser( id=m.id, study_id=m.study_id, user_id=m.user_id, role_in_study=m.role_in_study, is_active=m.is_active, added_at=m.added_at, user=UserResponse.model_validate(user) if user else None, ) ) return result @router.get( "/candidates", response_model=list[UserResponse], dependencies=[Depends(require_api_permission("project_members:candidates"))], ) @register_api_endpoint( endpoint_key="project_members:candidates", module="project_members", action="read", description="查询项目成员候选人", default_roles=["PM"], ) async def list_member_candidates( study_id: uuid.UUID, skip: int = 0, limit: int = 500, db: AsyncSession = Depends(get_db_session), ) -> list[UserResponse]: await _ensure_study_exists(db, study_id) users = await user_crud.list_active_member_candidates_for_study(db, study_id, skip=skip, limit=limit) return [UserResponse.model_validate(user) for user in users] @router.patch( "/{member_id}", response_model=StudyMemberRead, dependencies=[Depends(require_api_permission("project_members:update")), Depends(require_study_not_locked())], ) @register_api_endpoint( endpoint_key="project_members:update", module="project_members", action="write", description="更新项目成员", default_roles=["PM"], ) async def update_member( study_id: uuid.UUID, member_id: uuid.UUID, member_in: StudyMemberUpdate, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> StudyMemberRead: await _ensure_study_exists(db, study_id) member = await member_crud.get_member_by_id(db, member_id) if not member or member.study_id != study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="成员不存在") await _ensure_member_mutation_allowed( db, study_id, current_user, target_member=member, target_role=member_in.role_in_study, ) before_data = {"role_in_study": member.role_in_study, "is_active": member.is_active} updated = await member_crud.update_member(db, member, member_in) after_data = {"role_in_study": updated.role_in_study, "is_active": updated.is_active} await audit_crud.log_action( db, study_id=study_id, entity_type="study_member", entity_id=updated.id, action="PROJECT_MEMBER_UPDATED", detail=json.dumps({"targetName": str(updated.user_id), "before": before_data, "after": after_data}, ensure_ascii=False), operator_id=current_user.id, operator_role=current_user.role, ) return updated @router.delete( "/{member_id}", response_model=StudyMemberRead, dependencies=[Depends(require_api_permission("project_members:delete")), Depends(require_study_not_locked())], ) @register_api_endpoint( endpoint_key="project_members:delete", module="project_members", action="write", description="删除项目成员", default_roles=["PM"], ) async def remove_member( study_id: uuid.UUID, member_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> StudyMemberRead: await _ensure_study_exists(db, study_id) member = await member_crud.get_member_by_id(db, member_id) if not member or member.study_id != study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="成员不存在") target_user = await user_crud.get_by_id(db, member.user_id) if target_user and _role_value(target_user) == "ADMIN": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="系统管理员不可从项目中移除") await _ensure_member_mutation_allowed(db, study_id, current_user, target_member=member) before_data = {"role_in_study": member.role_in_study, "is_active": member.is_active} removed = await member_crud.remove_member(db, member) await audit_crud.log_action( db, study_id=study_id, entity_type="study_member", entity_id=removed.id, action="PROJECT_MEMBER_REMOVED", detail=json.dumps({"targetName": str(removed.user_id), "before": before_data, "after": {"is_active": removed.is_active}}, ensure_ascii=False), operator_id=current_user.id, operator_role=current_user.role, ) return removed