feat(subjects): add visit drawer and editable screening dates
This commit is contained in:
@@ -73,7 +73,7 @@ async def create_ae(
|
||||
is_sae=bool(ae_in.is_sae or ae_in.is_susar),
|
||||
is_susar=bool(ae_in.is_susar),
|
||||
report_due_date=due_date,
|
||||
status="NEW",
|
||||
status="FOLLOW_UP",
|
||||
description=ae_in.description,
|
||||
created_by=created_by,
|
||||
)
|
||||
|
||||
+32
-10
@@ -111,6 +111,20 @@ def get_last_planned_visit_window_start_date(visits: Sequence[Visit]) -> date |
|
||||
return max(window_start_dates) if window_start_dates else None
|
||||
|
||||
|
||||
def derive_visit_status_from_actual_date(
|
||||
*,
|
||||
actual_date: date | None,
|
||||
window_start: date | None,
|
||||
window_end: date | None,
|
||||
default_status: str = "PLANNED",
|
||||
) -> str:
|
||||
if actual_date is None:
|
||||
return default_status
|
||||
if (window_start and actual_date < window_start) or (window_end and actual_date > window_end):
|
||||
return "OVERDUE"
|
||||
return "DONE"
|
||||
|
||||
|
||||
def validate_early_termination_date(termination_date: date, visits: Sequence[Visit]) -> None:
|
||||
last_window_start_date = get_last_planned_visit_window_start_date(visits)
|
||||
if last_window_start_date is not None and termination_date >= last_window_start_date:
|
||||
@@ -157,16 +171,24 @@ async def create_visit(
|
||||
actual_date: date | None = None,
|
||||
status: str = "PLANNED",
|
||||
) -> Visit:
|
||||
resolved_window_start = window_start if window_start is not None else (visit_in.window_start if visit_in else None)
|
||||
resolved_window_end = window_end if window_end is not None else (visit_in.window_end if visit_in else None)
|
||||
resolved_actual_date = actual_date if actual_date is not None else (visit_in.actual_date if visit_in else None)
|
||||
visit = Visit(
|
||||
study_id=study_id,
|
||||
subject_id=subject.id,
|
||||
visit_code=visit_code,
|
||||
planned_date=planned_date,
|
||||
actual_date=actual_date,
|
||||
status=status,
|
||||
window_start=window_start if window_start is not None else (visit_in.window_start if visit_in else None),
|
||||
window_end=window_end if window_end is not None else (visit_in.window_end if visit_in else None),
|
||||
notes=None,
|
||||
actual_date=resolved_actual_date,
|
||||
status=derive_visit_status_from_actual_date(
|
||||
actual_date=resolved_actual_date,
|
||||
window_start=resolved_window_start,
|
||||
window_end=resolved_window_end,
|
||||
default_status=status,
|
||||
),
|
||||
window_start=resolved_window_start,
|
||||
window_end=resolved_window_end,
|
||||
notes=visit_in.notes if visit_in else None,
|
||||
)
|
||||
db.add(visit)
|
||||
await db.commit()
|
||||
@@ -240,11 +262,11 @@ async def get_visit(db: AsyncSession, visit_id: uuid.UUID) -> Visit | None:
|
||||
async def update_visit(db: AsyncSession, visit: Visit, visit_in: VisitUpdate) -> Visit:
|
||||
update_data = visit_in.model_dump(exclude_unset=True)
|
||||
if "actual_date" in update_data and update_data["actual_date"] is not None and "status" not in update_data:
|
||||
actual_date = update_data["actual_date"]
|
||||
if (visit.window_start and actual_date < visit.window_start) or (visit.window_end and actual_date > visit.window_end):
|
||||
update_data["status"] = "OVERDUE"
|
||||
else:
|
||||
update_data["status"] = "DONE"
|
||||
update_data["status"] = derive_visit_status_from_actual_date(
|
||||
actual_date=update_data["actual_date"],
|
||||
window_start=visit.window_start,
|
||||
window_end=visit.window_end,
|
||||
)
|
||||
if update_data:
|
||||
await db.execute(
|
||||
sa_update(Visit)
|
||||
|
||||
@@ -31,7 +31,7 @@ class AdverseEvent(Base):
|
||||
is_sae: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_susar: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
report_due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="NEW")
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="FOLLOW_UP")
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
|
||||
@@ -11,6 +11,8 @@ class VisitCreate(BaseModel):
|
||||
planned_date: Optional[date] = None
|
||||
window_start: Optional[date] = None
|
||||
window_end: Optional[date] = None
|
||||
actual_date: Optional[date] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class VisitUpdate(BaseModel):
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_new_ae_records_default_to_follow_up_status():
|
||||
crud_source = Path("app/crud/ae.py").read_text(encoding="utf-8")
|
||||
model_source = Path("app/models/ae.py").read_text(encoding="utf-8")
|
||||
|
||||
assert 'status="FOLLOW_UP"' in crud_source
|
||||
assert 'default="FOLLOW_UP"' in model_source
|
||||
assert 'status="NEW"' not in crud_source
|
||||
assert 'default="NEW"' not in model_source
|
||||
@@ -10,6 +10,7 @@ from app.crud.visit import (
|
||||
validate_early_termination_date,
|
||||
)
|
||||
from app.schemas.study import StudyUpdate
|
||||
from app.schemas.visit import VisitCreate
|
||||
|
||||
|
||||
def test_build_visit_schedule_dates_uses_per_visit_windows():
|
||||
@@ -105,6 +106,19 @@ def test_study_update_allows_multiple_visits_with_same_baseline_offset():
|
||||
assert [item.visit_code for item in payload.visit_schedule or []] == ["筛选访视", "基线访视"]
|
||||
|
||||
|
||||
def test_visit_create_accepts_actual_date_and_notes_for_shared_drawer_create_flow():
|
||||
payload = VisitCreate(
|
||||
subject_id="00000000-0000-0000-0000-000000000001",
|
||||
visit_code="V4",
|
||||
planned_date=date(2026, 6, 1),
|
||||
actual_date=date(2026, 6, 2),
|
||||
notes="现场已完成",
|
||||
)
|
||||
|
||||
assert payload.actual_date == date(2026, 6, 2)
|
||||
assert payload.notes == "现场已完成"
|
||||
|
||||
|
||||
def test_sort_visits_for_display_follows_configured_visit_order_without_dates():
|
||||
visits = [
|
||||
SimpleNamespace(visit_code="V1", planned_date=None),
|
||||
|
||||
Reference in New Issue
Block a user