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
This commit is contained in:
+99
-24
@@ -5,11 +5,59 @@ 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,
|
||||
*,
|
||||
@@ -20,14 +68,16 @@ async def create_visit(
|
||||
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=None,
|
||||
status="PLANNED",
|
||||
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,
|
||||
@@ -39,8 +89,13 @@ async def create_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).order_by(Visit.planned_date))
|
||||
return result.scalars().all()
|
||||
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:
|
||||
@@ -150,32 +205,50 @@ async def list_lost_visits(
|
||||
return result.all()
|
||||
|
||||
|
||||
async def create_followup_visits(
|
||||
async def create_scheduled_visits(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
subject: Subject,
|
||||
base_date: date,
|
||||
visit_total: int | None,
|
||||
visit_interval_days: int | None,
|
||||
window_start_offset: int | None,
|
||||
window_end_offset: int | None,
|
||||
base_date: date | None,
|
||||
visit_schedule: list[dict] | None,
|
||||
) -> Sequence[Visit]:
|
||||
if not visit_total or not visit_interval_days or visit_total < 2:
|
||||
if not visit_schedule:
|
||||
return []
|
||||
|
||||
result = await db.execute(select(Visit.visit_code).where(Visit.subject_id == subject.id))
|
||||
existing_codes = {row[0] for row in result.all()}
|
||||
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 index in range(2, visit_total + 1):
|
||||
code = f"V{index}"
|
||||
if code in existing_codes:
|
||||
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
|
||||
planned_date = base_date + timedelta(days=visit_interval_days * (index - 1))
|
||||
window_start = (
|
||||
planned_date + timedelta(days=window_start_offset) if window_start_offset is not None else None
|
||||
)
|
||||
window_end = planned_date + timedelta(days=window_end_offset) if window_end_offset is not None else None
|
||||
created.append(
|
||||
await create_visit(
|
||||
db,
|
||||
@@ -183,9 +256,11 @@ async def create_followup_visits(
|
||||
visit_in=None,
|
||||
subject=subject,
|
||||
visit_code=code,
|
||||
planned_date=planned_date,
|
||||
window_start=window_start,
|
||||
window_end=window_end,
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user