diff --git a/backend/alembic/versions/20260116_01_add_is_locked_to_studies.py b/backend/alembic/versions/20260116_01_add_is_locked_to_studies.py new file mode 100644 index 00000000..13b0e724 --- /dev/null +++ b/backend/alembic/versions/20260116_01_add_is_locked_to_studies.py @@ -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') diff --git a/backend/app/api/v1/aes.py b/backend/app/api/v1/aes.py index dc31260a..fbb28b69 100644 --- a/backend/app/api/v1/aes.py +++ b/backend/app/api/v1/aes.py @@ -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="权限不足") diff --git a/backend/app/api/v1/attachments.py b/backend/app/api/v1/attachments.py index 0d01f77d..4efa2241 100644 --- a/backend/app/api/v1/attachments.py +++ b/backend/app/api/v1/attachments.py @@ -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, diff --git a/backend/app/api/v1/drug_shipments.py b/backend/app/api/v1/drug_shipments.py index c3237c10..d6c96899 100644 --- a/backend/app/api/v1/drug_shipments.py +++ b/backend/app/api/v1/drug_shipments.py @@ -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, diff --git a/backend/app/api/v1/faq_categories.py b/backend/app/api/v1/faq_categories.py index 773e3974..d83a8d66 100644 --- a/backend/app/api/v1/faq_categories.py +++ b/backend/app/api/v1/faq_categories.py @@ -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, diff --git a/backend/app/api/v1/faqs.py b/backend/app/api/v1/faqs.py index 5d1f1206..63fc1642 100644 --- a/backend/app/api/v1/faqs.py +++ b/backend/app/api/v1/faqs.py @@ -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, diff --git a/backend/app/api/v1/fees_attachments.py b/backend/app/api/v1/fees_attachments.py index 5b3951b2..d07251cc 100644 --- a/backend/app/api/v1/fees_attachments.py +++ b/backend/app/api/v1/fees_attachments.py @@ -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, diff --git a/backend/app/api/v1/fees_contracts.py b/backend/app/api/v1/fees_contracts.py index 8f754403..4b160b39 100644 --- a/backend/app/api/v1/fees_contracts.py +++ b/backend/app/api/v1/fees_contracts.py @@ -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, diff --git a/backend/app/api/v1/fees_specials.py b/backend/app/api/v1/fees_specials.py index 9f7caad2..f83bd52e 100644 --- a/backend/app/api/v1/fees_specials.py +++ b/backend/app/api/v1/fees_specials.py @@ -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, diff --git a/backend/app/api/v1/finance_contracts.py b/backend/app/api/v1/finance_contracts.py index 5c08b811..27c56066 100644 --- a/backend/app/api/v1/finance_contracts.py +++ b/backend/app/api/v1/finance_contracts.py @@ -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, diff --git a/backend/app/api/v1/finance_specials.py b/backend/app/api/v1/finance_specials.py index 7efc752e..d5e9061a 100644 --- a/backend/app/api/v1/finance_specials.py +++ b/backend/app/api/v1/finance_specials.py @@ -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, diff --git a/backend/app/api/v1/knowledge_notes.py b/backend/app/api/v1/knowledge_notes.py index 94b228ac..cc491008 100644 --- a/backend/app/api/v1/knowledge_notes.py +++ b/backend/app/api/v1/knowledge_notes.py @@ -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, diff --git a/backend/app/api/v1/members.py b/backend/app/api/v1/members.py index 19666b98..7e9a4f8a 100644 --- a/backend/app/api/v1/members.py +++ b/backend/app/api/v1/members.py @@ -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, diff --git a/backend/app/api/v1/sites.py b/backend/app/api/v1/sites.py index 4afaab3d..ada8bd27 100644 --- a/backend/app/api/v1/sites.py +++ b/backend/app/api/v1/sites.py @@ -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 diff --git a/backend/app/api/v1/startup.py b/backend/app/api/v1/startup.py index 67566a16..212b56be 100644 --- a/backend/app/api/v1/startup.py +++ b/backend/app/api/v1/startup.py @@ -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, diff --git a/backend/app/api/v1/studies.py b/backend/app/api/v1/studies.py index 00da7a41..851af911 100644 --- a/backend/app/api/v1/studies.py +++ b/backend/app/api/v1/studies.py @@ -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 diff --git a/backend/app/api/v1/subject_histories.py b/backend/app/api/v1/subject_histories.py index b7fe66bc..53cd36a1 100644 --- a/backend/app/api/v1/subject_histories.py +++ b/backend/app/api/v1/subject_histories.py @@ -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="病史记录不存在") diff --git a/backend/app/api/v1/subjects.py b/backend/app/api/v1/subjects.py index 95d47274..c03c53bc 100644 --- a/backend/app/api/v1/subjects.py +++ b/backend/app/api/v1/subjects.py @@ -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, diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index 76a4b61d..8c73d4f5 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -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="用户不存在") diff --git a/backend/app/api/v1/visits.py b/backend/app/api/v1/visits.py index 075b56ab..edb34023 100644 --- a/backend/app/api/v1/visits.py +++ b/backend/app/api/v1/visits.py @@ -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="访视不存在") diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py index b0b65618..17822728 100644 --- a/backend/app/core/deps.py +++ b/backend/app/core/deps.py @@ -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 diff --git a/backend/app/crud/ae.py b/backend/app/crud/ae.py index 10008268..631fc547 100644 --- a/backend/app/crud/ae.py +++ b/backend/app/crud/ae.py @@ -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( diff --git a/backend/app/crud/finance_contract.py b/backend/app/crud/finance_contract.py index b9c91a00..806c0b6b 100644 --- a/backend/app/crud/finance_contract.py +++ b/backend/app/crud/finance_contract.py @@ -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: diff --git a/backend/app/crud/finance_special.py b/backend/app/crud/finance_special.py index 6642bc26..b34632ac 100644 --- a/backend/app/crud/finance_special.py +++ b/backend/app/crud/finance_special.py @@ -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: diff --git a/backend/app/crud/knowledge_note.py b/backend/app/crud/knowledge_note.py index d90f596d..fc959499 100644 --- a/backend/app/crud/knowledge_note.py +++ b/backend/app/crud/knowledge_note.py @@ -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: diff --git a/backend/app/crud/member.py b/backend/app/crud/member.py index b3bf611a..b6f93f8e 100644 --- a/backend/app/crud/member.py +++ b/backend/app/crud/member.py @@ -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 diff --git a/backend/app/crud/site.py b/backend/app/crud/site.py index db892324..00492f68 100644 --- a/backend/app/crud/site.py +++ b/backend/app/crud/site.py @@ -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 diff --git a/backend/app/crud/study.py b/backend/app/crud/study.py index 50542a5a..1b0ff7fb 100644 --- a/backend/app/crud/study.py +++ b/backend/app/crud/study.py @@ -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 diff --git a/backend/app/crud/subject.py b/backend/app/crud/subject.py index 6411c255..a8183de0 100644 --- a/backend/app/crud/subject.py +++ b/backend/app/crud/subject.py @@ -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() diff --git a/backend/app/models/study.py b/backend/app/models/study.py index 71e12c4a..a2bc4ee9 100644 --- a/backend/app/models/study.py +++ b/backend/app/models/study.py @@ -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) diff --git a/backend/app/schemas/study.py b/backend/app/schemas/study.py index d4d2b202..43d51073 100644 --- a/backend/app/schemas/study.py +++ b/backend/app/schemas/study.py @@ -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] diff --git a/backend/app/services/document_service.py b/backend/app/services/document_service.py index 8c96f260..3ddbc322 100644 --- a/backend/app/services/document_service.py +++ b/backend/app/services/document_service.py @@ -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="文档编号已存在") diff --git a/frontend/src/api/sites.ts b/frontend/src/api/sites.ts index 1c3cf694..a7d004e0 100644 --- a/frontend/src/api/sites.ts +++ b/frontend/src/api/sites.ts @@ -1,11 +1,16 @@ -import { apiGet, apiPatch, apiPost } from "./axios"; +import { apiDelete, apiGet, apiPatch, apiPost } from "./axios"; import type { ApiListResponse, Site } from "../types/api"; export const fetchSites = (studyId: string, params?: Record) => - apiGet | Site[]>(`/api/v1/studies/${studyId}/sites/`, { params }); + apiGet | Site[]>(`/api/v1/studies/${studyId}/sites/`, { + params: { include_inactive: true, ...(params || {}) }, + }); export const createSite = (studyId: string, payload: Partial & { name: string }) => apiPost(`/api/v1/studies/${studyId}/sites/`, payload); export const updateSite = (studyId: string, siteId: string, payload: Partial) => apiPatch(`/api/v1/studies/${studyId}/sites/${siteId}`, payload); + +export const deleteSite = (studyId: string, siteId: string) => + apiDelete(`/api/v1/studies/${studyId}/sites/${siteId}`); diff --git a/frontend/src/api/studies.ts b/frontend/src/api/studies.ts index a1b687ed..356daaec 100644 --- a/frontend/src/api/studies.ts +++ b/frontend/src/api/studies.ts @@ -1,13 +1,20 @@ -import { apiGet, apiPatch, apiPost } from "./axios"; +import { apiGet, apiPatch, apiPost, apiDelete } from "./axios"; import type { ApiListResponse, Study } from "../types/api"; // Backend expects trailing slash to avoid 307 redirect export const fetchStudies = () => apiGet>("/api/v1/studies/"); -export const createStudy = (payload: Partial & { code: string; name: string }) => +export const createStudy = (payload: Partial & { name: string }) => apiPost("/api/v1/studies/", payload); export const updateStudy = (studyId: string, payload: Partial) => apiPatch(`/api/v1/studies/${studyId}`, payload); export const fetchStudyDetail = (studyId: string) => apiGet(`/api/v1/studies/${studyId}`); + +export const deleteStudy = (studyId: string) => + apiDelete(`/api/v1/studies/${studyId}`); + +export const lockStudy = (studyId: string) => + apiPatch(`/api/v1/studies/${studyId}/lock`, {}); + diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index cee787b0..767dfc5d 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -20,5 +20,5 @@ export const updateUser = ( ) => apiPatch(`/api/v1/users/${userId}`, payload); -export const deleteUser = (userId: string, payload: { admin_password: string }) => - apiDelete(`/api/v1/users/${userId}`, { data: payload }); +export const deleteUser = (userId: string) => + apiDelete(`/api/v1/users/${userId}`); diff --git a/frontend/src/components/attachments/AttachmentList.vue b/frontend/src/components/attachments/AttachmentList.vue index 75ce80b1..88e1fba2 100644 --- a/frontend/src/components/attachments/AttachmentList.vue +++ b/frontend/src/components/attachments/AttachmentList.vue @@ -3,6 +3,7 @@
{{ headerTitle }} (); const attachments = ref([]); @@ -199,6 +201,7 @@ const preview = (row: any) => { }; const canDelete = (row: any) => { + if (props.readonly) return false; const userId = auth.user?.id; const role = auth.user?.role; const projectRole = study.currentStudyRole || (study.currentStudy as any)?.role_in_study; diff --git a/frontend/src/locales/zh-CN.ts b/frontend/src/locales/zh-CN.ts index 84de87df..96da006c 100644 --- a/frontend/src/locales/zh-CN.ts +++ b/frontend/src/locales/zh-CN.ts @@ -716,10 +716,10 @@ export const TEXT = { loadFailed: "用户列表加载失败", updateSuccess: "用户已更新", createSuccess: "用户已创建", - deleteConfirm: "删除后账号将无法登录且无法恢复,需先在各项目成员配置中移除该账号。请输入当前管理员密码确认删除。", + deleteConfirm: "删除后不可恢复,请输入“确认删除”继续。", deleteTitle: "危险操作:删除账号", - deletePasswordPlaceholder: "请输入 admin 密码", - deleteConfirmButton: "确认删除", + deleteConfirmPlaceholder: "确认删除", + deleteConfirmInputError: "请输入“确认删除”", deleteSuccess: "账号已删除", deleteFailed: "删除失败", enableConfirm: "确认启用该用户账号?", @@ -752,7 +752,7 @@ export const TEXT = { resetFailed: "重置失败", }, adminProjects: { - title: "项目治理", + title: "项目管理", subtitle: "项目是独立业务实体,账号加入项目后才成为项目成员(角色在成员配置中设置)", projectLabel: "项目", members: "项目账号/成员配置", @@ -827,6 +827,7 @@ export const TEXT = { craSaveSuccess: "绑定已保存", }, adminAuditLogs: { + title: "审计日志", filterEvent: "操作类型", filterOperator: "操作人", filterResult: "结果", diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 45628e05..ce52feb6 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -65,6 +65,7 @@ export interface Study { protocol_no?: string | null; phase?: string | null; status: string; + is_locked?: boolean; visit_interval_days?: number | null; visit_total?: number | null; visit_window_start_offset?: number | null; diff --git a/frontend/src/views/admin/AuditLogs.vue b/frontend/src/views/admin/AuditLogs.vue index 96ccfbfc..5a830b30 100644 --- a/frontend/src/views/admin/AuditLogs.vue +++ b/frontend/src/views/admin/AuditLogs.vue @@ -1,15 +1,20 @@ + + diff --git a/frontend/src/views/ia/FinanceSpecial.vue b/frontend/src/views/ia/FinanceSpecial.vue index a8741f18..423780f7 100644 --- a/frontend/src/views/ia/FinanceSpecial.vue +++ b/frontend/src/views/ia/FinanceSpecial.vue @@ -29,7 +29,14 @@ - + + + diff --git a/frontend/src/views/ia/KnowledgeInstructionFiles.vue b/frontend/src/views/ia/KnowledgeInstructionFiles.vue index 5f231a56..4bbe0fba 100644 --- a/frontend/src/views/ia/KnowledgeInstructionFiles.vue +++ b/frontend/src/views/ia/KnowledgeInstructionFiles.vue @@ -1,7 +1,6 @@ + + diff --git a/frontend/src/views/ia/KnowledgeSupportFiles.vue b/frontend/src/views/ia/KnowledgeSupportFiles.vue index 0deff42b..e61d04df 100644 --- a/frontend/src/views/ia/KnowledgeSupportFiles.vue +++ b/frontend/src/views/ia/KnowledgeSupportFiles.vue @@ -1,7 +1,6 @@