Files
ctms/backend/app/api/v1/collaboration.py
T
Cheng Zhou d5279b124f
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
release(main): 同步 dev 最新候选改动
2026-07-16 17:15:50 +08:00

630 lines
22 KiB
Python

from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, File, Form, Header, Request, Response, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_api_permission
from app.schemas.collaboration import (
CollaborationCallbackPayload,
CollaborationCandidateRead,
CollaborationEditorConfigRead,
CollaborationEditRequestRead,
CollaborationEditRequestResolve,
CollaborationExportRecord,
CollaborationFileCreate,
CollaborationFileRead,
CollaborationFileUpdate,
CollaborationFolderCreate,
CollaborationFolderRead,
CollaborationFolderUpdate,
CollaborationMemberRead,
CollaborationMemberUpsert,
CollaborationOwnershipTransferRequest,
CollaborationPublicEditorConfigRequest,
CollaborationPublicShareMetadata,
CollaborationRestoreRequest,
CollaborationRevisionCopyRequest,
CollaborationRevisionRead,
CollaborationRevisionUpdate,
CollaborationShareAccessGrant,
CollaborationShareLinkRead,
CollaborationShareLinkUpdate,
CollaborationSharePasswordRequest,
)
from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead
from app.services import collaboration_service, collaboration_share_service, onlyoffice_collaboration_service, onlyoffice_service
router = APIRouter()
public_router = APIRouter()
internal_router = APIRouter(include_in_schema=False)
@router.get(
"/folders",
response_model=list[CollaborationFolderRead],
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def list_folders(study_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)):
return await collaboration_service.list_folders(db, study_id)
@router.post(
"/folders",
response_model=CollaborationFolderRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_api_permission("collaboration:manage"))],
)
async def create_folder(
study_id: uuid.UUID,
payload: CollaborationFolderCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
return await collaboration_service.create_folder(db, study_id, payload, current_user)
@router.patch(
"/folders/{folder_id}",
response_model=CollaborationFolderRead,
dependencies=[Depends(require_api_permission("collaboration:manage"))],
)
async def update_folder(
study_id: uuid.UUID,
folder_id: uuid.UUID,
payload: CollaborationFolderUpdate,
db: AsyncSession = Depends(get_db_session),
):
return await collaboration_service.update_folder(db, study_id, folder_id, payload)
@router.delete(
"/folders/{folder_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_api_permission("collaboration:manage"))],
)
async def delete_folder(study_id: uuid.UUID, folder_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)):
await collaboration_service.delete_folder(db, study_id, folder_id)
@router.get(
"/files",
response_model=list[CollaborationFileRead],
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def list_files(
study_id: uuid.UUID,
folder_id: uuid.UUID | None = None,
keyword: str | None = None,
deleted: bool = False,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
return await collaboration_service.list_files(
db, study_id, current_user, folder_id=folder_id, keyword=keyword, deleted=deleted
)
@router.post(
"/files",
response_model=CollaborationFileRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_api_permission("collaboration:create"))],
)
async def create_file(
study_id: uuid.UUID,
payload: CollaborationFileCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.create_blank_file(db, study_id, payload, current_user)
return await collaboration_service.file_read(db, item, current_user)
@router.post(
"/files/import",
response_model=CollaborationFileRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_api_permission("collaboration:create"))],
)
async def import_file(
study_id: uuid.UUID,
file: UploadFile = File(...),
folder_id: uuid.UUID | None = Form(None),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.import_file(db, study_id, folder_id, file, current_user)
return await collaboration_service.file_read(db, item, current_user)
@router.post(
"/files/{file_id}/copy",
response_model=CollaborationFileRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_api_permission("collaboration:create"))],
)
async def copy_file(
study_id: uuid.UUID,
file_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
source = await collaboration_service.get_file_or_404(db, study_id, file_id)
item = await collaboration_service.copy_file(db, source, current_user)
return await collaboration_service.file_read(db, item, current_user)
@router.get(
"/files/{file_id}",
response_model=CollaborationFileRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def get_file(
study_id: uuid.UUID,
file_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.file_read(db, item, current_user)
@router.get(
"/files/{file_id}/download",
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def download_file(
study_id: uuid.UUID,
file_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
revision = await collaboration_service.prepare_download(db, item, current_user)
return FileResponse(
path=revision.file_uri,
media_type=revision.mime_type,
filename=item.title,
content_disposition_type="attachment",
headers={"Cache-Control": "no-store"},
)
@router.patch(
"/files/{file_id}",
response_model=CollaborationFileRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def update_file(
study_id: uuid.UUID,
file_id: uuid.UUID,
payload: CollaborationFileUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
item = await collaboration_service.update_file(db, item, payload, current_user)
return await collaboration_service.file_read(db, item, current_user)
@router.delete(
"/files/{file_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def trash_file(
study_id: uuid.UUID,
file_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
await collaboration_service.move_to_trash(db, item, current_user)
@router.post(
"/files/{file_id}/restore",
response_model=CollaborationFileRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def restore_file(
study_id: uuid.UUID,
file_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id, include_deleted=True)
item = await collaboration_service.restore_file(db, item, current_user)
return await collaboration_service.file_read(db, item, current_user)
@router.get(
"/files/{file_id}/members",
response_model=list[CollaborationMemberRead],
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def list_members(study_id: uuid.UUID, file_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.list_members(db, item)
@router.put(
"/files/{file_id}/members",
response_model=CollaborationMemberRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def upsert_member(
study_id: uuid.UUID,
file_id: uuid.UUID,
payload: CollaborationMemberUpsert,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.upsert_member(db, item, payload, current_user)
@router.delete(
"/files/{file_id}/members/{user_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def remove_member(
study_id: uuid.UUID,
file_id: uuid.UUID,
user_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
await collaboration_service.remove_member(db, item, user_id, current_user)
@router.post(
"/files/{file_id}/edit-requests",
response_model=CollaborationEditRequestRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def create_edit_request(
study_id: uuid.UUID,
file_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.create_edit_request(db, item, current_user)
@router.get(
"/files/{file_id}/edit-requests",
response_model=list[CollaborationEditRequestRead],
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def list_edit_requests(
study_id: uuid.UUID,
file_id: uuid.UUID,
pending_only: bool = True,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.list_edit_requests(db, item, current_user, pending_only=pending_only)
@router.post(
"/files/{file_id}/edit-requests/{request_id}/resolve",
response_model=CollaborationEditRequestRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def resolve_edit_request(
study_id: uuid.UUID,
file_id: uuid.UUID,
request_id: uuid.UUID,
payload: CollaborationEditRequestResolve,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.resolve_edit_request(db, item, request_id, payload, current_user)
@router.post(
"/files/{file_id}/transfer-ownership",
response_model=CollaborationFileRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def transfer_ownership(
study_id: uuid.UUID,
file_id: uuid.UUID,
payload: CollaborationOwnershipTransferRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
item = await collaboration_service.transfer_ownership(db, item, payload, current_user)
return await collaboration_service.file_read(db, item, current_user)
@router.get(
"/files/{file_id}/share-link",
response_model=CollaborationShareLinkRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def get_share_link(
study_id: uuid.UUID,
file_id: uuid.UUID,
response: Response,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
result = await collaboration_share_service.get_share_link(db, item, current_user)
response.headers["Cache-Control"] = "no-store"
return result
@router.put(
"/files/{file_id}/share-link",
response_model=CollaborationShareLinkRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def update_share_link(
study_id: uuid.UUID,
file_id: uuid.UUID,
payload: CollaborationShareLinkUpdate,
response: Response,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
result = await collaboration_share_service.update_share_link(db, item, payload, current_user)
response.headers["Cache-Control"] = "no-store"
return result
@router.get(
"/member-candidates",
response_model=list[CollaborationCandidateRead],
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def list_member_candidates(study_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)):
return await collaboration_service.list_candidates(db, study_id)
@router.get(
"/files/{file_id}/revisions",
response_model=list[CollaborationRevisionRead],
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def list_revisions(study_id: uuid.UUID, file_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.list_revisions(db, item)
@router.patch(
"/files/{file_id}/revisions/{revision_id}",
response_model=CollaborationRevisionRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def update_revision(
study_id: uuid.UUID,
file_id: uuid.UUID,
revision_id: uuid.UUID,
payload: CollaborationRevisionUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.update_revision(db, item, revision_id, payload, current_user)
@router.delete(
"/files/{file_id}/revisions/{revision_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def delete_revision(
study_id: uuid.UUID,
file_id: uuid.UUID,
revision_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
await collaboration_service.delete_revision(db, item, revision_id, current_user)
@router.post(
"/files/{file_id}/revisions/{revision_id}/copy",
response_model=CollaborationFileRead,
status_code=status.HTTP_201_CREATED,
dependencies=[
Depends(require_api_permission("collaboration:create")),
Depends(require_api_permission("collaboration:read")),
],
)
async def copy_revision(
study_id: uuid.UUID,
file_id: uuid.UUID,
revision_id: uuid.UUID,
payload: CollaborationRevisionCopyRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
copied = await collaboration_service.copy_revision(db, item, revision_id, payload, current_user)
return await collaboration_service.file_read(db, copied, current_user)
@router.get(
"/files/{file_id}/revisions/{revision_id}/preview-config",
response_model=OnlyOfficePreviewConfigRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def get_revision_preview_config(
study_id: uuid.UUID,
file_id: uuid.UUID,
revision_id: uuid.UUID,
response: Response,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
revision = await collaboration_service.prepare_revision_preview(db, item, revision_id, current_user)
await onlyoffice_service.ensure_onlyoffice_available()
result = onlyoffice_service.build_preview_config(
resource_type="collaboration_revision",
resource_id=revision.id,
file_name=item.title,
file_hash=revision.file_hash,
user_id=current_user.id,
user_name=current_user.full_name,
)
response.headers["Cache-Control"] = "no-store"
return result
@router.post(
"/files/{file_id}/revisions/{revision_id}/restore",
response_model=CollaborationRevisionRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def restore_revision(
study_id: uuid.UUID,
file_id: uuid.UUID,
revision_id: uuid.UUID,
payload: CollaborationRestoreRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.restore_revision(
db, item, revision_id, current_user, payload.change_summary
)
@router.get(
"/files/{file_id}/editor-config",
response_model=CollaborationEditorConfigRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def get_editor_config(
study_id: uuid.UUID,
file_id: uuid.UUID,
response: Response,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
result = await onlyoffice_collaboration_service.build_editor_config(db, item, current_user)
response.headers["Cache-Control"] = "no-store"
return result
@router.post(
"/files/{file_id}/exports",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def record_export(
study_id: uuid.UUID,
file_id: uuid.UUID,
payload: CollaborationExportRecord,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
await collaboration_service.record_export(db, item, current_user, payload.file_type)
@router.post(
"/files/{file_id}/downloads",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def record_download(
study_id: uuid.UUID,
file_id: uuid.UUID,
payload: CollaborationExportRecord,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
await collaboration_service.record_download(db, item, current_user, payload.file_type)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@public_router.get("/metadata", response_model=CollaborationPublicShareMetadata)
async def get_public_share_metadata(
response: Response,
x_ctms_share_token: str | None = Header(default=None, alias="X-CTMS-Share-Token"),
db: AsyncSession = Depends(get_db_session),
):
result = await collaboration_share_service.public_metadata(db, x_ctms_share_token)
response.headers["Cache-Control"] = "no-store"
return result
@public_router.post("/access", response_model=CollaborationShareAccessGrant)
async def verify_public_share_password(
payload: CollaborationSharePasswordRequest,
response: Response,
x_ctms_share_token: str | None = Header(default=None, alias="X-CTMS-Share-Token"),
db: AsyncSession = Depends(get_db_session),
):
result = await collaboration_share_service.verify_share_password(
db, x_ctms_share_token, payload.password
)
response.headers["Cache-Control"] = "no-store"
return result
@public_router.post("/editor-config", response_model=CollaborationEditorConfigRead)
async def get_public_share_editor_config(
payload: CollaborationPublicEditorConfigRequest,
response: Response,
x_ctms_share_token: str | None = Header(default=None, alias="X-CTMS-Share-Token"),
db: AsyncSession = Depends(get_db_session),
):
link, item = await collaboration_share_service.resolve_active_share(db, x_ctms_share_token)
collaboration_share_service.validate_access_grant(link, payload.access_token)
result = await onlyoffice_collaboration_service.build_shared_editor_config(
db,
item,
link,
client_id=payload.client_id,
display_name=payload.display_name,
)
response.headers["Cache-Control"] = "no-store"
return result
@internal_router.get("/internal/onlyoffice/collaboration/sessions/{session_id}/content")
async def get_session_content(session_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db_session)):
revision, item = await onlyoffice_collaboration_service.get_session_content(
db, session_id, request.headers.get("AuthorizationJwt")
)
return FileResponse(
path=revision.file_uri,
media_type=revision.mime_type,
filename=item.title,
content_disposition_type="inline",
)
@internal_router.post("/internal/onlyoffice/collaboration/sessions/{session_id}/callback")
async def collaboration_callback(
session_id: uuid.UUID,
payload: CollaborationCallbackPayload,
request: Request,
db: AsyncSession = Depends(get_db_session),
):
onlyoffice_collaboration_service.validate_callback_token(
request.headers.get("AuthorizationJwt"), payload
)
return await onlyoffice_collaboration_service.process_callback(db, session_id, payload)