未知(继上次中断)

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
@@ -0,0 +1,59 @@
"""migrate ae seriousness to roman numerals
Revision ID: 20260116_04
Revises: 20260116_03
Create Date: 2026-01-16 17:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "20260116_04"
down_revision: Union[str, None] = "20260116_03"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
conn.execute(
sa.text(
"""
UPDATE adverse_events
SET seriousness = CASE
WHEN UPPER(TRIM(seriousness)) = 'SERIOUS' THEN 'IV'
WHEN UPPER(TRIM(seriousness)) IN ('NON_SERIOUS', 'NON-SERIOUS', 'NON SERIOUS') THEN 'II'
WHEN TRIM(seriousness) = '1' THEN 'I'
WHEN TRIM(seriousness) = '2' THEN 'II'
WHEN TRIM(seriousness) = '3' THEN 'III'
WHEN TRIM(seriousness) = '4' THEN 'IV'
WHEN TRIM(seriousness) = '5' THEN 'V'
WHEN UPPER(TRIM(seriousness)) IN ('I', 'II', 'III', 'IV', 'V') THEN UPPER(TRIM(seriousness))
ELSE seriousness
END
"""
)
)
def downgrade() -> None:
conn = op.get_bind()
conn.execute(
sa.text(
"""
UPDATE adverse_events
SET seriousness = CASE
WHEN UPPER(TRIM(seriousness)) = 'I' THEN '1'
WHEN UPPER(TRIM(seriousness)) = 'II' THEN '2'
WHEN UPPER(TRIM(seriousness)) = 'III' THEN '3'
WHEN UPPER(TRIM(seriousness)) = 'IV' THEN '4'
WHEN UPPER(TRIM(seriousness)) = 'V' THEN '5'
ELSE seriousness
END
"""
)
)
@@ -0,0 +1,95 @@
"""remove document workflows
Revision ID: 20260116_05
Revises: 20260116_04
Create Date: 2026-01-16 17:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "20260116_05"
down_revision: Union[str, None] = "20260116_04"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_table("workflow_actions")
op.drop_table("version_workflows")
op.drop_table("workflow_nodes")
op.drop_table("workflow_templates")
op.execute("DROP TYPE IF EXISTS workflow_action_type")
op.execute("DROP TYPE IF EXISTS workflow_status")
def downgrade() -> None:
workflow_status = sa.Enum("PENDING", "APPROVED", "REJECTED", "CANCELED", name="workflow_status")
workflow_action_type = sa.Enum("SUBMIT", "APPROVE", "REJECT", name="workflow_action_type")
workflow_status.create(op.get_bind(), checkfirst=True)
workflow_action_type.create(op.get_bind(), checkfirst=True)
op.create_table(
"workflow_templates",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False),
sa.Column("trial_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("name", sa.String(length=200), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("is_active", sa.Boolean(), server_default="true", nullable=False),
sa.Column("created_by", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["trial_id"], ["studies.id"]),
sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
)
op.create_table(
"workflow_nodes",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False),
sa.Column("template_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("node_order", sa.Integer(), nullable=False),
sa.Column("role", sa.String(length=50), nullable=False),
sa.Column("name", sa.String(length=100), nullable=True),
sa.Column("is_required", sa.Boolean(), server_default="true", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["template_id"], ["workflow_templates.id"]),
sa.UniqueConstraint("template_id", "node_order", name="uq_workflow_nodes_order"),
)
op.create_table(
"version_workflows",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False),
sa.Column("document_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("version_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("template_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("status", workflow_status, server_default="PENDING", nullable=False),
sa.Column("current_node", sa.Integer(), nullable=True),
sa.Column("submitted_by", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("submitted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["document_id"], ["documents.id"]),
sa.ForeignKeyConstraint(["version_id"], ["document_versions.id"]),
sa.ForeignKeyConstraint(["template_id"], ["workflow_templates.id"]),
sa.ForeignKeyConstraint(["submitted_by"], ["users.id"]),
sa.UniqueConstraint("version_id", name="uq_version_workflow_version"),
)
op.create_table(
"workflow_actions",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False),
sa.Column("workflow_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("node_order", sa.Integer(), nullable=True),
sa.Column("action", workflow_action_type, nullable=False),
sa.Column("actor_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("comment", sa.Text(), nullable=True),
sa.Column("acted_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["workflow_id"], ["version_workflows.id"]),
sa.ForeignKeyConstraint(["actor_id"], ["users.id"]),
)
@@ -0,0 +1,41 @@
"""migrate ae seriousness cn to roman numerals
Revision ID: 20260116_06
Revises: 20260116_05
Create Date: 2026-01-16 18:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "20260116_06"
down_revision: Union[str, None] = "20260116_05"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
conn.execute(
sa.text(
"""
UPDATE adverse_events
SET seriousness = CASE
WHEN TRIM(seriousness) IN ('轻微', '轻度') THEN 'I'
WHEN TRIM(seriousness) = '一般' THEN 'II'
WHEN TRIM(seriousness) = '中度' THEN 'III'
WHEN TRIM(seriousness) IN ('严重', '重度') THEN 'IV'
WHEN TRIM(seriousness) = '危重' THEN 'V'
ELSE seriousness
END
"""
)
)
def downgrade() -> None:
pass
@@ -0,0 +1,41 @@
"""migrate ae seriousness enum strings to roman numerals
Revision ID: 20260116_07
Revises: 20260116_06
Create Date: 2026-01-16 19:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "20260116_07"
down_revision: Union[str, None] = "20260116_06"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
conn.execute(
sa.text(
"""
UPDATE adverse_events
SET seriousness = CASE
WHEN TRIM(seriousness) = 'AESERIOUSNESS.I' THEN 'I'
WHEN TRIM(seriousness) = 'AESERIOUSNESS.II' THEN 'II'
WHEN TRIM(seriousness) = 'AESERIOUSNESS.III' THEN 'III'
WHEN TRIM(seriousness) = 'AESERIOUSNESS.IV' THEN 'IV'
WHEN TRIM(seriousness) = 'AESERIOUSNESS.V' THEN 'V'
ELSE seriousness
END
"""
)
)
def downgrade() -> None:
pass
@@ -0,0 +1,41 @@
"""migrate ae seriousness enum strings case-insensitive
Revision ID: 20260116_08
Revises: 20260116_07
Create Date: 2026-01-16 19:10:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "20260116_08"
down_revision: Union[str, None] = "20260116_07"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
conn.execute(
sa.text(
"""
UPDATE adverse_events
SET seriousness = CASE
WHEN UPPER(TRIM(seriousness)) = 'AESERIOUSNESS.I' THEN 'I'
WHEN UPPER(TRIM(seriousness)) = 'AESERIOUSNESS.II' THEN 'II'
WHEN UPPER(TRIM(seriousness)) = 'AESERIOUSNESS.III' THEN 'III'
WHEN UPPER(TRIM(seriousness)) = 'AESERIOUSNESS.IV' THEN 'IV'
WHEN UPPER(TRIM(seriousness)) = 'AESERIOUSNESS.V' THEN 'V'
ELSE seriousness
END
"""
)
)
def downgrade() -> None:
pass
+35 -4
View File
@@ -5,7 +5,7 @@ from datetime import date
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_not_locked
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member, require_study_not_locked
from app.crud import ae as ae_crud
from app.crud import audit as audit_crud
from app.crud import member as member_crud
@@ -65,6 +65,14 @@ async def create_ae(
current_user=Depends(get_current_user),
) -> AERead:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope:
if ae_in.site_id and ae_in.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
if ae_in.subject_id:
subj = await subject_crud.get_subject(db, ae_in.subject_id)
if subj and subj.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
if ae_in.onset_date and ae_in.onset_date > date.today():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="发生日期不能晚于今天")
member_role = await _get_member_role(db, study_id, current_user.id)
@@ -102,9 +110,23 @@ async def list_ae(
subject_id: uuid.UUID | None = None,
overdue: bool | None = None,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[AERead]:
await _ensure_study_exists(db, study_id)
aes = await ae_crud.list_ae(db, study_id, status=status_filter, seriousness=seriousness, site_id=site_id, subject_id=subject_id, overdue=overdue)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_ids = cra_scope[0] if cra_scope else None
if site_id and site_ids is not None and site_id not in site_ids:
return []
aes = await ae_crud.list_ae(
db,
study_id,
status=status_filter,
seriousness=seriousness,
site_id=site_id,
subject_id=subject_id,
overdue=overdue,
site_ids=site_ids,
)
result: list[AERead] = []
for item in aes:
obj = AERead.model_validate(item)
@@ -122,6 +144,7 @@ async def get_ae(
study_id: uuid.UUID,
ae_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> AERead:
await _ensure_study_exists(db, study_id)
ae = await ae_crud.get_ae(db, ae_id)
@@ -129,6 +152,14 @@ async def get_ae(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
data = AERead.model_validate(ae)
data.is_overdue = _is_overdue(data)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope:
if data.site_id and data.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
if data.subject_id:
subj = await subject_crud.get_subject(db, data.subject_id)
if subj and subj.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return data
@@ -169,7 +200,7 @@ async def update_ae(
detail_before = {
"event": ae.term,
"onset_date": ae.onset_date,
"severity": ae.severity,
"seriousness": ae.seriousness,
"status": ae.status,
"description": ae.description,
}
@@ -177,7 +208,7 @@ async def update_ae(
detail_after = {
"event": updated.term,
"onset_date": updated.onset_date,
"severity": updated.severity,
"seriousness": updated.seriousness,
"status": updated.status,
"description": updated.description,
}
+89 -1
View File
@@ -3,9 +3,13 @@ from fastapi import APIRouter, Depends
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, require_study_member
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member
from app.models.milestone import Milestone
from app.schemas.progress import StudyProgressRead
from app.schemas.visit import VisitLostItem
from app.crud import visit as visit_crud
from app.schemas.dashboard import CenterSummaryItem
from app.crud import overview as overview_crud
router = APIRouter()
@@ -30,3 +34,87 @@ async def get_progress(
milestones_done=milestone_done,
completion_rate=completion_rate,
)
@router.get("/lost-visits", response_model=list[VisitLostItem], dependencies=[Depends(require_study_member())])
async def list_lost_visits(
study_id: uuid.UUID,
limit: int = 20,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[VisitLostItem]:
cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_ids = cra_scope[0] if cra_scope else None
rows = await visit_crud.list_lost_visits(db, study_id, site_ids=site_ids, limit=limit)
items: list[VisitLostItem] = []
for visit, subject_no, site_id in rows:
items.append(
VisitLostItem(
visit_id=visit.id,
subject_id=visit.subject_id,
subject_no=subject_no,
site_id=site_id,
visit_code=visit.visit_code,
status=visit.status,
updated_at=visit.updated_at,
)
)
return items
@router.get("/center-summary", response_model=list[CenterSummaryItem], dependencies=[Depends(require_study_member())])
async def get_center_summary(
study_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[CenterSummaryItem]:
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value not in {"ADMIN", "PM", "CRA"}:
return []
cra_scope = await get_cra_site_scope(db, study_id, current_user)
scope_ids = cra_scope[0] if cra_scope else None
scope_id_strs = {str(cid) for cid in scope_ids} if scope_ids is not None else None
overview = await overview_crud.get_project_overview(db, study_id)
centers = overview.get("centers") or []
stage_order = [
("institution_initiation_status", "机构立项"),
("ethics_status", "伦理审批"),
("contract_sign_status", "合同签署"),
("startup_status", "启动"),
("enrollment_status", "入组"),
("inspection_status", "末次稽查"),
("closeout_status", "关中心"),
]
summary_list: list[CenterSummaryItem] = []
for center in centers:
center_id = center.get("center_id")
if scope_id_strs is not None and center_id not in scope_id_strs:
continue
stage_label = "未开始"
stage_status = "NOT_STARTED"
for key, label in stage_order:
status = (center.get(key) or "NOT_STARTED").upper()
if status in ("IN_PROGRESS", "BLOCKED"):
stage_label = label
stage_status = status
break
if status == "COMPLETED":
stage_label = label
stage_status = status
continue
summary_list.append(
CenterSummaryItem(
id=center_id,
name=center.get("center_name") or "",
stage=stage_label,
stage_status=stage_status,
actual_enrolled=center.get("enrollment_actual") or 0,
planned_enrolled=center.get("enrollment_target") or 0,
)
)
return summary_list
+15 -61
View File
@@ -6,13 +6,12 @@ from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session
from app.crud import document_version as version_crud
from app.schemas.acknowledgement import AcknowledgementCreate, AcknowledgementRead
from app.schemas.common import PaginatedResponse
from app.schemas.distribution import DistributionCreate, DistributionRead
from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary
from app.schemas.document_version import DocumentVersionRead, VersionActionRequest, VersionSubmitRequest
from app.schemas.workflow_template import WorkflowTemplateRead
from app.schemas.document_version import DocumentVersionRead
from app.models.user import UserRole
from app.services import document_service
from app.utils.pagination import paginate
@@ -74,6 +73,9 @@ async def delete_document(
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DocumentSummary:
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value != UserRole.ADMIN.value:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员可删除文档")
doc = await document_service.delete_document(db, document_id, current_user)
return DocumentSummary.model_validate(doc)
@@ -86,6 +88,7 @@ async def delete_document(
async def create_version(
document_id: uuid.UUID,
version_no: str = Form(...),
version_date: str = Form(...),
change_summary: Optional[str] = Form(None),
parent_version_id: Optional[uuid.UUID] = Form(None),
file: UploadFile = File(...),
@@ -96,6 +99,7 @@ async def create_version(
db,
document_id,
version_no=version_no,
version_date=version_date,
change_summary=change_summary,
parent_version_id=parent_version_id,
file=file,
@@ -104,58 +108,6 @@ async def create_version(
return DocumentVersionRead.model_validate(version)
@router.post("/versions/{version_id}/submit", response_model=DocumentVersionRead)
async def submit_version(
version_id: uuid.UUID,
payload: VersionSubmitRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DocumentVersionRead:
await document_service.submit_version(db, version_id, payload.template_id, payload.comment, current_user)
version = await version_crud.get(db, version_id)
if not version:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="版本不存在")
return DocumentVersionRead.model_validate(version)
@router.post("/versions/{version_id}/approve", response_model=DocumentVersionRead)
async def approve_version(
version_id: uuid.UUID,
payload: VersionActionRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DocumentVersionRead:
await document_service.approve_version(db, version_id, payload.comment, current_user)
version = await version_crud.get(db, version_id)
if not version:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="版本不存在")
return DocumentVersionRead.model_validate(version)
@router.post("/versions/{version_id}/reject", response_model=DocumentVersionRead)
async def reject_version(
version_id: uuid.UUID,
payload: VersionActionRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DocumentVersionRead:
await document_service.reject_version(db, version_id, payload.comment, current_user)
version = await version_crud.get(db, version_id)
if not version:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="版本不存在")
return DocumentVersionRead.model_validate(version)
@router.post("/versions/{version_id}/make-effective", response_model=DocumentVersionRead)
async def make_version_effective(
version_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DocumentVersionRead:
version = await document_service.make_version_effective(db, version_id, current_user)
return DocumentVersionRead.model_validate(version)
@router.get("/versions/{version_id}/download", response_class=FileResponse)
async def download_version(
version_id: uuid.UUID,
@@ -165,14 +117,16 @@ async def download_version(
return await document_service.get_version_download_response(db, version_id, current_user)
@router.get("/workflow-templates", response_model=list[WorkflowTemplateRead])
async def list_workflow_templates(
trial_id: uuid.UUID | None = None,
@router.delete("/versions/{version_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_version(
version_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[WorkflowTemplateRead]:
templates = await document_service.list_workflow_templates(db, trial_id, current_user)
return [WorkflowTemplateRead.model_validate(t) for t in templates]
) -> None:
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value != UserRole.ADMIN.value:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员可删除版本")
await document_service.delete_version(db, version_id, current_user)
@router.post(
+20 -1
View File
@@ -3,7 +3,7 @@ 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.core.deps import get_cra_site_scope, 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 drug_shipment as shipment_crud
from app.crud import site as site_crud
@@ -42,6 +42,9 @@ async def create_shipment(
current_user=Depends(get_current_user),
) -> DrugShipmentRead:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and shipment_in.center_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
site = await _ensure_center_active(db, study_id, shipment_in.center_id)
shipment_in = shipment_in.model_copy(update={"site_name": site.name})
shipment = await shipment_crud.create_shipment(db, study_id, shipment_in, created_by=current_user.id)
@@ -73,12 +76,18 @@ async def list_shipments(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[DrugShipmentRead]:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
center_ids = cra_scope[0] if cra_scope else None
if center_id and center_ids is not None and center_id not in center_ids:
return []
items = await shipment_crud.list_shipments(
db,
study_id,
center_id=center_id,
center_ids=center_ids,
site_name=site_name,
tracking_no=tracking_no,
direction=direction,
@@ -98,11 +107,15 @@ async def get_shipment(
study_id: uuid.UUID,
shipment_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DrugShipmentRead:
await _ensure_study_exists(db, study_id)
shipment = await shipment_crud.get_shipment(db, shipment_id)
if not shipment or shipment.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="运输记录不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and shipment.center_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return DrugShipmentRead.model_validate(shipment)
@@ -122,6 +135,9 @@ async def update_shipment(
shipment = await shipment_crud.get_shipment(db, shipment_id)
if not shipment or shipment.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="运输记录不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and shipment.center_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
if shipment_in.center_id:
site = await _ensure_center_active(db, study_id, shipment_in.center_id)
shipment_in = shipment_in.model_copy(update={"site_name": site.name})
@@ -156,6 +172,9 @@ async def delete_shipment(
shipment = await shipment_crud.get_shipment(db, shipment_id)
if not shipment or shipment.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="运输记录不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and shipment.center_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_center_active(db, study_id, shipment.center_id)
await shipment_crud.delete_shipment(db, shipment)
await audit_crud.log_action(
+15 -2
View File
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.deps import get_current_user, get_db_session, require_study_not_locked
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked
from app.crud import audit as audit_crud
from app.crud import contract_fee as contract_fee_crud
from app.crud import contract_fee_payment as payment_crud
@@ -84,7 +84,17 @@ async def list_contract_fees(
current_user=Depends(get_current_user),
) -> FeeApiResponse[list[ContractFeeListItem]]:
await _ensure_project_access(db, project_id, current_user, write=False)
rows = await contract_fee_crud.list_contract_fees(db, project_id, center_id=center_id, q=q)
cra_scope = await get_cra_site_scope(db, project_id, current_user)
center_ids = cra_scope[0] if cra_scope else None
if center_id and center_ids is not None and center_id not in center_ids:
return FeeApiResponse(data=[], meta={"total": 0})
rows = await contract_fee_crud.list_contract_fees(
db,
project_id,
center_id=center_id,
center_ids=center_ids,
q=q,
)
items: list[ContractFeeListItem] = []
for contract, center_name, paid_total, verified_total, last_paid_date, last_verified_date in rows:
paid_total_decimal = _to_decimal(paid_total)
@@ -130,6 +140,9 @@ async def get_contract_fee(
if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_project_access(db, contract.project_id, current_user, write=False)
cra_scope = await get_cra_site_scope(db, contract.project_id, current_user)
if cra_scope and contract.center_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
payments = await payment_crud.list_payments(db, contract.id)
attachment_types = ["contract_fee_contract", "contract_fee_voucher", "contract_fee_invoice"]
+9 -1
View File
@@ -4,7 +4,7 @@ from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_not_locked
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked
from app.crud import audit as audit_crud
from app.crud import member as member_crud
from app.crud import special_expense as special_crud
@@ -76,10 +76,15 @@ async def list_special_expenses(
) -> FeeApiResponse[list[SpecialExpenseListItem]]:
await _ensure_project_access(db, project_id, current_user, write=False)
_validate_category(category)
cra_scope = await get_cra_site_scope(db, project_id, current_user)
center_ids = cra_scope[0] if cra_scope else None
if center_id and center_ids is not None and center_id not in center_ids:
return FeeApiResponse(data=[], meta={"total": 0})
rows = await special_crud.list_special_expenses(
db,
project_id,
center_id=center_id,
center_ids=center_ids,
category=category,
date_from=date_from,
date_to=date_to,
@@ -123,6 +128,9 @@ async def get_special_expense(
if not expense:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
await _ensure_project_access(db, expense.project_id, current_user, write=False)
cra_scope = await get_cra_site_scope(db, expense.project_id, current_user)
if cra_scope and expense.center_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return FeeApiResponse(data=SpecialExpenseRead.model_validate(expense))
+28 -2
View File
@@ -3,7 +3,7 @@ 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.core.deps import get_cra_site_scope, 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 finance_contract as contract_crud
from app.crud import site as site_crud
@@ -41,6 +41,9 @@ async def create_contract(
current_user=Depends(get_current_user),
) -> FinanceContractRead:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and contract_in.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, contract_in.site_name)
contract = await contract_crud.create_contract(db, study_id, contract_in, created_by=current_user.id)
await audit_crud.log_action(
@@ -68,9 +71,22 @@ async def list_contracts(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[FinanceContractRead]:
await _ensure_study_exists(db, study_id)
items = await contract_crud.list_contracts(db, study_id, site_name=site_name, contract_no=contract_no, skip=skip, limit=limit)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_names = cra_scope[1] if cra_scope else None
if site_name and site_names is not None and site_name not in site_names:
return []
items = await contract_crud.list_contracts(
db,
study_id,
site_name=site_name,
site_names=site_names,
contract_no=contract_no,
skip=skip,
limit=limit,
)
return [FinanceContractRead.model_validate(item) for item in items]
@@ -83,11 +99,15 @@ async def get_contract(
study_id: uuid.UUID,
contract_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FinanceContractRead:
await _ensure_study_exists(db, study_id)
contract = await contract_crud.get_contract(db, contract_id)
if not contract or contract.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and contract.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return FinanceContractRead.model_validate(contract)
@@ -107,6 +127,9 @@ async def update_contract(
contract = await contract_crud.get_contract(db, contract_id)
if not contract or contract.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and contract.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, contract.site_name)
contract = await contract_crud.update_contract(db, contract, contract_in)
await audit_crud.log_action(
@@ -137,6 +160,9 @@ async def delete_contract(
contract = await contract_crud.get_contract(db, contract_id)
if not contract or contract.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and contract.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, contract.site_name)
await contract_crud.delete_contract(db, contract)
await audit_crud.log_action(
+28 -2
View File
@@ -3,7 +3,7 @@ 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.core.deps import get_cra_site_scope, 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 finance_special as special_crud
from app.crud import site as site_crud
@@ -41,6 +41,9 @@ async def create_special(
current_user=Depends(get_current_user),
) -> FinanceSpecialRead:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and special_in.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, special_in.site_name)
item = await special_crud.create_special(db, study_id, special_in, created_by=current_user.id)
await audit_crud.log_action(
@@ -68,9 +71,22 @@ async def list_specials(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[FinanceSpecialRead]:
await _ensure_study_exists(db, study_id)
items = await special_crud.list_specials(db, study_id, site_name=site_name, fee_type=fee_type, skip=skip, limit=limit)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_names = cra_scope[1] if cra_scope else None
if site_name and site_names is not None and site_name not in site_names:
return []
items = await special_crud.list_specials(
db,
study_id,
site_name=site_name,
site_names=site_names,
fee_type=fee_type,
skip=skip,
limit=limit,
)
return [FinanceSpecialRead.model_validate(item) for item in items]
@@ -83,11 +99,15 @@ async def get_special(
study_id: uuid.UUID,
special_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FinanceSpecialRead:
await _ensure_study_exists(db, study_id)
item = await special_crud.get_special(db, special_id)
if not item or item.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and item.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return FinanceSpecialRead.model_validate(item)
@@ -107,6 +127,9 @@ async def update_special(
item = await special_crud.get_special(db, special_id)
if not item or item.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and item.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, item.site_name)
item = await special_crud.update_special(db, item, special_in)
await audit_crud.log_action(
@@ -137,6 +160,9 @@ async def delete_special(
item = await special_crud.get_special(db, special_id)
if not item or item.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and item.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, item.site_name)
await special_crud.delete_special(db, item)
await audit_crud.log_action(
+31
View File
@@ -0,0 +1,31 @@
import uuid
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, get_current_user, require_study_member
from app.schemas.notification import NotificationItem
from app.services import document_service
router = APIRouter()
@router.get(
"/notifications",
response_model=list[NotificationItem],
dependencies=[Depends(require_study_member())],
)
async def list_notifications(
study_id: uuid.UUID,
skip: int = 0,
limit: int = 20,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[NotificationItem]:
return await document_service.list_distribution_notifications(
db,
study_id,
current_user,
skip=skip,
limit=limit,
)
+2 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, finance_specials, fees_contracts, fees_specials, fees_attachments, drug_shipments, startup, knowledge_notes, subject_histories, faq_categories, faqs, documents, overview
from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, finance_specials, fees_contracts, fees_specials, fees_attachments, drug_shipments, startup, knowledge_notes, subject_histories, faq_categories, faqs, documents, overview, notifications
api_router = APIRouter()
@@ -9,6 +9,7 @@ api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"])
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(studies.router, prefix="/studies", tags=["studies"])
api_router.include_router(overview.router, prefix="/studies/{study_id}", tags=["overview"])
api_router.include_router(notifications.router, prefix="/studies/{study_id}", tags=["notifications"])
api_router.include_router(sites.router, prefix="/studies/{study_id}/sites", tags=["sites"])
api_router.include_router(members.router, prefix="/studies/{study_id}/members", tags=["study-members"])
api_router.include_router(attachments.router, prefix="/studies/{study_id}/{entity_type}/{entity_id}/attachments", tags=["attachments"])
+5 -1
View File
@@ -3,7 +3,7 @@ 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.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
from app.crud import site as site_crud
from app.crud import startup as startup_crud
from app.crud import study as study_crud
@@ -60,14 +60,18 @@ async def list_sites(
limit: int = 100,
include_inactive: bool = False,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[SiteRead]:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_ids = cra_scope[0] if cra_scope else None
sites = await site_crud.list_by_study(
db,
study_id,
skip=skip,
limit=limit,
include_inactive=include_inactive,
site_ids=site_ids,
)
return list(sites)
+77 -16
View File
@@ -3,7 +3,7 @@ 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.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member
from app.crud import audit as audit_crud
from app.crud import site as site_crud
from app.crud import startup as startup_crud
@@ -53,7 +53,7 @@ async def _ensure_site_active(db: AsyncSession, site_id: uuid.UUID | None):
"/feasibility",
response_model=StartupFeasibilityRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_member())],
)
async def create_feasibility(
study_id: uuid.UUID,
@@ -62,6 +62,9 @@ async def create_feasibility(
current_user=Depends(get_current_user),
) -> StartupFeasibilityRead:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record_in.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_active(db, record_in.site_id)
record = await startup_crud.create_feasibility(db, study_id, record_in, created_by=current_user.id)
await audit_crud.log_action(
@@ -87,9 +90,12 @@ async def list_feasibilities(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[StartupFeasibilityRead]:
await _ensure_study_exists(db, study_id)
items = await startup_crud.list_feasibilities(db, study_id, skip=skip, limit=limit)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_ids = cra_scope[0] if cra_scope else None
items = await startup_crud.list_feasibilities(db, study_id, skip=skip, limit=limit, site_ids=site_ids)
return [StartupFeasibilityRead.model_validate(item) for item in items]
@@ -102,18 +108,22 @@ async def get_feasibility(
study_id: uuid.UUID,
record_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> StartupFeasibilityRead:
await _ensure_study_exists(db, study_id)
record = await startup_crud.get_feasibility(db, record_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="立项记录不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return StartupFeasibilityRead.model_validate(record)
@router.patch(
"/feasibility/{record_id}",
response_model=StartupFeasibilityRead,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_member())],
)
async def update_feasibility(
study_id: uuid.UUID,
@@ -126,6 +136,9 @@ async def update_feasibility(
record = await startup_crud.get_feasibility(db, record_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="立项记录不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_active(db, record.site_id)
record = await startup_crud.update_feasibility(db, record, record_in)
await audit_crud.log_action(
@@ -144,7 +157,7 @@ async def update_feasibility(
@router.delete(
"/feasibility/{record_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_member())],
)
async def delete_feasibility(
study_id: uuid.UUID,
@@ -156,6 +169,9 @@ async def delete_feasibility(
record = await startup_crud.get_feasibility(db, record_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="立项记录不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_active(db, record.site_id)
await startup_crud.delete_feasibility(db, record)
await audit_crud.log_action(
@@ -174,7 +190,7 @@ async def delete_feasibility(
"/ethics",
response_model=StartupEthicsRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_member())],
)
async def create_ethics(
study_id: uuid.UUID,
@@ -183,6 +199,9 @@ async def create_ethics(
current_user=Depends(get_current_user),
) -> StartupEthicsRead:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record_in.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_active(db, record_in.site_id)
record = await startup_crud.create_ethics(db, study_id, record_in, created_by=current_user.id)
await audit_crud.log_action(
@@ -208,9 +227,12 @@ async def list_ethics(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[StartupEthicsRead]:
await _ensure_study_exists(db, study_id)
items = await startup_crud.list_ethics(db, study_id, skip=skip, limit=limit)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_ids = cra_scope[0] if cra_scope else None
items = await startup_crud.list_ethics(db, study_id, skip=skip, limit=limit, site_ids=site_ids)
return [StartupEthicsRead.model_validate(item) for item in items]
@@ -223,18 +245,22 @@ async def get_ethics(
study_id: uuid.UUID,
record_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> StartupEthicsRead:
await _ensure_study_exists(db, study_id)
record = await startup_crud.get_ethics(db, record_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="伦理记录不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return StartupEthicsRead.model_validate(record)
@router.patch(
"/ethics/{record_id}",
response_model=StartupEthicsRead,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_member())],
)
async def update_ethics(
study_id: uuid.UUID,
@@ -247,6 +273,9 @@ async def update_ethics(
record = await startup_crud.get_ethics(db, record_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="伦理记录不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_active(db, record.site_id)
record = await startup_crud.update_ethics(db, record, record_in)
await audit_crud.log_action(
@@ -265,7 +294,7 @@ async def update_ethics(
@router.delete(
"/ethics/{record_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_member())],
)
async def delete_ethics(
study_id: uuid.UUID,
@@ -277,6 +306,9 @@ async def delete_ethics(
record = await startup_crud.get_ethics(db, record_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="伦理记录不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_active(db, record.site_id)
await startup_crud.delete_ethics(db, record)
await audit_crud.log_action(
@@ -295,7 +327,7 @@ async def delete_ethics(
"/kickoff",
response_model=KickoffMeetingRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_member())],
)
async def create_kickoff(
study_id: uuid.UUID,
@@ -304,6 +336,9 @@ async def create_kickoff(
current_user=Depends(get_current_user),
) -> KickoffMeetingRead:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and meeting_in.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_active(db, meeting_in.site_id)
meeting = await startup_crud.create_kickoff(db, study_id, meeting_in, created_by=current_user.id)
await audit_crud.log_action(
@@ -329,9 +364,12 @@ async def list_kickoffs(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[KickoffMeetingRead]:
await _ensure_study_exists(db, study_id)
items = await startup_crud.list_kickoffs(db, study_id, skip=skip, limit=limit)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_ids = cra_scope[0] if cra_scope else None
items = await startup_crud.list_kickoffs(db, study_id, skip=skip, limit=limit, site_ids=site_ids)
return [KickoffMeetingRead.model_validate(item) for item in items]
@@ -344,18 +382,22 @@ async def get_kickoff(
study_id: uuid.UUID,
meeting_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> KickoffMeetingRead:
await _ensure_study_exists(db, study_id)
meeting = await startup_crud.get_kickoff(db, meeting_id)
if not meeting or meeting.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="启动会记录不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and meeting.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return KickoffMeetingRead.model_validate(meeting)
@router.patch(
"/kickoff/{meeting_id}",
response_model=KickoffMeetingRead,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_member())],
)
async def update_kickoff(
study_id: uuid.UUID,
@@ -368,6 +410,9 @@ async def update_kickoff(
meeting = await startup_crud.get_kickoff(db, meeting_id)
if not meeting or meeting.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="启动会记录不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and meeting.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_active(db, meeting.site_id)
meeting = await startup_crud.update_kickoff(db, meeting, meeting_in)
await audit_crud.log_action(
@@ -387,7 +432,7 @@ async def update_kickoff(
"/training-authorizations",
response_model=TrainingAuthorizationRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_member())],
)
async def create_training_authorization(
study_id: uuid.UUID,
@@ -396,6 +441,9 @@ async def create_training_authorization(
current_user=Depends(get_current_user),
) -> TrainingAuthorizationRead:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record_in.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, record_in.site_name)
record = await startup_crud.create_training_authorization(db, study_id, record_in, created_by=current_user.id)
await audit_crud.log_action(
@@ -421,9 +469,12 @@ async def list_training_authorizations(
skip: int = 0,
limit: int = 200,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[TrainingAuthorizationRead]:
await _ensure_study_exists(db, study_id)
items = await startup_crud.list_training_authorizations(db, study_id, skip=skip, limit=limit)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_names = cra_scope[1] if cra_scope else None
items = await startup_crud.list_training_authorizations(db, study_id, skip=skip, limit=limit, site_names=site_names)
return [TrainingAuthorizationRead.model_validate(item) for item in items]
@@ -436,18 +487,22 @@ async def get_training_authorization(
study_id: uuid.UUID,
record_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> TrainingAuthorizationRead:
await _ensure_study_exists(db, study_id)
record = await startup_crud.get_training_authorization(db, record_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="培训授权人员不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return TrainingAuthorizationRead.model_validate(record)
@router.patch(
"/training-authorizations/{record_id}",
response_model=TrainingAuthorizationRead,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_member())],
)
async def update_training_authorization(
study_id: uuid.UUID,
@@ -460,6 +515,9 @@ async def update_training_authorization(
record = await startup_crud.get_training_authorization(db, record_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="培训授权人员不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, record.site_name)
record = await startup_crud.update_training_authorization(db, record, record_in)
await audit_crud.log_action(
@@ -478,7 +536,7 @@ async def update_training_authorization(
@router.delete(
"/training-authorizations/{record_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_member())],
)
async def delete_training_authorization(
study_id: uuid.UUID,
@@ -490,6 +548,9 @@ async def delete_training_authorization(
record = await startup_crud.get_training_authorization(db, record_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="培训授权人员不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, record.site_name)
await startup_crud.delete_training_authorization(db, record)
await audit_crud.log_action(
+5
View File
@@ -84,6 +84,11 @@ async def update_study(
status_code=status.HTTP_403_FORBIDDEN,
detail="项目已锁定,无法编辑。请先解锁项目。"
)
if study_in.code and study_in.code != study.code:
existing = await study_crud.get_by_code(db, study_in.code)
if existing and existing.id != study.id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目编号已存在")
updated = await study_crud.update(db, study, study_in)
return updated
+48 -4
View File
@@ -1,13 +1,15 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
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.core.deps import get_cra_site_scope, 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.models.ae import AdverseEvent
from app.schemas.subject import SubjectCreate, SubjectRead, SubjectUpdate
router = APIRouter()
@@ -41,6 +43,9 @@ async def create_subject(
current_user=Depends(get_current_user),
) -> SubjectRead:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and subject_in.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
try:
subject = await subject_crud.create_subject(db, study_id, subject_in)
except ValueError as exc:
@@ -67,10 +72,31 @@ async def list_subjects(
study_id: uuid.UUID,
site_id: uuid.UUID | None = None,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[SubjectRead]:
await _ensure_study_exists(db, study_id)
subjects = await subject_crud.list_subjects(db, study_id, site_id=site_id)
return list(subjects)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_ids = cra_scope[0] if cra_scope else None
if site_id and site_ids is not None and site_id not in site_ids:
return []
subjects = await subject_crud.list_subjects(db, study_id, site_id=site_id, site_ids=site_ids)
subject_list = list(subjects)
if not subject_list:
return []
subject_ids = [subject.id for subject in subject_list]
ae_rows = await db.execute(
select(AdverseEvent.subject_id).where(
AdverseEvent.study_id == study_id,
AdverseEvent.subject_id.in_(subject_ids),
)
)
ae_subject_ids = {row[0] for row in ae_rows.all() if row[0]}
result: list[SubjectRead] = []
for subject in subject_list:
data = SubjectRead.model_validate(subject)
data.has_ae = subject.id in ae_subject_ids
result.append(data)
return result
@router.get(
@@ -82,13 +108,25 @@ async def get_subject(
study_id: uuid.UUID,
subject_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> SubjectRead:
await _ensure_study_exists(db, study_id)
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="参与者不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and subject.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_subject_active(db, subject)
return subject
data = SubjectRead.model_validate(subject)
ae_row = await db.execute(
select(AdverseEvent.id).where(
AdverseEvent.study_id == study_id,
AdverseEvent.subject_id == subject.id,
).limit(1)
)
data.has_ae = ae_row.scalar_one_or_none() is not None
return data
@router.patch(
@@ -107,6 +145,9 @@ async def update_subject(
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="参与者不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and subject.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_subject_active(db, subject)
old_status = subject.status
updated = await subject_crud.update_subject(db, subject, subject_in)
@@ -144,6 +185,9 @@ async def delete_subject(
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="参与者不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and subject.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await subject_crud.delete_subject(db, subject)
await audit_crud.log_action(
db,
+6 -2
View File
@@ -40,6 +40,7 @@ async def list_visits(
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)
@@ -61,15 +62,18 @@ async def create_visit(
await _ensure_subject_active(db, subject)
if visit_in.subject_id != subject_id:
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=visit_in.visit_code,
visit_code=next_visit_code,
planned_date=visit_in.planned_date,
)
if visit_in.visit_code == "V1" and 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_followup_visits(
+19
View File
@@ -9,6 +9,7 @@ from app.core.exceptions import AppException
from app.core.security import decode_token, oauth2_scheme
from app.crud import user as user_crud
from app.crud import member as member_crud
from app.crud import site as site_crud
from app.db.session import SessionLocal
from app.schemas.user import TokenPayload
@@ -117,6 +118,24 @@ def require_study_roles(roles: Iterable[str]):
return dependency
async def get_cra_site_scope(
db: AsyncSession,
study_id: uuid.UUID,
current_user,
) -> tuple[set[uuid.UUID], set[str]] | None:
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value == "ADMIN":
return None
membership = await member_crud.get_member(db, study_id, current_user.id)
if not membership or not membership.is_active:
return None
if membership.role_in_study != "CRA":
return None
site_ids = await site_crud.list_ids_by_contact_user(db, study_id, current_user.id)
site_names = await site_crud.list_names_by_contact_user(db, study_id, current_user.id)
return site_ids, site_names
def require_study_not_locked():
"""检查项目是否被锁定,如果锁定则拒绝所有修改请求"""
from app.crud import study as study_crud
+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()
-4
View File
@@ -9,10 +9,6 @@ from app.models.attachment import Attachment # noqa: F401
from app.models.audit_log import AuditLog # noqa: F401
from app.models.document import Document # noqa: F401
from app.models.document_version import DocumentVersion # noqa: F401
from app.models.workflow_template import WorkflowTemplate # noqa: F401
from app.models.workflow_node import WorkflowNode # noqa: F401
from app.models.version_workflow import VersionWorkflow # noqa: F401
from app.models.workflow_action import WorkflowAction # noqa: F401
from app.models.distribution import Distribution # noqa: F401
from app.models.acknowledgement import Acknowledgement # noqa: F401
from app.models.milestone import Milestone # noqa: F401
+6
View File
@@ -1,3 +1,4 @@
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI
@@ -11,10 +12,13 @@ from app.crud.user import ensure_admin_exists
from app.db.base import Base
from app.db.session import SessionLocal, engine
from app.services.fee_seed import seed_demo_fees
from app.services.visit_scheduler import run_daily_lost_visit_job
@asynccontextmanager
async def lifespan(_: FastAPI):
stop_event = asyncio.Event()
scheduler_task = asyncio.create_task(run_daily_lost_visit_job(stop_event))
# Ensure models are imported so metadata is populated
from app.models import user as user_model # noqa: F401
@@ -26,6 +30,8 @@ async def lifespan(_: FastAPI):
await ensure_admin_exists(session)
await seed_demo_fees(session)
yield
stop_event.set()
await scheduler_task
async def _ensure_legacy_primary_keys(conn) -> None:
-1
View File
@@ -57,5 +57,4 @@ class DocumentVersion(Base):
document = relationship("Document", back_populates="versions", foreign_keys=[document_id])
parent_version = relationship("DocumentVersion", remote_side=[id], foreign_keys=[parent_version_id])
derived_from_version = relationship("DocumentVersion", remote_side=[id], foreign_keys=[derived_from_version_id])
workflow_instance = relationship("VersionWorkflow", back_populates="version", uselist=False)
distributions = relationship("Distribution", back_populates="version")
-47
View File
@@ -1,47 +0,0 @@
import enum
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base_class import Base
class WorkflowStatus(str, enum.Enum):
PENDING = "PENDING"
APPROVED = "APPROVED"
REJECTED = "REJECTED"
CANCELED = "CANCELED"
class VersionWorkflow(Base):
__tablename__ = "version_workflows"
__table_args__ = (UniqueConstraint("version_id", name="uq_version_workflow_version"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
document_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("documents.id"), nullable=False)
version_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("document_versions.id"), nullable=False)
template_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("workflow_templates.id"), nullable=False)
status: Mapped[WorkflowStatus] = mapped_column(
Enum(WorkflowStatus, name="workflow_status"),
nullable=False,
server_default=WorkflowStatus.PENDING.value,
)
current_node: Mapped[int | None] = mapped_column(Integer, nullable=True)
submitted_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
template = relationship("WorkflowTemplate")
version = relationship("DocumentVersion", back_populates="workflow_instance")
document = relationship("Document")
actions = relationship("WorkflowAction", back_populates="workflow", cascade="all, delete-orphan")
-32
View File
@@ -1,32 +0,0 @@
import enum
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base_class import Base
class WorkflowActionType(str, enum.Enum):
SUBMIT = "SUBMIT"
APPROVE = "APPROVE"
REJECT = "REJECT"
class WorkflowAction(Base):
__tablename__ = "workflow_actions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
workflow_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("version_workflows.id"), nullable=False)
node_order: Mapped[int | None] = mapped_column(Integer, nullable=True)
action: Mapped[WorkflowActionType] = mapped_column(
Enum(WorkflowActionType, name="workflow_action_type"),
nullable=False,
)
actor_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
comment: Mapped[str | None] = mapped_column(Text, nullable=True)
acted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
workflow = relationship("VersionWorkflow", back_populates="actions")
-23
View File
@@ -1,23 +0,0 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base_class import Base
class WorkflowNode(Base):
__tablename__ = "workflow_nodes"
__table_args__ = (UniqueConstraint("template_id", "node_order", name="uq_workflow_nodes_order"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
template_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("workflow_templates.id"), nullable=False)
node_order: Mapped[int] = mapped_column(Integer, nullable=False)
role: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str | None] = mapped_column(String(100), nullable=True)
is_required: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
template = relationship("WorkflowTemplate", back_populates="nodes")
-33
View File
@@ -1,33 +0,0 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base_class import Base
class WorkflowTemplate(Base):
__tablename__ = "workflow_templates"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
trial_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true")
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
nodes = relationship(
"WorkflowNode",
back_populates="template",
cascade="all, delete-orphan",
order_by="WorkflowNode.node_order",
)
+36 -7
View File
@@ -1,8 +1,17 @@
import uuid
from datetime import date, datetime
from typing import Optional
from enum import Enum
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, field_validator
class AESeriousness(str, Enum):
I = "I"
II = "II"
III = "III"
IV = "IV"
V = "V"
class AECreate(BaseModel):
@@ -11,8 +20,7 @@ class AECreate(BaseModel):
visit_id: Optional[uuid.UUID] = None
term: str
onset_date: Optional[date] = None
seriousness: str
severity: str
seriousness: AESeriousness
causality: Optional[str] = None
description: Optional[str] = None
outcome: Optional[str] = None
@@ -23,8 +31,7 @@ class AEUpdate(BaseModel):
term: Optional[str] = None
onset_date: Optional[date] = None
resolution_date: Optional[date] = None
seriousness: Optional[str] = None
severity: Optional[str] = None
seriousness: Optional[AESeriousness] = None
causality: Optional[str] = None
action_taken: Optional[str] = None
outcome: Optional[str] = None
@@ -42,8 +49,7 @@ class AERead(BaseModel):
term: str
onset_date: Optional[date]
resolution_date: Optional[date]
seriousness: str
severity: str
seriousness: AESeriousness
causality: Optional[str]
action_taken: Optional[str]
outcome: Optional[str]
@@ -57,3 +63,26 @@ class AERead(BaseModel):
is_overdue: bool | None = None
model_config = ConfigDict(from_attributes=True)
@field_validator("seriousness", mode="before")
@classmethod
def normalize_seriousness(cls, value):
if value is None:
return AESeriousness.I
normalized = str(value).strip().upper()
digit_map = {
"1": "I",
"2": "II",
"3": "III",
"4": "IV",
"5": "V",
}
legacy_map = {
"SERIOUS": "IV",
"NON_SERIOUS": "II",
}
if normalized in digit_map:
return digit_map[normalized]
if normalized in legacy_map:
return legacy_map[normalized]
return normalized
+10
View File
@@ -0,0 +1,10 @@
import uuid
from pydantic import BaseModel
class CenterSummaryItem(BaseModel):
id: uuid.UUID
name: str
stage: str
stage_status: str | None = None
actual_enrolled: int
planned_enrolled: int
-9
View File
@@ -45,12 +45,3 @@ class DocumentVersionRead(BaseModel):
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class VersionSubmitRequest(BaseModel):
template_id: uuid.UUID
comment: Optional[str] = None
class VersionActionRequest(BaseModel):
comment: Optional[str] = None
+18
View File
@@ -0,0 +1,18 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class NotificationItem(BaseModel):
id: uuid.UUID
document_id: uuid.UUID
version_id: uuid.UUID
document_title: str
document_no: str | None = None
version_no: str
change_summary: str | None = None
effective_at: datetime | None = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
+2 -1
View File
@@ -20,6 +20,7 @@ class StudyCreate(BaseModel):
class StudyUpdate(BaseModel):
code: Optional[str] = None
name: Optional[str] = None
protocol_no: Optional[str] = None
phase: Optional[str] = None
@@ -33,9 +34,9 @@ class StudyUpdate(BaseModel):
class StudyRead(BaseModel):
id: uuid.UUID
code: str
name: str
protocol_no: Optional[str]
protocol_no: Optional[str]
phase: Optional[str]
status: StudyStatus
is_locked: bool
+1
View File
@@ -33,5 +33,6 @@ class SubjectRead(BaseModel):
drop_reason: Optional[str]
created_at: datetime
updated_at: datetime
has_ae: Optional[bool] = None
model_config = ConfigDict(from_attributes=True)
+12
View File
@@ -34,3 +34,15 @@ class VisitRead(BaseModel):
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class VisitLostItem(BaseModel):
visit_id: uuid.UUID
subject_id: uuid.UUID
subject_no: str
site_id: uuid.UUID | None
visit_code: str
status: str
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
-29
View File
@@ -1,29 +0,0 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class WorkflowNodeRead(BaseModel):
id: uuid.UUID
node_order: int
role: str
name: str | None = None
is_required: bool
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class WorkflowTemplateRead(BaseModel):
id: uuid.UUID
trial_id: uuid.UUID | None = None
name: str
description: str | None = None
is_active: bool
created_by: uuid.UUID | None = None
created_at: datetime
updated_at: datetime
nodes: list[WorkflowNodeRead] = []
model_config = ConfigDict(from_attributes=True)
+182 -215
View File
@@ -8,10 +8,11 @@ from typing import Iterable
import aiofiles
from fastapi import HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy import update as sa_update
from sqlalchemy import delete as sa_delete, or_, select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core import rbac
from app.core.deps import get_cra_site_scope
from app.crud import acknowledgement as acknowledgement_crud
from app.crud import distribution as distribution_crud
from app.crud import document as document_crud
@@ -20,20 +21,16 @@ from app.crud import member as member_crud
from app.crud import site as site_crud
from app.crud import study as study_crud
from app.crud import user as user_crud
from app.crud import version_workflow as workflow_crud
from app.crud import workflow_template as template_crud
from app.models.acknowledgement import Acknowledgement, AcknowledgementType
from app.models.audit_log import AuditLog
from app.models.distribution import Distribution, DistributionTargetType
from app.models.distribution import Distribution, DistributionStatus, DistributionTargetType
from app.models.document import Document, DocumentScopeType, DocumentStatus
from app.models.document_version import DocumentVersion, DocumentVersionStatus
from app.models.version_workflow import VersionWorkflow, WorkflowStatus
from app.models.workflow_action import WorkflowAction, WorkflowActionType
from app.models.workflow_template import WorkflowTemplate
from app.schemas.acknowledgement import AcknowledgementCreate
from app.schemas.distribution import DistributionCreate, DistributionRead, DistributionStats
from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary
from app.schemas.document_version import DocumentVersionRead, DocumentVersionSummary
from app.schemas.notification import NotificationItem
from app.schemas.user import UserDisplay
UPLOAD_ROOT = Path(__file__).resolve().parent.parent / "uploads" / "documents"
@@ -83,6 +80,18 @@ async def _ensure_study_access(db: AsyncSession, trial_id: uuid.UUID, current_us
return membership
async def _ensure_study_member(db: AsyncSession, trial_id: uuid.UUID, current_user):
study = await study_crud.get(db, trial_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
if current_user.role == "ADMIN":
return None
membership = await member_crud.get_member(db, trial_id, current_user.id)
if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
return membership
async def create_document(
db: AsyncSession,
doc_in: DocumentCreate,
@@ -140,6 +149,8 @@ async def list_documents(
current_user,
) -> list[DocumentSummary]:
await _ensure_study_access(db, trial_id, current_user, action="view")
cra_scope = await get_cra_site_scope(db, trial_id, current_user)
cra_site_ids = cra_scope[0] if cra_scope else None
docs = await document_crud.list_documents(
db,
trial_id=trial_id,
@@ -147,6 +158,7 @@ async def list_documents(
doc_type=doc_type,
status=status,
scope_type=scope_type,
cra_site_ids=cra_site_ids,
skip=skip,
limit=limit,
)
@@ -185,6 +197,9 @@ async def get_document_detail(
if not doc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在")
await _ensure_study_access(db, doc.trial_id, current_user, action="view")
cra_scope = await get_cra_site_scope(db, doc.trial_id, current_user)
if cra_scope and doc.scope_type == DocumentScopeType.SITE and doc.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
versions = await version_crud.list_by_document(db, document_id)
current_version = None
@@ -223,6 +238,7 @@ async def create_version(
document_id: uuid.UUID,
*,
version_no: str,
version_date: str,
change_summary: str | None,
parent_version_id: uuid.UUID | None,
file: UploadFile,
@@ -233,6 +249,8 @@ async def create_version(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在")
await _ensure_study_access(db, doc.trial_id, current_user, action="create_version")
previous_effective_id = doc.current_effective_version_id
existing = await version_crud.get_by_document_version_no(db, document_id, version_no)
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="版本号已存在")
@@ -251,11 +269,21 @@ async def create_version(
async with aiofiles.open(dest_path, "wb") as out_file:
await out_file.write(content)
now = datetime.now(timezone.utc)
effective_at = now
try:
parsed = datetime.fromisoformat(version_date)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
effective_at = parsed
except ValueError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="版本日期格式不正确")
version = DocumentVersion(
document_id=document_id,
version_no=version_no,
parent_version_id=parent_version_id,
status=DocumentVersionStatus.DRAFT,
status=DocumentVersionStatus.EFFECTIVE,
effective_at=effective_at,
file_uri=str(dest_path),
file_hash=file_hash,
file_size=len(content),
@@ -264,6 +292,21 @@ async def create_version(
created_by=current_user.id,
)
db.add(version)
await db.flush()
await db.execute(
sa_update(DocumentVersion)
.where(
DocumentVersion.document_id == document_id,
DocumentVersion.status == DocumentVersionStatus.EFFECTIVE,
DocumentVersion.id != version.id,
)
.values(status=DocumentVersionStatus.SUPERSEDED, superseded_at=now)
)
await db.execute(
sa_update(Document)
.where(Document.id == document_id)
.values(current_effective_version_id=version.id)
)
db.add(
AuditLog(
study_id=doc.trial_id,
@@ -275,227 +318,98 @@ async def create_version(
operator_role=_role_value(current_user),
)
)
if previous_effective_id:
previous_distributions = await distribution_crud.list_by_version(db, previous_effective_id)
if previous_distributions:
seen: set[tuple[str, str]] = set()
for dist in previous_distributions:
key = (dist.target_type, dist.target_id)
if key in seen:
continue
seen.add(key)
db.add(
Distribution(
document_id=document_id,
version_id=version.id,
target_type=dist.target_type,
target_id=dist.target_id,
created_by=current_user.id,
)
)
await db.commit()
await db.refresh(version)
return version
async def submit_version(
async def delete_version(
db: AsyncSession,
version_id: uuid.UUID,
template_id: uuid.UUID,
comment: str | None,
current_user,
) -> VersionWorkflow:
) -> None:
version = await version_crud.get(db, version_id)
if not version:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="版本不存在")
doc = await document_crud.get(db, version.document_id)
if not doc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在")
await _ensure_study_access(db, doc.trial_id, current_user, action="submit")
await _ensure_study_access(db, doc.trial_id, current_user, action="delete_document")
if version.status != DocumentVersionStatus.DRAFT:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="版本状态不允许提交")
pending = await version_crud.list_pending_versions(db, version.document_id)
if any(v.id != version.id for v in pending):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="存在待审批版本")
template = await template_crud.get(db, template_id)
if not template:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="审批模板不存在")
distribution_ids = (
await db.execute(
select(Distribution.id).where(Distribution.version_id == version.id)
)
).scalars().all()
if distribution_ids:
await db.execute(sa_delete(Acknowledgement).where(Acknowledgement.distribution_id.in_(distribution_ids)))
await db.execute(sa_delete(Distribution).where(Distribution.id.in_(distribution_ids)))
before = _version_snapshot(version)
version.status = DocumentVersionStatus.SUBMITTED
version.submitted_at = datetime.now(timezone.utc)
workflow = VersionWorkflow(
document_id=version.document_id,
version_id=version.id,
template_id=template_id,
status=WorkflowStatus.PENDING,
current_node=1,
submitted_by=current_user.id,
submitted_at=version.submitted_at,
)
action = WorkflowAction(
workflow=workflow,
node_order=workflow.current_node,
action=WorkflowActionType.SUBMIT,
actor_id=current_user.id,
comment=comment,
)
db.add(version)
db.add(workflow)
db.add(action)
if doc.current_effective_version_id == version.id:
candidates = await version_crud.list_by_document(db, version.document_id)
candidate = next((item for item in candidates if item.id != version.id), None)
if candidate:
update_values: dict[str, object] = {"status": DocumentVersionStatus.EFFECTIVE, "superseded_at": None}
if not candidate.effective_at:
update_values["effective_at"] = datetime.now(timezone.utc)
await db.execute(
sa_update(DocumentVersion)
.where(DocumentVersion.id == candidate.id)
.values(**update_values)
)
await db.execute(
sa_update(Document)
.where(Document.id == doc.id)
.values(current_effective_version_id=candidate.id)
)
else:
await db.execute(
sa_update(Document)
.where(Document.id == doc.id)
.values(current_effective_version_id=None)
)
await db.execute(sa_delete(DocumentVersion).where(DocumentVersion.id == version.id))
db.add(
AuditLog(
study_id=doc.trial_id,
entity_type="DOCUMENT_VERSION",
entity_id=version.id,
action="VERSION_SUBMITTED",
detail=_audit_detail(before, _version_snapshot(version)),
action="VERSION_DELETED",
detail=_audit_detail(before, None),
operator_id=current_user.id,
operator_role=_role_value(current_user),
)
)
await db.commit()
await db.refresh(workflow)
return workflow
async def approve_version(
db: AsyncSession,
version_id: uuid.UUID,
comment: str | None,
current_user,
) -> VersionWorkflow:
version = await version_crud.get(db, version_id)
if not version:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="版本不存在")
doc = await document_crud.get(db, version.document_id)
if not doc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在")
await _ensure_study_access(db, doc.trial_id, current_user, action="approve")
workflow = await workflow_crud.get_by_version(db, version_id)
if not workflow or workflow.status != WorkflowStatus.PENDING:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="审批流程不可用")
before = _version_snapshot(version)
version.status = DocumentVersionStatus.APPROVED
version.approved_at = datetime.now(timezone.utc)
workflow.status = WorkflowStatus.APPROVED
workflow.completed_at = version.approved_at
db.add(version)
db.add(workflow)
db.add(
WorkflowAction(
workflow_id=workflow.id,
node_order=workflow.current_node,
action=WorkflowActionType.APPROVE,
actor_id=current_user.id,
comment=comment,
)
)
db.add(
AuditLog(
study_id=doc.trial_id,
entity_type="DOCUMENT_VERSION",
entity_id=version.id,
action="VERSION_APPROVED",
detail=_audit_detail(before, _version_snapshot(version)),
operator_id=current_user.id,
operator_role=_role_value(current_user),
)
)
await db.commit()
await db.refresh(workflow)
return workflow
async def reject_version(
db: AsyncSession,
version_id: uuid.UUID,
comment: str | None,
current_user,
) -> VersionWorkflow:
version = await version_crud.get(db, version_id)
if not version:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="版本不存在")
doc = await document_crud.get(db, version.document_id)
if not doc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在")
await _ensure_study_access(db, doc.trial_id, current_user, action="approve")
workflow = await workflow_crud.get_by_version(db, version_id)
if not workflow or workflow.status != WorkflowStatus.PENDING:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="审批流程不可用")
before = _version_snapshot(version)
version.status = DocumentVersionStatus.WITHDRAWN
version.withdrawn_at = datetime.now(timezone.utc)
workflow.status = WorkflowStatus.REJECTED
workflow.completed_at = version.withdrawn_at
db.add(version)
db.add(workflow)
db.add(
WorkflowAction(
workflow_id=workflow.id,
node_order=workflow.current_node,
action=WorkflowActionType.REJECT,
actor_id=current_user.id,
comment=comment,
)
)
db.add(
AuditLog(
study_id=doc.trial_id,
entity_type="DOCUMENT_VERSION",
entity_id=version.id,
action="VERSION_REJECTED",
detail=_audit_detail(before, _version_snapshot(version)),
operator_id=current_user.id,
operator_role=_role_value(current_user),
)
)
await db.commit()
await db.refresh(workflow)
return workflow
async def make_version_effective(
db: AsyncSession,
version_id: uuid.UUID,
current_user,
) -> DocumentVersion:
version = await version_crud.get(db, version_id)
if not version:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="版本不存在")
doc = await document_crud.get(db, version.document_id)
if not doc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在")
await _ensure_study_access(db, doc.trial_id, current_user, action="approve")
if version.status != DocumentVersionStatus.APPROVED:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="版本未审批通过")
now = datetime.now(timezone.utc)
before = _version_snapshot(version)
version.status = DocumentVersionStatus.EFFECTIVE
version.effective_at = now
await db.execute(
sa_update(DocumentVersion)
.where(
DocumentVersion.document_id == version.document_id,
DocumentVersion.status == DocumentVersionStatus.EFFECTIVE,
DocumentVersion.id != version.id,
)
.values(status=DocumentVersionStatus.SUPERSEDED, superseded_at=now)
)
await db.execute(
sa_update(Document)
.where(Document.id == version.document_id)
.values(current_effective_version_id=version.id)
)
db.add(version)
db.add(
AuditLog(
study_id=doc.trial_id,
entity_type="DOCUMENT_VERSION",
entity_id=version.id,
action="VERSION_EFFECTIVE",
detail=_audit_detail(before, _version_snapshot(version)),
operator_id=current_user.id,
operator_role=_role_value(current_user),
)
)
await db.commit()
await db.refresh(version)
return version
file_path = Path(version.file_uri)
if file_path.exists():
try:
file_path.unlink()
except OSError:
pass
async def delete_document(
@@ -555,19 +469,6 @@ async def get_version_download_response(
)
async def list_workflow_templates(
db: AsyncSession,
trial_id: uuid.UUID | None,
current_user,
) -> list[WorkflowTemplate]:
if trial_id:
await _ensure_study_access(db, trial_id, current_user, action="view")
elif current_user.role != "ADMIN":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
templates = await template_crud.list_active(db, trial_id)
return list(templates)
async def create_distributions(
db: AsyncSession,
version_id: uuid.UUID,
@@ -580,7 +481,7 @@ async def create_distributions(
doc = await document_crud.get(db, version.document_id)
if not doc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在")
await _ensure_study_access(db, doc.trial_id, current_user, action="distribute")
await _ensure_study_member(db, doc.trial_id, current_user)
if version.status != DocumentVersionStatus.EFFECTIVE:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="只有生效版本可分发")
@@ -625,7 +526,7 @@ async def list_distributions(
doc = await document_crud.get(db, version.document_id)
if not doc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在")
await _ensure_study_access(db, doc.trial_id, current_user, action="view")
await _ensure_study_member(db, doc.trial_id, current_user)
distributions = await distribution_crud.list_by_version(db, version_id)
result: list[DistributionRead] = []
@@ -752,3 +653,69 @@ def _aggregate_ack_stats(
if due_at < now and not acks:
stats.overdue = target_count
return stats
async def list_distribution_notifications(
db: AsyncSession,
study_id: uuid.UUID,
current_user,
*,
skip: int = 0,
limit: int = 20,
) -> list[NotificationItem]:
role_value = _role_value(current_user)
membership = None
if role_value != "ADMIN":
membership = await member_crud.get_member(db, study_id, current_user.id)
role_in_study = membership.role_in_study if membership else None
target_filters = [
(Distribution.target_type == DistributionTargetType.USER)
& (Distribution.target_id == str(current_user.id))
]
if role_in_study:
target_filters.append(
(Distribution.target_type == DistributionTargetType.ROLE)
& (Distribution.target_id == role_in_study)
)
stmt = (
select(
Distribution.id,
Distribution.document_id,
Distribution.version_id,
Distribution.created_at,
Document.title,
Document.doc_no,
DocumentVersion.version_no,
DocumentVersion.change_summary,
DocumentVersion.effective_at,
)
.join(DocumentVersion, Distribution.version_id == DocumentVersion.id)
.join(Document, Distribution.document_id == Document.id)
.where(
Document.trial_id == study_id,
Distribution.status == DistributionStatus.ACTIVE,
or_(*target_filters),
)
.order_by(Distribution.created_at.desc())
.offset(skip)
.limit(limit)
)
result = await db.execute(stmt)
items: list[NotificationItem] = []
for row in result.fetchall():
items.append(
NotificationItem(
id=row.id,
document_id=row.document_id,
version_id=row.version_id,
document_title=row.title,
document_no=row.doc_no,
version_no=row.version_no,
change_summary=row.change_summary,
effective_at=row.effective_at,
created_at=row.created_at,
)
)
return items
+24
View File
@@ -0,0 +1,24 @@
import asyncio
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from app.crud import visit as visit_crud
from app.db.session import SessionLocal
async def _run_mark_lost_once() -> None:
async with SessionLocal() as session:
await visit_crud.mark_overdue_as_lost_global(session)
async def run_daily_lost_visit_job(stop_event: asyncio.Event) -> None:
tz = ZoneInfo("Asia/Shanghai")
while not stop_event.is_set():
now = datetime.now(tz)
next_midnight = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
sleep_seconds = max(1, (next_midnight - now).total_seconds())
try:
await asyncio.wait_for(stop_event.wait(), timeout=sleep_seconds)
break
except asyncio.TimeoutError:
await _run_mark_lost_once()