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:
@@ -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")
|
||||
@@ -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