Step 6:受试者(Subject)+ 访视(Visit)+ 随访进度

This commit is contained in:
Cheng Zhou
2025-12-16 17:43:11 +08:00
parent a9e15faedc
commit c77b20f932
69 changed files with 468 additions and 1 deletions
+3 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, studies, sites, members, comments, attachments, audit_logs, milestones, tasks, dashboard
from app.api.v1 import auth, users, studies, sites, members, comments, attachments, audit_logs, milestones, tasks, dashboard, subjects, visits
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
@@ -14,3 +14,5 @@ api_router.include_router(audit_logs.router, prefix="/studies/{study_id}/audit-l
api_router.include_router(milestones.router, prefix="/studies/{study_id}/milestones", tags=["milestones"])
api_router.include_router(tasks.router, prefix="/studies/{study_id}/tasks", tags=["tasks"])
api_router.include_router(dashboard.router, prefix="/studies/{study_id}/dashboard", tags=["dashboard"])
api_router.include_router(subjects.router, prefix="/studies/{study_id}/subjects", tags=["subjects"])
api_router.include_router(visits.router, prefix="/studies/{study_id}/subjects/{subject_id}/visits", tags=["visits"])
+102
View File
@@ -0,0 +1,102 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
from app.crud import audit as audit_crud
from app.crud import subject as subject_crud
from app.crud import study as study_crud
from app.schemas.subject import SubjectCreate, SubjectRead, SubjectUpdate
router = APIRouter()
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
return study
@router.post(
"/",
response_model=SubjectRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
)
async def create_subject(
study_id: uuid.UUID,
subject_in: SubjectCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> SubjectRead:
await _ensure_study_exists(db, study_id)
try:
subject = await subject_crud.create_subject(db, study_id, subject_in)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="subject",
entity_id=subject.id,
action="CREATE_SUBJECT",
detail=f"Subject {subject.subject_no} created",
operator_id=current_user.id,
operator_role=current_user.role,
)
return subject
@router.get(
"/",
response_model=list[SubjectRead],
dependencies=[Depends(require_study_member())],
)
async def list_subjects(
study_id: uuid.UUID,
site_id: uuid.UUID | None = None,
status_filter: str | None = None,
db: AsyncSession = Depends(get_db_session),
) -> list[SubjectRead]:
await _ensure_study_exists(db, study_id)
subjects = await subject_crud.list_subjects(db, study_id, site_id=site_id, status=status_filter)
return list(subjects)
@router.patch(
"/{subject_id}",
response_model=SubjectRead,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
)
async def update_subject(
study_id: uuid.UUID,
subject_id: uuid.UUID,
subject_in: SubjectUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> SubjectRead:
await _ensure_study_exists(db, study_id)
subject = await subject_crud.get_subject(db, subject_id)
if not subject or subject.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Subject not found")
old_status = subject.status
updated = await subject_crud.update_subject(db, subject, subject_in)
# auto-generate visits when enrolled
if subject_in.status and subject_in.status == "ENROLLED" and (subject_in.enrollment_date or updated.enrollment_date):
await subject_crud.generate_default_visits(db, updated)
detail = None
if subject_in.status and subject_in.status != old_status:
detail = f"subject {updated.subject_no} status {old_status} -> {subject_in.status}"
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="subject",
entity_id=subject_id,
action="SUBJECT_STATUS_CHANGE" if detail else "UPDATE_SUBJECT",
detail=detail or "Subject updated",
operator_id=current_user.id,
operator_role=current_user.role,
)
return updated
+70
View File
@@ -0,0 +1,70 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
from app.crud import audit as audit_crud
from app.crud import subject as subject_crud
from app.crud import visit as visit_crud
from app.schemas.visit import VisitRead, VisitUpdate
router = APIRouter()
async def _ensure_subject(db: AsyncSession, study_id: uuid.UUID, subject_id: uuid.UUID):
subject = await subject_crud.get_subject(db, subject_id)
if not subject or subject.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Subject not found")
return subject
@router.get(
"/",
response_model=list[VisitRead],
dependencies=[Depends(require_study_member())],
)
async def list_visits(
study_id: uuid.UUID,
subject_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
) -> list[VisitRead]:
await _ensure_subject(db, study_id, subject_id)
visits = await visit_crud.list_visits(db, subject_id)
return list(visits)
@router.patch(
"/{visit_id}",
response_model=VisitRead,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
)
async def update_visit(
study_id: uuid.UUID,
subject_id: uuid.UUID,
visit_id: uuid.UUID,
visit_in: VisitUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> VisitRead:
await _ensure_subject(db, study_id, subject_id)
visit = await visit_crud.get_visit(db, visit_id)
if not visit or visit.subject_id != subject_id or visit.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Visit not found")
if visit_in.status == "DONE" and not visit_in.actual_date:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="actual_date required when DONE")
updated = await visit_crud.update_visit(db, visit, visit_in)
detail = None
if visit_in.status:
detail = f"visit {visit.visit_code} {visit.status} -> {visit_in.status}"
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="visit",
entity_id=visit_id,
action="VISIT_STATUS_CHANGE" if detail else "UPDATE_VISIT",
detail=detail or "Visit updated",
operator_id=current_user.id,
operator_role=current_user.role,
)
return updated
+104
View File
@@ -0,0 +1,104 @@
import uuid
from datetime import date, timedelta
from typing import Sequence
from sqlalchemy import select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.crud import visit as visit_crud
from app.models.site import Site
from app.models.subject import Subject
from app.schemas.subject import SubjectCreate, SubjectUpdate
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()
if not site or site.study_id != study_id:
raise ValueError("Site not found in study")
async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: SubjectCreate) -> Subject:
await _validate_site(db, study_id, subject_in.site_id)
subject = Subject(
study_id=study_id,
site_id=subject_in.site_id,
subject_no=subject_in.subject_no,
status="SCREENING",
screening_date=subject_in.screening_date,
enrollment_date=None,
completion_date=None,
drop_reason=None,
)
db.add(subject)
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",
visit_name="Screening",
planned_date=subject.screening_date,
)
return subject
async def get_subject(db: AsyncSession, subject_id: uuid.UUID) -> Subject | None:
result = await db.execute(select(Subject).where(Subject.id == subject_id))
return result.scalar_one_or_none()
async def list_subjects(
db: AsyncSession,
study_id: uuid.UUID,
site_id: uuid.UUID | None = None,
status: str | None = None,
) -> Sequence[Subject]:
stmt = select(Subject).where(Subject.study_id == study_id)
if site_id:
stmt = stmt.where(Subject.site_id == site_id)
if status:
stmt = stmt.where(Subject.status == status)
result = await db.execute(stmt)
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
baseline_date = subject.enrollment_date
follow1 = baseline_date + timedelta(days=30)
follow2 = baseline_date + timedelta(days=60)
visits_data = [
("V1", "Baseline", baseline_date),
("FU1", "Follow-up 1", follow1),
("FU2", "Follow-up 2", follow2),
]
for code, name, plan_date in visits_data:
await visit_crud.create_visit(
db,
study_id=subject.study_id,
visit_in=None,
subject=subject,
visit_code=code,
visit_name=name,
planned_date=plan_date,
)
async def update_subject(db: AsyncSession, subject: Subject, subject_in: SubjectUpdate) -> Subject:
update_data = subject_in.model_dump(exclude_unset=True)
if update_data:
await db.execute(
sa_update(Subject)
.where(Subject.id == subject.id)
.values(**update_data)
)
await db.commit()
await db.refresh(subject)
return subject
+60
View File
@@ -0,0 +1,60 @@
import uuid
from typing import Sequence
from sqlalchemy import select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.subject import Subject
from app.models.visit import Visit
from app.schemas.visit import VisitCreate, VisitUpdate
async def create_visit(
db: AsyncSession,
*,
study_id: uuid.UUID,
visit_in: VisitCreate | None,
subject: Subject,
visit_code: str,
visit_name: str,
planned_date,
) -> Visit:
visit = Visit(
study_id=study_id,
subject_id=subject.id,
visit_code=visit_code,
visit_name=visit_name,
planned_date=planned_date,
actual_date=None,
status="PLANNED",
window_start=visit_in.window_start if visit_in else None,
window_end=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).order_by(Visit.planned_date))
return result.scalars().all()
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 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
+2
View File
@@ -10,3 +10,5 @@ from app.models.attachment import Attachment # noqa: F401
from app.models.audit_log import AuditLog # noqa: F401
from app.models.milestone import Milestone # noqa: F401
from app.models.task import Task # noqa: F401
from app.models.subject import Subject # noqa: F401
from app.models.visit import Visit # noqa: F401
+27
View File
@@ -0,0 +1,27 @@
import uuid
from datetime import datetime, date
from sqlalchemy import Date, DateTime, ForeignKey, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class Subject(Base):
__tablename__ = "subjects"
__table_args__ = (UniqueConstraint("study_id", "subject_no", name="uq_subject_study_no"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
site_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=False)
subject_no: Mapped[str] = mapped_column(String(50), nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="SCREENING")
screening_date: Mapped[date | None] = mapped_column(Date, nullable=True)
enrollment_date: Mapped[date | None] = mapped_column(Date, nullable=True)
completion_date: Mapped[date | None] = mapped_column(Date, nullable=True)
drop_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
+28
View File
@@ -0,0 +1,28 @@
import uuid
from datetime import datetime, date
from sqlalchemy import Date, DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class Visit(Base):
__tablename__ = "visits"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False)
subject_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("subjects.id"), index=True, nullable=False)
visit_code: Mapped[str] = mapped_column(String(50), nullable=False)
visit_name: Mapped[str] = mapped_column(String(200), nullable=False)
planned_date: Mapped[date | None] = mapped_column(Date, nullable=True)
actual_date: Mapped[date | None] = mapped_column(Date, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="PLANNED")
window_start: Mapped[date | None] = mapped_column(Date, nullable=True)
window_end: Mapped[date | None] = mapped_column(Date, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
+34
View File
@@ -0,0 +1,34 @@
import uuid
from datetime import date, datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict
class SubjectCreate(BaseModel):
site_id: uuid.UUID
subject_no: str
screening_date: Optional[date] = None
class SubjectUpdate(BaseModel):
status: Optional[str] = None
enrollment_date: Optional[date] = None
completion_date: Optional[date] = None
drop_reason: Optional[str] = None
class SubjectRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
site_id: uuid.UUID
subject_no: str
status: str
screening_date: Optional[date]
enrollment_date: Optional[date]
completion_date: Optional[date]
drop_reason: Optional[str]
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
+38
View File
@@ -0,0 +1,38 @@
import uuid
from datetime import date, datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict
class VisitCreate(BaseModel):
subject_id: uuid.UUID
visit_code: str
visit_name: str
planned_date: Optional[date] = None
window_start: Optional[date] = None
window_end: Optional[date] = None
class VisitUpdate(BaseModel):
actual_date: Optional[date] = None
status: Optional[str] = None
notes: Optional[str] = None
class VisitRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
subject_id: uuid.UUID
visit_code: str
visit_name: str
planned_date: Optional[date]
actual_date: Optional[date]
status: str
window_start: Optional[date]
window_end: Optional[date]
notes: Optional[str]
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.