未知(继上次中断)
This commit is contained in:
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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}%")]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user