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:
Cheng Zhou
2026-05-09 17:10:34 +08:00
parent 74feca4467
commit 917ab7ccf1
41 changed files with 3463 additions and 701 deletions
@@ -0,0 +1,230 @@
from datetime import date
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.schemas.study_setup_config import ProjectPublishSnapshot, StudySetupConfigData
def test_setup_config_data_keeps_project_info_draft():
draft = StudySetupConfigData.model_validate(
{
"projectInfo": {
"code": "PRJ-002",
"name": "草稿项目",
"plan_start_date": "2026-06-01",
"plan_end_date": "2026-12-31",
"planned_enrollment_count": 120,
"status": "ACTIVE",
"visit_schedule": [
{
"visit_code": "V1",
"baseline_offset_days": 7,
"window_before_days": 1,
"window_after_days": 2,
}
],
}
}
)
assert draft.projectInfo.code == "PRJ-002"
assert draft.projectInfo.name == "草稿项目"
assert draft.projectInfo.planned_enrollment_count == 120
assert draft.projectInfo.visit_schedule[0].visit_code == "V1"
def test_draft_schema_accepts_incomplete_project_snapshot_rows():
draft = StudySetupConfigData.model_validate(
{
"projectInfo": {
"code": "PRJ-002",
"name": "草稿项目",
"visit_schedule": [
{
"visit_code": "",
"baseline_offset_days": 0,
"window_before_days": 0,
"window_after_days": 0,
}
],
},
"projectMilestones": [
{
"id": "row-1",
"name": "",
"planDate": "",
"startDate": "",
"endDate": "",
"durationDays": 1,
"owner": "",
"remark": "",
"status": "",
}
],
"centerConfirm": [
{
"id": "row-1",
"siteId": "",
"siteName": "",
"confirmer": "",
"confirmStatus": "",
"confirmDate": "",
"note": "",
}
],
}
)
assert draft.projectInfo.visit_schedule[0].visit_code == ""
assert draft.projectMilestones[0].status == ""
assert draft.centerConfirm[0].confirmStatus == ""
def test_draft_save_allows_incomplete_setup_steps():
draft = StudySetupConfigData.model_validate(
{
"projectInfo": {
"code": "PRJ-002",
"name": "草稿项目",
"plan_start_date": "2026-05-01",
"plan_end_date": "2027-05-31",
},
"enrollmentPlan": {
"totalTarget": 180,
"startDate": "",
"endDate": "",
"monthlyGoalNote": "",
"stageBreakdown": "",
},
}
)
_validate_setup_data(
draft,
{},
project_plan_start=date(2026, 5, 1),
project_plan_end=date(2027, 5, 31),
strict_required=False,
)
def test_publish_validation_still_requires_complete_setup_steps():
draft = StudySetupConfigData.model_validate(
{
"projectInfo": {
"code": "PRJ-002",
"name": "草稿项目",
"plan_start_date": "2026-05-01",
"plan_end_date": "2027-05-31",
},
"enrollmentPlan": {
"totalTarget": 180,
"startDate": "",
"endDate": "",
"monthlyGoalNote": "",
"stageBreakdown": "",
},
}
)
try:
_validate_setup_data(
draft,
{},
project_plan_start=date(2026, 5, 1),
project_plan_end=date(2027, 5, 31),
)
except HTTPException as exc:
assert exc.status_code == 422
else:
raise AssertionError("publish validation should reject incomplete setup steps")
def test_project_info_is_part_of_setup_config_payload():
draft = StudySetupConfigData.model_validate(
{
"projectInfo": {
"code": "PRJ-VERSION",
"name": "版本内项目信息",
"project_full_name": "版本内项目全称",
"plan_start_date": "2026-06-01",
"plan_end_date": "2026-12-31",
"planned_site_count": 8,
"planned_enrollment_count": 120,
"status": "ACTIVE",
},
"enrollmentPlan": {
"totalTarget": 120,
"startDate": "2026-06-01",
"endDate": "2026-12-31",
"monthlyGoalNote": "",
"stageBreakdown": "",
},
}
)
payload = draft.model_dump(mode="json")
assert payload["projectInfo"]["code"] == "PRJ-VERSION"
assert payload["projectInfo"]["planned_enrollment_count"] == 120
assert payload["enrollmentPlan"]["totalTarget"] == 120
def test_apply_project_publish_snapshot_updates_formal_study_fields():
study = SimpleNamespace(
code="PRJ-001",
name="原项目",
project_full_name=None,
sponsor=None,
protocol_no=None,
lead_unit=None,
principal_investigator=None,
main_pm=None,
research_analysis=None,
research_product=None,
control_product=None,
indication=None,
research_population=None,
research_design=None,
plan_start_date=None,
plan_end_date=None,
planned_site_count=None,
planned_enrollment_count=10,
status="DRAFT",
visit_schedule=[],
)
snapshot = ProjectPublishSnapshot(
code="PRJ-002",
name="发布项目",
project_full_name="发布项目全称",
sponsor="申办方",
plan_start_date="2026-06-01",
plan_end_date="2026-12-31",
planned_site_count=8,
planned_enrollment_count=120,
status="ACTIVE",
visit_schedule=[
{
"visit_code": "V1",
"baseline_offset_days": 7,
"window_before_days": 1,
"window_after_days": 2,
}
],
)
changed = _apply_project_publish_snapshot_to_study(study, snapshot)
assert changed is True
assert study.code == "PRJ-002"
assert study.name == "发布项目"
assert study.project_full_name == "发布项目全称"
assert study.sponsor == "申办方"
assert study.plan_start_date == date(2026, 6, 1)
assert study.plan_end_date == date(2026, 12, 31)
assert study.planned_site_count == 8
assert study.planned_enrollment_count == 120
assert study.status == "ACTIVE"
assert study.visit_schedule[0]["visit_code"] == "V1"