管理后台前后端逻辑、UI美化
This commit is contained in:
@@ -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="权限不足")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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="病史记录不存在")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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="用户不存在")
|
||||
|
||||
@@ -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="访视不存在")
|
||||
|
||||
Reference in New Issue
Block a user