统一项目权限读取模型

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,
+47 -212
View File
@@ -5,6 +5,28 @@
from __future__ import annotations
READ_PERMISSION_ALIASES: dict[str, str] = {
"subjects:list": "subjects:read",
"subject_aes:list": "subject_aes:read",
"fees_contracts:list": "fees_contracts:read",
"project_members:list": "project_members:read",
"project_members:candidates": "project_members:read",
"sites:list": "sites:read",
"startup_ethics:list": "startup_ethics:read",
"startup_initiation:list": "startup_initiation:read",
"monitoring_issues:list": "monitoring_issues:read",
"drug_shipments:list": "drug_shipments:read",
"audit_logs:list": "audit_logs:read",
"precautions:list": "precautions:read",
"subject_histories:list": "subject_histories:read",
"material_equipments:list": "material_equipments:read",
}
def canonical_permission_key(endpoint_key: str) -> str:
return READ_PERMISSION_ALIASES.get(endpoint_key, endpoint_key)
# 接口级权限配置
# 格式: "业务操作名": {配置信息}
API_ENDPOINT_PERMISSIONS = {
@@ -16,17 +38,10 @@ API_ENDPOINT_PERMISSIONS = {
"default_roles": ["PM", "CRA"],
"prerequisite_permissions": ["sites:read"],
},
"subjects:list": {
"module": "subjects",
"action": "read",
"description": "查询参与者列表",
"default_roles": ["PM", "CRA", "PV", "QA", "CTA"],
"prerequisite_permissions": [],
},
"subjects:read": {
"module": "subjects",
"action": "read",
"description": "查询参与者详情",
"description": "查询参与者",
"default_roles": ["PM", "CRA", "PV", "QA", "CTA"],
"prerequisite_permissions": [],
},
@@ -81,17 +96,10 @@ API_ENDPOINT_PERMISSIONS = {
"default_roles": ["PM", "CRA", "PV", "QA"],
"prerequisite_permissions": ["subjects:read", "sites:read"],
},
"subject_aes:list": {
"module": "subjects",
"action": "read",
"description": "查询参与者AE列表",
"default_roles": ["PM", "CRA", "PV", "QA", "CTA"],
"prerequisite_permissions": [],
},
"subject_aes:read": {
"module": "subjects",
"action": "read",
"description": "查询参与者AE详情",
"description": "查询参与者AE",
"default_roles": ["PM", "CRA", "PV", "QA", "CTA"],
"prerequisite_permissions": [],
},
@@ -100,14 +108,14 @@ API_ENDPOINT_PERMISSIONS = {
"action": "write",
"description": "更新参与者AE",
"default_roles": ["PM", "CRA", "PV", "QA"],
"prerequisite_permissions": ["subject_aes:list", "subjects:read", "sites:read"],
"prerequisite_permissions": ["subject_aes:read", "subjects:read", "sites:read"],
},
"subject_aes:delete": {
"module": "subjects",
"action": "write",
"description": "删除参与者AE",
"default_roles": ["PM"],
"prerequisite_permissions": ["subject_aes:list", "subjects:read", "sites:read"],
"prerequisite_permissions": ["subject_aes:read", "subjects:read", "sites:read"],
},
# 合同费用管理 (fees_contracts)
"fees_contracts:create": {
@@ -117,17 +125,10 @@ API_ENDPOINT_PERMISSIONS = {
"default_roles": ["PM"],
"prerequisite_permissions": ["sites:read"],
},
"fees_contracts:list": {
"module": "fees",
"action": "read",
"description": "查询合同费用列表",
"default_roles": ["PM", "CRA", "CTA"],
"prerequisite_permissions": [],
},
"fees_contracts:read": {
"module": "fees",
"action": "read",
"description": "查询合同费用详情",
"description": "查询合同费用",
"default_roles": ["PM", "CRA", "CTA"],
"prerequisite_permissions": [],
},
@@ -152,16 +153,10 @@ API_ENDPOINT_PERMISSIONS = {
"description": "添加项目成员",
"default_roles": ["PM"],
},
"project_members:list": {
"project_members:read": {
"module": "project_members",
"action": "read",
"description": "查询项目成员列表",
"default_roles": ["PM"],
},
"project_members:candidates": {
"module": "project_members",
"action": "read",
"description": "查询项目成员候选人",
"description": "查询项目成员",
"default_roles": ["PM"],
},
"project_members:update": {
@@ -183,16 +178,10 @@ API_ENDPOINT_PERMISSIONS = {
"description": "创建中心",
"default_roles": ["PM"],
},
"sites:list": {
"module": "sites",
"action": "read",
"description": "查询中心列表",
"default_roles": ["PM", "CRA", "PV", "QA", "CTA"],
},
"sites:read": {
"module": "sites",
"action": "read",
"description": "查询中心详情",
"description": "查询中心",
"default_roles": ["PM", "CRA", "PV", "QA", "CTA"],
},
"sites:update": {
@@ -214,16 +203,10 @@ API_ENDPOINT_PERMISSIONS = {
"description": "创建伦理记录",
"default_roles": ["PM"],
},
"startup_ethics:list": {
"module": "startup_ethics",
"action": "read",
"description": "查询伦理记录列表",
"default_roles": ["PM", "CRA", "PV", "QA"],
},
"startup_ethics:read": {
"module": "startup_ethics",
"action": "read",
"description": "查询伦理记录详情",
"description": "查询伦理记录",
"default_roles": ["PM", "CRA", "PV", "QA"],
},
"startup_ethics:update": {
@@ -245,16 +228,10 @@ API_ENDPOINT_PERMISSIONS = {
"description": "创建立项记录",
"default_roles": ["PM"],
},
"startup_initiation:list": {
"module": "startup_ethics",
"action": "read",
"description": "查询立项记录列表",
"default_roles": ["PM", "CRA", "PV", "QA"],
},
"startup_initiation:read": {
"module": "startup_ethics",
"action": "read",
"description": "查询立项记录详情",
"description": "查询立项记录",
"default_roles": ["PM", "CRA", "PV", "QA"],
},
"startup_initiation:update": {
@@ -278,16 +255,10 @@ API_ENDPOINT_PERMISSIONS = {
"prerequisite_permissions": ["sites:read", "subjects:read"],
},
# 监查访视问题
"monitoring_issues:list": {
"module": "risk_issues",
"action": "read",
"description": "查询监查访视问题列表",
"default_roles": ["PM", "CRA", "PV", "QA"],
},
"monitoring_issues:read": {
"module": "risk_issues",
"action": "read",
"description": "查询监查访视问题详情",
"description": "查询监查访视问题",
"default_roles": ["PM", "CRA", "PV", "QA"],
},
"monitoring_issues:create": {
@@ -319,17 +290,10 @@ API_ENDPOINT_PERMISSIONS = {
"default_roles": ["PM", "CTA"],
"prerequisite_permissions": ["sites:read"],
},
"drug_shipments:list": {
"module": "materials",
"action": "read",
"description": "查询药物发货列表",
"default_roles": ["PM", "CRA", "CTA"],
"prerequisite_permissions": [],
},
"drug_shipments:read": {
"module": "materials",
"action": "read",
"description": "查询药物发货详情",
"description": "查询药物发货",
"default_roles": ["PM", "CRA", "CTA"],
"prerequisite_permissions": [],
},
@@ -377,16 +341,10 @@ API_ENDPOINT_PERMISSIONS = {
"prerequisite_permissions": ["subjects:read", "sites:read"],
},
# 审计日志管理
"audit_logs:list": {
"module": "audit_export",
"action": "read",
"description": "查询审计日志列表",
"default_roles": ["PM"],
},
"audit_logs:read": {
"module": "audit_export",
"action": "read",
"description": "查询审计日志详情",
"description": "查询审计日志",
"default_roles": ["PM"],
},
"audit_logs:export": {
@@ -402,16 +360,10 @@ API_ENDPOINT_PERMISSIONS = {
"description": "创建注意事项",
"default_roles": ["PM", "CRA", "PV", "QA"],
},
"precautions:list": {
"module": "shared_library",
"action": "read",
"description": "查询注意事项列表",
"default_roles": ["PM", "CRA", "PV", "QA", "CTA"],
},
"precautions:read": {
"module": "shared_library",
"action": "read",
"description": "查询注意事项详情",
"description": "查询注意事项",
"default_roles": ["PM", "CRA", "PV", "QA", "CTA"],
},
"precautions:update": {
@@ -453,7 +405,7 @@ API_ENDPOINT_PERMISSIONS = {
},
"setup_config:delete_version": {
"module": "setup_config",
"action": "write",
"action": "delete",
"description": "删除立项配置版本",
"default_roles": ["PM"],
},
@@ -496,107 +448,41 @@ API_ENDPOINT_PERMISSIONS = {
"default_roles": ["PM"],
},
# 模块附件管理
"fees_contracts_attachments:create": {
"module": "fees",
"action": "write",
"description": "上传合同费用附件",
"default_roles": ["PM"],
},
"fees_contracts_attachments:read": {
"module": "fees",
"action": "read",
"description": "查询合同费用附件",
"default_roles": ["PM", "CRA", "CTA"],
},
"fees_contracts_attachments:delete": {
"module": "fees",
"action": "write",
"description": "删除合同费用附件",
"default_roles": ["PM"],
},
"startup_initiation_attachments:create": {
"module": "startup_ethics",
"action": "write",
"description": "上传立项记录附件",
"default_roles": ["PM"],
},
"startup_initiation_attachments:read": {
"module": "startup_ethics",
"action": "read",
"description": "查询立项记录附件",
"default_roles": ["PM", "CRA", "PV", "QA"],
},
"startup_initiation_attachments:delete": {
"module": "startup_ethics",
"action": "write",
"description": "删除立项记录附件",
"default_roles": ["PM"],
},
"startup_ethics_attachments:create": {
"module": "startup_ethics",
"action": "write",
"description": "上传伦理记录附件",
"default_roles": ["PM"],
},
"startup_ethics_attachments:read": {
"module": "startup_ethics",
"action": "read",
"description": "查询伦理记录附件",
"default_roles": ["PM", "CRA", "PV", "QA"],
},
"startup_ethics_attachments:delete": {
"module": "startup_ethics",
"action": "write",
"description": "删除伦理记录附件",
"default_roles": ["PM"],
},
"startup_auth_attachments:create": {
"module": "startup_auth",
"action": "write",
"description": "上传启动授权附件",
"default_roles": ["PM"],
},
"startup_auth_attachments:read": {
"module": "startup_auth",
"action": "read",
"description": "查询启动授权附件",
"default_roles": ["PM", "CRA", "PV", "CTA"],
},
"startup_auth_attachments:delete": {
"module": "startup_auth",
"action": "write",
"description": "删除启动授权附件",
"default_roles": ["PM"],
},
"drug_shipments_attachments:create": {
"module": "materials",
"action": "write",
"description": "上传药物发货附件",
"default_roles": ["PM", "CTA"],
},
"drug_shipments_attachments:read": {
"module": "materials",
"action": "read",
"description": "查询药物发货附件",
"default_roles": ["PM", "CRA", "CTA"],
},
"drug_shipments_attachments:delete": {
"module": "materials",
"action": "write",
"description": "删除药物发货附件",
"default_roles": ["PM"],
},
"precautions_attachments:create": {
"module": "shared_library",
"material_equipments_attachments:delete": {
"module": "materials",
"action": "write",
"description": "上传注意事项附件",
"default_roles": ["PM", "CRA", "PV", "QA"],
},
"precautions_attachments:read": {
"module": "shared_library",
"action": "read",
"description": "查询注意事项附件",
"default_roles": ["PM", "CRA", "PV", "QA", "CTA"],
"description": "删除物资设备附件",
"default_roles": ["PM"],
},
"precautions_attachments:delete": {
"module": "shared_library",
@@ -604,18 +490,6 @@ API_ENDPOINT_PERMISSIONS = {
"description": "删除注意事项附件",
"default_roles": ["PM"],
},
"faq_attachments:create": {
"module": "shared_library",
"action": "write",
"description": "上传FAQ附件",
"default_roles": ["PM", "CRA", "PV", "QA"],
},
"faq_attachments:read": {
"module": "shared_library",
"action": "read",
"description": "查询FAQ附件",
"default_roles": ["PM", "CRA", "PV", "QA", "CTA"],
},
"faq_attachments:delete": {
"module": "shared_library",
"action": "write",
@@ -693,17 +567,10 @@ API_ENDPOINT_PERMISSIONS = {
"default_roles": ["PM", "CRA"],
"prerequisite_permissions": ["subjects:read"],
},
"subject_histories:list": {
"module": "subjects",
"action": "read",
"description": "查询病史记录列表",
"default_roles": ["PM", "CRA", "PV", "QA", "CTA"],
"prerequisite_permissions": [],
},
"subject_histories:read": {
"module": "subjects",
"action": "read",
"description": "查询病史记录详情",
"description": "查询病史记录",
"default_roles": ["PM", "CRA", "PV", "QA", "CTA"],
"prerequisite_permissions": [],
},
@@ -728,16 +595,10 @@ API_ENDPOINT_PERMISSIONS = {
"description": "创建物资设备",
"default_roles": ["PM", "CRA"],
},
"material_equipments:list": {
"module": "materials",
"action": "read",
"description": "查询物资设备列表",
"default_roles": ["PM", "CRA", "PV", "QA", "CTA"],
},
"material_equipments:read": {
"module": "materials",
"action": "read",
"description": "查询物资设备详情",
"description": "查询物资设备",
"default_roles": ["PM", "CRA", "PV", "QA", "CTA"],
},
"material_equipments:update": {
@@ -784,9 +645,7 @@ API_ENDPOINT_PERMISSIONS = {
OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
"subjects": {
"read": [
"subjects:list",
"subjects:read",
"subject_aes:list",
"subject_aes:read",
],
"write": [
@@ -810,7 +669,6 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
},
"risk_issues": {
"read": [
"monitoring_issues:list",
"monitoring_issues:read",
],
"write": [
@@ -821,22 +679,18 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
},
"fees": {
"read": [
"fees_contracts:list",
"fees_contracts:read",
"fees_contracts_attachments:read",
],
"write": [
"fees_contracts:create",
"fees_contracts:update",
"fees_contracts:delete",
"fees_contracts_attachments:create",
"fees_contracts_attachments:delete",
],
},
"project_members": {
"read": [
"project_members:list",
"project_members:candidates",
"project_members:read",
],
"write": [
"project_members:create",
@@ -846,7 +700,6 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
},
"sites": {
"read": [
"sites:list",
"sites:read",
],
"write": [
@@ -857,12 +710,8 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
},
"startup_ethics": {
"read": [
"startup_ethics:list",
"startup_ethics:read",
"startup_initiation:list",
"startup_initiation:read",
"startup_ethics_attachments:read",
"startup_initiation_attachments:read",
],
"write": [
"startup_ethics:create",
@@ -871,22 +720,18 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
"startup_initiation:create",
"startup_initiation:update",
"startup_initiation:delete",
"startup_ethics_attachments:create",
"startup_ethics_attachments:delete",
"startup_initiation_attachments:create",
"startup_initiation_attachments:delete",
],
},
"startup_auth": {
"read": [
"startup_auth:read",
"startup_auth_attachments:read",
],
"write": [
"startup_auth:create",
"startup_auth:update",
"startup_auth:delete",
"startup_auth_attachments:create",
"startup_auth_attachments:delete",
],
},
@@ -898,11 +743,8 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
},
"materials": {
"read": [
"drug_shipments:list",
"drug_shipments:read",
"material_equipments:list",
"material_equipments:read",
"drug_shipments_attachments:read",
],
"write": [
"drug_shipments:create",
@@ -911,13 +753,12 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
"material_equipments:create",
"material_equipments:update",
"material_equipments:delete",
"drug_shipments_attachments:create",
"drug_shipments_attachments:delete",
"material_equipments_attachments:delete",
],
},
"audit_export": {
"read": [
"audit_logs:list",
"audit_logs:read",
"audit_logs:export",
],
@@ -925,7 +766,6 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
},
"subject_histories": {
"read": [
"subject_histories:list",
"subject_histories:read",
],
"write": [
@@ -965,18 +805,14 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
},
"shared_library": {
"read": [
"precautions:list",
"precautions:read",
"precautions_attachments:read",
"faq:read",
"faq_category:read",
"faq_attachments:read",
],
"write": [
"precautions:create",
"precautions:update",
"precautions:delete",
"precautions_attachments:create",
"precautions_attachments:delete",
"faq:create",
"faq:update",
@@ -986,7 +822,6 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
"faq_category:delete",
"faq_reply:create",
"faq_reply:delete",
"faq_attachments:create",
"faq_attachments:delete",
],
},
@@ -1010,8 +845,8 @@ OPERATION_PREREQUISITES: dict[str, list[str]] = {
# 参与者AE
"subject_aes:create": ["subjects:read", "sites:read"],
"subject_aes:update": ["subject_aes:list", "subjects:read", "sites:read"],
"subject_aes:delete": ["subject_aes:list", "subjects:read", "sites:read"],
"subject_aes:update": ["subject_aes:read", "subjects:read", "sites:read"],
"subject_aes:delete": ["subject_aes:read", "subjects:read", "sites:read"],
# 合同费用
"fees_contracts:create": ["sites:read"],
+14 -2
View File
@@ -7,7 +7,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.api_endpoint_permission import ApiEndpointPermission
from app.models.study import Study
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, OPERATION_PREREQUISITES, PROJECT_PERMISSION_ROLES
from app.core.api_permissions import (
API_ENDPOINT_PERMISSIONS,
OPERATION_PREREQUISITES,
PROJECT_PERMISSION_ROLES,
canonical_permission_key,
)
from app.core.permission_cache import get_permission_cache
@@ -34,7 +39,10 @@ async def _get_project_permission_overrides(
permissions: dict[str, dict[str, bool]] = {}
for row in rows:
permissions.setdefault(row.role, {})[row.endpoint_key] = row.allowed
endpoint_key = canonical_permission_key(row.endpoint_key)
role_permissions = permissions.setdefault(row.role, {})
if row.endpoint_key == endpoint_key or endpoint_key not in role_permissions:
role_permissions[endpoint_key] = row.allowed
cache.set_project_permissions(study_id, permissions)
return permissions
@@ -48,6 +56,7 @@ async def role_has_api_permission(
check_prerequisites: bool = True,
) -> bool:
"""检查角色是否有权访问特定接口"""
endpoint_key = canonical_permission_key(endpoint_key)
if endpoint_key not in API_ENDPOINT_PERMISSIONS:
return False
@@ -93,6 +102,7 @@ async def get_missing_prerequisites(
return []
missing = []
endpoint_key = canonical_permission_key(endpoint_key)
prerequisites = OPERATION_PREREQUISITES.get(endpoint_key, [])
for prereq_endpoint in prerequisites:
has_prereq = await role_has_api_permission(
@@ -132,6 +142,7 @@ async def get_api_endpoint_permissions(
if role not in matrix:
matrix[role] = {}
for endpoint_key, allowed in endpoints.items():
endpoint_key = canonical_permission_key(endpoint_key)
if endpoint_key not in API_ENDPOINT_PERMISSIONS:
continue
matrix[role][endpoint_key] = {"allowed": allowed}
@@ -153,6 +164,7 @@ async def replace_api_endpoint_permissions(
if role in ("ADMIN", "PM"):
continue
for endpoint_key, allowed in endpoints.items():
endpoint_key = canonical_permission_key(endpoint_key)
if endpoint_key not in API_ENDPOINT_PERMISSIONS:
continue
result = await db.execute(
+176 -64
View File
@@ -60,12 +60,107 @@ def test_all_backend_permission_guards_are_configurable():
assert used_keys <= set(API_ENDPOINT_PERMISSIONS)
def test_read_permissions_do_not_duplicate_list_and_detail_entries():
"""同一业务对象的列表和详情读取能力应在权限矩阵中合并为一个读取权限。"""
merged_legacy_keys = {
"subjects:list",
"subject_aes:list",
"fees_contracts:list",
"project_members:list",
"project_members:candidates",
"sites:list",
"startup_ethics:list",
"startup_initiation:list",
"monitoring_issues:list",
"drug_shipments:list",
"audit_logs:list",
"precautions:list",
"subject_histories:list",
"material_equipments:list",
}
assert merged_legacy_keys.isdisjoint(API_ENDPOINT_PERMISSIONS)
expected_read_keys = {
"subjects:read",
"subject_aes:read",
"fees_contracts:read",
"project_members:read",
"sites:read",
"startup_ethics:read",
"startup_initiation:read",
"monitoring_issues:read",
"drug_shipments:read",
"audit_logs:read",
"precautions:read",
"subject_histories:read",
"material_equipments:read",
}
assert expected_read_keys <= set(API_ENDPOINT_PERMISSIONS)
@pytest.mark.asyncio
async def test_legacy_read_permission_overrides_are_folded_into_canonical_read_key(db_session: AsyncSession):
"""历史项目权限中的列表读取 key 应继续作用到合并后的读取权限。"""
study_id = uuid.uuid4()
db_session.add_all(
(
ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="subjects:list",
allowed=False,
),
ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="project_members:candidates",
allowed=True,
),
)
)
await db_session.commit()
matrix = await get_api_endpoint_permissions(db_session, study_id)
assert "subjects:list" not in matrix["CRA"]
assert matrix["CRA"]["subjects:read"]["allowed"] is False
assert "project_members:candidates" not in matrix["CRA"]
assert matrix["CRA"]["project_members:read"]["allowed"] is True
def test_backend_read_routes_use_canonical_read_permission_keys():
"""列表接口和详情接口应共用合并后的读取权限 key。"""
api_dir = Path(__file__).resolve().parents[1] / "app" / "api" / "v1"
merged_legacy_keys = {
"subjects:list",
"subject_aes:list",
"fees_contracts:list",
"project_members:list",
"project_members:candidates",
"sites:list",
"startup_ethics:list",
"startup_initiation:list",
"monitoring_issues:list",
"drug_shipments:list",
"audit_logs:list",
"precautions:list",
"subject_histories:list",
"material_equipments:list",
}
source = "\n".join(path.read_text() for path in api_dir.rglob("*.py"))
for endpoint_key in merged_legacy_keys:
assert f'require_api_permission("{endpoint_key}")' not in source
assert f'_ensure_study_access(db, study_id, current_user, "{endpoint_key}")' not in source
def test_subject_history_permissions_are_labeled_as_medical_history_under_subjects():
"""病史记录权限应归入参与者管理,而不是独立的历史模块。"""
expected_descriptions = {
"subject_histories:create": "创建病史记录",
"subject_histories:list": "查询病史记录列表",
"subject_histories:read": "查询病史记录详情",
"subject_histories:read": "查询病史记录",
"subject_histories:update": "更新病史记录",
"subject_histories:delete": "删除病史记录",
}
@@ -84,8 +179,7 @@ def test_contract_fee_permissions_do_not_include_legacy_finance_contracts():
expected_descriptions = {
"fees_contracts:create": "创建合同费用",
"fees_contracts:list": "查询合同费用列表",
"fees_contracts:read": "查询合同费用详情",
"fees_contracts:read": "查询合同费用",
"fees_contracts:update": "更新合同费用",
"fees_contracts:delete": "删除合同费用",
}
@@ -95,46 +189,56 @@ def test_contract_fee_permissions_do_not_include_legacy_finance_contracts():
assert API_ENDPOINT_PERMISSIONS[endpoint_key]["description"] == description
def test_setup_config_delete_version_permission_is_labeled_as_delete():
"""删除立项配置版本应在权限矩阵中展示为删除,而不是写入。"""
permission = API_ENDPOINT_PERMISSIONS["setup_config:delete_version"]
assert permission["module"] == "setup_config"
assert permission["action"] == "delete"
assert permission["description"] == "删除立项配置版本"
def test_generic_attachment_permissions_are_removed_from_matrix():
"""前端和模板迁移后,项目权限矩阵不再暴露通用 attachments:*。"""
assert not any(endpoint_key.startswith("attachments:") for endpoint_key in API_ENDPOINT_PERMISSIONS)
def test_module_attachment_permissions_are_available():
"""模块附件权限应接入权限矩阵。"""
"""附件权限矩阵只保留独立删除项,创建与读取由业务主记录权限控制"""
expected_keys = {
"fees_contracts_attachments:create",
"fees_contracts_attachments:read",
"fees_contracts_attachments:delete",
"startup_initiation_attachments:create",
"startup_initiation_attachments:read",
"startup_initiation_attachments:delete",
"startup_ethics_attachments:create",
"startup_ethics_attachments:read",
"startup_ethics_attachments:delete",
"startup_auth_attachments:create",
"startup_auth_attachments:read",
"startup_auth_attachments:delete",
"drug_shipments_attachments:create",
"drug_shipments_attachments:read",
"drug_shipments_attachments:delete",
"precautions_attachments:create",
"precautions_attachments:read",
"material_equipments_attachments:delete",
"precautions_attachments:delete",
"faq_attachments:create",
"faq_attachments:read",
"faq_attachments:delete",
}
assert expected_keys <= set(API_ENDPOINT_PERMISSIONS)
removed_action_keys = {
f"{prefix}:{action}"
for prefix in (
"fees_contracts_attachments",
"startup_initiation_attachments",
"startup_ethics_attachments",
"startup_auth_attachments",
"drug_shipments_attachments",
"material_equipments_attachments",
"precautions_attachments",
"faq_attachments",
)
for action in ("create", "read")
}
assert removed_action_keys.isdisjoint(API_ENDPOINT_PERMISSIONS)
def test_precautions_permissions_use_business_aligned_keys():
"""注意事项权限应使用业务一致的英文 key,并归入共享库父模块。"""
expected_descriptions = {
"precautions:create": "创建注意事项",
"precautions:list": "查询注意事项列表",
"precautions:read": "查询注意事项详情",
"precautions:read": "查询注意事项",
"precautions:update": "更新注意事项",
"precautions:delete": "删除注意事项",
}
@@ -200,31 +304,11 @@ def test_legacy_fee_attachment_runtime_code_is_removed():
assert offenders == []
def test_attachment_permissions_use_module_permissions():
"""附件鉴权应使用模块附件权限,不再回退通用 attachments:*。"""
def test_attachment_permissions_do_not_fall_back_to_generic_permissions():
"""附件鉴权回退通用 attachments:*,业务附件由父业务权限或删除附件权限控制"""
attachments_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "attachments.py"
source = attachments_path.read_text()
expected_entity_modules = {
'"contract_fee_contract": "fees_contracts_attachments"',
'"contract_fee_voucher": "fees_contracts_attachments"',
'"contract_fee_invoice": "fees_contracts_attachments"',
'"startup_feasibility": "startup_initiation_attachments"',
'"startup_ethics": "startup_ethics_attachments"',
'"startup_kickoff": "startup_auth_attachments"',
'"startup_kickoff_minutes": "startup_auth_attachments"',
'"startup_kickoff_signin": "startup_auth_attachments"',
'"startup_kickoff_ppt": "startup_auth_attachments"',
'"training_authorization": "startup_auth_attachments"',
'"drug_shipment": "drug_shipments_attachments"',
'"precaution": "precautions_attachments"',
'"faq_replies": "faq_attachments"',
}
for expected in expected_entity_modules:
assert expected in source
assert 'f"{module_prefix}:{operation}"' in source
assert 'f"attachments:{operation}"' not in source
assert "role_has_api_permission(" in source
assert "parent_permission" in source
@@ -237,15 +321,30 @@ def test_attachment_parent_permissions_cover_business_modules():
source = attachments_path.read_text()
expected_parent_permissions = {
'"fees_contracts:read" if action == "read" else "fees_contracts:update"',
'return f"startup_initiation:{parent_action}"',
'return f"startup_ethics:{parent_action}"',
'"startup_auth:read" if action == "read" else "startup_auth:update"',
'"drug_shipments:read" if action == "read" else "drug_shipments:update"',
'"precautions:read" if action == "read" else "precautions:update"',
'return "faq:read"',
'return "faq_reply:delete"',
'return "faq_reply:create"',
'"create": "fees_contracts:create"',
'"read": "fees_contracts:read"',
'"delete": "fees_contracts_attachments:delete"',
'"create": "startup_initiation:create"',
'"read": "startup_initiation:read"',
'"delete": "startup_initiation_attachments:delete"',
'"create": "startup_ethics:create"',
'"read": "startup_ethics:read"',
'"delete": "startup_ethics_attachments:delete"',
'"create": "startup_auth:create"',
'"read": "startup_auth:read"',
'"delete": "startup_auth_attachments:delete"',
'"create": "drug_shipments:create"',
'"read": "drug_shipments:read"',
'"delete": "drug_shipments_attachments:delete"',
'"create": "material_equipments:update"',
'"read": "material_equipments:read"',
'"delete": "material_equipments_attachments:delete"',
'"create": "precautions:create"',
'"read": "precautions:read"',
'"delete": "precautions_attachments:delete"',
'"create": "faq_reply:create"',
'"read": "faq:read"',
'"delete": "faq_attachments:delete"',
}
for expected in expected_parent_permissions:
@@ -256,13 +355,11 @@ def test_startup_ethics_permissions_use_visible_business_language():
"""立项与伦理权限矩阵应使用前台一致的业务名称。"""
expected_descriptions = {
"startup_initiation:create": "创建立项记录",
"startup_initiation:list": "查询立项记录列表",
"startup_initiation:read": "查询立项记录详情",
"startup_initiation:read": "查询立项记录",
"startup_initiation:update": "更新立项记录",
"startup_initiation:delete": "删除立项记录",
"startup_ethics:create": "创建伦理记录",
"startup_ethics:list": "查询伦理记录列表",
"startup_ethics:read": "查询伦理记录详情",
"startup_ethics:read": "查询伦理记录",
"startup_ethics:update": "更新伦理记录",
"startup_ethics:delete": "删除伦理记录",
}
@@ -294,6 +391,19 @@ def test_startup_auth_permissions_only_include_wired_operations():
assert stale_keys.isdisjoint(endpoint_keys)
def test_training_authorization_list_can_filter_by_site_name():
"""启动会详情页查询培训授权时应支持按当前中心过滤,避免串中心显示。"""
route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "startup.py"
crud_path = Path(__file__).resolve().parents[1] / "app" / "crud" / "startup.py"
route_source = route_path.read_text()
crud_source = crud_path.read_text()
assert "site_name: str | None = None" in route_source
assert "site_names=site_names, site_name=site_name" in route_source
assert "site_name: str | None = None" in crud_source
assert "TrainingAuthorization.site_name == site_name" in crud_source
def test_visit_list_permission_matches_wired_visit_endpoints():
"""访视当前没有详情查询接口,列表接口应只使用 visits:list。"""
visits_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "visits.py"
@@ -313,8 +423,8 @@ def test_ae_subject_and_summary_share_read_permission():
list_chunk = source[source.index('@router.get(\n "/",') : source.index('@router.get(\n "/{ae_id}"')]
detail_chunk = source[source.index('response_model=AERead,\n dependencies=[Depends(require_api_permission("subject_aes:read"))]') : source.index('@router.patch')]
assert 'require_api_permission("subject_aes:list")' in summary_chunk
assert 'require_api_permission("subject_aes:list")' in list_chunk
assert 'require_api_permission("subject_aes:read")' in summary_chunk
assert 'require_api_permission("subject_aes:read")' in list_chunk
assert 'require_api_permission("subject_aes:read")' in detail_chunk
@@ -322,8 +432,7 @@ def test_ae_mutation_permissions_are_under_subject_management():
"""AE维护发生在参与者详情内,风险问题模块只保留汇总读取权限。"""
expected_descriptions = {
"subject_aes:create": "创建参与者AE",
"subject_aes:list": "查询参与者AE列表",
"subject_aes:read": "查询参与者AE详情",
"subject_aes:read": "查询参与者AE",
"subject_aes:update": "更新参与者AE",
"subject_aes:delete": "删除参与者AE",
}
@@ -371,8 +480,7 @@ def test_risk_issue_monitoring_visit_uses_monitoring_issue_permissions():
import_chunk = source[source.index('@router.post(\n "/issues/import"') :]
expected_descriptions = {
"monitoring_issues:list": "查询监查访视问题列表",
"monitoring_issues:read": "查询监查访视问题详情",
"monitoring_issues:read": "查询监查访视问题",
"monitoring_issues:create": "创建监查访视问题",
"monitoring_issues:update": "更新监查访视问题",
"monitoring_issues:delete": "删除监查访视问题",
@@ -381,8 +489,8 @@ def test_risk_issue_monitoring_visit_uses_monitoring_issue_permissions():
assert API_ENDPOINT_PERMISSIONS[endpoint_key]["module"] == "risk_issues"
assert API_ENDPOINT_PERMISSIONS[endpoint_key]["description"] == description
assert 'require_api_permission("monitoring_issues:list")' in list_chunk
assert 'require_api_permission("monitoring_issues:list")' in export_chunk
assert 'require_api_permission("monitoring_issues:read")' in list_chunk
assert 'require_api_permission("monitoring_issues:read")' in export_chunk
assert 'require_api_permission("monitoring_issues:read")' in detail_chunk
assert 'require_api_permission("monitoring_issues:create")' in create_chunk
assert 'require_api_permission("monitoring_issues:create")' in import_chunk
@@ -404,6 +512,7 @@ def test_document_service_uses_specific_document_permission_keys():
source = service_path.read_text()
assert '"create_document": "documents:create"' in source
assert '"update_document": "documents:update"' in source
assert '"create_version": "documents:update"' in source
assert '"distribute": "documents:update"' in source
assert '"delete_document": "documents:delete"' in source
@@ -416,11 +525,14 @@ def test_document_routes_do_not_hardcode_admin_for_matrix_controlled_actions():
route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "documents.py"
source = route_path.read_text()
update_document_chunk = source[source.index("async def update_document") : source.index("@router.delete", source.index("async def update_document"))]
delete_document_chunk = source[source.index("async def delete_document") : source.index("@router.post", source.index("async def delete_document"))]
delete_version_chunk = source[source.index("async def delete_version") : source.index("@router.post", source.index("async def delete_version"))]
assert "require_roles" not in update_document_chunk
assert "require_roles" not in delete_document_chunk
assert "require_roles" not in delete_version_chunk
assert "仅管理员可编辑文档" not in update_document_chunk
assert "仅管理员可删除文档" not in delete_document_chunk
assert "仅管理员可删除版本" not in delete_version_chunk
@@ -62,7 +62,8 @@ async def test_replace_api_endpoint_permissions_single_role(db_session: AsyncSes
assert isinstance(result, dict)
assert "CRA" in result
assert result["CRA"]["subjects:create"]["allowed"] is True
assert result["CRA"]["subjects:list"]["allowed"] is True
assert "subjects:list" not in result["CRA"]
assert result["CRA"]["subjects:read"]["allowed"] is True
assert result["CRA"]["subjects:update"]["allowed"] is True
@@ -85,11 +86,35 @@ async def test_replace_api_endpoint_permissions_multiple_roles(db_session: Async
result = await replace_api_endpoint_permissions(db_session, study_id, payload)
assert result["CRA"]["subjects:create"]["allowed"] is True
assert result["CRA"]["subjects:list"]["allowed"] is True
assert result["PV"]["subjects:list"]["allowed"] is True
assert "subjects:list" not in result["CRA"]
assert result["CRA"]["subjects:read"]["allowed"] is True
assert "subjects:list" not in result["PV"]
assert result["PV"]["subjects:read"]["allowed"] is True
@pytest.mark.asyncio
async def test_replace_api_endpoint_permissions_accepts_legacy_read_alias_denials(db_session: AsyncSession):
"""历史列表读取 key 写入时应归并到当前读取权限,拒绝值不能被默认权限吞掉。"""
study_id = uuid.uuid4()
result = await replace_api_endpoint_permissions(
db_session,
study_id,
{
"CRA": {
"subjects:list": False,
}
},
)
assert "subjects:list" not in result["CRA"]
assert result["CRA"]["subjects:read"]["allowed"] is False
stored = await get_api_endpoint_permissions(db_session, study_id)
assert "subjects:list" not in stored["CRA"]
assert stored["CRA"]["subjects:read"]["allowed"] is False
@pytest.mark.asyncio
async def test_replace_api_endpoint_permissions_deny(db_session: AsyncSession):
"""测试替换权限为拒绝"""
@@ -152,11 +177,11 @@ async def test_replace_api_endpoint_permissions_multiple_endpoints(db_session: A
result = await replace_api_endpoint_permissions(db_session, study_id, payload)
assert result["CRA"]["subjects:create"]["allowed"] is True
assert result["CRA"]["subjects:list"]["allowed"] is True
assert result["CRA"]["subjects:read"]["allowed"] is True
assert result["CRA"]["subjects:update"]["allowed"] is True
assert result["CRA"]["subjects:delete"]["allowed"] is False
assert result["CRA"]["subject_aes:create"]["allowed"] is True
assert result["CRA"]["subject_aes:list"]["allowed"] is True
assert result["CRA"]["subject_aes:read"]["allowed"] is True
@pytest.mark.asyncio
@@ -234,7 +259,7 @@ async def test_replace_api_endpoint_permissions_partial_update(db_session: Async
# POST权限应该被更新
assert result["CRA"]["subjects:create"]["allowed"] is False
# GET权限应该保持不变
assert result["CRA"]["subjects:list"]["allowed"] is True
assert result["CRA"]["subjects:read"]["allowed"] is True
@pytest.mark.asyncio
@@ -60,7 +60,12 @@ async def test_materials_module_only_contains_drug_flow_attachments_and_equipmen
if op["module"] == "materials"
}
assert material_prefixes == {"drug_shipments", "drug_shipments_attachments", "material_equipments"}
assert material_prefixes == {
"drug_shipments",
"drug_shipments_attachments",
"material_equipments",
"material_equipments_attachments",
}
@pytest.mark.asyncio
+136
View File
@@ -0,0 +1,136 @@
import uuid
from dataclasses import dataclass
import pytest
from fastapi import HTTPException
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.study_active_roles import _normalize_active_roles, update_active_roles
@dataclass
class UserStub:
id: uuid.UUID
is_admin: bool = False
def test_normalize_active_roles_allows_renamed_qa_project_role():
assert _normalize_active_roles(["PM", "QA", "CTA", "QA", "", None]) == ["PM", "QA", "CTA"]
def test_normalize_active_roles_still_rejects_system_admin_role():
with pytest.raises(HTTPException) as exc_info:
_normalize_active_roles(["ADMIN"])
assert exc_info.value.status_code == 422
assert exc_info.value.detail == "ADMIN 不能作为项目角色"
def test_project_pm_update_keeps_pm_active_role():
assert _normalize_active_roles(["PV", "CRA"], preserve_pm=True) == ["PM", "PV", "CRA"]
def test_system_admin_update_can_remove_pm_active_role():
assert _normalize_active_roles(["PV", "CRA"], preserve_pm=False) == ["PV", "CRA"]
@pytest.mark.asyncio
async def test_project_pm_cannot_remove_pm_from_active_roles(db_session: AsyncSession):
user_id = uuid.uuid4()
study_id = uuid.uuid4()
await db_session.execute(
text(
"""
INSERT INTO studies (id, code, name, status, is_locked, visit_schedule, active_roles)
VALUES (:id, :code, :name, :status, :is_locked, :visit_schedule, :active_roles)
"""
),
{
"id": str(study_id),
"code": f"PROJECT-PM-ACTIVE-ROLE-{study_id.hex[:8]}",
"name": "PROJECT-PM-ACTIVE-ROLE",
"status": "ACTIVE",
"is_locked": False,
"visit_schedule": "[]",
"active_roles": '["PM","PV","CRA"]',
},
)
await db_session.commit()
result = await update_active_roles(
study_id=study_id,
payload={"active_roles": ["PV", "CRA"]},
current_user=UserStub(id=user_id),
db=db_session,
)
assert result["active_roles"] == ["PM", "PV", "CRA"]
@pytest.mark.asyncio
async def test_cannot_remove_active_role_assigned_to_active_member(db_session: AsyncSession):
user_id = uuid.uuid4()
member_id = uuid.uuid4()
study_id = uuid.uuid4()
await db_session.execute(
text(
"""
INSERT INTO studies (id, code, name, status, is_locked, visit_schedule, active_roles)
VALUES (:id, :code, :name, :status, :is_locked, :visit_schedule, :active_roles)
"""
),
{
"id": str(study_id),
"code": f"PROJECT-ROLE-IN-USE-{study_id.hex[:8]}",
"name": "PROJECT-ROLE-IN-USE",
"status": "ACTIVE",
"is_locked": False,
"visit_schedule": "[]",
"active_roles": '["PM","PV","CRA"]',
},
)
await db_session.execute(
text(
"""
INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status)
VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status)
"""
),
{
"id": str(user_id),
"email": f"{user_id.hex}@example.com",
"password_hash": "hash",
"full_name": "PV User",
"clinical_department": "Clinical",
"is_admin": False,
"status": "ACTIVE",
},
)
await db_session.execute(
text(
"""
INSERT INTO study_members (id, study_id, user_id, role_in_study, is_active)
VALUES (:id, :study_id, :user_id, :role, :active)
"""
),
{
"id": str(member_id),
"study_id": str(study_id),
"user_id": str(user_id),
"role": "PV",
"active": True,
},
)
await db_session.commit()
with pytest.raises(HTTPException) as exc_info:
await update_active_roles(
study_id=study_id,
payload={"active_roles": ["CRA"]},
current_user=UserStub(id=uuid.uuid4(), is_admin=True),
db=db_session,
)
assert exc_info.value.status_code == 409
assert exc_info.value.detail == "角色 PV 仍有成员使用,不能停用"
@@ -237,24 +237,24 @@ describe("ApiEndpointPermissions.vue", () => {
});
(wrapper.vm as any).operations = [
{ operation_key: "subject_aes:list", module: "subjects", action: "read", description: "查询参与者AE列表", default_roles: [] },
{ operation_key: "subject_aes:read", module: "subjects", action: "read", description: "查询参与者AE列表", default_roles: [] },
{ operation_key: "subject_pds:list", module: "subjects", action: "read", description: "查询参与者PD列表", default_roles: [] },
{ operation_key: "monitoring_issues:list", module: "risk_issues", action: "read", description: "查询监查访视问题列表", default_roles: [] },
{ operation_key: "monitoring_issues:read", module: "risk_issues", action: "read", description: "查询监查访视问题列表", default_roles: [] },
];
const riskRows = (wrapper.vm as any).filteredOperations.filter((operation: any) => operation.module === "risk_issues");
expect(riskRows.map((operation: any) => operation.operation_key)).toEqual([
"subject_aes:list",
"subject_aes:read",
"subject_pds:list",
"monitoring_issues:list",
"monitoring_issues:read",
]);
expect(riskRows.map((operation: any) => (wrapper.vm as any).getOperationSection(operation))).toEqual([
"AE/SAE",
"PD",
"监查访视问题",
]);
expect((wrapper.vm as any).filteredOperations.filter((operation: any) => operation.operation_key === "subject_aes:list")).toHaveLength(2);
expect((wrapper.vm as any).filteredOperations.filter((operation: any) => operation.operation_key === "subject_aes:read")).toHaveLength(2);
expect((wrapper.vm as any).filteredOperations.filter((operation: any) => operation.operation_key === "subject_pds:list")).toHaveLength(2);
});
@@ -309,6 +309,13 @@ describe("ApiEndpointPermissions.vue", () => {
description: "查询药品流向详情",
default_roles: [],
})).toBe("药品流向管理");
expect((wrapper.vm as any).getOperationSection({
operation_key: "drug_shipments_attachments:delete",
module: "materials",
action: "write",
description: "删除药物发货附件",
default_roles: [],
})).toBe("药品流向管理");
expect((wrapper.vm as any).getOperationSection({
operation_key: "material_equipments:read",
module: "materials",
@@ -316,9 +323,16 @@ describe("ApiEndpointPermissions.vue", () => {
description: "查询设备详情",
default_roles: [],
})).toBe("设备管理");
expect((wrapper.vm as any).getOperationSection({
operation_key: "material_equipments_attachments:delete",
module: "materials",
action: "write",
description: "删除物资设备附件",
default_roles: [],
})).toBe("设备管理");
});
it("groups startup ethics permissions with visible business names", () => {
it("groups startup ethics permissions into initiation and ethics sections", () => {
const wrapper = mount(ApiEndpointPermissions, {
props: {
project: { id: "test-id", name: "Test Project" },
@@ -338,14 +352,28 @@ describe("ApiEndpointPermissions.vue", () => {
action: "read",
description: "查询立项记录详情",
default_roles: [],
})).toBe("立项记录");
})).toBe("立项");
expect((wrapper.vm as any).getOperationSection({
operation_key: "startup_initiation_attachments:delete",
module: "startup_ethics",
action: "write",
description: "删除立项记录附件",
default_roles: [],
})).toBe("立项");
expect((wrapper.vm as any).getOperationSection({
operation_key: "startup_ethics:read",
module: "startup_ethics",
action: "read",
description: "查询伦理记录详情",
default_roles: [],
})).toBe("伦理记录");
})).toBe("伦理");
expect((wrapper.vm as any).getOperationSection({
operation_key: "startup_ethics_attachments:delete",
module: "startup_ethics",
action: "write",
description: "删除伦理记录附件",
default_roles: [],
})).toBe("伦理");
});
it("filters endpoints by search text", async () => {
@@ -340,9 +340,13 @@ const SECTION_LABELS: Record<string, string> = {
monitoring_issues: "监查访视问题",
project_milestones: "项目里程碑",
drug_shipments: "药品流向管理",
drug_shipments_attachments: "药品流向管理",
material_equipments: "设备管理",
startup_initiation: "立项记录",
startup_ethics: "伦理记录",
material_equipments_attachments: "设备管理",
startup_initiation: "立项",
startup_initiation_attachments: "立项",
startup_ethics: "伦理",
startup_ethics_attachments: "伦理",
precautions: "注意事项",
precautions_attachments: "注意事项",
faq: "医学咨询/FAQ",
@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from "vitest";
import { reactive } from "vue";
import { ElMessage } from "element-plus";
import { useDrawerDirtyGuard } from "./drawerDirtyGuard";
vi.mock("element-plus", () => ({
ElMessage: {
warning: vi.fn(),
},
}));
describe("useDrawerDirtyGuard", () => {
it("allows drawer close when the form has no changes", () => {
const form = reactive({ name: "设备A", status: "启用" });
const guard = useDrawerDirtyGuard(() => form);
const done = vi.fn();
guard.syncBaseline();
guard.beforeClose(done);
expect(done).toHaveBeenCalledTimes(1);
expect(ElMessage.warning).not.toHaveBeenCalled();
});
it("blocks drawer close when the form has unsaved changes", () => {
const form = reactive({ name: "设备A", status: "启用" });
const guard = useDrawerDirtyGuard(() => form);
const done = vi.fn();
guard.syncBaseline();
form.name = "设备B";
guard.beforeClose(done);
expect(done).not.toHaveBeenCalled();
expect(ElMessage.warning).toHaveBeenCalledWith("请先保存或取消编辑");
});
});
+25
View File
@@ -0,0 +1,25 @@
import { computed, ref } from "vue";
import { ElMessage } from "element-plus";
const snapshot = (value: unknown) => JSON.stringify(value);
export const useDrawerDirtyGuard = (getValue: () => unknown) => {
const baseline = ref("");
const syncBaseline = () => {
baseline.value = snapshot(getValue());
};
const isDirty = computed(() => baseline.value !== snapshot(getValue()));
const beforeClose = (done: () => void) => {
if (isDirty.value) {
ElMessage.warning("请先保存或取消编辑");
return;
}
done();
};
return {
isDirty,
syncBaseline,
beforeClose,
};
};
+23 -2
View File
@@ -12,25 +12,46 @@ describe("permission project role model", () => {
expect(source).toContain("getProjectRole");
expect(source).toContain("study.currentStudyRole");
expect(source).toContain("study.currentPermissions");
expect(source).toContain('"subject.delete": "subjects:delete"');
expect(source).toContain('"faq.edit": "faq:update"');
expect(source).toContain('"faq.update": "faq:update"');
expect(source).toContain('"faq.delete": "faq:delete"');
expect(source).toContain('"ae.create": "subject_aes:create"');
expect(source).toContain('"ae.close": "subject_aes:update"');
expect(source).toContain('"fees.contract.create": "fees_contracts:create"');
expect(source).toContain('"fees.contract.update": "fees_contracts:update"');
expect(source).toContain('"fees.contract.delete": "fees_contracts:delete"');
expect(source).toContain('"project.members.list": "project_members:list"');
expect(source).toContain('"project.members.candidates": "project_members:candidates"');
expect(source).toContain('"project.members.list": "project_members:read"');
expect(source).toContain('"project.members.candidates": "project_members:read"');
expect(source).toContain('"project.members.create": "project_members:create"');
expect(source).toContain('"project.members.update": "project_members:update"');
expect(source).toContain('"project.members.delete": "project_members:delete"');
expect(source).toContain('"documents.create": "documents:create"');
expect(source).toContain('"documents.delete": "documents:delete"');
expect(source).toContain('"precautions.create": "precautions:create"');
expect(source).toContain('"precautions.update": "precautions:update"');
expect(source).toContain('"precautions.delete": "precautions:delete"');
expect(source).toContain('"faq.category.create": "faq_category:create"');
expect(source).toContain('"faq.category.update": "faq_category:update"');
expect(source).toContain('"faq.category.delete": "faq_category:delete"');
expect(source).toContain('"faq.reply.delete": "faq_reply:delete"');
expect(source).toContain('"startup.initiation.create": "startup_initiation:create"');
expect(source).toContain('"startup.initiation.update": "startup_initiation:update"');
expect(source).toContain('"startup.initiation.delete": "startup_initiation:delete"');
expect(source).toContain('"startup.ethics.create": "startup_ethics:create"');
expect(source).toContain('"startup.ethics.update": "startup_ethics:update"');
expect(source).toContain('"startup.ethics.delete": "startup_ethics:delete"');
expect(source).toContain('"startup.auth.create": "startup_auth:create"');
expect(source).toContain('"startup.auth.update": "startup_auth:update"');
expect(source).toContain('"startup.auth.delete": "startup_auth:delete"');
expect(source).toContain('"project.milestones.update": "project_milestones:update"');
expect(source).toContain("isApiPermissionAllowed");
expect(source).toContain("projectPermissions.value?.[projectRole.value]?.[operationKey]");
expect(source).not.toContain("projectPermissions.value?.roles");
expect(source).not.toContain('"faq.edit": "etmf');
expect(source).not.toContain('"ADMIN", "PM"');
expect(source).not.toContain('"fees.contract.write": "fees_contracts:create"');
expect(source).not.toContain('"precautions.write": "precautions:create"');
expect(source).not.toContain("project.members.manage");
});
});
+64 -3
View File
@@ -10,11 +10,32 @@ const PERMISSIONS: Record<string, string[]> = {
"subject.enroll": ["ADMIN"],
"subject.complete": ["ADMIN"],
"subject.drop": ["ADMIN"],
"subject.delete": ["ADMIN"],
"ae.create": ["ADMIN"],
"ae.close": ["ADMIN"],
"faq.edit": ["ADMIN"],
"faq.update": ["ADMIN"],
"faq.delete": ["ADMIN"],
"faq.create": ["ADMIN"],
"faq.reply": ["ADMIN"],
"faq.reply.delete": ["ADMIN"],
"faq.category.read": ["ADMIN"],
"faq.category.create": ["ADMIN"],
"faq.category.update": ["ADMIN"],
"faq.category.delete": ["ADMIN"],
"precautions.create": ["ADMIN"],
"precautions.update": ["ADMIN"],
"precautions.delete": ["ADMIN"],
"startup.initiation.create": ["ADMIN"],
"startup.initiation.update": ["ADMIN"],
"startup.initiation.delete": ["ADMIN"],
"startup.ethics.create": ["ADMIN"],
"startup.ethics.update": ["ADMIN"],
"startup.ethics.delete": ["ADMIN"],
"startup.auth.create": ["ADMIN"],
"startup.auth.update": ["ADMIN"],
"startup.auth.delete": ["ADMIN"],
"project.milestones.update": ["ADMIN"],
"project.members.list": ["ADMIN"],
"project.members.candidates": ["ADMIN"],
"project.members.create": ["ADMIN"],
@@ -37,8 +58,28 @@ const REASONS: Record<string, string> = {
"subject.drop": TEXT.modules.permissions.subjectDrop,
"ae.close": TEXT.modules.permissions.aeClose,
"faq.edit": TEXT.modules.permissions.faqEdit,
"faq.update": TEXT.modules.permissions.faqEdit,
"faq.delete": TEXT.modules.permissions.faqEdit,
"faq.create": TEXT.modules.permissions.faqCreate,
"faq.reply": TEXT.modules.permissions.faqReply,
"faq.reply.delete": TEXT.modules.permissions.faqReply,
"faq.category.read": TEXT.modules.permissions.default,
"faq.category.create": TEXT.modules.permissions.faqEdit,
"faq.category.update": TEXT.modules.permissions.faqEdit,
"faq.category.delete": TEXT.modules.permissions.faqEdit,
"precautions.create": TEXT.modules.permissions.default,
"precautions.update": TEXT.modules.permissions.default,
"precautions.delete": TEXT.modules.permissions.default,
"startup.initiation.create": TEXT.modules.permissions.default,
"startup.initiation.update": TEXT.modules.permissions.default,
"startup.initiation.delete": TEXT.modules.permissions.default,
"startup.ethics.create": TEXT.modules.permissions.default,
"startup.ethics.update": TEXT.modules.permissions.default,
"startup.ethics.delete": TEXT.modules.permissions.default,
"startup.auth.create": TEXT.modules.permissions.default,
"startup.auth.update": TEXT.modules.permissions.default,
"startup.auth.delete": TEXT.modules.permissions.default,
"project.milestones.update": TEXT.modules.permissions.default,
"project.members.list": TEXT.modules.permissions.projectMembersManage,
"project.members.candidates": TEXT.modules.permissions.projectMembersManage,
"project.members.create": TEXT.modules.permissions.projectMembersManage,
@@ -71,16 +112,36 @@ export const usePermission = () => {
"subject.enroll": "subjects:update",
"subject.complete": "subjects:update",
"subject.drop": "subjects:update",
"subject.delete": "subjects:delete",
"ae.create": "subject_aes:create",
"ae.close": "subject_aes:update",
"project.overview.read": "project_overview:read",
"faq.edit": "faq:update",
"faq.update": "faq:update",
"faq.delete": "faq:delete",
"faq.create": "faq:create",
"faq.reply": "faq_reply:create",
"faq.reply.delete": "faq_reply:delete",
"faq.category.read": "faq_category:read",
"faq.category.create": "faq_category:create",
"faq.category.update": "faq_category:update",
"faq.category.delete": "faq_category:delete",
"precautions.read": "precautions:read",
"precautions.write": "precautions:create",
"project.members.list": "project_members:list",
"project.members.candidates": "project_members:candidates",
"precautions.create": "precautions:create",
"precautions.update": "precautions:update",
"precautions.delete": "precautions:delete",
"startup.initiation.create": "startup_initiation:create",
"startup.initiation.update": "startup_initiation:update",
"startup.initiation.delete": "startup_initiation:delete",
"startup.ethics.create": "startup_ethics:create",
"startup.ethics.update": "startup_ethics:update",
"startup.ethics.delete": "startup_ethics:delete",
"startup.auth.create": "startup_auth:create",
"startup.auth.update": "startup_auth:update",
"startup.auth.delete": "startup_auth:delete",
"project.milestones.update": "project_milestones:update",
"project.members.list": "project_members:read",
"project.members.candidates": "project_members:read",
"project.members.create": "project_members:create",
"project.members.update": "project_members:update",
"project.members.delete": "project_members:delete",
@@ -5,25 +5,30 @@ describe("project route permissions", () => {
it("maps project routes to backend operation keys", () => {
expect(getProjectRoutePermission("/project/overview")).toEqual({ operationKey: "project_overview:read" });
expect(getProjectRoutePermission("/project/milestones")).toEqual({ operationKey: "project_milestones:read" });
expect(getProjectRoutePermission("/fees/contracts")).toEqual({ operationKey: "fees_contracts:read" });
expect(getProjectRoutePermission("/finance/contracts")).toBeNull();
expect(getProjectRoutePermission("/finance/contracts/new")).toBeNull();
expect(getProjectRoutePermission("/finance/contracts/abc/edit")).toBeNull();
expect(getProjectRoutePermission("/drug/shipments")).toEqual({ operationKey: "drug_shipments:read" });
expect(getProjectRoutePermission("/drug/shipments/new")).toEqual({ operationKey: "drug_shipments:create" });
expect(getProjectRoutePermission("/drug/shipments/abc/edit")).toEqual({ operationKey: "drug_shipments:update" });
expect(getProjectRoutePermission("/drug/shipments/abc")).toEqual({ operationKey: "drug_shipments:read" });
expect(getProjectRoutePermission("/subjects/new")).toEqual({ operationKey: "subjects:create" });
expect(getProjectRoutePermission("/subjects/abc/edit")).toEqual({ operationKey: "subjects:update" });
expect(getProjectRoutePermission("/risk-issues")).toEqual({ operationKey: "subject_aes:list" });
expect(getProjectRoutePermission("/risk-issues/sae")).toEqual({ operationKey: "subject_aes:list" });
expect(getProjectRoutePermission("/risk-issues")).toEqual({ operationKey: "subject_aes:read" });
expect(getProjectRoutePermission("/risk-issues/sae")).toEqual({ operationKey: "subject_aes:read" });
expect(getProjectRoutePermission("/risk-issues/pd")).toEqual({ operationKey: "subject_pds:list" });
expect(getProjectRoutePermission("/risk-issues/monitoring-visits")).toEqual({ operationKey: "monitoring_issues:list" });
expect(getProjectRoutePermission("/risk-issues/monitoring-visits")).toEqual({ operationKey: "monitoring_issues:read" });
expect(getProjectRoutePermission("/etmf")).toEqual({ operationKey: "documents:read" });
expect(getProjectRoutePermission("/startup/feasibility/new")).toEqual({ operationKey: "startup_initiation:create" });
expect(getProjectRoutePermission("/startup/feasibility/abc/edit")).toEqual({ operationKey: "startup_initiation:update" });
expect(getProjectRoutePermission("/startup/ethics/new")).toEqual({ operationKey: "startup_ethics:create" });
expect(getProjectRoutePermission("/startup/ethics/abc/edit")).toEqual({ operationKey: "startup_ethics:update" });
expect(getProjectRoutePermission("/startup/feasibility-ethics")).toEqual({
operationKeys: ["startup_initiation:read", "startup_ethics:read"],
});
expect(getProjectRoutePermission("/startup/kickoff/new")).toEqual({ operationKey: "startup_auth:create" });
expect(getProjectRoutePermission("/startup/kickoff/abc/edit")).toEqual({ operationKey: "startup_auth:update" });
expect(getProjectRoutePermission("/startup/kickoff/abc/edit")).toBeNull();
expect(getProjectRoutePermission("/startup/training/new")).toBeNull();
expect(getProjectRoutePermission("/startup/training/abc/edit")).toBeNull();
expect(getProjectRoutePermission("/knowledge/medical-consult/abc")).toEqual({ operationKey: "faq:read" });
expect(getProjectRoutePermission("/knowledge/precautions/new")).toEqual({ operationKey: "precautions:create" });
expect(getProjectRoutePermission("/knowledge/precautions/abc/edit")).toEqual({ operationKey: "precautions:update" });
@@ -42,4 +47,46 @@ describe("project route permissions", () => {
expect(hasProjectPermission(matrix, "CRA", getProjectRoutePermission("/knowledge/precautions"), false)).toBe(true);
expect(findFirstAccessibleProjectPath(matrix, "CRA", false)).toBe("/knowledge/precautions");
});
it("can fall back to every accessible risk issue child module", () => {
expect(
findFirstAccessibleProjectPath(
{
QA: {
"subject_aes:read": { allowed: false },
"subject_pds:list": { allowed: true },
"monitoring_issues:read": { allowed: false },
},
} as any,
"QA",
false,
),
).toBe("/risk-issues/pd");
expect(
findFirstAccessibleProjectPath(
{
QA: {
"subject_aes:read": { allowed: false },
"subject_pds:list": { allowed: false },
"monitoring_issues:read": { allowed: true },
},
} as any,
"QA",
false,
),
).toBe("/risk-issues/monitoring-visits");
});
it("allows combined landing pages when any child module is readable", () => {
const matrix = {
CRA: {
"startup_initiation:read": { allowed: false },
"startup_ethics:read": { allowed: true },
},
} as any;
expect(hasProjectPermission(matrix, "CRA", getProjectRoutePermission("/startup/feasibility-ethics"), false)).toBe(true);
expect(findFirstAccessibleProjectPath(matrix, "CRA", false)).toBe("/startup/feasibility-ethics");
});
});
+20 -12
View File
@@ -2,15 +2,14 @@ import type { ApiEndpointPermissionsResponse } from "../types/api";
import { isApiPermissionAllowed } from "./apiPermissionValue";
export type ProjectRoutePermission = {
operationKey: string;
operationKey?: string;
operationKeys?: string[];
};
const routePermissions: Array<{ prefixes: string[]; permission: ProjectRoutePermission }> = [
{ prefixes: ["/fees/contracts/new"], permission: { operationKey: "fees_contracts:create" } },
{ prefixes: ["/drug/shipments/new"], permission: { operationKey: "drug_shipments:create" } },
{ prefixes: ["/startup/feasibility/new"], permission: { operationKey: "startup_initiation:create" } },
{ prefixes: ["/startup/ethics/new"], permission: { operationKey: "startup_ethics:create" } },
{ prefixes: ["/startup/kickoff/new", "/startup/training/new"], permission: { operationKey: "startup_auth:create" } },
{ prefixes: ["/startup/kickoff/new"], permission: { operationKey: "startup_auth:create" } },
{ prefixes: ["/subjects/new"], permission: { operationKey: "subjects:create" } },
{ prefixes: ["/knowledge/precautions/new"], permission: { operationKey: "precautions:create" } },
{ prefixes: ["/project/overview"], permission: { operationKey: "project_overview:read" } },
@@ -19,29 +18,32 @@ const routePermissions: Array<{ prefixes: string[]; permission: ProjectRoutePerm
{ prefixes: ["/drug/shipments"], permission: { operationKey: "drug_shipments:read" } },
{ prefixes: ["/materials/equipment"], permission: { operationKey: "material_equipments:read" } },
{ prefixes: ["/file-versions", "/trial/", "/documents/"], permission: { operationKey: "documents:read" } },
{ prefixes: ["/startup/feasibility-ethics", "/startup/feasibility"], permission: { operationKey: "startup_initiation:read" } },
{ prefixes: ["/startup/feasibility-ethics"], permission: { operationKeys: ["startup_initiation:read", "startup_ethics:read"] } },
{ prefixes: ["/startup/feasibility"], permission: { operationKey: "startup_initiation:read" } },
{ prefixes: ["/startup/ethics"], permission: { operationKey: "startup_ethics:read" } },
{ prefixes: ["/startup/meeting-auth", "/startup/kickoff", "/startup/training"], permission: { operationKey: "startup_auth:read" } },
{ prefixes: ["/subjects"], permission: { operationKey: "subjects:read" } },
{ prefixes: ["/risk-issues/monitoring-visits"], permission: { operationKey: "monitoring_issues:list" } },
{ prefixes: ["/risk-issues/monitoring-visits"], permission: { operationKey: "monitoring_issues:read" } },
{ prefixes: ["/risk-issues/pd"], permission: { operationKey: "subject_pds:list" } },
{ prefixes: ["/risk-issues"], permission: { operationKey: "subject_aes:list" } },
{ prefixes: ["/risk-issues"], permission: { operationKey: "subject_aes:read" } },
{ prefixes: ["/etmf"], permission: { operationKey: "documents:read" } },
{ prefixes: ["/knowledge/medical-consult"], permission: { operationKey: "faq:read" } },
{ prefixes: ["/knowledge/precautions", "/knowledge/support-files", "/knowledge/instruction-files"], permission: { operationKey: "precautions:read" } },
];
const editRoutePermissions: Array<{ pattern: RegExp; permission: ProjectRoutePermission }> = [
{ pattern: /^\/fees\/contracts\/[^/]+\/edit$/, permission: { operationKey: "fees_contracts:update" } },
{ pattern: /^\/drug\/shipments\/[^/]+\/edit$/, permission: { operationKey: "drug_shipments:update" } },
{ pattern: /^\/startup\/feasibility\/[^/]+\/edit$/, permission: { operationKey: "startup_initiation:update" } },
{ pattern: /^\/startup\/ethics\/[^/]+\/edit$/, permission: { operationKey: "startup_ethics:update" } },
{ pattern: /^\/startup\/kickoff\/[^/]+\/edit$/, permission: { operationKey: "startup_auth:update" } },
{ pattern: /^\/startup\/training\/[^/]+\/edit$/, permission: { operationKey: "startup_auth:update" } },
{ pattern: /^\/subjects\/[^/]+\/edit$/, permission: { operationKey: "subjects:update" } },
{ pattern: /^\/knowledge\/precautions\/[^/]+\/edit$/, permission: { operationKey: "precautions:update" } },
];
const removedRoutePatterns = [
/^\/startup\/kickoff\/[^/]+\/edit$/,
/^\/startup\/training\/new$/,
/^\/startup\/training\/[^/]+\/edit$/,
];
export const projectRouteLandingPaths = [
"/project/overview",
"/project/milestones",
@@ -53,6 +55,8 @@ export const projectRouteLandingPaths = [
"/startup/meeting-auth",
"/subjects",
"/risk-issues/sae",
"/risk-issues/pd",
"/risk-issues/monitoring-visits",
"/etmf",
"/knowledge/medical-consult",
"/knowledge/precautions",
@@ -62,6 +66,9 @@ export const projectRouteLandingPaths = [
export const getProjectRoutePermission = (path: string): ProjectRoutePermission | null => {
const normalized = path || "";
if (removedRoutePatterns.some((pattern) => pattern.test(normalized))) {
return null;
}
for (const item of editRoutePermissions) {
if (item.pattern.test(normalized)) {
return item.permission;
@@ -83,7 +90,8 @@ export const hasProjectPermission = (
) => {
if (!permission || isAdmin) return true;
if (!role || !permissions) return false;
return isApiPermissionAllowed(permissions[role]?.[permission.operationKey]);
const operationKeys = permission.operationKeys || (permission.operationKey ? [permission.operationKey] : []);
return operationKeys.some((operationKey) => isApiPermissionAllowed(permissions[role]?.[operationKey]));
};
export const findFirstAccessibleProjectPath = (
@@ -95,6 +95,35 @@ describe("permission management custom roles", () => {
expect(dialogSource).not.toContain("可选标签");
});
it("shows role template create edit and delete actions only to system admins", () => {
const source = readSource();
expect(source).toContain('<el-table-column v-if="isAdmin" label="操作"');
expect(source).toContain('v-if="templateDrawerTab === \'list\' && isAdmin"');
expect(source).toContain('<el-button v-if="isAdmin" link type="primary" @click="openEditTemplate(row)">编辑</el-button>');
expect(source).toContain('<el-button v-if="isAdmin" link type="danger" :disabled="row.is_system" @click="confirmDeleteTemplate(row)">删除</el-button>');
});
it("prevents project PMs from disabling the PM active role", () => {
const source = readSource();
expect(source).toContain(':disabled="!canToggleActiveRole(role.key)"');
expect(source).toContain('if (!isAdmin.value && role === "PM") return false;');
expect(source).toContain("if (!canToggleActiveRole(role)) return;");
expect(source).toContain('if (!isAdmin.value && !roles.includes("PM")) roles.unshift("PM");');
});
it("prevents disabling active roles that are still assigned to active members", () => {
const source = readSource();
expect(source).toContain("const activeRoleMemberCounts = computed(() =>");
expect(source).toContain("if (!member.is_active) continue;");
expect(source).toContain("const count = activeRoleMemberCount(role);");
expect(source).toContain("roleActiveDisableReason(role.key)");
expect(source).toContain('该角色仍有关联成员,需先调整成员角色后才能停用');
expect(source).toContain("if (isActiveRoleInUse(role)) return false;");
});
it("keeps member role selects bound to role keys used by the permission matrix", () => {
const source = readSource();
@@ -111,8 +140,8 @@ describe("permission management custom roles", () => {
it("uses granular project member permissions for member management actions", () => {
const source = readSource();
expect(source).toContain('selectedProjectPermissionAllowed("project_members:list")');
expect(source).toContain('selectedProjectPermissionAllowed("project_members:candidates")');
expect(source).toContain('selectedProjectPermissionAllowed("project_members:read")');
expect(source).toContain('selectedProjectPermissionAllowed("project_members:read")');
expect(source).toContain('selectedProjectPermissionAllowed("project_members:create")');
expect(source).toContain('selectedProjectPermissionAllowed("project_members:update")');
expect(source).toContain('selectedProjectPermissionAllowed("project_members:delete")');
@@ -222,6 +251,7 @@ describe("permission management custom roles", () => {
expect(moduleSource).not.toContain('precautions: "注意事项"');
expect(source).toContain('precautions: "注意事项"');
expect(source).toContain('faq: "医学咨询/FAQ"');
expect(source).toContain('material_equipments_attachments: "设备管理"');
});
it("labels contract fees without the removed finance contract prefix in role editor", () => {
@@ -231,7 +231,15 @@
<!--
角色管理抽屉
-->
<el-drawer v-model="templateDrawerVisible" title="角色管理" size="720px" direction="rtl" @open="onTemplateDrawerOpen">
<el-drawer
v-model="templateDrawerVisible"
title="角色管理"
size="720px"
direction="rtl"
:close-on-click-modal="true"
:before-close="handleTemplateDrawerBeforeClose"
@open="onTemplateDrawerOpen"
>
<div class="drawer-tabs-shell">
<el-tabs v-model="templateDrawerTab" class="drawer-tabs">
@@ -252,10 +260,10 @@
<el-table-column label="权限数" width="80">
<template #default="{ row }">{{ countPermissions(row) }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<el-table-column v-if="isAdmin" label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEditTemplate(row)">编辑</el-button>
<el-button link type="danger" :disabled="row.is_system" @click="confirmDeleteTemplate(row)">删除</el-button>
<el-button v-if="isAdmin" link type="primary" @click="openEditTemplate(row)">编辑</el-button>
<el-button v-if="isAdmin" link type="danger" :disabled="row.is_system" @click="confirmDeleteTemplate(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -279,12 +287,24 @@
<div>
<div class="active-role-name">{{ role.label }}</div>
<div class="active-role-desc">{{ role.desc }}</div>
<div v-if="roleActiveStatusLabel(role.key)" class="active-role-meta">
{{ roleActiveStatusLabel(role.key) }}
</div>
</div>
</div>
<el-switch
:model-value="activeRolesDraft.includes(role.key)"
@change="(v: boolean) => toggleActiveRole(role.key, v)"
/>
<el-tooltip
:disabled="!roleActiveDisableReason(role.key)"
:content="roleActiveDisableReason(role.key)"
placement="top"
>
<span>
<el-switch
:model-value="activeRolesDraft.includes(role.key)"
:disabled="!canToggleActiveRole(role.key)"
@change="(v: boolean) => toggleActiveRole(role.key, v)"
/>
</span>
</el-tooltip>
</div>
</div>
</el-tab-pane>
@@ -362,7 +382,7 @@
</el-tabs>
<div class="drawer-tab-actions">
<el-button v-if="templateDrawerTab === 'list'" type="primary" @click="openCreateTemplate">新增角色</el-button>
<el-button v-if="templateDrawerTab === 'list' && isAdmin" type="primary" @click="openCreateTemplate">新增角色</el-button>
<el-button v-else-if="templateDrawerTab === 'active'" type="primary" :loading="activeRolesSaving" @click="saveActiveRoles">
保存
</el-button>
@@ -464,6 +484,7 @@ import { useAuthStore } from "@/store/auth";
import { displayDateTime } from "@/utils/display";
import { isApiPermissionAllowed } from "@/utils/apiPermissionValue";
import { isSystemAdmin } from "@/utils/roles";
import { useDrawerDirtyGuard } from "@/utils/drawerDirtyGuard";
import {
compareProjectPermissionModules,
projectPermissionModuleLabel,
@@ -486,8 +507,8 @@ const selectedProjectPermissionAllowed = (operationKey: string) => {
if (!role || !apiMatrix.value) return false;
return isApiPermissionAllowed(apiMatrix.value?.[role]?.[operationKey]);
};
const canListProjectMembers = computed(() => selectedProjectPermissionAllowed("project_members:list"));
const canListProjectMemberCandidates = computed(() => selectedProjectPermissionAllowed("project_members:candidates"));
const canListProjectMembers = computed(() => selectedProjectPermissionAllowed("project_members:read"));
const canListProjectMemberCandidates = computed(() => selectedProjectPermissionAllowed("project_members:read"));
const canCreateProjectMember = computed(() => selectedProjectPermissionAllowed("project_members:create"));
const canUpdateProjectMembers = computed(() => selectedProjectPermissionAllowed("project_members:update"));
const canDeleteProjectMembers = computed(() => selectedProjectPermissionAllowed("project_members:delete"));
@@ -775,6 +796,7 @@ const templateSaving = ref(false);
const activeRolesDraft = ref<string[]>([]);
const activeRolesLoading = ref(false);
const activeRolesSaving = ref(false);
const activeRolesDirtyGuard = useDrawerDirtyGuard(() => activeRolesDraft.value);
const roleOptions = computed(() => {
const options = templates.value.filter((template) => template.category).map((template) => ({
@@ -805,7 +827,8 @@ const loadActiveRoles = async () => {
activeRolesLoading.value = true;
try {
const res = await fetchActiveRoles(selectedStudyId.value);
activeRolesDraft.value = res.data.active_roles;
activeRolesDraft.value = protectPmActiveRole([...res.data.active_roles]);
activeRolesDirtyGuard.syncBaseline();
} catch {
ElMessage.error("加载生效角色失败");
} finally {
@@ -813,7 +836,44 @@ const loadActiveRoles = async () => {
}
};
const activeRoleMemberCounts = computed(() => {
const counts: Record<string, number> = {};
for (const member of members.value) {
if (!member.is_active) continue;
const role = member.role_in_study;
if (!role) continue;
counts[role] = (counts[role] ?? 0) + 1;
}
return counts;
});
const activeRoleMemberCount = (role: string) => activeRoleMemberCounts.value[role] ?? 0;
const isActiveRoleInUse = (role: string) => activeRolesDraft.value.includes(role) && activeRoleMemberCount(role) > 0;
const canToggleActiveRole = (role: string) => {
if (!isAdmin.value && role === "PM") return false;
if (isActiveRoleInUse(role)) return false;
return true;
};
const roleActiveStatusLabel = (role: string) => {
if (role === "PM" && !isAdmin.value) return "项目负责人必需";
const count = activeRoleMemberCount(role);
return count > 0 ? `${count} 名成员使用中` : "";
};
const roleActiveDisableReason = (role: string) => {
if (role === "PM" && !isAdmin.value) return "PM 为项目必需角色,项目 PM 不可停用";
if (isActiveRoleInUse(role)) return "该角色仍有关联成员,需先调整成员角色后才能停用";
return "";
};
const protectPmActiveRole = (roles: string[]) => {
if (!isAdmin.value && !roles.includes("PM")) roles.unshift("PM");
return roles;
};
const toggleActiveRole = (role: string, active: boolean) => {
if (!canToggleActiveRole(role)) return;
if (active) {
if (!activeRolesDraft.value.includes(role)) activeRolesDraft.value.push(role);
} else {
@@ -825,7 +885,10 @@ const saveActiveRoles = async () => {
if (!canManageSelectedProject.value) return;
activeRolesSaving.value = true;
try {
await updateActiveRoles(selectedStudyId.value, activeRolesDraft.value);
const roles = protectPmActiveRole([...activeRolesDraft.value]);
await updateActiveRoles(selectedStudyId.value, roles);
activeRolesDraft.value = roles;
activeRolesDirtyGuard.syncBaseline();
await loadPermissionData();
ElMessage.success("生效角色已保存");
} catch {
@@ -996,6 +1059,15 @@ const roleEditorDraft = ref<Record<string, boolean>>({});
const roleEditorSaving = ref(false);
const roleEditorLoading = ref(false);
const allOperations = ref<RoleEditorOp[]>([]);
const roleEditorDirtyGuard = useDrawerDirtyGuard(() => roleEditorDraft.value);
const handleTemplateDrawerBeforeClose = (done: () => void) => {
if (activeRolesDirtyGuard.isDirty.value || (editingRole.value && roleEditorDirtyGuard.isDirty.value)) {
ElMessage.warning("请先保存或取消编辑");
return;
}
done();
};
const roleEditorModuleLabel = projectPermissionModuleLabel;
@@ -1009,6 +1081,7 @@ const PERMISSION_SECTION_LABELS: Record<string, string> = {
subject_histories: "病史",
monitoring_issues: "监查访视问题",
project_milestones: "项目里程碑",
material_equipments_attachments: "设备管理",
startup_initiation: "立项",
startup_initiation_attachments: "立项",
startup_ethics: "伦理",
@@ -1145,6 +1218,7 @@ const initRoleEditor = () => {
}
}
roleEditorDraft.value = draft;
roleEditorDirtyGuard.syncBaseline();
};
const saveRoleEditor = async () => {
@@ -1160,6 +1234,7 @@ const saveRoleEditor = async () => {
apiMatrix.value = res.data;
const savedRole = editingRole.value;
editingRole.value = "";
roleEditorDirtyGuard.syncBaseline();
ElMessage.success(`${roleLabel(savedRole)} 权限已保存`);
} catch {
ElMessage.error("保存失败");
@@ -1667,6 +1742,13 @@ onMounted(async () => {
margin-top: 2px;
}
.active-role-meta {
font-size: 12px;
color: #b45309;
margin-top: 4px;
font-weight: 500;
}
.template-name { font-weight: 600; color: #1a2332; }
/* ── 角色编辑器 ── */