ab4f0d93ed
1、合并列表与详情读取权限,统一使用 subjects:read、project_members:read、monitoring_issues:read 等业务读取 key。 2、增加历史读取权限 key 的兼容映射,确保存量项目权限覆盖值不会丢失。 3、同步前端权限工具、路由权限与权限管理页面,避免继续引用已合并的旧权限 key。 4、补充项目角色启停保护与抽屉未保存变更守卫,防止停用 PM 或仍有关联成员的角色。
296 lines
11 KiB
Python
296 lines
11 KiB
Python
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, is_system_admin, 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,
|
|
"PV": 50,
|
|
"QA": 50,
|
|
"CRA": 40,
|
|
"CTA": 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
|
|
|
|
|
|
async def _operator_project_role(db: AsyncSession, study_id: uuid.UUID, current_user) -> str:
|
|
if is_system_admin(current_user):
|
|
return "ADMIN"
|
|
member = await member_crud.get_member(db, study_id, current_user.id)
|
|
return member.role_in_study if member and member.is_active else ""
|
|
|
|
|
|
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 is_system_admin(current_user):
|
|
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 is_system_admin(target_user):
|
|
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=await _operator_project_role(db, study_id, current_user),
|
|
)
|
|
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=await _operator_project_role(db, study_id, current_user),
|
|
)
|
|
return member
|
|
|
|
|
|
@router.get(
|
|
"/",
|
|
response_model=list[StudyMemberReadWithUser],
|
|
dependencies=[Depends(require_api_permission("project_members:read"))],
|
|
)
|
|
@register_api_endpoint(
|
|
endpoint_key="project_members:read",
|
|
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:read"))],
|
|
)
|
|
@register_api_endpoint(
|
|
endpoint_key="project_members:read",
|
|
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=await _operator_project_role(db, study_id, current_user),
|
|
)
|
|
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 is_system_admin(target_user):
|
|
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=await _operator_project_role(db, study_id, current_user),
|
|
)
|
|
return removed
|