管理后台前后端逻辑、UI美化

This commit is contained in:
Cheng Zhou
2026-01-16 13:50:08 +08:00
parent 05c1f9579a
commit 7fdcfdaadd
82 changed files with 3210 additions and 549 deletions
+24 -4
View File
@@ -5,10 +5,12 @@ 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
from app.core.deps import 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
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.schemas.ae import AECreate, AERead, AEUpdate
@@ -25,6 +27,22 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
return study
async def _ensure_ae_visible(db: AsyncSession, study_id: uuid.UUID, ae: AERead | None):
if not ae:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
if ae.site_id:
site = await site_crud.get_site(db, ae.site_id)
if not site or site.study_id != study_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
if ae.subject_id:
subject = await subject_crud.get_subject(db, ae.subject_id)
if not subject or subject.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
site = await site_crud.get_site(db, subject.site_id)
if not site or not site.is_active:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
async def _get_member_role(db: AsyncSession, study_id: uuid.UUID, user_id: uuid.UUID) -> str | None:
member = await member_crud.get_member(db, study_id, user_id)
return member.role_in_study if member else None
@@ -38,7 +56,7 @@ def _is_overdue(ae: AERead) -> bool:
"/",
response_model=AERead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
)
async def create_ae(
study_id: uuid.UUID,
@@ -117,7 +135,7 @@ async def get_ae(
@router.patch(
"/{ae_id}",
response_model=AERead,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
)
async def update_ae(
study_id: uuid.UUID,
@@ -132,6 +150,7 @@ async def update_ae(
ae = await ae_crud.get_ae(db, ae_id)
if not ae or ae.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
await _ensure_ae_visible(db, study_id, AERead.model_validate(ae))
member_role = await _get_member_role(db, study_id, current_user.id)
if current_user.role == "ADMIN":
@@ -184,7 +203,7 @@ async def update_ae(
@router.delete(
"/{ae_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
)
async def delete_ae(
study_id: uuid.UUID,
@@ -196,6 +215,7 @@ async def delete_ae(
ae = await ae_crud.get_ae(db, ae_id)
if not ae or ae.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
await _ensure_ae_visible(db, study_id, AERead.model_validate(ae))
member_role = await _get_member_role(db, study_id, current_user.id)
if current_user.role != "ADMIN" and member_role not in {"PM", "PV"}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
+3 -3
View File
@@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status,
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, get_study_member
from app.core.deps import get_current_user, get_db_session, require_study_member, get_study_member, require_study_not_locked
from app.crud import attachment as attachment_crud
from app.crud import audit as audit_crud
from app.crud import study as study_crud
@@ -41,7 +41,7 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
"/",
response_model=AttachmentRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
)
async def upload_attachment(
study_id: uuid.UUID,
@@ -281,7 +281,7 @@ async def global_delete_attachment(
@router.delete(
"/{attachment_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
)
async def delete_attachment(
study_id: uuid.UUID,
+18 -10
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
from app.core.deps import 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
@@ -20,11 +20,20 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
return study
async def _ensure_center_active(db: AsyncSession, study_id: uuid.UUID, center_id: uuid.UUID | None):
if not center_id:
return None
site = await site_crud.get_site(db, center_id)
if not site or site.study_id != study_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
return site
@router.post(
"/shipments",
response_model=DrugShipmentRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
)
async def create_shipment(
study_id: uuid.UUID,
@@ -33,9 +42,7 @@ async def create_shipment(
current_user=Depends(get_current_user),
) -> DrugShipmentRead:
await _ensure_study_exists(db, study_id)
site = await site_crud.get_site(db, shipment_in.center_id)
if not site or site.study_id != study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, 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)
await audit_crud.log_action(
@@ -102,7 +109,7 @@ async def get_shipment(
@router.patch(
"/shipments/{shipment_id}",
response_model=DrugShipmentRead,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
)
async def update_shipment(
study_id: uuid.UUID,
@@ -116,10 +123,10 @@ async def update_shipment(
if not shipment or shipment.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="运输记录不存在")
if shipment_in.center_id:
site = await site_crud.get_site(db, shipment_in.center_id)
if not site or site.study_id != study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分中心不存在")
site = await _ensure_center_active(db, study_id, shipment_in.center_id)
shipment_in = shipment_in.model_copy(update={"site_name": site.name})
else:
await _ensure_center_active(db, study_id, shipment.center_id)
shipment = await shipment_crud.update_shipment(db, shipment, shipment_in)
await audit_crud.log_action(
db,
@@ -137,7 +144,7 @@ async def update_shipment(
@router.delete(
"/shipments/{shipment_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
)
async def delete_shipment(
study_id: uuid.UUID,
@@ -149,6 +156,7 @@ 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="运输记录不存在")
await _ensure_center_active(db, study_id, shipment.center_id)
await shipment_crud.delete_shipment(db, shipment)
await audit_crud.log_action(
db,
+4 -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
from app.core.deps import get_current_user, get_db_session, require_study_not_locked
from app.crud import audit as audit_crud
from app.crud import faq_category as category_crud
from app.crud import faq_item as item_crud
@@ -26,6 +26,7 @@ def _check_permission_for_scope(study_id: uuid.UUID, current_user, member_role:
status_code=status.HTTP_201_CREATED,
summary="创建 FAQ 分类",
description="创建全局或项目内 FAQ 分类,项目 PM/ADMIN 可用,全局仅 ADMIN。",
dependencies=[Depends(require_study_not_locked())],
)
async def create_category(
payload: CategoryCreate,
@@ -84,6 +85,7 @@ async def list_categories(
response_model=CategoryRead,
summary="更新 FAQ 分类",
description="更新分类名称或启停状态,项目 PM/ADMIN 或全局 ADMIN。",
dependencies=[Depends(require_study_not_locked())],
)
async def update_category(
category_id: uuid.UUID,
@@ -129,6 +131,7 @@ async def update_category(
status_code=status.HTTP_204_NO_CONTENT,
summary="删除 FAQ 分类",
description="仅 ADMIN 可删除分类,分类下存在 FAQ 时不可删除。",
dependencies=[Depends(require_study_not_locked())],
)
async def delete_category(
category_id: uuid.UUID,
+8 -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
from app.core.deps import get_current_user, get_db_session, require_study_not_locked
from app.crud import audit as audit_crud
from app.crud import faq_category as category_crud
from app.crud import faq_item as faq_crud
@@ -41,6 +41,7 @@ def _check_create_permission(current_user, is_member: bool):
status_code=status.HTTP_201_CREATED,
summary="创建 FAQ",
description="创建全局或项目内 FAQ,项目 FAQ 需项目 PM/ADMIN 权限。",
dependencies=[Depends(require_study_not_locked())],
)
async def create_faq(
payload: FaqCreate,
@@ -178,6 +179,7 @@ async def get_faq(
response_model=FaqRead,
summary="更新 FAQ",
description="更新 FAQ 内容或启停状态,项目内需 PM/ADMIN 权限。",
dependencies=[Depends(require_study_not_locked())],
)
async def update_faq(
item_id: uuid.UUID,
@@ -219,6 +221,7 @@ async def update_faq(
response_model=FaqRead,
summary="更新 FAQ 状态",
description="提问者或项目 PM/ADMIN 可确认已解决。",
dependencies=[Depends(require_study_not_locked())],
)
async def update_faq_status(
item_id: uuid.UUID,
@@ -248,6 +251,7 @@ async def update_faq_status(
response_model=FaqRead,
summary="设置最佳回复",
description="项目成员可设置最佳回复,全局仅 ADMIN。",
dependencies=[Depends(require_study_not_locked())],
)
async def set_best_reply(
item_id: uuid.UUID,
@@ -330,6 +334,7 @@ async def list_replies(
status_code=status.HTTP_201_CREATED,
summary="创建 FAQ 回复",
description="回复 FAQ,项目内成员可回复,全局仅 ADMIN。",
dependencies=[Depends(require_study_not_locked())],
)
async def create_reply(
item_id: uuid.UUID,
@@ -392,6 +397,7 @@ async def create_reply(
status_code=status.HTTP_204_NO_CONTENT,
summary="删除 FAQ",
description="删除 FAQ,项目内需 PM/ADMIN 权限,全局仅 ADMIN。",
dependencies=[Depends(require_study_not_locked())],
)
async def delete_faq(
item_id: uuid.UUID,
@@ -427,6 +433,7 @@ async def delete_faq(
status_code=status.HTTP_204_NO_CONTENT,
summary="删除 FAQ 回复",
description="删除 FAQ 回复,管理员、项目 PM 或回复者可删除。",
dependencies=[Depends(require_study_not_locked())],
)
async def delete_reply(
item_id: uuid.UUID,
+3 -3
View File
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status,
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session
from app.core.deps import get_current_user, get_db_session, require_study_not_locked
from app.core.security import decode_token
from app.crud import audit as audit_crud
from app.crud import contract_fee as contract_fee_crud
@@ -86,7 +86,7 @@ async def _authorize_download_user(request: Request, db: AsyncSession):
"/attachments",
response_model=FeeApiResponse[FeeAttachmentRead],
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(get_current_user)],
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def upload_fee_attachment(
entity_type: str = Form(...),
@@ -220,7 +220,7 @@ async def download_fee_attachment(
@router.delete(
"/attachments/{attachment_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(get_current_user)],
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def delete_fee_attachment(
attachment_id: uuid.UUID,
+19 -9
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
from app.core.deps import 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
@@ -43,6 +43,14 @@ async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, curren
return membership
async def _ensure_center_active(db: AsyncSession, project_id: uuid.UUID, center_id: uuid.UUID | None):
if not center_id:
return
site = await site_crud.get_site(db, center_id)
if not site or site.study_id != project_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
def _to_decimal(value: Any) -> Decimal:
if isinstance(value, Decimal):
return value
@@ -186,7 +194,7 @@ async def get_contract_fee(
"/contracts",
response_model=FeeApiResponse[ContractFeeRead],
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(get_current_user)],
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def create_contract_fee(
contract_in: ContractFeeCreate,
@@ -201,8 +209,8 @@ async def create_contract_fee(
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该中心已存在合同费用")
site = await site_crud.get_site(db, contract_in.center_id)
if not site or site.study_id != contract_in.project_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或不属于该项目")
if not site or site.study_id != contract_in.project_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或已停用")
contract = await contract_fee_crud.create_contract_fee(db, contract_in)
await audit_crud.log_action(
@@ -221,7 +229,7 @@ async def create_contract_fee(
@router.patch(
"/contracts/{contract_id}",
response_model=FeeApiResponse[ContractFeeRead],
dependencies=[Depends(get_current_user)],
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def update_contract_fee(
contract_id: uuid.UUID,
@@ -233,6 +241,7 @@ async def update_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=True)
await _ensure_center_active(db, contract.project_id, contract.center_id)
contract = await contract_fee_crud.update_contract_fee(db, contract, contract_in)
await audit_crud.log_action(
db,
@@ -250,7 +259,7 @@ async def update_contract_fee(
@router.delete(
"/contracts/{contract_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(get_current_user)],
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def delete_contract_fee(
contract_id: uuid.UUID,
@@ -261,6 +270,7 @@ async def delete_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=True)
await _ensure_center_active(db, contract.project_id, contract.center_id)
await contract_fee_crud.delete_contract_fee(db, contract)
await audit_crud.log_action(
db,
@@ -278,7 +288,7 @@ async def delete_contract_fee(
"/contracts/{contract_id}/payments",
response_model=FeeApiResponse[ContractFeePaymentRead],
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(get_current_user)],
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def create_contract_payment(
contract_id: uuid.UUID,
@@ -308,7 +318,7 @@ async def create_contract_payment(
@router.patch(
"/payments/{payment_id}",
response_model=FeeApiResponse[ContractFeePaymentRead],
dependencies=[Depends(get_current_user)],
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def update_contract_payment(
payment_id: uuid.UUID,
@@ -349,7 +359,7 @@ async def update_contract_payment(
@router.delete(
"/payments/{payment_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(get_current_user)],
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def delete_contract_payment(
payment_id: uuid.UUID,
+21 -8
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
from app.core.deps import 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
@@ -52,6 +52,14 @@ def _validate_category(value: str | None):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="费用类别无效")
async def _ensure_center_active(db: AsyncSession, project_id: uuid.UUID, center_id: uuid.UUID | None):
if not center_id:
return
site = await site_crud.get_site(db, center_id)
if not site or site.study_id != project_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
@router.get(
"/special",
response_model=FeeApiResponse[list[SpecialExpenseListItem]],
@@ -122,7 +130,7 @@ async def get_special_expense(
"/special",
response_model=FeeApiResponse[SpecialExpenseRead],
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(get_current_user)],
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def create_special_expense(
expense_in: SpecialExpenseCreate,
@@ -134,8 +142,9 @@ async def create_special_expense(
_validate_special_rules(expense_in)
if expense_in.center_id:
site = await site_crud.get_site(db, expense_in.center_id)
if not site or site.study_id != expense_in.project_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或不属于该项目")
if not site or site.study_id != expense_in.project_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或已停用")
await _ensure_center_active(db, expense_in.project_id, expense_in.center_id)
expense = await special_crud.create_special_expense(db, expense_in, created_by=current_user.id)
await audit_crud.log_action(
db,
@@ -153,7 +162,7 @@ async def create_special_expense(
@router.patch(
"/special/{expense_id}",
response_model=FeeApiResponse[SpecialExpenseRead],
dependencies=[Depends(get_current_user)],
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def update_special_expense(
expense_id: uuid.UUID,
@@ -181,8 +190,11 @@ async def update_special_expense(
_validate_special_rules(merged)
if expense_in.center_id:
site = await site_crud.get_site(db, expense_in.center_id)
if not site or site.study_id != expense.project_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或不属于该项目")
if not site or site.study_id != expense.project_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或已停用")
await _ensure_center_active(db, expense.project_id, expense_in.center_id)
else:
await _ensure_center_active(db, expense.project_id, expense.center_id)
expense = await special_crud.update_special_expense(db, expense, expense_in)
await audit_crud.log_action(
db,
@@ -200,7 +212,7 @@ async def update_special_expense(
@router.delete(
"/special/{expense_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(get_current_user)],
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def delete_special_expense(
expense_id: uuid.UUID,
@@ -211,6 +223,7 @@ async def delete_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=True)
await _ensure_center_active(db, expense.project_id, expense.center_id)
await special_crud.delete_special_expense(db, expense)
await audit_crud.log_action(
db,
+13 -1
View File
@@ -3,9 +3,10 @@ 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
from app.core.deps import 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
from app.crud import study as study_crud
from app.schemas.finance_contract import FinanceContractCreate, FinanceContractRead, FinanceContractUpdate
@@ -19,6 +20,14 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
return study
async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_name: str | None):
if not site_name:
return
active_names = await site_crud.list_active_names(db, study_id)
if site_name not in active_names:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
@router.post(
"/contracts",
response_model=FinanceContractRead,
@@ -32,6 +41,7 @@ async def create_contract(
current_user=Depends(get_current_user),
) -> FinanceContractRead:
await _ensure_study_exists(db, study_id)
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(
db,
@@ -97,6 +107,7 @@ 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="合同不存在")
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(
db,
@@ -126,6 +137,7 @@ 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="合同不存在")
await _ensure_site_name_active(db, study_id, contract.site_name)
await contract_crud.delete_contract(db, contract)
await audit_crud.log_action(
db,
+13 -1
View File
@@ -3,9 +3,10 @@ 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
from app.core.deps import 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
from app.crud import study as study_crud
from app.schemas.finance_special import FinanceSpecialCreate, FinanceSpecialRead, FinanceSpecialUpdate
@@ -19,6 +20,14 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
return study
async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_name: str | None):
if not site_name:
return
active_names = await site_crud.list_active_names(db, study_id)
if site_name not in active_names:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
@router.post(
"/specials",
response_model=FinanceSpecialRead,
@@ -32,6 +41,7 @@ async def create_special(
current_user=Depends(get_current_user),
) -> FinanceSpecialRead:
await _ensure_study_exists(db, study_id)
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(
db,
@@ -97,6 +107,7 @@ 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="特殊费用不存在")
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(
db,
@@ -126,6 +137,7 @@ 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="特殊费用不存在")
await _ensure_site_name_active(db, study_id, item.site_name)
await special_crud.delete_special(db, item)
await audit_crud.log_action(
db,
+13 -1
View File
@@ -3,9 +3,10 @@ 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
from app.core.deps import 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 knowledge_note as note_crud
from app.crud import site as site_crud
from app.crud import study as study_crud
from app.schemas.knowledge_note import KnowledgeNoteCreate, KnowledgeNoteRead, KnowledgeNoteUpdate
@@ -19,6 +20,14 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
return study
async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_name: str | None):
if not site_name:
return
active_names = await site_crud.list_active_names(db, study_id)
if site_name not in active_names:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
@router.post(
"/notes",
response_model=KnowledgeNoteRead,
@@ -32,6 +41,7 @@ async def create_note(
current_user=Depends(get_current_user),
) -> KnowledgeNoteRead:
await _ensure_study_exists(db, study_id)
await _ensure_site_name_active(db, study_id, note_in.site_name)
note = await note_crud.create_note(db, study_id, note_in, created_by=current_user.id)
await audit_crud.log_action(
db,
@@ -97,6 +107,7 @@ async def update_note(
note = await note_crud.get_note(db, note_id)
if not note or note.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
await _ensure_site_name_active(db, study_id, note.site_name)
note = await note_crud.update_note(db, note, note_in)
await audit_crud.log_action(
db,
@@ -126,6 +137,7 @@ async def delete_note(
note = await note_crud.get_note(db, note_id)
if not note or note.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
await _ensure_site_name_active(db, study_id, note.site_name)
await note_crud.delete_note(db, note)
await audit_crud.log_action(
db,
+18 -9
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_db_session, require_study_member, require_study_roles
from app.core.deps import get_db_session, require_study_member, require_study_roles, require_study_not_locked
from app.crud import member as member_crud
from app.crud import study as study_crud
from app.crud import user as user_crud
@@ -24,7 +24,7 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
"/",
response_model=StudyMemberRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM"]))],
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
)
async def add_member(
study_id: uuid.UUID,
@@ -34,6 +34,13 @@ async def add_member(
await _ensure_study_exists(db, study_id)
existing = await member_crud.get_member(db, study_id, member_in.user_id)
if existing:
if not existing.is_active:
updated = await member_crud.update_member(
db,
existing,
StudyMemberUpdate(is_active=True, role_in_study=member_in.role_in_study),
)
return updated
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="成员已存在")
member = await member_crud.add_member(db, study_id, member_in)
return member
@@ -48,6 +55,7 @@ async def list_members(
study_id: uuid.UUID,
skip: int = 0,
limit: int = 100,
include_inactive: bool = False,
db: AsyncSession = Depends(get_db_session),
) -> list[StudyMemberReadWithUser]:
await _ensure_study_exists(db, study_id)
@@ -56,12 +64,13 @@ async def list_members(
users_map = await user_crud.get_users_by_ids(db, user_ids)
result: list[StudyMemberReadWithUser] = []
for m in members:
# 仅返回项目内启用的成员 + 账号启用的用户
if not m.is_active:
continue
user = users_map.get(m.user_id)
if not user or not user.is_active:
continue
if not include_inactive:
# 仅返回项目内启用的成员 + 账号启用的用户
if not m.is_active:
continue
if not user or not user.is_active:
continue
result.append(
StudyMemberReadWithUser(
id=m.id,
@@ -79,7 +88,7 @@ async def list_members(
@router.patch(
"/{member_id}",
response_model=StudyMemberRead,
dependencies=[Depends(require_study_roles(["PM"]))],
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
)
async def update_member(
study_id: uuid.UUID,
@@ -98,7 +107,7 @@ async def update_member(
@router.delete(
"/{member_id}",
response_model=StudyMemberRead,
dependencies=[Depends(require_study_roles(["PM"]))],
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
)
async def remove_member(
study_id: uuid.UUID,
+48 -4
View File
@@ -3,10 +3,12 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, require_study_member, require_study_roles
from app.core.deps import 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
from app.schemas.site import SiteCreate, SiteRead, SiteUpdate
from app.schemas.startup import StartupEthicsCreate, StartupFeasibilityCreate
router = APIRouter()
@@ -22,15 +24,28 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
"/",
response_model=SiteRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM"]))],
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
)
async def create_site(
study_id: uuid.UUID,
site_in: SiteCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> SiteRead:
await _ensure_study_exists(db, study_id)
site = await site_crud.create_site(db, study_id, site_in)
await startup_crud.create_feasibility(
db,
study_id,
StartupFeasibilityCreate(site_id=site.id),
created_by=getattr(current_user, "id", None),
)
await startup_crud.create_ethics(
db,
study_id,
StartupEthicsCreate(site_id=site.id),
created_by=getattr(current_user, "id", None),
)
return site
@@ -43,17 +58,24 @@ async def list_sites(
study_id: uuid.UUID,
skip: int = 0,
limit: int = 100,
include_inactive: bool = False,
db: AsyncSession = Depends(get_db_session),
) -> list[SiteRead]:
await _ensure_study_exists(db, study_id)
sites = await site_crud.list_by_study(db, study_id, skip=skip, limit=limit)
sites = await site_crud.list_by_study(
db,
study_id,
skip=skip,
limit=limit,
include_inactive=include_inactive,
)
return list(sites)
@router.patch(
"/{site_id}",
response_model=SiteRead,
dependencies=[Depends(require_study_roles(["PM"]))],
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
)
async def update_site(
study_id: uuid.UUID,
@@ -67,3 +89,25 @@ async def update_site(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分中心不存在")
updated = await site_crud.update_site(db, site, site_in)
return updated
@router.delete(
"/{site_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_not_locked())],
)
async def delete_site(
study_id: uuid.UUID,
site_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> None:
await _ensure_study_exists(db, study_id)
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value != "ADMIN":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员可删除分中心")
site = await site_crud.get_site(db, site_id)
if not site or site.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分中心不存在")
await site_crud.delete_site_and_related(db, site)
return None
+29 -1
View File
@@ -3,8 +3,9 @@ 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
from app.core.deps import 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 startup as startup_crud
from app.crud import study as study_crud
from app.schemas.startup import (
@@ -32,6 +33,22 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
return study
async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_name: str | None):
if not site_name:
return
active_names = await site_crud.list_active_names(db, study_id)
if site_name not in active_names:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
async def _ensure_site_active(db: AsyncSession, site_id: uuid.UUID | None):
if not site_id:
return
site = await site_crud.get_site(db, site_id)
if not site or not site.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="中心已停用")
@router.post(
"/feasibility",
response_model=StartupFeasibilityRead,
@@ -45,6 +62,7 @@ async def create_feasibility(
current_user=Depends(get_current_user),
) -> StartupFeasibilityRead:
await _ensure_study_exists(db, study_id)
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(
db,
@@ -108,6 +126,7 @@ 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="立项记录不存在")
await _ensure_site_active(db, record.site_id)
record = await startup_crud.update_feasibility(db, record, record_in)
await audit_crud.log_action(
db,
@@ -137,6 +156,7 @@ 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="立项记录不存在")
await _ensure_site_active(db, record.site_id)
await startup_crud.delete_feasibility(db, record)
await audit_crud.log_action(
db,
@@ -163,6 +183,7 @@ async def create_ethics(
current_user=Depends(get_current_user),
) -> StartupEthicsRead:
await _ensure_study_exists(db, study_id)
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(
db,
@@ -226,6 +247,7 @@ 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="伦理记录不存在")
await _ensure_site_active(db, record.site_id)
record = await startup_crud.update_ethics(db, record, record_in)
await audit_crud.log_action(
db,
@@ -255,6 +277,7 @@ 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="伦理记录不存在")
await _ensure_site_active(db, record.site_id)
await startup_crud.delete_ethics(db, record)
await audit_crud.log_action(
db,
@@ -281,6 +304,7 @@ async def create_kickoff(
current_user=Depends(get_current_user),
) -> KickoffMeetingRead:
await _ensure_study_exists(db, study_id)
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(
db,
@@ -344,6 +368,7 @@ 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="启动会记录不存在")
await _ensure_site_active(db, meeting.site_id)
meeting = await startup_crud.update_kickoff(db, meeting, meeting_in)
await audit_crud.log_action(
db,
@@ -371,6 +396,7 @@ async def create_training_authorization(
current_user=Depends(get_current_user),
) -> TrainingAuthorizationRead:
await _ensure_study_exists(db, study_id)
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(
db,
@@ -434,6 +460,7 @@ 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="培训授权人员不存在")
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(
db,
@@ -463,6 +490,7 @@ 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="培训授权人员不存在")
await _ensure_site_name_active(db, study_id, record.site_name)
await startup_crud.delete_training_authorization(db, record)
await audit_crud.log_action(
db,
+103
View File
@@ -5,8 +5,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_roles, require_study_member, require_study_roles
from app.crud import study as study_crud
from app.crud import member as member_crud
from app.crud import audit as audit_crud
from app.schemas.common import PaginatedResponse
from app.schemas.study import StudyCreate, StudyRead, StudyUpdate
from app.schemas.member import StudyMemberCreate
from app.utils.pagination import paginate
router = APIRouter()
@@ -22,6 +25,15 @@ async def create_study(
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目编号已存在")
study = await study_crud.create(db, study_in, created_by=current_user.id)
# 自动将创建者添加为项目成员,角色为PM
member_in = StudyMemberCreate(
user_id=current_user.id,
role_in_study="PM",
is_active=True,
)
await member_crud.add_member(db, study.id, member_in)
return study
@@ -65,5 +77,96 @@ async def update_study(
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
# 检查项目是否已锁定(除非是解锁操作)
if study.is_locked and not (study_in.is_locked is False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="项目已锁定,无法编辑。请先解锁项目。"
)
updated = await study_crud.update(db, study, study_in)
return updated
@router.delete(
"/{study_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_roles(["ADMIN"]))],
)
async def delete_study(
study_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> None:
"""硬删除项目及其所有关联数据(不可逆操作)"""
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
# 删除项目及其所有关联数据(包括审计日志)
await study_crud.delete(db, study_id)
@router.patch(
"/{study_id}/lock",
response_model=StudyRead,
dependencies=[Depends(require_roles(["ADMIN"]))],
)
async def lock_study(
study_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> StudyRead:
"""锁定项目(仅管理员)"""
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
locked_study = await study_crud.lock(db, study_id)
# 记录审计日志
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="study",
entity_id=study_id,
action="LOCK_STUDY",
detail=f"项目已锁定:{study.name} ({study.code})",
operator_id=current_user.id,
operator_role=current_user.role,
)
return locked_study
@router.patch(
"/{study_id}/unlock",
response_model=StudyRead,
dependencies=[Depends(require_roles(["ADMIN"]))],
)
async def unlock_study(
study_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> StudyRead:
"""解锁项目(仅管理员)"""
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
unlocked_study = await study_crud.unlock(db, study_id)
# 记录审计日志
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="study",
entity_id=study_id,
action="UNLOCK_STUDY",
detail=f"项目已解锁:{study.name} ({study.code})",
operator_id=current_user.id,
operator_role=current_user.role,
)
return unlocked_study
+27 -1
View File
@@ -3,8 +3,10 @@ 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
from app.core.deps import 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 subject_history as history_crud
from app.crud import study as study_crud
from app.schemas.subject_history import SubjectHistoryCreate, SubjectHistoryRead, SubjectHistoryUpdate
@@ -19,6 +21,14 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
return study
async def _ensure_subject_active(db: AsyncSession, subject) -> None:
if not subject or not subject.site_id:
return
site = await site_crud.get_site(db, subject.site_id)
if not site or not site.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="中心已停用")
@router.post(
"/histories",
response_model=SubjectHistoryRead,
@@ -33,6 +43,10 @@ async def create_history(
current_user=Depends(get_current_user),
) -> SubjectHistoryRead:
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="参与者不存在")
await _ensure_subject_active(db, subject)
if history_in.subject_id != subject_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="参与者不匹配")
history = await history_crud.create_history(db, study_id, history_in, created_by=current_user.id)
@@ -62,6 +76,9 @@ async def list_histories(
db: AsyncSession = Depends(get_db_session),
) -> list[SubjectHistoryRead]:
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="参与者不存在")
items = await history_crud.list_histories(db, study_id, subject_id, skip=skip, limit=limit)
return [SubjectHistoryRead.model_validate(item) for item in items]
@@ -78,6 +95,9 @@ async def get_history(
db: AsyncSession = Depends(get_db_session),
) -> SubjectHistoryRead:
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="参与者不存在")
history = await history_crud.get_history(db, history_id)
if not history or history.study_id != study_id or history.subject_id != subject_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="病史记录不存在")
@@ -98,6 +118,9 @@ async def update_history(
current_user=Depends(get_current_user),
) -> SubjectHistoryRead:
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="参与者不存在")
history = await history_crud.get_history(db, history_id)
if not history or history.study_id != study_id or history.subject_id != subject_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="病史记录不存在")
@@ -128,6 +151,9 @@ async def delete_history(
current_user=Depends(get_current_user),
) -> None:
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="参与者不存在")
history = await history_crud.get_history(db, history_id)
if not history or history.study_id != study_id or history.subject_id != subject_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="病史记录不存在")
+16 -9
View File
@@ -3,8 +3,9 @@ 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
from app.core.deps import 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.schemas.subject import SubjectCreate, SubjectRead, SubjectUpdate
@@ -19,11 +20,19 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
return study
async def _ensure_subject_active(db: AsyncSession, subject) -> None:
if not subject or not subject.site_id:
return
site = await site_crud.get_site(db, subject.site_id)
if not site or not site.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="中心已停用")
@router.post(
"/",
response_model=SubjectRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
)
async def create_subject(
study_id: uuid.UUID,
@@ -57,14 +66,10 @@ async def create_subject(
async def list_subjects(
study_id: uuid.UUID,
site_id: uuid.UUID | None = None,
status_filter: str | None = None,
subject_no: str | None = None,
db: AsyncSession = Depends(get_db_session),
) -> list[SubjectRead]:
await _ensure_study_exists(db, study_id)
subjects = await subject_crud.list_subjects(
db, study_id, site_id=site_id, status=status_filter, subject_no=subject_no
)
subjects = await subject_crud.list_subjects(db, study_id, site_id=site_id)
return list(subjects)
@@ -82,13 +87,14 @@ async def get_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="参与者不存在")
await _ensure_subject_active(db, subject)
return subject
@router.patch(
"/{subject_id}",
response_model=SubjectRead,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
)
async def update_subject(
study_id: uuid.UUID,
@@ -101,6 +107,7 @@ 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="参与者不存在")
await _ensure_subject_active(db, subject)
old_status = subject.status
updated = await subject_crud.update_subject(db, subject, subject_in)
# auto-generate visits when enrolled
@@ -125,7 +132,7 @@ async def update_subject(
@router.delete(
"/{subject_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
)
async def delete_subject(
study_id: uuid.UUID,
-5
View File
@@ -4,7 +4,6 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, require_roles
from app.core.security import verify_password
from app.schemas.common import PaginatedResponse
from app.crud import user as user_crud
from app.crud import member as member_crud
@@ -70,13 +69,9 @@ async def update_user(
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: uuid.UUID,
payload: dict,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])),
):
admin_password = payload.get("admin_password")
if not admin_password or not verify_password(admin_password, current_user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="管理员密码错误")
db_user = await user_crud.get_by_id(db, user_id)
if not db_user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
+18 -6
View File
@@ -3,8 +3,9 @@ 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
from app.core.deps import 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.crud import visit as visit_crud
@@ -20,6 +21,14 @@ async def _ensure_subject(db: AsyncSession, study_id: uuid.UUID, subject_id: uui
return subject
async def _ensure_subject_active(db: AsyncSession, subject) -> None:
if not subject or not subject.site_id:
return
site = await site_crud.get_site(db, subject.site_id)
if not site or not site.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="中心已停用")
@router.get(
"/",
response_model=list[VisitRead],
@@ -39,7 +48,7 @@ async def list_visits(
"/",
response_model=VisitRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
)
async def create_visit(
study_id: uuid.UUID,
@@ -49,6 +58,7 @@ async def create_visit(
current_user=Depends(get_current_user),
) -> VisitRead:
subject = await _ensure_subject(db, study_id, subject_id)
await _ensure_subject_active(db, subject)
if visit_in.subject_id != subject_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="参与者不匹配")
visit = await visit_crud.create_visit(
@@ -88,7 +98,7 @@ async def create_visit(
@router.patch(
"/{visit_id}",
response_model=VisitRead,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
)
async def update_visit(
study_id: uuid.UUID,
@@ -98,7 +108,8 @@ async def update_visit(
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> VisitRead:
await _ensure_subject(db, study_id, subject_id)
subject = await _ensure_subject(db, study_id, subject_id)
await _ensure_subject_active(db, subject)
visit = await visit_crud.get_visit(db, visit_id)
if not visit or visit.subject_id != subject_id or visit.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="访视不存在")
@@ -124,7 +135,7 @@ async def update_visit(
@router.delete(
"/{visit_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
)
async def delete_visit(
study_id: uuid.UUID,
@@ -133,7 +144,8 @@ async def delete_visit(
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> None:
await _ensure_subject(db, study_id, subject_id)
subject = await _ensure_subject(db, study_id, subject_id)
await _ensure_subject_active(db, subject)
visit = await visit_crud.get_visit(db, visit_id)
if not visit or visit.subject_id != subject_id or visit.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="访视不存在")