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:
@@ -2,7 +2,13 @@ from datetime import date
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.crud.subject import should_generate_visits_after_subject_update
|
||||
from app.crud.visit import build_visit_schedule_dates, sort_visits_for_display
|
||||
from app.crud.visit import (
|
||||
build_early_termination_visit_changes,
|
||||
build_visit_schedule_dates,
|
||||
get_last_planned_visit_window_start_date,
|
||||
sort_visits_for_display,
|
||||
validate_early_termination_date,
|
||||
)
|
||||
from app.schemas.study import StudyUpdate
|
||||
|
||||
|
||||
@@ -129,6 +135,30 @@ def test_sort_visits_for_display_does_not_infer_business_order():
|
||||
assert [visit.visit_code for visit in sort_visits_for_display(visits, visit_schedule)] == ["V1", "V2", "基线访视"]
|
||||
|
||||
|
||||
def test_sort_visits_places_early_termination_after_last_actual_visit():
|
||||
visits = [
|
||||
SimpleNamespace(visit_code="筛选访视", planned_date=date(2026, 5, 3), actual_date=date(2026, 5, 3)),
|
||||
SimpleNamespace(visit_code="基线访视", planned_date=date(2026, 5, 3), actual_date=date(2026, 5, 3)),
|
||||
SimpleNamespace(visit_code="V1", planned_date=date(2026, 5, 10), actual_date=None),
|
||||
SimpleNamespace(visit_code="V2", planned_date=date(2026, 5, 17), actual_date=None),
|
||||
SimpleNamespace(visit_code="提前终止", planned_date=None, actual_date=date(2026, 5, 13)),
|
||||
]
|
||||
visit_schedule = [
|
||||
{"visit_code": "筛选访视"},
|
||||
{"visit_code": "基线访视"},
|
||||
{"visit_code": "V1"},
|
||||
{"visit_code": "V2"},
|
||||
]
|
||||
|
||||
assert [visit.visit_code for visit in sort_visits_for_display(visits, visit_schedule)] == [
|
||||
"筛选访视",
|
||||
"基线访视",
|
||||
"提前终止",
|
||||
"V1",
|
||||
"V2",
|
||||
]
|
||||
|
||||
|
||||
def test_should_generate_visits_when_baseline_date_is_set_or_changed():
|
||||
assert should_generate_visits_after_subject_update(
|
||||
previous_baseline_date=None,
|
||||
@@ -142,3 +172,58 @@ def test_should_generate_visits_when_baseline_date_is_set_or_changed():
|
||||
previous_baseline_date=None,
|
||||
next_baseline_date=None,
|
||||
)
|
||||
|
||||
|
||||
def test_build_early_termination_visit_changes_adds_event_and_cancels_future_planned_visits():
|
||||
visits = [
|
||||
SimpleNamespace(visit_code="筛选访视", planned_date=date(2026, 5, 1), actual_date=date(2026, 5, 1), status="DONE"),
|
||||
SimpleNamespace(visit_code="基线访视", planned_date=date(2026, 5, 1), actual_date=date(2026, 5, 1), status="DONE"),
|
||||
SimpleNamespace(visit_code="V0", planned_date=date(2026, 5, 5), actual_date=None, status="LOST"),
|
||||
SimpleNamespace(
|
||||
visit_code="V1",
|
||||
planned_date=date(2026, 5, 6),
|
||||
window_start=date(2026, 5, 4),
|
||||
window_end=date(2026, 5, 8),
|
||||
actual_date=None,
|
||||
status="PLANNED",
|
||||
),
|
||||
SimpleNamespace(
|
||||
visit_code="V2",
|
||||
planned_date=date(2026, 5, 15),
|
||||
window_start=date(2026, 5, 12),
|
||||
window_end=date(2026, 5, 18),
|
||||
actual_date=None,
|
||||
status="PLANNED",
|
||||
),
|
||||
]
|
||||
|
||||
changes = build_early_termination_visit_changes(
|
||||
visits,
|
||||
termination_date=date(2026, 5, 6),
|
||||
reason="不良事件退出",
|
||||
)
|
||||
|
||||
assert changes.event_visit_code == "提前终止"
|
||||
assert changes.event_actual_date == date(2026, 5, 6)
|
||||
assert changes.event_notes == "不良事件退出"
|
||||
assert [visit.visit_code for visit in changes.visits_to_cancel] == ["V1", "V2"]
|
||||
|
||||
|
||||
def test_validate_early_termination_date_requires_date_before_last_visit_window_start():
|
||||
visits = [
|
||||
SimpleNamespace(visit_code="筛选访视", planned_date=date(2026, 5, 1), window_start=date(2026, 5, 1)),
|
||||
SimpleNamespace(visit_code="基线访视", planned_date=date(2026, 5, 1), window_start=date(2026, 5, 1)),
|
||||
SimpleNamespace(visit_code="V1", planned_date=date(2026, 5, 8), window_start=date(2026, 5, 6)),
|
||||
SimpleNamespace(visit_code="V2", planned_date=date(2026, 5, 15), window_start=date(2026, 5, 12)),
|
||||
SimpleNamespace(visit_code="提前终止", planned_date=None, window_start=None),
|
||||
]
|
||||
|
||||
assert get_last_planned_visit_window_start_date(visits) == date(2026, 5, 12)
|
||||
validate_early_termination_date(date(2026, 5, 11), visits)
|
||||
|
||||
try:
|
||||
validate_early_termination_date(date(2026, 5, 12), visits)
|
||||
except ValueError as exc:
|
||||
assert "提前终止日期必须早于方案最后一个计划访视窗口开始日" in str(exc)
|
||||
else:
|
||||
raise AssertionError("same-day final visit window start should not be accepted as early termination")
|
||||
|
||||
Reference in New Issue
Block a user