Files
ctms/backend/app/api/v1/startup.py
T
2026-06-10 10:20:04 +08:00

606 lines
24 KiB
Python

import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_operator_role_label, get_cra_site_scope, get_current_user, get_db_session, require_api_permission
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 (
KickoffMeetingCreate,
KickoffMeetingRead,
KickoffMeetingUpdate,
StartupEthicsCreate,
StartupEthicsRead,
StartupEthicsUpdate,
StartupFeasibilityCreate,
StartupFeasibilityRead,
StartupFeasibilityUpdate,
TrainingAuthorizationCreate,
TrainingAuthorizationRead,
TrainingAuthorizationUpdate,
)
router = APIRouter()
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
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="中心已停用")
async def _site_label(db: AsyncSession, site_id: uuid.UUID | None, fallback: str = "项目级") -> str:
if not site_id:
return fallback
site = await site_crud.get_site(db, site_id)
return site.name if site else fallback
async def _feasibility_audit_detail(db: AsyncSession, action: str, record) -> str:
site_name = await _site_label(db, record.site_id)
project_no = str(record.project_no or "").strip()
name = f"{site_name} / {project_no}" if project_no else site_name
return json.dumps({"targetName": name, "description": f"{action}立项记录“{name}”"}, ensure_ascii=False)
async def _ethics_audit_detail(db: AsyncSession, action: str, record) -> str:
site_name = await _site_label(db, record.site_id)
approval_no = str(record.approval_no or "").strip()
name = f"{site_name} / {approval_no}" if approval_no else site_name
return json.dumps({"targetName": name, "description": f"{action}伦理记录“{name}”"}, ensure_ascii=False)
async def _kickoff_audit_detail(db: AsyncSession, action: str, meeting) -> str:
site_name = await _site_label(db, meeting.site_id)
date_text = meeting.kickoff_date.isoformat() if meeting.kickoff_date else ""
name = f"{site_name} / {date_text}" if date_text else site_name
return json.dumps({"targetName": name, "description": f"{action}启动会记录“{name}”"}, ensure_ascii=False)
def _training_audit_detail(action: str, record) -> str:
name = str(record.name or "").strip() or "培训授权人员"
site_name = str(record.site_name or "").strip()
target_name = f"{name} / {site_name}" if site_name else name
return json.dumps({"targetName": target_name, "description": f"{action}培训授权人员“{target_name}”"}, ensure_ascii=False)
@router.post(
"/feasibility",
response_model=StartupFeasibilityRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_api_permission("startup_initiation:create"))],
)
async def create_feasibility(
study_id: uuid.UUID,
record_in: StartupFeasibilityCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> StartupFeasibilityRead:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record_in.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_active(db, record_in.site_id)
record = await startup_crud.create_feasibility(db, study_id, record_in, created_by=current_user.id)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="startup_feasibility",
entity_id=record.id,
action="CREATE_STARTUP_FEASIBILITY",
detail=await _feasibility_audit_detail(db, "创建", record),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return StartupFeasibilityRead.model_validate(record)
@router.get(
"/feasibility",
response_model=list[StartupFeasibilityRead],
dependencies=[Depends(require_api_permission("startup_initiation:read"))],
)
async def list_feasibilities(
study_id: uuid.UUID,
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[StartupFeasibilityRead]:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_ids = cra_scope[0] if cra_scope else None
items = await startup_crud.list_feasibilities(db, study_id, skip=skip, limit=limit, site_ids=site_ids)
return [StartupFeasibilityRead.model_validate(item) for item in items]
@router.get(
"/feasibility/{record_id}",
response_model=StartupFeasibilityRead,
dependencies=[Depends(require_api_permission("startup_initiation:read"))],
)
async def get_feasibility(
study_id: uuid.UUID,
record_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> StartupFeasibilityRead:
await _ensure_study_exists(db, study_id)
record = await startup_crud.get_feasibility(db, record_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="立项记录不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return StartupFeasibilityRead.model_validate(record)
@router.patch(
"/feasibility/{record_id}",
response_model=StartupFeasibilityRead,
dependencies=[Depends(require_api_permission("startup_initiation:update"))],
)
async def update_feasibility(
study_id: uuid.UUID,
record_id: uuid.UUID,
record_in: StartupFeasibilityUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> StartupFeasibilityRead:
await _ensure_study_exists(db, study_id)
record = await startup_crud.get_feasibility(db, record_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="立项记录不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_active(db, record.site_id)
record = await startup_crud.update_feasibility(db, record, record_in)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="startup_feasibility",
entity_id=record_id,
action="UPDATE_STARTUP_FEASIBILITY",
detail=await _feasibility_audit_detail(db, "更新", record),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return StartupFeasibilityRead.model_validate(record)
@router.delete(
"/feasibility/{record_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_api_permission("startup_initiation:delete"))],
)
async def delete_feasibility(
study_id: uuid.UUID,
record_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> None:
await _ensure_study_exists(db, study_id)
record = await startup_crud.get_feasibility(db, record_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="立项记录不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_active(db, record.site_id)
record_detail = await _feasibility_audit_detail(db, "删除", record)
await startup_crud.delete_feasibility(db, record)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="startup_feasibility",
entity_id=record_id,
action="DELETE_STARTUP_FEASIBILITY",
detail=record_detail,
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@router.post(
"/ethics",
response_model=StartupEthicsRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_api_permission("startup_ethics:create"))],
)
async def create_ethics(
study_id: uuid.UUID,
record_in: StartupEthicsCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> StartupEthicsRead:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record_in.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_active(db, record_in.site_id)
record = await startup_crud.create_ethics(db, study_id, record_in, created_by=current_user.id)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="startup_ethics",
entity_id=record.id,
action="CREATE_STARTUP_ETHICS",
detail=await _ethics_audit_detail(db, "创建", record),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return StartupEthicsRead.model_validate(record)
@router.get(
"/ethics",
response_model=list[StartupEthicsRead],
dependencies=[Depends(require_api_permission("startup_ethics:read"))],
)
async def list_ethics(
study_id: uuid.UUID,
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[StartupEthicsRead]:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_ids = cra_scope[0] if cra_scope else None
items = await startup_crud.list_ethics(db, study_id, skip=skip, limit=limit, site_ids=site_ids)
return [StartupEthicsRead.model_validate(item) for item in items]
@router.get(
"/ethics/{record_id}",
response_model=StartupEthicsRead,
dependencies=[Depends(require_api_permission("startup_ethics:read"))],
)
async def get_ethics(
study_id: uuid.UUID,
record_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> StartupEthicsRead:
await _ensure_study_exists(db, study_id)
record = await startup_crud.get_ethics(db, record_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="伦理记录不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return StartupEthicsRead.model_validate(record)
@router.patch(
"/ethics/{record_id}",
response_model=StartupEthicsRead,
dependencies=[Depends(require_api_permission("startup_ethics:update"))],
)
async def update_ethics(
study_id: uuid.UUID,
record_id: uuid.UUID,
record_in: StartupEthicsUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> StartupEthicsRead:
await _ensure_study_exists(db, study_id)
record = await startup_crud.get_ethics(db, record_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="伦理记录不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_active(db, record.site_id)
record = await startup_crud.update_ethics(db, record, record_in)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="startup_ethics",
entity_id=record_id,
action="UPDATE_STARTUP_ETHICS",
detail=await _ethics_audit_detail(db, "更新", record),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return StartupEthicsRead.model_validate(record)
@router.delete(
"/ethics/{record_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_api_permission("startup_ethics:delete"))],
)
async def delete_ethics(
study_id: uuid.UUID,
record_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> None:
await _ensure_study_exists(db, study_id)
record = await startup_crud.get_ethics(db, record_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="伦理记录不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_active(db, record.site_id)
record_detail = await _ethics_audit_detail(db, "删除", record)
await startup_crud.delete_ethics(db, record)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="startup_ethics",
entity_id=record_id,
action="DELETE_STARTUP_ETHICS",
detail=record_detail,
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@router.post(
"/kickoff",
response_model=KickoffMeetingRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_api_permission("startup_auth:create"))],
)
async def create_kickoff(
study_id: uuid.UUID,
meeting_in: KickoffMeetingCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> KickoffMeetingRead:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and meeting_in.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_active(db, meeting_in.site_id)
meeting = await startup_crud.create_kickoff(db, study_id, meeting_in, created_by=current_user.id)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="startup_kickoff",
entity_id=meeting.id,
action="CREATE_KICKOFF_MEETING",
detail=await _kickoff_audit_detail(db, "创建", meeting),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return KickoffMeetingRead.model_validate(meeting)
@router.get(
"/kickoff",
response_model=list[KickoffMeetingRead],
dependencies=[Depends(require_api_permission("startup_auth:read"))],
)
async def list_kickoffs(
study_id: uuid.UUID,
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[KickoffMeetingRead]:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_ids = cra_scope[0] if cra_scope else None
items = await startup_crud.list_kickoffs(db, study_id, skip=skip, limit=limit, site_ids=site_ids)
return [KickoffMeetingRead.model_validate(item) for item in items]
@router.get(
"/kickoff/{meeting_id}",
response_model=KickoffMeetingRead,
dependencies=[Depends(require_api_permission("startup_auth:read"))],
)
async def get_kickoff(
study_id: uuid.UUID,
meeting_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> KickoffMeetingRead:
await _ensure_study_exists(db, study_id)
meeting = await startup_crud.get_kickoff(db, meeting_id)
if not meeting or meeting.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="启动会记录不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and meeting.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return KickoffMeetingRead.model_validate(meeting)
@router.patch(
"/kickoff/{meeting_id}",
response_model=KickoffMeetingRead,
dependencies=[Depends(require_api_permission("startup_auth:update"))],
)
async def update_kickoff(
study_id: uuid.UUID,
meeting_id: uuid.UUID,
meeting_in: KickoffMeetingUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> KickoffMeetingRead:
await _ensure_study_exists(db, study_id)
meeting = await startup_crud.get_kickoff(db, meeting_id)
if not meeting or meeting.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="启动会记录不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and meeting.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_active(db, meeting.site_id)
meeting = await startup_crud.update_kickoff(db, meeting, meeting_in)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="startup_kickoff",
entity_id=meeting_id,
action="UPDATE_KICKOFF_MEETING",
detail=await _kickoff_audit_detail(db, "更新", meeting),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return KickoffMeetingRead.model_validate(meeting)
@router.post(
"/training-authorizations",
response_model=TrainingAuthorizationRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_api_permission("startup_auth:create"))],
)
async def create_training_authorization(
study_id: uuid.UUID,
record_in: TrainingAuthorizationCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> TrainingAuthorizationRead:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record_in.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, record_in.site_name)
record = await startup_crud.create_training_authorization(db, study_id, record_in, created_by=current_user.id)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="training_authorization",
entity_id=record.id,
action="CREATE_TRAINING_AUTH",
detail=_training_audit_detail("创建", record),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return TrainingAuthorizationRead.model_validate(record)
@router.get(
"/training-authorizations",
response_model=list[TrainingAuthorizationRead],
dependencies=[Depends(require_api_permission("startup_auth:read"))],
)
async def list_training_authorizations(
study_id: uuid.UUID,
skip: int = 0,
limit: int = 200,
site_name: str | None = None,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[TrainingAuthorizationRead]:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_names = cra_scope[1] if cra_scope else None
items = await startup_crud.list_training_authorizations(db, study_id, skip=skip, limit=limit, site_names=site_names, site_name=site_name)
return [TrainingAuthorizationRead.model_validate(item) for item in items]
@router.get(
"/training-authorizations/{record_id}",
response_model=TrainingAuthorizationRead,
dependencies=[Depends(require_api_permission("startup_auth:read"))],
)
async def get_training_authorization(
study_id: uuid.UUID,
record_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> TrainingAuthorizationRead:
await _ensure_study_exists(db, study_id)
record = await startup_crud.get_training_authorization(db, record_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="培训授权人员不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return TrainingAuthorizationRead.model_validate(record)
@router.patch(
"/training-authorizations/{record_id}",
response_model=TrainingAuthorizationRead,
dependencies=[Depends(require_api_permission("startup_auth:update"))],
)
async def update_training_authorization(
study_id: uuid.UUID,
record_id: uuid.UUID,
record_in: TrainingAuthorizationUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> TrainingAuthorizationRead:
await _ensure_study_exists(db, study_id)
record = await startup_crud.get_training_authorization(db, record_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="培训授权人员不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
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,
study_id=study_id,
entity_type="training_authorization",
entity_id=record_id,
action="UPDATE_TRAINING_AUTH",
detail=_training_audit_detail("更新", record),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return TrainingAuthorizationRead.model_validate(record)
@router.delete(
"/training-authorizations/{record_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_api_permission("startup_auth:delete"))],
)
async def delete_training_authorization(
study_id: uuid.UUID,
record_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> None:
await _ensure_study_exists(db, study_id)
record = await startup_crud.get_training_authorization(db, record_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="培训授权人员不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and record.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, record.site_name)
record_detail = _training_audit_detail("删除", record)
await startup_crud.delete_training_authorization(db, record)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="training_authorization",
entity_id=record_id,
action="DELETE_TRAINING_AUTH",
detail=record_detail,
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)