未知(继上次中断)

This commit is contained in:
Cheng Zhou
2026-02-04 10:52:34 +08:00
parent 8e258d21a7
commit 737f84bf54
99 changed files with 5497 additions and 2143 deletions
+27 -4
View File
@@ -2,7 +2,7 @@ import uuid
from datetime import date, timedelta
from typing import Sequence
from sqlalchemy import select, update as sa_update
from sqlalchemy import or_, select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.ae import AdverseEvent
@@ -11,10 +11,20 @@ from app.models.subject import Subject
from app.schemas.ae import AECreate, AEUpdate
def _seriousness_rank(value: str | None) -> int | None:
if not value:
return None
normalized = str(value).strip().upper()
roman_map = {"I": 1, "II": 2, "III": 3, "IV": 4, "V": 5}
legacy_map = {"SERIOUS": 5, "NON_SERIOUS": 1}
return roman_map.get(normalized) or legacy_map.get(normalized)
def _calc_due_date(onset: date | None, seriousness: str) -> date | None:
if onset is None:
return None
delta = 1 if seriousness == "SERIOUS" else 7
rank = _seriousness_rank(seriousness)
delta = 1 if rank and rank >= 3 else 7
return onset + timedelta(days=delta)
@@ -54,8 +64,8 @@ async def create_ae(
term=ae_in.term,
onset_date=ae_in.onset_date,
resolution_date=None,
seriousness=ae_in.seriousness,
severity=ae_in.severity,
seriousness=str(ae_in.seriousness),
severity=str(ae_in.seriousness),
causality=ae_in.causality,
action_taken=None,
outcome=ae_in.outcome,
@@ -84,8 +94,19 @@ async def list_ae(
site_id: uuid.UUID | None = None,
subject_id: uuid.UUID | None = None,
overdue: bool | None = None,
site_ids: set[uuid.UUID] | None = None,
) -> Sequence[AdverseEvent]:
stmt = select(AdverseEvent).where(AdverseEvent.study_id == study_id)
if site_ids is not None:
if not site_ids:
return []
subject_site_ids = select(Subject.id).where(Subject.site_id.in_(site_ids))
stmt = stmt.where(
or_(
AdverseEvent.site_id.in_(site_ids),
AdverseEvent.subject_id.in_(subject_site_ids),
)
)
if status:
stmt = stmt.where(AdverseEvent.status == status)
if seriousness:
@@ -104,6 +125,8 @@ async def list_ae(
async def update_ae(db: AsyncSession, study_id: uuid.UUID, ae: AdverseEvent, ae_in: AEUpdate) -> AdverseEvent:
update_data = ae_in.model_dump(exclude_unset=True)
if "seriousness" in update_data:
update_data["seriousness"] = str(update_data["seriousness"])
if "subject_id" in update_data:
await _validate_site_subject(db, study_id, None, update_data["subject_id"])
if "onset_date" in update_data or "seriousness" in update_data:
+5
View File
@@ -48,6 +48,7 @@ async def list_contract_fees(
db: AsyncSession,
project_id: uuid.UUID,
center_id: uuid.UUID | None = None,
center_ids: set[uuid.UUID] | None = None,
q: str | None = None,
) -> Sequence[tuple[ContractFee, str, float, float, date | None, date | None]]:
paid_total = func.coalesce(
@@ -82,6 +83,10 @@ async def list_contract_fees(
if center_id:
stmt = stmt.where(ContractFee.center_id == center_id)
if center_ids is not None:
if not center_ids:
return []
stmt = stmt.where(ContractFee.center_id.in_(center_ids))
if q:
conditions = [Site.name.ilike(f"%{q}%")]
+6
View File
@@ -33,10 +33,16 @@ async def list_documents(
doc_type: str | None = None,
status: str | None = None,
scope_type: str | None = None,
cra_site_ids: set[uuid.UUID] | None = None,
skip: int = 0,
limit: int = 100,
) -> Sequence[Document]:
stmt = select(Document).where(Document.trial_id == trial_id)
if cra_site_ids is not None:
if not cra_site_ids:
stmt = stmt.where(Document.scope_type == "GLOBAL")
else:
stmt = stmt.where((Document.scope_type == "GLOBAL") | (Document.site_id.in_(cra_site_ids)))
if scope_type:
stmt = stmt.where(Document.scope_type == scope_type)
if doc_type:
+5
View File
@@ -44,6 +44,7 @@ async def list_shipments(
db: AsyncSession,
study_id: uuid.UUID,
center_id: uuid.UUID | None = None,
center_ids: set[uuid.UUID] | None = None,
site_name: str | None = None,
tracking_no: str | None = None,
direction: str | None = None,
@@ -52,6 +53,10 @@ async def list_shipments(
limit: int = 100,
) -> Sequence[DrugShipment]:
stmt = select(DrugShipment).where(DrugShipment.study_id == study_id)
if center_ids is not None:
if not center_ids:
return []
stmt = stmt.where(DrugShipment.center_id.in_(center_ids))
if center_id:
stmt = stmt.where(DrugShipment.center_id == center_id)
if site_name:
+5
View File
@@ -39,6 +39,7 @@ async def list_contracts(
db: AsyncSession,
study_id: uuid.UUID,
site_name: str | None = None,
site_names: set[str] | None = None,
contract_no: str | None = None,
skip: int = 0,
limit: int = 100,
@@ -47,6 +48,10 @@ async def list_contracts(
select(FinanceContract)
.where(FinanceContract.study_id == study_id)
)
if site_names is not None:
if not site_names:
return []
stmt = stmt.where(FinanceContract.site_name.in_(site_names))
if site_name:
stmt = stmt.where(FinanceContract.site_name.ilike(f"%{site_name}%"))
if contract_no:
+5
View File
@@ -39,6 +39,7 @@ async def list_specials(
db: AsyncSession,
study_id: uuid.UUID,
site_name: str | None = None,
site_names: set[str] | None = None,
fee_type: str | None = None,
skip: int = 0,
limit: int = 100,
@@ -47,6 +48,10 @@ async def list_specials(
select(FinanceSpecial)
.where(FinanceSpecial.study_id == study_id)
)
if site_names is not None:
if not site_names:
return []
stmt = stmt.where(FinanceSpecial.site_name.in_(site_names))
if site_name:
stmt = stmt.where(FinanceSpecial.site_name.ilike(f"%{site_name}%"))
if fee_type:
+44 -16
View File
@@ -28,9 +28,7 @@ from app.models.startup_feasibility import StartupFeasibility
from app.models.subject import Subject
from app.models.subject_history import SubjectHistory
from app.models.training_authorization import TrainingAuthorization
from app.models.version_workflow import VersionWorkflow
from app.models.visit import Visit
from app.models.workflow_action import WorkflowAction
from app.schemas.site import SiteCreate, SiteUpdate
ATTACHMENT_ROOT = Path(__file__).resolve().parent.parent / "uploads"
@@ -77,10 +75,15 @@ async def list_by_study(
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()
@@ -100,6 +103,45 @@ async def list_active_names(db: AsyncSession, study_id: uuid.UUID) -> set[str]:
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
@@ -167,17 +209,6 @@ async def delete_site_and_related(db: AsyncSession, site: Site) -> None:
await db.execute(select(DocumentVersion.id).where(DocumentVersion.document_id.in_(document_ids)))
).scalars().all()
workflow_ids = []
workflow_conditions = []
if document_ids:
workflow_conditions.append(VersionWorkflow.document_id.in_(document_ids))
if version_ids:
workflow_conditions.append(VersionWorkflow.version_id.in_(version_ids))
if workflow_conditions:
workflow_ids = (
await db.execute(select(VersionWorkflow.id).where(or_(*workflow_conditions)))
).scalars().all()
distribution_conditions = []
if document_ids:
distribution_conditions.append(Distribution.document_id.in_(document_ids))
@@ -261,9 +292,6 @@ async def delete_site_and_related(db: AsyncSession, site: Site) -> None:
)
)
if workflow_ids:
await db.execute(delete(WorkflowAction).where(WorkflowAction.workflow_id.in_(workflow_ids)))
await db.execute(delete(VersionWorkflow).where(VersionWorkflow.id.in_(workflow_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)))
+5
View File
@@ -44,6 +44,7 @@ async def list_special_expenses(
db: AsyncSession,
project_id: uuid.UUID,
center_id: uuid.UUID | None = None,
center_ids: set[uuid.UUID] | None = None,
category: str | None = None,
date_from: date | None = None,
date_to: date | None = None,
@@ -72,6 +73,10 @@ async def list_special_expenses(
if center_id:
stmt = stmt.where(SpecialExpense.center_id == center_id)
if center_ids is not None:
if not center_ids:
return []
stmt = stmt.where(SpecialExpense.center_id.in_(center_ids))
if category:
stmt = stmt.where(SpecialExpense.category == category)
if date_from:
+20
View File
@@ -51,7 +51,10 @@ async def list_feasibilities(
study_id: uuid.UUID,
skip: int = 0,
limit: int = 100,
site_ids: set[uuid.UUID] | None = None,
) -> Sequence[StartupFeasibility]:
if site_ids is not None and not site_ids:
return []
stmt = (
select(StartupFeasibility)
.where(StartupFeasibility.study_id == study_id)
@@ -59,6 +62,8 @@ async def list_feasibilities(
.offset(skip)
.limit(limit)
)
if site_ids is not None:
stmt = stmt.where(StartupFeasibility.site_id.in_(site_ids))
result = await db.execute(stmt)
return result.scalars().all()
@@ -111,7 +116,10 @@ async def list_ethics(
study_id: uuid.UUID,
skip: int = 0,
limit: int = 100,
site_ids: set[uuid.UUID] | None = None,
) -> Sequence[StartupEthics]:
if site_ids is not None and not site_ids:
return []
stmt = (
select(StartupEthics)
.where(StartupEthics.study_id == study_id)
@@ -119,6 +127,8 @@ async def list_ethics(
.offset(skip)
.limit(limit)
)
if site_ids is not None:
stmt = stmt.where(StartupEthics.site_id.in_(site_ids))
result = await db.execute(stmt)
return result.scalars().all()
@@ -168,7 +178,10 @@ async def list_kickoffs(
study_id: uuid.UUID,
skip: int = 0,
limit: int = 100,
site_ids: set[uuid.UUID] | None = None,
) -> Sequence[KickoffMeeting]:
if site_ids is not None and not site_ids:
return []
stmt = (
select(KickoffMeeting)
.where(KickoffMeeting.study_id == study_id)
@@ -176,6 +189,8 @@ async def list_kickoffs(
.offset(skip)
.limit(limit)
)
if site_ids is not None:
stmt = stmt.where(KickoffMeeting.site_id.in_(site_ids))
result = await db.execute(stmt)
return result.scalars().all()
@@ -227,7 +242,10 @@ async def list_training_authorizations(
study_id: uuid.UUID,
skip: int = 0,
limit: int = 200,
site_names: set[str] | None = None,
) -> Sequence[TrainingAuthorization]:
if site_names is not None and not site_names:
return []
stmt = (
select(TrainingAuthorization)
.where(TrainingAuthorization.study_id == study_id)
@@ -235,6 +253,8 @@ async def list_training_authorizations(
.offset(skip)
.limit(limit)
)
if site_names is not None:
stmt = stmt.where(TrainingAuthorization.site_name.in_(site_names))
result = await db.execute(stmt)
return result.scalars().all()
+1 -5
View File
@@ -114,7 +114,6 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None:
from app.models.faq_category import FaqCategory
from app.models.faq_item import FaqItem
from app.models.faq_reply import FaqReply
from app.models.workflow_template import WorkflowTemplate
# 按依赖关系顺序删除关联数据
# 1. 删除审计日志
@@ -128,10 +127,7 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None:
# 3. 删除知识库
await db.execute(sa_delete(KnowledgeNote).where(KnowledgeNote.study_id == study_id))
# 4. 删除工作流模板
await db.execute(sa_delete(WorkflowTemplate).where(WorkflowTemplate.trial_id == study_id))
# 5. 删除启动相关
# 4. 删除启动相关
await db.execute(sa_delete(KickoffMeeting).where(KickoffMeeting.study_id == study_id))
await db.execute(sa_delete(StartupEthics).where(StartupEthics.study_id == study_id))
await db.execute(sa_delete(StartupFeasibility).where(StartupFeasibility.study_id == study_id))
+14 -1
View File
@@ -2,11 +2,14 @@ import uuid
from datetime import date, timedelta
from typing import Sequence
from sqlalchemy import select, update as sa_update
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
@@ -60,8 +63,13 @@ 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)
@@ -125,5 +133,10 @@ async def update_subject(db: AsyncSession, subject: Subject, subject_in: Subject
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()
-30
View File
@@ -1,30 +0,0 @@
import uuid
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.version_workflow import VersionWorkflow
async def create(db: AsyncSession, workflow: VersionWorkflow, *, commit: bool = True) -> VersionWorkflow:
db.add(workflow)
if commit:
await db.commit()
await db.refresh(workflow)
return workflow
async def get(db: AsyncSession, workflow_id: uuid.UUID) -> VersionWorkflow | None:
result = await db.execute(select(VersionWorkflow).where(VersionWorkflow.id == workflow_id))
return result.scalar_one_or_none()
async def get_by_version(db: AsyncSession, version_id: uuid.UUID) -> VersionWorkflow | None:
result = await db.execute(select(VersionWorkflow).where(VersionWorkflow.version_id == version_id))
return result.scalar_one_or_none()
async def list_by_document(db: AsyncSession, document_id: uuid.UUID) -> Sequence[VersionWorkflow]:
result = await db.execute(select(VersionWorkflow).where(VersionWorkflow.document_id == document_id))
return result.scalars().all()
+79 -1
View File
@@ -2,7 +2,7 @@ import uuid
from datetime import date, timedelta
from typing import Sequence
from sqlalchemy import select, update as sa_update
from sqlalchemy import and_, func, or_, select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.subject import Subject
@@ -43,6 +43,54 @@ async def list_visits(db: AsyncSession, subject_id: uuid.UUID) -> Sequence[Visit
return result.scalars().all()
async def mark_overdue_as_lost(db: AsyncSession, subject_id: uuid.UUID) -> None:
today = func.current_date()
deadline = func.coalesce(Visit.window_end, Visit.planned_date)
await db.execute(
sa_update(Visit)
.where(
Visit.subject_id == subject_id,
Visit.actual_date.is_(None),
deadline.is_not(None),
deadline < today,
Visit.status.not_in(["DONE", "CANCELLED", "LOST"]),
)
.values(status="LOST")
)
await db.commit()
async def mark_overdue_as_lost_global(db: AsyncSession) -> None:
today = func.current_date()
deadline = func.coalesce(Visit.window_end, Visit.planned_date)
await db.execute(
sa_update(Visit)
.where(
Visit.actual_date.is_(None),
deadline.is_not(None),
deadline < today,
Visit.status.not_in(["DONE", "CANCELLED", "LOST"]),
)
.values(status="LOST")
)
await db.commit()
async def get_next_visit_code(db: AsyncSession, subject_id: uuid.UUID) -> str:
result = await db.execute(select(Visit.visit_code).where(Visit.subject_id == subject_id))
used: set[int] = set()
for (code,) in result.all():
if not code or not code.startswith("V"):
continue
num = code[1:]
if num.isdigit():
used.add(int(num))
next_num = 1
while next_num in used:
next_num += 1
return f"V{next_num}"
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()
@@ -72,6 +120,36 @@ async def delete_visit(db: AsyncSession, visit: Visit) -> None:
await db.commit()
async def list_lost_visits(
db: AsyncSession,
study_id: uuid.UUID,
*,
site_ids: set[uuid.UUID] | None = None,
limit: int = 50,
) -> Sequence[tuple[Visit, str, uuid.UUID | None]]:
if site_ids is not None and not site_ids:
return []
today = func.current_date()
deadline = func.coalesce(Visit.window_end, Visit.planned_date)
lost_condition = and_(
Visit.actual_date.is_(None),
deadline.is_not(None),
deadline < today,
Visit.status.not_in(["DONE", "CANCELLED"]),
)
stmt = (
select(Visit, Subject.subject_no, Subject.site_id)
.join(Subject, Subject.id == Visit.subject_id)
.where(Visit.study_id == study_id, lost_condition)
.order_by(Visit.updated_at.desc())
.limit(limit)
)
if site_ids is not None:
stmt = stmt.where(Subject.site_id.in_(site_ids))
result = await db.execute(stmt)
return result.all()
async def create_followup_visits(
db: AsyncSession,
*,
-11
View File
@@ -1,11 +0,0 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.workflow_action import WorkflowAction
async def create(db: AsyncSession, action: WorkflowAction, *, commit: bool = True) -> WorkflowAction:
db.add(action)
if commit:
await db.commit()
await db.refresh(action)
return action
-26
View File
@@ -1,26 +0,0 @@
import uuid
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.workflow_template import WorkflowTemplate
async def get(db: AsyncSession, template_id: uuid.UUID) -> WorkflowTemplate | None:
result = await db.execute(select(WorkflowTemplate).where(WorkflowTemplate.id == template_id))
return result.scalar_one_or_none()
async def list_active(db: AsyncSession, trial_id: uuid.UUID | None = None) -> Sequence[WorkflowTemplate]:
stmt = (
select(WorkflowTemplate)
.where(WorkflowTemplate.is_active.is_(True))
.options(selectinload(WorkflowTemplate.nodes))
)
if trial_id:
stmt = stmt.where(WorkflowTemplate.trial_id == trial_id)
result = await db.execute(stmt)
return result.scalars().all()