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,72 @@
import uuid
from datetime import datetime, timezone
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.crud import monitoring_visit_issue as issue_crud
from app.models.monitoring_visit_issue import MonitoringVisitIssue
from app.schemas.monitoring_visit_issue import MonitoringVisitIssueCreate
@pytest.mark.asyncio
async def test_monitoring_visit_issue_template_fields_can_be_filtered(tmp_path):
db_path = tmp_path / "monitoring-issues.db"
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with engine.begin() as conn:
await conn.run_sync(MonitoringVisitIssue.__table__.create)
study_id = uuid.UUID("11111111-1111-1111-1111-111111111111")
site_id = uuid.UUID("22222222-2222-2222-2222-222222222222")
async with SessionLocal() as session:
created = await issue_crud.create_issue(
session,
study_id,
MonitoringVisitIssueCreate(
issue_no="MV-001",
site_id=site_id,
category="原始记录",
subject_code="SUBJ-001",
status="OPEN",
severity="严重",
mark="SDV",
visit_cycle="V1",
center_query="请补充签名日期",
center_latest_reply="待中心回复",
rectification_completed=False,
due_at=datetime(2026, 5, 1, tzinfo=timezone.utc),
),
created_by=None,
)
assert created.site_id == site_id
assert created.severity == "严重"
assert created.mark == "SDV"
assert created.visit_cycle == "V1"
assert created.center_query == "请补充签名日期"
assert created.center_latest_reply == "待中心回复"
assert created.rectification_completed is False
matched = await issue_crud.list_issues(
session,
study_id,
site_id=site_id,
severity="严重",
mark="SDV",
visit_cycle="V1",
rectification_completed=False,
due_from=datetime(2026, 5, 1, tzinfo=timezone.utc).date(),
due_to=datetime(2026, 5, 1, tzinfo=timezone.utc).date(),
)
unmatched = await issue_crud.list_issues(
session,
study_id,
site_id=uuid.UUID("33333333-3333-3333-3333-333333333333"),
)
await engine.dispose()
assert [item.issue_no for item in matched] == ["MV-001"]
assert unmatched == []
@@ -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"
@@ -0,0 +1,44 @@
from datetime import datetime
from types import SimpleNamespace
import uuid
from app.crud.subject import _validate_actual_medication_count
from app.schemas.subject import SubjectRead, SubjectUpdate
def test_subject_update_accepts_actual_medication_count():
payload = SubjectUpdate(actual_medication_count=12)
assert payload.actual_medication_count == 12
def test_subject_read_includes_actual_medication_count():
subject = SimpleNamespace(
id=uuid.UUID("00000000-0000-0000-0000-000000000001"),
study_id=uuid.UUID("00000000-0000-0000-0000-000000000002"),
site_id=uuid.UUID("00000000-0000-0000-0000-000000000003"),
subject_no="S001",
status="ENROLLED",
screening_date=None,
consent_date=None,
enrollment_date=None,
baseline_date=None,
completion_date=None,
actual_medication_count=10,
drop_reason=None,
created_at=datetime(2026, 5, 9, 0, 0, 0),
updated_at=datetime(2026, 5, 9, 0, 0, 0),
)
data = SubjectRead.model_validate(subject)
assert data.actual_medication_count == 10
def test_actual_medication_count_cannot_be_negative():
try:
_validate_actual_medication_count(-1)
except ValueError as exc:
assert "实际用药次数不能小于0" in str(exc)
else:
raise AssertionError("negative actual medication count should be rejected")
+86 -1
View File
@@ -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")