统一项目权限读取模型

1、合并列表与详情读取权限,统一使用 subjects:read、project_members:read、monitoring_issues:read 等业务读取 key。

2、增加历史读取权限 key 的兼容映射,确保存量项目权限覆盖值不会丢失。

3、同步前端权限工具、路由权限与权限管理页面,避免继续引用已合并的旧权限 key。

4、补充项目角色启停保护与抽屉未保存变更守卫,防止停用 PM 或仍有关联成员的角色。
This commit is contained in:
Cheng Zhou
2026-06-04 11:06:03 +08:00
parent 44d69c2d7b
commit ab4f0d93ed
22 changed files with 852 additions and 349 deletions
+2 -2
View File
@@ -122,7 +122,7 @@ async def _list_ae_records(
@router.get(
"/summary",
response_model=list[AERead],
dependencies=[Depends(require_api_permission("subject_aes:list"))],
dependencies=[Depends(require_api_permission("subject_aes:read"))],
)
async def list_risk_issue_ae(
study_id: uuid.UUID,
@@ -149,7 +149,7 @@ async def list_risk_issue_ae(
@router.get(
"/",
response_model=list[AERead],
dependencies=[Depends(require_api_permission("subject_aes:list"))],
dependencies=[Depends(require_api_permission("subject_aes:read"))],
)
async def list_ae(
study_id: uuid.UUID,
+4 -4
View File
@@ -142,10 +142,10 @@ async def add_member(
@router.get(
"/",
response_model=list[StudyMemberReadWithUser],
dependencies=[Depends(require_api_permission("project_members:list"))],
dependencies=[Depends(require_api_permission("project_members:read"))],
)
@register_api_endpoint(
endpoint_key="project_members:list",
endpoint_key="project_members:read",
module="project_members",
action="read",
description="查询项目成员列表",
@@ -188,10 +188,10 @@ async def list_members(
@router.get(
"/candidates",
response_model=list[UserResponse],
dependencies=[Depends(require_api_permission("project_members:candidates"))],
dependencies=[Depends(require_api_permission("project_members:read"))],
)
@register_api_endpoint(
endpoint_key="project_members:candidates",
endpoint_key="project_members:read",
module="project_members",
action="read",
description="查询项目成员候选人",
@@ -332,7 +332,7 @@ def _normalize_file_rows(filename: str, content: bytes) -> list[dict[str, object
@router.get(
"/issues",
response_model=list[MonitoringVisitIssueRead],
dependencies=[Depends(require_api_permission("monitoring_issues:list"))],
dependencies=[Depends(require_api_permission("monitoring_issues:read"))],
)
async def list_monitoring_visit_issues(
study_id: uuid.UUID,
@@ -421,7 +421,7 @@ async def create_monitoring_visit_issue(
@router.get(
"/issues/export",
response_class=StreamingResponse,
dependencies=[Depends(require_api_permission("monitoring_issues:list"))],
dependencies=[Depends(require_api_permission("monitoring_issues:read"))],
)
async def export_monitoring_visit_issues(
study_id: uuid.UUID,
+41 -6
View File
@@ -9,13 +9,14 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, require_study_roles
from app.core.deps import get_db_session, is_system_admin, require_study_roles
from app.models.study import Study
from app.models.study_member import StudyMember
router = APIRouter(prefix="/active-roles", tags=["active-roles"])
def _normalize_active_roles(value: object) -> list[str]:
def _normalize_active_roles(value: object, *, preserve_pm: bool = False) -> list[str]:
if not isinstance(value, list):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="active_roles 必须是数组")
roles: list[str] = []
@@ -25,15 +26,44 @@ def _normalize_active_roles(value: object) -> list[str]:
continue
if role == "ADMIN":
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ADMIN 不能作为项目角色")
if role == "QA":
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="QA 不能作为项目角色")
if len(role) > 20:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="项目角色长度不能超过20个字符")
if role not in roles:
roles.append(role)
if preserve_pm and "PM" not in roles:
roles.insert(0, "PM")
return roles
async def _ensure_removed_roles_unused(
db: AsyncSession,
study_id: uuid.UUID,
current_roles: object,
next_roles: list[str],
) -> None:
if not isinstance(current_roles, list):
return
removed_roles = [role for role in current_roles if isinstance(role, str) and role not in next_roles]
if not removed_roles:
return
result = await db.execute(
select(StudyMember.role_in_study)
.where(
StudyMember.study_id == study_id,
StudyMember.is_active.is_(True),
StudyMember.role_in_study.in_(removed_roles),
)
.order_by(StudyMember.role_in_study)
)
role_in_use = result.scalars().first()
if role_in_use:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"角色 {role_in_use} 仍有成员使用,不能停用",
)
@router.get("", summary="获取项目已生效角色列表")
async def get_active_roles(
study_id: uuid.UUID,
@@ -51,13 +81,18 @@ async def get_active_roles(
async def update_active_roles(
study_id: uuid.UUID,
payload: dict,
_=Depends(require_study_roles(["PM"])),
current_user=Depends(require_study_roles(["PM"])),
db: Annotated[AsyncSession, Depends(get_db_session)] = None,
) -> dict:
result = await db.execute(select(Study).where(Study.id == study_id))
study = result.scalar_one_or_none()
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
study.active_roles = _normalize_active_roles(payload.get("active_roles", []))
active_roles = _normalize_active_roles(
payload.get("active_roles", []),
preserve_pm=not is_system_admin(current_user),
)
await _ensure_removed_roles_unused(db, study_id, study.active_roles, active_roles)
study.active_roles = active_roles
await db.commit()
return {"active_roles": study.active_roles}
+1 -1
View File
@@ -66,7 +66,7 @@ async def create_history(
@router.get(
"/histories",
response_model=list[SubjectHistoryRead],
dependencies=[Depends(require_api_permission("subject_histories:list"))],
dependencies=[Depends(require_api_permission("subject_histories:read"))],
)
async def list_histories(
study_id: uuid.UUID,
+1 -1
View File
@@ -66,7 +66,7 @@ async def create_subject(
@router.get(
"/",
response_model=list[SubjectRead],
dependencies=[Depends(require_api_permission("subjects:list"))],
dependencies=[Depends(require_api_permission("subjects:read"))],
)
async def list_subjects(
study_id: uuid.UUID,