From 89cecce6b5d49566d7cc7769563b2c7632a85bbe Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Wed, 13 May 2026 09:25:37 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9D=83=E9=99=90=E7=AE=A1=E7=90=86=EF=BC=9A?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=8C=BB=E5=AD=A6=E5=AE=A1=E6=A0=B8=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E8=A7=92=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 MEDICAL_REVIEW 项目角色,界面显示为医学审核,并通过 Alembic 迁移扩展 user_role 枚举。 为医学审核配置独立默认权限:项目总览、参与者、风险问题、监查稽查、FAQ、共享库按医学审核职责开放,不再复制 PV 默认权限。 同步前后端类型、项目成员角色下拉、权限矩阵展示、文件分发角色兜底和用户协议文案,并补充权限矩阵回归测试。 --- .../20260513_01_add_medical_review_role.py | 25 +++++++++++++++++ backend/app/core/project_permissions.py | 20 +++++++++++++- backend/app/models/user.py | 1 + backend/app/schemas/member.py | 2 +- backend/app/schemas/project_permission.py | 2 +- backend/app/schemas/user.py | 4 +-- backend/tests/test_project_permissions.py | 27 +++++++++++++++---- frontend/src/dictionaries/role.dict.ts | 4 ++- frontend/src/locales/zh-CN.ts | 1 + frontend/src/types/api.ts | 2 +- frontend/src/views/Login.vue | 4 +-- frontend/src/views/admin/ProjectMembers.vue | 4 +++ .../views/admin/ProjectPermissions.test.ts | 1 + .../src/views/admin/ProjectPermissions.vue | 7 ++++- .../src/views/documents/DocumentDetail.vue | 2 +- 15 files changed, 90 insertions(+), 16 deletions(-) create mode 100644 backend/alembic/versions/20260513_01_add_medical_review_role.py diff --git a/backend/alembic/versions/20260513_01_add_medical_review_role.py b/backend/alembic/versions/20260513_01_add_medical_review_role.py new file mode 100644 index 00000000..611151cb --- /dev/null +++ b/backend/alembic/versions/20260513_01_add_medical_review_role.py @@ -0,0 +1,25 @@ +"""add medical review role + +Revision ID: 20260513_01 +Revises: 20260512_03 +Create Date: 2026-05-13 09:15:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op + + +revision: str = "20260513_01" +down_revision: Union[str, None] = "20260512_03" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("ALTER TYPE user_role ADD VALUE IF NOT EXISTS 'MEDICAL_REVIEW'") + + +def downgrade() -> None: + pass diff --git a/backend/app/core/project_permissions.py b/backend/app/core/project_permissions.py index 3bedd659..0ba9fa42 100644 --- a/backend/app/core/project_permissions.py +++ b/backend/app/core/project_permissions.py @@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.models.study_role_permission import StudyRolePermission -PROJECT_PERMISSION_ROLES = ("ADMIN", "PM", "CRA", "PV", "IMP", "QA") +PROJECT_PERMISSION_ROLES = ("ADMIN", "PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA") PROJECT_PERMISSION_MODULES = ( {"key": "project_members", "label": "项目成员", "description": "维护项目账号、成员角色与启停状态"}, @@ -93,6 +93,24 @@ DEFAULT_PROJECT_ROLE_PERMISSIONS: dict[str, dict[str, dict[str, bool]]] = { "sites": {"read": False, "write": False}, "audit_export": {"read": False, "write": False}, }, + "MEDICAL_REVIEW": { + "project_overview": {"read": True, "write": False}, + "project_milestones": {"read": False, "write": False}, + "fees": {"read": False, "write": False}, + "materials": {"read": False, "write": False}, + "file_versions": {"read": False, "write": False}, + "startup_ethics": {"read": False, "write": False}, + "startup_auth": {"read": False, "write": False}, + "subjects": {"read": True, "write": False}, + "risk_issues": {"read": True, "write": True}, + "monitoring_audit": {"read": True, "write": False}, + "etmf": {"read": False, "write": False}, + "faq": {"read": True, "write": True}, + "shared_library": {"read": True, "write": True}, + "project_members": {"read": False, "write": False}, + "sites": {"read": False, "write": False}, + "audit_export": {"read": False, "write": False}, + }, "IMP": { "project_overview": {"read": True, "write": False}, "project_milestones": {"read": True, "write": False}, diff --git a/backend/app/models/user.py b/backend/app/models/user.py index dd0a5570..1d66622a 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -14,6 +14,7 @@ class UserRole(str, enum.Enum): PM = "PM" CRA = "CRA" PV = "PV" + MEDICAL_REVIEW = "MEDICAL_REVIEW" IMP = "IMP" QA = "QA" diff --git a/backend/app/schemas/member.py b/backend/app/schemas/member.py index 63242f4b..fb502dd1 100644 --- a/backend/app/schemas/member.py +++ b/backend/app/schemas/member.py @@ -5,7 +5,7 @@ from typing import Literal, Optional from pydantic import BaseModel, ConfigDict, Field from app.schemas.user import UserDisplay -StudyRole = Literal["PM", "CRA", "PV", "IMP", "QA", "ADMIN"] +StudyRole = Literal["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA", "ADMIN"] class StudyMemberCreate(BaseModel): diff --git a/backend/app/schemas/project_permission.py b/backend/app/schemas/project_permission.py index 3271d3f4..bb5640db 100644 --- a/backend/app/schemas/project_permission.py +++ b/backend/app/schemas/project_permission.py @@ -5,7 +5,7 @@ from typing import Literal from pydantic import BaseModel, Field -ProjectPermissionRole = Literal["ADMIN", "PM", "CRA", "PV", "IMP", "QA"] +ProjectPermissionRole = Literal["ADMIN", "PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"] ProjectPermissionAction = Literal["read", "write"] diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 8b0393ac..c2638d9f 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -5,8 +5,8 @@ from typing import Literal, Optional from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator -UserRole = Literal["PM", "CRA", "PV", "IMP", "QA", "ADMIN"] -RegisterRole = Literal["PM", "CRA", "PV", "IMP"] +UserRole = Literal["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA", "ADMIN"] +RegisterRole = Literal["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"] UserStatus = Literal["PENDING", "ACTIVE", "REJECTED", "DISABLED"] PASSWORD_REGEX = re.compile(r"^(?=.*[A-Za-z])(?=.*\d).{8,}$") diff --git a/backend/tests/test_project_permissions.py b/backend/tests/test_project_permissions.py index 094d34e9..d345fc81 100644 --- a/backend/tests/test_project_permissions.py +++ b/backend/tests/test_project_permissions.py @@ -75,6 +75,14 @@ def test_default_business_permissions_match_role_responsibilities(): assert matrix["CRA"]["subjects"] == {"read": True, "write": True} assert matrix["CRA"]["monitoring_audit"] == {"read": True, "write": True} assert matrix["PV"]["risk_issues"] == {"read": True, "write": True} + assert matrix["MEDICAL_REVIEW"]["project_overview"] == {"read": True, "write": False} + assert matrix["MEDICAL_REVIEW"]["subjects"] == {"read": True, "write": False} + assert matrix["MEDICAL_REVIEW"]["risk_issues"] == {"read": True, "write": True} + assert matrix["MEDICAL_REVIEW"]["monitoring_audit"] == {"read": True, "write": False} + assert matrix["MEDICAL_REVIEW"]["faq"] == {"read": True, "write": True} + assert matrix["MEDICAL_REVIEW"]["shared_library"] == {"read": True, "write": True} + assert matrix["MEDICAL_REVIEW"]["fees"] == {"read": False, "write": False} + assert matrix["MEDICAL_REVIEW"]["materials"] == {"read": False, "write": False} assert matrix["IMP"]["materials"] == {"read": True, "write": True} assert matrix["PM"]["faq"] == {"read": True, "write": True} assert matrix["CRA"]["faq"] == {"read": True, "write": True} @@ -115,16 +123,16 @@ def test_non_pm_roles_cannot_be_granted_management_backend_permissions(): module: {"read": True, "write": True} for module in management_modules } - for role in ["CRA", "PV", "IMP", "QA"] + for role in ["CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"] }) - for role in ["CRA", "PV", "IMP", "QA"]: + for role in ["CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"]: for module in management_modules: assert matrix[role][module] == {"read": False, "write": False} def test_each_non_admin_role_can_toggle_each_writable_module(): - roles = ["PM", "CRA", "PV", "IMP", "QA"] + roles = ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"] management_modules = {"project_members", "sites", "audit_export"} writable_modules = [ module["key"] @@ -162,11 +170,20 @@ def test_read_only_modules_never_accept_write_for_any_role(): module: {"read": False, "write": True} for module in read_only_modules } - for role in ["ADMIN", "PM", "CRA", "PV", "IMP", "QA"] + for role in ["ADMIN", "PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"] } matrix = normalize_permission_matrix(payload) - for role in ["ADMIN", "PM", "CRA", "PV", "IMP", "QA"]: + for role in ["ADMIN", "PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"]: for module in read_only_modules: assert matrix[role][module] == {"read": True, "write": False} + + +def test_medical_review_default_permissions_are_independent_from_pv(): + matrix = normalize_permission_matrix() + + assert matrix["PV"]["monitoring_audit"] == {"read": True, "write": True} + assert matrix["MEDICAL_REVIEW"]["monitoring_audit"] == {"read": True, "write": False} + assert matrix["PV"]["fees"] == {"read": True, "write": False} + assert matrix["MEDICAL_REVIEW"]["fees"] == {"read": False, "write": False} diff --git a/frontend/src/dictionaries/role.dict.ts b/frontend/src/dictionaries/role.dict.ts index a4e4d500..9c1552ca 100644 --- a/frontend/src/dictionaries/role.dict.ts +++ b/frontend/src/dictionaries/role.dict.ts @@ -7,7 +7,9 @@ const items = [ { value: "PM", label: TEXT.enums.userRole.PM, order: 2 }, { value: "CRA", label: TEXT.enums.userRole.CRA, order: 3 }, { value: "PV", label: TEXT.enums.userRole.PV, order: 4 }, - { value: "IMP", label: TEXT.enums.userRole.IMP, order: 5 }, + { value: "MEDICAL_REVIEW", label: TEXT.enums.userRole.MEDICAL_REVIEW, order: 5 }, + { value: "IMP", label: TEXT.enums.userRole.IMP, order: 6 }, + { value: "QA", label: TEXT.enums.userRole.QA, order: 7 }, ]; export const roleDict: Dict = createDict(items); diff --git a/frontend/src/locales/zh-CN.ts b/frontend/src/locales/zh-CN.ts index 306a6f82..14e9901b 100644 --- a/frontend/src/locales/zh-CN.ts +++ b/frontend/src/locales/zh-CN.ts @@ -989,6 +989,7 @@ export const TEXT = { PM: "项目负责人", CRA: "CRA", PV: "PV", + MEDICAL_REVIEW: "医学审核", IMP: "药品管理员", QA: "QA", }, diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 5dfa2fd0..e0f5b4a7 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -32,7 +32,7 @@ export interface LoginKeyResponse { expires_at: string; } -export type UserRole = "PM" | "CRA" | "PV" | "IMP" | "QA" | "ADMIN"; +export type UserRole = "PM" | "CRA" | "PV" | "MEDICAL_REVIEW" | "IMP" | "QA" | "ADMIN"; export type UserStatus = "PENDING" | "ACTIVE" | "REJECTED" | "DISABLED"; export interface UserInfo { diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 27ff8f3c..8312d8d5 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -167,7 +167,7 @@ const protocolSections = [ title: "1. 服务条款", paragraphs: [ "本系统为 CTMS 临床试验管理系统,用于支持临床试验项目的账号治理、项目与中心管理、项目里程碑、立项与伦理、启动会与培训授权、受试者管理、访视、AE/SAE、PD、风险问题、费用、药品物资、文件版本、知识库与审计日志等业务协同。", - "用户应在所属机构授权范围内使用本系统。系统中的流程提醒、状态跟踪和数据汇总用于辅助项目管理,不替代申办方、研究中心、CRA、PM、PV、IMP 或管理员按照法规、方案、SOP 和合同约定应履行的专业判断与职责。", + "用户应在所属机构授权范围内使用本系统。系统中的流程提醒、状态跟踪和数据汇总用于辅助项目管理,不替代申办方、研究中心、CRA、PM、PV、医学审核、IMP 或管理员按照法规、方案、SOP 和合同约定应履行的专业判断与职责。", ], }, { @@ -187,7 +187,7 @@ const protocolSections = [ { title: "4. 权限与审计", paragraphs: [ - "系统按照管理员、PM、CRA、PV、IMP 及普通成员等角色提供不同操作能力。同一账号在不同项目中的角色可能不同,用户仅可在被授权项目和中心范围内执行查看、新增、编辑、审批、关闭、导出等操作。", + "系统按照管理员、PM、CRA、PV、医学审核、IMP 及普通成员等角色提供不同操作能力。同一账号在不同项目中的角色可能不同,用户仅可在被授权项目和中心范围内执行查看、新增、编辑、审批、关闭、导出等操作。", "系统会记录关键业务操作和管理操作,包括但不限于账号、项目、成员、中心、AE、费用、文件、审计导出等事件。用户理解并同意这些日志可用于安全追踪、合规核查、问题复盘和内部管理。", ], }, diff --git a/frontend/src/views/admin/ProjectMembers.vue b/frontend/src/views/admin/ProjectMembers.vue index f6621ef1..583957a4 100644 --- a/frontend/src/views/admin/ProjectMembers.vue +++ b/frontend/src/views/admin/ProjectMembers.vue @@ -44,7 +44,9 @@ + + {{ TEXT.modules.adminProjectMembers.disabledGlobalDesc }} @@ -82,7 +84,9 @@ + + diff --git a/frontend/src/views/admin/ProjectPermissions.test.ts b/frontend/src/views/admin/ProjectPermissions.test.ts index 635e21c7..c28ebabf 100644 --- a/frontend/src/views/admin/ProjectPermissions.test.ts +++ b/frontend/src/views/admin/ProjectPermissions.test.ts @@ -10,6 +10,7 @@ describe("project permissions matrix", () => { expect(source).toContain('new Set(["project_members", "sites", "audit_export"])'); expect(source).toContain('role === "PM" || role === "ADMIN"'); + expect(source).toContain('"MEDICAL_REVIEW"'); expect(source).toContain("!canConfigureAdminModule(role, mod.key)"); expect(source).toContain("!canConfigureAdminModule(role, moduleKey)"); }); diff --git a/frontend/src/views/admin/ProjectPermissions.vue b/frontend/src/views/admin/ProjectPermissions.vue index 0c9f417d..a7f7c71d 100644 --- a/frontend/src/views/admin/ProjectPermissions.vue +++ b/frontend/src/views/admin/ProjectPermissions.vue @@ -86,7 +86,7 @@ import { TEXT } from "../../locales"; const route = useRoute(); const projectId = computed(() => route.params.projectId as string); -const roleOrder: UserRole[] = ["ADMIN", "PM", "CRA", "PV", "IMP", "QA"]; +const roleOrder: UserRole[] = ["ADMIN", "PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"]; const loading = ref(false); const saving = ref(false); @@ -388,6 +388,11 @@ onMounted(load); background: #ffe4e6; } +.role-badge--medical_review { + color: #0f766e; + background: #ccfbf1; +} + .role-badge--imp { color: #b45309; background: #fef3c7; diff --git a/frontend/src/views/documents/DocumentDetail.vue b/frontend/src/views/documents/DocumentDetail.vue index 6e34a5fc..29b04d40 100644 --- a/frontend/src/views/documents/DocumentDetail.vue +++ b/frontend/src/views/documents/DocumentDetail.vue @@ -360,7 +360,7 @@ const uploadRules: FormRules = { }; const roleOptions = computed(() => { - const fallbackRoles = ["CRA", "PM", "IMP", "PV"]; + const fallbackRoles = ["CRA", "PM", "IMP", "PV", "MEDICAL_REVIEW"]; const roles = new Set(members.value.map((m) => m.role_in_study).filter(Boolean)); const values = roles.size ? Array.from(roles) : fallbackRoles; return values.map((value) => ({