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:
@@ -30,14 +30,9 @@ async def create(db: AsyncSession, study_in: StudyCreate, *, created_by: uuid.UU
|
||||
planned_enrollment_count=study_in.planned_enrollment_count,
|
||||
enrollment_monthly_goal_note=study_in.enrollment_monthly_goal_note,
|
||||
enrollment_stage_breakdown=study_in.enrollment_stage_breakdown,
|
||||
summary_note=study_in.summary_note,
|
||||
objective_note=study_in.objective_note,
|
||||
phase=study_in.phase,
|
||||
status=study_in.status,
|
||||
visit_interval_days=study_in.visit_interval_days,
|
||||
visit_total=study_in.visit_total,
|
||||
visit_window_start_offset=study_in.visit_window_start_offset,
|
||||
visit_window_end_offset=study_in.visit_window_end_offset,
|
||||
visit_schedule=[item.model_dump() for item in study_in.visit_schedule],
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(study)
|
||||
|
||||
+26
-44
@@ -1,5 +1,5 @@
|
||||
import uuid
|
||||
from datetime import date, timedelta
|
||||
from datetime import date
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import delete as sa_delete, select, update as sa_update
|
||||
@@ -37,6 +37,10 @@ def _validate_subject_date_chain(
|
||||
raise ValueError("完成日期不能早于入组日期")
|
||||
|
||||
|
||||
def _should_sync_visits(previous_baseline_date: date | None, next_baseline_date: date | None) -> bool:
|
||||
return next_baseline_date is not None and previous_baseline_date != next_baseline_date
|
||||
|
||||
|
||||
async def _validate_site(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID) -> None:
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
@@ -51,7 +55,7 @@ async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: Subj
|
||||
_validate_subject_date_chain(
|
||||
screening_date=subject_in.screening_date,
|
||||
consent_date=subject_in.consent_date,
|
||||
enrollment_date=None,
|
||||
enrollment_date=subject_in.enrollment_date,
|
||||
completion_date=None,
|
||||
)
|
||||
subject = Subject(
|
||||
@@ -61,7 +65,8 @@ async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: Subj
|
||||
status="SCREENING",
|
||||
screening_date=subject_in.screening_date,
|
||||
consent_date=subject_in.consent_date,
|
||||
enrollment_date=None,
|
||||
enrollment_date=subject_in.enrollment_date,
|
||||
baseline_date=subject_in.baseline_date,
|
||||
completion_date=None,
|
||||
drop_reason=None,
|
||||
)
|
||||
@@ -69,15 +74,7 @@ async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: Subj
|
||||
await db.commit()
|
||||
await db.refresh(subject)
|
||||
|
||||
# initial visit: Screening (V0)
|
||||
await visit_crud.create_visit(
|
||||
db,
|
||||
study_id=study_id,
|
||||
visit_in=None,
|
||||
subject=subject,
|
||||
visit_code="V0",
|
||||
planned_date=subject.screening_date,
|
||||
)
|
||||
await sync_visits_from_baseline(db, subject)
|
||||
return subject
|
||||
|
||||
|
||||
@@ -103,49 +100,26 @@ async def list_subjects(
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def generate_default_visits(db: AsyncSession, subject: Subject) -> None:
|
||||
# Baseline + Follow-up visits based on enrollment_date
|
||||
if not subject.enrollment_date:
|
||||
return
|
||||
async def sync_visits_from_baseline(db: AsyncSession, subject: Subject) -> None:
|
||||
# 基线/治疗日期是访视计划的唯一推算基准,不能用入组日期替代。
|
||||
result = await db.execute(select(Study).where(Study.id == subject.study_id))
|
||||
study = result.scalar_one_or_none()
|
||||
if not study:
|
||||
return
|
||||
|
||||
visit_total = study.visit_total or 3
|
||||
visit_interval_days = study.visit_interval_days or 30
|
||||
window_start_offset = study.visit_window_start_offset
|
||||
window_end_offset = study.visit_window_end_offset
|
||||
baseline_date = subject.enrollment_date
|
||||
|
||||
result = await db.execute(select(Visit.visit_code).where(Visit.subject_id == subject.id))
|
||||
existing_codes = {row[0] for row in result.all()}
|
||||
if "V1" not in existing_codes:
|
||||
window_start = baseline_date + timedelta(days=window_start_offset) if window_start_offset is not None else None
|
||||
window_end = baseline_date + timedelta(days=window_end_offset) if window_end_offset is not None else None
|
||||
await visit_crud.create_visit(
|
||||
db,
|
||||
study_id=subject.study_id,
|
||||
visit_in=None,
|
||||
subject=subject,
|
||||
visit_code="V1",
|
||||
planned_date=baseline_date,
|
||||
window_start=window_start,
|
||||
window_end=window_end,
|
||||
)
|
||||
|
||||
await visit_crud.create_followup_visits(
|
||||
await visit_crud.create_scheduled_visits(
|
||||
db,
|
||||
study_id=subject.study_id,
|
||||
subject=subject,
|
||||
base_date=baseline_date,
|
||||
visit_total=visit_total,
|
||||
visit_interval_days=visit_interval_days,
|
||||
window_start_offset=window_start_offset,
|
||||
window_end_offset=window_end_offset,
|
||||
base_date=subject.baseline_date,
|
||||
visit_schedule=study.visit_schedule,
|
||||
)
|
||||
|
||||
|
||||
async def generate_default_visits(db: AsyncSession, subject: Subject) -> None:
|
||||
await sync_visits_from_baseline(db, subject)
|
||||
|
||||
|
||||
async def update_subject(db: AsyncSession, subject: Subject, subject_in: SubjectUpdate) -> Subject:
|
||||
update_data = subject_in.model_dump(exclude_unset=True)
|
||||
next_screening_date = subject.screening_date
|
||||
@@ -169,6 +143,14 @@ async def update_subject(db: AsyncSession, subject: Subject, subject_in: Subject
|
||||
return subject
|
||||
|
||||
|
||||
def should_generate_visits_after_subject_update(
|
||||
*,
|
||||
previous_baseline_date: date | None,
|
||||
next_baseline_date: date | None,
|
||||
) -> bool:
|
||||
return _should_sync_visits(previous_baseline_date, next_baseline_date)
|
||||
|
||||
|
||||
async def delete_subject(db: AsyncSession, subject: Subject) -> None:
|
||||
subject_id = subject.id
|
||||
await db.execute(sa_delete(Visit).where(Visit.subject_id == subject_id))
|
||||
|
||||
+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