完善审计日志展示与导出权限
This commit is contained in:
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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"]))])
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import uuid
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
@@ -7,11 +8,11 @@ from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.v1.api_permissions import get_my_study_api_permissions, update_study_api_permissions
|
||||
from app.api.v1.members import update_member
|
||||
from app.api.v1.members import add_member, update_member
|
||||
from app.api.v1.permission_monitoring import resolve_monitoring_scope
|
||||
from app.api.v1.system_permissions import list_system_permissions
|
||||
from app.core.deps import require_admin_or_any_project_pm, require_system_permission
|
||||
from app.schemas.member import StudyMemberUpdate
|
||||
from app.schemas.member import StudyMemberCreate, StudyMemberUpdate
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -40,6 +41,26 @@ async def _seed_user(db: AsyncSession, user_id: uuid.UUID, *, is_admin: bool = F
|
||||
)
|
||||
|
||||
|
||||
async def _seed_named_user(db: AsyncSession, user_id: uuid.UUID, full_name: str, *, is_admin: bool = False) -> None:
|
||||
await db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status)
|
||||
VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": str(user_id),
|
||||
"email": f"{user_id.hex}@example.com",
|
||||
"password_hash": "hash",
|
||||
"full_name": full_name,
|
||||
"clinical_department": "Clinical",
|
||||
"is_admin": is_admin,
|
||||
"status": "ACTIVE",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _seed_study(db: AsyncSession, study_id: uuid.UUID, code: str) -> None:
|
||||
await db.execute(
|
||||
text(
|
||||
@@ -289,3 +310,77 @@ async def test_project_pm_cannot_grant_peer_pm_role(db_session: AsyncSession):
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_member_add_audit_uses_member_display_name(db_session: AsyncSession):
|
||||
study_id = uuid.uuid4()
|
||||
admin_id = uuid.uuid4()
|
||||
target_user_id = uuid.uuid4()
|
||||
await _seed_study(db_session, study_id, "MEMBER-AUDIT-NAME")
|
||||
await _seed_user(db_session, admin_id, is_admin=True)
|
||||
await _seed_named_user(db_session, target_user_id, "张三")
|
||||
await db_session.commit()
|
||||
|
||||
await add_member(
|
||||
study_id=study_id,
|
||||
member_in=StudyMemberCreate(user_id=target_user_id, role_in_study="CRA"),
|
||||
current_user=UserStub(id=admin_id, is_admin=True),
|
||||
db=db_session,
|
||||
)
|
||||
|
||||
row = (
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT detail
|
||||
FROM audit_logs
|
||||
WHERE study_id = :study_id AND action = 'PROJECT_MEMBER_ADDED'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"study_id": str(study_id)},
|
||||
)
|
||||
).one()
|
||||
detail = json.loads(row.detail)
|
||||
assert detail["targetName"] == "张三"
|
||||
assert str(target_user_id) not in detail["targetName"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_member_update_audit_uses_member_display_name(db_session: AsyncSession):
|
||||
study_id = uuid.uuid4()
|
||||
admin_id = uuid.uuid4()
|
||||
target_user_id = uuid.uuid4()
|
||||
await _seed_study(db_session, study_id, "MEMBER-UPDATE-AUDIT-NAME")
|
||||
await _seed_user(db_session, admin_id, is_admin=True)
|
||||
await _seed_named_user(db_session, target_user_id, "李四")
|
||||
member_id = await _seed_member_return_id(db_session, study_id, target_user_id, "CRA")
|
||||
await db_session.commit()
|
||||
|
||||
await update_member(
|
||||
study_id=study_id,
|
||||
member_id=member_id,
|
||||
member_in=StudyMemberUpdate(role_in_study="CTA"),
|
||||
current_user=UserStub(id=admin_id, is_admin=True),
|
||||
db=db_session,
|
||||
)
|
||||
|
||||
row = (
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT detail
|
||||
FROM audit_logs
|
||||
WHERE study_id = :study_id AND action = 'PROJECT_MEMBER_UPDATED'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"study_id": str(study_id)},
|
||||
)
|
||||
).one()
|
||||
detail = json.loads(row.detail)
|
||||
assert detail["targetName"] == "李四"
|
||||
assert str(target_user_id) not in detail["targetName"]
|
||||
|
||||
@@ -588,6 +588,155 @@ def test_audit_export_event_uses_export_permission():
|
||||
assert "require_study_member()" not in event_chunk
|
||||
|
||||
|
||||
def test_audit_export_event_does_not_trust_client_detail():
|
||||
"""导出审计事件的业务描述应由后端生成,不能信任客户端 detail。"""
|
||||
route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "audit_logs.py"
|
||||
source = route_path.read_text()
|
||||
event_chunk = source[source.index("async def create_audit_event") :]
|
||||
|
||||
assert "detail=payload.detail" not in event_chunk
|
||||
assert "entity_type=payload.entity_type" not in event_chunk
|
||||
assert "entity_id=payload.entity_id" not in event_chunk
|
||||
assert "_build_export_audit_detail" in event_chunk
|
||||
|
||||
|
||||
def test_audit_logs_do_not_expose_delete_endpoint():
|
||||
"""审计日志不能提供单条删除接口,避免破坏审计不可篡改性。"""
|
||||
route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "audit_logs.py"
|
||||
source = route_path.read_text()
|
||||
|
||||
assert "@router.delete" not in source
|
||||
assert "delete_audit_log" not in source
|
||||
|
||||
|
||||
def test_etmf_audit_details_are_structured_json():
|
||||
"""eTMF 审计详情不能手拼 JSON 或写空对象。"""
|
||||
service_path = Path(__file__).resolve().parents[1] / "app" / "services" / "etmf_service.py"
|
||||
source = service_path.read_text()
|
||||
|
||||
assert "json.dumps" in source
|
||||
assert "detail=f'{{" not in source
|
||||
assert 'detail="{}"' not in source
|
||||
|
||||
|
||||
def test_setup_config_diff_summary_keeps_all_business_changes_and_skips_ids():
|
||||
"""立项配置审计明细应完整保留业务字段,跳过行内技术 ID。"""
|
||||
route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "studies.py"
|
||||
source = route_path.read_text()
|
||||
summary_chunk = source[source.index("def _setup_collect_diff_lines") : source.index("@router.post")]
|
||||
|
||||
assert '_SETUP_TECHNICAL_FIELDS = {"id"}' in source
|
||||
assert "if key in _SETUP_TECHNICAL_FIELDS:" in summary_chunk
|
||||
assert "已省略" not in summary_chunk
|
||||
assert "max_lines" not in summary_chunk
|
||||
|
||||
|
||||
def test_subject_audit_uses_structured_business_snapshot():
|
||||
"""参与者审计应把编号放入对象,把业务变化放入详情。"""
|
||||
route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "subjects.py"
|
||||
source = route_path.read_text()
|
||||
create_subject_source = source[source.index("async def create_subject") : source.index("@router.get")]
|
||||
update_subject_source = source[source.index("async def update_subject") : source.index("@router.delete")]
|
||||
delete_subject_source = source[source.index("async def delete_subject") :]
|
||||
|
||||
assert "_subject_audit_snapshot" in source
|
||||
assert '"targetName": subject.subject_no' in create_subject_source
|
||||
assert '"description": f"创建参与者 {subject.subject_no}"' in create_subject_source
|
||||
assert '"targetName": updated.subject_no' in update_subject_source
|
||||
assert '"description": description' in update_subject_source
|
||||
assert '"targetName": target_name' in delete_subject_source
|
||||
assert '"description": f"删除参与者 {target_name}"' in delete_subject_source
|
||||
assert '"before": before_snapshot' in delete_subject_source
|
||||
assert 'detail=f"参与者 {subject.subject_no} 已创建"' not in source
|
||||
assert 'f"参与者 {updated.subject_no} 状态' not in source
|
||||
assert 'detail=f"参与者 {subject_id} 已删除"' not in delete_subject_source
|
||||
|
||||
|
||||
def test_faq_audit_uses_business_descriptions():
|
||||
"""医学咨询审计详情应使用业务描述,避免 FAQ 技术文案。"""
|
||||
api_dir = Path(__file__).resolve().parents[1] / "app" / "api" / "v1"
|
||||
category_source = (api_dir / "faq_categories.py").read_text()
|
||||
item_source = (api_dir / "faqs.py").read_text()
|
||||
|
||||
assert 'description": f"创建“{category.name}”分类"' in category_source
|
||||
assert 'description": f"更新“{updated.name}”分类"' in category_source
|
||||
assert 'description": f"删除“{category_name}”分类"' in category_source
|
||||
assert 'f"创建医学咨询问题“{question_name}”"' in item_source
|
||||
assert 'f"更新医学咨询问题“{question_name}”"' in item_source
|
||||
assert 'f"回复医学咨询问题“{question_name}”"' in item_source
|
||||
assert 'f"删除医学咨询问题“{question_name}”"' in item_source
|
||||
assert 'f"删除医学咨询问题“{question_name}”的回复"' in item_source
|
||||
assert "FAQ 分类 {category.name} 已创建" not in category_source
|
||||
assert 'detail="FAQ 已创建"' not in item_source
|
||||
assert 'detail = "FAQ updated"' not in item_source
|
||||
assert '"description": "创建医学咨询问题"' not in item_source
|
||||
|
||||
|
||||
def test_domain_audits_use_business_object_names():
|
||||
"""药品、设备、访视、AE、费用、立项等审计应记录具体业务对象。"""
|
||||
api_dir = Path(__file__).resolve().parents[1] / "app" / "api" / "v1"
|
||||
sources = {
|
||||
name: (api_dir / name).read_text()
|
||||
for name in [
|
||||
"drug_shipments.py",
|
||||
"material_equipments.py",
|
||||
"visits.py",
|
||||
"aes.py",
|
||||
"precautions.py",
|
||||
"fees_contracts.py",
|
||||
"startup.py",
|
||||
"subject_histories.py",
|
||||
]
|
||||
}
|
||||
|
||||
assert "_shipment_audit_description" in sources["drug_shipments.py"]
|
||||
assert "_equipment_audit_detail" in sources["material_equipments.py"]
|
||||
assert "_visit_audit_detail" in sources["visits.py"]
|
||||
assert "_ae_audit_detail" in sources["aes.py"]
|
||||
assert "_precaution_audit_detail" in sources["precautions.py"]
|
||||
assert "_contract_audit_detail" in sources["fees_contracts.py"]
|
||||
assert "_payment_audit_detail" in sources["fees_contracts.py"]
|
||||
assert "_feasibility_audit_detail" in sources["startup.py"]
|
||||
assert "_ethics_audit_detail" in sources["startup.py"]
|
||||
assert "_kickoff_audit_detail" in sources["startup.py"]
|
||||
assert "_training_audit_detail" in sources["startup.py"]
|
||||
assert "_history_audit_detail" in sources["subject_histories.py"]
|
||||
|
||||
forbidden_fragments = [
|
||||
'detail=f"访视 {visit_id} 已删除"',
|
||||
'detail=f"AE {ae.id} 已创建"',
|
||||
'detail=f"AE {ae_id} 已删除"',
|
||||
'detail=f"设备 {item.id} 已创建"',
|
||||
'detail=f"设备 {equipment_id} 已更新"',
|
||||
'detail=f"设备 {equipment_id} 已删除"',
|
||||
'detail=f"注意事项 {precaution_id} 已更新"',
|
||||
'detail=f"注意事项 {precaution_id} 已删除"',
|
||||
'detail="合同费用已创建"',
|
||||
'detail="合同费用已更新"',
|
||||
'detail="合同费用已删除"',
|
||||
'detail="合同费用分期已创建"',
|
||||
'detail="合同费用分期已更新"',
|
||||
'detail="合同费用分期已删除"',
|
||||
'detail="立项记录已创建"',
|
||||
'detail="立项记录已更新"',
|
||||
'detail="立项记录已删除"',
|
||||
'detail="伦理记录已创建"',
|
||||
'detail="伦理记录已更新"',
|
||||
'detail="伦理记录已删除"',
|
||||
'detail="启动会记录已创建"',
|
||||
'detail="启动会记录已更新"',
|
||||
'detail="培训授权人员已创建"',
|
||||
'detail="培训授权人员已更新"',
|
||||
'detail="培训授权人员已删除"',
|
||||
'detail="病史记录已创建"',
|
||||
'detail="病史记录已更新"',
|
||||
'detail="病史记录已删除"',
|
||||
]
|
||||
combined_source = "\n".join(sources.values())
|
||||
for fragment in forbidden_fragments:
|
||||
assert fragment not in combined_source
|
||||
|
||||
|
||||
def test_user_role_contains_current_project_roles():
|
||||
assert {"QA", "CTA"} <= set(PROJECT_PERMISSION_ROLES)
|
||||
|
||||
|
||||
@@ -8,12 +8,14 @@ from app.crud import study as study_crud
|
||||
class _FakeDb:
|
||||
def __init__(self):
|
||||
self.tables = []
|
||||
self.operations = []
|
||||
self.committed = False
|
||||
|
||||
async def execute(self, stmt):
|
||||
table = getattr(getattr(stmt, "table", None), "name", None)
|
||||
if table:
|
||||
self.tables.append(table)
|
||||
self.operations.append((getattr(stmt, "__visit_name__", ""), table))
|
||||
|
||||
async def commit(self):
|
||||
self.committed = True
|
||||
@@ -29,3 +31,13 @@ async def test_delete_study_removes_contract_fee_payments_before_contract_fees()
|
||||
assert "contract_fees" in db.tables
|
||||
assert db.tables.index("contract_fee_payments") < db.tables.index("contract_fees")
|
||||
assert db.committed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_study_preserves_audit_logs():
|
||||
db = _FakeDb()
|
||||
|
||||
await study_crud.delete(db, uuid.uuid4())
|
||||
|
||||
assert ("delete", "audit_logs") not in db.operations
|
||||
assert ("update", "audit_logs") in db.operations
|
||||
|
||||
Reference in New Issue
Block a user