管理后台前后端逻辑、UI美化
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
"""add is_locked to studies
|
||||
|
||||
Revision ID: 20260116_01
|
||||
Revises: 20250310_02
|
||||
Create Date: 2026-01-16 11:41:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '20260116_01'
|
||||
down_revision: Union[str, None] = '20250310_02'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 添加 is_locked 字段到 studies 表
|
||||
op.add_column('studies', sa.Column('is_locked', sa.Boolean(), nullable=False, server_default='false'))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 删除 is_locked 字段
|
||||
op.drop_column('studies', 'is_locked')
|
||||
@@ -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="访视不存在")
|
||||
|
||||
@@ -115,3 +115,27 @@ def require_study_roles(roles: Iterable[str]):
|
||||
return current_user
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
def require_study_not_locked():
|
||||
"""检查项目是否被锁定,如果锁定则拒绝所有修改请求"""
|
||||
from app.crud import study as study_crud
|
||||
|
||||
async def dependency(
|
||||
study_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="项目已锁定,无法进行任何修改操作",
|
||||
)
|
||||
return study
|
||||
|
||||
return dependency
|
||||
|
||||
@@ -24,11 +24,17 @@ async def _validate_site_subject(db: AsyncSession, study_id: uuid.UUID, site_id:
|
||||
site = result.scalar_one_or_none()
|
||||
if not site or site.study_id != study_id:
|
||||
raise ValueError("分中心不属于当前项目")
|
||||
if not site.is_active:
|
||||
raise ValueError("中心已停用")
|
||||
if subject_id:
|
||||
result = await db.execute(select(Subject).where(Subject.id == subject_id))
|
||||
subj = result.scalar_one_or_none()
|
||||
if not subj or subj.study_id != study_id:
|
||||
raise ValueError("参与者不属于当前项目")
|
||||
result = await db.execute(select(Site).where(Site.id == subj.site_id))
|
||||
subj_site = result.scalar_one_or_none()
|
||||
if subj_site and not subj_site.is_active:
|
||||
raise ValueError("中心已停用")
|
||||
|
||||
|
||||
async def create_ae(
|
||||
|
||||
@@ -43,7 +43,10 @@ async def list_contracts(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> Sequence[FinanceContract]:
|
||||
stmt = select(FinanceContract).where(FinanceContract.study_id == study_id)
|
||||
stmt = (
|
||||
select(FinanceContract)
|
||||
.where(FinanceContract.study_id == study_id)
|
||||
)
|
||||
if site_name:
|
||||
stmt = stmt.where(FinanceContract.site_name.ilike(f"%{site_name}%"))
|
||||
if contract_no:
|
||||
|
||||
@@ -43,7 +43,10 @@ async def list_specials(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> Sequence[FinanceSpecial]:
|
||||
stmt = select(FinanceSpecial).where(FinanceSpecial.study_id == study_id)
|
||||
stmt = (
|
||||
select(FinanceSpecial)
|
||||
.where(FinanceSpecial.study_id == study_id)
|
||||
)
|
||||
if site_name:
|
||||
stmt = stmt.where(FinanceSpecial.site_name.ilike(f"%{site_name}%"))
|
||||
if fee_type:
|
||||
|
||||
@@ -41,7 +41,10 @@ async def list_notes(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> Sequence[KnowledgeNote]:
|
||||
stmt = select(KnowledgeNote).where(KnowledgeNote.study_id == study_id)
|
||||
stmt = (
|
||||
select(KnowledgeNote)
|
||||
.where(KnowledgeNote.study_id == study_id)
|
||||
)
|
||||
if site_name:
|
||||
stmt = stmt.where(KnowledgeNote.site_name.ilike(f"%{site_name}%"))
|
||||
if keyword:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.study_member import StudyMember
|
||||
@@ -54,13 +54,8 @@ async def update_member(db: AsyncSession, member: StudyMember, member_in: StudyM
|
||||
|
||||
|
||||
async def remove_member(db: AsyncSession, member: StudyMember) -> StudyMember:
|
||||
await db.execute(
|
||||
update(StudyMember)
|
||||
.where(StudyMember.id == member.id)
|
||||
.values(is_active=False)
|
||||
)
|
||||
await db.execute(delete(StudyMember).where(StudyMember.id == member.id))
|
||||
await db.commit()
|
||||
await db.refresh(member)
|
||||
return member
|
||||
|
||||
|
||||
|
||||
+312
-6
@@ -1,12 +1,42 @@
|
||||
import uuid
|
||||
from typing import Sequence
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import delete, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.acknowledgement import Acknowledgement
|
||||
from app.models.ae import AdverseEvent
|
||||
from app.models.attachment import Attachment
|
||||
from app.models.contract_fee import ContractFee
|
||||
from app.models.contract_fee_payment import ContractFeePayment
|
||||
from app.models.document import Document
|
||||
from app.models.document_version import DocumentVersion
|
||||
from app.models.distribution import Distribution
|
||||
from app.models.drug_shipment import DrugShipment
|
||||
from app.models.fee_attachment import FeeAttachment
|
||||
from app.models.finance import FinanceItem
|
||||
from app.models.finance_contract import FinanceContract
|
||||
from app.models.finance_special import FinanceSpecial
|
||||
from app.models.kickoff_meeting import KickoffMeeting
|
||||
from app.models.knowledge_note import KnowledgeNote
|
||||
from app.models.milestone import Milestone
|
||||
from app.models.site import Site
|
||||
from app.models.special_expense import SpecialExpense
|
||||
from app.models.startup_ethics import StartupEthics
|
||||
from app.models.startup_feasibility import StartupFeasibility
|
||||
from app.models.subject import Subject
|
||||
from app.models.subject_history import SubjectHistory
|
||||
from app.models.training_authorization import TrainingAuthorization
|
||||
from app.models.version_workflow import VersionWorkflow
|
||||
from app.models.visit import Visit
|
||||
from app.models.workflow_action import WorkflowAction
|
||||
from app.schemas.site import SiteCreate, SiteUpdate
|
||||
|
||||
ATTACHMENT_ROOT = Path(__file__).resolve().parent.parent / "uploads"
|
||||
FEE_ATTACHMENT_ROOT = ATTACHMENT_ROOT / "fees"
|
||||
DOCUMENT_ATTACHMENT_ROOT = ATTACHMENT_ROOT / "documents"
|
||||
|
||||
|
||||
async def create_site(db: AsyncSession, study_id: uuid.UUID, site_in: SiteCreate) -> Site:
|
||||
site = Site(
|
||||
@@ -41,10 +71,17 @@ async def update_site(db: AsyncSession, site: Site, site_in: SiteUpdate) -> Site
|
||||
return site
|
||||
|
||||
|
||||
async def list_by_study(db: AsyncSession, study_id: uuid.UUID, skip: int = 0, limit: int = 100) -> Sequence[Site]:
|
||||
result = await db.execute(
|
||||
select(Site).where(Site.study_id == study_id).offset(skip).limit(limit)
|
||||
)
|
||||
async def list_by_study(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
include_inactive: bool = False,
|
||||
) -> Sequence[Site]:
|
||||
stmt = select(Site).where(Site.study_id == study_id)
|
||||
if not include_inactive:
|
||||
stmt = stmt.where(Site.is_active.is_(True))
|
||||
result = await db.execute(stmt.offset(skip).limit(limit))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@@ -54,3 +91,272 @@ async def get_sites_by_ids(db: AsyncSession, ids: set[uuid.UUID]) -> dict[uuid.U
|
||||
result = await db.execute(select(Site).where(Site.id.in_(ids)))
|
||||
sites = result.scalars().all()
|
||||
return {s.id: s for s in sites}
|
||||
|
||||
|
||||
async def list_active_names(db: AsyncSession, study_id: uuid.UUID) -> set[str]:
|
||||
result = await db.execute(
|
||||
select(Site.name).where(Site.study_id == study_id, Site.is_active.is_(True))
|
||||
)
|
||||
return {row[0] for row in result.all() if row[0]}
|
||||
|
||||
|
||||
async def delete_site_and_related(db: AsyncSession, site: Site) -> None:
|
||||
site_id = site.id
|
||||
study_id = site.study_id
|
||||
site_name = site.name
|
||||
|
||||
subject_ids = (
|
||||
await db.execute(select(Subject.id).where(Subject.site_id == site_id))
|
||||
).scalars().all()
|
||||
contract_fee_ids = (
|
||||
await db.execute(select(ContractFee.id).where(ContractFee.center_id == site_id))
|
||||
).scalars().all()
|
||||
special_expense_ids = (
|
||||
await db.execute(select(SpecialExpense.id).where(SpecialExpense.center_id == site_id))
|
||||
).scalars().all()
|
||||
drug_shipment_ids = (
|
||||
await db.execute(select(DrugShipment.id).where(DrugShipment.center_id == site_id))
|
||||
).scalars().all()
|
||||
kickoff_ids = (
|
||||
await db.execute(select(KickoffMeeting.id).where(KickoffMeeting.site_id == site_id))
|
||||
).scalars().all()
|
||||
feasibility_ids = (
|
||||
await db.execute(select(StartupFeasibility.id).where(StartupFeasibility.site_id == site_id))
|
||||
).scalars().all()
|
||||
ethics_ids = (
|
||||
await db.execute(select(StartupEthics.id).where(StartupEthics.site_id == site_id))
|
||||
).scalars().all()
|
||||
training_ids = (
|
||||
await db.execute(
|
||||
select(TrainingAuthorization.id).where(
|
||||
TrainingAuthorization.study_id == study_id,
|
||||
TrainingAuthorization.site_name == site_name,
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
finance_contract_ids = (
|
||||
await db.execute(
|
||||
select(FinanceContract.id).where(
|
||||
FinanceContract.study_id == study_id,
|
||||
FinanceContract.site_name == site_name,
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
finance_special_ids = (
|
||||
await db.execute(
|
||||
select(FinanceSpecial.id).where(
|
||||
FinanceSpecial.study_id == study_id,
|
||||
FinanceSpecial.site_name == site_name,
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
knowledge_note_ids = (
|
||||
await db.execute(
|
||||
select(KnowledgeNote.id).where(
|
||||
KnowledgeNote.study_id == study_id,
|
||||
KnowledgeNote.site_name == site_name,
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
document_ids = (
|
||||
await db.execute(select(Document.id).where(Document.site_id == site_id))
|
||||
).scalars().all()
|
||||
version_ids = []
|
||||
if document_ids:
|
||||
version_ids = (
|
||||
await db.execute(select(DocumentVersion.id).where(DocumentVersion.document_id.in_(document_ids)))
|
||||
).scalars().all()
|
||||
|
||||
workflow_ids = []
|
||||
workflow_conditions = []
|
||||
if document_ids:
|
||||
workflow_conditions.append(VersionWorkflow.document_id.in_(document_ids))
|
||||
if version_ids:
|
||||
workflow_conditions.append(VersionWorkflow.version_id.in_(version_ids))
|
||||
if workflow_conditions:
|
||||
workflow_ids = (
|
||||
await db.execute(select(VersionWorkflow.id).where(or_(*workflow_conditions)))
|
||||
).scalars().all()
|
||||
|
||||
distribution_conditions = []
|
||||
if document_ids:
|
||||
distribution_conditions.append(Distribution.document_id.in_(document_ids))
|
||||
if version_ids:
|
||||
distribution_conditions.append(Distribution.version_id.in_(version_ids))
|
||||
distribution_conditions.append((Distribution.target_type == "SITE") & (Distribution.target_id == str(site_id)))
|
||||
distribution_ids = (
|
||||
await db.execute(select(Distribution.id).where(or_(*distribution_conditions)))
|
||||
).scalars().all()
|
||||
|
||||
attachment_groups: dict[str, list[uuid.UUID]] = {
|
||||
"startup_feasibility": feasibility_ids,
|
||||
"startup_ethics": ethics_ids,
|
||||
"startup_kickoff": kickoff_ids,
|
||||
"startup_kickoff_minutes": kickoff_ids,
|
||||
"startup_kickoff_signin": kickoff_ids,
|
||||
"startup_kickoff_ppt": kickoff_ids,
|
||||
"training_authorization": training_ids,
|
||||
"finance_contract": finance_contract_ids,
|
||||
"finance_special": finance_special_ids,
|
||||
"knowledge_note": knowledge_note_ids,
|
||||
"drug_shipment": drug_shipment_ids,
|
||||
}
|
||||
attachment_paths: list[str] = []
|
||||
for entity_type, ids in attachment_groups.items():
|
||||
if not ids:
|
||||
continue
|
||||
attachment_paths.extend(
|
||||
(
|
||||
await db.execute(
|
||||
select(Attachment.file_path).where(
|
||||
Attachment.study_id == study_id,
|
||||
Attachment.entity_type == entity_type,
|
||||
Attachment.entity_id.in_(ids),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
await db.execute(
|
||||
delete(Attachment).where(
|
||||
Attachment.study_id == study_id,
|
||||
Attachment.entity_type == entity_type,
|
||||
Attachment.entity_id.in_(ids),
|
||||
)
|
||||
)
|
||||
|
||||
fee_attachment_paths: list[str] = []
|
||||
if contract_fee_ids:
|
||||
fee_attachment_paths.extend(
|
||||
(
|
||||
await db.execute(
|
||||
select(FeeAttachment.storage_key).where(
|
||||
FeeAttachment.entity_type == "contract_fee",
|
||||
FeeAttachment.entity_id.in_(contract_fee_ids),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
await db.execute(
|
||||
delete(FeeAttachment).where(
|
||||
FeeAttachment.entity_type == "contract_fee",
|
||||
FeeAttachment.entity_id.in_(contract_fee_ids),
|
||||
)
|
||||
)
|
||||
await db.execute(delete(ContractFeePayment).where(ContractFeePayment.contract_fee_id.in_(contract_fee_ids)))
|
||||
if special_expense_ids:
|
||||
fee_attachment_paths.extend(
|
||||
(
|
||||
await db.execute(
|
||||
select(FeeAttachment.storage_key).where(
|
||||
FeeAttachment.entity_type == "special_expense",
|
||||
FeeAttachment.entity_id.in_(special_expense_ids),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
await db.execute(
|
||||
delete(FeeAttachment).where(
|
||||
FeeAttachment.entity_type == "special_expense",
|
||||
FeeAttachment.entity_id.in_(special_expense_ids),
|
||||
)
|
||||
)
|
||||
|
||||
if workflow_ids:
|
||||
await db.execute(delete(WorkflowAction).where(WorkflowAction.workflow_id.in_(workflow_ids)))
|
||||
await db.execute(delete(VersionWorkflow).where(VersionWorkflow.id.in_(workflow_ids)))
|
||||
if distribution_ids:
|
||||
await db.execute(delete(Acknowledgement).where(Acknowledgement.distribution_id.in_(distribution_ids)))
|
||||
await db.execute(delete(Distribution).where(Distribution.id.in_(distribution_ids)))
|
||||
await db.execute(delete(Acknowledgement).where(Acknowledgement.site_id == site_id))
|
||||
version_file_paths: list[str] = []
|
||||
if version_ids:
|
||||
version_file_paths = (
|
||||
await db.execute(select(DocumentVersion.file_uri).where(DocumentVersion.id.in_(version_ids)))
|
||||
).scalars().all()
|
||||
await db.execute(delete(DocumentVersion).where(DocumentVersion.id.in_(version_ids)))
|
||||
if document_ids:
|
||||
await db.execute(delete(Document).where(Document.id.in_(document_ids)))
|
||||
|
||||
if subject_ids:
|
||||
await db.execute(delete(SubjectHistory).where(SubjectHistory.subject_id.in_(subject_ids)))
|
||||
await db.execute(delete(Visit).where(Visit.subject_id.in_(subject_ids)))
|
||||
await db.execute(delete(AdverseEvent).where(AdverseEvent.subject_id.in_(subject_ids)))
|
||||
await db.execute(delete(FinanceItem).where(FinanceItem.subject_id.in_(subject_ids)))
|
||||
|
||||
await db.execute(delete(AdverseEvent).where(AdverseEvent.site_id == site_id))
|
||||
await db.execute(delete(FinanceItem).where(FinanceItem.site_id == site_id))
|
||||
await db.execute(delete(Subject).where(Subject.site_id == site_id))
|
||||
|
||||
await db.execute(delete(Milestone).where(Milestone.site_id == site_id))
|
||||
await db.execute(delete(KickoffMeeting).where(KickoffMeeting.site_id == site_id))
|
||||
await db.execute(delete(StartupFeasibility).where(StartupFeasibility.site_id == site_id))
|
||||
await db.execute(delete(StartupEthics).where(StartupEthics.site_id == site_id))
|
||||
|
||||
await db.execute(delete(ContractFee).where(ContractFee.center_id == site_id))
|
||||
await db.execute(delete(SpecialExpense).where(SpecialExpense.center_id == site_id))
|
||||
await db.execute(delete(DrugShipment).where(DrugShipment.center_id == site_id))
|
||||
|
||||
await db.execute(
|
||||
delete(TrainingAuthorization).where(
|
||||
TrainingAuthorization.study_id == study_id,
|
||||
TrainingAuthorization.site_name == site_name,
|
||||
)
|
||||
)
|
||||
await db.execute(
|
||||
delete(FinanceContract).where(
|
||||
FinanceContract.study_id == study_id,
|
||||
FinanceContract.site_name == site_name,
|
||||
)
|
||||
)
|
||||
await db.execute(
|
||||
delete(FinanceSpecial).where(
|
||||
FinanceSpecial.study_id == study_id,
|
||||
FinanceSpecial.site_name == site_name,
|
||||
)
|
||||
)
|
||||
await db.execute(
|
||||
delete(KnowledgeNote).where(
|
||||
KnowledgeNote.study_id == study_id,
|
||||
KnowledgeNote.site_name == site_name,
|
||||
)
|
||||
)
|
||||
|
||||
await db.execute(delete(Site).where(Site.id == site_id))
|
||||
await _remove_files(attachment_paths, ATTACHMENT_ROOT)
|
||||
await _remove_files(fee_attachment_paths, FEE_ATTACHMENT_ROOT)
|
||||
await _remove_files(version_file_paths, DOCUMENT_ATTACHMENT_ROOT)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _remove_files(paths: Iterable[str | None], root: Path) -> None:
|
||||
for raw in paths:
|
||||
if not raw:
|
||||
continue
|
||||
file_path = Path(str(raw))
|
||||
try:
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
_prune_empty_parents(file_path.parent, root)
|
||||
|
||||
|
||||
def _prune_empty_parents(start: Path, root: Path) -> None:
|
||||
try:
|
||||
root_resolved = root.resolve()
|
||||
except OSError:
|
||||
return
|
||||
try:
|
||||
current = start.resolve()
|
||||
except OSError:
|
||||
return
|
||||
if root_resolved not in current.parents and current != root_resolved:
|
||||
return
|
||||
while True:
|
||||
if current == root_resolved:
|
||||
break
|
||||
try:
|
||||
current.rmdir()
|
||||
except OSError:
|
||||
break
|
||||
current = current.parent
|
||||
|
||||
+132
-3
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import select, update as sa_update
|
||||
from sqlalchemy import delete as sa_delete, select, update as sa_update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.study import Study
|
||||
@@ -9,10 +9,22 @@ from app.schemas.study import StudyCreate, StudyUpdate
|
||||
|
||||
|
||||
async def create(db: AsyncSession, study_in: StudyCreate, *, created_by: uuid.UUID | None = None) -> Study:
|
||||
import uuid as uuid_lib # Import uuid to generate unique code if needed
|
||||
|
||||
code = study_in.code
|
||||
if not code:
|
||||
# Generate a unique code if not provided
|
||||
# Use first 8 chars of a new UUID, uppercase
|
||||
code = f"P-{str(uuid_lib.uuid4())[:8].upper()}"
|
||||
|
||||
study = Study(
|
||||
code=study_in.code,
|
||||
code=code,
|
||||
name=study_in.name,
|
||||
sponsor=study_in.sponsor,
|
||||
# sponsor is removed from schema, but model has it. Set to None/empty or keep existing value if schema had it (which it doesn't now)
|
||||
# If we removed it from schema, study_in.sponsor will fail if we try to access it via .sponsor attribute directly if it's not in the pydantic model anymore.
|
||||
# Since we removed `sponsor` field from StudyCreate, `study_in` object won't have `sponsor` attribute unless we access dict or use default.
|
||||
# However, the Study MODEL still has sponsor. We should set it to None.
|
||||
sponsor=None,
|
||||
protocol_no=study_in.protocol_no,
|
||||
phase=study_in.phase,
|
||||
status=study_in.status,
|
||||
@@ -73,3 +85,120 @@ async def list_studies_for_user(
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def delete(db: AsyncSession, study_id: uuid.UUID) -> None:
|
||||
"""硬删除项目及其所有关联数据"""
|
||||
# 导入所有关联的模型
|
||||
from app.models.study_member import StudyMember
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.site import Site
|
||||
from app.models.subject import Subject
|
||||
from app.models.subject_history import SubjectHistory
|
||||
from app.models.visit import Visit
|
||||
from app.models.ae import AdverseEvent
|
||||
from app.models.finance import FinanceItem
|
||||
from app.models.finance_contract import FinanceContract
|
||||
from app.models.finance_special import FinanceSpecial
|
||||
from app.models.contract_fee import ContractFee
|
||||
from app.models.special_expense import SpecialExpense
|
||||
from app.models.milestone import Milestone
|
||||
from app.models.document import Document
|
||||
from app.models.attachment import Attachment
|
||||
from app.models.drug_shipment import DrugShipment
|
||||
from app.models.training_authorization import TrainingAuthorization
|
||||
from app.models.startup_feasibility import StartupFeasibility
|
||||
from app.models.startup_ethics import StartupEthics
|
||||
from app.models.kickoff_meeting import KickoffMeeting
|
||||
from app.models.knowledge_note import KnowledgeNote
|
||||
from app.models.faq_category import FaqCategory
|
||||
from app.models.faq_item import FaqItem
|
||||
from app.models.faq_reply import FaqReply
|
||||
from app.models.workflow_template import WorkflowTemplate
|
||||
|
||||
# 按依赖关系顺序删除关联数据
|
||||
# 1. 删除审计日志
|
||||
await db.execute(sa_delete(AuditLog).where(AuditLog.study_id == study_id))
|
||||
|
||||
# 2. 删除FAQ相关(可能有依赖关系)
|
||||
await db.execute(sa_delete(FaqReply).where(FaqReply.study_id == study_id))
|
||||
await db.execute(sa_delete(FaqItem).where(FaqItem.study_id == study_id))
|
||||
await db.execute(sa_delete(FaqCategory).where(FaqCategory.study_id == study_id))
|
||||
|
||||
# 3. 删除知识库
|
||||
await db.execute(sa_delete(KnowledgeNote).where(KnowledgeNote.study_id == study_id))
|
||||
|
||||
# 4. 删除工作流模板
|
||||
await db.execute(sa_delete(WorkflowTemplate).where(WorkflowTemplate.trial_id == study_id))
|
||||
|
||||
# 5. 删除启动相关
|
||||
await db.execute(sa_delete(KickoffMeeting).where(KickoffMeeting.study_id == study_id))
|
||||
await db.execute(sa_delete(StartupEthics).where(StartupEthics.study_id == study_id))
|
||||
await db.execute(sa_delete(StartupFeasibility).where(StartupFeasibility.study_id == study_id))
|
||||
await db.execute(sa_delete(TrainingAuthorization).where(TrainingAuthorization.study_id == study_id))
|
||||
|
||||
# 6. 删除药物配送
|
||||
await db.execute(sa_delete(DrugShipment).where(DrugShipment.study_id == study_id))
|
||||
|
||||
# 7. 删除附件和文档
|
||||
await db.execute(sa_delete(Attachment).where(Attachment.study_id == study_id))
|
||||
await db.execute(sa_delete(Document).where(Document.trial_id == study_id))
|
||||
|
||||
# 8. 删除里程碑
|
||||
await db.execute(sa_delete(Milestone).where(Milestone.study_id == study_id))
|
||||
|
||||
# 9. 删除财务相关
|
||||
await db.execute(sa_delete(SpecialExpense).where(SpecialExpense.project_id == study_id))
|
||||
await db.execute(sa_delete(ContractFee).where(ContractFee.project_id == study_id))
|
||||
await db.execute(sa_delete(FinanceSpecial).where(FinanceSpecial.study_id == study_id))
|
||||
await db.execute(sa_delete(FinanceContract).where(FinanceContract.study_id == study_id))
|
||||
await db.execute(sa_delete(FinanceItem).where(FinanceItem.study_id == study_id))
|
||||
|
||||
# 10. 删除不良事件
|
||||
await db.execute(sa_delete(AdverseEvent).where(AdverseEvent.study_id == study_id))
|
||||
|
||||
# 11. 删除访视
|
||||
await db.execute(sa_delete(Visit).where(Visit.study_id == study_id))
|
||||
|
||||
# 12. 删除受试者相关
|
||||
await db.execute(sa_delete(SubjectHistory).where(SubjectHistory.study_id == study_id))
|
||||
await db.execute(sa_delete(Subject).where(Subject.study_id == study_id))
|
||||
|
||||
# 13. 删除站点
|
||||
await db.execute(sa_delete(Site).where(Site.study_id == study_id))
|
||||
|
||||
# 14. 删除成员
|
||||
await db.execute(sa_delete(StudyMember).where(StudyMember.study_id == study_id))
|
||||
|
||||
# 15. 最后删除项目本身
|
||||
await db.execute(sa_delete(Study).where(Study.id == study_id))
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def lock(db: AsyncSession, study_id: uuid.UUID) -> Study | None:
|
||||
"""锁定项目"""
|
||||
await db.execute(
|
||||
sa_update(Study)
|
||||
.where(Study.id == study_id)
|
||||
.values(is_locked=True)
|
||||
)
|
||||
await db.commit()
|
||||
return await get(db, study_id)
|
||||
|
||||
|
||||
async def unlock(db: AsyncSession, study_id: uuid.UUID) -> Study | None:
|
||||
"""解锁项目"""
|
||||
await db.execute(
|
||||
sa_update(Study)
|
||||
.where(Study.id == study_id)
|
||||
.values(is_locked=False)
|
||||
)
|
||||
await db.commit()
|
||||
return await get(db, study_id)
|
||||
|
||||
|
||||
async def is_locked(db: AsyncSession, study_id: uuid.UUID) -> bool:
|
||||
"""检查项目是否已锁定"""
|
||||
study = await get(db, study_id)
|
||||
return study.is_locked if study else False
|
||||
|
||||
@@ -18,6 +18,8 @@ async def _validate_site(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UU
|
||||
site = result.scalar_one_or_none()
|
||||
if not site or site.study_id != study_id:
|
||||
raise ValueError("分中心不属于当前项目")
|
||||
if not site.is_active:
|
||||
raise ValueError("中心已停用")
|
||||
|
||||
|
||||
async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: SubjectCreate) -> Subject:
|
||||
@@ -58,16 +60,10 @@ async def list_subjects(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
site_id: uuid.UUID | None = None,
|
||||
status: str | None = None,
|
||||
subject_no: str | None = None,
|
||||
) -> Sequence[Subject]:
|
||||
stmt = select(Subject).where(Subject.study_id == study_id)
|
||||
if site_id:
|
||||
stmt = stmt.where(Subject.site_id == site_id)
|
||||
if status:
|
||||
stmt = stmt.where(Subject.status == status)
|
||||
if subject_no:
|
||||
stmt = stmt.where(Subject.subject_no.ilike(f"%{subject_no}%"))
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -18,6 +18,7 @@ class Study(Base):
|
||||
protocol_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
phase: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="DRAFT")
|
||||
is_locked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
visit_interval_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
visit_total: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
visit_window_start_offset: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
@@ -8,9 +8,8 @@ StudyStatus = Literal["DRAFT", "ACTIVE", "CLOSED"]
|
||||
|
||||
|
||||
class StudyCreate(BaseModel):
|
||||
code: str = Field(min_length=1)
|
||||
name: str = Field(min_length=1)
|
||||
sponsor: Optional[str] = None
|
||||
code: Optional[str] = None
|
||||
protocol_no: Optional[str] = None
|
||||
phase: Optional[str] = None
|
||||
status: StudyStatus = "DRAFT"
|
||||
@@ -21,12 +20,11 @@ class StudyCreate(BaseModel):
|
||||
|
||||
|
||||
class StudyUpdate(BaseModel):
|
||||
code: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
sponsor: Optional[str] = None
|
||||
protocol_no: Optional[str] = None
|
||||
phase: Optional[str] = None
|
||||
status: Optional[StudyStatus] = None
|
||||
is_locked: Optional[bool] = None
|
||||
visit_interval_days: Optional[int] = None
|
||||
visit_total: Optional[int] = None
|
||||
visit_window_start_offset: Optional[int] = None
|
||||
@@ -35,12 +33,12 @@ class StudyUpdate(BaseModel):
|
||||
|
||||
class StudyRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
code: str
|
||||
name: str
|
||||
sponsor: Optional[str]
|
||||
protocol_no: Optional[str]
|
||||
protocol_no: Optional[str]
|
||||
phase: Optional[str]
|
||||
status: StudyStatus
|
||||
is_locked: bool
|
||||
visit_interval_days: Optional[int]
|
||||
visit_total: Optional[int]
|
||||
visit_window_start_offset: Optional[int]
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.crud import distribution as distribution_crud
|
||||
from app.crud import document as document_crud
|
||||
from app.crud import document_version as version_crud
|
||||
from app.crud import member as member_crud
|
||||
from app.crud import site as site_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.crud import user as user_crud
|
||||
from app.crud import version_workflow as workflow_crud
|
||||
@@ -91,6 +92,10 @@ async def create_document(
|
||||
if doc_in.scope_type == DocumentScopeType.SITE and not doc_in.site_id:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="分中心文件需指定分中心")
|
||||
site_id = doc_in.site_id if doc_in.scope_type == DocumentScopeType.SITE else None
|
||||
if site_id:
|
||||
site = await site_crud.get_site(db, site_id)
|
||||
if not site or not site.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心已停用")
|
||||
existing = await document_crud.get_by_trial_doc_no(db, doc_in.trial_id, doc_in.doc_no)
|
||||
if existing:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="文档编号已存在")
|
||||
|
||||
Reference in New Issue
Block a user