917ab7ccf1
Add early termination visit workflow with ordering, non-applicable visit handling, visit window display, and medication adherence support. Extend monitoring visit issue template fields, site scoping, setup draft project info handling, login security UI, attachment behavior, and related tests/migrations.
239 lines
9.2 KiB
Python
239 lines
9.2 KiB
Python
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, require_study_not_locked
|
|
from app.crud import audit as audit_crud
|
|
from app.crud import site as site_crud
|
|
from app.crud import subject as subject_crud
|
|
from app.crud import study as study_crud
|
|
from app.crud import visit as visit_crud
|
|
from app.schemas.subject import SubjectUpdate
|
|
from app.schemas.visit import EarlyTerminationCreate, VisitCreate, 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="参与者不存在")
|
|
return subject
|
|
|
|
|
|
async def _ensure_subject_active(db: AsyncSession, subject) -> None:
|
|
if not subject or not subject.site_id:
|
|
return
|
|
site = await site_crud.get_site(db, subject.site_id)
|
|
if not site or not site.is_active:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="中心已停用")
|
|
|
|
|
|
@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)
|
|
await visit_crud.mark_overdue_as_lost(db, subject_id)
|
|
visits = await visit_crud.list_visits(db, subject_id)
|
|
return list(visits)
|
|
|
|
|
|
@router.post(
|
|
"/",
|
|
response_model=VisitRead,
|
|
status_code=status.HTTP_201_CREATED,
|
|
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
|
)
|
|
async def create_visit(
|
|
study_id: uuid.UUID,
|
|
subject_id: uuid.UUID,
|
|
visit_in: VisitCreate,
|
|
db: AsyncSession = Depends(get_db_session),
|
|
current_user=Depends(get_current_user),
|
|
) -> VisitRead:
|
|
subject = await _ensure_subject(db, study_id, subject_id)
|
|
await _ensure_subject_active(db, subject)
|
|
if visit_in.subject_id != subject_id:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="参与者不匹配")
|
|
if visit_in.window_start and visit_in.window_end and visit_in.window_start > visit_in.window_end:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="访视窗口结束日期不能早于开始日期")
|
|
if (
|
|
visit_in.planned_date
|
|
and visit_in.window_start
|
|
and visit_in.planned_date < visit_in.window_start
|
|
):
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="计划访视日期不能早于访视窗口开始日期")
|
|
if (
|
|
visit_in.planned_date
|
|
and visit_in.window_end
|
|
and visit_in.planned_date > visit_in.window_end
|
|
):
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="计划访视日期不能晚于访视窗口结束日期")
|
|
next_visit_code = await visit_crud.get_next_visit_code(db, subject_id)
|
|
if next_visit_code == "V1" and not visit_in.planned_date:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="首次新增访视必须填写计划访视日期")
|
|
visit = await visit_crud.create_visit(
|
|
db,
|
|
study_id=study_id,
|
|
visit_in=visit_in,
|
|
subject=subject,
|
|
visit_code=next_visit_code,
|
|
planned_date=visit_in.planned_date,
|
|
)
|
|
if next_visit_code == "V1" and visit_in.planned_date:
|
|
study = await study_crud.get(db, study_id)
|
|
if study:
|
|
await visit_crud.create_scheduled_visits(
|
|
db,
|
|
study_id=study_id,
|
|
subject=subject,
|
|
base_date=visit_in.planned_date,
|
|
visit_schedule=study.visit_schedule,
|
|
)
|
|
await audit_crud.log_action(
|
|
db,
|
|
study_id=study_id,
|
|
entity_type="visit",
|
|
entity_id=visit.id,
|
|
action="CREATE_VISIT",
|
|
detail=f"访视 {visit.visit_code} 已创建",
|
|
operator_id=current_user.id,
|
|
operator_role=current_user.role,
|
|
)
|
|
return visit
|
|
|
|
|
|
@router.post(
|
|
"/early-termination",
|
|
response_model=VisitRead,
|
|
status_code=status.HTTP_201_CREATED,
|
|
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
|
)
|
|
async def create_early_termination(
|
|
study_id: uuid.UUID,
|
|
subject_id: uuid.UUID,
|
|
termination_in: EarlyTerminationCreate,
|
|
db: AsyncSession = Depends(get_db_session),
|
|
current_user=Depends(get_current_user),
|
|
) -> VisitRead:
|
|
subject = await _ensure_subject(db, study_id, subject_id)
|
|
await _ensure_subject_active(db, subject)
|
|
reason = termination_in.reason.strip()
|
|
if not reason:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="提前终止原因不能为空")
|
|
if subject.baseline_date and termination_in.termination_date < subject.baseline_date:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="提前终止日期不能早于基线/治疗日期")
|
|
|
|
try:
|
|
visit = await visit_crud.create_early_termination_visit(
|
|
db,
|
|
study_id=study_id,
|
|
subject=subject,
|
|
termination_date=termination_in.termination_date,
|
|
reason=reason,
|
|
notes=termination_in.notes,
|
|
)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
|
await subject_crud.update_subject(
|
|
db,
|
|
subject,
|
|
SubjectUpdate(
|
|
status="DROPPED",
|
|
completion_date=termination_in.termination_date,
|
|
drop_reason=reason,
|
|
),
|
|
)
|
|
await audit_crud.log_action(
|
|
db,
|
|
study_id=study_id,
|
|
entity_type="visit",
|
|
entity_id=visit.id,
|
|
action="CREATE_EARLY_TERMINATION",
|
|
detail=f"参与者 {subject.subject_no} 已提前终止",
|
|
operator_id=current_user.id,
|
|
operator_role=current_user.role,
|
|
)
|
|
return visit
|
|
|
|
|
|
@router.patch(
|
|
"/{visit_id}",
|
|
response_model=VisitRead,
|
|
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
|
)
|
|
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:
|
|
subject = await _ensure_subject(db, study_id, subject_id)
|
|
await _ensure_subject_active(db, subject)
|
|
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="访视不存在")
|
|
if visit_in.status == "DONE" and not visit_in.actual_date:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="状态为已完成时必须填写实际日期")
|
|
if visit.window_start and visit.window_end and visit.window_start > visit.window_end:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前访视窗口配置异常,请先修复窗口日期")
|
|
if visit_in.actual_date and visit.window_start and visit_in.actual_date < visit.window_start:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="实际访视日期不能早于访视窗口开始日期")
|
|
if visit_in.actual_date and visit.window_end and visit_in.actual_date > visit.window_end:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="实际访视日期不能晚于访视窗口结束日期")
|
|
updated = await visit_crud.update_visit(db, visit, visit_in)
|
|
detail = None
|
|
if visit_in.status:
|
|
detail = f"访视 {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 "访视已更新",
|
|
operator_id=current_user.id,
|
|
operator_role=current_user.role,
|
|
)
|
|
return updated
|
|
|
|
|
|
@router.delete(
|
|
"/{visit_id}",
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
|
)
|
|
async def delete_visit(
|
|
study_id: uuid.UUID,
|
|
subject_id: uuid.UUID,
|
|
visit_id: uuid.UUID,
|
|
db: AsyncSession = Depends(get_db_session),
|
|
current_user=Depends(get_current_user),
|
|
) -> None:
|
|
subject = await _ensure_subject(db, study_id, subject_id)
|
|
await _ensure_subject_active(db, subject)
|
|
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="访视不存在")
|
|
await visit_crud.delete_visit(db, visit)
|
|
await audit_crud.log_action(
|
|
db,
|
|
study_id=study_id,
|
|
entity_type="visit",
|
|
entity_id=visit_id,
|
|
action="DELETE_VISIT",
|
|
detail=f"访视 {visit_id} 已删除",
|
|
operator_id=current_user.id,
|
|
operator_role=current_user.role,
|
|
)
|