feat: refine subject visits and project workflows
Add early termination visit workflow with ordering, non-applicable visit handling, visit window display, and medication adherence support. Extend monitoring visit issue template fields, site scoping, setup draft project info handling, login security UI, attachment behavior, and related tests/migrations.
This commit is contained in:
+157
-28
@@ -263,6 +263,13 @@ def _to_date_text(value: date | None) -> str:
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
def _project_snapshot_has_draft_values(snapshot: ProjectPublishSnapshot | None) -> bool:
|
||||
if not snapshot:
|
||||
return False
|
||||
data = snapshot.model_dump(mode="json")
|
||||
return any(value not in ("", None, []) for value in data.values())
|
||||
|
||||
|
||||
def _build_project_publish_snapshot(study) -> ProjectPublishSnapshot:
|
||||
return ProjectPublishSnapshot(
|
||||
code=getattr(study, "code", None) or "",
|
||||
@@ -288,6 +295,61 @@ def _build_project_publish_snapshot(study) -> ProjectPublishSnapshot:
|
||||
)
|
||||
|
||||
|
||||
def _resolve_setup_project_snapshot(setup_data: StudySetupConfigData, study) -> ProjectPublishSnapshot:
|
||||
if _project_snapshot_has_draft_values(setup_data.projectInfo):
|
||||
return setup_data.projectInfo
|
||||
return _build_project_publish_snapshot(study)
|
||||
|
||||
|
||||
def _parse_optional_snapshot_date(value: str) -> date | None:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
return date.fromisoformat(text)
|
||||
|
||||
|
||||
def _normalize_optional_snapshot_text(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = value.strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _snapshot_visit_schedule(snapshot: ProjectPublishSnapshot) -> list[dict[str, int | str]]:
|
||||
return [item.model_dump(mode="json") for item in snapshot.visit_schedule]
|
||||
|
||||
|
||||
def _apply_project_publish_snapshot_to_study(study, snapshot: ProjectPublishSnapshot) -> bool:
|
||||
next_values = {
|
||||
"code": snapshot.code.strip(),
|
||||
"name": snapshot.name.strip(),
|
||||
"project_full_name": _normalize_optional_snapshot_text(snapshot.project_full_name),
|
||||
"sponsor": _normalize_optional_snapshot_text(snapshot.sponsor),
|
||||
"protocol_no": _normalize_optional_snapshot_text(snapshot.protocol_no),
|
||||
"lead_unit": _normalize_optional_snapshot_text(snapshot.lead_unit),
|
||||
"principal_investigator": _normalize_optional_snapshot_text(snapshot.principal_investigator),
|
||||
"main_pm": _normalize_optional_snapshot_text(snapshot.main_pm),
|
||||
"research_analysis": _normalize_optional_snapshot_text(snapshot.research_analysis),
|
||||
"research_product": _normalize_optional_snapshot_text(snapshot.research_product),
|
||||
"control_product": _normalize_optional_snapshot_text(snapshot.control_product),
|
||||
"indication": _normalize_optional_snapshot_text(snapshot.indication),
|
||||
"research_population": _normalize_optional_snapshot_text(snapshot.research_population),
|
||||
"research_design": _normalize_optional_snapshot_text(snapshot.research_design),
|
||||
"plan_start_date": _parse_optional_snapshot_date(snapshot.plan_start_date),
|
||||
"plan_end_date": _parse_optional_snapshot_date(snapshot.plan_end_date),
|
||||
"planned_site_count": snapshot.planned_site_count,
|
||||
"planned_enrollment_count": snapshot.planned_enrollment_count,
|
||||
"status": snapshot.status or "DRAFT",
|
||||
"visit_schedule": _snapshot_visit_schedule(snapshot),
|
||||
}
|
||||
changed = False
|
||||
for field, value in next_values.items():
|
||||
if getattr(study, field, None) != value:
|
||||
setattr(study, field, value)
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def _to_setup_config_read(
|
||||
record,
|
||||
*,
|
||||
@@ -360,6 +422,63 @@ def _ensure_study_timeline_valid(
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="项目计划结束日期不能早于开始日期")
|
||||
|
||||
|
||||
async def _validate_setup_project_snapshot(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
snapshot: ProjectPublishSnapshot,
|
||||
strict_required: bool = True,
|
||||
) -> None:
|
||||
errors: list[dict[str, str]] = []
|
||||
code = snapshot.code.strip()
|
||||
name = snapshot.name.strip()
|
||||
if strict_required and not code:
|
||||
errors.append({"field": "projectInfo.code", "message": "项目编号不能为空"})
|
||||
if strict_required and not name:
|
||||
errors.append({"field": "projectInfo.name", "message": "项目名称不能为空"})
|
||||
try:
|
||||
plan_start = _parse_optional_snapshot_date(snapshot.plan_start_date)
|
||||
plan_end = _parse_optional_snapshot_date(snapshot.plan_end_date)
|
||||
except ValueError:
|
||||
errors.append({"field": "projectInfo.plan_start_date", "message": "项目计划日期格式应为 YYYY-MM-DD"})
|
||||
plan_start = None
|
||||
plan_end = None
|
||||
if plan_start and plan_end and plan_start > plan_end:
|
||||
errors.append({"field": "projectInfo.plan_end_date", "message": "项目计划结束日期不能早于开始日期"})
|
||||
|
||||
seen_visit_codes: set[str] = set()
|
||||
for index, item in enumerate(snapshot.visit_schedule):
|
||||
row_prefix = f"projectInfo.visit_schedule[{index}]"
|
||||
visit_code = item.visit_code.strip()
|
||||
has_visit_values = bool(visit_code) or any(
|
||||
value != 0
|
||||
for value in (item.baseline_offset_days, item.window_before_days, item.window_after_days)
|
||||
)
|
||||
if strict_required and not visit_code:
|
||||
errors.append({"field": f"{row_prefix}.visit_code", "message": "访视编号不能为空"})
|
||||
if not has_visit_values:
|
||||
continue
|
||||
if visit_code:
|
||||
if len(visit_code) > 50:
|
||||
errors.append({"field": f"{row_prefix}.visit_code", "message": "访视编号不能超过50个字符"})
|
||||
if visit_code in seen_visit_codes:
|
||||
errors.append({"field": f"{row_prefix}.visit_code", "message": f"访视编号重复:{visit_code}"})
|
||||
seen_visit_codes.add(visit_code)
|
||||
if item.baseline_offset_days < 0 or item.baseline_offset_days > 3650:
|
||||
errors.append({"field": f"{row_prefix}.baseline_offset_days", "message": "基线偏移天数应在0到3650之间"})
|
||||
if item.window_before_days < 0 or item.window_before_days > 365:
|
||||
errors.append({"field": f"{row_prefix}.window_before_days", "message": "窗口前天数应在0到365之间"})
|
||||
if item.window_after_days < 0 or item.window_after_days > 365:
|
||||
errors.append({"field": f"{row_prefix}.window_after_days", "message": "窗口后天数应在0到365之间"})
|
||||
|
||||
if errors:
|
||||
_raise_validation_error(errors)
|
||||
if code:
|
||||
existing = await study_crud.get_by_code(db, code)
|
||||
if existing and existing.id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目编号已存在")
|
||||
|
||||
|
||||
def _resolve_version_label(record: Any) -> str:
|
||||
branch_name = (getattr(record, "branch_name", None) or "main").strip() or "main"
|
||||
display_version = int(getattr(record, "display_version", 0) or 0)
|
||||
@@ -897,18 +1016,23 @@ async def upsert_study_setup_config(
|
||||
|
||||
sites = await site_crud.list_by_study(db, study_id, skip=0, limit=1000, include_inactive=True)
|
||||
site_lookup = {str(site.id): site.name or "" for site in sites}
|
||||
setup_project_snapshot = _resolve_setup_project_snapshot(payload.data, study)
|
||||
await _validate_setup_project_snapshot(db, study_id=study_id, snapshot=setup_project_snapshot, strict_required=False)
|
||||
setup_plan_start = _parse_optional_snapshot_date(setup_project_snapshot.plan_start_date)
|
||||
setup_plan_end = _parse_optional_snapshot_date(setup_project_snapshot.plan_end_date)
|
||||
_validate_setup_data(
|
||||
payload.data,
|
||||
site_lookup,
|
||||
project_plan_start=getattr(study, "plan_start_date", None),
|
||||
project_plan_end=getattr(study, "plan_end_date", None),
|
||||
project_plan_start=setup_plan_start,
|
||||
project_plan_end=setup_plan_end,
|
||||
strict_required=False,
|
||||
)
|
||||
|
||||
existing = await study_setup_config_crud.get_by_study(db, study_id)
|
||||
old_config = dict(existing.config or {}) if existing else {}
|
||||
force_draft = bool(payload.force_draft)
|
||||
if existing and existing.publish_status == "PUBLISHED" and existing.published_project_snapshot:
|
||||
current_project_snapshot = _build_project_publish_snapshot(study).model_dump(mode="json")
|
||||
current_project_snapshot = setup_project_snapshot.model_dump(mode="json")
|
||||
if current_project_snapshot != dict(existing.published_project_snapshot or {}):
|
||||
force_draft = True
|
||||
record, conflict = await study_setup_config_crud.upsert(
|
||||
@@ -971,7 +1095,12 @@ async def publish_study_setup_config(
|
||||
record = await study_setup_config_crud.get_by_study(db, study_id)
|
||||
if not record:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="请先保存立项配置草稿")
|
||||
current_project_snapshot_for_publish = _build_project_publish_snapshot(study).model_dump(mode="json")
|
||||
setup_data = StudySetupConfigData.model_validate(record.config or {})
|
||||
project_snapshot_for_publish = _resolve_setup_project_snapshot(setup_data, study)
|
||||
await _validate_setup_project_snapshot(db, study_id=study_id, snapshot=project_snapshot_for_publish)
|
||||
setup_plan_start = _parse_optional_snapshot_date(project_snapshot_for_publish.plan_start_date)
|
||||
setup_plan_end = _parse_optional_snapshot_date(project_snapshot_for_publish.plan_end_date)
|
||||
current_project_snapshot_for_publish = project_snapshot_for_publish.model_dump(mode="json")
|
||||
force_create_snapshot = bool(
|
||||
(record.published_project_snapshot or {}) != current_project_snapshot_for_publish
|
||||
)
|
||||
@@ -979,10 +1108,10 @@ async def publish_study_setup_config(
|
||||
sites = await site_crud.list_by_study(db, study_id, skip=0, limit=1000, include_inactive=True)
|
||||
site_lookup = {str(site.id): site.name or "" for site in sites}
|
||||
_validate_setup_data(
|
||||
StudySetupConfigData.model_validate(record.config or {}),
|
||||
setup_data,
|
||||
site_lookup,
|
||||
project_plan_start=getattr(study, "plan_start_date", None),
|
||||
project_plan_end=getattr(study, "plan_end_date", None),
|
||||
project_plan_start=setup_plan_start,
|
||||
project_plan_end=setup_plan_end,
|
||||
)
|
||||
|
||||
published, conflict = await study_setup_config_crud.publish(
|
||||
@@ -1001,6 +1130,7 @@ async def publish_study_setup_config(
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="配置发布失败")
|
||||
|
||||
try:
|
||||
_apply_project_publish_snapshot_to_study(study, project_snapshot_for_publish)
|
||||
projection = await apply_setup_projection_on_publish(
|
||||
db,
|
||||
study_id=study_id,
|
||||
@@ -1017,16 +1147,14 @@ async def publish_study_setup_config(
|
||||
for item in projection.skipped_items
|
||||
],
|
||||
)
|
||||
study_after_publish = await study_crud.get(db, study_id)
|
||||
if study_after_publish:
|
||||
project_snapshot = _build_project_publish_snapshot(study_after_publish).model_dump(mode="json")
|
||||
published.published_project_snapshot = project_snapshot
|
||||
await study_setup_config_crud.set_version_project_snapshot(
|
||||
db,
|
||||
study_id,
|
||||
version=published.version,
|
||||
project_snapshot=project_snapshot,
|
||||
)
|
||||
project_snapshot = project_snapshot_for_publish.model_dump(mode="json")
|
||||
published.published_project_snapshot = project_snapshot
|
||||
await study_setup_config_crud.set_version_project_snapshot(
|
||||
db,
|
||||
study_id,
|
||||
version=published.version,
|
||||
project_snapshot=project_snapshot,
|
||||
)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
@@ -1362,10 +1490,13 @@ async def merge_study_setup_config_to_main(
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="合并主分支失败")
|
||||
|
||||
try:
|
||||
merged_setup_data = StudySetupConfigData.model_validate(merged.published_config or {})
|
||||
merged_project_snapshot = _resolve_setup_project_snapshot(merged_setup_data, study)
|
||||
_apply_project_publish_snapshot_to_study(study, merged_project_snapshot)
|
||||
projection = await apply_setup_projection_on_publish(
|
||||
db,
|
||||
study_id=study_id,
|
||||
setup_data=StudySetupConfigData.model_validate(merged.published_config or {}),
|
||||
setup_data=merged_setup_data,
|
||||
operator_id=current_user.id,
|
||||
)
|
||||
projection_summary = SetupProjectionSummary(
|
||||
@@ -1375,16 +1506,14 @@ async def merge_study_setup_config_to_main(
|
||||
warnings=projection.warnings,
|
||||
skipped_items=[{"site_id": item.site_id, "reason": item.reason} for item in projection.skipped_items],
|
||||
)
|
||||
study_after_publish = await study_crud.get(db, study_id)
|
||||
if study_after_publish:
|
||||
project_snapshot = _build_project_publish_snapshot(study_after_publish).model_dump(mode="json")
|
||||
merged.published_project_snapshot = project_snapshot
|
||||
await study_setup_config_crud.set_version_project_snapshot(
|
||||
db,
|
||||
study_id,
|
||||
version=merged.version,
|
||||
project_snapshot=project_snapshot,
|
||||
)
|
||||
project_snapshot = merged_project_snapshot.model_dump(mode="json")
|
||||
merged.published_project_snapshot = project_snapshot
|
||||
await study_setup_config_crud.set_version_project_snapshot(
|
||||
db,
|
||||
study_id,
|
||||
version=merged.version,
|
||||
project_snapshot=project_snapshot,
|
||||
)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
|
||||
Reference in New Issue
Block a user