修复项目配置草稿原子保存逻辑

This commit is contained in:
Cheng Zhou
2026-05-11 11:03:06 +08:00
parent 8d061a1520
commit 20d45cfdfe
6 changed files with 333 additions and 59 deletions
@@ -0,0 +1,157 @@
"""backfill project info in setup drafts
Revision ID: 20260511_01
Revises: 20260509_03
Create Date: 2026-05-11 10:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260511_01"
down_revision: Union[str, None] = "20260509_03"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
EMPTY_PROJECT_INFO_JSON = """{
"code": "",
"name": "",
"project_full_name": "",
"sponsor": "",
"protocol_no": "",
"lead_unit": "",
"principal_investigator": "",
"main_pm": "",
"research_analysis": "",
"research_product": "",
"control_product": "",
"indication": "",
"research_population": "",
"research_design": "",
"plan_start_date": "",
"plan_end_date": "",
"planned_site_count": null,
"planned_enrollment_count": null,
"status": "",
"visit_schedule": []
}"""
def _has_table(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in set(inspector.get_table_names())
def _has_columns(inspector: sa.Inspector, table_name: str, column_names: set[str]) -> bool:
existing = {col["name"] for col in inspector.get_columns(table_name)}
return column_names.issubset(existing)
def _backfill_config_column(table_name: str, config_column: str, *, require_existing_setup_content: bool) -> None:
content_filter = ""
if require_existing_setup_content:
content_filter = f"""
AND (
jsonb_array_length(COALESCE(setup.{config_column}->'projectMilestones', '[]'::jsonb)) > 0
OR COALESCE(setup.{config_column}->'enrollmentPlan', '{{}}'::jsonb) <> '{{"totalTarget": 0, "startDate": "", "endDate": "", "monthlyGoalNote": "", "stageBreakdown": ""}}'::jsonb
OR jsonb_array_length(COALESCE(setup.{config_column}->'siteMilestones', '[]'::jsonb)) > 0
OR jsonb_array_length(COALESCE(setup.{config_column}->'siteEnrollmentPlans', '[]'::jsonb)) > 0
OR jsonb_array_length(COALESCE(setup.{config_column}->'monitoringStrategies', '[]'::jsonb)) > 0
OR jsonb_array_length(COALESCE(setup.{config_column}->'centerConfirm', '[]'::jsonb)) > 0
)
"""
op.execute(
sa.text(
f"""
UPDATE {table_name} AS setup
SET {config_column} = jsonb_set(
setup.{config_column},
'{{projectInfo}}',
jsonb_build_object(
'code', COALESCE(studies.code, ''),
'name', COALESCE(studies.name, ''),
'project_full_name', COALESCE(studies.project_full_name, ''),
'sponsor', COALESCE(studies.sponsor, ''),
'protocol_no', COALESCE(studies.protocol_no, ''),
'lead_unit', COALESCE(studies.lead_unit, ''),
'principal_investigator', COALESCE(studies.principal_investigator, ''),
'main_pm', COALESCE(studies.main_pm, ''),
'research_analysis', COALESCE(studies.research_analysis, ''),
'research_product', COALESCE(studies.research_product, ''),
'control_product', COALESCE(studies.control_product, ''),
'indication', COALESCE(studies.indication, ''),
'research_population', COALESCE(studies.research_population, ''),
'research_design', COALESCE(studies.research_design, ''),
'plan_start_date', COALESCE(to_char(studies.plan_start_date, 'YYYY-MM-DD'), ''),
'plan_end_date', COALESCE(to_char(studies.plan_end_date, 'YYYY-MM-DD'), ''),
'planned_site_count', to_jsonb(studies.planned_site_count),
'planned_enrollment_count', to_jsonb(studies.planned_enrollment_count),
'status', COALESCE(studies.status, ''),
'visit_schedule', COALESCE(studies.visit_schedule::jsonb, '[]'::jsonb)
),
true
)
FROM studies
WHERE setup.study_id = studies.id
AND setup.{config_column} IS NOT NULL
AND (
NOT setup.{config_column} ? 'projectInfo'
OR setup.{config_column}->'projectInfo' = CAST(:empty_project_info AS jsonb)
)
{content_filter}
"""
).bindparams(empty_project_info=EMPTY_PROJECT_INFO_JSON)
)
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
required_study_columns = {
"id",
"code",
"name",
"project_full_name",
"sponsor",
"protocol_no",
"lead_unit",
"principal_investigator",
"main_pm",
"research_analysis",
"research_product",
"control_product",
"indication",
"research_population",
"research_design",
"plan_start_date",
"plan_end_date",
"planned_site_count",
"planned_enrollment_count",
"status",
"visit_schedule",
}
if not _has_table(inspector, "studies") or not _has_columns(inspector, "studies", required_study_columns):
return
if _has_table(inspector, "study_setup_configs") and _has_columns(
inspector, "study_setup_configs", {"study_id", "config"}
):
_backfill_config_column("study_setup_configs", "config", require_existing_setup_content=True)
if _has_table(inspector, "study_setup_configs") and _has_columns(
inspector, "study_setup_configs", {"study_id", "published_config"}
):
_backfill_config_column("study_setup_configs", "published_config", require_existing_setup_content=False)
if _has_table(inspector, "study_setup_config_versions") and _has_columns(
inspector, "study_setup_config_versions", {"study_id", "config"}
):
_backfill_config_column("study_setup_config_versions", "config", require_existing_setup_content=False)
def downgrade() -> None:
pass
+2 -10
View File
@@ -242,6 +242,7 @@ def _build_default_setup_config_from_study(study, sites: list) -> StudySetupConf
if total_target is None:
total_target = 0
return StudySetupConfigData(
projectInfo=_build_project_publish_snapshot(study),
projectMilestones=[],
enrollmentPlan={
"totalTarget": total_target,
@@ -263,13 +264,6 @@ 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 "",
@@ -296,9 +290,7 @@ 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)
return setup_data.projectInfo
def _parse_optional_snapshot_date(value: str) -> date | None:
@@ -3,7 +3,12 @@ from types import SimpleNamespace
from fastapi import HTTPException
from app.api.v1.studies import _apply_project_publish_snapshot_to_study, _validate_setup_data
from app.api.v1.studies import (
_apply_project_publish_snapshot_to_study,
_build_default_setup_config_from_study,
_resolve_setup_project_snapshot,
_validate_setup_data,
)
from app.schemas.study_setup_config import ProjectPublishSnapshot, StudySetupConfigData
@@ -172,6 +177,78 @@ def test_project_info_is_part_of_setup_config_payload():
assert payload["enrollmentPlan"]["totalTarget"] == 120
def test_default_setup_config_includes_project_info_snapshot():
study = SimpleNamespace(
code="PRJ-DEFAULT",
name="默认项目",
project_full_name="默认项目全称",
sponsor="申办方",
protocol_no="PROTO-DEFAULT",
lead_unit="组长单位",
principal_investigator="PI",
main_pm="PM",
research_analysis="III期",
research_product="研究药物",
control_product="对照药物",
indication="适应症",
research_population="研究人群",
research_design="随机双盲",
plan_start_date=date(2026, 6, 1),
plan_end_date=date(2026, 12, 31),
planned_site_count=8,
planned_enrollment_count=120,
enrollment_monthly_goal_note="",
enrollment_stage_breakdown="",
status="ACTIVE",
visit_schedule=[
{
"visit_code": "V1",
"baseline_offset_days": 7,
"window_before_days": 1,
"window_after_days": 2,
}
],
)
draft = _build_default_setup_config_from_study(study, [])
assert draft.projectInfo.code == "PRJ-DEFAULT"
assert draft.projectInfo.name == "默认项目"
assert draft.projectInfo.planned_enrollment_count == 120
assert draft.projectInfo.visit_schedule[0].visit_code == "V1"
def test_empty_project_info_does_not_fallback_to_study_snapshot():
study = SimpleNamespace(
code="PRJ-MASTER",
name="主表项目",
project_full_name="",
sponsor="",
protocol_no="",
lead_unit="",
principal_investigator="",
main_pm="",
research_analysis="",
research_product="",
control_product="",
indication="",
research_population="",
research_design="",
plan_start_date=None,
plan_end_date=None,
planned_site_count=None,
planned_enrollment_count=None,
status="DRAFT",
visit_schedule=[],
)
draft = StudySetupConfigData()
snapshot = _resolve_setup_project_snapshot(draft, study)
assert snapshot.code == ""
assert snapshot.name == ""
def test_apply_project_publish_snapshot_updates_formal_study_fields():
study = SimpleNamespace(
code="PRJ-001",