统一项目角色与接口权限配置

This commit is contained in:
Cheng Zhou
2026-05-28 10:48:33 +08:00
parent 6d1c98bcae
commit 2c85742040
45 changed files with 2863 additions and 1329 deletions
@@ -92,7 +92,7 @@ def _insert_system_templates() -> None:
"项目负责人,统筹项目全局,协调进度、资源与关键决策。",
"PM",
"PM",
'{"PM": {"subjects:create": true, "subjects:list": true, "subjects:read": true, "subjects:update": true, "subjects:delete": true, "visits:create": true, "visits:list": true, "visits:read": true, "visits:update": true, "visits:delete": true, "risk_issues:create": true, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": true, "risk_issues:delete": true, "finance_contracts:create": true, "finance_contracts:list": true, "finance_contracts:read": true, "finance_contracts:update": true, "finance_contracts:delete": true, "fees_contracts:create": true, "fees_contracts:list": true, "fees_contracts:read": true, "fees_contracts:update": true, "fees_contracts:delete": true, "fees_payments:create": true, "fees_payments:update": true, "fees_payments:delete": true, "project_members:create": true, "project_members:list": true, "project_members:candidates": true, "project_members:update": true, "project_members:delete": true, "sites:create": true, "sites:list": true, "sites:read": true, "sites:update": true, "sites:delete": true, "ethics:create": true, "ethics:list": true, "ethics:read": true, "ethics:update": true, "ethics:delete": true, "feasibility:create": true, "feasibility:list": true, "feasibility:read": true, "feasibility:update": true, "feasibility:delete": true, "budget:create": true, "budget:list": true, "budget:read": true, "budget:update": true, "budget:delete": true, "timeline:create": true, "timeline:list": true, "timeline:read": true, "timeline:update": true, "timeline:delete": true, "permissions:read": true, "permissions:update": true, "overview:read": true, "monitoring_issues:create": true, "monitoring_issues:list": true, "monitoring_issues:read": true, "monitoring_issues:update": true, "monitoring_issues:delete": true, "monitoring_issues:close": true, "monitoring_issues:history": true, "monitoring_audit:create": true, "monitoring_audit:read": true, "monitoring_audit:update": true, "monitoring_audit:delete": true, "drug_shipments:create": true, "drug_shipments:list": true, "drug_shipments:read": true, "drug_shipments:update": true, "drug_shipments:delete": true, "materials:create": true, "materials:list": true, "materials:read": true, "materials:update": true, "materials:delete": true, "subject_pds:create": true, "subject_pds:list": true, "subject_pds:read": true, "subject_pds:update": true, "audit_logs:list": true, "audit_logs:read": true, "audit_logs:export": true, "knowledge_notes:create": true, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": true, "knowledge_notes:delete": true, "subject_history:list": true, "subject_history:read": true, "subject_history:timeline": true, "subject_history:export": true, "subject_history:search": true, "milestones:list": true, "milestones:update": true, "attachments:create": true, "attachments:read": true, "attachments:update": true, "attachments:delete": true, "fees_attachments:create": true, "fees_attachments:read": true, "fees_attachments:delete": true, "faq:create": true, "faq:read": true, "faq:update": true, "faq:delete": true, "faq_category:create": true, "faq_category:read": true, "faq_category:update": true, "faq_category:delete": true, "faq_reply:create": true, "faq_reply:delete": true, "dashboard:read": true, "subject_histories:create": true, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": true, "subject_histories:delete": true, "material_equipments:create": true, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": true, "material_equipments:delete": true, "documents:create": true, "documents:read": true, "documents:update": true, "documents:delete": true}}',
'{"PM": {"subjects:create": true, "subjects:list": true, "subjects:read": true, "subjects:update": true, "subjects:delete": true, "visits:create": true, "visits:list": true, "visits:read": true, "visits:update": true, "visits:delete": true, "risk_issues:create": true, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": true, "risk_issues:delete": true, "finance_contracts:create": true, "finance_contracts:list": true, "finance_contracts:read": true, "finance_contracts:update": true, "finance_contracts:delete": true, "fees_contracts:create": true, "fees_contracts:list": true, "fees_contracts:read": true, "fees_contracts:update": true, "fees_contracts:delete": true, "fees_payments:create": true, "fees_payments:update": true, "fees_payments:delete": true, "project_members:create": true, "project_members:list": true, "project_members:candidates": true, "project_members:update": true, "project_members:delete": true, "sites:create": true, "sites:list": true, "sites:read": true, "sites:update": true, "sites:delete": true, "ethics:create": true, "ethics:list": true, "ethics:read": true, "ethics:update": true, "ethics:delete": true, "feasibility:create": true, "feasibility:list": true, "feasibility:read": true, "feasibility:update": true, "feasibility:delete": true, "permissions:read": true, "permissions:update": true, "overview:read": true, "monitoring_issues:create": true, "monitoring_issues:list": true, "monitoring_issues:read": true, "monitoring_issues:update": true, "monitoring_issues:delete": true, "drug_shipments:create": true, "drug_shipments:list": true, "drug_shipments:read": true, "drug_shipments:update": true, "drug_shipments:delete": true, "materials:create": true, "materials:list": true, "materials:read": true, "materials:update": true, "materials:delete": true, "subject_pds:create": true, "subject_pds:list": true, "subject_pds:read": true, "subject_pds:update": true, "audit_logs:list": true, "audit_logs:read": true, "audit_logs:export": true, "knowledge_notes:create": true, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": true, "knowledge_notes:delete": true, "subject_history:list": true, "subject_history:read": true, "subject_history:timeline": true, "subject_history:export": true, "subject_history:search": true, "milestones:list": true, "milestones:update": true, "attachments:create": true, "attachments:read": true, "attachments:update": true, "attachments:delete": true, "fees_attachments:create": true, "fees_attachments:read": true, "fees_attachments:delete": true, "faq:create": true, "faq:read": true, "faq:update": true, "faq:delete": true, "faq_category:create": true, "faq_category:read": true, "faq_category:update": true, "faq_category:delete": true, "faq_reply:create": true, "faq_reply:delete": true, "subject_histories:create": true, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": true, "subject_histories:delete": true, "material_equipments:create": true, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": true, "material_equipments:delete": true, "documents:create": true, "documents:read": true, "documents:update": true, "documents:delete": true}}',
),
(
"a17e6fd5-f0c6-4723-b166-6c25a595e28d",
@@ -100,7 +100,7 @@ def _insert_system_templates() -> None:
"负责各中心临床监查执行,跟进现场质量、数据和问题闭环。",
"CRA",
"CRA",
'{"CRA": {"subjects:create": true, "subjects:list": true, "subjects:read": true, "subjects:update": true, "subjects:delete": false, "visits:create": true, "visits:list": true, "visits:read": true, "visits:update": true, "visits:delete": false, "risk_issues:create": true, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": true, "risk_issues:delete": false, "finance_contracts:create": false, "finance_contracts:list": true, "finance_contracts:read": true, "finance_contracts:update": false, "finance_contracts:delete": false, "fees_contracts:create": false, "fees_contracts:list": true, "fees_contracts:read": true, "fees_contracts:update": false, "fees_contracts:delete": false, "fees_payments:create": false, "fees_payments:update": false, "fees_payments:delete": false, "project_members:create": false, "project_members:list": false, "project_members:candidates": false, "project_members:update": false, "project_members:delete": false, "sites:create": false, "sites:list": true, "sites:read": true, "sites:update": false, "sites:delete": false, "ethics:create": false, "ethics:list": true, "ethics:read": true, "ethics:update": false, "ethics:delete": false, "feasibility:create": false, "feasibility:list": true, "feasibility:read": true, "feasibility:update": false, "feasibility:delete": false, "budget:create": false, "budget:list": true, "budget:read": true, "budget:update": false, "budget:delete": false, "timeline:create": false, "timeline:list": true, "timeline:read": true, "timeline:update": false, "timeline:delete": false, "permissions:read": false, "permissions:update": false, "overview:read": true, "monitoring_issues:create": true, "monitoring_issues:list": true, "monitoring_issues:read": true, "monitoring_issues:update": true, "monitoring_issues:delete": false, "monitoring_issues:close": true, "monitoring_issues:history": true, "monitoring_audit:create": true, "monitoring_audit:read": true, "monitoring_audit:update": true, "monitoring_audit:delete": false, "drug_shipments:create": false, "drug_shipments:list": true, "drug_shipments:read": true, "drug_shipments:update": false, "drug_shipments:delete": false, "materials:create": false, "materials:list": true, "materials:read": true, "materials:update": false, "materials:delete": false, "subject_pds:create": true, "subject_pds:list": true, "subject_pds:read": true, "subject_pds:update": true, "audit_logs:list": false, "audit_logs:read": false, "audit_logs:export": false, "knowledge_notes:create": true, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": true, "knowledge_notes:delete": false, "subject_history:list": true, "subject_history:read": true, "subject_history:timeline": true, "subject_history:export": true, "subject_history:search": true, "milestones:list": true, "milestones:update": false, "attachments:create": true, "attachments:read": true, "attachments:update": true, "attachments:delete": true, "fees_attachments:create": true, "fees_attachments:read": true, "fees_attachments:delete": true, "faq:create": false, "faq:read": true, "faq:update": false, "faq:delete": false, "faq_category:create": false, "faq_category:read": true, "faq_category:update": false, "faq_category:delete": false, "faq_reply:create": true, "faq_reply:delete": false, "dashboard:read": true, "subject_histories:create": true, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": true, "subject_histories:delete": false, "material_equipments:create": true, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": true, "material_equipments:delete": false, "documents:create": true, "documents:read": true, "documents:update": true, "documents:delete": false}}',
'{"CRA": {"subjects:create": true, "subjects:list": true, "subjects:read": true, "subjects:update": true, "subjects:delete": false, "visits:create": true, "visits:list": true, "visits:read": true, "visits:update": true, "visits:delete": false, "risk_issues:create": true, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": true, "risk_issues:delete": false, "finance_contracts:create": false, "finance_contracts:list": true, "finance_contracts:read": true, "finance_contracts:update": false, "finance_contracts:delete": false, "fees_contracts:create": false, "fees_contracts:list": true, "fees_contracts:read": true, "fees_contracts:update": false, "fees_contracts:delete": false, "fees_payments:create": false, "fees_payments:update": false, "fees_payments:delete": false, "project_members:create": false, "project_members:list": false, "project_members:candidates": false, "project_members:update": false, "project_members:delete": false, "sites:create": false, "sites:list": true, "sites:read": true, "sites:update": false, "sites:delete": false, "ethics:create": false, "ethics:list": true, "ethics:read": true, "ethics:update": false, "ethics:delete": false, "feasibility:create": false, "feasibility:list": true, "feasibility:read": true, "feasibility:update": false, "feasibility:delete": false, "permissions:read": false, "permissions:update": false, "overview:read": true, "monitoring_issues:create": true, "monitoring_issues:list": true, "monitoring_issues:read": true, "monitoring_issues:update": true, "monitoring_issues:delete": false, "drug_shipments:create": false, "drug_shipments:list": true, "drug_shipments:read": true, "drug_shipments:update": false, "drug_shipments:delete": false, "materials:create": false, "materials:list": true, "materials:read": true, "materials:update": false, "materials:delete": false, "subject_pds:create": true, "subject_pds:list": true, "subject_pds:read": true, "subject_pds:update": true, "audit_logs:list": false, "audit_logs:read": false, "audit_logs:export": false, "knowledge_notes:create": true, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": true, "knowledge_notes:delete": false, "subject_history:list": true, "subject_history:read": true, "subject_history:timeline": true, "subject_history:export": true, "subject_history:search": true, "milestones:list": true, "milestones:update": false, "attachments:create": true, "attachments:read": true, "attachments:update": true, "attachments:delete": true, "fees_attachments:create": true, "fees_attachments:read": true, "fees_attachments:delete": true, "faq:create": false, "faq:read": true, "faq:update": false, "faq:delete": false, "faq_category:create": false, "faq_category:read": true, "faq_category:update": false, "faq_category:delete": false, "faq_reply:create": true, "faq_reply:delete": false, "subject_histories:create": true, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": true, "subject_histories:delete": false, "material_equipments:create": true, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": true, "material_equipments:delete": false, "documents:create": true, "documents:read": true, "documents:update": true, "documents:delete": false}}',
),
(
"f45fbfda-faed-4b83-b4a8-24ca4011894b",
@@ -108,7 +108,7 @@ def _insert_system_templates() -> None:
"负责药物警戒相关工作,跟踪安全性事件并支持风险评估。",
"PV",
"PV",
'{"PV": {"subjects:create": false, "subjects:list": true, "subjects:read": true, "subjects:update": false, "subjects:delete": false, "visits:create": false, "visits:list": true, "visits:read": true, "visits:update": false, "visits:delete": false, "risk_issues:create": true, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": true, "risk_issues:delete": false, "finance_contracts:create": false, "finance_contracts:list": false, "finance_contracts:read": false, "finance_contracts:update": false, "finance_contracts:delete": false, "fees_contracts:create": false, "fees_contracts:list": false, "fees_contracts:read": false, "fees_contracts:update": false, "fees_contracts:delete": false, "fees_payments:create": false, "fees_payments:update": false, "fees_payments:delete": false, "project_members:create": false, "project_members:list": false, "project_members:candidates": false, "project_members:update": false, "project_members:delete": false, "sites:create": false, "sites:list": true, "sites:read": true, "sites:update": false, "sites:delete": false, "ethics:create": false, "ethics:list": true, "ethics:read": true, "ethics:update": false, "ethics:delete": false, "feasibility:create": false, "feasibility:list": true, "feasibility:read": true, "feasibility:update": false, "feasibility:delete": false, "budget:create": false, "budget:list": false, "budget:read": false, "budget:update": false, "budget:delete": false, "timeline:create": false, "timeline:list": true, "timeline:read": true, "timeline:update": false, "timeline:delete": false, "permissions:read": false, "permissions:update": false, "overview:read": true, "monitoring_issues:create": false, "monitoring_issues:list": true, "monitoring_issues:read": true, "monitoring_issues:update": false, "monitoring_issues:delete": false, "monitoring_issues:close": false, "monitoring_issues:history": true, "monitoring_audit:create": true, "monitoring_audit:read": true, "monitoring_audit:update": true, "monitoring_audit:delete": false, "drug_shipments:create": false, "drug_shipments:list": false, "drug_shipments:read": false, "drug_shipments:update": false, "drug_shipments:delete": false, "materials:create": false, "materials:list": false, "materials:read": false, "materials:update": false, "materials:delete": false, "subject_pds:create": false, "subject_pds:list": true, "subject_pds:read": true, "subject_pds:update": false, "audit_logs:list": false, "audit_logs:read": false, "audit_logs:export": false, "knowledge_notes:create": true, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": true, "knowledge_notes:delete": false, "subject_history:list": true, "subject_history:read": true, "subject_history:timeline": true, "subject_history:export": false, "subject_history:search": true, "milestones:list": true, "milestones:update": false, "attachments:create": true, "attachments:read": true, "attachments:update": false, "attachments:delete": false, "fees_attachments:create": false, "fees_attachments:read": true, "fees_attachments:delete": false, "faq:create": false, "faq:read": true, "faq:update": false, "faq:delete": false, "faq_category:create": false, "faq_category:read": true, "faq_category:update": false, "faq_category:delete": false, "faq_reply:create": true, "faq_reply:delete": false, "dashboard:read": true, "subject_histories:create": false, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": false, "subject_histories:delete": false, "material_equipments:create": false, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": false, "material_equipments:delete": false, "documents:create": false, "documents:read": true, "documents:update": false, "documents:delete": false}}',
'{"PV": {"subjects:create": false, "subjects:list": true, "subjects:read": true, "subjects:update": false, "subjects:delete": false, "visits:create": false, "visits:list": true, "visits:read": true, "visits:update": false, "visits:delete": false, "risk_issues:create": true, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": true, "risk_issues:delete": false, "finance_contracts:create": false, "finance_contracts:list": false, "finance_contracts:read": false, "finance_contracts:update": false, "finance_contracts:delete": false, "fees_contracts:create": false, "fees_contracts:list": false, "fees_contracts:read": false, "fees_contracts:update": false, "fees_contracts:delete": false, "fees_payments:create": false, "fees_payments:update": false, "fees_payments:delete": false, "project_members:create": false, "project_members:list": false, "project_members:candidates": false, "project_members:update": false, "project_members:delete": false, "sites:create": false, "sites:list": true, "sites:read": true, "sites:update": false, "sites:delete": false, "ethics:create": false, "ethics:list": true, "ethics:read": true, "ethics:update": false, "ethics:delete": false, "feasibility:create": false, "feasibility:list": true, "feasibility:read": true, "feasibility:update": false, "feasibility:delete": false, "permissions:read": false, "permissions:update": false, "overview:read": true, "monitoring_issues:create": false, "monitoring_issues:list": true, "monitoring_issues:read": true, "monitoring_issues:update": false, "monitoring_issues:delete": false, "drug_shipments:create": false, "drug_shipments:list": false, "drug_shipments:read": false, "drug_shipments:update": false, "drug_shipments:delete": false, "materials:create": false, "materials:list": false, "materials:read": false, "materials:update": false, "materials:delete": false, "subject_pds:create": false, "subject_pds:list": true, "subject_pds:read": true, "subject_pds:update": false, "audit_logs:list": false, "audit_logs:read": false, "audit_logs:export": false, "knowledge_notes:create": true, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": true, "knowledge_notes:delete": false, "subject_history:list": true, "subject_history:read": true, "subject_history:timeline": true, "subject_history:export": false, "subject_history:search": true, "milestones:list": true, "milestones:update": false, "attachments:create": true, "attachments:read": true, "attachments:update": false, "attachments:delete": false, "fees_attachments:create": false, "fees_attachments:read": true, "fees_attachments:delete": false, "faq:create": false, "faq:read": true, "faq:update": false, "faq:delete": false, "faq_category:create": false, "faq_category:read": true, "faq_category:update": false, "faq_category:delete": false, "faq_reply:create": true, "faq_reply:delete": false, "subject_histories:create": false, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": false, "subject_histories:delete": false, "material_equipments:create": false, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": false, "material_equipments:delete": false, "documents:create": false, "documents:read": true, "documents:update": false, "documents:delete": false}}',
),
(
"b43d06e4-2ee2-4e80-b733-85964d738f40",
@@ -116,7 +116,7 @@ def _insert_system_templates() -> None:
"负责医学审核与稽查,关注质量风险、合规性和医学一致性。",
"MEDICAL_REVIEW",
"MEDICAL_REVIEW",
'{"MEDICAL_REVIEW": {"subjects:create": false, "subjects:list": true, "subjects:read": true, "subjects:update": false, "subjects:delete": false, "visits:create": false, "visits:list": true, "visits:read": true, "visits:update": false, "visits:delete": false, "risk_issues:create": true, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": true, "risk_issues:delete": false, "finance_contracts:create": false, "finance_contracts:list": false, "finance_contracts:read": false, "finance_contracts:update": false, "finance_contracts:delete": false, "fees_contracts:create": false, "fees_contracts:list": false, "fees_contracts:read": false, "fees_contracts:update": false, "fees_contracts:delete": false, "fees_payments:create": false, "fees_payments:update": false, "fees_payments:delete": false, "project_members:create": false, "project_members:list": false, "project_members:candidates": false, "project_members:update": false, "project_members:delete": false, "sites:create": false, "sites:list": true, "sites:read": true, "sites:update": false, "sites:delete": false, "ethics:create": false, "ethics:list": true, "ethics:read": true, "ethics:update": false, "ethics:delete": false, "feasibility:create": false, "feasibility:list": true, "feasibility:read": true, "feasibility:update": false, "feasibility:delete": false, "budget:create": false, "budget:list": false, "budget:read": false, "budget:update": false, "budget:delete": false, "timeline:create": false, "timeline:list": false, "timeline:read": false, "timeline:update": false, "timeline:delete": false, "permissions:read": false, "permissions:update": false, "overview:read": true, "monitoring_issues:create": false, "monitoring_issues:list": true, "monitoring_issues:read": true, "monitoring_issues:update": false, "monitoring_issues:delete": false, "monitoring_issues:close": false, "monitoring_issues:history": true, "monitoring_audit:create": true, "monitoring_audit:read": true, "monitoring_audit:update": true, "monitoring_audit:delete": false, "drug_shipments:create": false, "drug_shipments:list": false, "drug_shipments:read": false, "drug_shipments:update": false, "drug_shipments:delete": false, "materials:create": false, "materials:list": false, "materials:read": false, "materials:update": false, "materials:delete": false, "subject_pds:create": false, "subject_pds:list": true, "subject_pds:read": true, "subject_pds:update": false, "audit_logs:list": false, "audit_logs:read": false, "audit_logs:export": false, "knowledge_notes:create": true, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": true, "knowledge_notes:delete": false, "subject_history:list": true, "subject_history:read": true, "subject_history:timeline": true, "subject_history:export": false, "subject_history:search": true, "milestones:list": true, "milestones:update": false, "attachments:create": false, "attachments:read": true, "attachments:update": false, "attachments:delete": false, "fees_attachments:create": false, "fees_attachments:read": false, "fees_attachments:delete": false, "faq:create": false, "faq:read": true, "faq:update": false, "faq:delete": false, "faq_category:create": false, "faq_category:read": true, "faq_category:update": false, "faq_category:delete": false, "faq_reply:create": true, "faq_reply:delete": false, "dashboard:read": true, "subject_histories:create": false, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": false, "subject_histories:delete": false, "material_equipments:create": false, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": false, "material_equipments:delete": false, "documents:create": false, "documents:read": true, "documents:update": false, "documents:delete": false}}',
'{"MEDICAL_REVIEW": {"subjects:create": false, "subjects:list": true, "subjects:read": true, "subjects:update": false, "subjects:delete": false, "visits:create": false, "visits:list": true, "visits:read": true, "visits:update": false, "visits:delete": false, "risk_issues:create": true, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": true, "risk_issues:delete": false, "finance_contracts:create": false, "finance_contracts:list": false, "finance_contracts:read": false, "finance_contracts:update": false, "finance_contracts:delete": false, "fees_contracts:create": false, "fees_contracts:list": false, "fees_contracts:read": false, "fees_contracts:update": false, "fees_contracts:delete": false, "fees_payments:create": false, "fees_payments:update": false, "fees_payments:delete": false, "project_members:create": false, "project_members:list": false, "project_members:candidates": false, "project_members:update": false, "project_members:delete": false, "sites:create": false, "sites:list": true, "sites:read": true, "sites:update": false, "sites:delete": false, "ethics:create": false, "ethics:list": true, "ethics:read": true, "ethics:update": false, "ethics:delete": false, "feasibility:create": false, "feasibility:list": true, "feasibility:read": true, "feasibility:update": false, "feasibility:delete": false, "permissions:read": false, "permissions:update": false, "overview:read": true, "monitoring_issues:create": false, "monitoring_issues:list": true, "monitoring_issues:read": true, "monitoring_issues:update": false, "monitoring_issues:delete": false, "drug_shipments:create": false, "drug_shipments:list": false, "drug_shipments:read": false, "drug_shipments:update": false, "drug_shipments:delete": false, "materials:create": false, "materials:list": false, "materials:read": false, "materials:update": false, "materials:delete": false, "subject_pds:create": false, "subject_pds:list": true, "subject_pds:read": true, "subject_pds:update": false, "audit_logs:list": false, "audit_logs:read": false, "audit_logs:export": false, "knowledge_notes:create": true, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": true, "knowledge_notes:delete": false, "subject_history:list": true, "subject_history:read": true, "subject_history:timeline": true, "subject_history:export": false, "subject_history:search": true, "milestones:list": true, "milestones:update": false, "attachments:create": false, "attachments:read": true, "attachments:update": false, "attachments:delete": false, "fees_attachments:create": false, "fees_attachments:read": false, "fees_attachments:delete": false, "faq:create": false, "faq:read": true, "faq:update": false, "faq:delete": false, "faq_category:create": false, "faq_category:read": true, "faq_category:update": false, "faq_category:delete": false, "faq_reply:create": true, "faq_reply:delete": false, "subject_histories:create": false, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": false, "subject_histories:delete": false, "material_equipments:create": false, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": false, "material_equipments:delete": false, "documents:create": false, "documents:read": true, "documents:update": false, "documents:delete": false}}',
),
(
"1e38cffb-a7f2-4243-a31c-1a4f8ce66a05",
@@ -124,7 +124,7 @@ def _insert_system_templates() -> None:
"负责合同、药品及相关项目事务管理,保障执行支持与物资协同。",
"IMP",
"IMP",
'{"IMP": {"subjects:create": false, "subjects:list": true, "subjects:read": true, "subjects:update": false, "subjects:delete": false, "visits:create": false, "visits:list": true, "visits:read": true, "visits:update": false, "visits:delete": false, "risk_issues:create": false, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": false, "risk_issues:delete": false, "finance_contracts:create": false, "finance_contracts:list": true, "finance_contracts:read": true, "finance_contracts:update": false, "finance_contracts:delete": false, "fees_contracts:create": false, "fees_contracts:list": true, "fees_contracts:read": true, "fees_contracts:update": false, "fees_contracts:delete": false, "fees_payments:create": false, "fees_payments:update": false, "fees_payments:delete": false, "project_members:create": false, "project_members:list": false, "project_members:candidates": false, "project_members:update": false, "project_members:delete": false, "sites:create": false, "sites:list": true, "sites:read": true, "sites:update": false, "sites:delete": false, "ethics:create": false, "ethics:list": false, "ethics:read": false, "ethics:update": false, "ethics:delete": false, "feasibility:create": false, "feasibility:list": false, "feasibility:read": false, "feasibility:update": false, "feasibility:delete": false, "budget:create": false, "budget:list": true, "budget:read": true, "budget:update": false, "budget:delete": false, "timeline:create": false, "timeline:list": false, "timeline:read": false, "timeline:update": false, "timeline:delete": false, "permissions:read": false, "permissions:update": false, "overview:read": true, "monitoring_issues:create": false, "monitoring_issues:list": false, "monitoring_issues:read": false, "monitoring_issues:update": false, "monitoring_issues:delete": false, "monitoring_issues:close": false, "monitoring_issues:history": false, "monitoring_audit:create": false, "monitoring_audit:read": false, "monitoring_audit:update": false, "monitoring_audit:delete": false, "drug_shipments:create": true, "drug_shipments:list": true, "drug_shipments:read": true, "drug_shipments:update": true, "drug_shipments:delete": false, "materials:create": true, "materials:list": true, "materials:read": true, "materials:update": true, "materials:delete": false, "subject_pds:create": false, "subject_pds:list": false, "subject_pds:read": false, "subject_pds:update": false, "audit_logs:list": false, "audit_logs:read": false, "audit_logs:export": false, "knowledge_notes:create": false, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": false, "knowledge_notes:delete": false, "subject_history:list": false, "subject_history:read": false, "subject_history:timeline": false, "subject_history:export": false, "subject_history:search": false, "milestones:list": true, "milestones:update": false, "attachments:create": false, "attachments:read": true, "attachments:update": false, "attachments:delete": false, "fees_attachments:create": false, "fees_attachments:read": false, "fees_attachments:delete": false, "faq:create": false, "faq:read": true, "faq:update": false, "faq:delete": false, "faq_category:create": false, "faq_category:read": true, "faq_category:update": false, "faq_category:delete": false, "faq_reply:create": false, "faq_reply:delete": false, "dashboard:read": true, "subject_histories:create": false, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": false, "subject_histories:delete": false, "material_equipments:create": false, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": false, "material_equipments:delete": false, "documents:create": false, "documents:read": true, "documents:update": false, "documents:delete": false}}',
'{"IMP": {"subjects:create": false, "subjects:list": true, "subjects:read": true, "subjects:update": false, "subjects:delete": false, "visits:create": false, "visits:list": true, "visits:read": true, "visits:update": false, "visits:delete": false, "risk_issues:create": false, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": false, "risk_issues:delete": false, "finance_contracts:create": false, "finance_contracts:list": true, "finance_contracts:read": true, "finance_contracts:update": false, "finance_contracts:delete": false, "fees_contracts:create": false, "fees_contracts:list": true, "fees_contracts:read": true, "fees_contracts:update": false, "fees_contracts:delete": false, "fees_payments:create": false, "fees_payments:update": false, "fees_payments:delete": false, "project_members:create": false, "project_members:list": false, "project_members:candidates": false, "project_members:update": false, "project_members:delete": false, "sites:create": false, "sites:list": true, "sites:read": true, "sites:update": false, "sites:delete": false, "ethics:create": false, "ethics:list": false, "ethics:read": false, "ethics:update": false, "ethics:delete": false, "feasibility:create": false, "feasibility:list": false, "feasibility:read": false, "feasibility:update": false, "feasibility:delete": false, "permissions:read": false, "permissions:update": false, "overview:read": true, "monitoring_issues:create": false, "monitoring_issues:list": false, "monitoring_issues:read": false, "monitoring_issues:update": false, "monitoring_issues:delete": false, "drug_shipments:create": true, "drug_shipments:list": true, "drug_shipments:read": true, "drug_shipments:update": true, "drug_shipments:delete": false, "materials:create": true, "materials:list": true, "materials:read": true, "materials:update": true, "materials:delete": false, "subject_pds:create": false, "subject_pds:list": false, "subject_pds:read": false, "subject_pds:update": false, "audit_logs:list": false, "audit_logs:read": false, "audit_logs:export": false, "knowledge_notes:create": false, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": false, "knowledge_notes:delete": false, "subject_history:list": false, "subject_history:read": false, "subject_history:timeline": false, "subject_history:export": false, "subject_history:search": false, "milestones:list": true, "milestones:update": false, "attachments:create": false, "attachments:read": true, "attachments:update": false, "attachments:delete": false, "fees_attachments:create": false, "fees_attachments:read": false, "fees_attachments:delete": false, "faq:create": false, "faq:read": true, "faq:update": false, "faq:delete": false, "faq_category:create": false, "faq_category:read": true, "faq_category:update": false, "faq_category:delete": false, "faq_reply:create": false, "faq_reply:delete": false, "subject_histories:create": false, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": false, "subject_histories:delete": false, "material_equipments:create": false, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": false, "material_equipments:delete": false, "documents:create": false, "documents:read": true, "documents:update": false, "documents:delete": false}}',
),
]
@@ -1,4 +1,4 @@
"""remove qa role
"""remove legacy qa role
Revision ID: 20260521_01
Revises: 20260520_01
@@ -110,7 +110,7 @@ def upgrade() -> None:
"""
)
# Delete QA users after dependent project-role and audit rows have been removed.
# Delete legacy QA users after dependent project-role and audit rows have been removed.
op.execute("DELETE FROM users WHERE id IN (SELECT id FROM qa_user_ids)")
op.execute("DROP TABLE qa_user_ids")
@@ -134,7 +134,7 @@ def upgrade() -> None:
def downgrade() -> None:
allowed = ", ".join(f"'{role}'" for role in (*USER_ROLE_VALUES, "QA"))
allowed = ", ".join(f"'{role}'" for role in USER_ROLE_VALUES)
op.execute(
"""
ALTER TABLE users
@@ -0,0 +1,90 @@
"""remove stale unused permissions
Revision ID: 20260526_01
Revises: 20260522_01
Create Date: 2026-05-26 15:40:00.000000
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260526_01"
down_revision: Union[str, None] = "20260522_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
LEGACY_MONITORING_AUDIT_PREFIX = "monitoring" + "_audit"
MONITORING_ISSUE_PREFIX = "monitoring" + "_issues"
STALE_PERMISSION_KEYS = (
"subject_history:list",
"subject_history:read",
"subject_history:timeline",
"subject_history:export",
"subject_history:search",
"subject_pds:read",
"visits:read",
"risk_issues:create",
"risk_issues:list",
"risk_issues:read",
"risk_issues:update",
"risk_issues:delete",
"risk_issue_aes:list",
"risk_issue_pds:list",
f"{MONITORING_ISSUE_PREFIX}:close",
f"{MONITORING_ISSUE_PREFIX}:history",
f"{LEGACY_MONITORING_AUDIT_PREFIX}:create",
f"{LEGACY_MONITORING_AUDIT_PREFIX}:read",
f"{LEGACY_MONITORING_AUDIT_PREFIX}:update",
f"{LEGACY_MONITORING_AUDIT_PREFIX}:delete",
)
def _quoted_keys() -> str:
return ", ".join(f"'{key}'" for key in STALE_PERMISSION_KEYS)
def upgrade() -> None:
op.execute(
f"""
DELETE FROM api_endpoint_permissions
WHERE endpoint_key IN ({_quoted_keys()})
"""
)
op.execute(
f"""
UPDATE permission_templates
SET permissions = (
SELECT jsonb_object_agg(role_key, role_permissions - ARRAY[{_quoted_keys()}])
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
),
updated_at = NOW()
WHERE EXISTS (
SELECT 1
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
WHERE role_permissions ?| ARRAY[{_quoted_keys()}]
)
"""
)
op.execute(
f"""
UPDATE permission_template_versions
SET permissions = (
SELECT jsonb_object_agg(role_key, role_permissions - ARRAY[{_quoted_keys()}])
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
)
WHERE EXISTS (
SELECT 1
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
WHERE role_permissions ?| ARRAY[{_quoted_keys()}]
)
"""
)
def downgrade() -> None:
pass
@@ -0,0 +1,149 @@
"""rename project roles to qa and cta
Revision ID: 20260527_01
Revises: 20260526_01
Create Date: 2026-05-27 08:45:00.000000
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260527_01"
down_revision: Union[str, None] = "20260526_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _replace_role_values(table: str, column: str) -> None:
op.execute(f"UPDATE {table} SET {column} = 'QA' WHERE {column} = 'MEDICAL_REVIEW'")
op.execute(f"UPDATE {table} SET {column} = 'CTA' WHERE {column} = 'IMP'")
def _restore_role_values(table: str, column: str) -> None:
op.execute(f"UPDATE {table} SET {column} = 'MEDICAL_REVIEW' WHERE {column} = 'QA'")
op.execute(f"UPDATE {table} SET {column} = 'IMP' WHERE {column} = 'CTA'")
def _normalize_role_array(table: str, column: str) -> None:
op.execute(
f"""
UPDATE {table}
SET {column} = (
SELECT COALESCE(json_agg(DISTINCT mapped_role), '[]'::json)
FROM (
SELECT CASE role
WHEN 'MEDICAL_REVIEW' THEN 'QA'
WHEN 'IMP' THEN 'CTA'
ELSE role
END AS mapped_role
FROM json_array_elements_text({column}) AS role
) mapped
)
WHERE {column}::text LIKE '%MEDICAL_REVIEW%'
OR {column}::text LIKE '%IMP%'
"""
)
def _normalize_permission_template_roles(table: str) -> None:
op.execute(
f"""
UPDATE {table}
SET permissions = (
SELECT jsonb_object_agg(
CASE role_key
WHEN 'MEDICAL_REVIEW' THEN 'QA'
WHEN 'IMP' THEN 'CTA'
ELSE role_key
END,
role_permissions
)
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
)
WHERE permissions::jsonb ? 'MEDICAL_REVIEW'
OR permissions::jsonb ? 'IMP'
"""
)
def _normalize_recommended_roles() -> None:
op.execute(
"""
UPDATE permission_templates
SET recommended_roles = regexp_replace(
regexp_replace(recommended_roles, '(^|,)MEDICAL_REVIEW(,|$)', '\\1QA\\2', 'g'),
'(^|,)IMP(,|$)', '\\1CTA\\2', 'g'
)
WHERE recommended_roles ~ '(^|,)(MEDICAL_REVIEW|IMP)(,|$)'
"""
)
def _restore_permission_template_roles(table: str) -> None:
op.execute(
f"""
UPDATE {table}
SET permissions = (
SELECT jsonb_object_agg(
CASE role_key
WHEN 'QA' THEN 'MEDICAL_REVIEW'
WHEN 'CTA' THEN 'IMP'
ELSE role_key
END,
role_permissions
)
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
)
WHERE permissions::jsonb ? 'QA'
OR permissions::jsonb ? 'CTA'
"""
)
def _replace_role_enum_values() -> None:
op.execute("ALTER TYPE user_role ADD VALUE IF NOT EXISTS 'QA'")
op.execute("ALTER TYPE user_role ADD VALUE IF NOT EXISTS 'CTA'")
op.execute(
"""
ALTER TABLE users
ALTER COLUMN role TYPE text
USING role::text
"""
)
_replace_role_values("users", "role")
op.execute("DROP TYPE user_role")
op.execute("CREATE TYPE user_role AS ENUM ('ADMIN', 'PM', 'CRA', 'PV', 'QA', 'CTA')")
op.execute(
"""
ALTER TABLE users
ALTER COLUMN role TYPE user_role
USING role::user_role
"""
)
def upgrade() -> None:
_replace_role_enum_values()
_replace_role_values("study_members", "role_in_study")
_replace_role_values("api_endpoint_permissions", "role")
_replace_role_values("permission_access_logs", "role")
_replace_role_values("audit_logs", "operator_role")
_replace_role_values("permission_templates", "category")
_normalize_recommended_roles()
_normalize_role_array("studies", "active_roles")
_normalize_permission_template_roles("permission_templates")
_normalize_permission_template_roles("permission_template_versions")
def downgrade() -> None:
_restore_role_values("study_members", "role_in_study")
_restore_role_values("api_endpoint_permissions", "role")
_restore_role_values("permission_access_logs", "role")
_restore_role_values("audit_logs", "operator_role")
_restore_role_values("permission_templates", "category")
_restore_role_values("permission_templates", "recommended_roles")
_restore_permission_template_roles("permission_templates")
_restore_permission_template_roles("permission_template_versions")
@@ -0,0 +1,81 @@
"""remove stale startup permissions
Revision ID: 20260527_04
Revises: 20260527_03
Create Date: 2026-05-27 11:05:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260527_04"
down_revision: Union[str, None] = "20260527_03"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
STALE_STARTUP_PERMISSION_KEYS = (
"budget:create",
"budget:list",
"budget:read",
"budget:update",
"budget:delete",
"timeline:create",
"timeline:list",
"timeline:read",
"timeline:update",
"timeline:delete",
)
def _quoted_keys() -> str:
return ", ".join(f"'{key}'" for key in STALE_STARTUP_PERMISSION_KEYS)
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _remove_template_permission_keys(table_name: str, *, touch_updated_at: bool = False) -> None:
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
op.execute(
f"""
UPDATE {table_name}
SET permissions = (
SELECT jsonb_object_agg(role_key, role_permissions - ARRAY[{_quoted_keys()}])
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
){updated_at_assignment}
WHERE EXISTS (
SELECT 1
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
WHERE role_permissions ?| ARRAY[{_quoted_keys()}]
)
"""
)
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "api_endpoint_permissions"):
op.execute(
f"""
DELETE FROM api_endpoint_permissions
WHERE endpoint_key IN ({_quoted_keys()})
"""
)
if _table_exists(inspector, "permission_templates"):
_remove_template_permission_keys("permission_templates", touch_updated_at=True)
if _table_exists(inspector, "permission_template_versions"):
_remove_template_permission_keys("permission_template_versions")
def downgrade() -> None:
pass
@@ -0,0 +1,81 @@
"""remove legacy startup ethics permission keys
Revision ID: 20260527_05
Revises: 20260527_04
Create Date: 2026-05-27 11:20:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260527_05"
down_revision: Union[str, None] = "20260527_04"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
LEGACY_STARTUP_ETHICS_PERMISSION_KEYS = (
"feasibility:create",
"feasibility:list",
"feasibility:read",
"feasibility:update",
"feasibility:delete",
"ethics:create",
"ethics:list",
"ethics:read",
"ethics:update",
"ethics:delete",
)
def _quoted_keys() -> str:
return ", ".join(f"'{key}'" for key in LEGACY_STARTUP_ETHICS_PERMISSION_KEYS)
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _remove_template_permission_keys(table_name: str, *, touch_updated_at: bool = False) -> None:
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
op.execute(
f"""
UPDATE {table_name}
SET permissions = (
SELECT jsonb_object_agg(role_key, role_permissions - ARRAY[{_quoted_keys()}])
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
){updated_at_assignment}
WHERE EXISTS (
SELECT 1
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
WHERE role_permissions ?| ARRAY[{_quoted_keys()}]
)
"""
)
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "api_endpoint_permissions"):
op.execute(
f"""
DELETE FROM api_endpoint_permissions
WHERE endpoint_key IN ({_quoted_keys()})
"""
)
if _table_exists(inspector, "permission_templates"):
_remove_template_permission_keys("permission_templates", touch_updated_at=True)
if _table_exists(inspector, "permission_template_versions"):
_remove_template_permission_keys("permission_template_versions")
def downgrade() -> None:
pass
@@ -0,0 +1,70 @@
"""remove dashboard permission
Revision ID: 20260527_06
Revises: 20260527_05
Create Date: 2026-05-27 16:05:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260527_06"
down_revision: Union[str, None] = "20260527_05"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
LEGACY_DASHBOARD_PERMISSION_KEYS = ("dashboard:read",)
def _quoted_keys() -> str:
return ", ".join(f"'{key}'" for key in LEGACY_DASHBOARD_PERMISSION_KEYS)
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _remove_template_permission_keys(table_name: str, *, touch_updated_at: bool = False) -> None:
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
op.execute(
f"""
UPDATE {table_name}
SET permissions = (
SELECT jsonb_object_agg(role_key, role_permissions - ARRAY[{_quoted_keys()}])
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
){updated_at_assignment}
WHERE EXISTS (
SELECT 1
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
WHERE role_permissions ?| ARRAY[{_quoted_keys()}]
)
"""
)
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "api_endpoint_permissions"):
op.execute(
f"""
DELETE FROM api_endpoint_permissions
WHERE endpoint_key IN ({_quoted_keys()})
"""
)
if _table_exists(inspector, "permission_templates"):
_remove_template_permission_keys("permission_templates", touch_updated_at=True)
if _table_exists(inspector, "permission_template_versions"):
_remove_template_permission_keys("permission_template_versions")
def downgrade() -> None:
pass
File diff suppressed because it is too large Load Diff
+5
View File
@@ -48,6 +48,9 @@ async def role_has_api_permission(
check_prerequisites: bool = True,
) -> bool:
"""检查角色是否有权访问特定接口"""
if endpoint_key not in API_ENDPOINT_PERMISSIONS:
return False
if role == "ADMIN":
return True
@@ -129,6 +132,8 @@ async def get_api_endpoint_permissions(
if role not in matrix:
matrix[role] = {}
for endpoint_key, allowed in endpoints.items():
if endpoint_key not in API_ENDPOINT_PERMISSIONS:
continue
matrix[role][endpoint_key] = {"allowed": allowed}
return matrix
+1 -1
View File
@@ -25,7 +25,7 @@ ROLE_ACTIONS: dict[str, set[str]] = {
"PM": {"view", "create_document", "create_version", "submit", "approve", "distribute", "ack", "delete_document"},
"CRA": {"view", "create_version", "submit", "ack"},
"PV": {"view", "create_version", "submit", "ack"},
"IMP": {"view", "create_version", "submit", "ack"},
"CTA": {"view", "create_version", "submit", "ack"},
}
+2 -2
View File
@@ -17,8 +17,8 @@ class UserRole(str, enum.Enum):
PM = "PM"
CRA = "CRA"
PV = "PV"
MEDICAL_REVIEW = "MEDICAL_REVIEW"
IMP = "IMP"
QA = "QA"
CTA = "CTA"
class UserStatus(str, enum.Enum):
+1 -1
View File
@@ -5,7 +5,7 @@ from typing import Literal
from pydantic import BaseModel, Field
ProjectPermissionRole = Literal["ADMIN", "PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"]
ProjectPermissionRole = Literal["ADMIN", "PM", "CRA", "PV", "QA", "CTA"]
ProjectPermissionAction = Literal["read", "write"]
+1 -1
View File
@@ -5,7 +5,7 @@ from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
UserRole = Literal["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "ADMIN"]
UserRole = Literal["PM", "CRA", "PV", "QA", "CTA", "ADMIN"]
UserStatus = Literal["PENDING", "ACTIVE", "REJECTED", "DISABLED"]
PASSWORD_REGEX = re.compile(r"^(?=.*[A-Za-z])(?=.*\d).{8,}$")
+443 -6
View File
@@ -7,7 +7,7 @@ import ast
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, PROJECT_PERMISSION_ROLES
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, OPERATION_TO_ENDPOINTS, PROJECT_PERMISSION_ROLES
from app.core.project_permissions import role_has_api_permission
from app.core.project_permissions import get_api_endpoint_permissions, replace_api_endpoint_permissions
from app.core.permission_cache import PermissionCache, set_permission_cache
@@ -61,6 +61,326 @@ def test_all_backend_permission_guards_are_configurable():
assert used_keys <= set(API_ENDPOINT_PERMISSIONS)
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:update": "更新病史记录",
"subject_histories:delete": "删除病史记录",
}
for endpoint_key, description in expected_descriptions.items():
assert API_ENDPOINT_PERMISSIONS[endpoint_key]["module"] == "subjects"
assert API_ENDPOINT_PERMISSIONS[endpoint_key]["description"] == description
def test_contract_fee_permissions_do_not_include_legacy_finance_contracts():
"""旧合同基础信息权限应从当前权限矩阵移除。"""
for endpoint_key in API_ENDPOINT_PERMISSIONS:
assert not endpoint_key.startswith("finance_contracts:")
assert not endpoint_key.startswith("fees_payments:")
assert not endpoint_key.startswith("fees_attachments:")
expected_descriptions = {
"fees_contracts:create": "创建合同费用",
"fees_contracts:list": "查询合同费用列表",
"fees_contracts:read": "查询合同费用详情",
"fees_contracts:update": "更新合同费用",
"fees_contracts:delete": "删除合同费用",
}
for endpoint_key, description in expected_descriptions.items():
assert API_ENDPOINT_PERMISSIONS[endpoint_key]["module"] == "fees"
assert API_ENDPOINT_PERMISSIONS[endpoint_key]["description"] == 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",
"precautions_attachments:delete",
"faq_attachments:create",
"faq_attachments:read",
"faq_attachments:delete",
}
assert expected_keys <= set(API_ENDPOINT_PERMISSIONS)
def test_precautions_permissions_use_business_aligned_keys():
"""注意事项权限应使用业务一致的英文 key,并归入共享库父模块。"""
expected_descriptions = {
"precautions:create": "创建注意事项",
"precautions:list": "查询注意事项列表",
"precautions:read": "查询注意事项详情",
"precautions:update": "更新注意事项",
"precautions:delete": "删除注意事项",
}
for endpoint_key, description in expected_descriptions.items():
assert API_ENDPOINT_PERMISSIONS[endpoint_key]["module"] == "shared_library"
assert API_ENDPOINT_PERMISSIONS[endpoint_key]["description"] == description
assert not any(key.startswith("knowledge_notes:") for key in API_ENDPOINT_PERMISSIONS)
assert not any(key.startswith("knowledge_notes_attachments:") for key in API_ENDPOINT_PERMISSIONS)
def test_faq_and_precautions_are_shared_library_sibling_sections():
"""医学咨询与注意事项应同属共享库,由前端模块细分区分。"""
shared_library_keys = OPERATION_TO_ENDPOINTS["shared_library"]
assert "faq:read" in shared_library_keys["read"]
assert "precautions:read" in shared_library_keys["read"]
assert "faq:create" in shared_library_keys["write"]
assert "precautions:create" in shared_library_keys["write"]
assert API_ENDPOINT_PERMISSIONS["faq:read"]["module"] == "shared_library"
assert API_ENDPOINT_PERMISSIONS["precautions:read"]["module"] == "shared_library"
def test_precautions_runtime_code_uses_business_entity_names():
"""运行时代码不应继续使用 knowledge_note 作为注意事项实体命名。"""
backend_root = Path(__file__).resolve().parents[1] / "app"
ignored = {"__pycache__"}
combined_source = "\n".join(
path.read_text(encoding="utf-8")
for path in backend_root.rglob("*.py")
if not ignored.intersection(path.parts)
)
assert "KnowledgeNote" not in combined_source
assert "knowledge_note" not in combined_source
assert "knowledge_notes" not in combined_source
assert "Precaution" in combined_source
def test_legacy_fee_attachment_router_is_not_registered():
"""费用附件应统一走通用附件 API,不再注册专用 /fees/attachments 路由。"""
router_source = (Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "router.py").read_text()
assert "fees_attachments" not in router_source
def test_attachment_permissions_use_module_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
assert source.count("_ensure_attachment_permission(") >= 8
def test_attachment_parent_permissions_cover_business_modules():
"""已知附件实体必须叠加对应父模块权限,避免只靠附件权限越权读取业务数据。"""
attachments_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "attachments.py"
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"',
}
for expected in expected_parent_permissions:
assert expected in source
def test_startup_ethics_permissions_use_visible_business_language():
"""立项与伦理权限矩阵应使用前台一致的业务名称。"""
expected_descriptions = {
"startup_initiation:create": "创建立项记录",
"startup_initiation:list": "查询立项记录列表",
"startup_initiation:read": "查询立项记录详情",
"startup_initiation:update": "更新立项记录",
"startup_initiation:delete": "删除立项记录",
"startup_ethics:create": "创建伦理记录",
"startup_ethics:list": "查询伦理记录列表",
"startup_ethics:read": "查询伦理记录详情",
"startup_ethics:update": "更新伦理记录",
"startup_ethics:delete": "删除伦理记录",
}
assert not any(key.startswith("feasibility:") for key in API_ENDPOINT_PERMISSIONS)
assert not any(key.startswith("ethics:") for key in API_ENDPOINT_PERMISSIONS)
for endpoint_key, description in expected_descriptions.items():
assert API_ENDPOINT_PERMISSIONS[endpoint_key]["module"] == "startup_ethics"
assert API_ENDPOINT_PERMISSIONS[endpoint_key]["description"] == description
def test_startup_auth_permissions_only_include_wired_operations():
"""启动与授权权限矩阵只应展示实际路由使用的操作。"""
stale_keys = {
"budget:create",
"budget:list",
"budget:read",
"budget:update",
"budget:delete",
"timeline:create",
"timeline:list",
"timeline:read",
"timeline:update",
"timeline:delete",
}
assert stale_keys.isdisjoint(API_ENDPOINT_PERMISSIONS)
for endpoint_keys in OPERATION_TO_ENDPOINTS["startup_auth"].values():
assert stale_keys.isdisjoint(endpoint_keys)
def test_visit_list_permission_matches_wired_visit_endpoints():
"""访视当前没有详情查询接口,列表接口应只使用 visits:list。"""
visits_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "visits.py"
source = visits_path.read_text()
assert "visits:list" in API_ENDPOINT_PERMISSIONS
assert "visits:read" not in API_ENDPOINT_PERMISSIONS
assert 'require_api_permission("visits:list")' in source
assert 'require_api_permission("visits:read")' not in source
def test_ae_subject_and_summary_share_read_permission():
"""风险问题AE汇总与参与者AE列表应使用同一个读取权限。"""
aes_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "aes.py"
source = aes_path.read_text()
summary_chunk = source[source.index('"/summary"') : source.index('@router.get(\n "/"')]
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 detail_chunk
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:update": "更新参与者AE",
"subject_aes:delete": "删除参与者AE",
}
for endpoint_key, description in expected_descriptions.items():
assert API_ENDPOINT_PERMISSIONS[endpoint_key]["module"] == "subjects"
assert API_ENDPOINT_PERMISSIONS[endpoint_key]["description"] == description
for stale_key in ("risk_issues:create", "risk_issues:list", "risk_issues:read", "risk_issues:update", "risk_issues:delete"):
assert stale_key not in API_ENDPOINT_PERMISSIONS
for stale_key in ("risk_issue_aes:list", "risk_issue_pds:list"):
assert stale_key not in API_ENDPOINT_PERMISSIONS
def test_risk_issue_pd_summary_uses_subject_pd_permission():
"""风险问题PD汇总与参与者PD列表应使用同一个读取权限。"""
pds_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "study_subject_pds.py"
source = pds_path.read_text()
assert 'require_api_permission("subject_pds:list")' in source
assert 'require_api_permission("risk_issues:list")' not in source
def test_risk_issue_monitoring_visit_uses_monitoring_issue_permissions():
"""监查访视问题是风险问题下唯一独立权限子模块。"""
monitoring_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "monitoring_visit_issues.py"
source = monitoring_path.read_text()
legacy_issue_prefix = "monitoring" + "_issues"
tree = ast.parse(source)
used_permission_keys = {
node.args[0].value
for node in ast.walk(tree)
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "require_api_permission"
and node.args
and isinstance(node.args[0], ast.Constant)
and isinstance(node.args[0].value, str)
}
list_chunk = source[source.index('@router.get(\n "/issues"') : source.index("@router.post")]
create_chunk = source[source.index("@router.post") : source.index('@router.get(\n "/issues/export"')]
export_chunk = source[source.index('@router.get(\n "/issues/export"') : source.index('@router.get(\n "/issues/{issue_id}"')]
detail_chunk = source[source.index('@router.get(\n "/issues/{issue_id}"') : source.index("@router.patch")]
update_chunk = source[source.index("@router.patch") : source.index("@router.delete")]
delete_chunk = source[source.index("@router.delete") : source.index('@router.post(\n "/issues/import"')]
import_chunk = source[source.index('@router.post(\n "/issues/import"') :]
expected_descriptions = {
"monitoring_issues:list": "查询监查访视问题列表",
"monitoring_issues:read": "查询监查访视问题详情",
"monitoring_issues:create": "创建监查访视问题",
"monitoring_issues:update": "更新监查访视问题",
"monitoring_issues:delete": "删除监查访视问题",
}
for endpoint_key, description in expected_descriptions.items():
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 detail_chunk
assert 'require_api_permission("monitoring_issues:create")' in create_chunk
assert 'require_api_permission("monitoring_issues:create")' in import_chunk
assert 'require_api_permission("monitoring_issues:update")' in update_chunk
assert 'require_api_permission("monitoring_issues:delete")' in delete_chunk
assert used_permission_keys == set(expected_descriptions)
for stale_key in (
f"{legacy_issue_prefix}:close",
f"{legacy_issue_prefix}:history",
):
assert stale_key not in API_ENDPOINT_PERMISSIONS
assert not any(config["module"] == "monitoring" + "_audit" for config in API_ENDPOINT_PERMISSIONS.values())
def test_document_service_uses_specific_document_permission_keys():
"""文档模块应使用 create/update/delete 细粒度权限,而不是全部退化为 update。"""
service_path = Path(__file__).resolve().parents[1] / "app" / "services" / "document_service.py"
@@ -72,8 +392,8 @@ def test_document_service_uses_specific_document_permission_keys():
assert 'else "documents:update"' not in source
def test_user_role_no_longer_contains_qa():
assert "QA" not in {role.value for role in UserRole}
def test_user_role_contains_current_project_roles():
assert {"QA", "CTA"} <= {role.value for role in UserRole}
@pytest.mark.asyncio
@@ -82,8 +402,9 @@ async def test_default_matrix_covers_every_role_and_permission(db_session: Async
study_id = uuid.uuid4()
matrix = await get_api_endpoint_permissions(db_session, study_id)
assert "QA" not in PROJECT_PERMISSION_ROLES
assert all("QA" not in config["default_roles"] for config in API_ENDPOINT_PERMISSIONS.values())
assert {"QA", "CTA"} <= set(PROJECT_PERMISSION_ROLES)
assert any("QA" in config["default_roles"] for config in API_ENDPOINT_PERMISSIONS.values())
assert any("CTA" in config["default_roles"] for config in API_ENDPOINT_PERMISSIONS.values())
assert set(matrix) == set(PROJECT_PERMISSION_ROLES)
for role in PROJECT_PERMISSION_ROLES:
assert set(matrix[role]) == set(API_ENDPOINT_PERMISSIONS)
@@ -184,6 +505,69 @@ async def test_custom_active_role_permissions_take_effect(db_session: AsyncSessi
) is False
@pytest.mark.asyncio
async def test_permission_matrix_filters_stale_unknown_endpoint_overrides(db_session: AsyncSession):
"""数据库历史残留的未知权限项不应重新出现在权限矩阵中。"""
study_id = uuid.uuid4()
db_session.add_all(
(
ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="subject_history:export",
allowed=True,
),
ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="subject_pds:read",
allowed=True,
),
ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="visits:read",
allowed=True,
),
ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="risk_issues:create",
allowed=True,
),
ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="risk_issues:list",
allowed=True,
),
ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="risk_issue_aes:list",
allowed=True,
),
ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key=("monitoring" + "_issues") + ":history",
allowed=True,
),
)
)
await db_session.commit()
matrix = await get_api_endpoint_permissions(db_session, study_id)
assert "subject_history:export" not in matrix["CRA"]
assert "subject_pds:read" not in matrix["CRA"]
assert "visits:read" not in matrix["CRA"]
assert "risk_issues:create" not in matrix["CRA"]
assert "risk_issues:list" not in matrix["CRA"]
assert "risk_issue_aes:list" not in matrix["CRA"]
assert ("monitoring" + "_issues") + ":history" not in matrix["CRA"]
def test_admin_cannot_be_used_as_project_custom_role():
"""避免项目角色 ADMIN 触发后端系统管理员绕过逻辑。"""
with pytest.raises(ValueError):
@@ -218,7 +602,7 @@ async def test_admin_always_allowed(db_session: AsyncSession):
study_id = uuid.uuid4()
result = await role_has_api_permission(
db_session, study_id, "ADMIN", "POST:/subjects"
db_session, study_id, "ADMIN", "subjects:create"
)
assert result is True
@@ -370,6 +754,59 @@ async def test_api_permission_unknown_endpoint(db_session: AsyncSession):
assert result is False
@pytest.mark.asyncio
async def test_api_permission_unknown_endpoint_ignores_stale_override(db_session: AsyncSession):
"""即使数据库残留未知权限项,鉴权入口也不应放行。"""
study_id = uuid.uuid4()
db_session.add_all(
(
ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="subject_history:export",
allowed=True,
),
ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="subject_pds:read",
allowed=True,
),
ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="risk_issues:list",
allowed=True,
),
ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="risk_issue_aes:list",
allowed=True,
),
)
)
await db_session.commit()
history_result = await role_has_api_permission(
db_session, study_id, "CRA", "subject_history:export", check_prerequisites=False
)
pds_result = await role_has_api_permission(
db_session, study_id, "CRA", "subject_pds:read", check_prerequisites=False
)
risk_issue_result = await role_has_api_permission(
db_session, study_id, "CRA", "risk_issues:list", check_prerequisites=False
)
risk_issue_ae_result = await role_has_api_permission(
db_session, study_id, "CRA", "risk_issue_aes:list", check_prerequisites=False
)
assert history_result is False
assert pds_result is False
assert risk_issue_result is False
assert risk_issue_ae_result is False
@pytest.mark.asyncio
async def test_api_permission_check_uses_project_permission_cache(db_session: AsyncSession):
"""测试接口权限检查会复用项目权限缓存并记录命中指标"""
@@ -144,8 +144,8 @@ async def test_replace_api_endpoint_permissions_multiple_endpoints(db_session: A
"subjects:list": True,
"subjects:update": True,
"subjects:delete": False,
"risk_issues:create": True,
"risk_issues:list": True,
"subject_aes:create": True,
"subject_aes:list": True,
}
}
@@ -155,8 +155,8 @@ async def test_replace_api_endpoint_permissions_multiple_endpoints(db_session: A
assert result["CRA"]["subjects:list"]["allowed"] is True
assert result["CRA"]["subjects:update"]["allowed"] is True
assert result["CRA"]["subjects:delete"]["allowed"] is False
assert result["CRA"]["risk_issues:create"]["allowed"] is True
assert result["CRA"]["risk_issues:list"]["allowed"] is True
assert result["CRA"]["subject_aes:create"]["allowed"] is True
assert result["CRA"]["subject_aes:list"]["allowed"] is True
@pytest.mark.asyncio
@@ -2,6 +2,7 @@
import pytest
import uuid
from pathlib import Path
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.api_permissions import (
@@ -32,6 +33,63 @@ async def test_list_operations_with_prerequisites():
assert "sites:read" in subjects_create["prerequisite_permissions"]
@pytest.mark.asyncio
async def test_material_equipment_operations_are_grouped_under_materials_module():
"""测试设备管理权限归入物资管理模块"""
data = await list_api_operations()
operations = data["operations"]
material_equipment_read = next(
(op for op in operations if op["operation_key"] == "material_equipments:read"),
None,
)
assert material_equipment_read is not None
assert material_equipment_read["module"] == "materials"
@pytest.mark.asyncio
async def test_materials_module_only_contains_drug_flow_and_equipment_sections():
"""测试物资管理模块只包含药品流向管理和设备管理"""
data = await list_api_operations()
operations = data["operations"]
material_prefixes = {
op["operation_key"].split(":", 1)[0]
for op in operations
if op["module"] == "materials"
}
assert material_prefixes == {"drug_shipments", "material_equipments"}
@pytest.mark.asyncio
async def test_project_overview_replaces_dashboard_permission():
"""测试项目总览权限合并仪表板读取权限"""
data = await list_api_operations()
operations = data["operations"]
operation_keys = {op["operation_key"] for op in operations}
project_overview = next(
(op for op in operations if op["operation_key"] == "project_overview:read"),
None,
)
assert "dashboard:read" not in operation_keys
assert project_overview is not None
assert project_overview["module"] == "project_overview"
assert "sites:read" in project_overview["prerequisite_permissions"]
assert "subjects:read" in project_overview["prerequisite_permissions"]
def test_dashboard_summary_uses_project_overview_permission():
"""测试仪表盘统计接口复用项目总览读取权限"""
source = Path(__file__).parents[1].joinpath("app/api/v1/dashboard.py").read_text()
assert 'require_api_permission("project_overview:read")' in source
assert 'require_api_permission("dashboard:read")' not in source
@pytest.mark.asyncio
async def test_list_operation_prerequisites_endpoint():
"""测试获取所有操作的前置权限依赖"""
+41 -216
View File
@@ -18,7 +18,7 @@ async def test_create_subject_with_api_permission(db_session: AsyncSession):
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="POST:/subjects",
endpoint_key="subjects:create",
allowed=True,
)
db_session.add(perm)
@@ -26,7 +26,7 @@ async def test_create_subject_with_api_permission(db_session: AsyncSession):
# 验证权限
result = await role_has_api_permission(
db_session, study_id, "CRA", "POST:/subjects"
db_session, study_id, "CRA", "subjects:create"
)
assert result is True
@@ -40,7 +40,7 @@ async def test_create_subject_without_permission(db_session: AsyncSession):
perm = ApiEndpointPermission(
study_id=study_id,
role="PV",
endpoint_key="POST:/subjects",
endpoint_key="subjects:create",
allowed=False,
)
db_session.add(perm)
@@ -48,7 +48,7 @@ async def test_create_subject_without_permission(db_session: AsyncSession):
# 验证权限
result = await role_has_api_permission(
db_session, study_id, "PV", "POST:/subjects"
db_session, study_id, "PV", "subjects:create"
)
assert result is False
@@ -62,7 +62,7 @@ async def test_list_subjects_with_permission(db_session: AsyncSession):
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="GET:/subjects",
endpoint_key="subjects:list",
allowed=True,
)
db_session.add(perm)
@@ -70,7 +70,7 @@ async def test_list_subjects_with_permission(db_session: AsyncSession):
# 验证权限
result = await role_has_api_permission(
db_session, study_id, "CRA", "GET:/subjects"
db_session, study_id, "CRA", "subjects:list"
)
assert result is True
@@ -84,7 +84,7 @@ async def test_get_subject_detail_with_permission(db_session: AsyncSession):
perm = ApiEndpointPermission(
study_id=study_id,
role="PV",
endpoint_key="GET:/subjects/{id}",
endpoint_key="subjects:read",
allowed=True,
)
db_session.add(perm)
@@ -92,7 +92,7 @@ async def test_get_subject_detail_with_permission(db_session: AsyncSession):
# 验证权限
result = await role_has_api_permission(
db_session, study_id, "PV", "GET:/subjects/{id}"
db_session, study_id, "PV", "subjects:read"
)
assert result is True
@@ -106,7 +106,7 @@ async def test_update_subject_with_permission(db_session: AsyncSession):
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="PATCH:/subjects/{id}",
endpoint_key="subjects:update",
allowed=True,
)
db_session.add(perm)
@@ -114,7 +114,7 @@ async def test_update_subject_with_permission(db_session: AsyncSession):
# 验证权限
result = await role_has_api_permission(
db_session, study_id, "CRA", "PATCH:/subjects/{id}"
db_session, study_id, "CRA", "subjects:update"
)
assert result is True
@@ -128,7 +128,7 @@ async def test_delete_subject_with_permission(db_session: AsyncSession):
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="DELETE:/subjects/{id}",
endpoint_key="subjects:delete",
allowed=True,
)
db_session.add(perm)
@@ -136,21 +136,21 @@ async def test_delete_subject_with_permission(db_session: AsyncSession):
# 验证权限
result = await role_has_api_permission(
db_session, study_id, "PM", "DELETE:/subjects/{id}"
db_session, study_id, "PM", "subjects:delete"
)
assert result is True
@pytest.mark.asyncio
async def test_create_risk_issue_with_permission(db_session: AsyncSession):
"""测试创建不良事件 - 有权限"""
async def test_create_subject_ae_with_permission(db_session: AsyncSession):
"""测试创建参与者AE - 有权限"""
study_id = uuid.uuid4()
# 创建权限
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="POST:/risk-issues",
endpoint_key="subject_aes:create",
allowed=True,
)
db_session.add(perm)
@@ -158,21 +158,21 @@ async def test_create_risk_issue_with_permission(db_session: AsyncSession):
# 验证权限
result = await role_has_api_permission(
db_session, study_id, "CRA", "POST:/risk-issues"
db_session, study_id, "CRA", "subject_aes:create"
)
assert result is True
@pytest.mark.asyncio
async def test_list_risk_issues_with_permission(db_session: AsyncSession):
"""测试查询不良事件列表 - 有权限"""
async def test_list_subject_aes_with_permission(db_session: AsyncSession):
"""测试查询参与者AE列表 - 有权限"""
study_id = uuid.uuid4()
# 创建权限
perm = ApiEndpointPermission(
study_id=study_id,
role="PV",
endpoint_key="GET:/risk-issues",
endpoint_key="subject_aes:list",
allowed=True,
)
db_session.add(perm)
@@ -180,21 +180,21 @@ async def test_list_risk_issues_with_permission(db_session: AsyncSession):
# 验证权限
result = await role_has_api_permission(
db_session, study_id, "PV", "GET:/risk-issues"
db_session, study_id, "PV", "subject_aes:list"
)
assert result is True
@pytest.mark.asyncio
async def test_get_risk_issue_detail_with_permission(db_session: AsyncSession):
"""测试查询不良事件详情 - 有权限"""
async def test_get_subject_ae_detail_with_permission(db_session: AsyncSession):
"""测试查询参与者AE详情 - 有权限"""
study_id = uuid.uuid4()
# 创建权限
perm = ApiEndpointPermission(
study_id=study_id,
role="MEDICAL_REVIEW",
endpoint_key="GET:/risk-issues/{id}",
role="QA",
endpoint_key="subject_aes:read",
allowed=True,
)
db_session.add(perm)
@@ -202,21 +202,21 @@ async def test_get_risk_issue_detail_with_permission(db_session: AsyncSession):
# 验证权限
result = await role_has_api_permission(
db_session, study_id, "MEDICAL_REVIEW", "GET:/risk-issues/{id}"
db_session, study_id, "QA", "subject_aes:read"
)
assert result is True
@pytest.mark.asyncio
async def test_create_fee_contract_with_permission(db_session: AsyncSession):
"""测试创建费用合同 - 有权限"""
"""测试创建合同费用 - 有权限"""
study_id = uuid.uuid4()
# 创建权限
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="POST:/fees/contracts",
endpoint_key="fees_contracts:create",
allowed=True,
)
db_session.add(perm)
@@ -224,21 +224,21 @@ async def test_create_fee_contract_with_permission(db_session: AsyncSession):
# 验证权限
result = await role_has_api_permission(
db_session, study_id, "PM", "POST:/fees/contracts"
db_session, study_id, "PM", "fees_contracts:create"
)
assert result is True
@pytest.mark.asyncio
async def test_list_fee_contracts_with_permission(db_session: AsyncSession):
"""测试查询费用合同列表 - 有权限"""
"""测试查询合同费用列表 - 有权限"""
study_id = uuid.uuid4()
# 创建权限
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="GET:/fees/contracts",
endpoint_key="fees_contracts:list",
allowed=True,
)
db_session.add(perm)
@@ -246,21 +246,21 @@ async def test_list_fee_contracts_with_permission(db_session: AsyncSession):
# 验证权限
result = await role_has_api_permission(
db_session, study_id, "CRA", "GET:/fees/contracts"
db_session, study_id, "CRA", "fees_contracts:list"
)
assert result is True
@pytest.mark.asyncio
async def test_get_fee_contract_detail_with_permission(db_session: AsyncSession):
"""测试查询费用合同详情 - 有权限"""
"""测试查询合同费用详情 - 有权限"""
study_id = uuid.uuid4()
# 创建权限
perm = ApiEndpointPermission(
study_id=study_id,
role="IMP",
endpoint_key="GET:/fees/contracts/{id}",
role="CTA",
endpoint_key="fees_contracts:read",
allowed=True,
)
db_session.add(perm)
@@ -268,21 +268,21 @@ async def test_get_fee_contract_detail_with_permission(db_session: AsyncSession)
# 验证权限
result = await role_has_api_permission(
db_session, study_id, "IMP", "GET:/fees/contracts/{id}"
db_session, study_id, "CTA", "fees_contracts:read"
)
assert result is True
@pytest.mark.asyncio
async def test_update_fee_contract_with_permission(db_session: AsyncSession):
"""测试更新费用合同 - 有权限"""
"""测试更新合同费用 - 有权限"""
study_id = uuid.uuid4()
# 创建权限
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="PATCH:/fees/contracts/{id}",
endpoint_key="fees_contracts:update",
allowed=True,
)
db_session.add(perm)
@@ -290,21 +290,21 @@ async def test_update_fee_contract_with_permission(db_session: AsyncSession):
# 验证权限
result = await role_has_api_permission(
db_session, study_id, "PM", "PATCH:/fees/contracts/{id}"
db_session, study_id, "PM", "fees_contracts:update"
)
assert result is True
@pytest.mark.asyncio
async def test_delete_fee_contract_with_permission(db_session: AsyncSession):
"""测试删除费用合同 - 有权限"""
"""测试删除合同费用 - 有权限"""
study_id = uuid.uuid4()
# 创建权限
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="DELETE:/fees/contracts/{id}",
endpoint_key="fees_contracts:delete",
allowed=True,
)
db_session.add(perm)
@@ -312,182 +312,7 @@ async def test_delete_fee_contract_with_permission(db_session: AsyncSession):
# 验证权限
result = await role_has_api_permission(
db_session, study_id, "PM", "DELETE:/fees/contracts/{id}"
db_session, study_id, "PM", "fees_contracts:delete"
)
assert result is True
@pytest.mark.asyncio
async def test_create_fee_payment_with_permission(db_session: AsyncSession):
"""测试创建费用分期 - 有权限"""
study_id = uuid.uuid4()
# 创建权限
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="POST:/fees/contracts/{id}/payments",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
# 验证权限
result = await role_has_api_permission(
db_session, study_id, "PM", "POST:/fees/contracts/{id}/payments"
)
assert result is True
@pytest.mark.asyncio
async def test_update_fee_payment_with_permission(db_session: AsyncSession):
"""测试更新费用分期 - 有权限"""
study_id = uuid.uuid4()
# 创建权限
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="PATCH:/fees/payments/{id}",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
# 验证权限
result = await role_has_api_permission(
db_session, study_id, "PM", "PATCH:/fees/payments/{id}"
)
assert result is True
@pytest.mark.asyncio
async def test_delete_fee_payment_with_permission(db_session: AsyncSession):
"""测试删除费用分期 - 有权限"""
study_id = uuid.uuid4()
# 创建权限
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="DELETE:/fees/payments/{id}",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
# 验证权限
result = await role_has_api_permission(
db_session, study_id, "PM", "DELETE:/fees/payments/{id}"
)
assert result is True
@pytest.mark.asyncio
async def test_create_finance_contract_with_permission(db_session: AsyncSession):
"""测试创建财务合同 - 有权限"""
study_id = uuid.uuid4()
# 创建权限
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="POST:/finance/contracts",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
# 验证权限
result = await role_has_api_permission(
db_session, study_id, "PM", "POST:/finance/contracts"
)
assert result is True
@pytest.mark.asyncio
async def test_list_finance_contracts_with_permission(db_session: AsyncSession):
"""测试查询财务合同列表 - 有权限"""
study_id = uuid.uuid4()
# 创建权限
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="GET:/finance/contracts",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
# 验证权限
result = await role_has_api_permission(
db_session, study_id, "CRA", "GET:/finance/contracts"
)
assert result is True
@pytest.mark.asyncio
async def test_get_finance_contract_detail_with_permission(db_session: AsyncSession):
"""测试查询财务合同详情 - 有权限"""
study_id = uuid.uuid4()
# 创建权限
perm = ApiEndpointPermission(
study_id=study_id,
role="IMP",
endpoint_key="GET:/finance/contracts/{id}",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
# 验证权限
result = await role_has_api_permission(
db_session, study_id, "IMP", "GET:/finance/contracts/{id}"
)
assert result is True
@pytest.mark.asyncio
async def test_update_finance_contract_with_permission(db_session: AsyncSession):
"""测试更新财务合同 - 有权限"""
study_id = uuid.uuid4()
# 创建权限
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="PATCH:/finance/contracts/{id}",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
# 验证权限
result = await role_has_api_permission(
db_session, study_id, "PM", "PATCH:/finance/contracts/{id}"
)
assert result is True
@pytest.mark.asyncio
async def test_delete_finance_contract_with_permission(db_session: AsyncSession):
"""测试删除财务合同 - 有权限"""
study_id = uuid.uuid4()
# 创建权限
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="DELETE:/finance/contracts/{id}",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
# 验证权限
result = await role_has_api_permission(
db_session, study_id, "PM", "DELETE:/finance/contracts/{id}"
)
assert result is True
+26 -26
View File
@@ -20,14 +20,14 @@ async def test_add_member_with_permission(db_session: AsyncSession):
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="POST:/studies/{study_id}/members",
endpoint_key="project_members:create",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
# 验证权限检查
allowed = await role_has_api_permission(db_session, study_id, "PM", "POST:/studies/{study_id}/members")
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_members:create")
assert allowed is True
@@ -40,14 +40,14 @@ async def test_add_member_without_permission(db_session: AsyncSession):
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="POST:/studies/{study_id}/members",
endpoint_key="project_members:create",
allowed=False,
)
db_session.add(perm)
await db_session.commit()
# 验证权限检查
allowed = await role_has_api_permission(db_session, study_id, "CRA", "POST:/studies/{study_id}/members")
allowed = await role_has_api_permission(db_session, study_id, "CRA", "project_members:create")
assert allowed is False
@@ -60,14 +60,14 @@ async def test_list_members_with_permission(db_session: AsyncSession):
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="GET:/studies/{study_id}/members",
endpoint_key="project_members:list",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
# 验证权限检查
allowed = await role_has_api_permission(db_session, study_id, "PM", "GET:/studies/{study_id}/members")
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_members:list")
assert allowed is True
@@ -80,14 +80,14 @@ async def test_update_member_with_permission(db_session: AsyncSession):
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="PATCH:/studies/{study_id}/members/{member_id}",
endpoint_key="project_members:update",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
# 验证权限检查
allowed = await role_has_api_permission(db_session, study_id, "PM", "PATCH:/studies/{study_id}/members/{member_id}")
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_members:update")
assert allowed is True
@@ -100,14 +100,14 @@ async def test_delete_member_with_permission(db_session: AsyncSession):
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="DELETE:/studies/{study_id}/members/{member_id}",
endpoint_key="project_members:delete",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
# 验证权限检查
allowed = await role_has_api_permission(db_session, study_id, "PM", "DELETE:/studies/{study_id}/members/{member_id}")
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_members:delete")
assert allowed is True
@@ -120,14 +120,14 @@ async def test_list_member_candidates_with_permission(db_session: AsyncSession):
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="GET:/studies/{study_id}/members/candidates",
endpoint_key="project_members:candidates",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
# 验证权限检查
allowed = await role_has_api_permission(db_session, study_id, "PM", "GET:/studies/{study_id}/members/candidates")
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_members:candidates")
assert allowed is True
@@ -140,14 +140,14 @@ async def test_create_site_with_permission(db_session: AsyncSession):
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="POST:/studies/{study_id}/sites",
endpoint_key="sites:create",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
# 验证权限检查
allowed = await role_has_api_permission(db_session, study_id, "PM", "POST:/studies/{study_id}/sites")
allowed = await role_has_api_permission(db_session, study_id, "PM", "sites:create")
assert allowed is True
@@ -160,14 +160,14 @@ async def test_list_sites_with_permission(db_session: AsyncSession):
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="GET:/studies/{study_id}/sites",
endpoint_key="sites:list",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
# 验证权限检查
allowed = await role_has_api_permission(db_session, study_id, "PM", "GET:/studies/{study_id}/sites")
allowed = await role_has_api_permission(db_session, study_id, "PM", "sites:list")
assert allowed is True
@@ -180,14 +180,14 @@ async def test_list_sites_cra_with_permission(db_session: AsyncSession):
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="GET:/studies/{study_id}/sites",
endpoint_key="sites:list",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
# 验证权限检查
allowed = await role_has_api_permission(db_session, study_id, "CRA", "GET:/studies/{study_id}/sites")
allowed = await role_has_api_permission(db_session, study_id, "CRA", "sites:list")
assert allowed is True
@@ -200,14 +200,14 @@ async def test_update_site_with_permission(db_session: AsyncSession):
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="PATCH:/studies/{study_id}/sites/{site_id}",
endpoint_key="sites:update",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
# 验证权限检查
allowed = await role_has_api_permission(db_session, study_id, "PM", "PATCH:/studies/{study_id}/sites/{site_id}")
allowed = await role_has_api_permission(db_session, study_id, "PM", "sites:update")
assert allowed is True
@@ -220,14 +220,14 @@ async def test_delete_site_with_permission(db_session: AsyncSession):
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="DELETE:/studies/{study_id}/sites/{site_id}",
endpoint_key="sites:delete",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
# 验证权限检查
allowed = await role_has_api_permission(db_session, study_id, "PM", "DELETE:/studies/{study_id}/sites/{site_id}")
allowed = await role_has_api_permission(db_session, study_id, "PM", "sites:delete")
assert allowed is True
@@ -240,14 +240,14 @@ async def test_members_permission_denied_for_cra(db_session: AsyncSession):
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="POST:/studies/{study_id}/members",
endpoint_key="project_members:create",
allowed=False,
)
db_session.add(perm)
await db_session.commit()
# 验证权限检查
allowed = await role_has_api_permission(db_session, study_id, "CRA", "POST:/studies/{study_id}/members")
allowed = await role_has_api_permission(db_session, study_id, "CRA", "project_members:create")
assert allowed is False
@@ -260,13 +260,13 @@ async def test_sites_permission_denied_for_cra_write(db_session: AsyncSession):
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="POST:/studies/{study_id}/sites",
endpoint_key="sites:create",
allowed=False,
)
db_session.add(perm)
await db_session.commit()
# 验证权限检查
allowed = await role_has_api_permission(db_session, study_id, "CRA", "POST:/studies/{study_id}/sites")
allowed = await role_has_api_permission(db_session, study_id, "CRA", "sites:create")
assert allowed is False
+119 -136
View File
@@ -12,91 +12,91 @@ from app.models.api_endpoint_permission import ApiEndpointPermission
# ============================================================================
# 启动管理 (startup) - 19个端点
# 启动管理 (startup)
# ============================================================================
@pytest.mark.asyncio
async def test_startup_ethics_create_with_permission(db_session: AsyncSession):
"""验证有权限的PM可以创建伦理审批"""
"""验证有权限的PM可以创建伦理记录"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="POST:/studies/{study_id}/startup/ethics",
endpoint_key="startup_ethics:create",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "PM", "POST:/studies/{study_id}/startup/ethics")
allowed = await role_has_api_permission(db_session, study_id, "PM", "startup_ethics:create")
assert allowed is True
@pytest.mark.asyncio
async def test_startup_ethics_list_with_permission(db_session: AsyncSession):
"""验证有权限的PM可以查询伦理审批列表"""
"""验证有权限的PM可以查询伦理记录列表"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="GET:/studies/{study_id}/startup/ethics",
endpoint_key="startup_ethics:list",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "PM", "GET:/studies/{study_id}/startup/ethics")
allowed = await role_has_api_permission(db_session, study_id, "PM", "startup_ethics:list")
assert allowed is True
@pytest.mark.asyncio
async def test_startup_feasibility_create_with_permission(db_session: AsyncSession):
"""验证有权限的PM可以创建可行性评估"""
async def test_startup_initiation_create_with_permission(db_session: AsyncSession):
"""验证有权限的PM可以创建立项记录"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="POST:/studies/{study_id}/startup/feasibility",
endpoint_key="startup_initiation:create",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "PM", "POST:/studies/{study_id}/startup/feasibility")
allowed = await role_has_api_permission(db_session, study_id, "PM", "startup_initiation:create")
assert allowed is True
@pytest.mark.asyncio
async def test_startup_budget_create_with_permission(db_session: AsyncSession):
"""验证有权限的PM可以创建预算评估"""
async def test_startup_auth_create_with_permission(db_session: AsyncSession):
"""验证有权限的PM可以创建启动会或培训授权"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="POST:/studies/{study_id}/startup/budget",
endpoint_key="startup_auth:create",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "PM", "POST:/studies/{study_id}/startup/budget")
allowed = await role_has_api_permission(db_session, study_id, "PM", "startup_auth:create")
assert allowed is True
@pytest.mark.asyncio
async def test_startup_timeline_create_with_permission(db_session: AsyncSession):
"""验证有权限的PM可以创建时间表"""
async def test_startup_auth_read_with_permission(db_session: AsyncSession):
"""验证有权限的CRA可以查询启动会或培训授权"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="POST:/studies/{study_id}/startup/timeline",
role="CRA",
endpoint_key="startup_auth:read",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "PM", "POST:/studies/{study_id}/startup/timeline")
allowed = await role_has_api_permission(db_session, study_id, "CRA", "startup_auth:read")
assert allowed is True
@@ -111,13 +111,13 @@ async def test_project_permissions_get_with_permission(db_session: AsyncSession)
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="GET:/studies/{study_id}/permissions",
endpoint_key="permissions:read",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "PM", "GET:/studies/{study_id}/permissions")
allowed = await role_has_api_permission(db_session, study_id, "PM", "permissions:read")
assert allowed is True
@@ -128,13 +128,13 @@ async def test_project_permissions_update_with_permission(db_session: AsyncSessi
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="PUT:/studies/{study_id}/permissions",
endpoint_key="permissions:update",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "PM", "PUT:/studies/{study_id}/permissions")
allowed = await role_has_api_permission(db_session, study_id, "PM", "permissions:update")
assert allowed is True
@@ -149,51 +149,34 @@ async def test_overview_get_with_permission(db_session: AsyncSession):
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="GET:/studies/{study_id}/overview",
endpoint_key="project_overview:read",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "PM", "GET:/studies/{study_id}/overview")
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_overview:read")
assert allowed is True
# ============================================================================
# 监查问题 (monitoring_visit_issues) - 7个端点
# 监查访视问题汇总 (monitoring_visit_issues)
# ============================================================================
@pytest.mark.asyncio
async def test_monitoring_issues_create_with_permission(db_session: AsyncSession):
"""验证有权限的CRA可以创建监查问题"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="POST:/studies/{study_id}/monitoring-visits/{visit_id}/issues",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "CRA", "POST:/studies/{study_id}/monitoring-visits/{visit_id}/issues")
assert allowed is True
@pytest.mark.asyncio
async def test_monitoring_issues_list_with_permission(db_session: AsyncSession):
"""验证有权限的CRA可以查询监查问题列表"""
"""验证有权限的CRA可以查询监查访视问题列表"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="GET:/studies/{study_id}/monitoring-visits/{visit_id}/issues",
endpoint_key="monitoring_issues:list",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "CRA", "GET:/studies/{study_id}/monitoring-visits/{visit_id}/issues")
allowed = await role_has_api_permission(db_session, study_id, "CRA", "monitoring_issues:list")
assert allowed is True
@@ -203,111 +186,111 @@ async def test_monitoring_issues_list_with_permission(db_session: AsyncSession):
@pytest.mark.asyncio
async def test_drug_shipments_create_with_permission(db_session: AsyncSession):
"""验证有权限的IMP可以创建药物发货"""
"""验证有权限的CTA可以创建药物发货"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="IMP",
endpoint_key="POST:/studies/{study_id}/drug-shipments",
role="CTA",
endpoint_key="drug_shipments:create",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "IMP", "POST:/studies/{study_id}/drug-shipments")
allowed = await role_has_api_permission(db_session, study_id, "CTA", "drug_shipments:create")
assert allowed is True
@pytest.mark.asyncio
async def test_drug_shipments_list_with_permission(db_session: AsyncSession):
"""验证有权限的IMP可以查询药物发货列表"""
"""验证有权限的CTA可以查询药物发货列表"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="IMP",
endpoint_key="GET:/studies/{study_id}/drug-shipments",
role="CTA",
endpoint_key="drug_shipments:list",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "IMP", "GET:/studies/{study_id}/drug-shipments")
allowed = await role_has_api_permission(db_session, study_id, "CTA", "drug_shipments:list")
assert allowed is True
# ============================================================================
# 物资管理 (material_equipments) - 5个端点
# 设备管理 (material_equipments) - 5个端点
# ============================================================================
@pytest.mark.asyncio
async def test_materials_create_with_permission(db_session: AsyncSession):
"""验证有权限的IMP可以创建物资"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="IMP",
endpoint_key="POST:/studies/{study_id}/materials",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "IMP", "POST:/studies/{study_id}/materials")
assert allowed is True
@pytest.mark.asyncio
async def test_materials_list_with_permission(db_session: AsyncSession):
"""验证有权限的IMP可以查询物资列表"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="IMP",
endpoint_key="GET:/studies/{study_id}/materials",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "IMP", "GET:/studies/{study_id}/materials")
assert allowed is True
# ============================================================================
# 参与者PDS (subject_pds) - 4个端点
# ============================================================================
@pytest.mark.asyncio
async def test_subject_pds_create_with_permission(db_session: AsyncSession):
"""验证有权限的CRA可以创建参与者PDS"""
async def test_material_equipments_create_with_permission(db_session: AsyncSession):
"""验证有权限的CRA可以创建设备"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="POST:/studies/{study_id}/subjects/{subject_id}/pds",
endpoint_key="material_equipments:create",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "CRA", "POST:/studies/{study_id}/subjects/{subject_id}/pds")
allowed = await role_has_api_permission(db_session, study_id, "CRA", "material_equipments:create")
assert allowed is True
@pytest.mark.asyncio
async def test_material_equipments_list_with_permission(db_session: AsyncSession):
"""验证有权限的CTA可以查询设备列表"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="CTA",
endpoint_key="material_equipments:list",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "CTA", "material_equipments:list")
assert allowed is True
# ============================================================================
# 参与者PD (subject_pds) - 4个端点
# ============================================================================
@pytest.mark.asyncio
async def test_subject_pds_create_with_permission(db_session: AsyncSession):
"""验证有权限的CRA可以创建参与者PD"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="subject_pds:create",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "CRA", "subject_pds:create")
assert allowed is True
@pytest.mark.asyncio
async def test_subject_pds_list_with_permission(db_session: AsyncSession):
"""验证有权限的CRA可以查询参与者PDS列表"""
"""验证有权限的CRA可以查询参与者PD列表"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="GET:/studies/{study_id}/subjects/{subject_id}/pds",
endpoint_key="subject_pds:list",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "CRA", "GET:/studies/{study_id}/subjects/{subject_id}/pds")
allowed = await role_has_api_permission(db_session, study_id, "CRA", "subject_pds:list")
assert allowed is True
@@ -322,13 +305,13 @@ async def test_audit_logs_list_with_permission(db_session: AsyncSession):
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="GET:/studies/{study_id}/audit-logs",
endpoint_key="audit_logs:list",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "PM", "GET:/studies/{study_id}/audit-logs")
allowed = await role_has_api_permission(db_session, study_id, "PM", "audit_logs:list")
assert allowed is True
@@ -339,13 +322,13 @@ async def test_audit_logs_export_with_permission(db_session: AsyncSession):
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="POST:/studies/{study_id}/audit-logs/export",
endpoint_key="audit_logs:export",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "PM", "POST:/studies/{study_id}/audit-logs/export")
allowed = await role_has_api_permission(db_session, study_id, "PM", "audit_logs:export")
assert allowed is True
@@ -360,13 +343,13 @@ async def test_visits_create_with_permission(db_session: AsyncSession):
perm = ApiEndpointPermission(
study_id=study_id,
role="PV",
endpoint_key="POST:/studies/{study_id}/visits",
endpoint_key="visits:create",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "PV", "POST:/studies/{study_id}/visits")
allowed = await role_has_api_permission(db_session, study_id, "PV", "visits:create")
assert allowed is True
@@ -377,89 +360,89 @@ async def test_visits_list_with_permission(db_session: AsyncSession):
perm = ApiEndpointPermission(
study_id=study_id,
role="PV",
endpoint_key="GET:/studies/{study_id}/visits",
endpoint_key="visits:list",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "PV", "GET:/studies/{study_id}/visits")
allowed = await role_has_api_permission(db_session, study_id, "PV", "visits:list")
assert allowed is True
# ============================================================================
# 知识库笔记 (knowledge_notes) - 5个端点
# 注意事项 (precautions) - 5个端点
# ============================================================================
@pytest.mark.asyncio
async def test_knowledge_notes_create_with_permission(db_session: AsyncSession):
"""验证有权限的MEDICAL_REVIEW可以创建知识库笔记"""
async def test_precautions_create_with_permission(db_session: AsyncSession):
"""验证有权限的QA可以创建注意事项"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="MEDICAL_REVIEW",
endpoint_key="POST:/studies/{study_id}/knowledge-notes",
role="QA",
endpoint_key="precautions:create",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "MEDICAL_REVIEW", "POST:/studies/{study_id}/knowledge-notes")
allowed = await role_has_api_permission(db_session, study_id, "QA", "precautions:create")
assert allowed is True
@pytest.mark.asyncio
async def test_knowledge_notes_list_with_permission(db_session: AsyncSession):
"""验证有权限的MEDICAL_REVIEW可以查询知识库笔记列表"""
async def test_precautions_list_with_permission(db_session: AsyncSession):
"""验证有权限的QA可以查询注意事项列表"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="MEDICAL_REVIEW",
endpoint_key="GET:/studies/{study_id}/knowledge-notes",
role="QA",
endpoint_key="precautions:list",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "MEDICAL_REVIEW", "GET:/studies/{study_id}/knowledge-notes")
allowed = await role_has_api_permission(db_session, study_id, "QA", "precautions:list")
assert allowed is True
# ============================================================================
# 参与者历史 (subject_histories) - 5个端点
# 病史记录 (subject_histories)
# ============================================================================
@pytest.mark.asyncio
async def test_subject_histories_list_with_permission(db_session: AsyncSession):
"""验证有权限的CRA可以查询参与者历史列表"""
"""验证有权限的CRA可以查询病史记录列表"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="GET:/studies/{study_id}/subjects/{subject_id}/history",
endpoint_key="subject_histories:list",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "CRA", "GET:/studies/{study_id}/subjects/{subject_id}/history")
allowed = await role_has_api_permission(db_session, study_id, "CRA", "subject_histories:list")
assert allowed is True
@pytest.mark.asyncio
async def test_subject_histories_export_with_permission(db_session: AsyncSession):
"""验证有权限的CRA可以导出参与者历史"""
async def test_subject_histories_read_with_permission(db_session: AsyncSession):
"""验证有权限的CRA可以查询病史记录详情"""
study_id = uuid.uuid4()
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="GET:/studies/{study_id}/subjects/{subject_id}/history/export",
endpoint_key="subject_histories:read",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "CRA", "GET:/studies/{study_id}/subjects/{subject_id}/history/export")
allowed = await role_has_api_permission(db_session, study_id, "CRA", "subject_histories:read")
assert allowed is True
@@ -474,13 +457,13 @@ async def test_milestones_list_with_permission(db_session: AsyncSession):
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="GET:/studies/{study_id}/milestones",
endpoint_key="project_milestones:read",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "PM", "GET:/studies/{study_id}/milestones")
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_milestones:read")
assert allowed is True
@@ -491,13 +474,13 @@ async def test_milestones_update_with_permission(db_session: AsyncSession):
perm = ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="PATCH:/studies/{study_id}/milestones/{id}",
endpoint_key="project_milestones:update",
allowed=True,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "PM", "PATCH:/studies/{study_id}/milestones/{id}")
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_milestones:update")
assert allowed is True
@@ -512,13 +495,13 @@ async def test_startup_permission_denied_for_cra(db_session: AsyncSession):
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="POST:/studies/{study_id}/startup/ethics",
endpoint_key="startup_ethics:create",
allowed=False,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "CRA", "POST:/studies/{study_id}/startup/ethics")
allowed = await role_has_api_permission(db_session, study_id, "CRA", "startup_ethics:create")
assert allowed is False
@@ -529,11 +512,11 @@ async def test_project_permissions_denied_for_cra(db_session: AsyncSession):
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="PUT:/studies/{study_id}/permissions",
endpoint_key="permissions:update",
allowed=False,
)
db_session.add(perm)
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "CRA", "PUT:/studies/{study_id}/permissions")
allowed = await role_has_api_permission(db_session, study_id, "CRA", "permissions:update")
assert allowed is False
+62 -1
View File
@@ -33,7 +33,7 @@ def test_remove_qa_role_migration_casts_json_permissions_for_key_lookup():
assert "permissions::jsonb ? 'QA'" in source
def test_role_template_copy_migration_updates_display_copy_without_qa_role_key():
def test_role_template_copy_migration_updates_display_copy_for_current_role_keys():
source = Path("alembic/versions/20260522_01_update_role_template_copy.py").read_text(encoding="utf-8")
assert "项目负责人,统筹项目全局,协调进度、资源与关键决策。" in source
@@ -44,3 +44,64 @@ def test_role_template_copy_migration_updates_display_copy_without_qa_role_key()
assert '"IMP": ("CTA"' in source
assert '"MEDICAL_REVIEW": ("QA"' in source
assert "category = 'QA'" not in source
def test_permission_template_migrations_do_not_seed_stale_startup_permissions():
stale_keys = (
"budget:create",
"budget:list",
"budget:read",
"budget:update",
"budget:delete",
"timeline:create",
"timeline:list",
"timeline:read",
"timeline:update",
"timeline:delete",
)
for path in Path("alembic/versions").glob("*.py"):
if path.name == "20260527_04_remove_stale_startup_permissions.py":
continue
source = path.read_text(encoding="utf-8")
for key in stale_keys:
assert key not in source, f"{key} should not be seeded by {path}"
def test_legacy_startup_ethics_permission_keys_are_removed_by_followup_migration():
source = Path("alembic/versions/20260527_05_remove_legacy_startup_ethics_permission_keys.py").read_text(encoding="utf-8")
assert '"feasibility:create"' in source
assert '"feasibility:list"' in source
assert '"feasibility:read"' in source
assert '"feasibility:update"' in source
assert '"feasibility:delete"' in source
assert '"ethics:create"' in source
assert '"ethics:list"' in source
assert '"ethics:read"' in source
assert '"ethics:update"' in source
assert '"ethics:delete"' in source
assert "startup_initiation:" not in source
assert "startup_ethics:" not in source
assert "DELETE FROM api_endpoint_permissions" in source
assert "api_endpoint_permissions" in source
assert "permission_templates" in source
assert "permission_template_versions" in source
def test_precautions_migration_renames_table_and_attachment_entity_type():
source = Path("alembic/versions/20260527_08_rename_knowledge_notes_to_precautions.py").read_text(encoding="utf-8")
assert 'rename_table("knowledge_notes", "precautions")' in source
assert "entity_type = 'precaution'" in source
assert "entity_type = 'knowledge_note'" in source
assert "api_endpoint_permissions" in source
assert "permission_templates" in source
def test_etmf_migration_reuses_existing_document_scope_type():
source = Path("alembic/versions/20260527_09_add_etmf_nodes.py").read_text(encoding="utf-8")
assert "postgresql.ENUM(" in source
assert 'name="document_scope_type"' in source
assert "create_type=False" in source
assert "scope_type = sa.Enum" not in source
+2 -2
View File
@@ -157,7 +157,7 @@ async def test_list_templates_filter_by_type(db_session, user_id):
@pytest.mark.asyncio
async def test_list_templates_excludes_qa_system_preset(db_session):
async def test_list_templates_includes_qa_system_preset(db_session):
qa_template = PermissionTemplate(
name="质量保证",
template_type=TemplateType.ROLE,
@@ -178,7 +178,7 @@ async def test_list_templates_excludes_qa_system_preset(db_session):
role_templates = await PermissionTemplateService.list_templates(db_session, template_type=TemplateType.ROLE)
assert "PM" in {template.category for template in role_templates}
assert "QA" not in {template.category for template in role_templates if template.is_system}
assert "QA" in {template.category for template in role_templates if template.is_system}
# ---------------------------------------------------------------------------
+4 -5
View File
@@ -29,16 +29,15 @@ const entityTypeLabelMap: Record<string, string> = {
subject: "参与者",
visit: "访视",
ae: "不良事件",
finance_contract: "费用合同",
contract_fee: "合同费用条目",
contract_fee_payment: "合同费用回款",
drug_shipment: "药品流向",
startup_feasibility: "立项可行性",
startup_ethics: "立项伦理",
startup_feasibility: "立项记录",
startup_ethics: "伦理记录",
startup_kickoff: "启动会",
training_authorization: "培训授权",
knowledge_note: "注意事项",
subject_history: "参与者历史",
precaution: "注意事项",
subject_history: "病史记录",
faq_category: "医学咨询分类",
faq_item: "医学咨询问题",
faq_reply: "医学咨询回复",
@@ -12,8 +12,8 @@ vi.mock("@/api/projectPermissions", () => ({
data: [
{ category: "PM", name: "PM", description: "项目负责人,统筹项目全局,协调进度、资源与关键决策。" },
{ category: "CRA", name: "CRA", description: "负责各中心临床监查执行,跟进现场质量、数据和问题闭环。" },
{ category: "IMP", name: "CTA", description: "负责合同、药品及相关项目事务管理,保障执行支持与物资协同。" },
{ category: "MEDICAL_REVIEW", name: "QA", description: "负责医学审核与稽查,关注质量风险、合规性和医学一致性。" },
{ category: "CTA", name: "CTA", description: "负责合同、药品及相关项目事务管理,保障执行支持与物资协同。" },
{ category: "QA", name: "QA", description: "负责医学审核与稽查,关注质量风险、合规性和医学一致性。" },
{ category: "PV", name: "PV", description: "负责药物警戒相关工作,跟踪安全性事件并支持风险评估。" },
],
}),
@@ -39,18 +39,18 @@ describe("ApiEndpointPermissions.vue", () => {
const mockMatrix: ApiEndpointPermissionsResponse = {
PM: {
"POST:/subjects": true,
"GET:/subjects": true,
"GET:/subjects/{id}": true,
"PATCH:/subjects/{id}": true,
"DELETE:/subjects/{id}": true,
"subjects:create": true,
"subjects:list": true,
"subjects:read": true,
"subjects:update": true,
"subjects:delete": true,
},
CRA: {
"POST:/subjects": true,
"GET:/subjects": true,
"GET:/subjects/{id}": true,
"PATCH:/subjects/{id}": true,
"DELETE:/subjects/{id}": false,
"subjects:create": true,
"subjects:list": true,
"subjects:read": true,
"subjects:update": true,
"subjects:delete": false,
},
};
@@ -64,7 +64,6 @@ describe("ApiEndpointPermissions.vue", () => {
stubs: {
ElTable: false,
ElTableColumn: false,
ElCheckbox: false,
},
},
});
@@ -72,24 +71,283 @@ describe("ApiEndpointPermissions.vue", () => {
expect(wrapper.find(".api-permissions").exists()).toBe(true);
});
it("emits update event when permission changes", async () => {
it("keeps matrix read-only and renders authorization states", async () => {
const source = readFileSync(resolve(__dirname, "./ApiEndpointPermissions.vue"), "utf8");
expect(source).toContain('emit("update", updatedMatrix)');
expect(source).toContain("updatedMatrix[role][operation_key] = allowed");
expect(source).not.toContain("只读查看各角色权限覆盖");
expect(source).toContain("permission-state--allowed");
expect(source).toContain("permission-state--disabled");
expect(source).toContain("CircleCloseFilled");
expect(source).not.toContain('? "✓" : "—"');
expect(source).not.toContain("defineEmits");
expect(source).not.toContain("el-checkbox");
});
it("renders role headers from template display names while keeping matrix role keys", () => {
const source = readFileSync(resolve(__dirname, "./ApiEndpointPermissions.vue"), "utf8");
expect(source).toContain("useRoleTemplateMeta");
expect(source).toContain("compareRolesByTemplateOrder");
expect(source).toContain(':label="roleLabel(role)"');
expect(source).toContain(':key="role"');
expect(source).toContain("isOperationAllowed(row.operation_key, role)");
expect(source).toContain("onPermissionChange(row.operation_key, role, val)");
expect(source).toContain("permission-state");
expect(source).toContain('label="权限类型"');
expect(source).not.toContain(':label="role"');
});
it("sorts matrix role columns with the role list order", async () => {
const wrapper = mount(ApiEndpointPermissions, {
props: {
project: { id: "test-id", name: "Test Project" },
matrix: {
CRA: {},
CTA: {},
QA: {},
PM: {},
PV: {},
},
},
global: {
stubs: {
ElTable: false,
ElTableColumn: false,
},
},
});
await vi.dynamicImportSettled();
expect((wrapper.vm as any).roles).toEqual(["PM", "CRA", "CTA", "QA", "PV"]);
});
it("sorts project permission modules by sidebar menu order", async () => {
const wrapper = mount(ApiEndpointPermissions, {
props: {
project: { id: "test-id", name: "Test Project" },
matrix: mockMatrix,
},
global: {
stubs: {
ElTable: false,
ElTableColumn: false,
},
},
});
(wrapper.vm as any).operations = [
{ operation_key: "subjects:read", module: "subjects", action: "read", description: "查询参与者详情", default_roles: [] },
{ operation_key: "sites:read", module: "sites", action: "read", description: "查询中心详情", default_roles: [] },
{ operation_key: "audit_logs:read", module: "audit_export", action: "read", description: "查询审计日志", default_roles: [] },
{ operation_key: "project_overview:read", module: "project_overview", action: "read", description: "查询项目总览", default_roles: [] },
{ operation_key: "startup_auth:read", module: "startup_auth", action: "read", description: "查询启动授权", default_roles: [] },
{ operation_key: "fees_contracts:update", module: "fees", action: "write", description: "更新合同费用", default_roles: [] },
{ operation_key: "fees_contracts:read", module: "fees", action: "read", description: "查询合同费用", default_roles: [] },
{ operation_key: "monitoring_issues:read", module: "risk_issues", action: "read", description: "查询监查访视问题详情", default_roles: [] },
];
expect((wrapper.vm as any).filteredOperations.map((operation: any) => operation.module)).toEqual([
"sites",
"audit_export",
"project_overview",
"fees",
"fees",
"startup_auth",
"subjects",
"risk_issues",
]);
expect((wrapper.vm as any).filteredOperations.map((operation: any) => operation.operation_key)).toEqual([
"sites:read",
"audit_logs:read",
"project_overview:read",
"fees_contracts:update",
"fees_contracts:read",
"startup_auth:read",
"subjects:read",
"monitoring_issues:read",
]);
expect((wrapper.vm as any).uniqueModules).toEqual([
"sites",
"audit_export",
"project_overview",
"fees",
"startup_auth",
"subjects",
"risk_issues",
]);
expect((wrapper.vm as any).getPermissionCategoryLabel({ module: "sites" })).toBe("通用权限");
expect((wrapper.vm as any).getPermissionCategoryLabel({ module: "fees" })).toBe("业务权限");
});
it("groups participant permissions by section labels", async () => {
const wrapper = mount(ApiEndpointPermissions, {
props: {
project: { id: "test-id", name: "Test Project" },
matrix: mockMatrix,
},
global: {
stubs: {
ElTable: false,
ElTableColumn: false,
},
},
});
expect((wrapper.vm as any).getOperationSection({
operation_key: "subject_aes:create",
module: "subjects",
action: "write",
description: "创建参与者AE",
default_roles: [],
})).toBe("AE");
expect((wrapper.vm as any).getOperationSection({
operation_key: "subject_pds:create",
module: "subjects",
action: "write",
description: "创建参与者PD",
default_roles: [],
})).toBe("PD");
expect((wrapper.vm as any).getOperationSection({
operation_key: "visits:list",
module: "subjects",
action: "read",
description: "查询访视列表",
default_roles: [],
})).toBe("访视");
expect((wrapper.vm as any).getOperationSection({
operation_key: "monitoring_issues:create",
module: "risk_issues",
action: "write",
description: "创建监查访视问题",
default_roles: [],
})).toBe("监查访视问题");
});
it("projects AE/SAE and PD permissions into risk issues while reusing participant operation keys", async () => {
const wrapper = mount(ApiEndpointPermissions, {
props: {
project: { id: "test-id", name: "Test Project" },
matrix: mockMatrix,
},
global: {
stubs: {
ElTable: false,
ElTableColumn: false,
},
},
});
(wrapper.vm as any).operations = [
{ operation_key: "subject_aes:list", 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: [] },
];
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_pds:list",
"monitoring_issues:list",
]);
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_pds:list")).toHaveLength(2);
});
it("keeps contract fee permissions as one module section", () => {
const wrapper = mount(ApiEndpointPermissions, {
props: {
project: { id: "test-id", name: "Test Project" },
matrix: mockMatrix,
},
global: {
stubs: {
ElTable: false,
ElTableColumn: false,
},
},
});
expect((wrapper.vm as any).getOperationSection({
operation_key: "fees_contracts:create",
module: "fees",
action: "write",
description: "创建合同费用",
default_roles: [],
})).toBe("合同费用");
const source = readFileSync(resolve(__dirname, "./ApiEndpointPermissions.vue"), "utf8");
expect(source).not.toContain("finance_contracts");
expect(source).not.toContain("fees_payments");
expect(source).not.toContain("fees_attachments");
expect(source).not.toContain('contract_fees: "合同费用"');
expect(source).not.toContain("FEE_SECTION_LABELS");
});
it("groups materials permissions into drug flow and equipment sections", () => {
const wrapper = mount(ApiEndpointPermissions, {
props: {
project: { id: "test-id", name: "Test Project" },
matrix: mockMatrix,
},
global: {
stubs: {
ElTable: false,
ElTableColumn: false,
},
},
});
expect((wrapper.vm as any).getOperationSection({
operation_key: "drug_shipments:read",
module: "materials",
action: "read",
description: "查询药品流向详情",
default_roles: [],
})).toBe("药品流向管理");
expect((wrapper.vm as any).getOperationSection({
operation_key: "material_equipments:read",
module: "materials",
action: "read",
description: "查询设备详情",
default_roles: [],
})).toBe("设备管理");
});
it("groups startup ethics permissions with visible business names", () => {
const wrapper = mount(ApiEndpointPermissions, {
props: {
project: { id: "test-id", name: "Test Project" },
matrix: mockMatrix,
},
global: {
stubs: {
ElTable: false,
ElTableColumn: false,
},
},
});
expect((wrapper.vm as any).getOperationSection({
operation_key: "startup_initiation:read",
module: "startup_ethics",
action: "read",
description: "查询立项记录详情",
default_roles: [],
})).toBe("立项记录");
expect((wrapper.vm as any).getOperationSection({
operation_key: "startup_ethics:read",
module: "startup_ethics",
action: "read",
description: "查询伦理记录详情",
default_roles: [],
})).toBe("伦理记录");
});
it("filters endpoints by search text", async () => {
const wrapper = mount(ApiEndpointPermissions, {
props: {
@@ -100,7 +358,6 @@ describe("ApiEndpointPermissions.vue", () => {
stubs: {
ElTable: false,
ElTableColumn: false,
ElCheckbox: false,
ElInput: false,
ElSelect: false,
},
@@ -2,8 +2,7 @@
<div class="api-permissions">
<div class="api-permissions-toolbar">
<div class="toolbar-left">
<span class="toolbar-title">接口权限矩阵</span>
<span class="toolbar-desc">为各角色配置 API 接口的访问权限</span>
<span class="toolbar-title">项目级权限矩阵</span>
</div>
<div class="toolbar-filters">
<el-input
@@ -34,14 +33,40 @@
</div>
<div class="api-permissions-table-wrapper">
<el-table :data="filteredOperations" border stripe :span-method="spanMethod" class="perm-matrix-table">
<el-table-column prop="module" label="模块" width="140">
<el-table
:data="filteredOperations"
border
stripe
:span-method="spanMethod"
class="perm-matrix-table"
>
<el-table-column prop="permission_category" label="权限类型" width="120">
<template #default="{ row }">
<span class="module-label">{{ moduleLabel(row.module) }}</span>
<div class="hierarchy-cell hierarchy-cell--category" :data-category="getPermissionCategory(row)">
<span class="hierarchy-bar" />
<span class="category-label">{{ getPermissionCategoryLabel(row) }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="operation_key" label="权限操作" width="200">
<el-table-column prop="module" label="模块" width="160">
<template #default="{ row }">
<div class="hierarchy-cell hierarchy-cell--module">
<span class="module-dot" />
<span class="module-label">{{ moduleLabel(row.module) }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="section" label="模块细分" width="160">
<template #default="{ row }">
<div class="hierarchy-cell hierarchy-cell--section">
<span class="section-chip">{{ getOperationSection(row) }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="operation_key" label="权限操作" min-width="240">
<template #default="{ row }">
<div class="operation-cell">
<el-tag :type="getActionType(row)" size="small" class="action-tag">
@@ -60,11 +85,17 @@
align="center"
>
<template #default="{ row }">
<el-checkbox
:model-value="isOperationAllowed(row.operation_key, role)"
:disabled="!isRoleEditable(role)"
@change="(val: boolean) => onPermissionChange(row.operation_key, role, val)"
/>
<span
class="permission-state"
:class="{
'permission-state--allowed': isOperationAllowed(row.operation_key, role),
'permission-state--disabled': !isOperationAllowed(row.operation_key, role),
}"
:title="isOperationAllowed(row.operation_key, role) ? '已授权' : '未授权'"
>
<template v-if="isOperationAllowed(row.operation_key, role)"></template>
<CircleCloseFilled v-else />
</span>
</template>
</el-table-column>
</el-table>
@@ -74,14 +105,18 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { Search } from "@element-plus/icons-vue";
import { CircleCloseFilled, Search } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import type { ApiEndpointPermissionsResponse } from "@/types/api";
import { fetchApiOperations } from "@/api/projectPermissions";
import { isApiPermissionAllowed } from "@/utils/apiPermissionValue";
import { useAuthStore } from "@/store/auth";
import { isSystemAdmin } from "@/utils/roles";
import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta";
import {
compareProjectPermissionModules,
projectPermissionCategory,
projectPermissionCategoryLabel,
projectPermissionModuleLabel,
} from "@/utils/projectPermissionModules";
interface Operation {
operation_key: string;
@@ -91,61 +126,65 @@ interface Operation {
default_roles: string[];
}
interface DisplayOperation extends Operation {
source_module: string;
source_index: number;
display_section?: string;
}
interface Props {
project: any;
matrix: ApiEndpointPermissionsResponse | null;
}
interface Emits {
(e: "update", matrix: ApiEndpointPermissionsResponse): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const auth = useAuthStore();
const { roleLabel, loadRoleTemplates } = useRoleTemplateMeta();
const isAdmin = computed(() => isSystemAdmin(auth.user));
const isRoleEditable = (role: string) => {
if (role === "ADMIN") return false;
if (isAdmin.value) return true;
return role !== "PM";
};
const { roleLabel, compareRolesByTemplateOrder, loadRoleTemplates } = useRoleTemplateMeta();
const searchText = ref("");
const filterModule = ref("");
const filterAction = ref("");
const operations = ref<Operation[]>([]);
const MODULE_LABELS: Record<string, string> = {
subjects: "参与者管理",
sites: "中心管理",
risk_issues: "风险问题",
monitoring_audit: "监查稽查",
project_members: "项目成员",
project_milestones: "项目里程碑",
project_overview: "项目总览",
setup_config: "立项配置",
fees: "合同费用",
materials: "物资管理",
material_equipments: "物资设备",
documents: "文件版本",
startup_ethics: "立项与伦理",
startup_auth: "启动与授权",
audit_export: "审计日志",
faq: "FAQ",
shared_library: "共享库",
attachments: "附件管理",
dashboard: "仪表板",
subject_histories: "参与者历史",
const moduleLabel = projectPermissionModuleLabel;
const getPermissionCategory = (row: Operation): "common" | "business" => projectPermissionCategory(row.module);
const getPermissionCategoryLabel = (row: Operation): string => projectPermissionCategoryLabel(row.module);
const RISK_ISSUE_REUSED_SECTIONS: Record<string, string> = {
subject_aes: "AE/SAE",
subject_pds: "PD",
};
const moduleLabel = (module: string): string => MODULE_LABELS[module] || module;
const getOperationPrefix = (row: Operation): string => row.operation_key.split(":")[0] || row.module;
const displayOperations = computed<DisplayOperation[]>(() => {
const rows: DisplayOperation[] = operations.value.map((operation, index) => ({
...operation,
source_module: operation.module,
source_index: index,
}));
operations.value.forEach((operation, index) => {
const prefix = getOperationPrefix(operation);
const displaySection = RISK_ISSUE_REUSED_SECTIONS[prefix];
if (!displaySection) return;
if (rows.some((row) => row.module === "risk_issues" && row.operation_key === operation.operation_key)) return;
rows.push({
...operation,
module: "risk_issues",
source_module: operation.module,
source_index: index,
display_section: displaySection,
});
});
return rows;
});
//
const roles = computed(() => {
if (!props.matrix) return [];
return Object.keys(props.matrix).sort();
return Object.keys(props.matrix).sort(compareRolesByTemplateOrder);
});
//
@@ -161,17 +200,19 @@ const loadOperations = async () => {
//
const uniqueModules = computed(() => {
return [...new Set(operations.value.map((o) => o.module))].sort();
return [...new Set(displayOperations.value.map((o) => o.module))].sort(compareProjectPermissionModules);
});
//
const filteredOperations = computed(() => {
const filtered = operations.value.filter((operation) => {
const filtered = displayOperations.value.filter((operation) => {
const sectionLabel = getOperationSection(operation);
const matchSearch =
!searchText.value ||
operation.operation_key.toLowerCase().includes(searchText.value.toLowerCase()) ||
operation.description.toLowerCase().includes(searchText.value.toLowerCase()) ||
moduleLabel(operation.module).includes(searchText.value);
moduleLabel(operation.module).includes(searchText.value) ||
sectionLabel.includes(searchText.value);
const matchModule = !filterModule.value || operation.module === filterModule.value;
const matchAction = !filterAction.value || getOperationVerb(operation) === filterAction.value;
@@ -179,20 +220,36 @@ const filteredOperations = computed(() => {
return matchSearch && matchModule && matchAction;
});
//
//
filtered.sort((a, b) => {
const categoryDiff = Number(getPermissionCategory(a) === "business") - Number(getPermissionCategory(b) === "business");
if (categoryDiff !== 0) return categoryDiff;
if (a.module !== b.module) {
return moduleLabel(a.module).localeCompare(moduleLabel(b.module), "zh-CN");
return compareProjectPermissionModules(a.module, b.module);
}
return a.operation_key.localeCompare(b.operation_key);
const sectionDiff = compareOperationSections(a, b);
if (sectionDiff !== 0) return sectionDiff;
return a.source_index - b.source_index;
});
return filtered;
});
//
const spanMethod = ({ row, rowIndex, columnIndex }: { row: Operation; rowIndex: number; column: any; columnIndex: number }) => {
//
const spanMethod = ({ row, rowIndex, columnIndex }: { row: DisplayOperation; rowIndex: number; column: any; columnIndex: number }) => {
if (columnIndex === 0) {
const list = filteredOperations.value;
const category = getPermissionCategory(row);
if (rowIndex === 0 || getPermissionCategory(list[rowIndex - 1]) !== category) {
let count = 1;
for (let i = rowIndex + 1; i < list.length && getPermissionCategory(list[i]) === category; i++) {
count++;
}
return { rowspan: count, colspan: 1 };
}
return { rowspan: 0, colspan: 0 };
}
if (columnIndex === 1) {
const list = filteredOperations.value;
if (rowIndex === 0 || list[rowIndex - 1].module !== row.module) {
let count = 1;
@@ -203,6 +260,28 @@ const spanMethod = ({ row, rowIndex, columnIndex }: { row: Operation; rowIndex:
}
return { rowspan: 0, colspan: 0 };
}
if (columnIndex === 2) {
const list = filteredOperations.value;
const section = getOperationSection(row);
if (
rowIndex === 0 ||
list[rowIndex - 1].module !== row.module ||
getOperationSection(list[rowIndex - 1]) !== section
) {
let count = 1;
for (
let i = rowIndex + 1;
i < list.length &&
list[i].module === row.module &&
getOperationSection(list[i]) === section;
i++
) {
count++;
}
return { rowspan: count, colspan: 1 };
}
return { rowspan: 0, colspan: 0 };
}
return { rowspan: 1, colspan: 1 };
};
@@ -252,19 +331,53 @@ const getActionType = (row: Operation): string => {
return ACTION_TAG_TYPES[verb] || "info";
};
//
const onPermissionChange = (operation_key: string, role: string, allowed: boolean) => {
if (!props.matrix) return;
const SECTION_LABELS: Record<string, string> = {
subjects: "参与者基础信息",
visits: "访视",
subject_aes: "AE",
subject_pds: "PD",
subject_histories: "病史",
monitoring_issues: "监查访视问题",
project_milestones: "项目里程碑",
drug_shipments: "药品流向管理",
material_equipments: "设备管理",
startup_initiation: "立项记录",
startup_ethics: "伦理记录",
precautions: "注意事项",
precautions_attachments: "注意事项",
faq: "医学咨询/FAQ",
faq_category: "医学咨询/FAQ",
faq_reply: "医学咨询/FAQ",
faq_attachments: "医学咨询/FAQ",
};
const updatedMatrix: ApiEndpointPermissionsResponse = JSON.parse(JSON.stringify(props.matrix));
const SECTION_ORDER_BY_MODULE: Record<string, string[]> = {
subjects: ["参与者基础信息", "访视", "AE", "PD", "病史"],
risk_issues: ["AE/SAE", "PD", "监查访视问题"],
shared_library: ["医学咨询/FAQ", "注意事项"],
};
if (!updatedMatrix[role]) {
updatedMatrix[role] = {};
const getOperationSection = (row: Operation): string => {
const displaySection = (row as Partial<DisplayOperation>).display_section;
if (displaySection) return displaySection;
const prefix = getOperationPrefix(row);
return SECTION_LABELS[prefix] || moduleLabel(row.module);
};
const compareOperationSections = (a: Operation, b: Operation): number => {
const aSection = getOperationSection(a);
const bSection = getOperationSection(b);
const orderedSections = a.module === b.module ? SECTION_ORDER_BY_MODULE[a.module] : undefined;
if (orderedSections) {
const aIndex = orderedSections.indexOf(aSection);
const bIndex = orderedSections.indexOf(bSection);
if (aIndex !== -1 || bIndex !== -1) {
if (aIndex === -1) return 1;
if (bIndex === -1) return -1;
return aIndex - bIndex;
}
}
updatedMatrix[role][operation_key] = allowed;
emit("update", updatedMatrix);
return aSection.localeCompare(bSection, "zh-CN");
};
onMounted(() => {
@@ -272,7 +385,7 @@ onMounted(() => {
loadRoleTemplates();
});
defineExpose({ searchText });
defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermissionCategoryLabel });
</script>
<style scoped>
@@ -305,11 +418,6 @@ defineExpose({ searchText });
color: #1a2332;
}
.toolbar-desc {
font-size: 12px;
color: #64748b;
}
.toolbar-filters {
display: flex;
align-items: center;
@@ -327,10 +435,73 @@ defineExpose({ searchText });
width: 100%;
}
.hierarchy-cell {
display: flex;
align-items: center;
gap: 10px;
height: 100%;
}
/* 权限类型:左侧渐变色条 + 加粗大字 */
.hierarchy-cell--category {
position: relative;
padding-left: 6px;
}
.hierarchy-bar {
position: absolute;
left: -8px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 36px;
border-radius: 0 3px 3px 0;
background: linear-gradient(180deg, #94a3b8 0%, #cbd5e1 100%);
}
.hierarchy-cell--category[data-category="common"] .hierarchy-bar {
background: linear-gradient(180deg, #3b82f6 0%, #60a5fa 100%);
}
.hierarchy-cell--category[data-category="business"] .hierarchy-bar {
background: linear-gradient(180deg, #8b5cf6 0%, #a78bfa 100%);
}
.category-label {
font-size: 14px;
font-weight: 700;
color: #0f172a;
letter-spacing: 0.3px;
}
/* 模块:实心圆点 + 中等加粗字 */
.module-dot {
flex-shrink: 0;
width: 7px;
height: 7px;
border-radius: 50%;
background: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12);
}
.module-label {
font-weight: 600;
font-size: 13px;
color: #1a2332;
font-weight: 600;
color: #1e293b;
}
/* 模块细分:胶囊样式 */
.section-chip {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
font-size: 12px;
font-weight: 500;
color: #475569;
white-space: nowrap;
}
.operation-cell {
@@ -349,4 +520,31 @@ defineExpose({ searchText });
font-size: 13px;
color: #475569;
}
.permission-state {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: #94a3b8;
font-size: 14px;
font-weight: 600;
}
.permission-state--allowed {
color: #0f766e;
background: #ecfdf5;
border: 1px solid #99f6e4;
border-radius: 999px;
}
.permission-state--disabled {
color: #94a3b8;
}
.permission-state--disabled :deep(svg) {
width: 16px;
height: 16px;
}
</style>
@@ -29,11 +29,10 @@ describe("PermissionAccessLogs", () => {
expect(source).not.toContain("fetchIpLocations");
});
it("does not expose QA in role filter presets", () => {
it("exposes QA and CTA in role filter presets", () => {
const source = readSource();
expect(source).not.toContain('label="QA" value="QA"');
expect(source).not.toContain('QA: "QA"');
expect(source).toContain('const ROLE_FILTER_KEYS = ["ADMIN", "PM", "CRA", "PV", "QA", "CTA"];');
});
it("uses permission template names for role filters and log lines", () => {
@@ -45,7 +44,7 @@ describe("PermissionAccessLogs", () => {
expect(source).toContain('v-for="role in roleFilterOptions"');
expect(source).toContain("role=${roleLabel(row.role)}");
expect(source).not.toContain("const ROLE_LABELS");
expect(source).not.toContain('label="医学审核" value="MEDICAL_REVIEW"');
expect(source).not.toContain('label="医学审核" value="QA"');
});
it("uses narrow metric cards without helper subtitles", () => {
@@ -219,7 +219,7 @@ const filters = reactive({
allowed: undefined as boolean | undefined,
});
const ROLE_FILTER_KEYS = ["ADMIN", "PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"];
const ROLE_FILTER_KEYS = ["ADMIN", "PM", "CRA", "PV", "QA", "CTA"];
const roleFilterOptions = computed(() => roleOptionsFor(ROLE_FILTER_KEYS));
const SECURITY_AUTH_LABELS: Record<string, string> = {
@@ -32,10 +32,11 @@ describe("PermissionTemplateSelector", () => {
expect(source).toContain("emit('edit-role', template.category!)");
});
it("does not define QA as a preset role label or icon", () => {
it("does not keep hard-coded preset role labels or icons", () => {
const source = readSource();
expect(source).not.toContain('QA: "QA"');
expect(source).not.toContain('CTA: "CTA"');
expect(source).not.toContain('QA: "✅"');
});
});
@@ -3,7 +3,6 @@
<div class="selector-header">
<div class="selector-title-group">
<h3>角色权限概览</h3>
<p class="selector-desc">展示当前项目各角色的权限配置状态</p>
</div>
<el-tag effect="plain" size="small" type="info" round>{{ templates.length }} 个角色</el-tag>
</div>
@@ -85,7 +84,7 @@ const templates = ref<PermissionTemplate[]>([]);
const roleIcon = (category: string | null) => {
const icons: Record<string, string> = {
PM: "👑", CRA: "📋", PV: "🔍",
MEDICAL_REVIEW: "🏥", IMP: "📦",
QA: "🏥", CTA: "📦",
};
return icons[category ?? ""] ?? "📄";
};
@@ -162,12 +161,6 @@ watch(() => props.refreshKey, loadTemplates);
color: #1a2332;
}
.selector-desc {
margin: 0;
font-size: 12px;
color: #64748b;
}
.template-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
@@ -1,21 +1,46 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { useRoleTemplateMeta } from "./useRoleTemplateMeta";
vi.mock("@/api/projectPermissions", () => ({
fetchPermissionTemplates: vi.fn().mockResolvedValue({
data: [
{ category: "PM", name: "PM" },
{ category: "CRA", name: "CRA" },
{ category: "CTA", name: "CTA" },
{ category: "QA", name: "QA" },
{ category: "PV", name: "PV" },
],
}),
}));
const readSource = () => readFileSync(resolve(__dirname, "./useRoleTemplateMeta.ts"), "utf8");
describe("useRoleTemplateMeta fallback copy", () => {
it("uses polished preset role labels and descriptions without introducing QA role key", () => {
it("uses QA and CTA as first-class preset role keys", () => {
const source = readSource();
expect(source).toContain('PM: { label: "PM"');
expect(source).toContain("项目负责人,统筹项目全局,协调进度、资源与关键决策。");
expect(source).toContain("负责各中心临床监查执行,跟进现场质量、数据和问题闭环。");
expect(source).toContain("负责合同、药品及相关项目事务管理,保障执行支持与物资协同。");
expect(source).toContain('MEDICAL_REVIEW: { label: "QA"');
expect(source).toContain('QA: { label: "QA"');
expect(source).toContain("负责医学审核与稽查,关注质量风险、合规性和医学一致性。");
expect(source).toContain('IMP: { label: "CTA"');
expect(source).toContain('CTA: { label: "CTA"');
expect(source).toContain("负责药物警戒相关工作,跟踪安全性事件并支持风险评估。");
expect(source).not.toContain("QA: {");
});
it("sorts roles by the role template list order", async () => {
const { compareRolesByTemplateOrder, loadRoleTemplates } = useRoleTemplateMeta();
await loadRoleTemplates();
expect(["CRA", "PV", "QA", "PM", "CTA"].sort(compareRolesByTemplateOrder)).toEqual([
"PM",
"CRA",
"CTA",
"QA",
"PV",
]);
});
});
@@ -6,8 +6,8 @@ const FALLBACK_ROLE_META: Record<string, { label: string; icon: string; desc: st
PM: { label: "PM", icon: "👑", desc: "项目负责人,统筹项目全局,协调进度、资源与关键决策。" },
CRA: { label: "CRA", icon: "📋", desc: "负责各中心临床监查执行,跟进现场质量、数据和问题闭环。" },
PV: { label: "PV", icon: "🔍", desc: "负责药物警戒相关工作,跟踪安全性事件并支持风险评估。" },
MEDICAL_REVIEW: { label: "QA", icon: "🏥", desc: "负责医学审核与稽查,关注质量风险、合规性和医学一致性。" },
IMP: { label: "CTA", icon: "📦", desc: "负责合同、药品及相关项目事务管理,保障执行支持与物资协同。" },
QA: { label: "QA", icon: "🏥", desc: "负责医学审核与稽查,关注质量风险、合规性和医学一致性。" },
CTA: { label: "CTA", icon: "📦", desc: "负责合同、药品及相关项目事务管理,保障执行支持与物资协同。" },
};
const templates = ref<PermissionTemplate[]>([]);
@@ -47,6 +47,24 @@ export const useRoleTemplateMeta = () => {
desc: roleDescription(role),
}));
const roleOrder = computed(() => {
const result: Record<string, number> = {};
templates.value.forEach((template, index) => {
if (template.category && result[template.category] === undefined) {
result[template.category] = index;
}
});
return result;
});
const compareRolesByTemplateOrder = (a: string, b: string) => {
const order = roleOrder.value;
const aOrder = order[a] ?? Number.MAX_SAFE_INTEGER;
const bOrder = order[b] ?? Number.MAX_SAFE_INTEGER;
if (aOrder !== bOrder) return aOrder - bOrder;
return a.localeCompare(b);
};
const loadRoleTemplates = async () => {
if (pendingLoad) return pendingLoad;
loading.value = true;
@@ -69,6 +87,7 @@ export const useRoleTemplateMeta = () => {
roleDescription,
roleIcon,
roleOptionsFor,
compareRolesByTemplateOrder,
loadRoleTemplates,
};
};
+2 -2
View File
@@ -7,8 +7,8 @@ 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: "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: 5 },
{ value: "CTA", label: TEXT.enums.userRole.CTA, order: 6 },
];
export const roleDict: Dict = createDict(items);
+49 -40
View File
@@ -89,6 +89,8 @@ export const TEXT = {
userFallback: "用户",
basicInfo: "基本信息",
allSites: "所有中心",
yes: "是",
no: "否",
},
units: {
case: "例",
@@ -261,7 +263,6 @@ export const TEXT = {
projectOverview: "项目总览",
projectMilestones: "项目里程碑",
finance: "合同费用管理",
financeContracts: "合同费用管理",
feeContracts: "合同费用管理",
drug: "药品管理",
materialManagement: "物资管理",
@@ -275,10 +276,7 @@ export const TEXT = {
riskIssueSae: "AE/SAE",
riskIssuePd: "PD",
riskIssueMonitoringVisits: "监查访视问题",
monitoringAudit: "监查稽查",
etmf: "eTMF",
monitoring: "监查",
audit: "稽查",
sharedLibrary: "共享库",
knowledgeMedicalConsult: "医学咨询",
knowledgeNotes: "注意事项",
@@ -358,17 +356,6 @@ export const TEXT = {
remark: "备注",
},
},
financeContracts: {
title: "合同费用",
subtitle: "维护分中心合同费用与附件",
empty: "暂无合同费用记录",
newTitle: "新增合同费用",
editTitle: "编辑合同费用",
formSubtitle: "填写分中心合同信息并保存",
uploadHint: "保存后可上传合同附件",
detailTitle: "合同费用详情",
detailSubtitle: "查看合同费用与附件",
},
feeContracts: {
title: "合同费用管理",
subtitle: "按中心维护合同费用与分期付款",
@@ -378,6 +365,7 @@ export const TEXT = {
formSubtitle: "维护合同费用与分期付款信息",
detailTitle: "合同费用详情",
detailSubtitle: "查看合同费用与分期付款信息",
contractInfoTitle: "合同信息",
overviewTitle: "费用总览",
totalContractAmount: "合同金额总计",
totalPaidAmount: "已付金额总计",
@@ -387,8 +375,6 @@ export const TEXT = {
contractAmount: "合同金额",
contractCases: "合同例数",
actualCases: "实际例数",
settlementAmount: "结算费用",
finalPaymentAmount: "尾款费用",
caseProgress: "合同/实际例数",
paidSummary: "已付/未付",
paidTotal: "已付合计",
@@ -722,29 +708,52 @@ export const TEXT = {
listTitle: "说明文件列表",
emptyDescription: "说明文件功能正在搭建基础能力,敬请期待。",
},
monitoring: {
title: "监查",
subtitle: "功能建设中",
listTitle: "监查记录列表",
emptyDescription: "监查功能正在搭建基础能力,敬请期待。",
},
audit: {
title: "稽查",
subtitle: "功能建设中",
listTitle: "稽查记录列表",
emptyDescription: "稽查模块正在准备基础框架,敬请期待。",
},
monitoringAudit: {
title: "监查稽查",
subtitle: "功能建设中",
listTitle: "监查稽查列表",
emptyDescription: "监查稽查模块正在建设中,敬请期待。",
},
etmf: {
title: "eTMF",
subtitle: "功能建设中",
listTitle: "eTMF 列表",
emptyDescription: "eTMF 模块正在建设中,敬请期待。",
subtitle: "按 TMF 目录归档项目级与中心级文件",
listTitle: "eTMF 目录",
emptyDescription: "暂无 eTMF 目录",
treeTitle: "TMF 目录",
detailTitle: "归档状态",
noNodeSelected: "未选择目录",
selectNodeHint: "请选择左侧目录查看文件",
emptyDocuments: "当前目录暂无文件",
rootNode: "根目录",
actions: {
newNode: "新增目录",
newDocument: "归档文件",
},
dialog: {
nodeTitle: "新增 eTMF 目录",
documentTitle: "归档文件",
},
fields: {
parent: "上级目录",
node: "归档目录",
code: "目录编码",
name: "目录名称",
scope: "适用范围",
required: "必需",
documentCount: "文件数",
effectiveCount: "生效版本",
},
status: {
NOT_REQUIRED: "非必需",
MISSING: "缺失",
UPLOADED: "已上传",
EFFECTIVE: "已生效",
INACTIVE: "已停用",
},
statusDescriptions: {
NOT_REQUIRED: "该目录不是必需项,当前没有归档文件。",
MISSING: "该目录为必需项,尚未归档文件。",
UPLOADED: "该目录已有文件,但暂无生效版本。",
EFFECTIVE: "该目录已有生效版本,可满足当前归档要求。",
INACTIVE: "该目录已停用,仅保留历史归档上下文。",
},
messages: {
siteRequired: "中心级目录需选择分中心",
},
},
adminUserApproval: {
title: "注册审核",
@@ -961,8 +970,8 @@ export const TEXT = {
PM: "PM",
CRA: "CRA",
PV: "PV",
MEDICAL_REVIEW: "QA",
IMP: "CTA",
QA: "QA",
CTA: "CTA",
},
userStatus: {
PENDING: "待审核",
+19
View File
@@ -43,4 +43,23 @@ describe("admin project route permissions", () => {
expect(source).not.toContain(`path: "${removedPath.slice(1)}"`);
expect(source).not.toContain(`"${removedPath}"`);
});
it("does not register legacy finance contract routes", () => {
const source = readRouter();
expect(source).not.toContain("FinanceContracts");
expect(source).not.toContain("FinanceContract");
expect(source).not.toContain("finance/contracts");
});
it("uses precautions routes instead of knowledge note routes", () => {
const source = readRouter();
expect(source).toContain('path: "knowledge/precautions"');
expect(source).toContain('path: "knowledge/precautions/new"');
expect(source).toContain('path: "knowledge/precautions/:id"');
expect(source).toContain('path: "knowledge/precautions/:id/edit"');
expect(source).not.toContain("KnowledgeNote");
expect(source).not.toContain("/knowledge/notes");
});
});
+1 -1
View File
@@ -32,7 +32,7 @@ export interface LoginKeyResponse {
expires_at: string;
}
export type UserRole = "PM" | "CRA" | "PV" | "MEDICAL_REVIEW" | "IMP" | "ADMIN";
export type UserRole = "PM" | "CRA" | "PV" | "QA" | "CTA" | "ADMIN";
export type UserStatus = "PENDING" | "ACTIVE" | "REJECTED" | "DISABLED";
export interface UserInfo {
+2
View File
@@ -13,6 +13,8 @@ describe("permission project role model", () => {
expect(source).toContain("study.currentStudyRole");
expect(source).toContain("study.currentPermissions");
expect(source).toContain('"faq.edit": "faq:update"');
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"');
+4 -7
View File
@@ -59,15 +59,14 @@ export const usePermission = () => {
"subject.enroll": "subjects:update",
"subject.complete": "subjects:update",
"subject.drop": "subjects:update",
"ae.create": "risk_issues:create",
"ae.close": "risk_issues:update",
"ae.create": "subject_aes:create",
"ae.close": "subject_aes:update",
"project.overview.read": "project_overview:read",
"faq.edit": "faq:update",
"faq.create": "faq:create",
"faq.reply": "faq_reply:create",
"knowledge.notes.write": "knowledge_notes:create",
"shared.library.read": "knowledge_notes:read",
"shared.library.write": "knowledge_notes:create",
"precautions.read": "precautions:read",
"precautions.write": "precautions:create",
"project.members.manage": "project_members:update",
"site.manage": "sites:update",
"site.cra.bind": "sites:update",
@@ -76,8 +75,6 @@ export const usePermission = () => {
"fees.contract.delete": "fees_contracts:delete",
"documents.create": "documents:create",
"documents.delete": "documents:delete",
"fees.attachment.delete": "fees_attachments:delete",
"file.attachment.delete": "attachments:delete",
"audit.export.read": "audit_logs:read",
};
const operationKey = permissionMap[action];
@@ -0,0 +1,69 @@
export const PROJECT_PERMISSION_MODULE_LABELS: Record<string, string> = {
subjects: "参与者管理",
sites: "中心管理",
risk_issues: "风险问题",
project_members: "项目成员",
project_milestones: "项目里程碑",
project_overview: "项目总览",
setup_config: "立项配置",
fees: "合同费用",
materials: "物资管理",
documents: "文件版本",
startup_ethics: "立项与伦理",
startup_auth: "启动与授权",
audit_export: "审计日志",
shared_library: "共享库",
subject_histories: "病史记录",
};
const COMMON_PROJECT_PERMISSION_MODULES = new Set([
"setup_config",
"project_members",
"sites",
"audit_export",
]);
export const PROJECT_PERMISSION_CATEGORY_LABELS: Record<"common" | "business", string> = {
common: "通用权限",
business: "业务权限",
};
const PROJECT_MENU_MODULE_ORDER = [
"setup_config",
"project_members",
"sites",
"audit_export",
"project_overview",
"project_milestones",
"fees",
"materials",
"documents",
"startup_ethics",
"startup_auth",
"subjects",
"subject_histories",
"risk_issues",
"shared_library",
];
export const projectPermissionModuleLabel = (module: string): string =>
PROJECT_PERMISSION_MODULE_LABELS[module] || module;
export const projectPermissionCategory = (module: string): "common" | "business" =>
COMMON_PROJECT_PERMISSION_MODULES.has(module) ? "common" : "business";
export const projectPermissionCategoryLabel = (module: string): string =>
PROJECT_PERMISSION_CATEGORY_LABELS[projectPermissionCategory(module)];
export const getProjectPermissionModuleOrder = (module: string): number => {
const index = PROJECT_MENU_MODULE_ORDER.indexOf(module);
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
};
export const compareProjectPermissionModules = (a: string, b: string): number => {
const categoryDiff = Number(projectPermissionCategory(a) === "business") - Number(projectPermissionCategory(b) === "business");
if (categoryDiff !== 0) return categoryDiff;
const orderDiff = getProjectPermissionModuleOrder(a) - getProjectPermissionModuleOrder(b);
if (orderDiff !== 0) return orderDiff;
return projectPermissionModuleLabel(a).localeCompare(projectPermissionModuleLabel(b), "zh-CN");
};
@@ -0,0 +1,11 @@
const PROJECT_ROLE_RANK: Record<string, number> = {
ADMIN: 100,
PM: 80,
QA: 60,
PV: 50,
CRA: 40,
CTA: 40,
};
export const compareProjectPermissionRoles = (a: string, b: string): number =>
(PROJECT_ROLE_RANK[b] ?? 0) - (PROJECT_ROLE_RANK[a] ?? 0) || a.localeCompare(b);
@@ -5,33 +5,41 @@ 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("/finance/contracts")).toEqual({ operationKey: "finance_contracts:read" });
expect(getProjectRoutePermission("/finance/contracts/new")).toEqual({ operationKey: "finance_contracts:create" });
expect(getProjectRoutePermission("/finance/contracts/abc/edit")).toEqual({ operationKey: "finance_contracts:update" });
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("/subjects/new")).toEqual({ operationKey: "subjects:create" });
expect(getProjectRoutePermission("/subjects/abc/edit")).toEqual({ operationKey: "subjects:update" });
expect(getProjectRoutePermission("/startup/feasibility/new")).toEqual({ operationKey: "ethics:create" });
expect(getProjectRoutePermission("/startup/feasibility/abc/edit")).toEqual({ operationKey: "ethics:update" });
expect(getProjectRoutePermission("/risk-issues")).toEqual({ operationKey: "subject_aes:list" });
expect(getProjectRoutePermission("/risk-issues/sae")).toEqual({ operationKey: "subject_aes:list" });
expect(getProjectRoutePermission("/risk-issues/pd")).toEqual({ operationKey: "subject_pds:list" });
expect(getProjectRoutePermission("/risk-issues/monitoring-visits")).toEqual({ operationKey: "monitoring_issues:list" });
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/kickoff/new")).toEqual({ operationKey: "startup_auth:create" });
expect(getProjectRoutePermission("/startup/kickoff/abc/edit")).toEqual({ operationKey: "startup_auth:update" });
expect(getProjectRoutePermission("/knowledge/medical-consult/abc")).toEqual({ operationKey: "faq:read" });
expect(getProjectRoutePermission("/knowledge/notes/new")).toEqual({ operationKey: "knowledge_notes:create" });
expect(getProjectRoutePermission("/knowledge/notes/abc/edit")).toEqual({ operationKey: "knowledge_notes:update" });
expect(getProjectRoutePermission("/knowledge/precautions/new")).toEqual({ operationKey: "precautions:create" });
expect(getProjectRoutePermission("/knowledge/precautions/abc/edit")).toEqual({ operationKey: "precautions:update" });
expect(getProjectRoutePermission("/knowledge/notes/new")).toBeNull();
});
it("rejects project routes when the role lacks operation permission", () => {
const matrix = {
CRA: {
"project_overview:read": { allowed: false },
"knowledge_notes:read": { allowed: true },
"precautions:read": { allowed: true },
},
} as any;
expect(hasProjectPermission(matrix, "CRA", getProjectRoutePermission("/project/overview"), false)).toBe(false);
expect(hasProjectPermission(matrix, "CRA", getProjectRoutePermission("/knowledge/notes"), false)).toBe(true);
expect(findFirstAccessibleProjectPath(matrix, "CRA", false)).toBe("/knowledge/notes");
expect(hasProjectPermission(matrix, "CRA", getProjectRoutePermission("/knowledge/precautions"), false)).toBe(true);
expect(findFirstAccessibleProjectPath(matrix, "CRA", false)).toBe("/knowledge/precautions");
});
});
+13 -14
View File
@@ -7,39 +7,39 @@ export type ProjectRoutePermission = {
const routePermissions: Array<{ prefixes: string[]; permission: ProjectRoutePermission }> = [
{ prefixes: ["/fees/contracts/new"], permission: { operationKey: "fees_contracts:create" } },
{ prefixes: ["/finance/contracts/new"], permission: { operationKey: "finance_contracts:create" } },
{ prefixes: ["/drug/shipments/new"], permission: { operationKey: "drug_shipments:create" } },
{ prefixes: ["/startup/feasibility/new", "/startup/ethics/new"], permission: { operationKey: "ethics: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: ["/subjects/new"], permission: { operationKey: "subjects:create" } },
{ prefixes: ["/knowledge/notes/new"], permission: { operationKey: "knowledge_notes:create" } },
{ prefixes: ["/knowledge/precautions/new"], permission: { operationKey: "precautions:create" } },
{ prefixes: ["/project/overview"], permission: { operationKey: "project_overview:read" } },
{ prefixes: ["/project/milestones"], permission: { operationKey: "project_milestones:read" } },
{ prefixes: ["/fees/contracts"], permission: { operationKey: "fees_contracts:read" } },
{ prefixes: ["/finance/contracts"], permission: { operationKey: "finance_contracts:read" } },
{ 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", "/startup/ethics"], permission: { operationKey: "ethics:read" } },
{ prefixes: ["/startup/feasibility-ethics", "/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"], permission: { operationKey: "risk_issues:read" } },
{ prefixes: ["/monitoring-audit", "/monitoring", "/audit"], permission: { operationKey: "monitoring_audit:read" } },
{ prefixes: ["/risk-issues/monitoring-visits"], permission: { operationKey: "monitoring_issues:list" } },
{ prefixes: ["/risk-issues/pd"], permission: { operationKey: "subject_pds:list" } },
{ prefixes: ["/risk-issues"], permission: { operationKey: "subject_aes:list" } },
{ prefixes: ["/etmf"], permission: { operationKey: "documents:read" } },
{ prefixes: ["/knowledge/medical-consult"], permission: { operationKey: "faq:read" } },
{ prefixes: ["/knowledge/notes", "/knowledge/support-files", "/knowledge/instruction-files"], permission: { operationKey: "knowledge_notes: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: /^\/finance\/contracts\/[^/]+\/edit$/, permission: { operationKey: "finance_contracts:update" } },
{ pattern: /^\/drug\/shipments\/[^/]+\/edit$/, permission: { operationKey: "drug_shipments:update" } },
{ pattern: /^\/startup\/feasibility\/[^/]+\/edit$/, permission: { operationKey: "ethics:update" } },
{ pattern: /^\/startup\/ethics\/[^/]+\/edit$/, permission: { operationKey: "ethics: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\/notes\/[^/]+\/edit$/, permission: { operationKey: "knowledge_notes:update" } },
{ pattern: /^\/knowledge\/precautions\/[^/]+\/edit$/, permission: { operationKey: "precautions:update" } },
];
export const projectRouteLandingPaths = [
@@ -53,10 +53,9 @@ export const projectRouteLandingPaths = [
"/startup/meeting-auth",
"/subjects",
"/risk-issues/sae",
"/monitoring-audit",
"/etmf",
"/knowledge/medical-consult",
"/knowledge/notes",
"/knowledge/precautions",
"/knowledge/support-files",
"/knowledge/instruction-files",
];
@@ -18,4 +18,18 @@ describe("audit logs access", () => {
expect(source).not.toContain('const role = auth.user?.role');
expect(source).not.toContain('role !== "ADMIN"');
});
it("does not expose legacy startup ethics business labels", () => {
const auditSource = readFileSync(resolve(__dirname, "../../audit/index.ts"), "utf8");
const overviewSource = readFileSync(resolve(__dirname, "../ia/project-overview/overview.adapter.ts"), "utf8");
const combined = `${auditSource}\n${overviewSource}`;
expect(combined).not.toContain("可行性评估");
expect(combined).not.toContain("伦理审批");
expect(combined).not.toContain("立项可行性");
expect(combined).not.toContain("立项伦理");
expect(auditSource).toContain('startup_feasibility: "立项记录"');
expect(auditSource).toContain('startup_ethics: "伦理记录"');
expect(overviewSource).toContain('label: "伦理记录"');
});
});
@@ -3,6 +3,8 @@ import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./PermissionManagement.vue"), "utf8");
const readProjectPermissionModulesSource = () =>
readFileSync(resolve(__dirname, "../../utils/projectPermissionModules.ts"), "utf8");
describe("permission management custom roles", () => {
it("uses role management wording in the drawer", () => {
@@ -38,7 +40,7 @@ describe("permission management custom roles", () => {
expect(source).not.toContain("输入自定义角色标识");
expect(source).toContain("roleOptions");
expect(source).toContain("useRoleTemplateMeta");
expect(source).toContain("const { roleLabel, roleDescription, roleIcon, loadRoleTemplates } = useRoleTemplateMeta();");
expect(source).toContain("const { roleLabel, roleDescription, roleIcon, compareRolesByTemplateOrder, loadRoleTemplates } = useRoleTemplateMeta();");
expect(source).toContain("await loadRoleTemplates();");
expect(source).toContain("const roleDisplayName = (row: PermissionTemplate) => row.name;");
expect(source).toContain("await loadPermissionData();");
@@ -59,7 +61,7 @@ describe("permission management custom roles", () => {
it("uses database role names when listing and editing preset roles", () => {
const source = readSource();
expect(source).toContain("const { roleLabel, roleDescription, roleIcon, loadRoleTemplates } = useRoleTemplateMeta();");
expect(source).toContain("const { roleLabel, roleDescription, roleIcon, compareRolesByTemplateOrder, loadRoleTemplates } = useRoleTemplateMeta();");
expect(source).toContain("const roleDisplayName = (row: PermissionTemplate) => row.name;");
expect(source).toContain("name: row.name, description: row.description ?? \"\",");
expect(source).not.toContain("templateRoleName(row)");
@@ -106,12 +108,10 @@ describe("permission management custom roles", () => {
expect(source).toContain("await addMember(selectedStudyId.value, addMemberForm);");
});
it("does not offer QA as a preset project permission role", () => {
it("uses QA and CTA as preset project permission role keys", () => {
const source = readSource();
expect(source).not.toContain('key: "QA"');
expect(source).not.toContain('QA: "QA"');
expect(source).not.toContain("QA: 60");
expect(source).toContain("compareRolesByTemplateOrder(a.key, b.key)");
});
it("submits only the selected role from role editor saves", () => {
@@ -143,7 +143,77 @@ describe("permission management custom roles", () => {
it("renders setup_config as a Chinese module name in the role editor", () => {
const source = readSource();
expect(source).toContain('setup_config: "立项配置"');
expect(source).toContain("projectPermissionModuleLabel");
});
it("sorts role editor modules with the shared project permission module order", () => {
const source = readSource();
expect(source).toContain("compareProjectPermissionModules");
expect(source).toContain("const sorted = [...filtered].sort");
expect(source).toContain("const moduleDiff = compareProjectPermissionModules(a.module, b.module);");
expect(source).toContain("return allOperations.value.indexOf(a) - allOperations.value.indexOf(b);");
expect(source).not.toContain("const ROLE_EDITOR_MODULE_LABELS");
});
it("groups role editor permissions by module sections", () => {
const source = readSource();
expect(source).toContain("roleEditorSectionGroups");
expect(source).toContain("roleEditorSectionLabel(op)");
expect(source).toContain("role-editor-section");
expect(source).toContain("getPermissionSectionLabel");
expect(source).toContain("comparePermissionSectionLabels");
expect(source).toContain('const SUBJECT_PERMISSION_SECTION_ORDER = ["基础信息", "病史", "访视", "AE", "PD"];');
expect(source).toContain('subjects: "基础信息"');
expect(source).toContain('visits: "访视"');
expect(source).toContain('subject_aes: "AE"');
expect(source).toContain('subject_pds: "PD"');
expect(source).toContain('subject_histories: "病史"');
expect(source).not.toContain('subjects: "参与者基础信息"');
});
it("shows startup ethics permissions in role editor with business section names", () => {
const source = readSource();
const moduleSource = readProjectPermissionModulesSource();
expect(moduleSource).toContain('startup_ethics: "立项与伦理"');
expect(source).toContain('startup_initiation: "立项记录"');
expect(source).toContain('startup_ethics: "伦理记录"');
});
it("does not expose the legacy dashboard module after merging into project overview", () => {
const moduleSource = readProjectPermissionModulesSource();
expect(moduleSource).not.toContain('dashboard: "仪表板"');
});
it("does not expose generic attachment management after module attachment migration", () => {
const moduleSource = readProjectPermissionModulesSource();
const source = readSource();
expect(moduleSource).not.toContain('attachments: "附件管理"');
expect(source).not.toContain('attachments: "附件管理"');
});
it("shows precautions under the shared library parent module", () => {
const moduleSource = readProjectPermissionModulesSource();
const source = readSource();
expect(moduleSource).toContain('shared_library: "共享库"');
expect(moduleSource).not.toContain('precautions: "注意事项"');
expect(source).toContain('precautions: "注意事项"');
expect(source).toContain('faq: "医学咨询/FAQ"');
});
it("labels contract fees without the removed finance contract prefix in role editor", () => {
const source = readSource();
expect(source).not.toContain("FEE_PERMISSION_SECTION_LABELS");
expect(source).not.toContain('fees_payments: "费用分析"');
expect(source).not.toContain('fees_attachments: "合同附件"');
expect(source).not.toContain("finance_contracts");
expect(source).not.toContain('contract_fees: "合同费用"');
});
it("does not render the duplicated monitoring header", () => {
+383 -211
View File
@@ -8,7 +8,6 @@
<div class="perm-header">
<div class="perm-header-left">
<h2 class="perm-title">项目权限配置</h2>
<span class="perm-subtitle">为每个项目的角色配置接口级权限</span>
</div>
<div class="perm-header-actions">
<div class="project-selector">
@@ -53,10 +52,6 @@
<el-icon><Files /></el-icon>
角色管理
</el-button>
<el-button type="primary" :loading="saving" :disabled="!dirty" @click="save">
<el-icon><Check /></el-icon>
保存变更
</el-button>
</div>
</div>
<PermissionTemplateSelector
@@ -69,7 +64,6 @@
<ApiEndpointPermissions
:project="selectedStudy"
:matrix="apiMatrix"
@update="onApiMatrixUpdate"
/>
</el-tab-pane>
<el-tab-pane label="成员管理" name="members">
@@ -238,137 +232,151 @@
角色管理抽屉
-->
<el-drawer v-model="templateDrawerVisible" title="角色管理" size="720px" direction="rtl" @open="onTemplateDrawerOpen">
<el-tabs v-model="templateDrawerTab" class="drawer-tabs">
<div class="drawer-tabs-shell">
<el-tabs v-model="templateDrawerTab" class="drawer-tabs">
<!-- 标签页角色列表 -->
<el-tab-pane label="角色列表" name="list">
<div class="drawer-toolbar">
<div class="filter-spacer" />
<el-button type="primary" @click="openCreateTemplate">新增角色</el-button>
</div>
<el-table :data="templates" v-loading="templatesLoading" stripe style="margin-top: 12px">
<el-table-column prop="name" label="角色名称" min-width="140">
<template #default="{ row }">
<span class="template-name">{{ roleDisplayName(row) }}</span>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
<el-table-column prop="template_type" label="类型" width="100">
<template #default="{ row }">
<el-tag :type="typeTagType(row.template_type)" size="small">{{ typeLabel(row.template_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="权限数" width="80">
<template #default="{ row }">{{ countPermissions(row) }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEditTemplate(row)">编辑</el-button>
<el-button link type="danger" :disabled="row.is_system" @click="confirmDeleteTemplate(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- 标签页角色列表 -->
<el-tab-pane label="角色列表" name="list">
<el-table :data="templates" v-loading="templatesLoading" stripe class="template-table">
<el-table-column prop="name" label="角色名称" min-width="140">
<template #default="{ row }">
<span class="template-name">{{ roleDisplayName(row) }}</span>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
<el-table-column prop="template_type" label="类型" width="100">
<template #default="{ row }">
<el-tag :type="typeTagType(row.template_type)" size="small">{{ typeLabel(row.template_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="权限数" width="80">
<template #default="{ row }">{{ countPermissions(row) }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEditTemplate(row)">编辑</el-button>
<el-button link type="danger" :disabled="row.is_system" @click="confirmDeleteTemplate(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- 标签页生效管理 -->
<el-tab-pane label="生效管理" name="active" :disabled="!selectedStudyId">
<div class="active-roles-desc">
<el-alert
title="生效的角色将映射到成员管理的项目角色中,未生效的角色不可在成员管理中分配。"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
/>
</div>
<div class="active-roles-list" v-loading="activeRolesLoading">
<div v-for="role in roleOptions" :key="role.key" class="active-role-row">
<div class="active-role-info">
<span class="active-role-icon">{{ role.icon }}</span>
<div>
<div class="active-role-name">{{ role.label }}</div>
<div class="active-role-desc">{{ role.desc }}</div>
</div>
</div>
<el-switch
:model-value="activeRolesDraft.includes(role.key)"
@change="(v: boolean) => toggleActiveRole(role.key, v)"
<!-- 标签页生效管理 -->
<el-tab-pane label="生效管理" name="active" :disabled="!selectedStudyId">
<div class="active-roles-desc">
<el-alert
title="生效的角色将映射到成员管理的项目角色中,未生效的角色不可在成员管理中分配。"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
/>
</div>
</div>
<div class="drawer-footer">
<el-button type="primary" :loading="activeRolesSaving" @click="saveActiveRoles">保存</el-button>
</div>
</el-tab-pane>
<!-- 标签页编辑权限 -->
<el-tab-pane label="编辑权限" name="edit-role" :disabled="!selectedStudyId">
<div class="role-editor-layout" v-loading="roleEditorLoading">
<!-- 左侧角色列表 -->
<div class="role-editor-sidebar">
<div
v-for="role in roleOptions"
:key="role.key"
class="role-sidebar-item"
:class="{ active: editingRole === role.key }"
@click="selectRoleForEdit(role.key)"
>
<span class="role-sidebar-icon">{{ role.icon }}</span>
<div class="role-sidebar-info">
<div class="role-sidebar-name">{{ role.label }}</div>
<div class="role-sidebar-count" v-if="editingRole === role.key && Object.keys(roleEditorDraft).length">
{{ Object.values(roleEditorDraft).filter(Boolean).length }} 项已启用
<div class="active-roles-list" v-loading="activeRolesLoading">
<div v-for="role in roleOptions" :key="role.key" class="active-role-row">
<div class="active-role-info">
<span class="active-role-icon">{{ role.icon }}</span>
<div>
<div class="active-role-name">{{ role.label }}</div>
<div class="active-role-desc">{{ role.desc }}</div>
</div>
</div>
<el-icon v-if="editingRole === role.key" class="role-sidebar-arrow"><ArrowRight /></el-icon>
<el-switch
:model-value="activeRolesDraft.includes(role.key)"
@change="(v: boolean) => toggleActiveRole(role.key, v)"
/>
</div>
</div>
</el-tab-pane>
<!-- 右侧权限编辑区 -->
<div class="role-editor-content">
<template v-if="editingRole">
<div v-if="!canEditSelectedRole" class="role-editor-notice">
<el-alert :title="editingRole === 'PM' ? '仅系统管理员可修改项目负责人权限' : '该角色权限不可修改'" type="info" :closable="false" show-icon />
</div>
<div class="role-editor-toolbar">
<el-input v-model="roleEditorSearch" placeholder="搜索权限..." clearable style="width: 220px">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<div class="filter-spacer" />
<el-button size="small" :disabled="!canEditSelectedRole" @click="roleEditorSelectAll(true)">全选</el-button>
<el-button size="small" :disabled="!canEditSelectedRole" @click="roleEditorSelectAll(false)">全不选</el-button>
</div>
<div class="role-editor-modules">
<div v-for="(ops, mod) in roleEditorGrouped" :key="mod" class="role-editor-module">
<div class="role-editor-module-header">
<span>{{ roleEditorModuleLabel(mod) }}</span>
<el-tag size="small" type="info">{{ ops.length }}</el-tag>
</div>
<div class="role-editor-ops">
<div v-for="op in ops" :key="op.operation_key" class="role-editor-op-row">
<el-checkbox
:model-value="roleEditorDraft[op.operation_key] ?? false"
:disabled="!canEditSelectedRole"
@change="(v: boolean) => roleEditorDraft[op.operation_key] = v"
/>
<el-tag :type="opTagType(op)" size="small" class="op-action-tag">{{ opActionLabel(op) }}</el-tag>
<span class="op-desc">{{ op.description || op.operation_key }}</span>
</div>
<!-- 标签页编辑权限 -->
<el-tab-pane label="编辑权限" name="edit-role" :disabled="!selectedStudyId">
<div class="role-editor-layout" v-loading="roleEditorLoading">
<!-- 左侧角色列表 -->
<div class="role-editor-sidebar">
<div
v-for="role in roleOptions"
:key="role.key"
class="role-sidebar-item"
:class="{ active: editingRole === role.key }"
@click="selectRoleForEdit(role.key)"
>
<span class="role-sidebar-icon">{{ role.icon }}</span>
<div class="role-sidebar-info">
<div class="role-sidebar-name">{{ role.label }}</div>
<div class="role-sidebar-count" v-if="editingRole === role.key && Object.keys(roleEditorDraft).length">
{{ Object.values(roleEditorDraft).filter(Boolean).length }} 项已启用
</div>
</div>
<el-icon v-if="editingRole === role.key" class="role-sidebar-arrow"><ArrowRight /></el-icon>
</div>
<div class="drawer-footer">
<el-button type="primary" :loading="roleEditorSaving" :disabled="!canEditSelectedRole" @click="saveRoleEditor">
保存 {{ roleLabel(editingRole) }} 权限
</el-button>
</div>
</template>
<el-empty v-else description="请从左侧选择角色" style="padding: 80px 0" />
</div>
</div>
</el-tab-pane>
</div>
</el-tabs>
<!-- 右侧权限编辑区 -->
<div class="role-editor-content">
<template v-if="editingRole">
<div v-if="!canEditSelectedRole" class="role-editor-notice">
<el-alert :title="editingRole === 'PM' ? '仅系统管理员可修改项目负责人权限' : '该角色权限不可修改'" type="info" :closable="false" show-icon />
</div>
<div class="role-editor-toolbar">
<el-input v-model="roleEditorSearch" placeholder="搜索权限..." clearable class="role-editor-search">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
</div>
<div class="role-editor-modules">
<div v-for="(sections, mod) in roleEditorSectionGroups" :key="mod" class="role-editor-module">
<div class="role-editor-module-header">
<span>{{ roleEditorModuleLabel(mod) }}</span>
</div>
<el-collapse class="role-editor-collapse">
<el-collapse-item
v-for="section in sections"
:key="`${mod}:${section.label}`"
:name="`${mod}:${section.label}`"
class="role-editor-section"
>
<template #title>
<span class="role-editor-section-title">{{ section.label }}</span>
<span class="role-editor-section-summary">{{ roleEditorEnabledCount(section.ops) }} / {{ section.ops.length }} 已启用</span>
</template>
<div class="role-editor-ops">
<div v-for="op in section.ops" :key="op.operation_key" class="role-editor-op-row">
<el-checkbox
:model-value="roleEditorDraft[op.operation_key] ?? false"
:disabled="!canEditSelectedRole"
@change="(v: boolean) => roleEditorDraft[op.operation_key] = v"
/>
<el-tag :type="opTagType(op)" size="small" class="op-action-tag" effect="light">{{ opActionLabel(op) }}</el-tag>
<span class="op-desc">{{ op.description || op.operation_key }}</span>
</div>
</div>
</el-collapse-item>
</el-collapse>
</div>
</div>
</template>
<el-empty v-else description="请从左侧选择角色" style="padding: 80px 0" />
</div>
</div>
</el-tab-pane>
</el-tabs>
<div class="drawer-tab-actions">
<el-button v-if="templateDrawerTab === 'list'" type="primary" @click="openCreateTemplate">新增角色</el-button>
<el-button v-else-if="templateDrawerTab === 'active'" type="primary" :loading="activeRolesSaving" @click="saveActiveRoles">
保存
</el-button>
<el-button
v-else-if="templateDrawerTab === 'edit-role' && editingRole"
type="primary"
:loading="roleEditorSaving"
:disabled="!canEditSelectedRole"
@click="saveRoleEditor"
>
保存 {{ roleLabel(editingRole) }} 权限
</el-button>
</div>
</div>
</el-drawer>
<!-- 新增/编辑角色对话框 -->
@@ -427,7 +435,7 @@
import { ref, computed, onMounted, watch, reactive } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Check, Files, Search, ArrowRight } from "@element-plus/icons-vue";
import { Files, Search, ArrowRight } from "@element-plus/icons-vue";
import type { FormInstance, FormRules } from "element-plus";
import type {
ApiEndpointPermissionsResponse,
@@ -456,6 +464,10 @@ import { useAuthStore } from "@/store/auth";
import { displayDateTime } from "@/utils/display";
import { isApiPermissionAllowed } from "@/utils/apiPermissionValue";
import { isSystemAdmin } from "@/utils/roles";
import {
compareProjectPermissionModules,
projectPermissionModuleLabel,
} from "@/utils/projectPermissionModules";
import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta";
import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue";
import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
@@ -469,9 +481,9 @@ const selectedProjectRole = computed(() => selectedStudy.value?.role_in_study ||
const isSelectedProjectPm = computed(() => selectedProjectRole.value === "PM");
const canManageSelectedProject = computed(() => isAdmin.value || isSelectedProjectPm.value);
const { roleLabel, roleDescription, roleIcon, loadRoleTemplates } = useRoleTemplateMeta();
const { roleLabel, roleDescription, roleIcon, compareRolesByTemplateOrder, loadRoleTemplates } = useRoleTemplateMeta();
const ROLE_RANK: Record<string, number> = {
ADMIN: 100, PM: 80, PV: 50, MEDICAL_REVIEW: 50, CRA: 40, IMP: 40,
ADMIN: 100, PM: 80, QA: 60, PV: 50, CRA: 40, CTA: 40,
};
type MemberRoleRow = { user_id: string; role_in_study: string; user?: any };
const canAssignProjectRole = (role: string) => isAdmin.value || (ROLE_RANK[role] ?? 0) < ROLE_RANK.PM;
@@ -516,8 +528,6 @@ const projectSubTab = ref<"api" | "members">("api");
//
const apiMatrix = ref<ApiEndpointPermissionsResponse | null>(null);
const dirty = ref(false);
const saving = ref(false);
const currentPermissionsForTemplate = computed(() => {
if (!apiMatrix.value) return undefined;
@@ -545,7 +555,6 @@ const loadPermissionData = async () => {
try {
const res = await fetchApiEndpointPermissions(selectedStudyId.value);
apiMatrix.value = res.data;
dirty.value = false;
} catch {
ElMessage.error("加载权限数据失败");
} finally {
@@ -561,7 +570,6 @@ const onStudyChange = async (id: string) => {
return;
}
apiMatrix.value = null;
dirty.value = false;
members.value = [];
candidates.value = [];
activeRolesDraft.value = [];
@@ -570,8 +578,6 @@ const onStudyChange = async (id: string) => {
loadCandidates();
};
const onApiMatrixUpdate = (m: ApiEndpointPermissionsResponse) => { apiMatrix.value = m; dirty.value = true; };
const activeRolesInStudy = computed(() => activeRolesDraft.value);
// apiMatrix { allowed: boolean } boolean boolean
@@ -598,24 +604,6 @@ const flattenRolePermissions = (
[role]: { ...permissions },
});
const save = async () => {
if (!selectedStudyId.value || !dirty.value || !apiMatrix.value) return;
saving.value = true;
try {
const res = await updateApiEndpointPermissions(
selectedStudyId.value,
flattenMatrix(apiMatrix.value, { includePm: isAdmin.value })
);
apiMatrix.value = res.data;
dirty.value = false;
ElMessage.success("权限已保存");
} catch {
ElMessage.error("保存权限失败");
} finally {
saving.value = false;
}
};
//
const members = ref<StudyMember[]>([]);
const candidates = ref<UserInfo[]>([]);
@@ -766,7 +754,7 @@ const roleOptions = computed(() => {
known.add(role);
}
}
options.sort((a, b) => (ROLE_RANK[b.key] ?? 0) - (ROLE_RANK[a.key] ?? 0) || a.key.localeCompare(b.key));
options.sort((a, b) => compareRolesByTemplateOrder(a.key, b.key));
return options;
});
@@ -961,6 +949,11 @@ interface RoleEditorOp {
description: string;
}
interface RoleEditorSection {
label: string;
ops: RoleEditorOp[];
}
const editingRole = ref("");
const roleEditorSearch = ref("");
const roleEditorDraft = ref<Record<string, boolean>>({});
@@ -968,17 +961,43 @@ const roleEditorSaving = ref(false);
const roleEditorLoading = ref(false);
const allOperations = ref<RoleEditorOp[]>([]);
const ROLE_EDITOR_MODULE_LABELS: Record<string, string> = {
subjects: "参与者管理", sites: "中心管理", risk_issues: "风险问题",
monitoring_audit: "监查稽查", project_members: "项目成员",
project_milestones: "项目里程碑", project_overview: "项目总览",
setup_config: "立项配置", fees: "合同费用", materials: "物资管理", material_equipments: "物资设备",
documents: "文件版本", startup_ethics: "立项与伦理", startup_auth: "启动与授权",
audit_export: "审计日志", faq: "FAQ", shared_library: "共享库",
attachments: "附件管理", dashboard: "仪表板", subject_histories: "参与者历史",
const roleEditorModuleLabel = projectPermissionModuleLabel;
const SUBJECT_PERMISSION_SECTION_ORDER = ["基础信息", "病史", "访视", "AE", "PD"];
const PERMISSION_SECTION_LABELS: Record<string, string> = {
subjects: "基础信息",
visits: "访视",
subject_aes: "AE",
subject_pds: "PD",
subject_histories: "病史",
monitoring_issues: "监查访视问题",
project_milestones: "项目里程碑",
startup_initiation: "立项记录",
startup_ethics: "伦理记录",
precautions: "注意事项",
precautions_attachments: "注意事项",
faq: "医学咨询/FAQ",
faq_category: "医学咨询/FAQ",
faq_reply: "医学咨询/FAQ",
faq_attachments: "医学咨询/FAQ",
};
const roleEditorModuleLabel = (mod: string) => ROLE_EDITOR_MODULE_LABELS[mod] || mod;
const getPermissionSectionLabel = (op: RoleEditorOp): string => {
const prefix = op.operation_key.split(":")[0] || op.module;
return PERMISSION_SECTION_LABELS[prefix] || roleEditorModuleLabel(op.module);
};
const roleEditorSectionLabel = (op: RoleEditorOp): string => getPermissionSectionLabel(op);
const comparePermissionSectionLabels = (a: string, b: string): number => {
const aIndex = SUBJECT_PERMISSION_SECTION_ORDER.indexOf(a);
const bIndex = SUBJECT_PERMISSION_SECTION_ORDER.indexOf(b);
if (aIndex !== -1 || bIndex !== -1) {
return (aIndex === -1 ? Number.MAX_SAFE_INTEGER : aIndex) - (bIndex === -1 ? Number.MAX_SAFE_INTEGER : bIndex);
}
return a.localeCompare(b, "zh-CN");
};
const getOpVerb = (op: { operation_key: string; action: string }): string => {
const suffix = op.operation_key.split(":").pop() || "";
@@ -1008,14 +1027,44 @@ const roleEditorGrouped = computed(() => {
op.operation_key.toLowerCase().includes(search) ||
roleEditorModuleLabel(op.module).includes(search)
);
const sorted = [...filtered].sort((a, b) => {
const moduleDiff = compareProjectPermissionModules(a.module, b.module);
if (moduleDiff !== 0) return moduleDiff;
return allOperations.value.indexOf(a) - allOperations.value.indexOf(b);
});
const map: Record<string, RoleEditorOp[]> = {};
for (const op of filtered) {
for (const op of sorted) {
if (!map[op.module]) map[op.module] = [];
map[op.module].push(op);
}
return map;
});
const roleEditorSectionGroups = computed(() => {
const result: Record<string, RoleEditorSection[]> = {};
for (const [mod, ops] of Object.entries(roleEditorGrouped.value)) {
const sections = new Map<string, RoleEditorOp[]>();
for (const op of ops) {
const label = roleEditorSectionLabel(op);
if (!sections.has(label)) sections.set(label, []);
sections.get(label)!.push(op);
}
result[mod] = [...sections.entries()]
.sort(([a], [b]) => comparePermissionSectionLabels(a, b))
.map(([label, sectionOps]) => ({
label,
ops: sectionOps,
}));
}
return result;
});
const roleEditorModuleCount = (sections: RoleEditorSection[]): number =>
sections.reduce((total, section) => total + section.ops.length, 0);
const roleEditorEnabledCount = (ops: RoleEditorOp[]): number =>
ops.filter((op) => roleEditorDraft.value[op.operation_key]).length;
const canEditSelectedRole = computed(() => {
if (editingRole.value === "ADMIN") return false;
if (isAdmin.value) return true;
@@ -1060,11 +1109,6 @@ const initRoleEditor = () => {
roleEditorDraft.value = draft;
};
const roleEditorSelectAll = (val: boolean) => {
const ops = Object.values(roleEditorGrouped.value).flat();
for (const op of ops) roleEditorDraft.value[op.operation_key] = val;
};
const saveRoleEditor = async () => {
if (!selectedStudyId.value || !apiMatrix.value) return;
if (!canEditSelectedRole.value) {
@@ -1076,7 +1120,6 @@ const saveRoleEditor = async () => {
const payload = flattenRolePermissions(editingRole.value, roleEditorDraft.value);
const res = await updateApiEndpointPermissions(selectedStudyId.value, payload);
apiMatrix.value = res.data;
dirty.value = false;
const savedRole = editingRole.value;
editingRole.value = "";
ElMessage.success(`${roleLabel(savedRole)} 权限已保存`);
@@ -1150,11 +1193,6 @@ onMounted(async () => {
line-height: 1.3;
}
.perm-subtitle {
font-size: 13px;
color: #64748b;
}
.perm-header-actions {
display: flex;
align-items: center;
@@ -1515,28 +1553,27 @@ onMounted(async () => {
}
/* ── 抽屉内部 ── */
.filter-spacer { flex: 1; }
.drawer-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 4px;
padding: 10px 14px;
background: #f8fafc;
border-radius: 10px;
border: 1px solid #f1f5f9;
.drawer-tabs-shell {
position: relative;
min-height: calc(100vh - 110px);
}
.drawer-tabs { padding-top: 4px; }
.drawer-footer {
.drawer-tab-actions {
position: absolute;
right: 0;
top: 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding-top: 16px;
margin-top: 16px;
border-top: 1px solid #f1f5f9;
min-width: 160px;
height: 48px;
background: #fff;
}
.template-table {
margin-top: 12px;
}
/* ── 生效角色列表 ── */
@@ -1670,60 +1707,195 @@ onMounted(async () => {
flex-direction: column;
}
.role-editor-modules { flex: 1; }
.role-editor-modules {
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
}
.role-editor-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
padding: 10px 14px;
background: #f8fafc;
border-radius: 10px;
border: 1px solid #f1f5f9;
margin-bottom: 18px;
padding: 4px;
background: #fff;
border-radius: 12px;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.role-editor-module { margin-bottom: 20px; }
.role-editor-search {
flex: 1;
}
:deep(.role-editor-search .el-input__wrapper) {
background: transparent;
border-radius: 8px;
box-shadow: none;
padding: 4px 12px;
transition: background 0.18s ease;
}
:deep(.role-editor-search .el-input__wrapper:hover) {
background: #f8fafc;
box-shadow: none;
}
:deep(.role-editor-search .el-input__wrapper.is-focus) {
background: #eff6ff;
box-shadow: none;
}
.role-editor-module {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
transition: box-shadow 0.18s ease, border-color 0.18s ease;
}
.role-editor-module:hover {
border-color: #cbd5e1;
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.06);
}
.role-editor-module-header {
position: relative;
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
color: #1a2332;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid #f1f5f9;
gap: 10px;
font-size: 14px;
font-weight: 700;
color: #0f172a;
padding: 14px 18px 14px 22px;
background: linear-gradient(135deg, #f1f5ff 0%, #f8fafc 100%);
border-bottom: 1px solid #e2e8f0;
letter-spacing: 0.2px;
}
.role-editor-module-header::before {
content: "";
position: absolute;
left: 0;
top: 12px;
bottom: 12px;
width: 4px;
border-radius: 0 4px 4px 0;
background: linear-gradient(180deg, #3b82f6 0%, #60a5fa 100%);
}
.role-editor-ops {
display: flex;
flex-direction: column;
gap: 4px;
gap: 2px;
padding: 8px 6px 6px;
}
.role-editor-collapse {
border: 0;
background: #fff;
}
.role-editor-section {
background: #fff;
}
.role-editor-section-title {
font-size: 13px;
font-weight: 600;
color: #1e293b;
margin-right: 8px;
display: inline-flex;
align-items: center;
gap: 8px;
}
.role-editor-section-title::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: #94a3b8;
flex-shrink: 0;
}
.role-editor-section-summary {
margin-left: auto;
margin-right: 14px;
font-size: 12px;
color: #64748b;
font-weight: 500;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
:deep(.role-editor-section .el-collapse-item__header) {
height: 46px;
padding: 0 18px;
background: #fff;
border-bottom: 1px solid #f1f5f9;
font-weight: 600;
transition: background 0.15s ease;
}
:deep(.role-editor-section:last-child .el-collapse-item__header) {
border-bottom: 0;
}
:deep(.role-editor-section .el-collapse-item__header:hover) {
background: #f8fafc;
}
:deep(.role-editor-section .el-collapse-item__header.is-active) {
background: #f8fafc;
border-bottom-color: #e2e8f0;
}
:deep(.role-editor-section .el-collapse-item__header.is-active .role-editor-section-title::before) {
background: #3b82f6;
}
:deep(.role-editor-section .el-collapse-item__arrow) {
margin-right: 0;
color: #94a3b8;
}
:deep(.role-editor-section .el-collapse-item__content) {
padding: 4px 8px 10px;
background: #fafbfc;
}
:deep(.role-editor-section .el-collapse-item__wrap) {
border-bottom: 0;
background: #fafbfc;
}
.role-editor-op-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
transition: background 0.12s ease;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
transition: background 0.15s ease;
}
.role-editor-op-row:hover {
background: #f8fafc;
background: #eef2ff;
}
.op-action-tag {
flex-shrink: 0;
width: 42px;
width: 48px;
text-align: center;
font-weight: 600;
border-radius: 6px;
}
.op-desc {
font-size: 13px;
color: #334155;
color: #1e293b;
}
</style>
@@ -47,11 +47,12 @@ describe("ProjectMembers user directory access", () => {
expect(source).not.toContain(':label="TEXT.enums.userRole.PM"');
});
it("does not offer QA as a project member role preset", () => {
it("offers QA and CTA as project member role presets", () => {
const source = readProjectMembers();
expect(source).not.toContain('value="QA"');
expect(source).not.toContain("canAssignRole('QA')");
expect(source).not.toContain("QA: 60");
expect(source).toContain('"QA"');
expect(source).toContain('"CTA"');
expect(source).toContain("QA: 60");
expect(source).toContain("CTA: 40");
});
});
+3 -3
View File
@@ -132,15 +132,15 @@ const newMember = reactive({
});
const canManageMembers = computed(() => permission.can("project.members.manage"));
const projectRole = computed(() => project.value?.role_in_study || "");
const ROLE_KEYS = ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "ADMIN"];
const ROLE_KEYS = ["PM", "CRA", "PV", "QA", "CTA", "ADMIN"];
const roleOptions = computed(() => roleOptionsFor(ROLE_KEYS));
const roleRank: Record<string, number> = {
ADMIN: 100,
PM: 80,
PV: 50,
MEDICAL_REVIEW: 50,
QA: 60,
CRA: 40,
IMP: 40,
CTA: 40,
};
const currentRoleRank = computed(() => auth.user?.is_admin ? Number.POSITIVE_INFINITY : roleRank[projectRole.value] || 0);
const canAssignRole = (role: string) => (auth.user?.is_admin ? true : (roleRank[role] || 0) <= currentRoleRank.value);