新增全局协作台账并统一工作台管理界面
新增与项目平级的协作台账,提供空白模板、独立账号授权和在线编辑。 复用共享库的访问权限与历史版本界面,拆分信息维护入口并统一工作台卡片样式。 补充在线文档字体构建支持、数据库表结构升级和回归测试。 不包含本地台账内容、账号授权记录或历史文件迁移数据。
This commit is contained in:
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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, "仅台账管理人员可以执行此操作")
|
||||
@@ -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.
Binary file not shown.
@@ -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
|
||||
Reference in New Issue
Block a user