Files
ctms/backend/app/crud/visit.py
T
Cheng Zhou 74feca4467 feat: harden auth and study workflows
- replace plaintext login and unlock requests with RSA-OAEP/AES-GCM encrypted payloads

- add login challenge replay protection, production RSA key validation, and auth tests

- wire compose to environment-driven dev/prod settings without committing local secrets

- update setup-config smoke scripts and Postman docs for encrypted login

- add visit schedule migrations/tests and update study/subject setup workflows
2026-05-08 22:16:43 +08:00

267 lines
9.1 KiB
Python

import uuid
from datetime import date, timedelta
from typing import Sequence
from sqlalchemy import and_, func, or_, select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.study import Study
from app.models.subject import Subject
from app.models.visit import Visit
from app.schemas.visit import VisitCreate, VisitUpdate
def _visit_schedule_order_map(visit_schedule: list[dict] | None) -> dict[str, int]:
order_map: dict[str, int] = {}
for index, item in enumerate(visit_schedule or []):
code = str(item.get("visit_code") or "").strip()
if code and code not in order_map:
order_map[code] = index
return order_map
def sort_visits_for_display(visits: Sequence[Visit], visit_schedule: list[dict] | None = None) -> list[Visit]:
order_map = _visit_schedule_order_map(visit_schedule)
fallback_start = len(order_map)
return sorted(
visits,
key=lambda visit: (
order_map.get((visit.visit_code or "").strip(), fallback_start),
visit.planned_date is None,
visit.planned_date or date.max,
visit.visit_code or "",
),
)
def build_visit_schedule_dates(visit_schedule: list[dict] | None, base_date: date | None) -> list[dict]:
if not visit_schedule:
return []
rows: list[dict] = []
for item in visit_schedule:
code = str(item.get("visit_code") or "").strip()
if not code:
continue
baseline_offset_days = int(item.get("baseline_offset_days", 0))
window_before_days = int(item.get("window_before_days", 0))
window_after_days = int(item.get("window_after_days", 0))
planned_date = base_date + timedelta(days=baseline_offset_days) if base_date else None
rows.append(
{
"visit_code": code,
"baseline_offset_days": baseline_offset_days,
"planned_date": planned_date,
"window_start": planned_date - timedelta(days=window_before_days) if planned_date else None,
"window_end": planned_date + timedelta(days=window_after_days) if planned_date else None,
}
)
return rows
async def create_visit(
db: AsyncSession,
*,
study_id: uuid.UUID,
visit_in: VisitCreate | None,
subject: Subject,
visit_code: str,
planned_date,
window_start: date | None = None,
window_end: date | None = None,
actual_date: date | None = None,
status: str = "PLANNED",
) -> Visit:
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,
)
db.add(visit)
await db.commit()
await db.refresh(visit)
return visit
async def list_visits(db: AsyncSession, subject_id: uuid.UUID) -> Sequence[Visit]:
result = await db.execute(select(Visit).where(Visit.subject_id == subject_id))
visits = result.scalars().all()
if not visits:
return []
study_result = await db.execute(select(Study.visit_schedule).where(Study.id == visits[0].study_id))
visit_schedule = study_result.scalar_one_or_none() or []
return sort_visits_for_display(visits, visit_schedule)
async def mark_overdue_as_lost(db: AsyncSession, subject_id: uuid.UUID) -> None:
today = func.current_date()
deadline = func.coalesce(Visit.window_end, Visit.planned_date)
await db.execute(
sa_update(Visit)
.where(
Visit.subject_id == subject_id,
Visit.actual_date.is_(None),
deadline.is_not(None),
deadline < today,
Visit.status.not_in(["DONE", "CANCELLED", "LOST"]),
)
.values(status="LOST")
)
await db.commit()
async def mark_overdue_as_lost_global(db: AsyncSession) -> None:
today = func.current_date()
deadline = func.coalesce(Visit.window_end, Visit.planned_date)
await db.execute(
sa_update(Visit)
.where(
Visit.actual_date.is_(None),
deadline.is_not(None),
deadline < today,
Visit.status.not_in(["DONE", "CANCELLED", "LOST"]),
)
.values(status="LOST")
)
await db.commit()
async def get_next_visit_code(db: AsyncSession, subject_id: uuid.UUID) -> str:
result = await db.execute(select(Visit.visit_code).where(Visit.subject_id == subject_id))
used: set[int] = set()
for (code,) in result.all():
if not code or not code.startswith("V"):
continue
num = code[1:]
if num.isdigit():
used.add(int(num))
next_num = 1
while next_num in used:
next_num += 1
return f"V{next_num}"
async def get_visit(db: AsyncSession, visit_id: uuid.UUID) -> Visit | None:
result = await db.execute(select(Visit).where(Visit.id == visit_id))
return result.scalar_one_or_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"
if update_data:
await db.execute(
sa_update(Visit)
.where(Visit.id == visit.id)
.values(**update_data)
)
await db.commit()
await db.refresh(visit)
return visit
async def delete_visit(db: AsyncSession, visit: Visit) -> None:
await db.delete(visit)
await db.commit()
async def list_lost_visits(
db: AsyncSession,
study_id: uuid.UUID,
*,
site_ids: set[uuid.UUID] | None = None,
limit: int = 50,
) -> Sequence[tuple[Visit, str, uuid.UUID | None]]:
if site_ids is not None and not site_ids:
return []
today = func.current_date()
deadline = func.coalesce(Visit.window_end, Visit.planned_date)
lost_condition = and_(
Visit.actual_date.is_(None),
deadline.is_not(None),
deadline < today,
Visit.status.not_in(["DONE", "CANCELLED"]),
)
stmt = (
select(Visit, Subject.subject_no, Subject.site_id)
.join(Subject, Subject.id == Visit.subject_id)
.where(Visit.study_id == study_id, lost_condition)
.order_by(Visit.updated_at.desc())
.limit(limit)
)
if site_ids is not None:
stmt = stmt.where(Subject.site_id.in_(site_ids))
result = await db.execute(stmt)
return result.all()
async def create_scheduled_visits(
db: AsyncSession,
*,
study_id: uuid.UUID,
subject: Subject,
base_date: date | None,
visit_schedule: list[dict] | None,
) -> Sequence[Visit]:
if not visit_schedule:
return []
result = await db.execute(select(Visit).where(Visit.subject_id == subject.id))
existing_by_code = {visit.visit_code: visit for visit in result.scalars().all()}
created: list[Visit] = []
for item in build_visit_schedule_dates(visit_schedule, base_date):
code = item["visit_code"]
is_baseline_visit = item["baseline_offset_days"] == 0 and base_date is not None
existing = existing_by_code.get(code)
if existing:
if is_baseline_visit:
await db.execute(
sa_update(Visit)
.where(Visit.id == existing.id)
.values(
planned_date=item["planned_date"],
window_start=item["window_start"],
window_end=item["window_end"],
actual_date=base_date,
status="DONE",
)
)
await db.commit()
elif existing.status == "PLANNED" and existing.actual_date is None:
await db.execute(
sa_update(Visit)
.where(Visit.id == existing.id)
.values(
planned_date=item["planned_date"],
window_start=item["window_start"],
window_end=item["window_end"],
)
)
await db.commit()
continue
created.append(
await create_visit(
db,
study_id=study_id,
visit_in=None,
subject=subject,
visit_code=code,
planned_date=item["planned_date"],
window_start=item["window_start"],
window_end=item["window_end"],
actual_date=base_date if is_baseline_visit else None,
status="DONE" if is_baseline_visit else "PLANNED",
)
)
return created