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.
168 lines
6.3 KiB
Python
168 lines
6.3 KiB
Python
import uuid
|
|
from datetime import date
|
|
from typing import Sequence
|
|
|
|
from sqlalchemy import delete as sa_delete, select, update as sa_update
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.crud import visit as visit_crud
|
|
from app.models.ae import AdverseEvent
|
|
from app.models.finance import FinanceItem
|
|
from app.models.study import Study
|
|
from app.models.subject_history import SubjectHistory
|
|
from app.models.visit import Visit
|
|
from app.models.site import Site
|
|
from app.models.subject import Subject
|
|
from app.schemas.subject import SubjectCreate, SubjectUpdate
|
|
|
|
|
|
def _validate_subject_date_chain(
|
|
*,
|
|
screening_date: date | None,
|
|
consent_date: date | None,
|
|
enrollment_date: date | None,
|
|
completion_date: date | None,
|
|
) -> None:
|
|
if screening_date and consent_date and consent_date < screening_date:
|
|
raise ValueError("知情同意日期不能早于筛选日期")
|
|
if screening_date and enrollment_date and enrollment_date < screening_date:
|
|
raise ValueError("入组日期不能早于筛选日期")
|
|
if consent_date and enrollment_date and enrollment_date < consent_date:
|
|
raise ValueError("入组日期不能早于知情同意日期")
|
|
if screening_date and completion_date and completion_date < screening_date:
|
|
raise ValueError("完成日期不能早于筛选日期")
|
|
if consent_date and completion_date and completion_date < consent_date:
|
|
raise ValueError("完成日期不能早于知情同意日期")
|
|
if enrollment_date and completion_date and completion_date < enrollment_date:
|
|
raise ValueError("完成日期不能早于入组日期")
|
|
|
|
|
|
def _validate_actual_medication_count(value: int | None) -> None:
|
|
if value is not None and value < 0:
|
|
raise ValueError("实际用药次数不能小于0")
|
|
|
|
|
|
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()
|
|
if not site or site.study_id != study_id:
|
|
raise ValueError("分中心不属于当前项目")
|
|
if not site.is_active:
|
|
raise ValueError("中心已停用")
|
|
|
|
|
|
async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: SubjectCreate) -> Subject:
|
|
await _validate_site(db, study_id, subject_in.site_id)
|
|
_validate_subject_date_chain(
|
|
screening_date=subject_in.screening_date,
|
|
consent_date=subject_in.consent_date,
|
|
enrollment_date=subject_in.enrollment_date,
|
|
completion_date=None,
|
|
)
|
|
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,
|
|
consent_date=subject_in.consent_date,
|
|
enrollment_date=subject_in.enrollment_date,
|
|
baseline_date=subject_in.baseline_date,
|
|
completion_date=None,
|
|
drop_reason=None,
|
|
)
|
|
db.add(subject)
|
|
await db.commit()
|
|
await db.refresh(subject)
|
|
|
|
await sync_visits_from_baseline(db, subject)
|
|
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,
|
|
site_ids: set[uuid.UUID] | None = None,
|
|
) -> Sequence[Subject]:
|
|
stmt = select(Subject).where(Subject.study_id == study_id)
|
|
if site_ids is not None:
|
|
if not site_ids:
|
|
return []
|
|
stmt = stmt.where(Subject.site_id.in_(site_ids))
|
|
if site_id:
|
|
stmt = stmt.where(Subject.site_id == site_id)
|
|
result = await db.execute(stmt)
|
|
return result.scalars().all()
|
|
|
|
|
|
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
|
|
|
|
await visit_crud.create_scheduled_visits(
|
|
db,
|
|
study_id=subject.study_id,
|
|
subject=subject,
|
|
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
|
|
next_consent_date = update_data.get("consent_date", subject.consent_date)
|
|
next_enrollment_date = update_data.get("enrollment_date", subject.enrollment_date)
|
|
next_completion_date = update_data.get("completion_date", subject.completion_date)
|
|
_validate_subject_date_chain(
|
|
screening_date=next_screening_date,
|
|
consent_date=next_consent_date,
|
|
enrollment_date=next_enrollment_date,
|
|
completion_date=next_completion_date,
|
|
)
|
|
_validate_actual_medication_count(update_data.get("actual_medication_count", subject.actual_medication_count))
|
|
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
|
|
|
|
|
|
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))
|
|
await db.execute(sa_delete(SubjectHistory).where(SubjectHistory.subject_id == subject_id))
|
|
await db.execute(sa_delete(AdverseEvent).where(AdverseEvent.subject_id == subject_id))
|
|
await db.execute(sa_delete(FinanceItem).where(FinanceItem.subject_id == subject_id))
|
|
await db.delete(subject)
|
|
await db.commit()
|