未知(继上次中断)

This commit is contained in:
Cheng Zhou
2026-02-04 10:52:34 +08:00
parent 8e258d21a7
commit 737f84bf54
99 changed files with 5497 additions and 2143 deletions
+35 -4
View File
@@ -5,7 +5,7 @@ from datetime import date
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_not_locked
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member, require_study_not_locked
from app.crud import ae as ae_crud
from app.crud import audit as audit_crud
from app.crud import member as member_crud
@@ -65,6 +65,14 @@ async def create_ae(
current_user=Depends(get_current_user),
) -> AERead:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope:
if ae_in.site_id and ae_in.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
if ae_in.subject_id:
subj = await subject_crud.get_subject(db, ae_in.subject_id)
if subj and subj.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
if ae_in.onset_date and ae_in.onset_date > date.today():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="发生日期不能晚于今天")
member_role = await _get_member_role(db, study_id, current_user.id)
@@ -102,9 +110,23 @@ async def list_ae(
subject_id: uuid.UUID | None = None,
overdue: bool | None = None,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[AERead]:
await _ensure_study_exists(db, study_id)
aes = await ae_crud.list_ae(db, study_id, status=status_filter, seriousness=seriousness, site_id=site_id, subject_id=subject_id, overdue=overdue)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_ids = cra_scope[0] if cra_scope else None
if site_id and site_ids is not None and site_id not in site_ids:
return []
aes = await ae_crud.list_ae(
db,
study_id,
status=status_filter,
seriousness=seriousness,
site_id=site_id,
subject_id=subject_id,
overdue=overdue,
site_ids=site_ids,
)
result: list[AERead] = []
for item in aes:
obj = AERead.model_validate(item)
@@ -122,6 +144,7 @@ async def get_ae(
study_id: uuid.UUID,
ae_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> AERead:
await _ensure_study_exists(db, study_id)
ae = await ae_crud.get_ae(db, ae_id)
@@ -129,6 +152,14 @@ async def get_ae(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
data = AERead.model_validate(ae)
data.is_overdue = _is_overdue(data)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope:
if data.site_id and data.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
if data.subject_id:
subj = await subject_crud.get_subject(db, data.subject_id)
if subj and subj.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return data
@@ -169,7 +200,7 @@ async def update_ae(
detail_before = {
"event": ae.term,
"onset_date": ae.onset_date,
"severity": ae.severity,
"seriousness": ae.seriousness,
"status": ae.status,
"description": ae.description,
}
@@ -177,7 +208,7 @@ async def update_ae(
detail_after = {
"event": updated.term,
"onset_date": updated.onset_date,
"severity": updated.severity,
"seriousness": updated.seriousness,
"status": updated.status,
"description": updated.description,
}
+89 -1
View File
@@ -3,9 +3,13 @@ from fastapi import APIRouter, Depends
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, require_study_member
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member
from app.models.milestone import Milestone
from app.schemas.progress import StudyProgressRead
from app.schemas.visit import VisitLostItem
from app.crud import visit as visit_crud
from app.schemas.dashboard import CenterSummaryItem
from app.crud import overview as overview_crud
router = APIRouter()
@@ -30,3 +34,87 @@ async def get_progress(
milestones_done=milestone_done,
completion_rate=completion_rate,
)
@router.get("/lost-visits", response_model=list[VisitLostItem], dependencies=[Depends(require_study_member())])
async def list_lost_visits(
study_id: uuid.UUID,
limit: int = 20,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[VisitLostItem]:
cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_ids = cra_scope[0] if cra_scope else None
rows = await visit_crud.list_lost_visits(db, study_id, site_ids=site_ids, limit=limit)
items: list[VisitLostItem] = []
for visit, subject_no, site_id in rows:
items.append(
VisitLostItem(
visit_id=visit.id,
subject_id=visit.subject_id,
subject_no=subject_no,
site_id=site_id,
visit_code=visit.visit_code,
status=visit.status,
updated_at=visit.updated_at,
)
)
return items
@router.get("/center-summary", response_model=list[CenterSummaryItem], dependencies=[Depends(require_study_member())])
async def get_center_summary(
study_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[CenterSummaryItem]:
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value not in {"ADMIN", "PM", "CRA"}:
return []
cra_scope = await get_cra_site_scope(db, study_id, current_user)
scope_ids = cra_scope[0] if cra_scope else None
scope_id_strs = {str(cid) for cid in scope_ids} if scope_ids is not None else None
overview = await overview_crud.get_project_overview(db, study_id)
centers = overview.get("centers") or []
stage_order = [
("institution_initiation_status", "机构立项"),
("ethics_status", "伦理审批"),
("contract_sign_status", "合同签署"),
("startup_status", "启动"),
("enrollment_status", "入组"),
("inspection_status", "末次稽查"),
("closeout_status", "关中心"),
]
summary_list: list[CenterSummaryItem] = []
for center in centers:
center_id = center.get("center_id")
if scope_id_strs is not None and center_id not in scope_id_strs:
continue
stage_label = "未开始"
stage_status = "NOT_STARTED"
for key, label in stage_order:
status = (center.get(key) or "NOT_STARTED").upper()
if status in ("IN_PROGRESS", "BLOCKED"):
stage_label = label
stage_status = status
break
if status == "COMPLETED":
stage_label = label
stage_status = status
continue
summary_list.append(
CenterSummaryItem(
id=center_id,
name=center.get("center_name") or "",
stage=stage_label,
stage_status=stage_status,
actual_enrolled=center.get("enrollment_actual") or 0,
planned_enrolled=center.get("enrollment_target") or 0,
)
)
return summary_list
+15 -61
View File
@@ -6,13 +6,12 @@ from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session
from app.crud import document_version as version_crud
from app.schemas.acknowledgement import AcknowledgementCreate, AcknowledgementRead
from app.schemas.common import PaginatedResponse
from app.schemas.distribution import DistributionCreate, DistributionRead
from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary
from app.schemas.document_version import DocumentVersionRead, VersionActionRequest, VersionSubmitRequest
from app.schemas.workflow_template import WorkflowTemplateRead
from app.schemas.document_version import DocumentVersionRead
from app.models.user import UserRole
from app.services import document_service
from app.utils.pagination import paginate
@@ -74,6 +73,9 @@ async def delete_document(
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DocumentSummary:
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value != UserRole.ADMIN.value:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员可删除文档")
doc = await document_service.delete_document(db, document_id, current_user)
return DocumentSummary.model_validate(doc)
@@ -86,6 +88,7 @@ async def delete_document(
async def create_version(
document_id: uuid.UUID,
version_no: str = Form(...),
version_date: str = Form(...),
change_summary: Optional[str] = Form(None),
parent_version_id: Optional[uuid.UUID] = Form(None),
file: UploadFile = File(...),
@@ -96,6 +99,7 @@ async def create_version(
db,
document_id,
version_no=version_no,
version_date=version_date,
change_summary=change_summary,
parent_version_id=parent_version_id,
file=file,
@@ -104,58 +108,6 @@ async def create_version(
return DocumentVersionRead.model_validate(version)
@router.post("/versions/{version_id}/submit", response_model=DocumentVersionRead)
async def submit_version(
version_id: uuid.UUID,
payload: VersionSubmitRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DocumentVersionRead:
await document_service.submit_version(db, version_id, payload.template_id, payload.comment, current_user)
version = await version_crud.get(db, version_id)
if not version:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="版本不存在")
return DocumentVersionRead.model_validate(version)
@router.post("/versions/{version_id}/approve", response_model=DocumentVersionRead)
async def approve_version(
version_id: uuid.UUID,
payload: VersionActionRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DocumentVersionRead:
await document_service.approve_version(db, version_id, payload.comment, current_user)
version = await version_crud.get(db, version_id)
if not version:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="版本不存在")
return DocumentVersionRead.model_validate(version)
@router.post("/versions/{version_id}/reject", response_model=DocumentVersionRead)
async def reject_version(
version_id: uuid.UUID,
payload: VersionActionRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DocumentVersionRead:
await document_service.reject_version(db, version_id, payload.comment, current_user)
version = await version_crud.get(db, version_id)
if not version:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="版本不存在")
return DocumentVersionRead.model_validate(version)
@router.post("/versions/{version_id}/make-effective", response_model=DocumentVersionRead)
async def make_version_effective(
version_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DocumentVersionRead:
version = await document_service.make_version_effective(db, version_id, current_user)
return DocumentVersionRead.model_validate(version)
@router.get("/versions/{version_id}/download", response_class=FileResponse)
async def download_version(
version_id: uuid.UUID,
@@ -165,14 +117,16 @@ async def download_version(
return await document_service.get_version_download_response(db, version_id, current_user)
@router.get("/workflow-templates", response_model=list[WorkflowTemplateRead])
async def list_workflow_templates(
trial_id: uuid.UUID | None = None,
@router.delete("/versions/{version_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_version(
version_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[WorkflowTemplateRead]:
templates = await document_service.list_workflow_templates(db, trial_id, current_user)
return [WorkflowTemplateRead.model_validate(t) for t in templates]
) -> None:
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value != UserRole.ADMIN.value:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员可删除版本")
await document_service.delete_version(db, version_id, current_user)
@router.post(
+20 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
from app.crud import audit as audit_crud
from app.crud import drug_shipment as shipment_crud
from app.crud import site as site_crud
@@ -42,6 +42,9 @@ async def create_shipment(
current_user=Depends(get_current_user),
) -> DrugShipmentRead:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and shipment_in.center_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
site = await _ensure_center_active(db, study_id, shipment_in.center_id)
shipment_in = shipment_in.model_copy(update={"site_name": site.name})
shipment = await shipment_crud.create_shipment(db, study_id, shipment_in, created_by=current_user.id)
@@ -73,12 +76,18 @@ async def list_shipments(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[DrugShipmentRead]:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
center_ids = cra_scope[0] if cra_scope else None
if center_id and center_ids is not None and center_id not in center_ids:
return []
items = await shipment_crud.list_shipments(
db,
study_id,
center_id=center_id,
center_ids=center_ids,
site_name=site_name,
tracking_no=tracking_no,
direction=direction,
@@ -98,11 +107,15 @@ async def get_shipment(
study_id: uuid.UUID,
shipment_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DrugShipmentRead:
await _ensure_study_exists(db, study_id)
shipment = await shipment_crud.get_shipment(db, shipment_id)
if not shipment or shipment.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="运输记录不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and shipment.center_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return DrugShipmentRead.model_validate(shipment)
@@ -122,6 +135,9 @@ async def update_shipment(
shipment = await shipment_crud.get_shipment(db, shipment_id)
if not shipment or shipment.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="运输记录不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and shipment.center_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
if shipment_in.center_id:
site = await _ensure_center_active(db, study_id, shipment_in.center_id)
shipment_in = shipment_in.model_copy(update={"site_name": site.name})
@@ -156,6 +172,9 @@ async def delete_shipment(
shipment = await shipment_crud.get_shipment(db, shipment_id)
if not shipment or shipment.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="运输记录不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and shipment.center_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_center_active(db, study_id, shipment.center_id)
await shipment_crud.delete_shipment(db, shipment)
await audit_crud.log_action(
+15 -2
View File
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.deps import get_current_user, get_db_session, require_study_not_locked
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked
from app.crud import audit as audit_crud
from app.crud import contract_fee as contract_fee_crud
from app.crud import contract_fee_payment as payment_crud
@@ -84,7 +84,17 @@ async def list_contract_fees(
current_user=Depends(get_current_user),
) -> FeeApiResponse[list[ContractFeeListItem]]:
await _ensure_project_access(db, project_id, current_user, write=False)
rows = await contract_fee_crud.list_contract_fees(db, project_id, center_id=center_id, q=q)
cra_scope = await get_cra_site_scope(db, project_id, current_user)
center_ids = cra_scope[0] if cra_scope else None
if center_id and center_ids is not None and center_id not in center_ids:
return FeeApiResponse(data=[], meta={"total": 0})
rows = await contract_fee_crud.list_contract_fees(
db,
project_id,
center_id=center_id,
center_ids=center_ids,
q=q,
)
items: list[ContractFeeListItem] = []
for contract, center_name, paid_total, verified_total, last_paid_date, last_verified_date in rows:
paid_total_decimal = _to_decimal(paid_total)
@@ -130,6 +140,9 @@ async def get_contract_fee(
if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_project_access(db, contract.project_id, current_user, write=False)
cra_scope = await get_cra_site_scope(db, contract.project_id, current_user)
if cra_scope and contract.center_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
payments = await payment_crud.list_payments(db, contract.id)
attachment_types = ["contract_fee_contract", "contract_fee_voucher", "contract_fee_invoice"]
+9 -1
View File
@@ -4,7 +4,7 @@ from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_not_locked
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked
from app.crud import audit as audit_crud
from app.crud import member as member_crud
from app.crud import special_expense as special_crud
@@ -76,10 +76,15 @@ async def list_special_expenses(
) -> FeeApiResponse[list[SpecialExpenseListItem]]:
await _ensure_project_access(db, project_id, current_user, write=False)
_validate_category(category)
cra_scope = await get_cra_site_scope(db, project_id, current_user)
center_ids = cra_scope[0] if cra_scope else None
if center_id and center_ids is not None and center_id not in center_ids:
return FeeApiResponse(data=[], meta={"total": 0})
rows = await special_crud.list_special_expenses(
db,
project_id,
center_id=center_id,
center_ids=center_ids,
category=category,
date_from=date_from,
date_to=date_to,
@@ -123,6 +128,9 @@ async def get_special_expense(
if not expense:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
await _ensure_project_access(db, expense.project_id, current_user, write=False)
cra_scope = await get_cra_site_scope(db, expense.project_id, current_user)
if cra_scope and expense.center_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return FeeApiResponse(data=SpecialExpenseRead.model_validate(expense))
+28 -2
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
from app.crud import audit as audit_crud
from app.crud import finance_contract as contract_crud
from app.crud import site as site_crud
@@ -41,6 +41,9 @@ async def create_contract(
current_user=Depends(get_current_user),
) -> FinanceContractRead:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and contract_in.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, contract_in.site_name)
contract = await contract_crud.create_contract(db, study_id, contract_in, created_by=current_user.id)
await audit_crud.log_action(
@@ -68,9 +71,22 @@ async def list_contracts(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[FinanceContractRead]:
await _ensure_study_exists(db, study_id)
items = await contract_crud.list_contracts(db, study_id, site_name=site_name, contract_no=contract_no, skip=skip, limit=limit)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_names = cra_scope[1] if cra_scope else None
if site_name and site_names is not None and site_name not in site_names:
return []
items = await contract_crud.list_contracts(
db,
study_id,
site_name=site_name,
site_names=site_names,
contract_no=contract_no,
skip=skip,
limit=limit,
)
return [FinanceContractRead.model_validate(item) for item in items]
@@ -83,11 +99,15 @@ async def get_contract(
study_id: uuid.UUID,
contract_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FinanceContractRead:
await _ensure_study_exists(db, study_id)
contract = await contract_crud.get_contract(db, contract_id)
if not contract or contract.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and contract.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return FinanceContractRead.model_validate(contract)
@@ -107,6 +127,9 @@ async def update_contract(
contract = await contract_crud.get_contract(db, contract_id)
if not contract or contract.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and contract.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, contract.site_name)
contract = await contract_crud.update_contract(db, contract, contract_in)
await audit_crud.log_action(
@@ -137,6 +160,9 @@ async def delete_contract(
contract = await contract_crud.get_contract(db, contract_id)
if not contract or contract.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and contract.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, contract.site_name)
await contract_crud.delete_contract(db, contract)
await audit_crud.log_action(
+28 -2
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
from app.crud import audit as audit_crud
from app.crud import finance_special as special_crud
from app.crud import site as site_crud
@@ -41,6 +41,9 @@ async def create_special(
current_user=Depends(get_current_user),
) -> FinanceSpecialRead:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and special_in.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, special_in.site_name)
item = await special_crud.create_special(db, study_id, special_in, created_by=current_user.id)
await audit_crud.log_action(
@@ -68,9 +71,22 @@ async def list_specials(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[FinanceSpecialRead]:
await _ensure_study_exists(db, study_id)
items = await special_crud.list_specials(db, study_id, site_name=site_name, fee_type=fee_type, skip=skip, limit=limit)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_names = cra_scope[1] if cra_scope else None
if site_name and site_names is not None and site_name not in site_names:
return []
items = await special_crud.list_specials(
db,
study_id,
site_name=site_name,
site_names=site_names,
fee_type=fee_type,
skip=skip,
limit=limit,
)
return [FinanceSpecialRead.model_validate(item) for item in items]
@@ -83,11 +99,15 @@ async def get_special(
study_id: uuid.UUID,
special_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FinanceSpecialRead:
await _ensure_study_exists(db, study_id)
item = await special_crud.get_special(db, special_id)
if not item or item.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and item.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return FinanceSpecialRead.model_validate(item)
@@ -107,6 +127,9 @@ async def update_special(
item = await special_crud.get_special(db, special_id)
if not item or item.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and item.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, item.site_name)
item = await special_crud.update_special(db, item, special_in)
await audit_crud.log_action(
@@ -137,6 +160,9 @@ async def delete_special(
item = await special_crud.get_special(db, special_id)
if not item or item.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and item.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, item.site_name)
await special_crud.delete_special(db, item)
await audit_crud.log_action(
+31
View File
@@ -0,0 +1,31 @@
import uuid
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, get_current_user, require_study_member
from app.schemas.notification import NotificationItem
from app.services import document_service
router = APIRouter()
@router.get(
"/notifications",
response_model=list[NotificationItem],
dependencies=[Depends(require_study_member())],
)
async def list_notifications(
study_id: uuid.UUID,
skip: int = 0,
limit: int = 20,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[NotificationItem]:
return await document_service.list_distribution_notifications(
db,
study_id,
current_user,
skip=skip,
limit=limit,
)
+2 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, finance_specials, fees_contracts, fees_specials, fees_attachments, drug_shipments, startup, knowledge_notes, subject_histories, faq_categories, faqs, documents, overview
from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, finance_specials, fees_contracts, fees_specials, fees_attachments, drug_shipments, startup, knowledge_notes, subject_histories, faq_categories, faqs, documents, overview, notifications
api_router = APIRouter()
@@ -9,6 +9,7 @@ api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"])
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(studies.router, prefix="/studies", tags=["studies"])
api_router.include_router(overview.router, prefix="/studies/{study_id}", tags=["overview"])
api_router.include_router(notifications.router, prefix="/studies/{study_id}", tags=["notifications"])
api_router.include_router(sites.router, prefix="/studies/{study_id}/sites", tags=["sites"])
api_router.include_router(members.router, prefix="/studies/{study_id}/members", tags=["study-members"])
api_router.include_router(attachments.router, prefix="/studies/{study_id}/{entity_type}/{entity_id}/attachments", tags=["attachments"])
+5 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
from app.crud import site as site_crud
from app.crud import startup as startup_crud
from app.crud import study as study_crud
@@ -60,14 +60,18 @@ async def list_sites(
limit: int = 100,
include_inactive: bool = False,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[SiteRead]:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_ids = cra_scope[0] if cra_scope else None
sites = await site_crud.list_by_study(
db,
study_id,
skip=skip,
limit=limit,
include_inactive=include_inactive,
site_ids=site_ids,
)
return list(sites)
+77 -16
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member
from app.crud import audit as audit_crud
from app.crud import site as site_crud
from app.crud import startup as startup_crud
@@ -53,7 +53,7 @@ async def _ensure_site_active(db: AsyncSession, site_id: uuid.UUID | None):
"/feasibility",
response_model=StartupFeasibilityRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_member())],
)
async def create_feasibility(
study_id: uuid.UUID,
@@ -62,6 +62,9 @@ async def create_feasibility(
current_user=Depends(get_current_user),
) -> StartupFeasibilityRead:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record_in.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_active(db, record_in.site_id)
record = await startup_crud.create_feasibility(db, study_id, record_in, created_by=current_user.id)
await audit_crud.log_action(
@@ -87,9 +90,12 @@ async def list_feasibilities(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[StartupFeasibilityRead]:
await _ensure_study_exists(db, study_id)
items = await startup_crud.list_feasibilities(db, study_id, skip=skip, limit=limit)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_ids = cra_scope[0] if cra_scope else None
items = await startup_crud.list_feasibilities(db, study_id, skip=skip, limit=limit, site_ids=site_ids)
return [StartupFeasibilityRead.model_validate(item) for item in items]
@@ -102,18 +108,22 @@ async def get_feasibility(
study_id: uuid.UUID,
record_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> StartupFeasibilityRead:
await _ensure_study_exists(db, study_id)
record = await startup_crud.get_feasibility(db, record_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="立项记录不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return StartupFeasibilityRead.model_validate(record)
@router.patch(
"/feasibility/{record_id}",
response_model=StartupFeasibilityRead,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_member())],
)
async def update_feasibility(
study_id: uuid.UUID,
@@ -126,6 +136,9 @@ async def update_feasibility(
record = await startup_crud.get_feasibility(db, record_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="立项记录不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_active(db, record.site_id)
record = await startup_crud.update_feasibility(db, record, record_in)
await audit_crud.log_action(
@@ -144,7 +157,7 @@ async def update_feasibility(
@router.delete(
"/feasibility/{record_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_member())],
)
async def delete_feasibility(
study_id: uuid.UUID,
@@ -156,6 +169,9 @@ async def delete_feasibility(
record = await startup_crud.get_feasibility(db, record_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="立项记录不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_active(db, record.site_id)
await startup_crud.delete_feasibility(db, record)
await audit_crud.log_action(
@@ -174,7 +190,7 @@ async def delete_feasibility(
"/ethics",
response_model=StartupEthicsRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_member())],
)
async def create_ethics(
study_id: uuid.UUID,
@@ -183,6 +199,9 @@ async def create_ethics(
current_user=Depends(get_current_user),
) -> StartupEthicsRead:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record_in.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_active(db, record_in.site_id)
record = await startup_crud.create_ethics(db, study_id, record_in, created_by=current_user.id)
await audit_crud.log_action(
@@ -208,9 +227,12 @@ async def list_ethics(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[StartupEthicsRead]:
await _ensure_study_exists(db, study_id)
items = await startup_crud.list_ethics(db, study_id, skip=skip, limit=limit)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_ids = cra_scope[0] if cra_scope else None
items = await startup_crud.list_ethics(db, study_id, skip=skip, limit=limit, site_ids=site_ids)
return [StartupEthicsRead.model_validate(item) for item in items]
@@ -223,18 +245,22 @@ async def get_ethics(
study_id: uuid.UUID,
record_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> StartupEthicsRead:
await _ensure_study_exists(db, study_id)
record = await startup_crud.get_ethics(db, record_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="伦理记录不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return StartupEthicsRead.model_validate(record)
@router.patch(
"/ethics/{record_id}",
response_model=StartupEthicsRead,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_member())],
)
async def update_ethics(
study_id: uuid.UUID,
@@ -247,6 +273,9 @@ async def update_ethics(
record = await startup_crud.get_ethics(db, record_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="伦理记录不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_active(db, record.site_id)
record = await startup_crud.update_ethics(db, record, record_in)
await audit_crud.log_action(
@@ -265,7 +294,7 @@ async def update_ethics(
@router.delete(
"/ethics/{record_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_member())],
)
async def delete_ethics(
study_id: uuid.UUID,
@@ -277,6 +306,9 @@ async def delete_ethics(
record = await startup_crud.get_ethics(db, record_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="伦理记录不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_active(db, record.site_id)
await startup_crud.delete_ethics(db, record)
await audit_crud.log_action(
@@ -295,7 +327,7 @@ async def delete_ethics(
"/kickoff",
response_model=KickoffMeetingRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_member())],
)
async def create_kickoff(
study_id: uuid.UUID,
@@ -304,6 +336,9 @@ async def create_kickoff(
current_user=Depends(get_current_user),
) -> KickoffMeetingRead:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and meeting_in.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_active(db, meeting_in.site_id)
meeting = await startup_crud.create_kickoff(db, study_id, meeting_in, created_by=current_user.id)
await audit_crud.log_action(
@@ -329,9 +364,12 @@ async def list_kickoffs(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[KickoffMeetingRead]:
await _ensure_study_exists(db, study_id)
items = await startup_crud.list_kickoffs(db, study_id, skip=skip, limit=limit)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_ids = cra_scope[0] if cra_scope else None
items = await startup_crud.list_kickoffs(db, study_id, skip=skip, limit=limit, site_ids=site_ids)
return [KickoffMeetingRead.model_validate(item) for item in items]
@@ -344,18 +382,22 @@ async def get_kickoff(
study_id: uuid.UUID,
meeting_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> KickoffMeetingRead:
await _ensure_study_exists(db, study_id)
meeting = await startup_crud.get_kickoff(db, meeting_id)
if not meeting or meeting.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="启动会记录不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and meeting.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return KickoffMeetingRead.model_validate(meeting)
@router.patch(
"/kickoff/{meeting_id}",
response_model=KickoffMeetingRead,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_member())],
)
async def update_kickoff(
study_id: uuid.UUID,
@@ -368,6 +410,9 @@ async def update_kickoff(
meeting = await startup_crud.get_kickoff(db, meeting_id)
if not meeting or meeting.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="启动会记录不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and meeting.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_active(db, meeting.site_id)
meeting = await startup_crud.update_kickoff(db, meeting, meeting_in)
await audit_crud.log_action(
@@ -387,7 +432,7 @@ async def update_kickoff(
"/training-authorizations",
response_model=TrainingAuthorizationRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_member())],
)
async def create_training_authorization(
study_id: uuid.UUID,
@@ -396,6 +441,9 @@ async def create_training_authorization(
current_user=Depends(get_current_user),
) -> TrainingAuthorizationRead:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record_in.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, record_in.site_name)
record = await startup_crud.create_training_authorization(db, study_id, record_in, created_by=current_user.id)
await audit_crud.log_action(
@@ -421,9 +469,12 @@ async def list_training_authorizations(
skip: int = 0,
limit: int = 200,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[TrainingAuthorizationRead]:
await _ensure_study_exists(db, study_id)
items = await startup_crud.list_training_authorizations(db, study_id, skip=skip, limit=limit)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_names = cra_scope[1] if cra_scope else None
items = await startup_crud.list_training_authorizations(db, study_id, skip=skip, limit=limit, site_names=site_names)
return [TrainingAuthorizationRead.model_validate(item) for item in items]
@@ -436,18 +487,22 @@ async def get_training_authorization(
study_id: uuid.UUID,
record_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> TrainingAuthorizationRead:
await _ensure_study_exists(db, study_id)
record = await startup_crud.get_training_authorization(db, record_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="培训授权人员不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return TrainingAuthorizationRead.model_validate(record)
@router.patch(
"/training-authorizations/{record_id}",
response_model=TrainingAuthorizationRead,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_member())],
)
async def update_training_authorization(
study_id: uuid.UUID,
@@ -460,6 +515,9 @@ async def update_training_authorization(
record = await startup_crud.get_training_authorization(db, record_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="培训授权人员不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, record.site_name)
record = await startup_crud.update_training_authorization(db, record, record_in)
await audit_crud.log_action(
@@ -478,7 +536,7 @@ async def update_training_authorization(
@router.delete(
"/training-authorizations/{record_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_member())],
)
async def delete_training_authorization(
study_id: uuid.UUID,
@@ -490,6 +548,9 @@ async def delete_training_authorization(
record = await startup_crud.get_training_authorization(db, record_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="培训授权人员不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, record.site_name)
await startup_crud.delete_training_authorization(db, record)
await audit_crud.log_action(
+5
View File
@@ -84,6 +84,11 @@ async def update_study(
status_code=status.HTTP_403_FORBIDDEN,
detail="项目已锁定,无法编辑。请先解锁项目。"
)
if study_in.code and study_in.code != study.code:
existing = await study_crud.get_by_code(db, study_in.code)
if existing and existing.id != study.id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目编号已存在")
updated = await study_crud.update(db, study, study_in)
return updated
+48 -4
View File
@@ -1,13 +1,15 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
from app.crud import audit as audit_crud
from app.crud import site as site_crud
from app.crud import subject as subject_crud
from app.crud import study as study_crud
from app.models.ae import AdverseEvent
from app.schemas.subject import SubjectCreate, SubjectRead, SubjectUpdate
router = APIRouter()
@@ -41,6 +43,9 @@ async def create_subject(
current_user=Depends(get_current_user),
) -> SubjectRead:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and subject_in.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
try:
subject = await subject_crud.create_subject(db, study_id, subject_in)
except ValueError as exc:
@@ -67,10 +72,31 @@ async def list_subjects(
study_id: uuid.UUID,
site_id: uuid.UUID | None = None,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[SubjectRead]:
await _ensure_study_exists(db, study_id)
subjects = await subject_crud.list_subjects(db, study_id, site_id=site_id)
return list(subjects)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_ids = cra_scope[0] if cra_scope else None
if site_id and site_ids is not None and site_id not in site_ids:
return []
subjects = await subject_crud.list_subjects(db, study_id, site_id=site_id, site_ids=site_ids)
subject_list = list(subjects)
if not subject_list:
return []
subject_ids = [subject.id for subject in subject_list]
ae_rows = await db.execute(
select(AdverseEvent.subject_id).where(
AdverseEvent.study_id == study_id,
AdverseEvent.subject_id.in_(subject_ids),
)
)
ae_subject_ids = {row[0] for row in ae_rows.all() if row[0]}
result: list[SubjectRead] = []
for subject in subject_list:
data = SubjectRead.model_validate(subject)
data.has_ae = subject.id in ae_subject_ids
result.append(data)
return result
@router.get(
@@ -82,13 +108,25 @@ async def get_subject(
study_id: uuid.UUID,
subject_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> SubjectRead:
await _ensure_study_exists(db, study_id)
subject = await subject_crud.get_subject(db, subject_id)
if not subject or subject.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="参与者不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and subject.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_subject_active(db, subject)
return subject
data = SubjectRead.model_validate(subject)
ae_row = await db.execute(
select(AdverseEvent.id).where(
AdverseEvent.study_id == study_id,
AdverseEvent.subject_id == subject.id,
).limit(1)
)
data.has_ae = ae_row.scalar_one_or_none() is not None
return data
@router.patch(
@@ -107,6 +145,9 @@ async def update_subject(
subject = await subject_crud.get_subject(db, subject_id)
if not subject or subject.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="参与者不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and subject.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_subject_active(db, subject)
old_status = subject.status
updated = await subject_crud.update_subject(db, subject, subject_in)
@@ -144,6 +185,9 @@ async def delete_subject(
subject = await subject_crud.get_subject(db, subject_id)
if not subject or subject.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="参与者不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and subject.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await subject_crud.delete_subject(db, subject)
await audit_crud.log_action(
db,
+6 -2
View File
@@ -40,6 +40,7 @@ async def list_visits(
db: AsyncSession = Depends(get_db_session),
) -> list[VisitRead]:
await _ensure_subject(db, study_id, subject_id)
await visit_crud.mark_overdue_as_lost(db, subject_id)
visits = await visit_crud.list_visits(db, subject_id)
return list(visits)
@@ -61,15 +62,18 @@ async def create_visit(
await _ensure_subject_active(db, subject)
if visit_in.subject_id != subject_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="参与者不匹配")
next_visit_code = await visit_crud.get_next_visit_code(db, subject_id)
if next_visit_code == "V1" and not visit_in.planned_date:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="首次新增访视必须填写计划访视日期")
visit = await visit_crud.create_visit(
db,
study_id=study_id,
visit_in=visit_in,
subject=subject,
visit_code=visit_in.visit_code,
visit_code=next_visit_code,
planned_date=visit_in.planned_date,
)
if visit_in.visit_code == "V1" and visit_in.planned_date:
if next_visit_code == "V1" and visit_in.planned_date:
study = await study_crud.get(db, study_id)
if study:
await visit_crud.create_followup_visits(