Files
ctms/backend/app/api/v1/study_active_roles.py
T
Cheng Zhou ab4f0d93ed 统一项目权限读取模型
1、合并列表与详情读取权限,统一使用 subjects:read、project_members:read、monitoring_issues:read 等业务读取 key。

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

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

4、补充项目角色启停保护与抽屉未保存变更守卫,防止停用 PM 或仍有关联成员的角色。
2026-06-04 11:06:03 +08:00

99 lines
3.5 KiB
Python

"""项目角色生效管理API"""
from __future__ import annotations
import uuid
from typing import Annotated
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, 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, *, 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] = []
for item in value:
role = str(item or "").strip()
if not role:
continue
if role == "ADMIN":
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ADMIN 不能作为项目角色")
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,
_=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="项目不存在")
return {"active_roles": study.active_roles or []}
@router.put("", summary="更新项目已生效角色列表")
async def update_active_roles(
study_id: uuid.UUID,
payload: dict,
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="项目不存在")
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}