fix(审计): 移除共享库审计与预览噪声

This commit is contained in:
Cheng Zhou
2026-07-16 15:29:26 +08:00
parent 6b55c18610
commit 88bd0c5942
13 changed files with 134 additions and 377 deletions
@@ -0,0 +1,35 @@
"""Remove shared-library and passive Office preview audit noise.
Revision ID: 20260716_04
Revises: 20260716_03
"""
from alembic import op
revision = "20260716_04"
down_revision = "20260716_03"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
DELETE FROM audit_logs
WHERE action = 'OFFICE_PREVIEW_OPEN'
OR lower(entity_type) IN (
'faq_category',
'faq_item',
'faq_reply',
'faq_replies',
'precaution',
'knowledge_note',
'knowledge_notes',
'collaboration_file'
)
""")
def downgrade() -> None:
# 被清理的是明确排除出审计范围的共享库和被动预览记录,无法也不应伪造恢复。
pass
+34 -30
View File
@@ -77,6 +77,7 @@ FAQ_REPLY_ATTACHMENT_PERMISSION_BY_ACTION = {
"read": "faq:read", "read": "faq:read",
"delete": "faq_attachments:delete", "delete": "faq_attachments:delete",
} }
SHARED_LIBRARY_ENTITY_TYPES = {"precaution", "faq_replies"}
STARTUP_AUTH_ATTACHMENT_ENTITY_TYPES = { STARTUP_AUTH_ATTACHMENT_ENTITY_TYPES = {
"startup_kickoff", "startup_kickoff",
"startup_kickoff_minutes", "startup_kickoff_minutes",
@@ -239,16 +240,17 @@ async def upload_attachment(
content_type=file.content_type, content_type=file.content_type,
uploaded_by=current_user.id, uploaded_by=current_user.id,
) )
await audit_crud.log_action( if entity_type not in SHARED_LIBRARY_ENTITY_TYPES:
db, await audit_crud.log_action(
study_id=study_id, db,
entity_type=entity_type, study_id=study_id,
entity_id=entity_id, entity_type=entity_type,
action="UPLOAD_FILE", entity_id=entity_id,
detail=f"文件已上传:{file.filename}", action="UPLOAD_FILE",
operator_id=current_user.id, detail=f"文件已上传:{file.filename}",
operator_role=await get_operator_role_label(db, study_id, current_user), operator_id=current_user.id,
) operator_role=await get_operator_role_label(db, study_id, current_user),
)
return AttachmentRead( return AttachmentRead(
id=attachment.id, id=attachment.id,
filename=attachment.filename, filename=attachment.filename,
@@ -442,16 +444,17 @@ async def global_delete_attachment(
if not can_delete: if not can_delete:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
await attachment_crud.soft_delete_attachment(db, attachment) await attachment_crud.soft_delete_attachment(db, attachment)
await audit_crud.log_action( if attachment.entity_type not in SHARED_LIBRARY_ENTITY_TYPES:
db, await audit_crud.log_action(
study_id=attachment.study_id, db,
entity_type=attachment.entity_type, study_id=attachment.study_id,
entity_id=attachment.entity_id, entity_type=attachment.entity_type,
action="DELETE_ATTACHMENT", entity_id=attachment.entity_id,
detail=f"文件已删除:{attachment.filename}", action="DELETE_ATTACHMENT",
operator_id=user.id, detail=f"文件已删除:{attachment.filename}",
operator_role=await get_operator_role_label(db, attachment.study_id, user), operator_id=user.id,
) operator_role=await get_operator_role_label(db, attachment.study_id, user),
)
@router.delete( @router.delete(
@@ -494,13 +497,14 @@ async def delete_attachment(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
await attachment_crud.soft_delete_attachment(db, attachment) await attachment_crud.soft_delete_attachment(db, attachment)
await audit_crud.log_action( if entity_type not in SHARED_LIBRARY_ENTITY_TYPES:
db, await audit_crud.log_action(
study_id=study_id, db,
entity_type=entity_type, study_id=study_id,
entity_id=entity_id, entity_type=entity_type,
action="DELETE_ATTACHMENT", entity_id=entity_id,
detail=f"文件已删除:{attachment.filename}", action="DELETE_ATTACHMENT",
operator_id=current_user.id, detail=f"文件已删除:{attachment.filename}",
operator_role=await get_operator_role_label(db, study_id, current_user), operator_id=current_user.id,
) operator_role=await get_operator_role_label(db, study_id, current_user),
)
+1 -36
View File
@@ -1,15 +1,11 @@
import uuid import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, get_operator_role_label, is_system_admin, require_study_not_locked, require_api_permission from app.core.deps import get_current_user, get_db_session, is_system_admin, require_study_not_locked, require_api_permission
from app.core.project_permissions import role_has_api_permission
from app.crud import audit as audit_crud
from app.crud import faq_category as category_crud from app.crud import faq_category as category_crud
from app.crud import faq_item as item_crud from app.crud import faq_item as item_crud
from app.crud import member as member_crud
from app.schemas.common import PaginatedResponse from app.schemas.common import PaginatedResponse
from app.schemas.faq import CategoryCreate, CategoryRead, CategoryUpdate from app.schemas.faq import CategoryCreate, CategoryRead, CategoryUpdate
from app.utils.pagination import paginate from app.utils.pagination import paginate
@@ -47,16 +43,6 @@ async def create_category(
if dup: if dup:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该图标已被其他分类使用") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该图标已被其他分类使用")
category = await category_crud.create_category(db, payload) category = await category_crud.create_category(db, payload)
await audit_crud.log_action(
db,
study_id=payload.study_id,
entity_type="faq_category",
entity_id=category.id,
action="CREATE_FAQ_CATEGORY",
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),
)
return CategoryRead.model_validate(category) return CategoryRead.model_validate(category)
@@ -112,16 +98,6 @@ async def update_category(
if not target_study_id: if not target_study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
updated = await category_crud.update_category(db, category, payload) updated = await category_crud.update_category(db, category, payload)
await audit_crud.log_action(
db,
study_id=updated.study_id,
entity_type="faq_category",
entity_id=category_id,
action="UPDATE_FAQ_CATEGORY",
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),
)
return CategoryRead.model_validate(updated) return CategoryRead.model_validate(updated)
@@ -148,16 +124,5 @@ async def delete_category(
item_count = await item_crud.count_items_by_category(db, category_id) item_count = await item_crud.count_items_by_category(db, category_id)
if item_count > 0: if item_count > 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类下存在 FAQ,无法删除") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类下存在 FAQ,无法删除")
category_name = category.name
await db.delete(category) await db.delete(category)
await db.commit() await db.commit()
await audit_crud.log_action(
db,
study_id=category.study_id,
entity_type="faq_category",
entity_id=category_id,
action="DELETE_FAQ_CATEGORY",
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 -68
View File
@@ -1,12 +1,9 @@
import json
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, get_operator_role_label, is_system_admin, require_study_not_locked, require_api_permission from app.core.deps import get_current_user, get_db_session, is_system_admin, require_study_not_locked, require_api_permission
from app.core.project_permissions import role_has_api_permission
from app.crud import audit as audit_crud
from app.crud import faq_category as category_crud from app.crud import faq_category as category_crud
from app.crud import faq_item as faq_crud from app.crud import faq_item as faq_crud
from app.crud import faq_reply as reply_crud from app.crud import faq_reply as reply_crud
@@ -31,13 +28,6 @@ def _is_system_admin(current_user) -> bool:
return is_system_admin(current_user) 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( @router.post(
"/", "/",
response_model=FaqRead, response_model=FaqRead,
@@ -74,17 +64,6 @@ async def create_faq(
reply_in=FaqReplyCreate(content=payload.answer), reply_in=FaqReplyCreate(content=payload.answer),
) )
await faq_crud.set_status(db, item.id, "PROCESSING") 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=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),
)
return FaqRead.model_validate(item) return FaqRead.model_validate(item)
@@ -178,19 +157,6 @@ async def update_faq(
if not item: if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
updated = await faq_crud.update_item(db, item, payload) updated = await faq_crud.update_item(db, item, payload)
action = "UPDATE_FAQ_ITEM"
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,
entity_type="faq_item",
entity_id=item_id,
action=action,
detail=detail,
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, item.study_id, current_user),
)
return FaqRead.model_validate(updated) return FaqRead.model_validate(updated)
@@ -337,17 +303,6 @@ async def create_reply(
if item.status != "RESOLVED": if item.status != "RESOLVED":
await faq_crud.set_status(db, item.id, "PROCESSING") await faq_crud.set_status(db, item.id, "PROCESSING")
await faq_crud.touch_item(db, item.id) 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=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),
)
data = FaqReplyRead.model_validate(reply) data = FaqReplyRead.model_validate(reply)
if quote: if quote:
if quote.is_deleted: if quote.is_deleted:
@@ -380,20 +335,9 @@ async def delete_faq(
item = await faq_crud.get_item(db, item_id) item = await faq_crud.get_item(db, item_id)
if not item: if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") 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 reply_crud.delete_replies_by_faq_id(db, item.id)
await db.delete(item) await db.delete(item)
await db.commit() await db.commit()
await audit_crud.log_action(
db,
study_id=item.study_id,
entity_type="faq_item",
entity_id=item_id,
action="DELETE_FAQ_ITEM",
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),
)
@router.delete( @router.delete(
@@ -415,7 +359,6 @@ async def delete_reply(
item = await faq_crud.get_item(db, item_id) item = await faq_crud.get_item(db, item_id)
if not item: if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") 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) reply = await reply_crud.get_reply(db, reply_id)
if not reply or reply.faq_id != item.id: if not reply or reply.faq_id != item.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="回复不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="回复不存在")
@@ -438,13 +381,3 @@ async def delete_reply(
resolved_by_confirm=False, resolved_by_confirm=False,
) )
await faq_crud.touch_item(db, item.id) await faq_crud.touch_item(db, item.id)
await audit_crud.log_action(
db,
study_id=item.study_id,
entity_type="faq_reply",
entity_id=reply_id,
action="DELETE_FAQ_REPLY",
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 -40
View File
@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import json
import os import os
import uuid import uuid
from pathlib import Path from pathlib import Path
@@ -11,9 +10,8 @@ from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.attachments import _ensure_attachment_permission, _ensure_study_exists from app.api.v1.attachments import _ensure_attachment_permission, _ensure_study_exists
from app.core.deps import get_current_user, get_db_session, get_operator_role_label from app.core.deps import get_current_user, get_db_session
from app.crud import attachment as attachment_crud from app.crud import attachment as attachment_crud
from app.crud import audit as audit_crud
from app.crud import document as document_crud from app.crud import document as document_crud
from app.crud import document_version as version_crud from app.crud import document_version as version_crud
from app.models.collaboration import CollaborationRevision from app.models.collaboration import CollaborationRevision
@@ -33,29 +31,6 @@ def _content_disposition(filename: str) -> str:
return f'inline; filename="{fallback}"; filename*=UTF-8\'\'{encoded}' return f'inline; filename="{fallback}"; filename*=UTF-8\'\'{encoded}'
async def _log_preview_open(
db: AsyncSession,
*,
study_id: uuid.UUID,
resource_type: str,
resource_id: uuid.UUID,
current_user,
) -> None:
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="ATTACHMENT" if resource_type == "attachment" else "DOCUMENT_VERSION",
entity_id=resource_id,
action="OFFICE_PREVIEW_OPEN",
detail=json.dumps(
{"resource_type": resource_type, "resource_id": str(resource_id), "result": "issued"},
ensure_ascii=True,
),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@router.get( @router.get(
"/attachments/{attachment_id}/config", "/attachments/{attachment_id}/config",
response_model=OnlyOfficePreviewConfigRead, response_model=OnlyOfficePreviewConfigRead,
@@ -98,13 +73,6 @@ async def get_attachment_preview_config(
user_id=current_user.id, user_id=current_user.id,
user_name=current_user.full_name, user_name=current_user.full_name,
) )
await _log_preview_open(
db,
study_id=attachment.study_id,
resource_type="attachment",
resource_id=attachment.id,
current_user=current_user,
)
response.headers["Cache-Control"] = "no-store" response.headers["Cache-Control"] = "no-store"
return result return result
@@ -151,13 +119,6 @@ async def get_version_preview_config(
user_id=current_user.id, user_id=current_user.id,
user_name=current_user.full_name, user_name=current_user.full_name,
) )
await _log_preview_open(
db,
study_id=document.trial_id,
resource_type="version",
resource_id=version.id,
current_user=current_user,
)
response.headers["Cache-Control"] = "no-store" response.headers["Cache-Control"] = "no-store"
return result return result
+1 -39
View File
@@ -1,11 +1,9 @@
import uuid import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_study_not_locked, require_api_permission from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_api_permission
from app.crud import audit as audit_crud
from app.crud import precaution as precaution_crud from app.crud import precaution as precaution_crud
from app.crud import site as site_crud from app.crud import site as site_crud
from app.crud import study as study_crud from app.crud import study as study_crud
@@ -29,11 +27,6 @@ async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_n
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用") 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( @router.post(
"/precautions", "/precautions",
response_model=PrecautionRead, response_model=PrecautionRead,
@@ -49,16 +42,6 @@ async def create_precaution(
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
await _ensure_site_name_active(db, study_id, precaution_in.site_name) await _ensure_site_name_active(db, study_id, precaution_in.site_name)
precaution = await precaution_crud.create_precaution(db, study_id, precaution_in, created_by=current_user.id) precaution = await precaution_crud.create_precaution(db, study_id, precaution_in, created_by=current_user.id)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="precaution",
entity_id=precaution.id,
action="CREATE_PRECAUTION",
detail=_precaution_audit_detail("创建", precaution),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return PrecautionRead.model_validate(precaution) return PrecautionRead.model_validate(precaution)
@@ -115,16 +98,6 @@ async def update_precaution(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
await _ensure_site_name_active(db, study_id, precaution.site_name) await _ensure_site_name_active(db, study_id, precaution.site_name)
precaution = await precaution_crud.update_precaution(db, precaution, precaution_in) precaution = await precaution_crud.update_precaution(db, precaution, precaution_in)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="precaution",
entity_id=precaution_id,
action="UPDATE_PRECAUTION",
detail=_precaution_audit_detail("更新", precaution),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return PrecautionRead.model_validate(precaution) return PrecautionRead.model_validate(precaution)
@@ -144,15 +117,4 @@ async def delete_precaution(
if not precaution or precaution.study_id != study_id: if not precaution or precaution.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
await _ensure_site_name_active(db, study_id, precaution.site_name) 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 precaution_crud.delete_precaution(db, precaution)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="precaution",
entity_id=precaution_id,
action="DELETE_PRECAUTION",
detail=precaution_detail,
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
+20 -96
View File
@@ -16,9 +16,8 @@ from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings from app.core.config import settings
from app.core.deps import get_operator_role_label, is_system_admin from app.core.deps import is_system_admin
from app.core.project_permissions import role_has_api_permission from app.core.project_permissions import role_has_api_permission
from app.crud import audit as audit_crud
from app.crud import member as member_crud from app.crud import member as member_crud
from app.models.collaboration import ( from app.models.collaboration import (
CollaborationEditRequest, CollaborationEditRequest,
@@ -427,23 +426,6 @@ async def require_ownership_transfer(db: AsyncSession, item: CollaborationFile,
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="您没有转让此协作文件所有权的权限") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="您没有转让此协作文件所有权的权限")
async def _audit(db: AsyncSession, item: CollaborationFile, action: str, user, extra: dict | None = None) -> None:
detail = {"file_id": str(item.id), "title": item.title}
if extra:
detail.update(extra)
await audit_crud.log_action(
db,
study_id=item.study_id,
entity_type="COLLABORATION_FILE",
entity_id=item.id,
action=action,
detail=json.dumps(detail, ensure_ascii=False),
operator_id=user.id,
operator_role=await get_operator_role_label(db, item.study_id, user),
auto_commit=False,
)
async def create_folder(db: AsyncSession, study_id: uuid.UUID, payload: CollaborationFolderCreate, user) -> CollaborationFolder: async def create_folder(db: AsyncSession, study_id: uuid.UUID, payload: CollaborationFolderCreate, user) -> CollaborationFolder:
if payload.parent_id: if payload.parent_id:
await _folder_or_404(db, study_id, payload.parent_id) await _folder_or_404(db, study_id, payload.parent_id)
@@ -618,7 +600,6 @@ async def _create_file_from_bytes(
content: bytes, content: bytes,
source: str, source: str,
user, user,
source_file_id: uuid.UUID | None = None,
) -> CollaborationFile: ) -> CollaborationFile:
membership = await member_crud.get_member(db, study_id, user.id) membership = await member_crud.get_member(db, study_id, user.id)
await _require_role_assignable(db, study_id, user, membership, "MANAGER") await _require_role_assignable(db, study_id, user, membership, "MANAGER")
@@ -637,10 +618,6 @@ async def _create_file_from_bytes(
await db.flush() await db.flush()
db.add(CollaborationMember(file_id=item.id, user_id=user.id, role="MANAGER", invited_by=user.id)) db.add(CollaborationMember(file_id=item.id, user_id=user.id, role="MANAGER", invited_by=user.id))
await _persist_revision_bytes(db, item, content, source=source, created_by=user.id, original_filename=item.title) await _persist_revision_bytes(db, item, content, source=source, created_by=user.id, original_filename=item.title)
audit_detail = {"source": source}
if source_file_id:
audit_detail["source_file_id"] = str(source_file_id)
await _audit(db, item, "COLLABORATION_FILE_CREATED", user, audit_detail)
await db.commit() await db.commit()
await db.refresh(item) await db.refresh(item)
return item return item
@@ -775,11 +752,6 @@ async def update_file(
db: AsyncSession, item: CollaborationFile, payload: CollaborationFileUpdate, user db: AsyncSession, item: CollaborationFile, payload: CollaborationFileUpdate, user
) -> CollaborationFile: ) -> CollaborationFile:
await require_file_manager(db, item, user) await require_file_manager(db, item, user)
previous_title = item.title
previous_folder_id = item.folder_id
previous_allow_export = item.allow_export
previous_allow_edit_request = item.allow_edit_request
previous_allow_sheet_structure_edit = item.allow_sheet_structure_edit
values = payload.model_dump(exclude_unset=True) values = payload.model_dump(exclude_unset=True)
if "folder_id" in values and values["folder_id"]: if "folder_id" in values and values["folder_id"]:
await _folder_or_404(db, item.study_id, values["folder_id"]) await _folder_or_404(db, item.study_id, values["folder_id"])
@@ -810,25 +782,6 @@ async def update_file(
item.generation += 1 item.generation += 1
for key, value in values.items(): for key, value in values.items():
setattr(item, key, value) setattr(item, key, value)
title_changed = item.title != previous_title
folder_changed = item.folder_id != previous_folder_id
if title_changed and not folder_changed:
action = "COLLABORATION_FILE_RENAMED"
elif folder_changed and not title_changed:
action = "COLLABORATION_FILE_MOVED"
else:
action = "COLLABORATION_FILE_UPDATED"
await _audit(db, item, action, user, {
"previous_title": previous_title,
"previous_folder_id": str(previous_folder_id) if previous_folder_id else None,
"folder_id": str(item.folder_id) if item.folder_id else None,
"previous_allow_export": previous_allow_export,
"allow_export": item.allow_export,
"previous_allow_edit_request": previous_allow_edit_request,
"allow_edit_request": item.allow_edit_request,
"previous_allow_sheet_structure_edit": previous_allow_sheet_structure_edit,
"allow_sheet_structure_edit": item.allow_sheet_structure_edit,
})
await db.commit() await db.commit()
await db.refresh(item) await db.refresh(item)
return item return item
@@ -874,7 +827,6 @@ async def copy_file(db: AsyncSession, item: CollaborationFile, user) -> Collabor
content=content, content=content,
source="COPY", source="COPY",
user=user, user=user,
source_file_id=item.id,
) )
@@ -885,14 +837,6 @@ async def prepare_download(db: AsyncSession, item: CollaborationFile, user) -> C
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作文件尚无可下载版本") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作文件尚无可下载版本")
if not Path(revision.file_uri).is_file(): if not Path(revision.file_uri).is_file():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件当前版本不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件当前版本不存在")
await _audit(
db,
item,
"COLLABORATION_FILE_DOWNLOADED",
user,
{"revision_id": str(revision.id), "revision_no": revision.revision_no},
)
await db.commit()
return revision return revision
@@ -900,7 +844,6 @@ async def move_to_trash(db: AsyncSession, item: CollaborationFile, user) -> None
await require_file_manager(db, item, user) await require_file_manager(db, item, user)
item.status = "DELETED" item.status = "DELETED"
item.deleted_at = datetime.now(timezone.utc) item.deleted_at = datetime.now(timezone.utc)
await _audit(db, item, "COLLABORATION_FILE_TRASHED", user)
await db.commit() await db.commit()
@@ -908,7 +851,6 @@ async def restore_file(db: AsyncSession, item: CollaborationFile, user) -> Colla
await require_file_manager(db, item, user) await require_file_manager(db, item, user)
item.status = "ACTIVE" item.status = "ACTIVE"
item.deleted_at = None item.deleted_at = None
await _audit(db, item, "COLLABORATION_FILE_RESTORED", user)
await db.commit() await db.commit()
await db.refresh(item) await db.refresh(item)
return item return item
@@ -916,14 +858,10 @@ async def restore_file(db: AsyncSession, item: CollaborationFile, user) -> Colla
async def record_export(db: AsyncSession, item: CollaborationFile, user, file_type: str) -> None: async def record_export(db: AsyncSession, item: CollaborationFile, user, file_type: str) -> None:
await require_file_exporter(db, item, user) await require_file_exporter(db, item, user)
await _audit(db, item, "COLLABORATION_FILE_EXPORTED", user, {"file_type": file_type})
await db.commit()
async def record_download(db: AsyncSession, item: CollaborationFile, user, file_type: str) -> None: async def record_download(db: AsyncSession, item: CollaborationFile, user, file_type: str) -> None:
await require_file_exporter(db, item, user) await require_file_exporter(db, item, user)
await _audit(db, item, "COLLABORATION_FILE_DOWNLOADED", user, {"file_type": file_type})
await db.commit()
async def list_revisions(db: AsyncSession, item: CollaborationFile) -> list[CollaborationRevisionRead]: async def list_revisions(db: AsyncSession, item: CollaborationFile) -> list[CollaborationRevisionRead]:
@@ -967,10 +905,6 @@ async def delete_revision(
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="当前版本不能删除") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="当前版本不能删除")
revision.deleted_at = datetime.now(timezone.utc) revision.deleted_at = datetime.now(timezone.utc)
revision.deleted_by = user.id revision.deleted_by = user.id
await _audit(db, item, "COLLABORATION_REVISION_DELETED", user, {
"revision_id": str(revision.id),
"revision_no": revision.revision_no,
})
await db.commit() await db.commit()
@@ -984,13 +918,6 @@ async def update_revision(
await require_file_editor(db, item, user) await require_file_editor(db, item, user)
revision = await get_revision_or_404(db, item, revision_id) revision = await get_revision_or_404(db, item, revision_id)
revision.change_summary = payload.change_summary revision.change_summary = payload.change_summary
await _audit(
db,
item,
"COLLABORATION_REVISION_NAMED",
user,
{"revision_id": str(revision.id), "revision_no": revision.revision_no},
)
await db.commit() await db.commit()
await db.refresh(revision) await db.refresh(revision)
return revision return revision
@@ -1002,14 +929,6 @@ async def prepare_revision_preview(
revision = await get_revision_or_404(db, item, revision_id) revision = await get_revision_or_404(db, item, revision_id)
if not Path(revision.file_uri).is_file(): if not Path(revision.file_uri).is_file():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作修订内容不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作修订内容不存在")
await _audit(
db,
item,
"COLLABORATION_REVISION_PREVIEWED",
user,
{"revision_id": str(revision.id), "revision_no": revision.revision_no},
)
await db.commit()
return revision return revision
@@ -1040,7 +959,6 @@ async def copy_revision(
content=content, content=content,
source="COPY", source="COPY",
user=user, user=user,
source_file_id=item.id,
) )
@@ -1063,7 +981,6 @@ async def restore_revision(
locked = await db.get(CollaborationFile, item.id) locked = await db.get(CollaborationFile, item.id)
if locked: if locked:
locked.generation += 1 locked.generation += 1
await _audit(db, item, "COLLABORATION_REVISION_RESTORED", user, {"revision_no": revision.revision_no})
await db.commit() await db.commit()
await db.refresh(restored) await db.refresh(restored)
return restored return restored
@@ -1111,7 +1028,6 @@ async def upsert_member(
else: else:
member = CollaborationMember(file_id=item.id, user_id=payload.user_id, role=payload.role, invited_by=user.id) member = CollaborationMember(file_id=item.id, user_id=payload.user_id, role=payload.role, invited_by=user.id)
db.add(member) db.add(member)
await _audit(db, item, "COLLABORATION_MEMBER_UPSERTED", user, {"member_id": str(payload.user_id), "role": payload.role})
await db.commit() await db.commit()
await db.refresh(member) await db.refresh(member)
return CollaborationMemberRead( return CollaborationMemberRead(
@@ -1132,7 +1048,6 @@ async def remove_member(db: AsyncSession, item: CollaborationFile, user_id: uuid
if not member: if not member:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作成员不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作成员不存在")
await db.delete(member) await db.delete(member)
await _audit(db, item, "COLLABORATION_MEMBER_REMOVED", user, {"member_id": str(user_id)})
await db.commit() await db.commit()
@@ -1192,7 +1107,6 @@ async def create_edit_request(
request = CollaborationEditRequest(file_id=item.id, requester_id=user.id) request = CollaborationEditRequest(file_id=item.id, requester_id=user.id)
db.add(request) db.add(request)
await db.flush() await db.flush()
await _audit(db, item, "COLLABORATION_EDIT_REQUESTED", user, {"request_id": str(request.id)})
manager_ids = set((await db.scalars( manager_ids = set((await db.scalars(
select(StudyMember.user_id) select(StudyMember.user_id)
.outerjoin( .outerjoin(
@@ -1286,16 +1200,30 @@ async def resolve_edit_request(
request.status = payload.status request.status = payload.status
request.resolved_by = user.id request.resolved_by = user.id
request.resolved_at = datetime.now(timezone.utc) request.resolved_at = datetime.now(timezone.utc)
approved = payload.status == "APPROVED"
await notification_service.create_recipient_notifications(
db,
study_id=item.study_id,
recipient_ids=[request.requester_id],
category="COLLABORATION_EDIT_REQUEST_RESULT",
priority="NORMAL",
title="编辑权限申请已通过" if approved else "编辑权限申请未通过",
message=f"您对“{item.title}”的编辑权限申请已{'通过' if approved else '被拒绝'}",
action_path=(
f"/knowledge/collaboration/{item.id}"
if approved
else "/knowledge/collaboration"
),
source_type="COLLABORATION_EDIT_REQUEST_RESULT",
source_id=str(request.id),
dedupe_key=f"collaboration-edit-request-result:{request.id}",
requires_action=False,
)
await notification_service.resolve_source_notifications( await notification_service.resolve_source_notifications(
db, db,
source_type="COLLABORATION_EDIT_REQUEST", source_type="COLLABORATION_EDIT_REQUEST",
source_id=str(request.id), source_id=str(request.id),
) )
await _audit(db, item, "COLLABORATION_EDIT_REQUEST_RESOLVED", user, {
"request_id": str(request.id),
"requester_id": str(request.requester_id),
"status": payload.status,
})
await db.commit() await db.commit()
await db.refresh(request) await db.refresh(request)
return _edit_request_read(request, requester) return _edit_request_read(request, requester)
@@ -1341,10 +1269,6 @@ async def transfer_ownership(
previous_owner_member.role = "MANAGER" previous_owner_member.role = "MANAGER"
locked.owner_id = payload.new_owner_id locked.owner_id = payload.new_owner_id
locked.updated_at = datetime.now(timezone.utc) locked.updated_at = datetime.now(timezone.utc)
await _audit(db, locked, "COLLABORATION_OWNERSHIP_TRANSFERRED", user, {
"previous_owner_id": str(previous_owner_id),
"new_owner_id": str(payload.new_owner_id),
})
await db.commit() await db.commit()
await db.refresh(locked) await db.refresh(locked)
return locked return locked
@@ -177,19 +177,6 @@ async def update_share_link(
link.failed_attempts = 0 link.failed_attempts = 0
link.last_failed_at = None link.last_failed_at = None
link.locked_until = None link.locked_until = None
await collaboration_service._audit(
db,
item,
"COLLABORATION_SHARE_LINK_UPDATED",
user,
{
"enabled": link.enabled,
"access_mode": link.access_mode,
"file_allow_export": item.allow_export,
"expiry_policy": link.expiry_policy,
"has_password": bool(link.password_hash),
},
)
await db.commit() await db.commit()
await db.refresh(link) await db.refresh(link)
return share_link_read(link) return share_link_read(link)
@@ -396,11 +396,6 @@ async def process_callback(
# 强制保存不结束当前共同编辑会话;同步基线可保证 Document # 强制保存不结束当前共同编辑会话;同步基线可保证 Document
# Server 缓存重建时仍从最近一次持久化内容恢复。 # Server 缓存重建时仍从最近一次持久化内容恢复。
session.base_revision_id = revision.id session.base_revision_id = revision.id
if created and actor:
await collaboration_service._audit(
db, item, "COLLABORATION_REVISION_SAVED", actor,
{"revision_no": revision.revision_no, "source": source},
)
elif payload.status == 4: elif payload.status == 4:
session.status = "CLOSED" session.status = "CLOSED"
session.closed_at = datetime.now(timezone.utc) session.closed_at = datetime.now(timezone.utc)
+22 -18
View File
@@ -674,24 +674,30 @@ def test_subject_audit_uses_structured_business_snapshot():
assert 'detail=f"参与者 {subject_id} 已删除"' not in delete_subject_source assert 'detail=f"参与者 {subject_id} 已删除"' not in delete_subject_source
def test_faq_audit_uses_business_descriptions(): def test_shared_library_does_not_write_audits():
"""医学咨询审计详情应使用业务描述,避免 FAQ 技术文案""" """共享库各模块及其附件不应写入业务审计"""
api_dir = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" api_dir = Path(__file__).resolve().parents[1] / "app" / "api" / "v1"
category_source = (api_dir / "faq_categories.py").read_text() services_dir = Path(__file__).resolve().parents[1] / "app" / "services"
item_source = (api_dir / "faqs.py").read_text() non_audited_sources = [
(api_dir / "faq_categories.py").read_text(),
(api_dir / "faqs.py").read_text(),
(api_dir / "precautions.py").read_text(),
(services_dir / "collaboration_service.py").read_text(),
(services_dir / "collaboration_share_service.py").read_text(),
(services_dir / "onlyoffice_collaboration_service.py").read_text(),
]
assert 'description": f"创建“{category.name}”分类"' in category_source for source in non_audited_sources:
assert 'description": f"更新“{updated.name}”分类"' in category_source assert "audit_crud" not in source
assert 'description": f"删除“{category_name}”分类"' in category_source assert "._audit(" not in source
assert 'f"创建医学咨询问题“{question_name}"' in item_source
assert 'f"更新医学咨询问题“{question_name}"' in item_source attachment_source = (api_dir / "attachments.py").read_text()
assert 'f"回复医学咨询问题“{question_name}"' in item_source assert 'SHARED_LIBRARY_ENTITY_TYPES = {"precaution", "faq_replies"}' in attachment_source
assert 'f"删除医学咨询问题“{question_name}"' in item_source assert "if entity_type not in SHARED_LIBRARY_ENTITY_TYPES:" in attachment_source
assert 'f"删除医学咨询问题“{question_name}”的回复"' in item_source assert "if attachment.entity_type not in SHARED_LIBRARY_ENTITY_TYPES:" in attachment_source
assert "FAQ 分类 {category.name} 已创建" not in category_source
assert 'detail="FAQ 已创建"' not in item_source onlyoffice_source = (api_dir / "onlyoffice.py").read_text()
assert 'detail = "FAQ updated"' not in item_source assert "OFFICE_PREVIEW_OPEN" not in onlyoffice_source
assert '"description": "创建医学咨询问题"' not in item_source
def test_domain_audits_use_business_object_names(): def test_domain_audits_use_business_object_names():
@@ -704,7 +710,6 @@ def test_domain_audits_use_business_object_names():
"material_equipments.py", "material_equipments.py",
"visits.py", "visits.py",
"aes.py", "aes.py",
"precautions.py",
"fees_contracts.py", "fees_contracts.py",
"startup.py", "startup.py",
"subject_histories.py", "subject_histories.py",
@@ -715,7 +720,6 @@ def test_domain_audits_use_business_object_names():
assert "_equipment_audit_detail" in sources["material_equipments.py"] assert "_equipment_audit_detail" in sources["material_equipments.py"]
assert "_visit_audit_detail" in sources["visits.py"] assert "_visit_audit_detail" in sources["visits.py"]
assert "_ae_audit_detail" in sources["aes.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 "_contract_audit_detail" in sources["fees_contracts.py"]
assert "_payment_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 "_feasibility_audit_detail" in sources["startup.py"]
+19 -27
View File
@@ -140,7 +140,6 @@ async def test_copy_revision_saves_to_the_selected_workspace_folder(monkeypatch,
content=content, content=content,
source="COPY", source="COPY",
user=user, user=user,
source_file_id=item.id,
) )
@@ -159,22 +158,13 @@ async def test_delete_revision_soft_deletes_a_non_current_version(monkeypatch):
) )
db = SimpleNamespace(get=AsyncMock(return_value=revision), commit=AsyncMock()) db = SimpleNamespace(get=AsyncMock(return_value=revision), commit=AsyncMock())
require_manager = AsyncMock() require_manager = AsyncMock()
audit = AsyncMock()
monkeypatch.setattr(collaboration_service, "require_file_manager", require_manager) monkeypatch.setattr(collaboration_service, "require_file_manager", require_manager)
monkeypatch.setattr(collaboration_service, "_audit", audit)
await collaboration_service.delete_revision(db, item, revision_id, user) await collaboration_service.delete_revision(db, item, revision_id, user)
require_manager.assert_awaited_once_with(db, item, user) require_manager.assert_awaited_once_with(db, item, user)
assert revision.deleted_at is not None assert revision.deleted_at is not None
assert revision.deleted_by == user.id assert revision.deleted_by == user.id
audit.assert_awaited_once_with(
db,
item,
"COLLABORATION_REVISION_DELETED",
user,
{"revision_id": str(revision_id), "revision_no": 2},
)
db.commit.assert_awaited_once() db.commit.assert_awaited_once()
@@ -260,7 +250,6 @@ async def test_sheet_permission_change_updates_current_file_without_creating_rev
user = SimpleNamespace(id=uuid.uuid4()) user = SimpleNamespace(id=uuid.uuid4())
db = SimpleNamespace(get=AsyncMock(return_value=revision), commit=AsyncMock(), refresh=AsyncMock()) db = SimpleNamespace(get=AsyncMock(return_value=revision), commit=AsyncMock(), refresh=AsyncMock())
monkeypatch.setattr(collaboration_service, "require_file_manager", AsyncMock()) monkeypatch.setattr(collaboration_service, "require_file_manager", AsyncMock())
monkeypatch.setattr(collaboration_service, "_audit", AsyncMock())
persist_revision = AsyncMock() persist_revision = AsyncMock()
monkeypatch.setattr(collaboration_service, "_persist_revision_bytes", persist_revision) monkeypatch.setattr(collaboration_service, "_persist_revision_bytes", persist_revision)
@@ -540,7 +529,6 @@ async def test_share_link_url_stays_immutable_when_disabled_and_reenabled(monkey
refresh=AsyncMock(), refresh=AsyncMock(),
) )
monkeypatch.setattr(collaboration_service, "require_file_manager", AsyncMock()) monkeypatch.setattr(collaboration_service, "require_file_manager", AsyncMock())
monkeypatch.setattr(collaboration_service, "_audit", AsyncMock())
original_token = collaboration_share_service.share_token(link) original_token = collaboration_share_service.share_token(link)
await collaboration_share_service.update_share_link( await collaboration_share_service.update_share_link(
@@ -803,12 +791,11 @@ async def test_copy_creates_an_independent_r1_from_the_current_revision(monkeypa
content=content, content=content,
source="COPY", source="COPY",
user=user, user=user,
source_file_id=item.id,
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_download_returns_current_revision_and_records_audit(monkeypatch, tmp_path): async def test_download_returns_current_revision_without_writing_audit(monkeypatch, tmp_path):
source = tmp_path / "source.docx" source = tmp_path / "source.docx"
source.write_bytes(collaboration_service.blank_file_bytes("word")) source.write_bytes(collaboration_service.blank_file_bytes("word"))
revision = SimpleNamespace( revision = SimpleNamespace(
@@ -821,22 +808,33 @@ async def test_download_returns_current_revision_and_records_audit(monkeypatch,
user = SimpleNamespace(id=uuid.uuid4()) user = SimpleNamespace(id=uuid.uuid4())
db = SimpleNamespace(get=AsyncMock(return_value=revision), commit=AsyncMock()) db = SimpleNamespace(get=AsyncMock(return_value=revision), commit=AsyncMock())
require_exporter = AsyncMock() require_exporter = AsyncMock()
audit = AsyncMock()
monkeypatch.setattr(collaboration_service, "require_file_exporter", require_exporter) monkeypatch.setattr(collaboration_service, "require_file_exporter", require_exporter)
monkeypatch.setattr(collaboration_service, "_audit", audit)
result = await collaboration_service.prepare_download(db, item, user) result = await collaboration_service.prepare_download(db, item, user)
assert result is revision assert result is revision
require_exporter.assert_awaited_once_with(db, item, user) require_exporter.assert_awaited_once_with(db, item, user)
audit.assert_awaited_once_with( db.commit.assert_not_awaited()
@pytest.mark.asyncio
async def test_revision_preview_does_not_write_passive_audit(monkeypatch, tmp_path):
revision_path = tmp_path / "revision.docx"
revision_path.write_bytes(b"office")
revision = SimpleNamespace(id=uuid.uuid4(), file_uri=str(revision_path), revision_no=2)
item = SimpleNamespace(id=uuid.uuid4())
db = SimpleNamespace(commit=AsyncMock())
monkeypatch.setattr(collaboration_service, "get_revision_or_404", AsyncMock(return_value=revision))
result = await collaboration_service.prepare_revision_preview(
db, db,
item, item,
"COLLABORATION_FILE_DOWNLOADED", revision.id,
user, SimpleNamespace(id=uuid.uuid4()),
{"revision_id": str(revision.id), "revision_no": 3},
) )
db.commit.assert_awaited_once()
assert result is revision
db.commit.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -1041,12 +1039,6 @@ async def test_force_save_updates_session_recovery_revision(monkeypatch):
"append_revision", "append_revision",
AsyncMock(return_value=(revision, True)), AsyncMock(return_value=(revision, True)),
) )
monkeypatch.setattr(
onlyoffice_collaboration_service.collaboration_service,
"_audit",
AsyncMock(),
)
result = await onlyoffice_collaboration_service.process_callback( result = await onlyoffice_collaboration_service.process_callback(
db, db,
session.id, session.id,
@@ -128,7 +128,6 @@ async def test_edit_request_notifies_the_active_file_owner_and_managers(monkeypa
"get_member", "get_member",
AsyncMock(return_value=SimpleNamespace(is_active=True)), AsyncMock(return_value=SimpleNamespace(is_active=True)),
) )
monkeypatch.setattr(collaboration_service, "_audit", AsyncMock())
monkeypatch.setattr(collaboration_service.notification_service, "create_recipient_notifications", notify) monkeypatch.setattr(collaboration_service.notification_service, "create_recipient_notifications", notify)
result = await collaboration_service.create_edit_request(db, item, user) result = await collaboration_service.create_edit_request(db, item, user)
-4
View File
@@ -39,12 +39,10 @@ async def test_attachment_config_reuses_permission_and_preserves_original_name(m
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
) )
permission = AsyncMock() permission = AsyncMock()
audit = AsyncMock()
monkeypatch.setattr(onlyoffice_api.attachment_crud, "get_attachment", AsyncMock(return_value=attachment)) monkeypatch.setattr(onlyoffice_api.attachment_crud, "get_attachment", AsyncMock(return_value=attachment))
monkeypatch.setattr(onlyoffice_api, "_ensure_study_exists", AsyncMock()) monkeypatch.setattr(onlyoffice_api, "_ensure_study_exists", AsyncMock())
monkeypatch.setattr(onlyoffice_api, "_ensure_attachment_permission", permission) monkeypatch.setattr(onlyoffice_api, "_ensure_attachment_permission", permission)
monkeypatch.setattr(onlyoffice_api.onlyoffice_service, "ensure_onlyoffice_available", AsyncMock()) monkeypatch.setattr(onlyoffice_api.onlyoffice_service, "ensure_onlyoffice_available", AsyncMock())
monkeypatch.setattr(onlyoffice_api, "_log_preview_open", audit)
response = Response() response = Response()
db = object() db = object()
@@ -54,7 +52,6 @@ async def test_attachment_config_reuses_permission_and_preserves_original_name(m
assert result.file_name == "研究方案最终版.DOCX" assert result.file_name == "研究方案最终版.DOCX"
assert result.config["document"]["title"] == "研究方案最终版.DOCX" assert result.config["document"]["title"] == "研究方案最终版.DOCX"
assert response.headers["Cache-Control"] == "no-store" assert response.headers["Cache-Control"] == "no-store"
audit.assert_awaited_once()
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -96,7 +93,6 @@ async def test_version_config_reuses_document_access_and_version_hash(monkeypatc
monkeypatch.setattr(onlyoffice_api.document_crud, "get", AsyncMock(return_value=document)) monkeypatch.setattr(onlyoffice_api.document_crud, "get", AsyncMock(return_value=document))
monkeypatch.setattr(onlyoffice_api.document_service, "_ensure_study_access", access) monkeypatch.setattr(onlyoffice_api.document_service, "_ensure_study_access", access)
monkeypatch.setattr(onlyoffice_api.onlyoffice_service, "ensure_onlyoffice_available", AsyncMock()) monkeypatch.setattr(onlyoffice_api.onlyoffice_service, "ensure_onlyoffice_available", AsyncMock())
monkeypatch.setattr(onlyoffice_api, "_log_preview_open", AsyncMock())
result = await onlyoffice_api.get_version_preview_config(version.id, Response(), object(), user) result = await onlyoffice_api.get_version_preview_config(version.id, Response(), object(), user)