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.
402 lines
14 KiB
Python
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
|