中文:收口权限与中心/立项配置改造

This commit is contained in:
Cheng Zhou
2026-05-12 10:16:52 +08:00
parent 6e90370a5f
commit 77e842637d
67 changed files with 706 additions and 669 deletions
+3 -1
View File
@@ -196,7 +196,9 @@ async def update_me(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前密码不正确")
update_data = {
"full_name": payload.full_name if payload.full_name is not None else current_user.full_name,
"department": payload.department if payload.department is not None else current_user.department,
"clinical_department": (
payload.clinical_department if payload.clinical_department is not None else current_user.clinical_department
),
"password": payload.password if payload.password else None,
"avatar_url": payload.avatar_url if payload.avatar_url is not None else current_user.avatar_url,
}
+11 -2
View File
@@ -4,6 +4,7 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member
from app.crud import member as member_crud
from app.models.milestone import Milestone
from app.schemas.progress import StudyProgressRead
from app.schemas.visit import VisitLostItem
@@ -14,6 +15,10 @@ from app.crud import overview as overview_crud
router = APIRouter()
def _role_value(user) -> str:
return user.role.value if hasattr(user.role, "value") else str(user.role)
@router.get("/progress", response_model=StudyProgressRead, dependencies=[Depends(require_study_member())])
async def get_progress(
study_id: uuid.UUID,
@@ -68,8 +73,12 @@ async def get_center_summary(
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[CenterSummaryItem]:
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value not in {"ADMIN", "PM", "CRA"}:
role_value = _role_value(current_user)
member_role = None
if role_value != "ADMIN":
membership = await member_crud.get_member(db, study_id, current_user.id)
member_role = membership.role_in_study if membership and membership.is_active else None
if role_value != "ADMIN" and member_role not in {"PM", "CRA"}:
return []
cra_scope = await get_cra_site_scope(db, study_id, current_user)
scope_ids = cra_scope[0] if cra_scope else None
+1 -1
View File
@@ -54,7 +54,7 @@ async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, curren
membership = await member_crud.get_member(db, project_id, current_user.id)
if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
if write and membership.role_in_study != "PM":
if write and membership.role_in_study not in {"ADMIN", "PM"}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足")
return membership
+1 -1
View File
@@ -38,7 +38,7 @@ async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, curren
membership = await member_crud.get_member(db, project_id, current_user.id)
if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
if write and membership.role_in_study != "PM":
if write and membership.role_in_study not in {"ADMIN", "PM"}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足")
return membership
+3 -3
View File
@@ -26,7 +26,7 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
"/",
response_model=StudyMemberRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())],
)
async def add_member(
study_id: uuid.UUID,
@@ -115,7 +115,7 @@ async def list_members(
@router.patch(
"/{member_id}",
response_model=StudyMemberRead,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())],
)
async def update_member(
study_id: uuid.UUID,
@@ -147,7 +147,7 @@ async def update_member(
@router.delete(
"/{member_id}",
response_model=StudyMemberRead,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())],
)
async def remove_member(
study_id: uuid.UUID,
+5 -2
View File
@@ -26,7 +26,7 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
"/",
response_model=SiteRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())],
)
async def create_site(
study_id: uuid.UUID,
@@ -91,7 +91,7 @@ async def list_sites(
@router.patch(
"/{site_id}",
response_model=SiteRead,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())],
)
async def update_site(
study_id: uuid.UUID,
@@ -108,6 +108,7 @@ async def update_site(
"name": site.name,
"city": site.city,
"pi_name": site.pi_name,
"phone": site.phone,
"contact": site.contact,
"is_active": site.is_active,
}
@@ -116,6 +117,7 @@ async def update_site(
"name": updated.name,
"city": updated.city,
"pi_name": updated.pi_name,
"phone": updated.phone,
"contact": updated.contact,
"is_active": updated.is_active,
}
@@ -155,6 +157,7 @@ async def delete_site(
"name": site.name,
"city": site.city,
"pi_name": site.pi_name,
"phone": site.phone,
"contact": site.contact,
"is_active": site.is_active,
}
+31 -35
View File
@@ -42,6 +42,16 @@ from app.utils.pagination import paginate
router = APIRouter()
def _study_read_with_role(study, role_in_study: str | None = None) -> StudyRead:
data = StudyRead.model_validate(study)
data.role_in_study = role_in_study
return data
def _role_value(user) -> str:
return user.role.value if hasattr(user.role, "value") else str(user.role)
def _raise_validation_error(errors: list[dict[str, str]]) -> None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
@@ -196,19 +206,6 @@ def _validate_setup_data(
if total_site_target > plan.totalTarget:
errors.append({"field": "siteEnrollmentPlans", "message": "中心计划总例数不能超过项目总入组例数"})
for index, row in enumerate(payload.monitoringStrategies):
row_prefix = f"monitoringStrategies[{index}]"
if not row.id:
errors.append({"field": f"{row_prefix}.id", "message": "ID不能为空"})
if _is_empty_row([row.strategyType, row.detail, row.frequency]):
continue
if row.strategyType not in allowed_strategy_types:
errors.append({"field": f"{row_prefix}.strategyType", "message": "监查类型不合法"})
if not row.detail.strip():
errors.append({"field": f"{row_prefix}.detail", "message": "策略详情不能为空"})
if row.frequency not in {"不限", "按触发", "每月1次"} and re.fullmatch(r"\d+次", row.frequency) is None:
errors.append({"field": f"{row_prefix}.frequency", "message": "监查次数格式应为“不限”/“按触发”/“每月1次”或“N次”"})
for index, row in enumerate(payload.centerConfirm):
row_prefix = f"centerConfirm[{index}]"
if not row.id:
@@ -253,7 +250,6 @@ def _build_default_setup_config_from_study(study, sites: list) -> StudySetupConf
},
siteMilestones=[],
siteEnrollmentPlans=[],
monitoringStrategies=[],
centerConfirm=[],
)
@@ -602,7 +598,6 @@ _SETUP_MODULE_KEYS = (
"enrollmentPlan",
"siteMilestones",
"siteEnrollmentPlans",
"monitoringStrategies",
"centerConfirm",
)
@@ -611,7 +606,6 @@ _SETUP_MODULE_LABELS = {
"enrollmentPlan": "项目入组计划",
"siteMilestones": "中心里程碑",
"siteEnrollmentPlans": "中心入组计划",
"monitoringStrategies": "监查策略",
"centerConfirm": "中心确认",
}
@@ -635,11 +629,6 @@ _SETUP_FIELD_LABELS = {
"siteName": "中心名称",
"target": "计划例数",
"note": "备注",
"strategyType": "监查类型",
"detail": "策略详情",
"frequency": "监查频次",
"updatedAt": "更新时间",
"enabled": "启用状态",
"confirmer": "确认人",
"confirmStatus": "确认状态",
"confirmDate": "确认日期",
@@ -676,8 +665,6 @@ def _setup_row_identity(module_key: str, row: Any, index: int) -> str:
return str(row.get("milestone") or "").strip() or f"{index + 1}"
if module_key in {"siteEnrollmentPlans", "centerConfirm"}:
return str(row.get("siteName") or row.get("siteId") or "").strip() or f"{index + 1}"
if module_key == "monitoringStrategies":
return str(row.get("strategyType") or "").strip() or f"{index + 1}"
return f"{index + 1}"
@@ -794,30 +781,39 @@ async def list_studies(
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> PaginatedResponse[StudyRead]:
if current_user.role == "ADMIN":
if _role_value(current_user) == "ADMIN":
studies = await study_crud.list_studies(db, skip=skip, limit=limit)
total = await study_crud.list_studies(db, skip=0, limit=10_000_000)
items = [_study_read_with_role(study) for study in studies]
else:
studies = await study_crud.list_studies_for_user(db, current_user.id, skip=skip, limit=limit)
total = await study_crud.list_studies_for_user(db, current_user.id, skip=0, limit=10_000_000)
return paginate(list(studies), total=len(total))
items = []
for study in studies:
member = await member_crud.get_member(db, study.id, current_user.id)
items.append(_study_read_with_role(study, member.role_in_study if member else None))
return paginate(items, total=len(total))
@router.get("/{study_id}", response_model=StudyRead, dependencies=[Depends(require_study_member())])
async def get_study(
study_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> StudyRead:
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
return study
if _role_value(current_user) == "ADMIN":
return _study_read_with_role(study)
member = await member_crud.get_member(db, study_id, current_user.id)
return _study_read_with_role(study, member.role_in_study if member else None)
@router.patch(
"/{study_id}",
response_model=StudyRead,
dependencies=[Depends(require_study_roles(["PM"]))],
dependencies=[Depends(require_study_roles(["ADMIN", "PM"]))],
)
async def update_study(
study_id: uuid.UUID,
@@ -994,7 +990,7 @@ async def get_study_setup_config(
@router.put(
"/{study_id}/setup-config",
response_model=StudySetupConfigRead,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())],
)
async def upsert_study_setup_config(
study_id: uuid.UUID,
@@ -1072,7 +1068,7 @@ async def upsert_study_setup_config(
@router.post(
"/{study_id}/setup-config/publish",
response_model=StudySetupConfigRead,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())],
)
async def publish_study_setup_config(
study_id: uuid.UUID,
@@ -1225,7 +1221,7 @@ async def list_study_setup_config_versions(
@router.post(
"/{study_id}/setup-config/rollback",
response_model=StudySetupConfigRead,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())],
)
async def rollback_study_setup_config(
study_id: uuid.UUID,
@@ -1283,7 +1279,7 @@ async def rollback_study_setup_config(
@router.post(
"/{study_id}/setup-config/draft/checkout-branch",
response_model=StudySetupConfigRead,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())],
)
async def checkout_study_setup_config_branch_draft(
study_id: uuid.UUID,
@@ -1342,7 +1338,7 @@ async def checkout_study_setup_config_branch_draft(
@router.post(
"/{study_id}/setup-config/draft/clear",
response_model=StudySetupConfigRead,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())],
)
async def clear_study_setup_config_draft(
study_id: uuid.UUID,
@@ -1394,7 +1390,7 @@ async def clear_study_setup_config_draft(
@router.post(
"/{study_id}/setup-config/draft/refill",
response_model=StudySetupConfigRead,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())],
)
async def refill_study_setup_config_draft(
study_id: uuid.UUID,
@@ -1448,7 +1444,7 @@ async def refill_study_setup_config_draft(
@router.post(
"/{study_id}/setup-config/merge-main",
response_model=StudySetupConfigRead,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())],
)
async def merge_study_setup_config_to_main(
study_id: uuid.UUID,
@@ -1543,7 +1539,7 @@ async def merge_study_setup_config_to_main(
@router.delete(
"/{study_id}/setup-config/versions/{target_version}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())],
)
async def delete_study_setup_config_version(
study_id: uuid.UUID,