feat(collaboration): 完善在线文档协作与通知闭环
- 新增协作文件夹、文件、不可变修订、成员、会话、回调回执、编辑申请与分享链接数据模型。 - 补齐新建、导入、复制、下载、回收站、恢复、成员授权、所有权转让及文件级权限接口。 - 接入 ONLYOFFICE 共同编辑、历史版本预览与恢复、修订另存副本、导出下载审计和幂等回调保存。 - 增加编辑权限申请、审批通知、项目提醒聚合、通知 Feed、已读处理及历史待办数据回填。 - 支持公开分享的查看或编辑模式、有效期、密码哈希、失败锁定、短时访问凭证与固定分享地址。 - 增加协作者导出、申请编辑、工作表结构保护和所有权管理策略,并纳入项目接口权限矩阵。 - 新增协作文件库、编辑工作区、公开分享页、下载与另存为对话框,以及导航、路由和权限入口。 - 统一网页端与桌面端通知布局,增加沉浸式工作区和浏览器、Tauri 双端全屏能力。 - 扩展运行时文件下载适配、Tauri 环境识别和原生全屏命令,继续保持业务代码运行时边界。 - 加固 ONLYOFFICE 消息桥的同源下载、签名地址隔离和保存为能力校验,并更新桌面发布检查。 - 增加连续数据库迁移、50MB 上传限制、OnlyOffice 中文文案与开发启动路由校验。 - 补充协作、通知、权限、路由、运行时、布局和 OnlyOffice 相关测试及模块说明文档。
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
"""Add the independent shared-library collaboration module.
|
||||
|
||||
Revision ID: 20260714_01
|
||||
Revises: 20260713_02
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision = "20260714_01"
|
||||
down_revision = "20260713_02"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
uuid_type = postgresql.UUID(as_uuid=True)
|
||||
op.create_table(
|
||||
"collaboration_folders",
|
||||
sa.Column("id", uuid_type, primary_key=True),
|
||||
sa.Column("study_id", uuid_type, sa.ForeignKey("studies.id"), nullable=False),
|
||||
sa.Column("parent_id", uuid_type, sa.ForeignKey("collaboration_folders.id", ondelete="SET NULL")),
|
||||
sa.Column("name", sa.String(120), nullable=False),
|
||||
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("created_by", uuid_type, sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True)),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_collaboration_folders_study_parent", "collaboration_folders", ["study_id", "parent_id"])
|
||||
|
||||
op.create_table(
|
||||
"collaboration_files",
|
||||
sa.Column("id", uuid_type, primary_key=True),
|
||||
sa.Column("study_id", uuid_type, sa.ForeignKey("studies.id"), nullable=False),
|
||||
sa.Column("folder_id", uuid_type, sa.ForeignKey("collaboration_folders.id", ondelete="SET NULL")),
|
||||
sa.Column("title", sa.String(255), nullable=False),
|
||||
sa.Column("file_type", sa.String(16), nullable=False),
|
||||
sa.Column("extension", sa.String(16), nullable=False),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="ACTIVE"),
|
||||
sa.Column("owner_id", uuid_type, sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("current_revision_id", uuid_type),
|
||||
sa.Column("generation", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True)),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_collaboration_files_study_folder", "collaboration_files", ["study_id", "folder_id"])
|
||||
op.create_index("ix_collaboration_files_study_status", "collaboration_files", ["study_id", "status"])
|
||||
|
||||
op.create_table(
|
||||
"collaboration_members",
|
||||
sa.Column("id", uuid_type, primary_key=True),
|
||||
sa.Column("file_id", uuid_type, sa.ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("user_id", uuid_type, sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("role", sa.String(16), nullable=False),
|
||||
sa.Column("invited_by", uuid_type, sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("file_id", "user_id", name="uq_collaboration_member_file_user"),
|
||||
)
|
||||
op.create_index("ix_collaboration_members_user", "collaboration_members", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"collaboration_revisions",
|
||||
sa.Column("id", uuid_type, primary_key=True),
|
||||
sa.Column("file_id", uuid_type, sa.ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("revision_no", sa.Integer(), nullable=False),
|
||||
sa.Column("parent_revision_id", uuid_type, sa.ForeignKey("collaboration_revisions.id", ondelete="SET NULL")),
|
||||
sa.Column("file_uri", sa.String(500), nullable=False),
|
||||
sa.Column("original_filename", sa.String(255), nullable=False),
|
||||
sa.Column("file_hash", sa.String(128), nullable=False),
|
||||
sa.Column("file_size", sa.BigInteger(), nullable=False),
|
||||
sa.Column("mime_type", sa.String(100), nullable=False),
|
||||
sa.Column("source", sa.String(24), nullable=False),
|
||||
sa.Column("change_summary", sa.Text()),
|
||||
sa.Column("created_by", uuid_type, sa.ForeignKey("users.id")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("file_id", "revision_no", name="uq_collaboration_revision_file_no"),
|
||||
)
|
||||
op.create_index("ix_collaboration_revisions_file_created", "collaboration_revisions", ["file_id", "created_at"])
|
||||
op.create_foreign_key(
|
||||
"fk_collaboration_files_current_revision",
|
||||
"collaboration_files",
|
||||
"collaboration_revisions",
|
||||
["current_revision_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"collaboration_sessions",
|
||||
sa.Column("id", uuid_type, primary_key=True),
|
||||
sa.Column("file_id", uuid_type, sa.ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("base_revision_id", uuid_type, sa.ForeignKey("collaboration_revisions.id"), nullable=False),
|
||||
sa.Column("document_key", sa.String(128), nullable=False),
|
||||
sa.Column("generation", sa.Integer(), nullable=False),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="ACTIVE"),
|
||||
sa.Column("started_by", uuid_type, sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("active_users", sa.Text()),
|
||||
sa.Column("last_callback_at", sa.DateTime(timezone=True)),
|
||||
sa.Column("closed_at", sa.DateTime(timezone=True)),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("document_key", name="uq_collaboration_session_document_key"),
|
||||
)
|
||||
op.create_index("ix_collaboration_sessions_file_status", "collaboration_sessions", ["file_id", "status"])
|
||||
|
||||
op.create_table(
|
||||
"collaboration_callback_receipts",
|
||||
sa.Column("id", uuid_type, primary_key=True),
|
||||
sa.Column("session_id", uuid_type, sa.ForeignKey("collaboration_sessions.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("fingerprint", sa.String(128), nullable=False),
|
||||
sa.Column("callback_status", sa.Integer(), nullable=False),
|
||||
sa.Column("result", sa.String(24), nullable=False),
|
||||
sa.Column("revision_id", uuid_type, sa.ForeignKey("collaboration_revisions.id", ondelete="SET NULL")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("session_id", "fingerprint", name="uq_collaboration_callback_session_fingerprint"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("collaboration_callback_receipts")
|
||||
op.drop_index("ix_collaboration_sessions_file_status", table_name="collaboration_sessions")
|
||||
op.drop_table("collaboration_sessions")
|
||||
op.drop_constraint("fk_collaboration_files_current_revision", "collaboration_files", type_="foreignkey")
|
||||
op.drop_index("ix_collaboration_revisions_file_created", table_name="collaboration_revisions")
|
||||
op.drop_table("collaboration_revisions")
|
||||
op.drop_index("ix_collaboration_members_user", table_name="collaboration_members")
|
||||
op.drop_table("collaboration_members")
|
||||
op.drop_index("ix_collaboration_files_study_status", table_name="collaboration_files")
|
||||
op.drop_index("ix_collaboration_files_study_folder", table_name="collaboration_files")
|
||||
op.drop_table("collaboration_files")
|
||||
op.drop_index("ix_collaboration_folders_study_parent", table_name="collaboration_folders")
|
||||
op.drop_table("collaboration_folders")
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Add protected public links for collaboration files.
|
||||
|
||||
Revision ID: 20260715_01
|
||||
Revises: 20260714_01
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision = "20260715_01"
|
||||
down_revision = "20260714_01"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
uuid_type = postgresql.UUID(as_uuid=True)
|
||||
op.create_table(
|
||||
"collaboration_share_links",
|
||||
sa.Column("id", uuid_type, primary_key=True),
|
||||
sa.Column(
|
||||
"file_id",
|
||||
uuid_type,
|
||||
sa.ForeignKey("collaboration_files.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("access_mode", sa.String(12), nullable=False, server_default="VIEW"),
|
||||
sa.Column("expiry_policy", sa.String(16), nullable=False, server_default="SEVEN_DAYS"),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True)),
|
||||
sa.Column("password_hash", sa.String(255)),
|
||||
sa.Column("token_version", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("failed_attempts", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("last_failed_at", sa.DateTime(timezone=True)),
|
||||
sa.Column("locked_until", sa.DateTime(timezone=True)),
|
||||
sa.Column("created_by", uuid_type, sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("updated_by", uuid_type, sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("file_id", name="uq_collaboration_share_link_file"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_collaboration_share_links_enabled_expiry",
|
||||
"collaboration_share_links",
|
||||
["enabled", "expires_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_collaboration_share_links_enabled_expiry", table_name="collaboration_share_links")
|
||||
op.drop_table("collaboration_share_links")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Add effective export controls to collaboration share links.
|
||||
|
||||
Revision ID: 20260715_02
|
||||
Revises: 20260715_01
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "20260715_02"
|
||||
down_revision = "20260715_01"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"collaboration_share_links",
|
||||
sa.Column("allow_export", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("collaboration_share_links", "allow_export")
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Add soft deletion metadata to collaboration revisions.
|
||||
|
||||
Revision ID: 20260715_03
|
||||
Revises: 20260715_02
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision = "20260715_03"
|
||||
down_revision = "20260715_02"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"collaboration_revisions",
|
||||
sa.Column("deleted_by", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"collaboration_revisions",
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_collaboration_revision_deleted_by",
|
||||
"collaboration_revisions",
|
||||
"users",
|
||||
["deleted_by"],
|
||||
["id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint(
|
||||
"fk_collaboration_revision_deleted_by",
|
||||
"collaboration_revisions",
|
||||
type_="foreignkey",
|
||||
)
|
||||
op.drop_column("collaboration_revisions", "deleted_at")
|
||||
op.drop_column("collaboration_revisions", "deleted_by")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Move collaboration export control to the file.
|
||||
|
||||
Revision ID: 20260715_04
|
||||
Revises: 20260715_03
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "20260715_04"
|
||||
down_revision = "20260715_03"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"collaboration_files",
|
||||
sa.Column("allow_export", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
# Preserve links that already granted export: after consolidation the same
|
||||
# setting applies to authenticated collaborators and anonymous visitors.
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE collaboration_files AS file
|
||||
SET allow_export = true
|
||||
FROM collaboration_share_links AS link
|
||||
WHERE link.file_id = file.id AND link.allow_export = true
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("collaboration_files", "allow_export")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Remove the redundant share-link export permission.
|
||||
|
||||
Revision ID: 20260715_05
|
||||
Revises: 20260715_04
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "20260715_05"
|
||||
down_revision = "20260715_04"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_column("collaboration_share_links", "allow_export")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column(
|
||||
"collaboration_share_links",
|
||||
sa.Column("allow_export", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE collaboration_share_links AS link
|
||||
SET allow_export = file.allow_export
|
||||
FROM collaboration_files AS file
|
||||
WHERE file.id = link.file_id
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Add collaboration edit requests and ownership controls.
|
||||
|
||||
Revision ID: 20260715_06
|
||||
Revises: 20260715_05
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision = "20260715_06"
|
||||
down_revision = "20260715_05"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"collaboration_files",
|
||||
sa.Column("allow_edit_request", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
op.add_column(
|
||||
"collaboration_files",
|
||||
sa.Column("allow_sheet_structure_edit", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
)
|
||||
op.add_column(
|
||||
"collaboration_files",
|
||||
sa.Column("sheet_structure_protection_backup", sa.Text(), nullable=True),
|
||||
)
|
||||
op.create_table(
|
||||
"collaboration_edit_requests",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("file_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("requester_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("status", sa.String(length=16), nullable=False, server_default="PENDING"),
|
||||
sa.Column("resolved_by", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(["file_id"], ["collaboration_files.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["requester_id"], ["users.id"]),
|
||||
sa.ForeignKeyConstraint(["resolved_by"], ["users.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_collaboration_edit_requests_file_status",
|
||||
"collaboration_edit_requests",
|
||||
["file_id", "status"],
|
||||
)
|
||||
op.create_index(
|
||||
"uq_collaboration_edit_requests_pending_user",
|
||||
"collaboration_edit_requests",
|
||||
["file_id", "requester_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("status = 'PENDING'"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("uq_collaboration_edit_requests_pending_user", table_name="collaboration_edit_requests")
|
||||
op.drop_index("ix_collaboration_edit_requests_file_status", table_name="collaboration_edit_requests")
|
||||
op.drop_table("collaboration_edit_requests")
|
||||
op.drop_column("collaboration_files", "sheet_structure_protection_backup")
|
||||
op.drop_column("collaboration_files", "allow_sheet_structure_edit")
|
||||
op.drop_column("collaboration_files", "allow_edit_request")
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Add recipient-scoped generic notifications.
|
||||
|
||||
Revision ID: 20260716_01
|
||||
Revises: 20260715_06
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision = "20260716_01"
|
||||
down_revision = "20260715_06"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"notifications",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("study_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("recipient_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("category", sa.String(length=64), nullable=False),
|
||||
sa.Column("priority", sa.String(length=16), nullable=False, server_default="NORMAL"),
|
||||
sa.Column("title", sa.String(length=180), nullable=False),
|
||||
sa.Column("message", sa.String(length=500), nullable=False),
|
||||
sa.Column("action_path", sa.Text(), nullable=True),
|
||||
sa.Column("source_type", sa.String(length=64), nullable=False),
|
||||
sa.Column("source_id", sa.String(length=100), nullable=False),
|
||||
sa.Column("source_version", sa.String(length=100), nullable=True),
|
||||
sa.Column("dedupe_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(["recipient_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["study_id"], ["studies.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("recipient_id", "dedupe_key", name="uq_notifications_recipient_dedupe"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_notifications_recipient_study_state",
|
||||
"notifications",
|
||||
["recipient_id", "study_id", "resolved_at", "read_at", "created_at"],
|
||||
)
|
||||
op.create_index("ix_notifications_source", "notifications", ["source_type", "source_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_notifications_source", table_name="notifications")
|
||||
op.drop_index("ix_notifications_recipient_study_state", table_name="notifications")
|
||||
op.drop_table("notifications")
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Backfill notifications for pending collaboration edit requests.
|
||||
|
||||
Revision ID: 20260716_02
|
||||
Revises: 20260716_01
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "20260716_02"
|
||||
down_revision = "20260716_01"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
INSERT INTO notifications (
|
||||
id,
|
||||
study_id,
|
||||
recipient_id,
|
||||
category,
|
||||
priority,
|
||||
title,
|
||||
message,
|
||||
action_path,
|
||||
source_type,
|
||||
source_id,
|
||||
dedupe_key,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
collaboration_files.study_id,
|
||||
study_members.user_id,
|
||||
'COLLABORATION_EDIT_REQUEST',
|
||||
'NORMAL',
|
||||
'新的编辑权限申请',
|
||||
concat(
|
||||
coalesce(nullif(users.full_name, ''), users.email, '项目成员'),
|
||||
' 申请编辑“',
|
||||
collaboration_files.title,
|
||||
'”'
|
||||
),
|
||||
concat('/knowledge/collaboration?editRequestFile=', collaboration_files.id::text),
|
||||
'COLLABORATION_EDIT_REQUEST',
|
||||
collaboration_edit_requests.id::text,
|
||||
concat('collaboration-edit-request:', collaboration_edit_requests.id::text),
|
||||
collaboration_edit_requests.created_at,
|
||||
collaboration_edit_requests.updated_at
|
||||
FROM collaboration_edit_requests
|
||||
JOIN collaboration_files
|
||||
ON collaboration_files.id = collaboration_edit_requests.file_id
|
||||
JOIN users
|
||||
ON users.id = collaboration_edit_requests.requester_id
|
||||
JOIN study_members
|
||||
ON study_members.study_id = collaboration_files.study_id
|
||||
AND study_members.is_active IS TRUE
|
||||
LEFT JOIN collaboration_members
|
||||
ON collaboration_members.file_id = collaboration_files.id
|
||||
AND collaboration_members.user_id = study_members.user_id
|
||||
WHERE collaboration_edit_requests.status = 'PENDING'
|
||||
AND collaboration_files.deleted_at IS NULL
|
||||
AND (
|
||||
study_members.user_id = collaboration_files.owner_id
|
||||
OR collaboration_members.role = 'MANAGER'
|
||||
)
|
||||
ON CONFLICT (recipient_id, dedupe_key) DO NOTHING
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 这是业务通知数据迁移;降级时保留已读/未读状态,避免删除升级后新产生的同源通知。
|
||||
pass
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Backfill pending edit request notifications for file owners.
|
||||
|
||||
Revision ID: 20260716_03
|
||||
Revises: 20260716_02
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "20260716_03"
|
||||
down_revision = "20260716_02"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
INSERT INTO notifications (
|
||||
id,
|
||||
study_id,
|
||||
recipient_id,
|
||||
category,
|
||||
priority,
|
||||
title,
|
||||
message,
|
||||
action_path,
|
||||
source_type,
|
||||
source_id,
|
||||
dedupe_key,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
collaboration_files.study_id,
|
||||
collaboration_files.owner_id,
|
||||
'COLLABORATION_EDIT_REQUEST',
|
||||
'NORMAL',
|
||||
'新的编辑权限申请',
|
||||
concat(
|
||||
coalesce(nullif(users.full_name, ''), users.email, '项目成员'),
|
||||
' 申请编辑“',
|
||||
collaboration_files.title,
|
||||
'”'
|
||||
),
|
||||
concat('/knowledge/collaboration?editRequestFile=', collaboration_files.id::text),
|
||||
'COLLABORATION_EDIT_REQUEST',
|
||||
collaboration_edit_requests.id::text,
|
||||
concat('collaboration-edit-request:', collaboration_edit_requests.id::text),
|
||||
collaboration_edit_requests.created_at,
|
||||
collaboration_edit_requests.updated_at
|
||||
FROM collaboration_edit_requests
|
||||
JOIN collaboration_files
|
||||
ON collaboration_files.id = collaboration_edit_requests.file_id
|
||||
JOIN users
|
||||
ON users.id = collaboration_edit_requests.requester_id
|
||||
WHERE collaboration_edit_requests.status = 'PENDING'
|
||||
AND collaboration_files.deleted_at IS NULL
|
||||
ON CONFLICT (recipient_id, dedupe_key) DO NOTHING
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 与上一数据迁移一致,降级时保留已产生的业务通知状态。
|
||||
pass
|
||||
@@ -0,0 +1,629 @@
|
||||
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)
|
||||
@@ -1,13 +1,15 @@
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_db_session, get_current_user, require_study_member
|
||||
from app.schemas.notification import NotificationItem
|
||||
from app.services import document_service
|
||||
from app.schemas.notification import GeneralNotificationFeed, GeneralNotificationRead, NotificationItem
|
||||
from app.services import document_service, notification_service, project_reminder_service
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -29,3 +31,60 @@ async def list_notifications(
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/notifications/feed",
|
||||
response_model=GeneralNotificationFeed,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def list_general_notifications(
|
||||
study_id: uuid.UUID,
|
||||
limit: int = 10,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> GeneralNotificationFeed:
|
||||
try:
|
||||
await project_reminder_service.sync_project_reminders(db, study_id, current_user)
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
logger.warning("Failed to synchronize legacy project reminders", exc_info=True)
|
||||
return await notification_service.list_feed(
|
||||
db,
|
||||
study_id=study_id,
|
||||
recipient_id=current_user.id,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/notifications/{notification_id}/read",
|
||||
response_model=GeneralNotificationRead,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def mark_general_notification_read(
|
||||
study_id: uuid.UUID,
|
||||
notification_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> GeneralNotificationRead:
|
||||
return await notification_service.mark_read(
|
||||
db,
|
||||
study_id=study_id,
|
||||
recipient_id=current_user.id,
|
||||
notification_id=notification_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/notifications/read-all",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def mark_all_general_notifications_read(
|
||||
study_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> Response:
|
||||
await notification_service.mark_all_read(db, study_id=study_id, recipient_id=current_user.id)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.crud import attachment as attachment_crud
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import document as document_crud
|
||||
from app.crud import document_version as version_crud
|
||||
from app.models.collaboration import CollaborationRevision
|
||||
from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead
|
||||
from app.services import document_service, onlyoffice_service
|
||||
|
||||
@@ -210,3 +211,24 @@ async def get_internal_version_content(
|
||||
media_type=version.mime_type or "application/octet-stream",
|
||||
headers={"Content-Disposition": _content_disposition(file_name)},
|
||||
)
|
||||
|
||||
|
||||
@internal_router.get("/internal/onlyoffice/collaboration-revisions/{revision_id}/content")
|
||||
async def get_internal_collaboration_revision_content(
|
||||
revision_id: uuid.UUID,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> FileResponse:
|
||||
expected_url = onlyoffice_service.onlyoffice_content_url("collaboration_revision", revision_id)
|
||||
_authorize_internal_file_request(request, expected_url)
|
||||
revision = await db.get(CollaborationRevision, revision_id)
|
||||
file_path = Path(revision.file_uri) if revision else None
|
||||
if not revision or getattr(revision, "deleted_at", None) is not None or not file_path or not file_path.exists():
|
||||
raise onlyoffice_service.onlyoffice_error(
|
||||
"COLLABORATION_REVISION_NOT_FOUND", "协作修订不存在", status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
return FileResponse(
|
||||
path=str(file_path),
|
||||
media_type=revision.mime_type or "application/octet-stream",
|
||||
headers={"Content-Disposition": _content_disposition(revision.original_filename)},
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -30,6 +30,8 @@ api_router.include_router(material_equipments.router, prefix="/studies/{study_id
|
||||
api_router.include_router(project_milestones.router, prefix="/studies/{study_id}/project", tags=["project-milestones"])
|
||||
api_router.include_router(startup.router, prefix="/studies/{study_id}/startup", tags=["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(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"])
|
||||
|
||||
@@ -643,6 +643,43 @@ API_ENDPOINT_PERMISSIONS = {
|
||||
"description": "删除文档",
|
||||
"default_roles": ["PM"],
|
||||
},
|
||||
# 共享库在线协作(文档级邀请权限在接口权限之后继续校验)
|
||||
"collaboration:create": {
|
||||
"module": "shared_library",
|
||||
"action": "write",
|
||||
"description": "创建在线协作文件",
|
||||
"default_roles": ["PM", "CRA", "PV", "QA"],
|
||||
},
|
||||
"collaboration:read": {
|
||||
"module": "shared_library",
|
||||
"action": "read",
|
||||
"description": "查看在线协作文件",
|
||||
"default_roles": ["PM", "CRA", "PV", "QA", "CTA"],
|
||||
},
|
||||
"collaboration:edit": {
|
||||
"module": "shared_library",
|
||||
"action": "write",
|
||||
"description": "编辑受邀在线协作文件",
|
||||
"default_roles": ["PM", "CRA", "PV", "QA", "CTA"],
|
||||
},
|
||||
"collaboration:manage": {
|
||||
"module": "shared_library",
|
||||
"action": "write",
|
||||
"description": "管理在线协作文件成员与目录",
|
||||
"default_roles": ["PM", "CRA", "PV", "QA"],
|
||||
},
|
||||
"collaboration:export": {
|
||||
"module": "shared_library",
|
||||
"action": "export",
|
||||
"description": "另存为在线协作文件副本",
|
||||
"default_roles": ["PM", "CRA", "PV", "QA"],
|
||||
},
|
||||
"collaboration:delete": {
|
||||
"module": "shared_library",
|
||||
"action": "write",
|
||||
"description": "移入或恢复在线协作文件",
|
||||
"default_roles": ["PM", "CRA", "PV", "QA"],
|
||||
},
|
||||
}
|
||||
|
||||
def _operation_read_candidates(operation_key: str) -> list[str]:
|
||||
@@ -843,6 +880,7 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
|
||||
"precautions:read",
|
||||
"faq:read",
|
||||
"faq_category:read",
|
||||
"collaboration:read",
|
||||
],
|
||||
"write": [
|
||||
"precautions:create",
|
||||
@@ -858,6 +896,11 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
|
||||
"faq_reply:create",
|
||||
"faq_reply:delete",
|
||||
"faq_attachments:delete",
|
||||
"collaboration:create",
|
||||
"collaboration:edit",
|
||||
"collaboration:manage",
|
||||
"collaboration:export",
|
||||
"collaboration:delete",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ class Settings(BaseSettings):
|
||||
ONLYOFFICE_STORAGE_BASE_URL: str = "http://backend:8000"
|
||||
ONLYOFFICE_INSTANCE_ID: Optional[str] = None
|
||||
ONLYOFFICE_CONFIG_TTL_SECONDS: int = Field(default=300, ge=60, le=900)
|
||||
COLLABORATION_MAX_FILE_BYTES: int = Field(default=50 * 1024 * 1024, ge=1024, le=500 * 1024 * 1024)
|
||||
|
||||
|
||||
@lru_cache
|
||||
|
||||
@@ -10,6 +10,16 @@ from app.models.audit_log import AuditLog # noqa: F401
|
||||
from app.models.etmf import EtmfNode # noqa: F401
|
||||
from app.models.document import Document # noqa: F401
|
||||
from app.models.document_version import DocumentVersion # noqa: F401
|
||||
from app.models.collaboration import ( # noqa: F401
|
||||
CollaborationCallbackReceipt,
|
||||
CollaborationEditRequest,
|
||||
CollaborationFile,
|
||||
CollaborationFolder,
|
||||
CollaborationMember,
|
||||
CollaborationRevision,
|
||||
CollaborationSession,
|
||||
CollaborationShareLink,
|
||||
)
|
||||
from app.models.distribution import Distribution # noqa: F401
|
||||
from app.models.acknowledgement import Acknowledgement # noqa: F401
|
||||
from app.models.milestone import Milestone # noqa: F401
|
||||
@@ -48,4 +58,5 @@ from app.models.desktop_notification import ( # noqa: F401
|
||||
DesktopNotificationDelivery,
|
||||
DesktopNotificationSubscription,
|
||||
)
|
||||
from app.models.notification import Notification # noqa: F401
|
||||
from app.models.email_settings import EmailVerificationCode, SystemEmailSettings # noqa: F401
|
||||
|
||||
@@ -12,6 +12,7 @@ from sqlalchemy import text
|
||||
|
||||
from app.api.v1.router import api_router
|
||||
from app.api.v1.onlyoffice import internal_router as onlyoffice_internal_router
|
||||
from app.api.v1.collaboration import internal_router as collaboration_internal_router
|
||||
from app.core.config import get_cors_allowed_origins, settings, validate_onlyoffice_configuration
|
||||
from app.core.exceptions import register_exception_handlers
|
||||
from app.core.login_crypto import validate_login_crypto_configuration
|
||||
@@ -300,6 +301,7 @@ def create_app() -> FastAPI:
|
||||
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
app.include_router(onlyoffice_internal_router)
|
||||
app.include_router(collaboration_internal_router)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class CollaborationFolder(Base):
|
||||
__tablename__ = "collaboration_folders"
|
||||
__table_args__ = (
|
||||
Index("ix_collaboration_folders_study_parent", "study_id", "parent_id"),
|
||||
)
|
||||
|
||||
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)
|
||||
parent_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("collaboration_folders.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class CollaborationFile(Base):
|
||||
__tablename__ = "collaboration_files"
|
||||
__table_args__ = (
|
||||
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)
|
||||
folder_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("collaboration_folders.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
file_type: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
extension: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="ACTIVE", server_default="ACTIVE")
|
||||
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
current_revision_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("collaboration_revisions.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
generation: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
|
||||
allow_export: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
allow_edit_request: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
allow_sheet_structure_edit: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||
sheet_structure_protection_backup: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class CollaborationMember(Base):
|
||||
__tablename__ = "collaboration_members"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("file_id", "user_id", name="uq_collaboration_member_file_user"),
|
||||
Index("ix_collaboration_members_user", "user_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
file_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
invited_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class CollaborationEditRequest(Base):
|
||||
__tablename__ = "collaboration_edit_requests"
|
||||
__table_args__ = (
|
||||
Index("ix_collaboration_edit_requests_file_status", "file_id", "status"),
|
||||
Index(
|
||||
"uq_collaboration_edit_requests_pending_user",
|
||||
"file_id",
|
||||
"requester_id",
|
||||
unique=True,
|
||||
postgresql_where=text("status = 'PENDING'"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
file_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
requester_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="PENDING", server_default="PENDING")
|
||||
resolved_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
resolved_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class CollaborationShareLink(Base):
|
||||
__tablename__ = "collaboration_share_links"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("file_id", name="uq_collaboration_share_link_file"),
|
||||
Index("ix_collaboration_share_links_enabled_expiry", "enabled", "expires_at"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
file_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
access_mode: Mapped[str] = mapped_column(String(12), nullable=False, default="VIEW", server_default="VIEW")
|
||||
expiry_policy: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="SEVEN_DAYS", server_default="SEVEN_DAYS"
|
||||
)
|
||||
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
password_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
token_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
|
||||
failed_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
last_failed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
updated_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class CollaborationRevision(Base):
|
||||
__tablename__ = "collaboration_revisions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("file_id", "revision_no", name="uq_collaboration_revision_file_no"),
|
||||
Index("ix_collaboration_revisions_file_created", "file_id", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
file_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
revision_no: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
parent_revision_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("collaboration_revisions.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
file_uri: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
original_filename: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
file_hash: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
file_size: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
mime_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
source: Mapped[str] = mapped_column(String(24), nullable=False)
|
||||
change_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
deleted_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class CollaborationSession(Base):
|
||||
__tablename__ = "collaboration_sessions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("document_key", name="uq_collaboration_session_document_key"),
|
||||
Index("ix_collaboration_sessions_file_status", "file_id", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
file_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
base_revision_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("collaboration_revisions.id"), nullable=False
|
||||
)
|
||||
document_key: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
generation: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="ACTIVE", server_default="ACTIVE")
|
||||
started_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
active_users: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
last_callback_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
|
||||
|
||||
class CollaborationCallbackReceipt(Base):
|
||||
__tablename__ = "collaboration_callback_receipts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("session_id", "fingerprint", name="uq_collaboration_callback_session_fingerprint"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
session_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("collaboration_sessions.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
fingerprint: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
callback_status: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
result: Mapped[str] = mapped_column(String(24), nullable=False)
|
||||
revision_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("collaboration_revisions.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
__tablename__ = "notifications"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("recipient_id", "dedupe_key", name="uq_notifications_recipient_dedupe"),
|
||||
Index(
|
||||
"ix_notifications_recipient_study_state",
|
||||
"recipient_id",
|
||||
"study_id",
|
||||
"resolved_at",
|
||||
"read_at",
|
||||
"created_at",
|
||||
),
|
||||
Index("ix_notifications_source", "source_type", "source_id"),
|
||||
)
|
||||
|
||||
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", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
recipient_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
category: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
priority: Mapped[str] = mapped_column(String(16), nullable=False, default="NORMAL", server_default="NORMAL")
|
||||
title: Mapped[str] = mapped_column(String(180), nullable=False)
|
||||
message: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
action_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
source_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
source_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
source_version: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
dedupe_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
@@ -0,0 +1,285 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
|
||||
CollaborationFileType = Literal["word", "cell", "slide"]
|
||||
CollaborationMemberRole = Literal["EDITOR", "MANAGER"]
|
||||
CollaborationFileStatus = Literal["ACTIVE", "ARCHIVED", "DELETED"]
|
||||
CollaborationShareAccessMode = Literal["VIEW", "EDIT"]
|
||||
CollaborationShareExpiryPolicy = Literal["ONE_DAY", "SEVEN_DAYS", "THIRTY_DAYS", "PERMANENT"]
|
||||
CollaborationEditRequestStatus = Literal["PENDING", "APPROVED", "REJECTED"]
|
||||
|
||||
|
||||
class CollaborationFolderCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
parent_id: Optional[uuid.UUID] = None
|
||||
sort_order: int = 0
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def normalize_name(cls, value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("文件夹名称不能为空")
|
||||
return value
|
||||
|
||||
|
||||
class CollaborationFolderUpdate(BaseModel):
|
||||
name: Optional[str] = Field(default=None, min_length=1, max_length=120)
|
||||
parent_id: Optional[uuid.UUID] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
|
||||
class CollaborationFolderRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
study_id: uuid.UUID
|
||||
parent_id: Optional[uuid.UUID]
|
||||
name: str
|
||||
sort_order: int
|
||||
created_by: uuid.UUID
|
||||
deleted_at: Optional[datetime]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CollaborationFileCreate(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=240)
|
||||
file_type: CollaborationFileType
|
||||
folder_id: Optional[uuid.UUID] = None
|
||||
|
||||
|
||||
class CollaborationFileUpdate(BaseModel):
|
||||
title: Optional[str] = Field(default=None, min_length=1, max_length=240)
|
||||
folder_id: Optional[uuid.UUID] = None
|
||||
status: Optional[Literal["ACTIVE", "ARCHIVED"]] = None
|
||||
allow_export: Optional[bool] = None
|
||||
allow_edit_request: Optional[bool] = None
|
||||
allow_sheet_structure_edit: Optional[bool] = None
|
||||
|
||||
|
||||
class CollaborationRevisionRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
file_id: uuid.UUID
|
||||
revision_no: int
|
||||
parent_revision_id: Optional[uuid.UUID]
|
||||
original_filename: str
|
||||
file_hash: str
|
||||
file_size: int
|
||||
mime_type: str
|
||||
source: str
|
||||
change_summary: Optional[str]
|
||||
created_by: Optional[uuid.UUID]
|
||||
created_by_name: Optional[str] = None
|
||||
created_by_avatar_url: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CollaborationRevisionUpdate(BaseModel):
|
||||
change_summary: str = Field(min_length=1, max_length=240)
|
||||
|
||||
@field_validator("change_summary")
|
||||
@classmethod
|
||||
def normalize_change_summary(cls, value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("版本名称不能为空")
|
||||
return value
|
||||
|
||||
|
||||
class CollaborationRevisionCopyRequest(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=240)
|
||||
folder_id: Optional[uuid.UUID] = None
|
||||
|
||||
@field_validator("title")
|
||||
@classmethod
|
||||
def normalize_title(cls, value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("文件名不能为空")
|
||||
return value
|
||||
|
||||
|
||||
class CollaborationFileCollaboratorRead(BaseModel):
|
||||
user_id: uuid.UUID
|
||||
full_name: str
|
||||
role: CollaborationMemberRole
|
||||
avatar_url: Optional[str] = None
|
||||
|
||||
|
||||
class CollaborationFileRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
study_id: uuid.UUID
|
||||
folder_id: Optional[uuid.UUID]
|
||||
title: str
|
||||
file_type: CollaborationFileType
|
||||
extension: str
|
||||
status: CollaborationFileStatus
|
||||
owner_id: uuid.UUID
|
||||
current_revision_id: Optional[uuid.UUID]
|
||||
generation: int
|
||||
allow_export: bool = False
|
||||
allow_edit_request: bool = False
|
||||
allow_sheet_structure_edit: bool = True
|
||||
deleted_at: Optional[datetime]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
owner_name: Optional[str] = None
|
||||
folder_name: Optional[str] = None
|
||||
current_revision_no: Optional[int] = None
|
||||
current_revision_file_size: Optional[int] = None
|
||||
current_revision_mime_type: Optional[str] = None
|
||||
current_revision_created_at: Optional[datetime] = None
|
||||
collaboration_role: Optional[CollaborationMemberRole] = None
|
||||
collaborators: list[CollaborationFileCollaboratorRead] = Field(default_factory=list)
|
||||
can_edit: bool = False
|
||||
can_manage: bool = False
|
||||
can_export: bool = False
|
||||
can_request_edit: bool = False
|
||||
edit_request_status: Optional[CollaborationEditRequestStatus] = None
|
||||
can_transfer_ownership: bool = False
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CollaborationMemberUpsert(BaseModel):
|
||||
user_id: uuid.UUID
|
||||
role: CollaborationMemberRole
|
||||
|
||||
|
||||
class CollaborationMemberRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
file_id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
role: CollaborationMemberRole
|
||||
invited_by: uuid.UUID
|
||||
full_name: str
|
||||
email: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CollaborationEditRequestRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
file_id: uuid.UUID
|
||||
requester_id: uuid.UUID
|
||||
requester_name: str
|
||||
requester_email: str
|
||||
status: CollaborationEditRequestStatus
|
||||
resolved_by: Optional[uuid.UUID]
|
||||
resolved_at: Optional[datetime]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CollaborationEditRequestResolve(BaseModel):
|
||||
status: Literal["APPROVED", "REJECTED"]
|
||||
|
||||
|
||||
class CollaborationOwnershipTransferRequest(BaseModel):
|
||||
new_owner_id: uuid.UUID
|
||||
|
||||
|
||||
class CollaborationShareLinkUpdate(BaseModel):
|
||||
enabled: bool
|
||||
access_mode: CollaborationShareAccessMode = "VIEW"
|
||||
expiry_policy: CollaborationShareExpiryPolicy = "SEVEN_DAYS"
|
||||
password_mode: Literal["KEEP", "SET", "CLEAR"] = "KEEP"
|
||||
password: Optional[str] = Field(default=None, min_length=4, max_length=64)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_password_change(self):
|
||||
if self.password_mode == "SET" and not self.password:
|
||||
raise ValueError("设置链接密码时必须提供密码")
|
||||
if self.password_mode != "SET" and self.password is not None:
|
||||
raise ValueError("仅在设置链接密码时允许提交密码")
|
||||
return self
|
||||
|
||||
|
||||
class CollaborationShareLinkRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
file_id: uuid.UUID
|
||||
enabled: bool
|
||||
access_mode: CollaborationShareAccessMode
|
||||
expiry_policy: CollaborationShareExpiryPolicy
|
||||
expires_at: Optional[datetime]
|
||||
has_password: bool
|
||||
share_path: str = "/collaboration/share"
|
||||
share_token: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class CollaborationPublicShareMetadata(BaseModel):
|
||||
file_name: str
|
||||
file_type: CollaborationFileType
|
||||
access_mode: Literal["view", "edit"]
|
||||
allow_export: bool
|
||||
requires_password: bool
|
||||
expires_at: Optional[datetime]
|
||||
|
||||
|
||||
class CollaborationSharePasswordRequest(BaseModel):
|
||||
password: str = Field(min_length=1, max_length=64)
|
||||
|
||||
|
||||
class CollaborationShareAccessGrant(BaseModel):
|
||||
access_token: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class CollaborationPublicEditorConfigRequest(BaseModel):
|
||||
access_token: Optional[str] = Field(default=None, max_length=2048)
|
||||
client_id: str = Field(min_length=8, max_length=64, pattern=r"^[A-Za-z0-9_-]+$")
|
||||
display_name: str = Field(default="链接访客", min_length=1, max_length=40)
|
||||
|
||||
@field_validator("display_name")
|
||||
@classmethod
|
||||
def normalize_display_name(cls, value: str) -> str:
|
||||
return value.strip() or "链接访客"
|
||||
|
||||
|
||||
class CollaborationCandidateRead(BaseModel):
|
||||
user_id: uuid.UUID
|
||||
full_name: str
|
||||
email: str
|
||||
role_in_study: str
|
||||
can_be_editor: bool
|
||||
can_be_manager: bool
|
||||
|
||||
|
||||
class CollaborationEditorConfigRead(BaseModel):
|
||||
file_id: uuid.UUID
|
||||
file_name: str
|
||||
access_mode: Literal["view", "edit"]
|
||||
can_save_as: bool = False
|
||||
can_download: bool = False
|
||||
can_request_edit: bool = False
|
||||
host_path: str = "/onlyoffice-host.html"
|
||||
expires_at: datetime
|
||||
config: dict
|
||||
|
||||
|
||||
class CollaborationExportRecord(BaseModel):
|
||||
file_type: str = Field(min_length=1, max_length=16, pattern=r"^[a-z0-9]+$")
|
||||
|
||||
|
||||
class CollaborationCallbackPayload(BaseModel):
|
||||
key: str
|
||||
status: int
|
||||
url: Optional[str] = None
|
||||
changesurl: Optional[str] = None
|
||||
filetype: Optional[str] = None
|
||||
forcesavetype: Optional[int] = None
|
||||
userdata: Optional[str] = None
|
||||
users: list[str] = Field(default_factory=list)
|
||||
actions: list[dict] = Field(default_factory=list)
|
||||
history: Optional[dict] = None
|
||||
|
||||
|
||||
class CollaborationRestoreRequest(BaseModel):
|
||||
change_summary: Optional[str] = Field(default=None, max_length=500)
|
||||
@@ -46,3 +46,25 @@ class DesktopNotificationClaimResponse(BaseModel):
|
||||
class DesktopNotificationAckRequest(BaseModel):
|
||||
claim_token: uuid.UUID
|
||||
delivered_ids: list[uuid.UUID]
|
||||
|
||||
|
||||
class GeneralNotificationRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
study_id: uuid.UUID
|
||||
recipient_id: uuid.UUID
|
||||
category: str
|
||||
priority: str
|
||||
title: str
|
||||
message: str
|
||||
action_path: str | None = None
|
||||
source_type: str
|
||||
source_id: str
|
||||
read_at: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class GeneralNotificationFeed(BaseModel):
|
||||
unread_count: int
|
||||
items: list[GeneralNotificationRead]
|
||||
|
||||
@@ -6,7 +6,7 @@ from pydantic import BaseModel
|
||||
|
||||
|
||||
class OnlyOfficePreviewConfigRead(BaseModel):
|
||||
resource_type: Literal["attachment", "version"]
|
||||
resource_type: Literal["attachment", "version", "collaboration_revision"]
|
||||
resource_id: uuid.UUID
|
||||
file_name: str
|
||||
host_path: str = "/onlyoffice-host.html"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,302 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import hmac
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from anyio import to_thread
|
||||
from fastapi import HTTPException, status
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import hash_password, verify_password
|
||||
from app.models.collaboration import CollaborationFile, CollaborationShareLink
|
||||
from app.schemas.collaboration import (
|
||||
CollaborationPublicShareMetadata,
|
||||
CollaborationShareAccessGrant,
|
||||
CollaborationShareLinkRead,
|
||||
CollaborationShareLinkUpdate,
|
||||
)
|
||||
from app.services import collaboration_service
|
||||
|
||||
|
||||
SHARE_PATH = "/collaboration/share"
|
||||
SHARE_ACCESS_TTL_SECONDS = 30 * 60
|
||||
PASSWORD_FAILURE_WINDOW = timedelta(minutes=15)
|
||||
PASSWORD_LOCK_DURATION = timedelta(minutes=15)
|
||||
PASSWORD_FAILURE_LIMIT = 5
|
||||
_ACCESS_PURPOSE = "ctms-collaboration-share-access"
|
||||
_EXPIRY_DURATIONS = {
|
||||
"ONE_DAY": timedelta(days=1),
|
||||
"SEVEN_DAYS": timedelta(days=7),
|
||||
"THIRTY_DAYS": timedelta(days=30),
|
||||
"PERMANENT": None,
|
||||
}
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _signing_key() -> bytes:
|
||||
return hmac.new(
|
||||
settings.JWT_SECRET_KEY.encode("utf-8"),
|
||||
b"ctms-collaboration-share-v1",
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
|
||||
|
||||
def _b64encode(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64decode(value: str) -> bytes:
|
||||
padding = "=" * (-len(value) % 4)
|
||||
return base64.urlsafe_b64decode(f"{value}{padding}".encode("ascii"))
|
||||
|
||||
|
||||
def share_token(link: CollaborationShareLink) -> str:
|
||||
payload = f"{link.id}.{link.token_version}".encode("ascii")
|
||||
signature = hmac.new(_signing_key(), payload, hashlib.sha256).digest()
|
||||
return f"{_b64encode(payload)}.{_b64encode(signature)}"
|
||||
|
||||
|
||||
def _decode_share_token(value: str | None) -> tuple[uuid.UUID, int]:
|
||||
token = (value or "").strip()
|
||||
if not token or len(token) > 256 or token.count(".") != 1:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享链接不存在或已失效")
|
||||
encoded_payload, encoded_signature = token.split(".", 1)
|
||||
try:
|
||||
payload = _b64decode(encoded_payload)
|
||||
actual_signature = _b64decode(encoded_signature)
|
||||
if (
|
||||
not hmac.compare_digest(_b64encode(payload), encoded_payload)
|
||||
or not hmac.compare_digest(_b64encode(actual_signature), encoded_signature)
|
||||
):
|
||||
raise ValueError("non-canonical token encoding")
|
||||
expected_signature = hmac.new(_signing_key(), payload, hashlib.sha256).digest()
|
||||
if not hmac.compare_digest(actual_signature, expected_signature):
|
||||
raise ValueError("signature mismatch")
|
||||
raw_id, raw_version = payload.decode("ascii").split(".", 1)
|
||||
return uuid.UUID(raw_id), int(raw_version)
|
||||
except (ValueError, UnicodeError, TypeError, binascii.Error) as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享链接不存在或已失效") from exc
|
||||
|
||||
|
||||
def _expiry_for_policy(policy: str, now: datetime) -> datetime | None:
|
||||
duration = _EXPIRY_DURATIONS[policy]
|
||||
return now + duration if duration else None
|
||||
|
||||
|
||||
def _is_expired(link: CollaborationShareLink, now: datetime | None = None) -> bool:
|
||||
return bool(link.expires_at and link.expires_at <= (now or _now()))
|
||||
|
||||
|
||||
async def _link_for_file(
|
||||
db: AsyncSession,
|
||||
item: CollaborationFile,
|
||||
user,
|
||||
*,
|
||||
create: bool,
|
||||
lock: bool = False,
|
||||
) -> CollaborationShareLink | None:
|
||||
await collaboration_service.require_file_manager(db, item, user)
|
||||
statement = select(CollaborationShareLink).where(CollaborationShareLink.file_id == item.id)
|
||||
if lock:
|
||||
statement = statement.with_for_update()
|
||||
link = await db.scalar(statement)
|
||||
if link or not create:
|
||||
return link
|
||||
now = _now()
|
||||
link = CollaborationShareLink(
|
||||
file_id=item.id,
|
||||
enabled=False,
|
||||
access_mode="VIEW",
|
||||
expiry_policy="SEVEN_DAYS",
|
||||
expires_at=now + timedelta(days=7),
|
||||
created_by=user.id,
|
||||
updated_by=user.id,
|
||||
)
|
||||
db.add(link)
|
||||
await db.flush()
|
||||
return link
|
||||
|
||||
|
||||
def share_link_read(link: CollaborationShareLink) -> CollaborationShareLinkRead:
|
||||
return CollaborationShareLinkRead(
|
||||
id=link.id,
|
||||
file_id=link.file_id,
|
||||
enabled=link.enabled,
|
||||
access_mode=link.access_mode,
|
||||
expiry_policy=link.expiry_policy,
|
||||
expires_at=link.expires_at,
|
||||
has_password=bool(link.password_hash),
|
||||
share_path=SHARE_PATH,
|
||||
share_token=share_token(link) if link.enabled else None,
|
||||
created_at=link.created_at,
|
||||
updated_at=link.updated_at,
|
||||
)
|
||||
|
||||
|
||||
async def get_share_link(
|
||||
db: AsyncSession, item: CollaborationFile, user
|
||||
) -> CollaborationShareLinkRead:
|
||||
link = await _link_for_file(db, item, user, create=True)
|
||||
assert link is not None
|
||||
await db.commit()
|
||||
await db.refresh(link)
|
||||
return share_link_read(link)
|
||||
|
||||
|
||||
async def update_share_link(
|
||||
db: AsyncSession,
|
||||
item: CollaborationFile,
|
||||
payload: CollaborationShareLinkUpdate,
|
||||
user,
|
||||
) -> CollaborationShareLinkRead:
|
||||
link = await _link_for_file(db, item, user, create=True, lock=True)
|
||||
assert link is not None
|
||||
now = _now()
|
||||
link.enabled = payload.enabled
|
||||
link.access_mode = payload.access_mode
|
||||
link.expiry_policy = payload.expiry_policy
|
||||
link.expires_at = _expiry_for_policy(payload.expiry_policy, now)
|
||||
link.updated_by = user.id
|
||||
if payload.password_mode == "SET":
|
||||
link.password_hash = await to_thread.run_sync(hash_password, payload.password or "")
|
||||
link.failed_attempts = 0
|
||||
link.last_failed_at = None
|
||||
link.locked_until = None
|
||||
elif payload.password_mode == "CLEAR":
|
||||
link.password_hash = None
|
||||
link.failed_attempts = 0
|
||||
link.last_failed_at = None
|
||||
link.locked_until = None
|
||||
await collaboration_service._audit(
|
||||
db,
|
||||
item,
|
||||
"COLLABORATION_SHARE_LINK_UPDATED",
|
||||
user,
|
||||
{
|
||||
"enabled": link.enabled,
|
||||
"access_mode": link.access_mode,
|
||||
"file_allow_export": item.allow_export,
|
||||
"expiry_policy": link.expiry_policy,
|
||||
"has_password": bool(link.password_hash),
|
||||
},
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(link)
|
||||
return share_link_read(link)
|
||||
|
||||
|
||||
async def resolve_active_share(
|
||||
db: AsyncSession,
|
||||
token: str | None,
|
||||
*,
|
||||
lock: bool = False,
|
||||
) -> tuple[CollaborationShareLink, CollaborationFile]:
|
||||
link_id, version = _decode_share_token(token)
|
||||
statement = select(CollaborationShareLink).where(CollaborationShareLink.id == link_id)
|
||||
if lock:
|
||||
statement = statement.with_for_update()
|
||||
link = await db.scalar(statement)
|
||||
if not link or link.token_version != version or not link.enabled:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享链接不存在或已失效")
|
||||
if _is_expired(link):
|
||||
raise HTTPException(status_code=status.HTTP_410_GONE, detail="共享链接已过期")
|
||||
item = await db.get(CollaborationFile, link.file_id)
|
||||
if not item or item.deleted_at or item.status != "ACTIVE":
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享文件不存在或已停止共享")
|
||||
return link, item
|
||||
|
||||
|
||||
async def public_metadata(
|
||||
db: AsyncSession, token: str | None
|
||||
) -> CollaborationPublicShareMetadata:
|
||||
link, item = await resolve_active_share(db, token)
|
||||
return CollaborationPublicShareMetadata(
|
||||
file_name=item.title,
|
||||
file_type=item.file_type,
|
||||
access_mode="edit" if link.access_mode == "EDIT" else "view",
|
||||
allow_export=item.allow_export,
|
||||
requires_password=bool(link.password_hash),
|
||||
expires_at=link.expires_at,
|
||||
)
|
||||
|
||||
|
||||
def _grant_token(link: CollaborationShareLink) -> CollaborationShareAccessGrant:
|
||||
now = _now()
|
||||
expires_at = now + timedelta(seconds=SHARE_ACCESS_TTL_SECONDS)
|
||||
if link.expires_at and link.expires_at < expires_at:
|
||||
expires_at = link.expires_at
|
||||
value = jwt.encode(
|
||||
{
|
||||
"purpose": _ACCESS_PURPOSE,
|
||||
"sub": str(link.id),
|
||||
"ver": link.token_version,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int(expires_at.timestamp()),
|
||||
},
|
||||
_signing_key().hex(),
|
||||
algorithm="HS256",
|
||||
)
|
||||
return CollaborationShareAccessGrant(access_token=value, expires_at=expires_at)
|
||||
|
||||
|
||||
async def verify_share_password(
|
||||
db: AsyncSession,
|
||||
token: str | None,
|
||||
password: str,
|
||||
) -> CollaborationShareAccessGrant:
|
||||
link, _ = await resolve_active_share(db, token, lock=True)
|
||||
if not link.password_hash:
|
||||
return _grant_token(link)
|
||||
now = _now()
|
||||
if link.locked_until and link.locked_until > now:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="密码尝试次数过多,请稍后再试",
|
||||
)
|
||||
if link.last_failed_at and now - link.last_failed_at > PASSWORD_FAILURE_WINDOW:
|
||||
link.failed_attempts = 0
|
||||
valid = await to_thread.run_sync(verify_password, password, link.password_hash)
|
||||
if not valid:
|
||||
link.failed_attempts += 1
|
||||
link.last_failed_at = now
|
||||
if link.failed_attempts >= PASSWORD_FAILURE_LIMIT:
|
||||
link.locked_until = now + PASSWORD_LOCK_DURATION
|
||||
await db.commit()
|
||||
if link.locked_until:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="密码尝试次数过多,请稍后再试",
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="链接密码不正确")
|
||||
link.failed_attempts = 0
|
||||
link.last_failed_at = None
|
||||
link.locked_until = None
|
||||
await db.commit()
|
||||
return _grant_token(link)
|
||||
|
||||
|
||||
def validate_access_grant(link: CollaborationShareLink, value: str | None) -> None:
|
||||
if not link.password_hash:
|
||||
return
|
||||
if not value:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请输入链接密码")
|
||||
try:
|
||||
payload = jwt.decode(value, _signing_key().hex(), algorithms=["HS256"])
|
||||
if (
|
||||
payload.get("purpose") != _ACCESS_PURPOSE
|
||||
or not hmac.compare_digest(str(payload.get("sub") or ""), str(link.id))
|
||||
or payload.get("ver") != link.token_version
|
||||
):
|
||||
raise JWTError("share grant mismatch")
|
||||
except JWTError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="链接访问凭证已失效") from exc
|
||||
@@ -0,0 +1,186 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Iterable
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.notification import Notification
|
||||
from app.schemas.notification import GeneralNotificationFeed
|
||||
|
||||
|
||||
async def create_recipient_notifications(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
recipient_ids: Iterable[uuid.UUID],
|
||||
category: str,
|
||||
priority: str,
|
||||
title: str,
|
||||
message: str,
|
||||
action_path: str | None,
|
||||
source_type: str,
|
||||
source_id: str,
|
||||
dedupe_key: str,
|
||||
) -> None:
|
||||
recipients = set(recipient_ids)
|
||||
if not recipients:
|
||||
return
|
||||
existing = set((await db.scalars(
|
||||
select(Notification.recipient_id).where(
|
||||
Notification.recipient_id.in_(recipients),
|
||||
Notification.dedupe_key == dedupe_key,
|
||||
)
|
||||
)).all())
|
||||
for recipient_id in recipients - existing:
|
||||
db.add(Notification(
|
||||
study_id=study_id,
|
||||
recipient_id=recipient_id,
|
||||
category=category,
|
||||
priority=priority,
|
||||
title=title,
|
||||
message=message,
|
||||
action_path=action_path,
|
||||
source_type=source_type,
|
||||
source_id=source_id,
|
||||
dedupe_key=dedupe_key,
|
||||
))
|
||||
|
||||
|
||||
async def sync_aggregate_notification(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
recipient_id: uuid.UUID,
|
||||
category: str,
|
||||
priority: str,
|
||||
title: str,
|
||||
message: str,
|
||||
action_path: str,
|
||||
source_type: str,
|
||||
source_id: str,
|
||||
count: int,
|
||||
) -> None:
|
||||
dedupe_key = f"aggregate:{source_type}:{source_id}"
|
||||
item = await db.scalar(select(Notification).where(
|
||||
Notification.recipient_id == recipient_id,
|
||||
Notification.dedupe_key == dedupe_key,
|
||||
))
|
||||
now = datetime.now(timezone.utc)
|
||||
if count <= 0:
|
||||
if item and item.resolved_at is None:
|
||||
item.resolved_at = now
|
||||
item.read_at = item.read_at or now
|
||||
return
|
||||
version = str(count)
|
||||
if item is None:
|
||||
db.add(Notification(
|
||||
study_id=study_id,
|
||||
recipient_id=recipient_id,
|
||||
category=category,
|
||||
priority=priority,
|
||||
title=title,
|
||||
message=message,
|
||||
action_path=action_path,
|
||||
source_type=source_type,
|
||||
source_id=source_id,
|
||||
source_version=version,
|
||||
dedupe_key=dedupe_key,
|
||||
))
|
||||
return
|
||||
previous_count = int(item.source_version or 0)
|
||||
item.priority = priority
|
||||
item.title = title
|
||||
item.message = message
|
||||
item.action_path = action_path
|
||||
item.source_version = version
|
||||
if item.resolved_at is not None or count > previous_count:
|
||||
item.read_at = None
|
||||
item.created_at = now
|
||||
item.resolved_at = None
|
||||
|
||||
|
||||
async def resolve_source_notifications(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
source_type: str,
|
||||
source_id: str,
|
||||
) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
await db.execute(
|
||||
update(Notification)
|
||||
.where(
|
||||
Notification.source_type == source_type,
|
||||
Notification.source_id == source_id,
|
||||
Notification.resolved_at.is_(None),
|
||||
)
|
||||
.values(resolved_at=now, read_at=func.coalesce(Notification.read_at, now))
|
||||
)
|
||||
|
||||
|
||||
async def list_feed(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
recipient_id: uuid.UUID,
|
||||
limit: int = 10,
|
||||
) -> GeneralNotificationFeed:
|
||||
active_filter = (
|
||||
Notification.study_id == study_id,
|
||||
Notification.recipient_id == recipient_id,
|
||||
Notification.resolved_at.is_(None),
|
||||
)
|
||||
unread_count = int(await db.scalar(
|
||||
select(func.count(Notification.id)).where(*active_filter, Notification.read_at.is_(None))
|
||||
) or 0)
|
||||
items = (await db.scalars(
|
||||
select(Notification)
|
||||
.where(*active_filter)
|
||||
.order_by(Notification.read_at.is_not(None), Notification.created_at.desc())
|
||||
.limit(max(1, min(limit, 50)))
|
||||
)).all()
|
||||
return GeneralNotificationFeed(unread_count=unread_count, items=list(items))
|
||||
|
||||
|
||||
async def mark_read(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
recipient_id: uuid.UUID,
|
||||
notification_id: uuid.UUID,
|
||||
) -> Notification:
|
||||
item = await db.scalar(select(Notification).where(
|
||||
Notification.id == notification_id,
|
||||
Notification.study_id == study_id,
|
||||
Notification.recipient_id == recipient_id,
|
||||
Notification.resolved_at.is_(None),
|
||||
))
|
||||
if item is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="通知不存在")
|
||||
if item.read_at is None:
|
||||
item.read_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
async def mark_all_read(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
recipient_id: uuid.UUID,
|
||||
) -> None:
|
||||
await db.execute(
|
||||
update(Notification)
|
||||
.where(
|
||||
Notification.study_id == study_id,
|
||||
Notification.recipient_id == recipient_id,
|
||||
Notification.resolved_at.is_(None),
|
||||
Notification.read_at.is_(None),
|
||||
)
|
||||
.values(read_at=datetime.now(timezone.utc))
|
||||
)
|
||||
await db.commit()
|
||||
@@ -0,0 +1,422 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, Request, status
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.collaboration import (
|
||||
CollaborationCallbackReceipt,
|
||||
CollaborationFile,
|
||||
CollaborationRevision,
|
||||
CollaborationSession,
|
||||
CollaborationShareLink,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.schemas.collaboration import CollaborationCallbackPayload, CollaborationEditorConfigRead
|
||||
from app.services import collaboration_service, onlyoffice_service
|
||||
|
||||
|
||||
def collaboration_document_key(file_id: uuid.UUID, generation: int) -> str:
|
||||
fingerprint = f"{settings.ONLYOFFICE_INSTANCE_ID or ''}:collaboration:{file_id}:{generation}"
|
||||
return f"ctms-collab-{hashlib.sha256(fingerprint.encode('utf-8')).hexdigest()}"
|
||||
|
||||
|
||||
def _content_url(session_id: uuid.UUID) -> str:
|
||||
return (
|
||||
f"{settings.ONLYOFFICE_STORAGE_BASE_URL.rstrip('/')}"
|
||||
f"/internal/onlyoffice/collaboration/sessions/{session_id}/content"
|
||||
)
|
||||
|
||||
|
||||
def _callback_url(session_id: uuid.UUID) -> str:
|
||||
return (
|
||||
f"{settings.ONLYOFFICE_STORAGE_BASE_URL.rstrip('/')}"
|
||||
f"/internal/onlyoffice/collaboration/sessions/{session_id}/callback"
|
||||
)
|
||||
|
||||
|
||||
async def _active_session(
|
||||
db: AsyncSession, item: CollaborationFile, user_id: uuid.UUID
|
||||
) -> CollaborationSession:
|
||||
session = await db.scalar(
|
||||
select(CollaborationSession).where(
|
||||
CollaborationSession.file_id == item.id,
|
||||
CollaborationSession.generation == item.generation,
|
||||
).order_by(CollaborationSession.created_at.desc())
|
||||
)
|
||||
if session:
|
||||
if session.status != "ACTIVE":
|
||||
session.status = "ACTIVE"
|
||||
session.closed_at = None
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
return session
|
||||
if not item.current_revision_id:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作文件尚无可编辑内容")
|
||||
session = CollaborationSession(
|
||||
file_id=item.id,
|
||||
base_revision_id=item.current_revision_id,
|
||||
document_key=collaboration_document_key(item.id, item.generation),
|
||||
generation=item.generation,
|
||||
started_by=user_id,
|
||||
)
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
return session
|
||||
|
||||
|
||||
async def build_editor_config(
|
||||
db: AsyncSession, item: CollaborationFile, user
|
||||
) -> CollaborationEditorConfigRead:
|
||||
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():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件内容不存在")
|
||||
can_edit = await collaboration_service.can_edit_file(db, item, user)
|
||||
can_request_edit = await collaboration_service.can_request_edit_file(db, item, user)
|
||||
can_download = await collaboration_service.can_export_file(db, item, user)
|
||||
can_save_as = can_download and await collaboration_service.can_create_file(db, item, user)
|
||||
session = await _active_session(db, item, user.id)
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = now + timedelta(seconds=settings.ONLYOFFICE_CONFIG_TTL_SECONDS)
|
||||
config: dict[str, Any] = {
|
||||
"type": "desktop",
|
||||
"documentType": item.file_type,
|
||||
"document": {
|
||||
"fileType": item.extension,
|
||||
"key": session.document_key,
|
||||
"title": item.title,
|
||||
"url": _content_url(session.id),
|
||||
"permissions": {
|
||||
"chat": False,
|
||||
"copy": can_download,
|
||||
"comment": can_edit,
|
||||
"download": can_download,
|
||||
# In view mode ONLYOFFICE displays "Edit current file" only
|
||||
# when edit=true and onRequestEditRights is registered. CTMS
|
||||
# handles that event as an approval request, not an escalation.
|
||||
"edit": can_edit or can_request_edit,
|
||||
"fillForms": False,
|
||||
"modifyContentControl": can_edit,
|
||||
"modifyFilter": can_edit,
|
||||
"print": can_download,
|
||||
"protect": False,
|
||||
"review": False,
|
||||
},
|
||||
},
|
||||
"editorConfig": {
|
||||
"callbackUrl": _callback_url(session.id),
|
||||
"coEditing": {"mode": "fast", "change": False},
|
||||
"customization": {
|
||||
"autosave": True,
|
||||
"chat": False,
|
||||
"comments": can_edit,
|
||||
"forcesave": can_edit,
|
||||
"help": False,
|
||||
"plugins": False,
|
||||
},
|
||||
"lang": "zh-CN",
|
||||
"mode": "edit" if can_edit else "view",
|
||||
"user": {"id": str(user.id), "name": user.full_name},
|
||||
},
|
||||
}
|
||||
config["token"] = jwt.encode(
|
||||
{**config, "iat": int(now.timestamp()), "exp": int(expires_at.timestamp())},
|
||||
settings.ONLYOFFICE_JWT_SECRET or "",
|
||||
algorithm="HS256",
|
||||
)
|
||||
return CollaborationEditorConfigRead(
|
||||
file_id=item.id,
|
||||
file_name=item.title,
|
||||
access_mode="edit" if can_edit else "view",
|
||||
can_save_as=can_save_as,
|
||||
can_download=can_download,
|
||||
can_request_edit=can_request_edit,
|
||||
expires_at=expires_at,
|
||||
config=config,
|
||||
)
|
||||
|
||||
|
||||
async def build_shared_editor_config(
|
||||
db: AsyncSession,
|
||||
item: CollaborationFile,
|
||||
link: CollaborationShareLink,
|
||||
*,
|
||||
client_id: str,
|
||||
display_name: str,
|
||||
) -> CollaborationEditorConfigRead:
|
||||
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():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享文件内容不存在")
|
||||
can_edit = link.access_mode == "EDIT"
|
||||
session = await _active_session(db, item, item.owner_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = now + timedelta(seconds=settings.ONLYOFFICE_CONFIG_TTL_SECONDS)
|
||||
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]}"
|
||||
config: dict[str, Any] = {
|
||||
"type": "desktop",
|
||||
"documentType": item.file_type,
|
||||
"document": {
|
||||
"fileType": item.extension,
|
||||
"key": session.document_key,
|
||||
"title": item.title,
|
||||
"url": _content_url(session.id),
|
||||
"permissions": {
|
||||
"chat": False,
|
||||
"copy": item.allow_export,
|
||||
"comment": can_edit,
|
||||
"download": item.allow_export,
|
||||
"edit": can_edit,
|
||||
"fillForms": False,
|
||||
"modifyContentControl": can_edit,
|
||||
"modifyFilter": can_edit,
|
||||
"print": item.allow_export,
|
||||
"protect": False,
|
||||
"review": False,
|
||||
},
|
||||
},
|
||||
"editorConfig": {
|
||||
"callbackUrl": _callback_url(session.id),
|
||||
"coEditing": {"mode": "fast", "change": False},
|
||||
"customization": {
|
||||
"autosave": can_edit,
|
||||
"chat": False,
|
||||
"comments": can_edit,
|
||||
"forcesave": can_edit,
|
||||
"help": False,
|
||||
"plugins": False,
|
||||
},
|
||||
"lang": "zh-CN",
|
||||
"mode": "edit" if can_edit else "view",
|
||||
"user": {"id": external_user_id, "name": display_name},
|
||||
},
|
||||
}
|
||||
config["token"] = jwt.encode(
|
||||
{**config, "iat": int(now.timestamp()), "exp": int(expires_at.timestamp())},
|
||||
settings.ONLYOFFICE_JWT_SECRET or "",
|
||||
algorithm="HS256",
|
||||
)
|
||||
return CollaborationEditorConfigRead(
|
||||
file_id=item.id,
|
||||
file_name=item.title,
|
||||
access_mode="edit" if can_edit else "view",
|
||||
can_save_as=False,
|
||||
can_download=item.allow_export,
|
||||
expires_at=expires_at,
|
||||
config=config,
|
||||
)
|
||||
|
||||
|
||||
async def get_session_content(
|
||||
db: AsyncSession, session_id: uuid.UUID, authorization: str | None
|
||||
) -> tuple[CollaborationRevision, CollaborationFile]:
|
||||
session = await db.get(CollaborationSession, session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作会话不存在")
|
||||
onlyoffice_service.validate_outbox_token(authorization, _content_url(session_id))
|
||||
revision = await db.get(CollaborationRevision, session.base_revision_id)
|
||||
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="协作文件内容不存在")
|
||||
return revision, item
|
||||
|
||||
|
||||
def validate_callback_token(token: str | None, payload: CollaborationCallbackPayload) -> dict[str, Any]:
|
||||
if not token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="缺少 ONLYOFFICE 回调签名")
|
||||
value = token.strip()
|
||||
if " " in value:
|
||||
scheme, credential = value.split(" ", 1)
|
||||
if scheme.lower() != "bearer":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调签名格式无效")
|
||||
value = credential.strip()
|
||||
try:
|
||||
decoded = onlyoffice_service.decode_onlyoffice_token(value)
|
||||
except JWTError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调签名无效") from exc
|
||||
signed = decoded.get("payload") if isinstance(decoded.get("payload"), dict) else decoded
|
||||
if not isinstance(signed, dict):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调载荷无效")
|
||||
signed_key = signed.get("key")
|
||||
signed_status = signed.get("status")
|
||||
if not isinstance(signed_key, str) or not hmac.compare_digest(signed_key, payload.key):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调 key 不匹配")
|
||||
if not isinstance(signed_status, int) or signed_status != payload.status:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调状态不匹配")
|
||||
if payload.url:
|
||||
signed_url = signed.get("url")
|
||||
if not isinstance(signed_url, str) or not hmac.compare_digest(signed_url, payload.url):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调文件地址不匹配")
|
||||
return decoded
|
||||
|
||||
|
||||
def _callback_fingerprint(payload: CollaborationCallbackPayload) -> str:
|
||||
normalized = payload.model_dump(mode="json", exclude_none=True)
|
||||
return hashlib.sha256(json.dumps(normalized, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
|
||||
|
||||
def _url_origin_matches(actual, expected) -> bool:
|
||||
actual_port = actual.port or (443 if actual.scheme == "https" else 80)
|
||||
expected_port = expected.port or (443 if expected.scheme == "https" else 80)
|
||||
return (
|
||||
actual.scheme == expected.scheme
|
||||
and actual.hostname
|
||||
and actual.hostname.lower() == (expected.hostname or "").lower()
|
||||
and actual_port == expected_port
|
||||
)
|
||||
|
||||
|
||||
def _validate_result_url(url: str) -> str:
|
||||
actual = urlsplit(url)
|
||||
if (
|
||||
actual.scheme not in {"http", "https"}
|
||||
or actual.username
|
||||
or actual.password
|
||||
or actual.fragment
|
||||
or not actual.hostname
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ONLYOFFICE 保存地址不受信任")
|
||||
|
||||
internal = urlsplit(settings.ONLYOFFICE_INTERNAL_URL.rstrip("/"))
|
||||
if _url_origin_matches(actual, internal):
|
||||
return urlunsplit((internal.scheme, internal.netloc, actual.path, actual.query, ""))
|
||||
|
||||
public = urlsplit(settings.FRONTEND_PUBLIC_URL.rstrip("/"))
|
||||
proxy_prefix = "/onlyoffice/"
|
||||
if not _url_origin_matches(actual, public) or not actual.path.startswith(proxy_prefix):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ONLYOFFICE 保存地址不受信任")
|
||||
internal_path = f"{internal.path.rstrip('/')}/{actual.path[len(proxy_prefix):]}"
|
||||
return urlunsplit((internal.scheme, internal.netloc, internal_path, actual.query, ""))
|
||||
|
||||
|
||||
async def _download_result(url: str) -> bytes:
|
||||
download_url = _validate_result_url(url)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=False) as client:
|
||||
async with client.stream("GET", download_url) as response:
|
||||
if response.status_code != status.HTTP_200_OK:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="ONLYOFFICE 保存文件下载失败")
|
||||
content = bytearray()
|
||||
async for chunk in response.aiter_bytes():
|
||||
content.extend(chunk)
|
||||
if len(content) > settings.COLLABORATION_MAX_FILE_BYTES:
|
||||
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="ONLYOFFICE 保存文件超出限制")
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="ONLYOFFICE 保存文件下载失败") from exc
|
||||
return bytes(content)
|
||||
|
||||
|
||||
async def _callback_user(
|
||||
db: AsyncSession, payload: CollaborationCallbackPayload, session: CollaborationSession
|
||||
) -> User | None:
|
||||
has_public_share_user = False
|
||||
for value in payload.users:
|
||||
if value.startswith("share-"):
|
||||
has_public_share_user = True
|
||||
continue
|
||||
try:
|
||||
user = await db.get(User, uuid.UUID(value))
|
||||
except (ValueError, TypeError):
|
||||
user = None
|
||||
if user:
|
||||
return user
|
||||
if has_public_share_user:
|
||||
return None
|
||||
user = await db.get(User, session.started_by)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作会话用户不存在")
|
||||
return user
|
||||
|
||||
|
||||
async def process_callback(
|
||||
db: AsyncSession,
|
||||
session_id: uuid.UUID,
|
||||
payload: CollaborationCallbackPayload,
|
||||
) -> dict[str, int]:
|
||||
session = await db.scalar(
|
||||
select(CollaborationSession).where(CollaborationSession.id == session_id).with_for_update()
|
||||
)
|
||||
if not session:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作会话不存在")
|
||||
if not hmac.compare_digest(session.document_key, payload.key):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作会话 key 不匹配")
|
||||
fingerprint = _callback_fingerprint(payload)
|
||||
duplicate = await db.scalar(select(CollaborationCallbackReceipt.id).where(
|
||||
CollaborationCallbackReceipt.session_id == session.id,
|
||||
CollaborationCallbackReceipt.fingerprint == fingerprint,
|
||||
))
|
||||
if duplicate:
|
||||
return {"error": 0}
|
||||
|
||||
item = await db.get(CollaborationFile, session.file_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件不存在")
|
||||
session.last_callback_at = datetime.now(timezone.utc)
|
||||
session.active_users = json.dumps(payload.users, ensure_ascii=True)
|
||||
result = "ACKNOWLEDGED"
|
||||
saved_revision_id = None
|
||||
|
||||
if payload.status in {2, 6}:
|
||||
if not payload.url:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ONLYOFFICE 保存回调缺少文件地址")
|
||||
if session.generation != item.generation:
|
||||
result = "STALE"
|
||||
else:
|
||||
content = await _download_result(payload.url)
|
||||
actor = await _callback_user(db, payload, session)
|
||||
source = "SESSION_CLOSE" if payload.status == 2 else "FORCE_SAVE"
|
||||
if actor is None:
|
||||
source = "SHARE_SESSION_CLOSE" if payload.status == 2 else "SHARE_FORCE_SAVE"
|
||||
revision, created = await collaboration_service.append_revision(
|
||||
db, item, content, source=source, created_by=actor.id if actor else None
|
||||
)
|
||||
saved_revision_id = revision.id
|
||||
result = "SAVED" if created else "UNCHANGED"
|
||||
if payload.status == 2:
|
||||
item.generation += 1
|
||||
session.status = "CLOSED"
|
||||
session.closed_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
# 强制保存不结束当前共同编辑会话;同步基线可保证 Document
|
||||
# Server 缓存重建时仍从最近一次持久化内容恢复。
|
||||
session.base_revision_id = revision.id
|
||||
if created and actor:
|
||||
await collaboration_service._audit(
|
||||
db, item, "COLLABORATION_REVISION_SAVED", actor,
|
||||
{"revision_no": revision.revision_no, "source": source},
|
||||
)
|
||||
elif payload.status == 4:
|
||||
session.status = "CLOSED"
|
||||
session.closed_at = datetime.now(timezone.utc)
|
||||
result = "UNCHANGED"
|
||||
elif payload.status in {3, 7}:
|
||||
session.status = "ERROR"
|
||||
result = "ERROR"
|
||||
|
||||
db.add(CollaborationCallbackReceipt(
|
||||
session_id=session.id,
|
||||
fingerprint=fingerprint,
|
||||
callback_status=payload.status,
|
||||
result=result,
|
||||
revision_id=saved_revision_id,
|
||||
))
|
||||
await db.commit()
|
||||
# ONLYOFFICE 要求回调处理器在接收并记录状态后固定确认成功。
|
||||
# status 3/7 表示文档服务自身保存失败,不应通过 error=1 制造重试环。
|
||||
return {"error": 0}
|
||||
@@ -18,7 +18,7 @@ from app.core.exceptions import AppException
|
||||
from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead
|
||||
|
||||
OnlyOfficeDocumentType = Literal["word", "cell", "slide"]
|
||||
OnlyOfficeResourceType = Literal["attachment", "version"]
|
||||
OnlyOfficeResourceType = Literal["attachment", "version", "collaboration_revision"]
|
||||
|
||||
WORD_FORMATS = frozenset({
|
||||
"doc", "docx", "docm", "dot", "dotx", "dotm", "odt", "ott", "rtf", "txt", "wps", "wpt",
|
||||
@@ -52,7 +52,11 @@ def office_format_for_filename(filename: str) -> tuple[str, OnlyOfficeDocumentTy
|
||||
|
||||
|
||||
def onlyoffice_content_url(resource_type: OnlyOfficeResourceType, resource_id: uuid.UUID) -> str:
|
||||
plural = "attachments" if resource_type == "attachment" else "versions"
|
||||
plural = {
|
||||
"attachment": "attachments",
|
||||
"version": "versions",
|
||||
"collaboration_revision": "collaboration-revisions",
|
||||
}[resource_type]
|
||||
return (
|
||||
f"{settings.ONLYOFFICE_STORAGE_BASE_URL.rstrip('/')}"
|
||||
f"/internal/onlyoffice/{plural}/{resource_id}/content"
|
||||
@@ -200,15 +204,7 @@ def validate_outbox_token(token: str | None, expected_url: str) -> dict[str, Any
|
||||
)
|
||||
token = credential.strip()
|
||||
try:
|
||||
header = jwt.get_unverified_header(token)
|
||||
if header.get("alg") != "HS256":
|
||||
raise JWTError("unexpected algorithm")
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.ONLYOFFICE_JWT_SECRET or "",
|
||||
algorithms=["HS256"],
|
||||
options={"verify_aud": False},
|
||||
)
|
||||
payload = decode_onlyoffice_token(token)
|
||||
except JWTError as exc:
|
||||
raise onlyoffice_error(
|
||||
"ONLYOFFICE_SOURCE_UNAUTHORIZED",
|
||||
@@ -225,3 +221,15 @@ def validate_outbox_token(token: str | None, expected_url: str) -> dict[str, Any
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def decode_onlyoffice_token(token: str) -> dict[str, Any]:
|
||||
header = jwt.get_unverified_header(token)
|
||||
if header.get("alg") != "HS256":
|
||||
raise JWTError("unexpected algorithm")
|
||||
return jwt.decode(
|
||||
token,
|
||||
settings.ONLYOFFICE_JWT_SECRET or "",
|
||||
algorithms=["HS256"],
|
||||
options={"verify_aud": False},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_cra_site_scope, is_system_admin
|
||||
from app.core.project_permissions import role_has_api_permission
|
||||
from app.crud import ae as ae_crud
|
||||
from app.crud import member as member_crud
|
||||
from app.crud import monitoring_visit_issue as monitoring_issue_crud
|
||||
from app.services import notification_service
|
||||
|
||||
|
||||
async def sync_project_reminders(db: AsyncSession, study_id: uuid.UUID, user) -> None:
|
||||
membership = await member_crud.get_member(db, study_id, user.id)
|
||||
if not is_system_admin(user) and (not membership or not membership.is_active):
|
||||
return
|
||||
role = membership.role_in_study if membership else ""
|
||||
can_read_aes = is_system_admin(user) or await role_has_api_permission(
|
||||
db, study_id, role, "subject_aes:read"
|
||||
)
|
||||
can_read_monitoring = is_system_admin(user) or await role_has_api_permission(
|
||||
db, study_id, role, "monitoring_issues:read"
|
||||
)
|
||||
|
||||
overdue_aes = 0
|
||||
if can_read_aes:
|
||||
cra_scope = await get_cra_site_scope(db, study_id, user)
|
||||
items = await ae_crud.list_ae(
|
||||
db,
|
||||
study_id,
|
||||
overdue=True,
|
||||
site_ids=cra_scope[0] if cra_scope else None,
|
||||
)
|
||||
overdue_aes = len(items)
|
||||
await notification_service.sync_aggregate_notification(
|
||||
db,
|
||||
study_id=study_id,
|
||||
recipient_id=user.id,
|
||||
category="RISK_OVERDUE_AE",
|
||||
priority="HIGH",
|
||||
title="逾期 AE 待处理",
|
||||
message=f"当前有 {overdue_aes} 条逾期 AE 需要跟进",
|
||||
action_path="/risk-issues/sae",
|
||||
source_type="RISK_OVERDUE_AE",
|
||||
source_id=str(study_id),
|
||||
count=overdue_aes if can_read_aes else 0,
|
||||
)
|
||||
|
||||
overdue_monitoring = 0
|
||||
if can_read_monitoring:
|
||||
overdue_monitoring = len(await monitoring_issue_crud.list_issues(
|
||||
db,
|
||||
study_id,
|
||||
overdue=True,
|
||||
limit=2000,
|
||||
))
|
||||
await notification_service.sync_aggregate_notification(
|
||||
db,
|
||||
study_id=study_id,
|
||||
recipient_id=user.id,
|
||||
category="RISK_OVERDUE_MONITORING",
|
||||
priority="HIGH",
|
||||
title="监查问题已逾期",
|
||||
message=f"当前有 {overdue_monitoring} 条监查问题已超过计划解决日期",
|
||||
action_path="/risk-issues/monitoring-visits",
|
||||
source_type="RISK_OVERDUE_MONITORING",
|
||||
source_id=str(study_id),
|
||||
count=overdue_monitoring if can_read_monitoring else 0,
|
||||
)
|
||||
await db.commit()
|
||||
@@ -272,6 +272,18 @@ def test_faq_and_precautions_are_shared_library_sibling_sections():
|
||||
assert API_ENDPOINT_PERMISSIONS["precautions:read"]["module"] == "shared_library"
|
||||
|
||||
|
||||
def test_collaboration_is_an_independent_shared_library_section():
|
||||
shared_library_keys = OPERATION_TO_ENDPOINTS["shared_library"]
|
||||
assert "collaboration:read" in shared_library_keys["read"]
|
||||
assert "collaboration:create" in shared_library_keys["write"]
|
||||
assert "collaboration:edit" in shared_library_keys["write"]
|
||||
assert "collaboration:manage" in shared_library_keys["write"]
|
||||
assert "collaboration:export" in shared_library_keys["write"]
|
||||
assert "collaboration:delete" in shared_library_keys["write"]
|
||||
assert API_ENDPOINT_PERMISSIONS["collaboration:read"]["module"] == "shared_library"
|
||||
assert API_ENDPOINT_PERMISSIONS["collaboration:read"]["default_roles"] == ["PM", "CRA", "PV", "QA", "CTA"]
|
||||
|
||||
|
||||
def test_precautions_runtime_code_uses_business_entity_names():
|
||||
"""运行时代码不应继续使用 knowledge_note 作为注意事项实体命名。"""
|
||||
backend_root = Path(__file__).resolve().parents[1] / "app"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,139 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.notification import Notification
|
||||
from app.models.collaboration import CollaborationEditRequest
|
||||
from app.services import collaboration_service, notification_service, project_reminder_service
|
||||
|
||||
|
||||
def test_generic_notification_table_has_recipient_dedupe_and_state_indexes():
|
||||
table = Notification.__table__
|
||||
assert table.name == "notifications"
|
||||
assert {"study_id", "recipient_id", "category", "action_path", "source_type", "source_id", "read_at", "resolved_at"} <= set(table.columns.keys())
|
||||
assert any(constraint.name == "uq_notifications_recipient_dedupe" for constraint in table.constraints)
|
||||
assert {index.name for index in table.indexes} >= {
|
||||
"ix_notifications_recipient_study_state",
|
||||
"ix_notifications_source",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recipient_notification_creation_is_deduplicated_per_recipient():
|
||||
existing_id = uuid.uuid4()
|
||||
new_id = uuid.uuid4()
|
||||
result = SimpleNamespace(all=lambda: [existing_id])
|
||||
db = SimpleNamespace(scalars=AsyncMock(return_value=result), add=Mock())
|
||||
|
||||
await notification_service.create_recipient_notifications(
|
||||
db,
|
||||
study_id=uuid.uuid4(),
|
||||
recipient_ids=[existing_id, new_id, new_id],
|
||||
category="COLLABORATION_EDIT_REQUEST",
|
||||
priority="NORMAL",
|
||||
title="新的编辑权限申请",
|
||||
message="申请编辑文件",
|
||||
action_path="/knowledge/collaboration?editRequestFile=file-id",
|
||||
source_type="COLLABORATION_EDIT_REQUEST",
|
||||
source_id="request-id",
|
||||
dedupe_key="collaboration-edit-request:request-id",
|
||||
)
|
||||
|
||||
db.add.assert_called_once()
|
||||
created = db.add.call_args.args[0]
|
||||
assert created.recipient_id == new_id
|
||||
assert created.source_id == "request-id"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolving_a_source_closes_every_recipient_notification():
|
||||
db = SimpleNamespace(execute=AsyncMock())
|
||||
await notification_service.resolve_source_notifications(
|
||||
db,
|
||||
source_type="COLLABORATION_EDIT_REQUEST",
|
||||
source_id="request-id",
|
||||
)
|
||||
db.execute.assert_awaited_once()
|
||||
statement = str(db.execute.await_args.args[0])
|
||||
assert "UPDATE notifications" in statement
|
||||
assert "source_type" in statement
|
||||
assert "source_id" in statement
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_risks_are_materialized_into_the_generic_notification_table(monkeypatch):
|
||||
study_id = uuid.uuid4()
|
||||
user = SimpleNamespace(id=uuid.uuid4(), is_admin=False)
|
||||
membership = SimpleNamespace(is_active=True, role_in_study="PM")
|
||||
db = SimpleNamespace(commit=AsyncMock())
|
||||
sync = AsyncMock()
|
||||
monkeypatch.setattr(project_reminder_service.member_crud, "get_member", AsyncMock(return_value=membership))
|
||||
monkeypatch.setattr(project_reminder_service, "role_has_api_permission", AsyncMock(return_value=True))
|
||||
monkeypatch.setattr(project_reminder_service, "get_cra_site_scope", AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(project_reminder_service.ae_crud, "list_ae", AsyncMock(return_value=[object(), object()]))
|
||||
monkeypatch.setattr(project_reminder_service.monitoring_issue_crud, "list_issues", AsyncMock(return_value=[object()]))
|
||||
monkeypatch.setattr(project_reminder_service.notification_service, "sync_aggregate_notification", sync)
|
||||
|
||||
await project_reminder_service.sync_project_reminders(db, study_id, user)
|
||||
|
||||
assert sync.await_count == 2
|
||||
assert sync.await_args_list[0].kwargs["count"] == 2
|
||||
assert sync.await_args_list[1].kwargs["count"] == 1
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_request_notifies_the_active_file_owner_and_managers(monkeypatch):
|
||||
study_id = uuid.uuid4()
|
||||
file_id = uuid.uuid4()
|
||||
owner_id = uuid.uuid4()
|
||||
manager_id = uuid.uuid4()
|
||||
request_id = uuid.uuid4()
|
||||
item = SimpleNamespace(
|
||||
id=file_id,
|
||||
study_id=study_id,
|
||||
owner_id=owner_id,
|
||||
title="PK_示例.xlsx",
|
||||
allow_edit_request=True,
|
||||
)
|
||||
user = SimpleNamespace(id=uuid.uuid4(), full_name="周成成", email="member@example.com")
|
||||
added = []
|
||||
|
||||
def add(value):
|
||||
added.append(value)
|
||||
|
||||
async def flush():
|
||||
request = next(value for value in added if isinstance(value, CollaborationEditRequest))
|
||||
request.id = request_id
|
||||
request.status = "PENDING"
|
||||
request.resolved_by = None
|
||||
request.resolved_at = None
|
||||
request.created_at = datetime.now(timezone.utc)
|
||||
|
||||
db = SimpleNamespace(
|
||||
scalar=AsyncMock(return_value=None),
|
||||
scalars=AsyncMock(return_value=SimpleNamespace(all=lambda: [owner_id, manager_id])),
|
||||
add=Mock(side_effect=add),
|
||||
flush=AsyncMock(side_effect=flush),
|
||||
commit=AsyncMock(),
|
||||
refresh=AsyncMock(),
|
||||
)
|
||||
notify = AsyncMock()
|
||||
monkeypatch.setattr(collaboration_service, "can_edit_file", AsyncMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
collaboration_service.member_crud,
|
||||
"get_member",
|
||||
AsyncMock(return_value=SimpleNamespace(is_active=True)),
|
||||
)
|
||||
monkeypatch.setattr(collaboration_service, "_audit", AsyncMock())
|
||||
monkeypatch.setattr(collaboration_service.notification_service, "create_recipient_notifications", notify)
|
||||
|
||||
result = await collaboration_service.create_edit_request(db, item, user)
|
||||
|
||||
assert result.id == request_id
|
||||
assert set(notify.await_args.kwargs["recipient_ids"]) == {owner_id, manager_id}
|
||||
assert notify.await_args.kwargs["action_path"] == f"/knowledge/collaboration?editRequestFile={file_id}"
|
||||
assert notify.await_args.kwargs["source_id"] == str(request_id)
|
||||
Reference in New Issue
Block a user