新增全局协作台账并统一工作台管理界面

新增与项目平级的协作台账,提供空白模板、独立账号授权和在线编辑。
复用共享库的访问权限与历史版本界面,拆分信息维护入口并统一工作台卡片样式。
补充在线文档字体构建支持、数据库表结构升级和回归测试。
不包含本地台账内容、账号授权记录或历史文件迁移数据。
This commit is contained in:
Cheng Zhou
2026-09-03 16:55:08 +08:00
parent 46a6dda753
commit c1dd8def2c
41 changed files with 4155 additions and 1815 deletions
@@ -0,0 +1,38 @@
"""Add global ledgers sharing the collaboration engine.
Revision ID: 20260903_01
Revises: 20260716_05
"""
import sqlalchemy as sa
from alembic import op
revision = "20260903_01"
down_revision = "20260716_05"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.alter_column("collaboration_files", "study_id", nullable=True)
op.add_column("collaboration_files", sa.Column("scope", sa.String(16), nullable=False, server_default="PROJECT"))
op.add_column("collaboration_files", sa.Column("ledger_key", sa.String(64), nullable=True))
op.add_column("collaboration_files", sa.Column("description", sa.String(500), nullable=False, server_default=""))
op.create_unique_constraint("uq_collaboration_file_ledger_key", "collaboration_files", ["ledger_key"])
op.create_check_constraint(
"ck_collaboration_file_scope", "collaboration_files",
"(scope = 'PROJECT' AND study_id IS NOT NULL AND ledger_key IS NULL) OR "
"(scope = 'LEDGER' AND study_id IS NULL AND folder_id IS NULL AND ledger_key IS NOT NULL "
"AND file_type = 'cell' AND extension = 'xlsx' AND deleted_at IS NULL AND status IN ('ACTIVE', 'ARCHIVED'))",
)
def downgrade() -> None:
# Never discard retained ledger files, grants or history during rollback.
if op.get_bind().scalar(sa.text("SELECT count(*) FROM collaboration_files WHERE scope = 'LEDGER'")):
raise RuntimeError("已有全局台账,不能回滚台账迁移;请保留数据并使用前向修复")
op.drop_constraint("ck_collaboration_file_scope", "collaboration_files", type_="check")
op.drop_constraint("uq_collaboration_file_ledger_key", "collaboration_files", type_="unique")
op.drop_column("collaboration_files", "description")
op.drop_column("collaboration_files", "ledger_key")
op.drop_column("collaboration_files", "scope")
op.alter_column("collaboration_files", "study_id", nullable=False)
+201
View File
@@ -0,0 +1,201 @@
import uuid
from fastapi import APIRouter, Depends, File, Form, Query, Response, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_roles
from app.schemas.collaboration import (
CollaborationEditorConfigRead, CollaborationExportRecord, CollaborationRevisionRead,
CollaborationEditRequestRead, CollaborationEditRequestResolve, CollaborationOwnershipTransferRequest,
CollaborationShareLinkRead, CollaborationShareLinkUpdate, CollaborationRevisionUpdate,
)
from app.schemas.ledger import (
LedgerAccessSettings, LedgerCandidateRead, LedgerMemberRead, LedgerMemberUpsert, LedgerRead, LedgerRestore, LedgerSettings,
)
from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead
from app.services import collaboration_service, collaboration_share_service, ledger_service, onlyoffice_collaboration_service, onlyoffice_service
def no_store(response: Response):
response.headers["Cache-Control"] = "no-store"
router = APIRouter(dependencies=[Depends(no_store)])
@router.get("", response_model=list[LedgerRead])
async def list_ledgers(db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
return await ledger_service.list_ledgers(db, user)
@router.post("/initialize", response_model=list[LedgerRead])
async def initialize(db: AsyncSession = Depends(get_db_session), user=Depends(require_roles(["ADMIN"]))):
return await ledger_service.initialize_ledgers(db, user)
@router.get("/{ledger_id}", response_model=LedgerRead)
async def read(ledger_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user)
return await ledger_service.read_ledger(db, item, user)
@router.patch("/{ledger_id}", response_model=LedgerRead)
async def update(ledger_id: uuid.UUID, payload: LedgerSettings, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
return await ledger_service.update_settings(db, item, payload, user)
@router.post("/{ledger_id}/import", response_model=LedgerRead)
async def import_template(
ledger_id: uuid.UUID, file: UploadFile = File(...), generation: int = Form(..., ge=1),
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
return await ledger_service.import_content(db, item, file, generation, user)
@router.get("/{ledger_id}/members", response_model=list[LedgerMemberRead])
async def members(ledger_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True)
return await ledger_service.list_members(db, item)
@router.get("/{ledger_id}/candidates", response_model=list[LedgerCandidateRead])
async def candidates(
ledger_id: uuid.UUID, keyword: str = Query(default="", max_length=100),
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
):
await ledger_service.get_ledger(db, ledger_id, user, manage=True)
return await ledger_service.candidates(db, keyword)
@router.put("/{ledger_id}/members", status_code=204)
async def grant(
ledger_id: uuid.UUID, payload: LedgerMemberUpsert,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
await ledger_service.set_member(db, item, payload, user)
@router.delete("/{ledger_id}/members/{user_id}", status_code=204)
async def revoke(
ledger_id: uuid.UUID, user_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
await ledger_service.remove_member(db, item, user_id, user)
@router.get("/{ledger_id}/editor-config", response_model=CollaborationEditorConfigRead)
async def editor_config(ledger_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user, lock=True)
return await onlyoffice_collaboration_service.build_editor_config(db, item, user)
@router.patch("/{ledger_id}/access-settings", response_model=LedgerRead)
async def access_settings(ledger_id: uuid.UUID, payload: LedgerAccessSettings,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
return await ledger_service.update_access_settings(db, item, payload, user)
@router.get("/{ledger_id}/share-link", response_model=CollaborationShareLinkRead)
async def share_link(ledger_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
return await collaboration_share_service.get_share_link(db, item, user)
@router.put("/{ledger_id}/share-link", response_model=CollaborationShareLinkRead)
async def update_share_link(ledger_id: uuid.UUID, payload: CollaborationShareLinkUpdate,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
return await collaboration_share_service.update_share_link(db, item, payload, user)
@router.post("/{ledger_id}/edit-requests", response_model=CollaborationEditRequestRead)
async def request_edit(ledger_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user, lock=True)
return await collaboration_service.create_edit_request(db, item, user)
@router.get("/{ledger_id}/edit-requests", response_model=list[CollaborationEditRequestRead])
async def edit_requests(ledger_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True)
return await collaboration_service.list_edit_requests(db, item, user)
@router.post("/{ledger_id}/edit-requests/{request_id}/resolve", response_model=CollaborationEditRequestRead)
async def resolve_request(ledger_id: uuid.UUID, request_id: uuid.UUID, payload: CollaborationEditRequestResolve,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
return await collaboration_service.resolve_edit_request(db, item, request_id, payload, user)
@router.post("/{ledger_id}/transfer-ownership", response_model=LedgerRead)
async def transfer_owner(ledger_id: uuid.UUID, payload: CollaborationOwnershipTransferRequest,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
item = await collaboration_service.transfer_ownership(db, item, payload, user)
return await ledger_service.read_ledger(db, item, user)
@router.post("/{ledger_id}/downloads", status_code=204)
async def download_check(
ledger_id: uuid.UUID, payload: CollaborationExportRecord,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
):
item = await ledger_service.get_ledger(db, ledger_id, user)
await collaboration_service.record_download(db, item, user, payload.file_type)
@router.get("/{ledger_id}/revisions", response_model=list[CollaborationRevisionRead])
async def revisions(ledger_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user)
return await collaboration_service.list_revisions(db, item)
@router.post("/{ledger_id}/revisions/{revision_id}/restore", response_model=CollaborationRevisionRead)
async def restore(
ledger_id: uuid.UUID, revision_id: uuid.UUID, payload: LedgerRestore,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
return await ledger_service.restore_revision(db, item, revision_id, payload.generation, user)
@router.patch("/{ledger_id}/revisions/{revision_id}", response_model=CollaborationRevisionRead)
async def name_revision(
ledger_id: uuid.UUID, revision_id: uuid.UUID, payload: CollaborationRevisionUpdate,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
):
item = await ledger_service.get_ledger(db, ledger_id, user, lock=True)
return await collaboration_service.update_revision(db, item, revision_id, payload, user)
@router.get("/{ledger_id}/revisions/{revision_id}/download")
async def download_revision(
ledger_id: uuid.UUID, revision_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
):
item = await ledger_service.get_ledger(db, ledger_id, user, lock=True)
await collaboration_service.require_file_exporter(db, item, user)
revision = await collaboration_service.prepare_revision_preview(db, item, revision_id, user)
await collaboration_service.record_download(db, item, user, "xlsx")
return FileResponse(revision.file_uri, filename=item.title, media_type=revision.mime_type,
headers={"Cache-Control": "no-store"})
@router.get("/{ledger_id}/revisions/{revision_id}/preview-config", response_model=OnlyOfficePreviewConfigRead)
async def preview(
ledger_id: uuid.UUID, revision_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
):
item = await ledger_service.get_ledger(db, ledger_id, user)
revision = await collaboration_service.prepare_revision_preview(db, item, revision_id, user)
await onlyoffice_service.ensure_onlyoffice_available()
return onlyoffice_service.build_preview_config(
resource_type="collaboration_revision", resource_id=revision.id, file_name=item.title,
file_hash=revision.file_hash, user_id=user.id, user_name=user.full_name,
)
+2
View File
@@ -1,4 +1,5 @@
from fastapi import APIRouter
from app.api.v1 import ledgers
from app.api.v1 import auth, users, admin_email_settings, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, desktop_notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles, onlyoffice, collaboration
@@ -32,6 +33,7 @@ api_router.include_router(startup.router, prefix="/studies/{study_id}/startup",
api_router.include_router(precautions.router, prefix="/studies/{study_id}/shared-library", tags=["precautions"])
api_router.include_router(collaboration.router, prefix="/studies/{study_id}/collaboration", tags=["collaboration"])
api_router.include_router(collaboration.public_router, prefix="/collaboration/shares", tags=["collaboration-shares"])
api_router.include_router(ledgers.router, prefix="/ledgers", tags=["ledgers"])
api_router.include_router(monitoring_visit_issues.router, prefix="/studies/{study_id}/monitoring", tags=["monitoring-visit-issues"])
api_router.include_router(subject_histories.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-histories"])
api_router.include_router(subject_pds.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-pds"])
+12 -2
View File
@@ -4,7 +4,7 @@ import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func, text
from sqlalchemy import BigInteger, Boolean, CheckConstraint, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -35,12 +35,22 @@ class CollaborationFolder(Base):
class CollaborationFile(Base):
__tablename__ = "collaboration_files"
__table_args__ = (
CheckConstraint(
"(scope = 'PROJECT' AND study_id IS NOT NULL AND ledger_key IS NULL) OR "
"(scope = 'LEDGER' AND study_id IS NULL AND folder_id IS NULL AND ledger_key IS NOT NULL "
"AND file_type = 'cell' AND extension = 'xlsx' AND deleted_at IS NULL AND status IN ('ACTIVE', 'ARCHIVED'))",
name="ck_collaboration_file_scope",
),
UniqueConstraint("ledger_key", name="uq_collaboration_file_ledger_key"),
Index("ix_collaboration_files_study_folder", "study_id", "folder_id"),
Index("ix_collaboration_files_study_status", "study_id", "status"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False)
study_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=True)
scope: Mapped[str] = mapped_column(String(16), nullable=False, default="PROJECT", server_default="PROJECT")
ledger_key: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
description: Mapped[str] = mapped_column(String(500), nullable=False, default="", server_default="")
folder_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("collaboration_folders.id", ondelete="SET NULL"), nullable=True
)
+88
View File
@@ -0,0 +1,88 @@
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field, field_validator
LedgerRole = Literal["VIEWER", "EDITOR", "MANAGER"]
class LedgerRead(BaseModel):
id: uuid.UUID
title: str
description: str
status: Literal["ACTIVE", "ARCHIVED"]
owner_id: uuid.UUID
owner_name: str
generation: int
updated_at: datetime
current_revision_id: uuid.UUID | None
current_revision_no: int | None
current_revision_file_size: int | None
current_revision_mime_type: str | None
current_revision_created_at: datetime | None
role: LedgerRole
can_edit: bool
can_manage: bool
can_export: bool
allow_export: bool
allow_edit_request: bool
allow_sheet_structure_edit: bool
file_type: Literal["cell"] = "cell"
extension: Literal["xlsx"] = "xlsx"
can_request_edit: bool
edit_request_status: Literal["PENDING", "APPROVED", "REJECTED"] | None = None
can_transfer_ownership: bool
class LedgerSettings(BaseModel):
title: str = Field(min_length=1, max_length=240)
description: str = Field(default="", max_length=500)
status: Literal["ACTIVE", "ARCHIVED"]
owner_id: uuid.UUID
allow_export: bool = False
allow_edit_request: bool | None = None
allow_sheet_structure_edit: bool = False
generation: int = Field(ge=1)
@field_validator("title")
@classmethod
def trim_title(cls, value):
value = value.strip()
if not value:
raise ValueError("台账名称不能为空")
return value
class LedgerMemberUpsert(BaseModel):
user_id: uuid.UUID
role: LedgerRole
class LedgerMemberRead(BaseModel):
user_id: uuid.UUID
full_name: str
email: str
role: LedgerRole
is_active: bool
protected_label: str | None = None
class LedgerCandidateRead(BaseModel):
user_id: uuid.UUID
full_name: str
email: str
role_in_study: str
can_be_editor: bool = True
can_be_manager: bool = True
class LedgerAccessSettings(BaseModel):
generation: int = Field(ge=1)
allow_export: bool | None = None
allow_edit_request: bool | None = None
allow_sheet_structure_edit: bool | None = None
class LedgerRestore(BaseModel):
generation: int = Field(ge=1)
+121 -69
View File
@@ -46,6 +46,7 @@ from app.schemas.collaboration import (
CollaborationRevisionUpdate,
)
from app.services import notification_service
from app.services import ledger_access
COLLABORATION_ROOT = Path(__file__).resolve().parent.parent / "uploads" / "collaboration"
FILE_TYPE_EXTENSION = {"word": "docx", "cell": "xlsx", "slide": "pptx"}
@@ -332,6 +333,8 @@ async def _require_role_assignable(
async def can_edit_file(db: AsyncSession, item: CollaborationFile, user) -> bool:
if ledger_access.is_ledger(item):
return await ledger_access.can_edit(db, item, user)
if is_system_admin(user):
return True
membership = await member_crud.get_member(db, item.study_id, user.id)
@@ -342,6 +345,8 @@ async def can_edit_file(db: AsyncSession, item: CollaborationFile, user) -> bool
async def can_manage_file(db: AsyncSession, item: CollaborationFile, user) -> bool:
if ledger_access.is_ledger(item):
return await ledger_access.can_manage(db, item, user)
if is_system_admin(user):
return True
membership = await member_crud.get_member(db, item.study_id, user.id)
@@ -351,6 +356,8 @@ async def can_manage_file(db: AsyncSession, item: CollaborationFile, user) -> bo
async def can_export_file(db: AsyncSession, item: CollaborationFile, user) -> bool:
if ledger_access.is_ledger(item):
return await ledger_access.can_export(db, item, user)
if is_system_admin(user):
return True
membership = await member_crud.get_member(db, item.study_id, user.id)
@@ -362,6 +369,8 @@ async def can_export_file(db: AsyncSession, item: CollaborationFile, user) -> bo
async def can_create_file(db: AsyncSession, item: CollaborationFile, user) -> bool:
if ledger_access.is_ledger(item):
return False
if is_system_admin(user):
return True
membership = await member_crud.get_member(db, item.study_id, user.id)
@@ -387,6 +396,12 @@ async def edit_request_status(
async def can_request_edit_file(db: AsyncSession, item: CollaborationFile, user) -> bool:
if ledger_access.is_ledger(item):
return (
item.status == "ACTIVE" and item.allow_edit_request
and await ledger_access.role_for(db, item, user) == "VIEWER"
and await edit_request_status(db, item, user.id) != "PENDING"
)
if not item.allow_edit_request or await can_edit_file(db, item, user):
return False
membership = await member_crud.get_member(db, item.study_id, user.id)
@@ -398,6 +413,8 @@ async def can_request_edit_file(db: AsyncSession, item: CollaborationFile, user)
async def can_transfer_ownership(db: AsyncSession, item: CollaborationFile, user) -> bool:
if ledger_access.is_ledger(item):
return bool(user and user.is_active and (is_system_admin(user) or item.owner_id == user.id))
if is_system_admin(user):
return True
membership = await member_crud.get_member(db, item.study_id, user.id)
@@ -535,7 +552,7 @@ async def _persist_revision_bytes(
select(func.max(CollaborationRevision.revision_no)).where(CollaborationRevision.file_id == locked.id)
)
revision_no = int(last_no or 0) + 1
directory = COLLABORATION_ROOT / str(locked.study_id) / str(locked.id)
directory = COLLABORATION_ROOT / ("ledgers" if ledger_access.is_ledger(locked) else str(locked.study_id)) / str(locked.id)
directory.mkdir(parents=True, exist_ok=True)
destination = directory / f"{uuid.uuid4()}.{locked.extension}"
async with aiofiles.open(destination, "wb") as stream:
@@ -841,6 +858,8 @@ async def prepare_download(db: AsyncSession, item: CollaborationFile, user) -> C
async def move_to_trash(db: AsyncSession, item: CollaborationFile, user) -> None:
if ledger_access.is_ledger(item):
raise HTTPException(403, "台账禁止删除,请使用归档")
await require_file_manager(db, item, user)
item.status = "DELETED"
item.deleted_at = datetime.now(timezone.utc)
@@ -899,6 +918,8 @@ async def delete_revision(
revision_id: uuid.UUID,
user,
) -> None:
if ledger_access.is_ledger(item):
raise HTTPException(403, "台账历史版本禁止删除")
await require_file_manager(db, item, user)
revision = await get_revision_or_404(db, item, revision_id)
if item.current_revision_id == revision.id:
@@ -965,10 +986,19 @@ async def copy_revision(
async def restore_revision(
db: AsyncSession, item: CollaborationFile, revision_id: uuid.UUID, user, change_summary: str | None
) -> CollaborationRevision:
if ledger_access.is_ledger(item):
await ledger_access.require_access(db, item, user, manage=True)
await require_file_editor(db, item, user)
revision = await get_revision_or_404(db, item, revision_id)
async with aiofiles.open(revision.file_uri, "rb") as stream:
content = await stream.read()
if ledger_access.is_ledger(item):
content, _ = apply_workbook_structure_policy(
content, file_id=item.id, allow_sheet_structure_edit=item.allow_sheet_structure_edit,
# A historical revision may still carry CTMS protection after the
# current ledger was unlocked. Apply today's policy on restoration.
protection_backup=item.sheet_structure_protection_backup or "",
)
restored, created = await append_revision(
db,
item,
@@ -1092,9 +1122,14 @@ async def create_edit_request(
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="您已拥有此文件的编辑权限")
if not item.allow_edit_request:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="此文件未开放编辑权限申请")
membership = await member_crud.get_member(db, item.study_id, user.id)
if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有当前项目成员可以申请编辑权限")
if ledger_access.is_ledger(item):
await ledger_access.require_access(db, item, user)
if item.status != "ACTIVE":
raise HTTPException(409, "已归档台账不能申请编辑权限")
else:
membership = await member_crud.get_member(db, item.study_id, user.id)
if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有当前项目成员可以申请编辑权限")
pending = await db.scalar(
select(CollaborationEditRequest).where(
CollaborationEditRequest.file_id == item.id,
@@ -1107,40 +1142,41 @@ async def create_edit_request(
request = CollaborationEditRequest(file_id=item.id, requester_id=user.id)
db.add(request)
await db.flush()
manager_ids = set((await db.scalars(
select(StudyMember.user_id)
.outerjoin(
CollaborationMember,
and_(
CollaborationMember.file_id == item.id,
CollaborationMember.user_id == StudyMember.user_id,
),
if not ledger_access.is_ledger(item):
manager_ids = set((await db.scalars(
select(StudyMember.user_id)
.outerjoin(
CollaborationMember,
and_(
CollaborationMember.file_id == item.id,
CollaborationMember.user_id == StudyMember.user_id,
),
)
.where(
StudyMember.study_id == item.study_id,
StudyMember.is_active.is_(True),
or_(
StudyMember.user_id == item.owner_id,
CollaborationMember.role == "MANAGER",
),
)
)).all())
manager_ids.add(item.owner_id)
requester_name = str(user.full_name or user.email or "项目成员")
await notification_service.create_recipient_notifications(
db,
study_id=item.study_id,
recipient_ids=manager_ids,
category="COLLABORATION_EDIT_REQUEST",
priority="NORMAL",
title="新的编辑权限申请",
message=f"{requester_name} 申请编辑“{item.title}”",
action_path=f"/knowledge/collaboration?editRequestFile={item.id}",
source_type="COLLABORATION_EDIT_REQUEST",
source_id=str(request.id),
dedupe_key=f"collaboration-edit-request:{request.id}",
source_version="PENDING",
)
.where(
StudyMember.study_id == item.study_id,
StudyMember.is_active.is_(True),
or_(
StudyMember.user_id == item.owner_id,
CollaborationMember.role == "MANAGER",
),
)
)).all())
manager_ids.add(item.owner_id)
requester_name = str(user.full_name or user.email or "项目成员")
await notification_service.create_recipient_notifications(
db,
study_id=item.study_id,
recipient_ids=manager_ids,
category="COLLABORATION_EDIT_REQUEST",
priority="NORMAL",
title="新的编辑权限申请",
message=f"{requester_name} 申请编辑“{item.title}”",
action_path=f"/knowledge/collaboration?editRequestFile={item.id}",
source_type="COLLABORATION_EDIT_REQUEST",
source_id=str(request.id),
dedupe_key=f"collaboration-edit-request:{request.id}",
source_version="PENDING",
)
await db.commit()
await db.refresh(request)
return _edit_request_read(request, user)
@@ -1179,11 +1215,18 @@ async def resolve_edit_request(
if request.status != "PENDING":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="编辑权限申请已处理")
requester = await db.get(User, request.requester_id)
membership = await member_crud.get_member(db, item.study_id, request.requester_id)
if not requester or not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="申请人已不是当前项目的有效成员")
if ledger_access.is_ledger(item):
if item.status != "ACTIVE" or not await ledger_access.role_for(db, item, requester):
raise HTTPException(422, "申请人已无台账访问权限或台账已归档")
else:
membership = await member_crud.get_member(db, item.study_id, request.requester_id)
if not requester or not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="申请人已不是当前项目的有效成员")
if payload.status == "APPROVED":
await _require_role_assignable(db, item.study_id, requester, membership, "EDITOR")
if ledger_access.is_ledger(item):
item.generation += 1
else:
await _require_role_assignable(db, item.study_id, requester, membership, "EDITOR")
member = await db.scalar(select(CollaborationMember).where(
CollaborationMember.file_id == item.id,
CollaborationMember.user_id == request.requester_id,
@@ -1201,30 +1244,31 @@ async def resolve_edit_request(
request.status = payload.status
request.resolved_by = user.id
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(
db,
source_type="COLLABORATION_EDIT_REQUEST",
source_id=str(request.id),
)
if not ledger_access.is_ledger(item):
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(
db,
source_type="COLLABORATION_EDIT_REQUEST",
source_id=str(request.id),
)
await db.commit()
await db.refresh(request)
return _edit_request_read(request, requester)
@@ -1242,11 +1286,16 @@ async def transfer_ownership(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件不存在")
if payload.new_owner_id == locked.owner_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="所选联系人已经是文档所有者")
membership = await member_crud.get_member(db, locked.study_id, payload.new_owner_id)
target = await db.get(User, payload.new_owner_id)
if not target or not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="只能转让给当前项目的有效成员")
await _require_role_assignable(db, locked.study_id, target, membership, "MANAGER")
if ledger_access.is_ledger(locked):
if not target or not target.is_active:
raise HTTPException(422, "只能转让给有效的系统账号")
locked.generation += 1
else:
membership = await member_crud.get_member(db, locked.study_id, payload.new_owner_id)
if not target or not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="只能转让给当前项目的有效成员")
await _require_role_assignable(db, locked.study_id, target, membership, "MANAGER")
previous_owner_id = locked.owner_id
member = await db.scalar(select(CollaborationMember).where(
CollaborationMember.file_id == locked.id,
@@ -1268,6 +1317,9 @@ async def transfer_ownership(
))
if previous_owner_member:
previous_owner_member.role = "MANAGER"
elif ledger_access.is_ledger(locked):
db.add(CollaborationMember(file_id=locked.id, user_id=previous_owner_id,
role="MANAGER", invited_by=user.id))
locked.owner_id = payload.new_owner_id
locked.updated_at = datetime.now(timezone.utc)
await db.commit()
@@ -22,7 +22,7 @@ from app.schemas.collaboration import (
CollaborationShareLinkRead,
CollaborationShareLinkUpdate,
)
from app.services import collaboration_service
from app.services import collaboration_service, ledger_access
SHARE_PATH = "/collaboration/share"
@@ -177,6 +177,11 @@ async def update_share_link(
link.failed_attempts = 0
link.last_failed_at = None
link.locked_until = None
if ledger_access.is_ledger(item):
# Ledger routes hold the same file lock as callbacks before changing a link.
item.generation += 1
item.updated_at = now
link.token_version += 1
await db.commit()
await db.refresh(link)
return share_link_read(link)
+41
View File
@@ -0,0 +1,41 @@
"""Account-level ledger permissions, independent of study membership."""
from sqlalchemy import select
from fastapi import HTTPException
from app.core.deps import is_system_admin
from app.models.collaboration import CollaborationMember
def is_ledger(item) -> bool:
return getattr(item, "scope", "PROJECT") == "LEDGER"
async def role_for(db, item, user) -> str | None:
if not user or not user.is_active:
return None
if is_system_admin(user) or item.owner_id == user.id:
return "MANAGER"
return await db.scalar(select(CollaborationMember.role).where(
CollaborationMember.file_id == item.id, CollaborationMember.user_id == user.id,
))
async def can_edit(db, item, user) -> bool:
return item.status == "ACTIVE" and await role_for(db, item, user) in {"EDITOR", "MANAGER"}
async def can_manage(db, item, user) -> bool:
return await role_for(db, item, user) == "MANAGER"
async def can_export(db, item, user) -> bool:
role = await role_for(db, item, user)
return role == "MANAGER" or (role in {"EDITOR", "VIEWER"} and item.allow_export)
async def require_access(db, item, user, *, manage=False):
role = await role_for(db, item, user)
if role not in {"VIEWER", "EDITOR", "MANAGER"}:
raise HTTPException(404, "台账不存在或未获授权")
if manage and role != "MANAGER":
raise HTTPException(403, "仅台账管理人员可以执行此操作")
+259
View File
@@ -0,0 +1,259 @@
"""Global ledger metadata and grants; reuse collaboration revisions and sessions."""
import io
import uuid
import zipfile
from datetime import datetime, timezone
from pathlib import Path
import aiofiles
from fastapi import HTTPException
from sqlalchemy import or_, select, text
from app.core.config import settings
from app.core.deps import is_system_admin
from app.models.collaboration import CollaborationFile, CollaborationMember, CollaborationRevision
from app.models.user import User, UserStatus
from app.schemas.ledger import LedgerRead, LedgerSettings
from app.services import collaboration_service as collaboration
from app.services import ledger_access
LEDGER_TEMPLATES = (
("ledger-1", "医学事务部合同台账明细表.xlsx", "contract-ledger.xlsx", "记录合同编号、项目代码与合同内容"),
("ledger-2", "医学事务部临床运营项目编号.xlsx", "clinical-project-register.xlsx", "记录项目编号、产品信息与生效日期"),
)
TEMPLATE_ROOT = Path(__file__).resolve().parents[1] / "templates" / "ledgers"
async def get_ledger(db, ledger_id, user, *, manage=False, lock=False):
stmt = select(CollaborationFile).where(
CollaborationFile.id == ledger_id, CollaborationFile.scope == "LEDGER",
).execution_options(populate_existing=True)
if lock:
stmt = stmt.with_for_update()
item = await db.scalar(stmt)
if not item:
raise HTTPException(404, "台账不存在或未获授权")
await ledger_access.require_access(db, item, user, manage=manage)
return item
async def read_ledger(db, item, user):
role = await ledger_access.role_for(db, item, user)
await ledger_access.require_access(db, item, user)
owner = await db.get(User, item.owner_id)
revision = await db.get(CollaborationRevision, item.current_revision_id) if item.current_revision_id else None
return LedgerRead(
id=item.id, title=item.title, description=item.description, status=item.status,
owner_id=item.owner_id, owner_name=owner.full_name if owner else "",
generation=item.generation, updated_at=item.updated_at,
current_revision_id=item.current_revision_id,
current_revision_no=revision.revision_no if revision else None,
current_revision_file_size=revision.file_size if revision else None,
current_revision_mime_type=revision.mime_type if revision else None,
current_revision_created_at=revision.created_at if revision else None,
role=role, can_edit=await ledger_access.can_edit(db, item, user),
can_manage=role == "MANAGER", can_export=await ledger_access.can_export(db, item, user),
allow_export=item.allow_export, allow_sheet_structure_edit=item.allow_sheet_structure_edit,
allow_edit_request=item.allow_edit_request,
can_request_edit=await collaboration.can_request_edit_file(db, item, user),
edit_request_status=await collaboration.edit_request_status(db, item, user.id),
can_transfer_ownership=await collaboration.can_transfer_ownership(db, item, user),
)
async def list_ledgers(db, user):
stmt = select(CollaborationFile).where(CollaborationFile.scope == "LEDGER")
if not is_system_admin(user):
granted = select(CollaborationMember.file_id).where(
CollaborationMember.user_id == user.id,
CollaborationMember.role.in_(["VIEWER", "EDITOR", "MANAGER"]),
)
stmt = stmt.where(or_(CollaborationFile.owner_id == user.id, CollaborationFile.id.in_(granted)))
rows = (await db.scalars(stmt.order_by(CollaborationFile.ledger_key))).all()
return [await read_ledger(db, item, user) for item in rows]
async def initialize_ledgers(db, user):
if not is_system_admin(user):
raise HTTPException(403, "仅系统管理员可以初始化台账")
# Serialize initialization across administrators; unique keys also prevent duplicates.
if db.get_bind().dialect.name == "postgresql":
await db.execute(text("SELECT pg_advisory_xact_lock(2026090301)"))
for key, title, template_name, description in LEDGER_TEMPLATES:
if await db.scalar(select(CollaborationFile.id).where(CollaborationFile.ledger_key == key)):
continue
item = CollaborationFile(
id=uuid.uuid4(), scope="LEDGER", ledger_key=key, study_id=None, folder_id=None,
title=title, description=description, file_type="cell", extension="xlsx", owner_id=user.id,
status="ACTIVE", generation=1, allow_sheet_structure_edit=False, allow_export=False,
)
db.add(item)
await db.flush()
content, backup = collaboration.apply_workbook_structure_policy(
(TEMPLATE_ROOT / template_name).read_bytes(), file_id=item.id,
allow_sheet_structure_edit=False, protection_backup=None,
)
item.sheet_structure_protection_backup = backup
await collaboration.append_revision(db, item, content, source="CREATE", created_by=user.id)
await db.commit()
return await list_ledgers(db, user)
def check_generation(item, generation):
if item.generation != generation:
raise HTTPException(409, "台账已发生变化,请刷新后重试")
def advance_generation(item):
item.generation += 1
item.updated_at = datetime.now(timezone.utc)
async def active_user(db, user_id):
user = await db.get(User, user_id)
if not user or not user.is_active:
raise HTTPException(422, "请选择有效的系统账号")
return user
async def update_settings(db, item, payload, user):
await ledger_access.require_access(db, item, user, manage=True)
check_generation(item, payload.generation)
if payload.owner_id != item.owner_id:
if not is_system_admin(user):
raise HTTPException(403, "仅系统管理员可以变更台账负责人")
await active_user(db, payload.owner_id)
if payload.allow_sheet_structure_edit != item.allow_sheet_structure_edit:
revision = await db.get(CollaborationRevision, item.current_revision_id)
if not revision or not Path(revision.file_uri).is_file():
raise HTTPException(404, "台账内容不存在")
async with aiofiles.open(revision.file_uri, "rb") as stream:
content = await stream.read(settings.COLLABORATION_MAX_FILE_BYTES + 1)
content, backup = collaboration.apply_workbook_structure_policy(
content, file_id=item.id, allow_sheet_structure_edit=payload.allow_sheet_structure_edit,
protection_backup=item.sheet_structure_protection_backup,
)
item.sheet_structure_protection_backup = backup
await collaboration.append_revision(db, item, content, source="PERMISSION_CHANGE", created_by=user.id)
item.title = collaboration._safe_title(payload.title, "xlsx")
item.description = payload.description.strip()
item.status = payload.status
item.owner_id = payload.owner_id
item.allow_export = payload.allow_export
if payload.allow_edit_request is not None:
item.allow_edit_request = payload.allow_edit_request
item.allow_sheet_structure_edit = payload.allow_sheet_structure_edit
advance_generation(item)
await db.commit()
return await read_ledger(db, item, user)
async def update_access_settings(db, item, payload, user):
values = dict(title=item.title, description=item.description, status=item.status,
owner_id=item.owner_id, allow_export=item.allow_export,
allow_edit_request=item.allow_edit_request,
allow_sheet_structure_edit=item.allow_sheet_structure_edit)
values.update(payload.model_dump(exclude_none=True))
return await update_settings(db, item, LedgerSettings(**values), user)
async def list_members(db, item):
rows = (await db.execute(select(CollaborationMember, User).join(
User, User.id == CollaborationMember.user_id,
).where(CollaborationMember.file_id == item.id).order_by(User.full_name))).all()
grants = {user.id: dict(user_id=user.id, full_name=user.full_name, email=user.email,
role=member.role, is_active=user.is_active) for member, user in rows}
implicit = (await db.scalars(select(User).where(or_(
User.id == item.owner_id,
(User.is_admin.is_(True)) & (User.status == UserStatus.ACTIVE),
)).order_by(User.full_name))).all()
for user in implicit:
grants[user.id] = dict(user_id=user.id, full_name=user.full_name, email=user.email,
role="MANAGER", is_active=user.is_active,
protected_label="所有者" if user.id == item.owner_id else "系统管理员")
return sorted(grants.values(), key=lambda member: (
member["user_id"] != item.owner_id, member["role"] != "MANAGER", member["full_name"],
))
async def set_member(db, item, payload, user):
await ledger_access.require_access(db, item, user, manage=True)
target = await active_user(db, payload.user_id)
if target.id == item.owner_id or is_system_admin(target):
raise HTTPException(422, "负责人和系统管理员已拥有管理权限")
member = await db.scalar(select(CollaborationMember).where(
CollaborationMember.file_id == item.id, CollaborationMember.user_id == target.id,
))
if member and member.role == payload.role:
return
if member:
member.role = payload.role
else:
db.add(CollaborationMember(file_id=item.id, user_id=target.id, role=payload.role, invited_by=user.id))
advance_generation(item)
await db.commit()
async def remove_member(db, item, user_id, user):
await ledger_access.require_access(db, item, user, manage=True)
if user_id == item.owner_id:
raise HTTPException(422, "不能移除台账负责人")
target = await db.get(User, user_id)
if target and is_system_admin(target):
raise HTTPException(422, "系统管理员始终拥有管理权限")
member = await db.scalar(select(CollaborationMember).where(
CollaborationMember.file_id == item.id, CollaborationMember.user_id == user_id,
))
if member:
await db.delete(member)
advance_generation(item)
await db.commit()
async def candidates(db, keyword):
stmt = select(User).where(User.status == UserStatus.ACTIVE)
if keyword:
pattern = f"%{keyword}%"
stmt = stmt.where(or_(User.full_name.ilike(pattern), User.email.ilike(pattern)))
rows = (await db.scalars(stmt.order_by(User.full_name, User.id))).all()
return [dict(user_id=user.id, full_name=user.full_name, email=user.email,
role_in_study="系统管理员" if user.is_admin else "系统账号") for user in rows]
async def import_content(db, item, upload, generation, user):
await ledger_access.require_access(db, item, user, manage=True)
check_generation(item, generation)
if item.status != "ACTIVE":
raise HTTPException(409, "请先启用台账再导入")
if Path(upload.filename or "").suffix.lower() != ".xlsx":
raise HTTPException(415, "台账仅支持 XLSX 文件")
content = await upload.read(settings.COLLABORATION_MAX_FILE_BYTES + 1)
if not content or len(content) > settings.COLLABORATION_MAX_FILE_BYTES:
raise HTTPException(413, "台账文件为空或超出大小限制")
try:
with zipfile.ZipFile(io.BytesIO(content)) as package:
if "xl/workbook.xml" not in package.namelist():
raise ValueError()
if sum(info.file_size for info in package.infolist()) > settings.COLLABORATION_MAX_FILE_BYTES * 20:
raise ValueError()
if any("vbaproject" in name.lower() for name in package.namelist()):
raise ValueError()
except (zipfile.BadZipFile, ValueError):
raise HTTPException(422, "XLSX 文件无效、包含宏或解压后过大")
content, backup = collaboration.apply_workbook_structure_policy(
content, file_id=item.id, allow_sheet_structure_edit=item.allow_sheet_structure_edit,
protection_backup=None,
)
item.sheet_structure_protection_backup = backup
await collaboration.append_revision(db, item, content, source="IMPORT", created_by=user.id)
advance_generation(item)
await db.commit()
return await read_ledger(db, item, user)
async def restore_revision(db, item, revision_id, generation, user):
await ledger_access.require_access(db, item, user, manage=True)
check_generation(item, generation)
if item.status != "ACTIVE":
raise HTTPException(409, "请先启用台账再恢复版本")
return await collaboration.restore_revision(db, item, revision_id, user, None)
@@ -25,7 +25,7 @@ from app.models.collaboration import (
)
from app.models.user import User
from app.schemas.collaboration import CollaborationCallbackPayload, CollaborationEditorConfigRead
from app.services import collaboration_service, onlyoffice_service
from app.services import collaboration_service, ledger_access, onlyoffice_service
def collaboration_document_key(file_id: uuid.UUID, generation: int) -> str:
@@ -81,6 +81,8 @@ async def _active_session(
async def build_editor_config(
db: AsyncSession, item: CollaborationFile, user
) -> CollaborationEditorConfigRead:
if ledger_access.is_ledger(item):
await ledger_access.require_access(db, item, user)
await onlyoffice_service.ensure_onlyoffice_available()
revision = await db.get(CollaborationRevision, item.current_revision_id)
if not revision or not Path(revision.file_uri).exists():
@@ -158,6 +160,15 @@ async def build_shared_editor_config(
client_id: str,
display_name: str,
) -> CollaborationEditorConfigRead:
if ledger_access.is_ledger(item):
version = link.token_version
item = await db.scalar(select(CollaborationFile).where(
CollaborationFile.id == item.id,
).with_for_update().execution_options(populate_existing=True))
await db.refresh(link)
if (not item or item.status != "ACTIVE" or not link.enabled or link.token_version != version
or (link.expires_at and link.expires_at <= datetime.now(timezone.utc))):
raise HTTPException(404, "共享链接不存在或已失效")
await onlyoffice_service.ensure_onlyoffice_available()
revision = await db.get(CollaborationRevision, item.current_revision_id)
if not revision or not Path(revision.file_uri).exists():
@@ -169,6 +180,10 @@ async def build_shared_editor_config(
if link.expires_at and link.expires_at < expires_at:
expires_at = link.expires_at
external_user_id = f"share-{link.id.hex[:12]}-{client_id[:32]}"
if ledger_access.is_ledger(item):
known_users = json.loads(session.active_users or "[]")
session.active_users = json.dumps(list(dict.fromkeys([*known_users, external_user_id])))
await db.commit()
config: dict[str, Any] = {
"type": "desktop",
"documentType": item.file_type,
@@ -234,6 +249,8 @@ async def get_session_content(
item = await db.get(CollaborationFile, session.file_id)
if not revision or not item or not Path(revision.file_uri).exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件内容不存在")
if ledger_access.is_ledger(item) and session.generation != item.generation:
raise HTTPException(403, "台账会话已失效,请重新打开")
return revision, item
@@ -326,7 +343,7 @@ async def _callback_user(
db: AsyncSession, payload: CollaborationCallbackPayload, session: CollaborationSession
) -> User | None:
has_public_share_user = False
for value in payload.users:
for value in payload.users or json.loads(session.active_users or "[]"):
if value.startswith("share-"):
has_public_share_user = True
continue
@@ -344,13 +361,40 @@ async def _callback_user(
return user
async def _ledger_callback_can_edit(db, item, payload, session):
users = payload.users or json.loads(session.active_users or "[]")
if not users:
return await ledger_access.can_edit(db, item, await _callback_user(db, payload, session))
for value in users:
try:
actor = await db.get(User, uuid.UUID(value))
except (ValueError, TypeError):
continue
if await ledger_access.can_edit(db, item, actor):
return True
link = await db.scalar(select(CollaborationShareLink).where(
CollaborationShareLink.file_id == item.id,
CollaborationShareLink.enabled.is_(True),
CollaborationShareLink.access_mode == "EDIT",
))
if not link or (link.expires_at and link.expires_at <= datetime.now(timezone.utc)):
return False
prefix = f"share-{link.id.hex[:12]}-"
return any(value.startswith(prefix) for value in users)
async def process_callback(
db: AsyncSession,
session_id: uuid.UUID,
payload: CollaborationCallbackPayload,
) -> dict[str, int]:
# Lock the file before writing sessions, matching editor initialization and
# ledger permission changes. This also serializes callbacks across generations.
session = await db.scalar(
select(CollaborationSession).where(CollaborationSession.id == session_id).with_for_update()
select(CollaborationSession)
.join(CollaborationFile, CollaborationFile.id == CollaborationSession.file_id)
.where(CollaborationSession.id == session_id)
.with_for_update(of=CollaborationFile)
)
if not session:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作会话不存在")
@@ -367,8 +411,12 @@ async def process_callback(
item = await db.get(CollaborationFile, session.file_id)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件不存在")
if ledger_access.is_ledger(item):
# Same lock as grant/settings changes: a stale callback cannot race a revocation.
item = await db.scalar(select(CollaborationFile).where(
CollaborationFile.id == item.id,
).with_for_update().execution_options(populate_existing=True))
session.last_callback_at = datetime.now(timezone.utc)
session.active_users = json.dumps(payload.users, ensure_ascii=True)
result = "ACKNOWLEDGED"
saved_revision_id = None
@@ -377,6 +425,10 @@ async def process_callback(
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ONLYOFFICE 保存回调缺少文件地址")
if session.generation != item.generation:
result = "STALE"
elif ledger_access.is_ledger(item) and (
item.status != "ACTIVE" or not await _ledger_callback_can_edit(db, item, payload, session)
):
result = "ACCESS_REVOKED"
else:
content = await _download_result(payload.url)
actor = await _callback_user(db, payload, session)
@@ -404,6 +456,8 @@ async def process_callback(
session.status = "ERROR"
result = "ERROR"
if payload.users or not ledger_access.is_ledger(item):
session.active_users = json.dumps(payload.users, ensure_ascii=True)
db.add(CollaborationCallbackReceipt(
session_id=session.id,
fingerprint=fingerprint,
Binary file not shown.
+463
View File
@@ -0,0 +1,463 @@
import io
import uuid
import zipfile
from datetime import timezone
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
import httpx
import pytest
import pytest_asyncio
from fastapi import FastAPI, HTTPException, UploadFile
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.types import DateTime, TypeDecorator
from conftest import _create_test_engine
from app.api.v1.ledgers import router
from app.core.config import settings
from app.core.deps import get_current_user, get_db_session
from app.models.collaboration import CollaborationCallbackReceipt, CollaborationFile, CollaborationRevision, CollaborationSession, CollaborationShareLink
from app.models.user import User, UserStatus
from app.schemas.collaboration import CollaborationCallbackPayload
from app.schemas.ledger import LedgerMemberUpsert, LedgerSettings
from app.services import collaboration_service as collaboration
from app.services import ledger_service as ledgers, onlyoffice_collaboration_service as office, onlyoffice_service
class SQLiteUTCDateTime(TypeDecorator):
"""Preserve the PostgreSQL UTC timestamp contract in the SQLite fixture."""
impl = DateTime(timezone=True)
cache_ok = True
def process_result_value(self, value, dialect):
return value.replace(tzinfo=timezone.utc) if value and value.tzinfo is None else value
@pytest_asyncio.fixture
async def env(monkeypatch, tmp_path):
for column in CollaborationShareLink.__table__.columns:
if isinstance(column.type, DateTime) and column.type.timezone:
monkeypatch.setattr(column, "type", SQLiteUTCDateTime())
engine = await _create_test_engine()
monkeypatch.setattr(collaboration, "COLLABORATION_ROOT", tmp_path)
monkeypatch.setattr(settings, "ONLYOFFICE_JWT_SECRET", "ledger-test-secret-long-enough-for-tests")
monkeypatch.setattr(onlyoffice_service, "ensure_onlyoffice_available", AsyncMock())
async with AsyncSession(engine, expire_on_commit=False) as db:
users = [User(id=uuid.uuid4(), email=f"ledger-{i}@example.com", password_hash="hash",
full_name=f"Ledger user {i}", clinical_department="test", is_admin=i == 0,
status=UserStatus.ACTIVE) for i in range(4)]
db.add_all(users)
await db.commit()
initial = await ledgers.initialize_ledgers(db, users[0])
app = FastAPI()
app.include_router(router, prefix="/api/v1/ledgers")
identity = SimpleNamespace(user=users[0])
app.dependency_overrides[get_current_user] = lambda: identity.user
app.dependency_overrides[get_db_session] = lambda: db
async with httpx.AsyncClient(app=app, base_url="http://test") as client:
yield SimpleNamespace(db=db, admin=users[0], editor=users[1], viewer=users[2], outsider=users[3],
initial=initial, identity=identity, client=client, app=app)
await engine.dispose()
async def grant(env, user, role, index=0):
item = await ledgers.get_ledger(env.db, env.initial[index].id, env.admin, manage=True, lock=True)
await ledgers.set_member(env.db, item, LedgerMemberUpsert(user_id=user.id, role=role), env.admin)
return item
@pytest.mark.asyncio
async def test_initialization_preserves_both_uploaded_templates_and_is_idempotent(env):
repeated = await ledgers.initialize_ledgers(env.db, env.admin)
assert [r.id for r in repeated] == [r.id for r in env.initial]
for result, (_, title, filename, _) in zip(repeated, ledgers.LEDGER_TEMPLATES):
item = await env.db.get(CollaborationFile, result.id)
assert item.study_id is None and item.folder_id is None and item.scope == "LEDGER"
assert item.title == title
revision = await env.db.get(CollaborationRevision, item.current_revision_id)
with zipfile.ZipFile(ledgers.TEMPLATE_ROOT / filename) as original, zipfile.ZipFile(revision.file_uri) as saved:
assert original.namelist() == saved.namelist()
for part in original.namelist():
if part != "xl/workbook.xml":
assert original.read(part) == saved.read(part)
assert b'lockStructure="1"' in saved.read("xl/workbook.xml")
assert "ledgers" in Path(revision.file_uri).parts
@pytest.mark.asyncio
async def test_account_grants_are_independent_and_all_file_routes_require_access(env):
await grant(env, env.editor, "EDITOR")
env.identity.user = env.editor
response = await env.client.get("/api/v1/ledgers")
assert response.status_code == 200 and response.headers["cache-control"] == "no-store"
assert [row["id"] for row in response.json()] == [str(env.initial[0].id)]
assert response.json()[0]["can_edit"] is True
other = env.initial[1].id
for suffix in ["", "/editor-config", "/revisions", "/members", "/candidates"]:
assert (await env.client.get(f"/api/v1/ledgers/{other}{suffix}")).status_code == 404
assert (await env.client.post("/api/v1/ledgers/initialize")).status_code == 403
env.identity.user = env.outsider
assert (await env.client.get("/api/v1/ledgers")).json() == []
env.app.dependency_overrides.pop(get_current_user)
assert (await env.client.get("/api/v1/ledgers")).status_code == 401
@pytest.mark.asyncio
async def test_editor_and_viewer_share_session_but_receive_distinct_signed_permissions(env):
item = await grant(env, env.editor, "EDITOR")
await grant(env, env.viewer, "VIEWER")
editor = await office.build_editor_config(env.db, item, env.editor)
viewer = await office.build_editor_config(env.db, item, env.viewer)
assert editor.config["document"]["key"] == viewer.config["document"]["key"]
assert editor.access_mode == "edit" and viewer.access_mode == "view"
assert editor.config["document"]["permissions"]["edit"] is True
assert viewer.config["document"]["permissions"]["edit"] is False
assert not editor.can_save_as and not editor.can_request_edit and not editor.can_download
env.identity.user = env.editor
assert (await env.client.get(f"/api/v1/ledgers/{item.id}/members")).status_code == 403
assert (await env.client.post(f"/api/v1/ledgers/{item.id}/downloads", json={"file_type": "xlsx"})).status_code == 403
with pytest.raises(HTTPException) as error:
await collaboration.restore_revision(env.db, item, item.current_revision_id, env.editor, None)
assert error.value.status_code == 403
@pytest.mark.asyncio
async def test_neither_editors_nor_admins_can_delete_ledger_or_revision(env):
item = await grant(env, env.editor, "EDITOR")
for user in [env.admin, env.editor]:
env.identity.user = user
assert (await env.client.delete(f"/api/v1/ledgers/{item.id}")).status_code == 405
with pytest.raises(HTTPException) as error:
await collaboration.move_to_trash(env.db, item, user)
assert error.value.status_code == 403
with pytest.raises(HTTPException) as error:
await collaboration.delete_revision(env.db, item, item.current_revision_id, user)
assert error.value.status_code == 403
@pytest.mark.asyncio
async def test_revocation_invalidates_old_callback_before_result_download(env, monkeypatch):
item = await grant(env, env.editor, "EDITOR")
config = await office.build_editor_config(env.db, item, env.editor)
session = await env.db.scalar(select(CollaborationSession).where(CollaborationSession.file_id == item.id))
baseline = item.current_revision_id
download = AsyncMock(return_value=b"should never be downloaded")
monkeypatch.setattr(office, "_download_result", download)
await ledgers.remove_member(env.db, item, env.editor.id, env.admin)
result = await office.process_callback(env.db, session.id, CollaborationCallbackPayload(
key=config.config["document"]["key"], status=6, url="http://onlyoffice/result.xlsx", users=[str(env.editor.id)],
))
assert result == {"error": 0} and item.current_revision_id == baseline
download.assert_not_awaited()
receipt = await env.db.scalar(select(CollaborationCallbackReceipt).where(CollaborationCallbackReceipt.session_id == session.id))
assert receipt.result == "STALE"
@pytest.mark.asyncio
async def test_saved_callback_is_versioned_and_duplicate_is_idempotent(env, monkeypatch):
item = await grant(env, env.editor, "EDITOR")
config = await office.build_editor_config(env.db, item, env.editor)
session = await env.db.scalar(select(CollaborationSession).where(CollaborationSession.file_id == item.id))
monkeypatch.setattr(office, "_download_result", AsyncMock(return_value=collaboration.blank_file_bytes("cell")))
payload = CollaborationCallbackPayload(key=config.config["document"]["key"], status=6,
url="http://onlyoffice/result.xlsx", users=[str(env.editor.id)])
await office.process_callback(env.db, session.id, payload)
saved = item.current_revision_id
await office.process_callback(env.db, session.id, payload)
assert item.current_revision_id == saved
assert len(await collaboration.list_revisions(env.db, item)) == 2
@pytest.mark.asyncio
async def test_archive_disables_edit_and_stale_settings_are_rejected(env):
item = await grant(env, env.editor, "EDITOR")
payload = LedgerSettings(title=item.title, description="Archived", status="ARCHIVED", owner_id=item.owner_id,
allow_export=False, allow_sheet_structure_edit=False, generation=item.generation)
await ledgers.update_settings(env.db, item, payload, env.admin)
assert not (await ledgers.read_ledger(env.db, item, env.editor)).can_edit
assert (await office.build_editor_config(env.db, item, env.editor)).access_mode == "view"
with pytest.raises(HTTPException) as error:
await ledgers.update_settings(env.db, item, payload, env.admin)
assert error.value.status_code == 409
@pytest.mark.asyncio
async def test_import_and_restore_keep_previous_versions_and_workbook_protection(env):
item = await ledgers.get_ledger(env.db, env.initial[0].id, env.admin, manage=True)
first = item.current_revision_id
upload = UploadFile(filename="import.xlsx", file=io.BytesIO(collaboration.blank_file_bytes("cell")))
await ledgers.import_content(env.db, item, upload, item.generation, env.admin)
second = item.current_revision_id
await ledgers.restore_revision(env.db, item, first, item.generation, env.admin)
assert item.current_revision_id not in {first, second}
assert len(await collaboration.list_revisions(env.db, item)) == 3
revision = await env.db.get(CollaborationRevision, item.current_revision_id)
with zipfile.ZipFile(revision.file_uri) as package:
assert b'lockStructure="1"' in package.read("xl/workbook.xml")
@pytest.mark.asyncio
async def test_inactive_account_cannot_be_granted_or_save_existing_session(env, monkeypatch):
item = await grant(env, env.editor, "EDITOR")
await office.build_editor_config(env.db, item, env.editor)
session = await env.db.scalar(select(CollaborationSession).where(CollaborationSession.file_id == item.id))
env.editor.status = UserStatus.DISABLED
await env.db.commit()
with pytest.raises(HTTPException) as error:
await ledgers.set_member(env.db, item, LedgerMemberUpsert(user_id=env.editor.id, role="MANAGER"), env.admin)
assert error.value.status_code == 422
download = AsyncMock()
monkeypatch.setattr(office, "_download_result", download)
await office.process_callback(env.db, session.id, CollaborationCallbackPayload(
key=session.document_key, status=6, url="http://onlyoffice/result.xlsx", users=[str(env.editor.id)],
))
download.assert_not_awaited()
@pytest.mark.asyncio
async def test_restore_respects_current_unlocked_workbook_policy(env):
item = await ledgers.get_ledger(env.db, env.initial[0].id, env.admin, manage=True)
initial = item.current_revision_id
payload = LedgerSettings(title=item.title, status="ACTIVE", owner_id=item.owner_id,
allow_sheet_structure_edit=True, generation=item.generation)
await ledgers.update_settings(env.db, item, payload, env.admin)
await ledgers.import_content(env.db, item, UploadFile(filename="blank.xlsx", file=io.BytesIO(
collaboration.blank_file_bytes("cell"))), item.generation, env.admin)
await ledgers.restore_revision(env.db, item, initial, item.generation, env.admin)
revision = await env.db.get(CollaborationRevision, item.current_revision_id)
with zipfile.ZipFile(revision.file_uri) as package:
assert b"workbookProtection" not in package.read("xl/workbook.xml")
@pytest.mark.asyncio
async def test_shared_access_uses_global_candidates_and_protects_implicit_managers(env):
item = await grant(env, env.editor, "EDITOR")
base = f"/api/v1/ledgers/{item.id}"
members = (await env.client.get(f"{base}/members")).json()
owner = next(row for row in members if row["user_id"] == str(env.admin.id))
assert owner["protected_label"] == "所有者" and owner["role"] == "MANAGER"
assert (await env.client.delete(f"{base}/members/{env.admin.id}")).status_code == 422
candidates = (await env.client.get(f"{base}/candidates")).json()
assert {row["user_id"] for row in candidates} == {str(user.id) for user in [env.admin, env.editor, env.viewer, env.outsider]}
assert all(row["can_be_manager"] and row["can_be_editor"] for row in candidates)
original_title = item.title
result = await env.client.patch(f"{base}/access-settings", json={"generation": item.generation, "allow_edit_request": False})
assert result.status_code == 200 and not result.json()["allow_edit_request"]
assert result.json()["title"] == original_title and not result.json()["allow_export"]
for actor, code in [(env.editor, 403), (env.outsider, 404)]:
env.identity.user = actor
for suffix in ["share-link", "edit-requests", "members", "candidates"]:
assert (await env.client.get(f"{base}/{suffix}")).status_code == code
assert (await env.client.patch(f"{base}/access-settings", json={"generation": item.generation, "allow_export": True})).status_code == code
assert (await env.client.put(f"{base}/share-link", json={"enabled": True})).status_code == code
assert (await env.client.post(f"{base}/transfer-ownership", json={"new_owner_id": str(env.viewer.id)})).status_code == code
@pytest.mark.asyncio
async def test_viewer_edit_request_is_deduplicated_and_approval_grants_editor(env):
item = await grant(env, env.viewer, "VIEWER")
base = f"/api/v1/ledgers/{item.id}"
assert (await env.client.patch(f"{base}/access-settings", json={"generation": item.generation, "allow_edit_request": True})).status_code == 200
env.identity.user = env.viewer
assert (await env.client.get(base)).json()["can_request_edit"]
request = await env.client.post(f"{base}/edit-requests")
assert request.status_code == 200
request_id = request.json()["id"]
assert (await env.client.post(f"{base}/edit-requests")).json()["id"] == request_id
read = (await env.client.get(base)).json()
assert read["edit_request_status"] == "PENDING" and not read["can_request_edit"]
assert (await env.client.post(f"{base}/edit-requests/{request_id}/resolve", json={"status": "APPROVED"})).status_code == 403
generation = item.generation
env.identity.user = env.admin
assert len((await env.client.get(f"{base}/edit-requests")).json()) == 1
result = await env.client.post(f"{base}/edit-requests/{request_id}/resolve", json={"status": "APPROVED"})
assert result.status_code == 200 and result.json()["status"] == "APPROVED"
env.identity.user = env.viewer
read = (await env.client.get(base)).json()
assert read["role"] == "EDITOR" and read["can_edit"] and read["generation"] == generation + 1
assert not read["can_request_edit"]
@pytest.mark.asyncio
async def test_revoked_requester_cannot_regain_access_through_pending_approval(env):
item = await grant(env, env.viewer, "VIEWER")
base = f"/api/v1/ledgers/{item.id}"
assert (await env.client.patch(f"{base}/access-settings", json={"generation": item.generation, "allow_edit_request": True})).status_code == 200
env.identity.user = env.viewer
request_id = (await env.client.post(f"{base}/edit-requests")).json()["id"]
await ledgers.remove_member(env.db, item, env.viewer.id, env.admin)
env.identity.user = env.admin
assert (await env.client.post(f"{base}/edit-requests/{request_id}/resolve", json={"status": "APPROVED"})).status_code == 422
assert not await collaboration.can_edit_file(env.db, item, env.viewer)
@pytest.mark.asyncio
async def test_ownership_transfer_accepts_system_accounts_and_keeps_previous_owner_access(env):
item = await grant(env, env.editor, "MANAGER")
base = f"/api/v1/ledgers/{item.id}"
env.identity.user = env.editor
assert (await env.client.post(f"{base}/transfer-ownership", json={"new_owner_id": str(env.viewer.id)})).status_code == 403
env.identity.user = env.admin
assert (await env.client.post(f"{base}/transfer-ownership", json={"new_owner_id": str(env.viewer.id)})).status_code == 200
env.identity.user = env.viewer
result = await env.client.post(f"{base}/transfer-ownership", json={"new_owner_id": str(env.outsider.id)})
assert result.status_code == 200 and result.json()["owner_id"] == str(env.outsider.id)
assert result.json()["can_manage"] and not result.json()["can_transfer_ownership"]
assert item.study_id is None
members = (await env.client.get(f"{base}/members")).json()
admin = next(row for row in members if row["user_id"] == str(env.admin.id))
assert admin["protected_label"] == "系统管理员"
assert (await env.client.delete(f"{base}/members/{env.admin.id}")).status_code == 422
async def shared_ledger(env, *, mode="EDIT", password=None):
from app.models.collaboration import CollaborationShareLink
item = await ledgers.get_ledger(env.db, env.initial[0].id, env.admin)
response = await env.client.put(f"/api/v1/ledgers/{item.id}/share-link", json={
"enabled": True, "access_mode": mode, "expiry_policy": "SEVEN_DAYS",
"password_mode": "SET" if password else "KEEP", **({"password": password} if password else {}),
})
assert response.status_code == 200
link = await env.db.get(CollaborationShareLink, uuid.UUID(response.json()["id"]))
return item, link, response.json()["share_token"]
@pytest.mark.asyncio
async def test_ledger_share_defaults_closed_and_password_change_revokes_old_credentials(env):
from app.services import collaboration_share_service as shares
base = f"/api/v1/ledgers/{env.initial[0].id}"
initial = (await env.client.get(f"{base}/share-link")).json()
assert not initial["enabled"] and initial["share_token"] is None
item, link, token = await shared_ledger(env, password="test-password")
grant_token = await shares.verify_share_password(env.db, token, "test-password")
shares.validate_access_grant(link, grant_token.access_token)
with pytest.raises(HTTPException) as wrong:
await shares.verify_share_password(env.db, token, "incorrect")
assert wrong.value.status_code == 401
generation = item.generation
await env.client.put(f"{base}/share-link", json={"enabled": True, "password_mode": "SET", "password": "new-password"})
assert item.generation == generation + 1
with pytest.raises(HTTPException):
await shares.resolve_active_share(env.db, token)
with pytest.raises(HTTPException):
shares.validate_access_grant(link, grant_token.access_token)
@pytest.mark.asyncio
async def test_anonymous_ledger_save_keeps_share_identity_and_close_blocks_stale_callback(env, monkeypatch):
item, link, _ = await shared_ledger(env)
config = await office.build_shared_editor_config(env.db, item, link, client_id="test-client", display_name="访客")
assert config.access_mode == "edit" and not config.can_save_as and not config.can_download
session = await env.db.scalar(select(CollaborationSession).where(CollaborationSession.file_id == item.id))
download = AsyncMock(return_value=collaboration.blank_file_bytes("cell"))
monkeypatch.setattr(office, "_download_result", download)
# ONLYOFFICE can send a final save with an empty user list. It must not become an owner save.
await office.process_callback(env.db, session.id, CollaborationCallbackPayload(
key=session.document_key, status=6, url="http://onlyoffice/first.xlsx", users=[],
))
revision = await env.db.get(CollaborationRevision, item.current_revision_id)
assert revision.source == "SHARE_FORCE_SAVE" and revision.created_by is None
saved = item.current_revision_id
await env.client.put(f"/api/v1/ledgers/{item.id}/share-link", json={"enabled": False})
download.reset_mock()
await office.process_callback(env.db, session.id, CollaborationCallbackPayload(
key=session.document_key, status=2, url="http://onlyoffice/after-close.xlsx", users=[],
))
download.assert_not_awaited()
assert item.current_revision_id == saved
with pytest.raises(HTTPException):
await office.build_shared_editor_config(env.db, item, link, client_id="new-client", display_name="访客")
@pytest.mark.asyncio
@pytest.mark.parametrize("mode,expired", [("VIEW", False), ("EDIT", True)])
async def test_anonymous_view_or_expired_link_cannot_save_even_without_callback_users(env, monkeypatch, mode, expired):
from datetime import datetime, timedelta, timezone
item, link, _ = await shared_ledger(env, mode=mode)
await office.build_shared_editor_config(env.db, item, link, client_id="test-client", display_name="访客")
session = await env.db.scalar(select(CollaborationSession).where(CollaborationSession.file_id == item.id))
if expired:
link.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
await env.db.commit()
saved = item.current_revision_id
download = AsyncMock()
monkeypatch.setattr(office, "_download_result", download)
await office.process_callback(env.db, session.id, CollaborationCallbackPayload(
key=session.document_key, status=6, url="http://onlyoffice/denied.xlsx", users=[],
))
download.assert_not_awaited()
assert item.current_revision_id == saved
receipt = await env.db.scalar(select(CollaborationCallbackReceipt).where(CollaborationCallbackReceipt.session_id == session.id))
assert receipt.result == "ACCESS_REVOKED"
@pytest.mark.asyncio
async def test_ledger_uses_existing_public_share_http_flow_without_a_project(env):
from app.api.v1.collaboration import public_router
env.app.include_router(public_router, prefix="/api/v1/collaboration/shares")
item, _, token = await shared_ledger(env, password="share-password")
env.app.dependency_overrides.pop(get_current_user)
headers = {"X-CTMS-Share-Token": token}
base = "/api/v1/collaboration/shares"
metadata = await env.client.get(f"{base}/metadata", headers=headers)
assert metadata.status_code == 200 and metadata.json()["requires_password"]
body = {"client_id": "test-client", "display_name": "访客"}
assert (await env.client.post(f"{base}/editor-config", headers=headers, json=body)).status_code == 401
access = await env.client.post(f"{base}/access", headers=headers, json={"password": "share-password"})
assert access.status_code == 200
config = await env.client.post(f"{base}/editor-config", headers=headers,
json={**body, "access_token": access.json()["access_token"]})
assert config.status_code == 200 and config.headers["cache-control"] == "no-store"
assert config.json()["file_name"] == item.title and config.json()["access_mode"] == "edit"
assert not config.json()["can_save_as"] and item.study_id is None
@pytest.mark.asyncio
async def test_revision_names_reuse_collaboration_metadata_without_replacing_content(env):
item = await grant(env, env.editor, "EDITOR")
await grant(env, env.viewer, "VIEWER")
revision_id = item.current_revision_id
generation = item.generation
base = f"/api/v1/ledgers/{item.id}/revisions/{revision_id}"
env.identity.user = env.editor
result = await env.client.patch(base, json={"change_summary": "审核提交版"})
assert result.status_code == 200 and result.json()["change_summary"] == "审核提交版"
assert item.current_revision_id == revision_id and item.generation == generation
assert len(await collaboration.list_revisions(env.db, item)) == 1
for actor, status in [(env.viewer, 403), (env.outsider, 404)]:
env.identity.user = actor
assert (await env.client.patch(base, json={"change_summary": "不应保存"})).status_code == status
env.identity.user = env.editor
other_revision = env.initial[1].current_revision_id
assert (await env.client.patch(f"/api/v1/ledgers/{item.id}/revisions/{other_revision}", json={"change_summary": "跨台账"})).status_code == 404
env.identity.user = env.admin
await env.client.patch(f"/api/v1/ledgers/{item.id}", json={
"title": item.title, "status": "ARCHIVED", "owner_id": str(item.owner_id), "generation": item.generation,
})
assert (await env.client.patch(base, json={"change_summary": "归档后"})).status_code == 403
@pytest.mark.asyncio
async def test_history_export_uses_live_export_permission_and_checks_revision_belongs_to_ledger(env):
item = await grant(env, env.editor, "EDITOR")
await grant(env, env.viewer, "VIEWER")
base = f"/api/v1/ledgers/{item.id}"
path = f"{base}/revisions/{item.current_revision_id}/download"
for actor in [env.editor, env.viewer]:
env.identity.user = actor
assert (await env.client.get(path)).status_code == 403
env.identity.user = env.admin
response = await env.client.get(path)
assert response.status_code == 200 and response.headers["cache-control"] == "no-store"
revision = await env.db.get(CollaborationRevision, item.current_revision_id)
assert response.content == Path(revision.file_uri).read_bytes()
await env.client.patch(f"{base}/access-settings", json={"generation": item.generation, "allow_export": True})
env.identity.user = env.viewer
assert (await env.client.get(path)).status_code == 200
assert (await env.client.get(f"{base}/revisions/{env.initial[1].current_revision_id}/download")).status_code == 404
await ledgers.remove_member(env.db, item, env.viewer.id, env.admin)
assert (await env.client.get(path)).status_code == 404
env.identity.user = env.outsider
assert (await env.client.get(path)).status_code == 404