完善审计日志展示与导出权限

This commit is contained in:
Cheng Zhou
2026-06-10 10:20:04 +08:00
parent 1fef12cd86
commit ea3f19e241
33 changed files with 1862 additions and 240 deletions
+22 -3
View File
@@ -44,6 +44,15 @@ def _is_overdue(ae: AERead) -> bool:
return bool(ae.report_due_date and date.today() > ae.report_due_date and ae.status != "CLOSED")
def _ae_audit_name(ae) -> str:
return str(getattr(ae, "term", "") or "").strip() or "AE 记录"
def _ae_audit_detail(action: str, ae) -> str:
name = _ae_audit_name(ae)
return json.dumps({"targetName": name, "description": f"{action}AE“{name}"}, ensure_ascii=False)
@router.post(
"/",
response_model=AERead,
@@ -77,7 +86,7 @@ async def create_ae(
entity_type="ae",
entity_id=ae.id,
action="CREATE_AE",
detail=f"AE {ae.id} 已创建",
detail=_ae_audit_detail("创建", ae),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@@ -247,7 +256,16 @@ async def update_ae(
entity_type="ae",
entity_id=ae_id,
action=action,
detail=json.dumps({"before": detail_before, "after": detail_after}, ensure_ascii=False, default=str),
detail=json.dumps(
{
"targetName": _ae_audit_name(updated),
"description": f"{'变更' if action == 'AE_STATUS_CHANGE' else '更新'}AE“{_ae_audit_name(updated)}",
"before": detail_before,
"after": detail_after,
},
ensure_ascii=False,
default=str,
),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@@ -272,6 +290,7 @@ async def delete_ae(
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))
ae_detail = _ae_audit_detail("删除", ae)
await ae_crud.delete_ae(db, ae)
await audit_crud.log_action(
db,
@@ -279,7 +298,7 @@ async def delete_ae(
entity_type="ae",
entity_id=ae_id,
action="DELETE_AE",
detail=f"AE {ae_id} 已删除",
detail=ae_detail,
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
+23 -20
View File
@@ -1,15 +1,34 @@
import json
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_roles, require_api_permission
from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_api_permission
from app.crud import audit as audit_crud
from app.crud import study as study_crud
from app.schemas.audit import AuditEventCreate, AuditLogRead
router = APIRouter()
async def _build_export_audit_detail(db: AsyncSession, study_id: uuid.UUID, action: str) -> str:
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
scope = "system" if action == "AUDIT_EXPORT_SYSTEM" else "project"
description = "导出了系统审计日志" if scope == "system" else f"导出了项目审计日志:{study.name}"
return json.dumps(
{
"targetName": "系统审计日志" if scope == "system" else study.name,
"scope": scope,
"description": description,
"result": "SUCCESS",
},
ensure_ascii=False,
)
@router.get(
"/",
response_model=list[AuditLogRead],
@@ -38,22 +57,6 @@ async def list_audit_logs(
return list(logs)
@router.delete(
"/{log_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_roles(["ADMIN"]))],
)
async def delete_audit_log(
study_id: uuid.UUID,
log_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
):
log = await audit_crud.get_log(db, log_id)
if not log or log.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="审计日志不存在")
await audit_crud.delete_log(db, log)
@router.post(
"/events",
status_code=status.HTTP_201_CREATED,
@@ -71,10 +74,10 @@ async def create_audit_event(
await audit_crud.log_action(
db,
study_id=study_id,
entity_type=payload.entity_type or "audit_log",
entity_id=payload.entity_id,
entity_type="audit_log",
entity_id=None,
action=payload.action,
detail=payload.detail,
detail=await _build_export_audit_detail(db, study_id, payload.action),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
+8 -3
View File
@@ -23,6 +23,11 @@ def _shipment_audit_name(shipment) -> str:
return shipment.tracking_no or shipment.batch_no or shipment.site_name or "药品运输记录"
def _shipment_audit_description(action: str, shipment) -> str:
name = _shipment_audit_name(shipment)
return f"{action}药品流向“{name}"
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id)
if not study:
@@ -65,7 +70,7 @@ async def create_shipment(
entity_id=shipment.id,
action="CREATE_DRUG_SHIPMENT",
detail=json.dumps(
{"targetName": _shipment_audit_name(shipment), "description": f"药品运输 {_shipment_audit_name(shipment)} 已创建"},
{"targetName": _shipment_audit_name(shipment), "description": _shipment_audit_description("创建", shipment)},
ensure_ascii=False,
),
operator_id=current_user.id,
@@ -178,7 +183,7 @@ async def update_shipment(
entity_id=shipment_id,
action="UPDATE_DRUG_SHIPMENT",
detail=json.dumps(
{"targetName": _shipment_audit_name(shipment), "description": f"药品运输 {_shipment_audit_name(shipment)} 已更新"},
{"targetName": _shipment_audit_name(shipment), "description": _shipment_audit_description("更新", shipment)},
ensure_ascii=False,
),
operator_id=current_user.id,
@@ -214,7 +219,7 @@ async def delete_shipment(
entity_type="drug_shipment",
entity_id=shipment_id,
action="DELETE_DRUG_SHIPMENT",
detail=json.dumps({"targetName": shipment_name, "description": f"药品运输 {shipment_name} 已删除"}, ensure_ascii=False),
detail=json.dumps({"targetName": shipment_name, "description": f"删除药品流向“{shipment_name}"}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
+3 -3
View File
@@ -53,7 +53,7 @@ async def create_category(
entity_type="faq_category",
entity_id=category.id,
action="CREATE_FAQ_CATEGORY",
detail=json.dumps({"targetName": category.name, "description": f"FAQ 分类 {category.name} 已创建"}, ensure_ascii=False),
detail=json.dumps({"targetName": category.name, "description": f"创建“{category.name}”分类"}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, payload.study_id, current_user),
)
@@ -118,7 +118,7 @@ async def update_category(
entity_type="faq_category",
entity_id=category_id,
action="UPDATE_FAQ_CATEGORY",
detail=json.dumps({"targetName": updated.name, "description": f"FAQ 分类 {updated.name} 已更新"}, ensure_ascii=False),
detail=json.dumps({"targetName": updated.name, "description": f"更新“{updated.name}”分类"}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, updated.study_id, current_user),
)
@@ -157,7 +157,7 @@ async def delete_category(
entity_type="faq_category",
entity_id=category_id,
action="DELETE_FAQ_CATEGORY",
detail=json.dumps({"targetName": category_name, "description": f"FAQ 分类 {category_name} 已删除"}, ensure_ascii=False),
detail=json.dumps({"targetName": category_name, "description": f"删除“{category_name}”分类"}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, category.study_id, current_user),
)
+18 -5
View File
@@ -1,3 +1,4 @@
import json
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
@@ -30,6 +31,13 @@ def _is_system_admin(current_user) -> bool:
return is_system_admin(current_user)
def _compact_text(value: str | None, max_length: int = 40) -> str:
text = " ".join(str(value or "").split())
if len(text) <= max_length:
return text
return f"{text[:max_length]}..."
@router.post(
"/",
response_model=FaqRead,
@@ -66,13 +74,14 @@ async def create_faq(
reply_in=FaqReplyCreate(content=payload.answer),
)
await faq_crud.set_status(db, item.id, "PROCESSING")
question_name = _compact_text(item.question)
await audit_crud.log_action(
db,
study_id=payload.study_id,
entity_type="faq_item",
entity_id=item.id,
action="CREATE_FAQ_ITEM",
detail="FAQ 已创建",
detail=json.dumps({"targetName": question_name, "description": f"创建医学咨询问题“{question_name}"}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, payload.study_id, current_user),
)
@@ -170,7 +179,8 @@ async def update_faq(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
updated = await faq_crud.update_item(db, item, payload)
action = "UPDATE_FAQ_ITEM"
detail = "FAQ updated"
question_name = _compact_text(updated.question)
detail = json.dumps({"targetName": question_name, "description": f"更新医学咨询问题“{question_name}"}, ensure_ascii=False)
await audit_crud.log_action(
db,
study_id=item.study_id,
@@ -327,13 +337,14 @@ async def create_reply(
if item.status != "RESOLVED":
await faq_crud.set_status(db, item.id, "PROCESSING")
await faq_crud.touch_item(db, item.id)
question_name = _compact_text(item.question)
await audit_crud.log_action(
db,
study_id=item.study_id,
entity_type="faq_reply",
entity_id=reply.id,
action="CREATE_FAQ_REPLY",
detail="FAQ 已回复",
detail=json.dumps({"targetName": question_name, "description": f"回复医学咨询问题“{question_name}"}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, item.study_id, current_user),
)
@@ -369,6 +380,7 @@ async def delete_faq(
item = await faq_crud.get_item(db, item_id)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
question_name = _compact_text(item.question)
await reply_crud.delete_replies_by_faq_id(db, item.id)
await db.delete(item)
await db.commit()
@@ -378,7 +390,7 @@ async def delete_faq(
entity_type="faq_item",
entity_id=item_id,
action="DELETE_FAQ_ITEM",
detail="FAQ 已删除",
detail=json.dumps({"targetName": question_name, "description": f"删除医学咨询问题“{question_name}"}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, item.study_id, current_user),
)
@@ -403,6 +415,7 @@ async def delete_reply(
item = await faq_crud.get_item(db, item_id)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
question_name = _compact_text(item.question)
reply = await reply_crud.get_reply(db, reply_id)
if not reply or reply.faq_id != item.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="回复不存在")
@@ -431,7 +444,7 @@ async def delete_reply(
entity_type="faq_reply",
entity_id=reply_id,
action="DELETE_FAQ_REPLY",
detail="FAQ 回复已删除",
detail=json.dumps({"targetName": question_name, "description": f"删除医学咨询问题“{question_name}”的回复"}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, item.study_id, current_user),
)
+37 -6
View File
@@ -1,4 +1,5 @@
import uuid
import json
from decimal import Decimal
from typing import Any
@@ -52,6 +53,34 @@ async def _ensure_center_active(db: AsyncSession, study_id: uuid.UUID, center_id
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
async def _contract_audit_name(db: AsyncSession, contract) -> str:
site = await site_crud.get_site(db, contract.center_id)
center_name = site.name if site else "中心"
contract_no = str(contract.contract_no or "").strip()
return f"{center_name} / {contract_no}" if contract_no else center_name
async def _contract_audit_detail(db: AsyncSession, action: str, contract) -> str:
name = await _contract_audit_name(db, contract)
return json.dumps({"targetName": name, "description": f"{action}合同费用“{name}"}, ensure_ascii=False)
async def _payment_audit_name(db: AsyncSession, contract, payment) -> str:
contract_name = await _contract_audit_name(db, contract)
seq = getattr(payment, "seq", None)
amount = getattr(payment, "amount", None)
if seq:
return f"{contract_name} / 第{seq}"
if amount is not None:
return f"{contract_name} / {amount}"
return contract_name
async def _payment_audit_detail(db: AsyncSession, action: str, contract, payment) -> str:
name = await _payment_audit_name(db, contract, payment)
return json.dumps({"targetName": name, "description": f"{action}合同费用分期“{name}"}, ensure_ascii=False)
def _parse_optional_uuid(value: str | None, detail: str) -> uuid.UUID | None:
if not value:
return None
@@ -261,7 +290,7 @@ async def create_contract_fee(
entity_type="contract_fee",
entity_id=contract.id,
action="CREATE_CONTRACT_FEE",
detail="合同费用已创建",
detail=await _contract_audit_detail(db, "创建", contract),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
)
@@ -291,7 +320,7 @@ async def update_contract_fee(
entity_type="contract_fee",
entity_id=contract_id,
action="UPDATE_CONTRACT_FEE",
detail="合同费用已更新",
detail=await _contract_audit_detail(db, "更新", contract),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
)
@@ -313,6 +342,7 @@ async def delete_contract_fee(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:delete")
await _ensure_center_active(db, contract.study_id, contract.center_id)
contract_detail = await _contract_audit_detail(db, "删除", contract)
await contract_fee_crud.delete_contract_fee(db, contract)
await audit_crud.log_action(
db,
@@ -320,7 +350,7 @@ async def delete_contract_fee(
entity_type="contract_fee",
entity_id=contract_id,
action="DELETE_CONTRACT_FEE",
detail="合同费用已删除",
detail=contract_detail,
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
)
@@ -350,7 +380,7 @@ async def create_contract_payment(
entity_type="contract_fee_payment",
entity_id=payment.id,
action="CREATE_CONTRACT_FEE_PAYMENT",
detail="合同费用分期已创建",
detail=await _payment_audit_detail(db, "创建", contract, payment),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
)
@@ -391,7 +421,7 @@ async def update_contract_payment(
entity_type="contract_fee_payment",
entity_id=payment_id,
action="UPDATE_CONTRACT_FEE_PAYMENT",
detail="合同费用分期已更新",
detail=await _payment_audit_detail(db, "更新", contract, payment),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
)
@@ -415,6 +445,7 @@ async def delete_contract_payment(
if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:update")
payment_detail = await _payment_audit_detail(db, "删除", contract, payment)
await payment_crud.delete_payment(db, payment)
await payment_crud.resequence_payments(db, contract.id)
await audit_crud.log_action(
@@ -423,7 +454,7 @@ async def delete_contract_payment(
entity_type="contract_fee_payment",
entity_id=payment_id,
action="DELETE_CONTRACT_FEE_PAYMENT",
detail="合同费用分期已删除",
detail=payment_detail,
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
)
+17 -3
View File
@@ -1,4 +1,5 @@
import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
@@ -24,6 +25,18 @@ def _validate_calibration(need_calibration: bool, calibration_cycle_days: int |
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="需要校准时校准周期必须大于0")
def _equipment_audit_name(item) -> str:
parts = [item.name, item.spec_model, item.brand]
return " / ".join(str(part).strip() for part in parts if str(part or "").strip()) or "设备记录"
def _equipment_audit_detail(action: str, item) -> str:
return json.dumps(
{"targetName": _equipment_audit_name(item), "description": f"{action}设备“{_equipment_audit_name(item)}"},
ensure_ascii=False,
)
@router.post(
"/equipment",
response_model=MaterialEquipmentRead,
@@ -47,7 +60,7 @@ async def create_equipment(
entity_type="material_equipment",
entity_id=item.id,
action="CREATE_MATERIAL_EQUIPMENT",
detail=f"设备 {item.id} 已创建",
detail=_equipment_audit_detail("创建", item),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@@ -118,7 +131,7 @@ async def update_equipment(
entity_type="material_equipment",
entity_id=equipment_id,
action="UPDATE_MATERIAL_EQUIPMENT",
detail=f"设备 {equipment_id} 已更新",
detail=_equipment_audit_detail("更新", item),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@@ -140,6 +153,7 @@ async def delete_equipment(
item = await equipment_crud.get_equipment(db, equipment_id)
if not item or item.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="设备记录不存在")
equipment_detail = _equipment_audit_detail("删除", item)
await equipment_crud.delete_equipment(db, item)
await audit_crud.log_action(
db,
@@ -147,7 +161,7 @@ async def delete_equipment(
entity_type="material_equipment",
entity_id=equipment_id,
action="DELETE_MATERIAL_EQUIPMENT",
detail=f"设备 {equipment_id} 已删除",
detail=equipment_detail,
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
+29 -4
View File
@@ -43,6 +43,20 @@ def _role_rank(role: str | None) -> int:
return PROJECT_ROLE_RANK.get(role or "", 0)
def _user_audit_name(user, user_id: uuid.UUID) -> str:
if user:
for attr in ("full_name", "username", "email"):
value = getattr(user, attr, None)
if value:
return str(value)
return str(user_id)
async def _member_audit_name(db: AsyncSession, user_id: uuid.UUID) -> str:
user = await user_crud.get_by_id(db, user_id)
return _user_audit_name(user, user_id)
async def _ensure_member_mutation_allowed(
db: AsyncSession,
study_id: uuid.UUID,
@@ -116,7 +130,7 @@ async def add_member(
entity_id=updated.id,
action="PROJECT_MEMBER_UPDATED",
detail=json.dumps({
"targetName": str(updated.user_id),
"targetName": await _member_audit_name(db, updated.user_id),
"before": {"is_active": existing.is_active, "role_in_study": existing.role_in_study},
"after": {"is_active": updated.is_active, "role_in_study": updated.role_in_study},
}, ensure_ascii=False),
@@ -132,7 +146,10 @@ async def add_member(
entity_type="study_member",
entity_id=member.id,
action="PROJECT_MEMBER_ADDED",
detail=json.dumps({"targetName": str(member.user_id), "after": {"role_in_study": member.role_in_study, "is_active": member.is_active}}, ensure_ascii=False),
detail=json.dumps({
"targetName": await _member_audit_name(db, member.user_id),
"after": {"role_in_study": member.role_in_study, "is_active": member.is_active},
}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=await _operator_project_role(db, study_id, current_user),
)
@@ -247,7 +264,11 @@ async def update_member(
entity_type="study_member",
entity_id=updated.id,
action="PROJECT_MEMBER_UPDATED",
detail=json.dumps({"targetName": str(updated.user_id), "before": before_data, "after": after_data}, ensure_ascii=False),
detail=json.dumps({
"targetName": await _member_audit_name(db, updated.user_id),
"before": before_data,
"after": after_data,
}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=await _operator_project_role(db, study_id, current_user),
)
@@ -288,7 +309,11 @@ async def remove_member(
entity_type="study_member",
entity_id=removed.id,
action="PROJECT_MEMBER_REMOVED",
detail=json.dumps({"targetName": str(removed.user_id), "before": before_data, "after": {"is_active": removed.is_active}}, ensure_ascii=False),
detail=json.dumps({
"targetName": _user_audit_name(target_user, removed.user_id),
"before": before_data,
"after": {"is_active": removed.is_active},
}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=await _operator_project_role(db, study_id, current_user),
)
+10 -3
View File
@@ -1,4 +1,5 @@
import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
@@ -28,6 +29,11 @@ async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_n
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
def _precaution_audit_detail(action: str, precaution) -> str:
title = str(precaution.title or "").strip() or "注意事项"
return json.dumps({"targetName": title, "description": f"{action}注意事项“{title}"}, ensure_ascii=False)
@router.post(
"/precautions",
response_model=PrecautionRead,
@@ -49,7 +55,7 @@ async def create_precaution(
entity_type="precaution",
entity_id=precaution.id,
action="CREATE_PRECAUTION",
detail=f"注意事项 {precaution.title} 已创建",
detail=_precaution_audit_detail("创建", precaution),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@@ -115,7 +121,7 @@ async def update_precaution(
entity_type="precaution",
entity_id=precaution_id,
action="UPDATE_PRECAUTION",
detail=f"注意事项 {precaution_id} 已更新",
detail=_precaution_audit_detail("更新", precaution),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@@ -138,6 +144,7 @@ async def delete_precaution(
if not precaution or precaution.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
await _ensure_site_name_active(db, study_id, precaution.site_name)
precaution_detail = _precaution_audit_detail("删除", precaution)
await precaution_crud.delete_precaution(db, precaution)
await audit_crud.log_action(
db,
@@ -145,7 +152,7 @@ async def delete_precaution(
entity_type="precaution",
entity_id=precaution_id,
action="DELETE_PRECAUTION",
detail=f"注意事项 {precaution_id} 已删除",
detail=precaution_detail,
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
+50 -11
View File
@@ -1,4 +1,5 @@
import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
@@ -49,6 +50,41 @@ async def _ensure_site_active(db: AsyncSession, site_id: uuid.UUID | None):
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,
@@ -73,7 +109,7 @@ async def create_feasibility(
entity_type="startup_feasibility",
entity_id=record.id,
action="CREATE_STARTUP_FEASIBILITY",
detail="立项记录已创建",
detail=await _feasibility_audit_detail(db, "创建", record),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@@ -147,7 +183,7 @@ async def update_feasibility(
entity_type="startup_feasibility",
entity_id=record_id,
action="UPDATE_STARTUP_FEASIBILITY",
detail="立项记录已更新",
detail=await _feasibility_audit_detail(db, "更新", record),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@@ -173,6 +209,7 @@ async def delete_feasibility(
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,
@@ -180,7 +217,7 @@ async def delete_feasibility(
entity_type="startup_feasibility",
entity_id=record_id,
action="DELETE_STARTUP_FEASIBILITY",
detail="立项记录已删除",
detail=record_detail,
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@@ -210,7 +247,7 @@ async def create_ethics(
entity_type="startup_ethics",
entity_id=record.id,
action="CREATE_STARTUP_ETHICS",
detail="伦理记录已创建",
detail=await _ethics_audit_detail(db, "创建", record),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@@ -284,7 +321,7 @@ async def update_ethics(
entity_type="startup_ethics",
entity_id=record_id,
action="UPDATE_STARTUP_ETHICS",
detail="伦理记录已更新",
detail=await _ethics_audit_detail(db, "更新", record),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@@ -310,6 +347,7 @@ async def delete_ethics(
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,
@@ -317,7 +355,7 @@ async def delete_ethics(
entity_type="startup_ethics",
entity_id=record_id,
action="DELETE_STARTUP_ETHICS",
detail="伦理记录已删除",
detail=record_detail,
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@@ -347,7 +385,7 @@ async def create_kickoff(
entity_type="startup_kickoff",
entity_id=meeting.id,
action="CREATE_KICKOFF_MEETING",
detail="启动会记录已创建",
detail=await _kickoff_audit_detail(db, "创建", meeting),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@@ -421,7 +459,7 @@ async def update_kickoff(
entity_type="startup_kickoff",
entity_id=meeting_id,
action="UPDATE_KICKOFF_MEETING",
detail="启动会记录已更新",
detail=await _kickoff_audit_detail(db, "更新", meeting),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@@ -452,7 +490,7 @@ async def create_training_authorization(
entity_type="training_authorization",
entity_id=record.id,
action="CREATE_TRAINING_AUTH",
detail="培训授权人员已创建",
detail=_training_audit_detail("创建", record),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@@ -527,7 +565,7 @@ async def update_training_authorization(
entity_type="training_authorization",
entity_id=record_id,
action="UPDATE_TRAINING_AUTH",
detail="培训授权人员已更新",
detail=_training_audit_detail("更新", record),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@@ -553,6 +591,7 @@ async def delete_training_authorization(
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,
@@ -560,7 +599,7 @@ async def delete_training_authorization(
entity_type="training_authorization",
entity_id=record_id,
action="DELETE_TRAINING_AUTH",
detail="培训授权人员已删除",
detail=record_detail,
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
+5 -6
View File
@@ -610,7 +610,6 @@ _SETUP_MODULE_LABELS = {
}
_SETUP_FIELD_LABELS = {
"id": "ID",
"name": "名称",
"milestone": "里程碑",
"planDate": "计划日期",
@@ -634,6 +633,8 @@ _SETUP_FIELD_LABELS = {
"confirmDate": "确认日期",
}
_SETUP_TECHNICAL_FIELDS = {"id"}
def _setup_value_text(value: Any) -> str:
if value is None:
@@ -690,6 +691,8 @@ def _setup_collect_diff_lines(
new_dict = new_value if isinstance(new_value, dict) else {}
keys = sorted(set(old_dict.keys()) | set(new_dict.keys()))
for key in keys:
if key in _SETUP_TECHNICAL_FIELDS:
continue
_setup_collect_diff_lines(module_key, old_dict.get(key), new_dict.get(key), [*path, key], lines)
return
@@ -718,11 +721,7 @@ def _top_level_diff_summary(old: dict | None, new: dict | None) -> str:
if not detail_lines:
return module_summary
max_lines = 20
visible_lines = detail_lines[:max_lines]
if len(detail_lines) > max_lines:
visible_lines.append(f"其余 {len(detail_lines) - max_lines} 项变更已省略")
return f"{module_summary}; 变更明细:" + "".join(visible_lines)
return f"{module_summary}; 变更明细:" + "".join(detail_lines)
@router.post("/", response_model=StudyRead, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_roles(["ADMIN"]))])
+25 -3
View File
@@ -1,4 +1,5 @@
import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
@@ -14,6 +15,26 @@ from app.schemas.subject_history import SubjectHistoryCreate, SubjectHistoryRead
router = APIRouter()
def _compact_text(value: str | None, max_length: int = 30) -> str:
text = " ".join(str(value or "").split())
if len(text) <= max_length:
return text
return f"{text[:max_length]}..."
def _history_audit_name(subject, history) -> str:
subject_no = getattr(subject, "subject_no", None) or "参与者"
record_date = getattr(history, "record_date", None)
content = _compact_text(getattr(history, "content", None))
suffix = record_date.isoformat() if record_date else content
return f"{subject_no} / {suffix}" if suffix else subject_no
def _history_audit_detail(action: str, subject, history) -> str:
name = _history_audit_name(subject, history)
return json.dumps({"targetName": name, "description": f"{action}病史记录“{name}"}, ensure_ascii=False)
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id)
if not study:
@@ -56,7 +77,7 @@ async def create_history(
entity_type="subject_history",
entity_id=history.id,
action="CREATE_SUBJECT_HISTORY",
detail="病史记录已创建",
detail=_history_audit_detail("创建", subject, history),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@@ -131,7 +152,7 @@ async def update_history(
entity_type="subject_history",
entity_id=history_id,
action="UPDATE_SUBJECT_HISTORY",
detail="病史记录已更新",
detail=_history_audit_detail("更新", subject, history),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@@ -157,6 +178,7 @@ async def delete_history(
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="病史记录不存在")
history_detail = _history_audit_detail("删除", subject, history)
await history_crud.delete_history(db, history)
await audit_crud.log_action(
db,
@@ -164,7 +186,7 @@ async def delete_history(
entity_type="subject_history",
entity_id=history_id,
action="DELETE_SUBJECT_HISTORY",
detail="病史记录已删除",
detail=history_detail,
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
+58 -7
View File
@@ -1,3 +1,4 @@
import json
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
@@ -15,6 +16,29 @@ from app.schemas.subject import SubjectCreate, SubjectRead, SubjectUpdate
router = APIRouter()
def _format_date(value):
return value.isoformat() if value else None
async def _subject_audit_snapshot(db: AsyncSession, subject) -> dict:
site_name = None
if subject.site_id:
site = await site_crud.get_site(db, subject.site_id)
site_name = site.name if site else None
return {
"subject_no": subject.subject_no,
"site_name": site_name,
"status": subject.status,
"screening_date": _format_date(subject.screening_date),
"consent_date": _format_date(subject.consent_date),
"enrollment_date": _format_date(subject.enrollment_date),
"baseline_date": _format_date(subject.baseline_date),
"completion_date": _format_date(subject.completion_date),
"actual_medication_count": subject.actual_medication_count,
"drop_reason": subject.drop_reason,
}
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id)
if not study:
@@ -50,13 +74,22 @@ async def create_subject(
subject = await subject_crud.create_subject(db, study_id, subject_in)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
after_snapshot = await _subject_audit_snapshot(db, subject)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="subject",
entity_id=subject.id,
action="CREATE_SUBJECT",
detail=f"参与者 {subject.subject_no} 已创建",
detail=json.dumps(
{
"targetName": subject.subject_no,
"description": f"创建参与者 {subject.subject_no}",
"before": None,
"after": after_snapshot,
},
ensure_ascii=False,
),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@@ -172,21 +205,29 @@ async def update_subject(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_subject_active(db, subject)
old_status = subject.status
before_snapshot = await _subject_audit_snapshot(db, subject)
updated = await subject_crud.update_subject(db, subject, subject_in)
# 基线/治疗日期是访视计划的唯一推算基准,不能用入组日期替代。
if updated.baseline_date:
await subject_crud.sync_visits_from_baseline(db, updated)
updated = await subject_crud.sync_subject_status(db, updated)
detail = None
if updated.status != old_status:
detail = f"参与者 {updated.subject_no} 状态 {old_status} -> {updated.status}"
after_snapshot = await _subject_audit_snapshot(db, updated)
description = f"变更参与者 {updated.subject_no} 状态" if updated.status != old_status else f"更新参与者 {updated.subject_no}"
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="subject",
entity_id=subject_id,
action="SUBJECT_STATUS_CHANGE" if detail else "UPDATE_SUBJECT",
detail=detail or "参与者已更新",
action="SUBJECT_STATUS_CHANGE" if updated.status != old_status else "UPDATE_SUBJECT",
detail=json.dumps(
{
"targetName": updated.subject_no,
"description": description,
"before": before_snapshot,
"after": after_snapshot,
},
ensure_ascii=False,
),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@@ -211,6 +252,8 @@ async def delete_subject(
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and subject.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
before_snapshot = await _subject_audit_snapshot(db, subject)
target_name = subject.subject_no
await subject_crud.delete_subject(db, subject)
await audit_crud.log_action(
db,
@@ -218,7 +261,15 @@ async def delete_subject(
entity_type="subject",
entity_id=subject_id,
action="DELETE_SUBJECT",
detail=f"参与者 {subject_id} 已删除",
detail=json.dumps(
{
"targetName": target_name,
"description": f"删除参与者 {target_name}",
"before": before_snapshot,
"after": None,
},
ensure_ascii=False,
),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
+30 -5
View File
@@ -1,4 +1,5 @@
import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
@@ -15,6 +16,17 @@ from app.schemas.visit import EarlyTerminationCreate, VisitCreate, VisitRead, Vi
router = APIRouter()
def _visit_audit_name(subject, visit) -> str:
subject_no = getattr(subject, "subject_no", None) or "参与者"
visit_code = getattr(visit, "visit_code", None) or "访视"
return f"{subject_no} / {visit_code}"
def _visit_audit_detail(action: str, subject, visit) -> str:
name = _visit_audit_name(subject, visit)
return json.dumps({"targetName": name, "description": f"{action}访视“{name}"}, ensure_ascii=False)
async def _ensure_subject(db: AsyncSession, study_id: uuid.UUID, subject_id: uuid.UUID):
subject = await subject_crud.get_subject(db, subject_id)
if not subject or subject.study_id != study_id:
@@ -105,7 +117,7 @@ async def create_visit(
entity_type="visit",
entity_id=visit.id,
action="CREATE_VISIT",
detail=f"访视 {visit.visit_code} 已创建",
detail=_visit_audit_detail("创建", subject, visit),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@@ -158,7 +170,10 @@ async def create_early_termination(
entity_type="visit",
entity_id=visit.id,
action="CREATE_EARLY_TERMINATION",
detail=f"参与者 {subject.subject_no} 已提前终止",
detail=json.dumps(
{"targetName": subject.subject_no, "description": f"创建参与者 {subject.subject_no} 提前终止访视"},
ensure_ascii=False,
),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@@ -191,18 +206,27 @@ async def update_visit(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="实际访视日期不能早于访视窗口开始日期")
if visit_in.actual_date and visit.window_end and visit_in.actual_date > visit.window_end:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="实际访视日期不能晚于访视窗口结束日期")
old_status = visit.status
updated = await visit_crud.update_visit(db, visit, visit_in)
await subject_crud.sync_subject_status(db, subject)
detail = None
if visit_in.status:
detail = f"访视 {visit.visit_code} {visit.status} -> {visit_in.status}"
detail = json.dumps(
{
"targetName": _visit_audit_name(subject, updated),
"description": f"变更访视“{_visit_audit_name(subject, updated)}”状态",
"before": {"status": old_status},
"after": {"status": visit_in.status},
},
ensure_ascii=False,
)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="visit",
entity_id=visit_id,
action="VISIT_STATUS_CHANGE" if detail else "UPDATE_VISIT",
detail=detail or "访视已更新",
detail=detail or _visit_audit_detail("更新", subject, updated),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@@ -226,6 +250,7 @@ async def delete_visit(
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="访视不存在")
visit_detail = _visit_audit_detail("删除", subject, visit)
await visit_crud.delete_visit(db, visit)
await subject_crud.sync_subject_status(db, subject)
await audit_crud.log_action(
@@ -234,7 +259,7 @@ async def delete_visit(
entity_type="visit",
entity_id=visit_id,
action="DELETE_VISIT",
detail=f"访视 {visit_id} 已删除",
detail=visit_detail,
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
+2 -5
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import uuid
from typing import Sequence
@@ -65,8 +67,3 @@ async def list_logs(
async def get_log(db: AsyncSession, log_id: uuid.UUID) -> AuditLog | None:
result = await db.execute(select(AuditLog).where(AuditLog.id == log_id))
return result.scalar_one_or_none()
async def delete_log(db: AsyncSession, log: AuditLog) -> None:
await db.delete(log)
await db.commit()
+3 -4
View File
@@ -121,11 +121,10 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None:
from app.models.study_monitoring_strategy import StudyMonitoringStrategy
from app.models.study_center_confirm import StudyCenterConfirm
# 按依赖关系顺序删除关联数据
# 1. 删除审计日志
await db.execute(sa_delete(AuditLog).where(AuditLog.study_id == study_id))
# 按依赖关系顺序删除关联数据。审计日志必须保留,只解除项目外键,避免硬删除项目时破坏审计链。
await db.execute(sa_update(AuditLog).where(AuditLog.study_id == study_id).values(study_id=None))
# 2. 删除FAQ相关(可能有依赖关系)
# 1. 删除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))
+4 -2
View File
@@ -56,23 +56,25 @@ def _audit_detail(before: dict | None, after: dict | None) -> str:
def _doc_snapshot(doc: Document) -> dict:
status = doc.status.value if hasattr(doc.status, "value") else str(doc.status)
return {
"id": str(doc.id),
"trial_id": str(doc.trial_id),
"site_id": str(doc.site_id) if doc.site_id else None,
"etmf_node_id": str(doc.etmf_node_id) if doc.etmf_node_id else None,
"doc_no": doc.doc_no,
"status": str(doc.status),
"status": status,
"current_effective_version_id": str(doc.current_effective_version_id) if doc.current_effective_version_id else None,
}
def _version_snapshot(version: DocumentVersion) -> dict:
status = version.status.value if hasattr(version.status, "value") else str(version.status)
return {
"id": str(version.id),
"document_id": str(version.document_id),
"version_no": version.version_no,
"status": str(version.status),
"status": status,
"file_hash": version.file_hash,
}
+23 -2
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import uuid
import json
from collections import defaultdict
from typing import Iterable
@@ -18,6 +19,17 @@ from app.schemas.document import DocumentCreate, DocumentSummary
from app.schemas.etmf import EtmfNodeCreate, EtmfNodeRead, EtmfNodeStatus, EtmfTreeNode, EtmfNodeUpdate
def _node_audit_snapshot(node: EtmfNode) -> dict[str, object | None]:
return {
"code": node.code,
"name": node.name,
"parent_id": str(node.parent_id) if node.parent_id else None,
"scope_type": node.scope_type,
"required": node.required,
"is_active": node.is_active,
}
def calculate_node_status(node: EtmfNode, documents: Iterable[Document]) -> EtmfNodeStatus:
docs = list(documents)
if not node.is_active:
@@ -102,7 +114,10 @@ async def create_node(db: AsyncSession, payload: EtmfNodeCreate, current_user) -
entity_type="ETMF_NODE",
entity_id=node.id,
action="ETMF_NODE_CREATED",
detail=f'{{"code":"{node.code}","name":"{node.name}"}}',
detail=json.dumps(
{"targetName": node.name, "after": _node_audit_snapshot(node)},
ensure_ascii=False,
),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, payload.study_id, current_user),
)
@@ -124,14 +139,20 @@ async def update_node(db: AsyncSession, node_id: uuid.UUID, payload: EtmfNodeUpd
parent = await etmf_crud.get_node(db, values["parent_id"])
if not parent or parent.study_id != node.study_id or parent.id == node.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="父级eTMF目录不合法")
before = _node_audit_snapshot(node)
await etmf_crud.update_node(db, node_id, values, commit=False)
updated_snapshot = {**before, **{key: value for key, value in values.items() if key in before}}
db.add(
AuditLog(
study_id=node.study_id,
entity_type="ETMF_NODE",
entity_id=node.id,
action="ETMF_NODE_UPDATED",
detail="{}",
detail=json.dumps(
{"targetName": updated_snapshot.get("name") or before.get("name"), "before": before, "after": updated_snapshot},
ensure_ascii=False,
default=str,
),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, node.study_id, current_user),
)