diff --git a/backend/app/api/v1/aes.py b/backend/app/api/v1/aes.py index 992eff50..4b661710 100644 --- a/backend/app/api/v1/aes.py +++ b/backend/app/api/v1/aes.py @@ -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, diff --git a/backend/app/api/v1/members.py b/backend/app/api/v1/members.py index 38994e6d..e4de7b0f 100644 --- a/backend/app/api/v1/members.py +++ b/backend/app/api/v1/members.py @@ -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="查询项目成员候选人", diff --git a/backend/app/api/v1/monitoring_visit_issues.py b/backend/app/api/v1/monitoring_visit_issues.py index b49bfc1b..316617fb 100644 --- a/backend/app/api/v1/monitoring_visit_issues.py +++ b/backend/app/api/v1/monitoring_visit_issues.py @@ -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, diff --git a/backend/app/api/v1/study_active_roles.py b/backend/app/api/v1/study_active_roles.py index 4d2560d3..a2fdce93 100644 --- a/backend/app/api/v1/study_active_roles.py +++ b/backend/app/api/v1/study_active_roles.py @@ -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} diff --git a/backend/app/api/v1/subject_histories.py b/backend/app/api/v1/subject_histories.py index 660c7e21..33ad91f0 100644 --- a/backend/app/api/v1/subject_histories.py +++ b/backend/app/api/v1/subject_histories.py @@ -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, diff --git a/backend/app/api/v1/subjects.py b/backend/app/api/v1/subjects.py index 4f09d543..57cac496 100644 --- a/backend/app/api/v1/subjects.py +++ b/backend/app/api/v1/subjects.py @@ -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, diff --git a/backend/app/core/api_permissions.py b/backend/app/core/api_permissions.py index 435958a6..f2c57897 100644 --- a/backend/app/core/api_permissions.py +++ b/backend/app/core/api_permissions.py @@ -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"], diff --git a/backend/app/core/project_permissions.py b/backend/app/core/project_permissions.py index b3771fa7..0779a8e2 100644 --- a/backend/app/core/project_permissions.py +++ b/backend/app/core/project_permissions.py @@ -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( diff --git a/backend/tests/test_api_permissions.py b/backend/tests/test_api_permissions.py index 7abe6e56..30693351 100644 --- a/backend/tests/test_api_permissions.py +++ b/backend/tests/test_api_permissions.py @@ -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 diff --git a/backend/tests/test_api_permissions_endpoints.py b/backend/tests/test_api_permissions_endpoints.py index 72e10ea2..bd5cc805 100644 --- a/backend/tests/test_api_permissions_endpoints.py +++ b/backend/tests/test_api_permissions_endpoints.py @@ -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 diff --git a/backend/tests/test_api_permissions_prerequisites.py b/backend/tests/test_api_permissions_prerequisites.py index 87feef09..28ee2afb 100644 --- a/backend/tests/test_api_permissions_prerequisites.py +++ b/backend/tests/test_api_permissions_prerequisites.py @@ -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 diff --git a/backend/tests/test_study_active_roles.py b/backend/tests/test_study_active_roles.py new file mode 100644 index 00000000..29c71629 --- /dev/null +++ b/backend/tests/test_study_active_roles.py @@ -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 仍有成员使用,不能停用" diff --git a/frontend/src/components/ApiEndpointPermissions.test.ts b/frontend/src/components/ApiEndpointPermissions.test.ts index 1786df0c..a45b36ef 100644 --- a/frontend/src/components/ApiEndpointPermissions.test.ts +++ b/frontend/src/components/ApiEndpointPermissions.test.ts @@ -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 () => { diff --git a/frontend/src/components/ApiEndpointPermissions.vue b/frontend/src/components/ApiEndpointPermissions.vue index eb52b467..85319d22 100644 --- a/frontend/src/components/ApiEndpointPermissions.vue +++ b/frontend/src/components/ApiEndpointPermissions.vue @@ -340,9 +340,13 @@ const SECTION_LABELS: Record = { 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", diff --git a/frontend/src/utils/drawerDirtyGuard.test.ts b/frontend/src/utils/drawerDirtyGuard.test.ts new file mode 100644 index 00000000..15263fc5 --- /dev/null +++ b/frontend/src/utils/drawerDirtyGuard.test.ts @@ -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("请先保存或取消编辑"); + }); +}); diff --git a/frontend/src/utils/drawerDirtyGuard.ts b/frontend/src/utils/drawerDirtyGuard.ts new file mode 100644 index 00000000..222897a3 --- /dev/null +++ b/frontend/src/utils/drawerDirtyGuard.ts @@ -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, + }; +}; diff --git a/frontend/src/utils/permission.test.ts b/frontend/src/utils/permission.test.ts index 8a9478b7..6ebf4841 100644 --- a/frontend/src/utils/permission.test.ts +++ b/frontend/src/utils/permission.test.ts @@ -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"); }); }); diff --git a/frontend/src/utils/permission.ts b/frontend/src/utils/permission.ts index 24820e6b..b4eb2a2a 100644 --- a/frontend/src/utils/permission.ts +++ b/frontend/src/utils/permission.ts @@ -10,11 +10,32 @@ const PERMISSIONS: Record = { "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 = { "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", diff --git a/frontend/src/utils/projectRoutePermissions.test.ts b/frontend/src/utils/projectRoutePermissions.test.ts index 1231e24a..4a0b2c63 100644 --- a/frontend/src/utils/projectRoutePermissions.test.ts +++ b/frontend/src/utils/projectRoutePermissions.test.ts @@ -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"); + }); }); diff --git a/frontend/src/utils/projectRoutePermissions.ts b/frontend/src/utils/projectRoutePermissions.ts index c71a037b..c8d4b032 100644 --- a/frontend/src/utils/projectRoutePermissions.ts +++ b/frontend/src/utils/projectRoutePermissions.ts @@ -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 = ( diff --git a/frontend/src/views/admin/PermissionManagement.test.ts b/frontend/src/views/admin/PermissionManagement.test.ts index efb0e7f0..a5b241a2 100644 --- a/frontend/src/views/admin/PermissionManagement.test.ts +++ b/frontend/src/views/admin/PermissionManagement.test.ts @@ -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('编辑'); + expect(source).toContain('删除'); + }); + + 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", () => { diff --git a/frontend/src/views/admin/PermissionManagement.vue b/frontend/src/views/admin/PermissionManagement.vue index 7c359fd5..5c501084 100644 --- a/frontend/src/views/admin/PermissionManagement.vue +++ b/frontend/src/views/admin/PermissionManagement.vue @@ -231,7 +231,15 @@ - +
@@ -252,10 +260,10 @@ - + @@ -279,12 +287,24 @@
{{ role.label }}
{{ role.desc }}
+
+ {{ roleActiveStatusLabel(role.key) }} +
- + + + + + @@ -362,7 +382,7 @@
- 新增角色 + 新增角色 保存 @@ -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([]); 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 = {}; + 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>({}); const roleEditorSaving = ref(false); const roleEditorLoading = ref(false); const allOperations = ref([]); +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 = { 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; } /* ── 角色编辑器 ── */