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