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_study_roles, require_study_not_locked 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 UserDisplay router = APIRouter() 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 @router.post( "/", response_model=StudyMemberRead, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())], ) 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) existing = await member_crud.get_member(db, study_id, member_in.user_id) if existing: 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_study_member())], ) 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=UserDisplay.model_validate(user) if user else None, ) ) return result @router.patch( "/{member_id}", response_model=StudyMemberRead, dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())], ) 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="成员不存在") 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_study_roles(["PM"])), Depends(require_study_not_locked())], ) 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="成员不存在") 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