Files
ctms/backend/app/crud/site.py
T
Cheng Zhou 917ab7ccf1 feat: refine subject visits and project workflows
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.
2026-05-09 17:10:34 +08:00

402 lines
14 KiB
Python

import uuid
from pathlib import Path
from typing import Iterable, Sequence
from sqlalchemy import delete, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.acknowledgement import Acknowledgement
from app.models.ae import AdverseEvent
from app.models.attachment import Attachment
from app.models.contract_fee import ContractFee
from app.models.contract_fee_payment import ContractFeePayment
from app.models.document import Document
from app.models.document_version import DocumentVersion
from app.models.distribution import Distribution
from app.models.drug_shipment import DrugShipment
from app.models.fee_attachment import FeeAttachment
from app.models.finance import FinanceItem
from app.models.finance_contract import FinanceContract
from app.models.finance_special import FinanceSpecial
from app.models.kickoff_meeting import KickoffMeeting
from app.models.knowledge_note import KnowledgeNote
from app.models.milestone import Milestone
from app.models.monitoring_visit_issue import MonitoringVisitIssue
from app.models.site import Site
from app.models.special_expense import SpecialExpense
from app.models.startup_ethics import StartupEthics
from app.models.startup_feasibility import StartupFeasibility
from app.models.study_center_confirm import StudyCenterConfirm
from app.models.subject import Subject
from app.models.subject_history import SubjectHistory
from app.models.training_authorization import TrainingAuthorization
from app.models.visit import Visit
from app.schemas.site import SiteCreate, SiteUpdate
ATTACHMENT_ROOT = Path(__file__).resolve().parent.parent / "uploads"
FEE_ATTACHMENT_ROOT = ATTACHMENT_ROOT / "fees"
DOCUMENT_ATTACHMENT_ROOT = ATTACHMENT_ROOT / "documents"
async def create_site(db: AsyncSession, study_id: uuid.UUID, site_in: SiteCreate) -> Site:
site = Site(
study_id=study_id,
name=site_in.name,
city=site_in.city,
pi_name=site_in.pi_name,
contact=site_in.contact,
is_active=site_in.is_active,
enrollment_plan_start_date=site_in.enrollment_plan_start_date,
enrollment_plan_end_date=site_in.enrollment_plan_end_date,
enrollment_plan_note=site_in.enrollment_plan_note,
)
db.add(site)
await db.commit()
await db.refresh(site)
return site
async def get_site(db: AsyncSession, site_id: uuid.UUID) -> Site | None:
result = await db.execute(select(Site).where(Site.id == site_id))
return result.scalar_one_or_none()
async def update_site(db: AsyncSession, site: Site, site_in: SiteUpdate) -> Site:
update_data = site_in.model_dump(exclude_unset=True)
if update_data:
await db.execute(
update(Site)
.where(Site.id == site.id)
.values(**update_data)
)
await db.commit()
await db.refresh(site)
return site
async def list_by_study(
db: AsyncSession,
study_id: uuid.UUID,
skip: int = 0,
limit: int = 100,
include_inactive: bool = False,
site_ids: set[uuid.UUID] | None = None,
) -> Sequence[Site]:
if site_ids is not None and not site_ids:
return []
stmt = select(Site).where(Site.study_id == study_id)
if not include_inactive:
stmt = stmt.where(Site.is_active.is_(True))
if site_ids is not None:
stmt = stmt.where(Site.id.in_(site_ids))
result = await db.execute(stmt.offset(skip).limit(limit))
return result.scalars().all()
async def get_sites_by_ids(db: AsyncSession, ids: set[uuid.UUID]) -> dict[uuid.UUID, Site]:
if not ids:
return {}
result = await db.execute(select(Site).where(Site.id.in_(ids)))
sites = result.scalars().all()
return {s.id: s for s in sites}
async def list_active_names(db: AsyncSession, study_id: uuid.UUID) -> set[str]:
result = await db.execute(
select(Site.name).where(Site.study_id == study_id, Site.is_active.is_(True))
)
return {row[0] for row in result.all() if row[0]}
def _contact_filter(user_id: str):
return or_(
Site.contact == user_id,
Site.contact.like(f"{user_id},%"),
Site.contact.like(f"%,{user_id}"),
Site.contact.like(f"%,{user_id},%"),
)
async def list_ids_by_contact_user(
db: AsyncSession,
study_id: uuid.UUID,
user_id: uuid.UUID,
*,
include_inactive: bool = False,
) -> set[uuid.UUID]:
user_id_str = str(user_id)
stmt = select(Site.id).where(Site.study_id == study_id, _contact_filter(user_id_str))
if not include_inactive:
stmt = stmt.where(Site.is_active.is_(True))
result = await db.execute(stmt)
return {row[0] for row in result.all() if row[0]}
async def list_names_by_contact_user(
db: AsyncSession,
study_id: uuid.UUID,
user_id: uuid.UUID,
*,
include_inactive: bool = False,
) -> set[str]:
user_id_str = str(user_id)
stmt = select(Site.name).where(Site.study_id == study_id, _contact_filter(user_id_str))
if not include_inactive:
stmt = stmt.where(Site.is_active.is_(True))
result = await db.execute(stmt)
return {row[0] for row in result.all() if row[0]}
async def delete_site_and_related(db: AsyncSession, site: Site) -> None:
site_id = site.id
study_id = site.study_id
site_name = site.name
subject_ids = (
await db.execute(select(Subject.id).where(Subject.site_id == site_id))
).scalars().all()
contract_fee_ids = (
await db.execute(select(ContractFee.id).where(ContractFee.center_id == site_id))
).scalars().all()
special_expense_ids = (
await db.execute(select(SpecialExpense.id).where(SpecialExpense.center_id == site_id))
).scalars().all()
drug_shipment_ids = (
await db.execute(select(DrugShipment.id).where(DrugShipment.center_id == site_id))
).scalars().all()
kickoff_ids = (
await db.execute(select(KickoffMeeting.id).where(KickoffMeeting.site_id == site_id))
).scalars().all()
feasibility_ids = (
await db.execute(select(StartupFeasibility.id).where(StartupFeasibility.site_id == site_id))
).scalars().all()
ethics_ids = (
await db.execute(select(StartupEthics.id).where(StartupEthics.site_id == site_id))
).scalars().all()
training_ids = (
await db.execute(
select(TrainingAuthorization.id).where(
TrainingAuthorization.study_id == study_id,
TrainingAuthorization.site_name == site_name,
)
)
).scalars().all()
finance_contract_ids = (
await db.execute(
select(FinanceContract.id).where(
FinanceContract.study_id == study_id,
FinanceContract.site_name == site_name,
)
)
).scalars().all()
finance_special_ids = (
await db.execute(
select(FinanceSpecial.id).where(
FinanceSpecial.study_id == study_id,
FinanceSpecial.site_name == site_name,
)
)
).scalars().all()
knowledge_note_ids = (
await db.execute(
select(KnowledgeNote.id).where(
KnowledgeNote.study_id == study_id,
KnowledgeNote.site_name == site_name,
)
)
).scalars().all()
document_ids = (
await db.execute(select(Document.id).where(Document.site_id == site_id))
).scalars().all()
version_ids = []
if document_ids:
version_ids = (
await db.execute(select(DocumentVersion.id).where(DocumentVersion.document_id.in_(document_ids)))
).scalars().all()
distribution_conditions = []
if document_ids:
distribution_conditions.append(Distribution.document_id.in_(document_ids))
if version_ids:
distribution_conditions.append(Distribution.version_id.in_(version_ids))
distribution_conditions.append((Distribution.target_type == "SITE") & (Distribution.target_id == str(site_id)))
distribution_ids = (
await db.execute(select(Distribution.id).where(or_(*distribution_conditions)))
).scalars().all()
attachment_groups: dict[str, list[uuid.UUID]] = {
"startup_feasibility": feasibility_ids,
"startup_ethics": ethics_ids,
"startup_kickoff": kickoff_ids,
"startup_kickoff_minutes": kickoff_ids,
"startup_kickoff_signin": kickoff_ids,
"startup_kickoff_ppt": kickoff_ids,
"training_authorization": training_ids,
"finance_contract": finance_contract_ids,
"finance_special": finance_special_ids,
"knowledge_note": knowledge_note_ids,
"drug_shipment": drug_shipment_ids,
}
attachment_paths: list[str] = []
for entity_type, ids in attachment_groups.items():
if not ids:
continue
attachment_paths.extend(
(
await db.execute(
select(Attachment.file_path).where(
Attachment.study_id == study_id,
Attachment.entity_type == entity_type,
Attachment.entity_id.in_(ids),
)
)
).scalars().all()
)
await db.execute(
delete(Attachment).where(
Attachment.study_id == study_id,
Attachment.entity_type == entity_type,
Attachment.entity_id.in_(ids),
)
)
fee_attachment_paths: list[str] = []
if contract_fee_ids:
fee_attachment_paths.extend(
(
await db.execute(
select(FeeAttachment.storage_key).where(
FeeAttachment.entity_type == "contract_fee",
FeeAttachment.entity_id.in_(contract_fee_ids),
)
)
).scalars().all()
)
await db.execute(
delete(FeeAttachment).where(
FeeAttachment.entity_type == "contract_fee",
FeeAttachment.entity_id.in_(contract_fee_ids),
)
)
await db.execute(delete(ContractFeePayment).where(ContractFeePayment.contract_fee_id.in_(contract_fee_ids)))
if special_expense_ids:
fee_attachment_paths.extend(
(
await db.execute(
select(FeeAttachment.storage_key).where(
FeeAttachment.entity_type == "special_expense",
FeeAttachment.entity_id.in_(special_expense_ids),
)
)
).scalars().all()
)
await db.execute(
delete(FeeAttachment).where(
FeeAttachment.entity_type == "special_expense",
FeeAttachment.entity_id.in_(special_expense_ids),
)
)
if distribution_ids:
await db.execute(delete(Acknowledgement).where(Acknowledgement.distribution_id.in_(distribution_ids)))
await db.execute(delete(Distribution).where(Distribution.id.in_(distribution_ids)))
await db.execute(delete(Acknowledgement).where(Acknowledgement.site_id == site_id))
version_file_paths: list[str] = []
if version_ids:
version_file_paths = (
await db.execute(select(DocumentVersion.file_uri).where(DocumentVersion.id.in_(version_ids)))
).scalars().all()
await db.execute(delete(DocumentVersion).where(DocumentVersion.id.in_(version_ids)))
if document_ids:
await db.execute(delete(Document).where(Document.id.in_(document_ids)))
if subject_ids:
await db.execute(delete(SubjectHistory).where(SubjectHistory.subject_id.in_(subject_ids)))
await db.execute(delete(Visit).where(Visit.subject_id.in_(subject_ids)))
await db.execute(delete(AdverseEvent).where(AdverseEvent.subject_id.in_(subject_ids)))
await db.execute(delete(FinanceItem).where(FinanceItem.subject_id.in_(subject_ids)))
await db.execute(delete(AdverseEvent).where(AdverseEvent.site_id == site_id))
await db.execute(delete(FinanceItem).where(FinanceItem.site_id == site_id))
await db.execute(delete(Subject).where(Subject.site_id == site_id))
await db.execute(delete(StudyCenterConfirm).where(StudyCenterConfirm.site_id == site_id))
await db.execute(delete(Milestone).where(Milestone.site_id == site_id))
await db.execute(delete(KickoffMeeting).where(KickoffMeeting.site_id == site_id))
await db.execute(delete(StartupFeasibility).where(StartupFeasibility.site_id == site_id))
await db.execute(delete(StartupEthics).where(StartupEthics.site_id == site_id))
await db.execute(delete(ContractFee).where(ContractFee.center_id == site_id))
await db.execute(delete(SpecialExpense).where(SpecialExpense.center_id == site_id))
await db.execute(delete(DrugShipment).where(DrugShipment.center_id == site_id))
await db.execute(
update(MonitoringVisitIssue)
.where(MonitoringVisitIssue.site_id == site_id)
.values(site_id=None)
)
await db.execute(
delete(TrainingAuthorization).where(
TrainingAuthorization.study_id == study_id,
TrainingAuthorization.site_name == site_name,
)
)
await db.execute(
delete(FinanceContract).where(
FinanceContract.study_id == study_id,
FinanceContract.site_name == site_name,
)
)
await db.execute(
delete(FinanceSpecial).where(
FinanceSpecial.study_id == study_id,
FinanceSpecial.site_name == site_name,
)
)
await db.execute(
delete(KnowledgeNote).where(
KnowledgeNote.study_id == study_id,
KnowledgeNote.site_name == site_name,
)
)
await db.execute(delete(Site).where(Site.id == site_id))
await _remove_files(attachment_paths, ATTACHMENT_ROOT)
await _remove_files(fee_attachment_paths, FEE_ATTACHMENT_ROOT)
await _remove_files(version_file_paths, DOCUMENT_ATTACHMENT_ROOT)
await db.commit()
async def _remove_files(paths: Iterable[str | None], root: Path) -> None:
for raw in paths:
if not raw:
continue
file_path = Path(str(raw))
try:
if file_path.exists():
file_path.unlink()
except OSError:
pass
_prune_empty_parents(file_path.parent, root)
def _prune_empty_parents(start: Path, root: Path) -> None:
try:
root_resolved = root.resolve()
except OSError:
return
try:
current = start.resolve()
except OSError:
return
if root_resolved not in current.parents and current != root_resolved:
return
while True:
if current == root_resolved:
break
try:
current.rmdir()
except OSError:
break
current = current.parent