1005 lines
29 KiB
Python
1005 lines
29 KiB
Python
"""
|
||
InsightFlow - 协作与共享模块 (Phase 7 Task 4)
|
||
支持项目分享、评论批注、变更历史、团队空间
|
||
"""
|
||
|
||
import hashlib
|
||
import json
|
||
import uuid
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timedelta
|
||
from enum import Enum
|
||
from typing import Any
|
||
|
||
|
||
class SharePermission(Enum):
|
||
"""分享权限级别"""
|
||
|
||
READ_ONLY = "read_only" # 只读
|
||
COMMENT = "comment" # 可评论
|
||
EDIT = "edit" # 可编辑
|
||
ADMIN = "admin" # 管理员
|
||
|
||
|
||
class CommentTargetType(Enum):
|
||
"""评论目标类型"""
|
||
|
||
ENTITY = "entity" # 实体评论
|
||
RELATION = "relation" # 关系评论
|
||
TRANSCRIPT = "transcript" # 转录文本评论
|
||
PROJECT = "project" # 项目级评论
|
||
|
||
|
||
class ChangeType(Enum):
|
||
"""变更类型"""
|
||
|
||
CREATE = "create" # 创建
|
||
UPDATE = "update" # 更新
|
||
DELETE = "delete" # 删除
|
||
MERGE = "merge" # 合并
|
||
SPLIT = "split" # 拆分
|
||
|
||
|
||
@dataclass
|
||
class ProjectShare:
|
||
"""项目分享链接"""
|
||
|
||
id: str
|
||
project_id: str
|
||
token: str # 分享令牌
|
||
permission: str # 权限级别
|
||
created_by: str # 创建者
|
||
created_at: str
|
||
expires_at: str | None # 过期时间
|
||
max_uses: int | None # 最大使用次数
|
||
use_count: int # 已使用次数
|
||
password_hash: str | None # 密码保护
|
||
is_active: bool # 是否激活
|
||
allow_download: bool # 允许下载
|
||
allow_export: bool # 允许导出
|
||
|
||
|
||
@dataclass
|
||
class Comment:
|
||
"""评论/批注"""
|
||
|
||
id: str
|
||
project_id: str
|
||
target_type: str # 评论目标类型
|
||
target_id: str # 目标ID
|
||
parent_id: str | None # 父评论ID(支持回复)
|
||
author: str # 作者
|
||
author_name: str # 作者显示名
|
||
content: str # 评论内容
|
||
created_at: str
|
||
updated_at: str
|
||
resolved: bool # 是否已解决
|
||
resolved_by: str | None # 解决者
|
||
resolved_at: str | None # 解决时间
|
||
mentions: list[str] # 提及的用户
|
||
attachments: list[dict] # 附件
|
||
|
||
|
||
@dataclass
|
||
class ChangeRecord:
|
||
"""变更记录"""
|
||
|
||
id: str
|
||
project_id: str
|
||
change_type: str # 变更类型
|
||
entity_type: str # 实体类型 (entity/relation/transcript/project)
|
||
entity_id: str # 实体ID
|
||
entity_name: str # 实体名称(用于显示)
|
||
changed_by: str # 变更者
|
||
changed_by_name: str # 变更者显示名
|
||
changed_at: str
|
||
old_value: dict | None # 旧值
|
||
new_value: dict | None # 新值
|
||
description: str # 变更描述
|
||
session_id: str | None # 会话ID(批量变更关联)
|
||
reverted: bool # 是否已回滚
|
||
reverted_at: str | None # 回滚时间
|
||
reverted_by: str | None # 回滚者
|
||
|
||
|
||
@dataclass
|
||
class TeamMember:
|
||
"""团队成员"""
|
||
|
||
id: str
|
||
project_id: str
|
||
user_id: str # 用户ID
|
||
user_name: str # 用户名
|
||
user_email: str # 用户邮箱
|
||
role: str # 角色 (owner/admin/editor/viewer)
|
||
joined_at: str
|
||
invited_by: str # 邀请者
|
||
last_active_at: str | None # 最后活跃时间
|
||
permissions: list[str] # 具体权限列表
|
||
|
||
|
||
@dataclass
|
||
class TeamSpace:
|
||
"""团队空间"""
|
||
|
||
id: str
|
||
name: str
|
||
description: str
|
||
created_by: str
|
||
created_at: str
|
||
updated_at: str
|
||
member_count: int
|
||
project_count: int
|
||
settings: dict[str, Any] # 团队设置
|
||
|
||
|
||
class CollaborationManager:
|
||
"""协作管理主类"""
|
||
|
||
def __init__(self, db_manager=None) -> None:
|
||
self.db = db_manager
|
||
self._shares_cache: dict[str, ProjectShare] = {}
|
||
self._comments_cache: dict[str, list[Comment]] = {}
|
||
|
||
# ============ 项目分享 ============
|
||
|
||
def create_share_link(
|
||
self,
|
||
project_id: str,
|
||
created_by: str,
|
||
permission: str = "read_only",
|
||
expires_in_days: int | None = None,
|
||
max_uses: int | None = None,
|
||
password: str | None = None,
|
||
allow_download: bool = False,
|
||
allow_export: bool = False,
|
||
) -> ProjectShare:
|
||
"""创建项目分享链接"""
|
||
share_id = str(uuid.uuid4())
|
||
token = self._generate_share_token(project_id)
|
||
|
||
now = datetime.now().isoformat()
|
||
expires_at = None
|
||
if expires_in_days:
|
||
expires_at = (datetime.now() + timedelta(days=expires_in_days)).isoformat()
|
||
|
||
password_hash = None
|
||
if password:
|
||
password_hash = hashlib.sha256(password.encode()).hexdigest()
|
||
|
||
share = ProjectShare(
|
||
id=share_id,
|
||
project_id=project_id,
|
||
token=token,
|
||
permission=permission,
|
||
created_by=created_by,
|
||
created_at=now,
|
||
expires_at=expires_at,
|
||
max_uses=max_uses,
|
||
use_count=0,
|
||
password_hash=password_hash,
|
||
is_active=True,
|
||
allow_download=allow_download,
|
||
allow_export=allow_export,
|
||
)
|
||
|
||
# 保存到数据库
|
||
if self.db:
|
||
self._save_share_to_db(share)
|
||
|
||
self._shares_cache[token] = share
|
||
return share
|
||
|
||
def _generate_share_token(self, project_id: str) -> str:
|
||
"""生成分享令牌"""
|
||
data = f"{project_id}:{datetime.now().timestamp()}:{uuid.uuid4()}"
|
||
return hashlib.sha256(data.encode()).hexdigest()[:32]
|
||
|
||
def _save_share_to_db(self, share: ProjectShare) -> None:
|
||
"""保存分享记录到数据库"""
|
||
cursor = self.db.conn.cursor()
|
||
cursor.execute(
|
||
"""
|
||
INSERT INTO project_shares
|
||
(id, project_id, token, permission, created_by, created_at,
|
||
expires_at, max_uses, use_count, password_hash, is_active,
|
||
allow_download, allow_export)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
""",
|
||
(
|
||
share.id,
|
||
share.project_id,
|
||
share.token,
|
||
share.permission,
|
||
share.created_by,
|
||
share.created_at,
|
||
share.expires_at,
|
||
share.max_uses,
|
||
share.use_count,
|
||
share.password_hash,
|
||
share.is_active,
|
||
share.allow_download,
|
||
share.allow_export,
|
||
),
|
||
)
|
||
self.db.conn.commit()
|
||
|
||
def validate_share_token(self, token: str, password: str | None = None) -> ProjectShare | None:
|
||
"""验证分享令牌"""
|
||
# 从缓存或数据库获取
|
||
share = self._shares_cache.get(token)
|
||
if not share and self.db:
|
||
share = self._get_share_from_db(token)
|
||
|
||
if not share:
|
||
return None
|
||
|
||
# 检查是否激活
|
||
if not share.is_active:
|
||
return None
|
||
|
||
# 检查是否过期
|
||
if share.expires_at and datetime.now().isoformat() > share.expires_at:
|
||
return None
|
||
|
||
# 检查使用次数
|
||
if share.max_uses and share.use_count >= share.max_uses:
|
||
return None
|
||
|
||
# 验证密码
|
||
if share.password_hash:
|
||
if not password:
|
||
return None
|
||
password_hash = hashlib.sha256(password.encode()).hexdigest()
|
||
if password_hash != share.password_hash:
|
||
return None
|
||
|
||
return share
|
||
|
||
def _get_share_from_db(self, token: str) -> ProjectShare | None:
|
||
"""从数据库获取分享记录"""
|
||
cursor = self.db.conn.cursor()
|
||
cursor.execute(
|
||
"""
|
||
SELECT * FROM project_shares WHERE token = ?
|
||
""",
|
||
(token, ),
|
||
)
|
||
row = cursor.fetchone()
|
||
|
||
if not row:
|
||
return None
|
||
|
||
return ProjectShare(
|
||
id=row[0],
|
||
project_id=row[1],
|
||
token=row[2],
|
||
permission=row[3],
|
||
created_by=row[4],
|
||
created_at=row[5],
|
||
expires_at=row[6],
|
||
max_uses=row[7],
|
||
use_count=row[8],
|
||
password_hash=row[9],
|
||
is_active=bool(row[10]),
|
||
allow_download=bool(row[11]),
|
||
allow_export=bool(row[12]),
|
||
)
|
||
|
||
def increment_share_usage(self, token: str) -> None:
|
||
"""增加分享链接使用次数"""
|
||
share = self._shares_cache.get(token)
|
||
if share:
|
||
share.use_count += 1
|
||
|
||
if self.db:
|
||
cursor = self.db.conn.cursor()
|
||
cursor.execute(
|
||
"""
|
||
UPDATE project_shares
|
||
SET use_count = use_count + 1
|
||
WHERE token = ?
|
||
""",
|
||
(token, ),
|
||
)
|
||
self.db.conn.commit()
|
||
|
||
def revoke_share_link(self, share_id: str, revoked_by: str) -> bool:
|
||
"""撤销分享链接"""
|
||
if self.db:
|
||
cursor = self.db.conn.cursor()
|
||
cursor.execute(
|
||
"""
|
||
UPDATE project_shares
|
||
SET is_active = 0
|
||
WHERE id = ?
|
||
""",
|
||
(share_id, ),
|
||
)
|
||
self.db.conn.commit()
|
||
return cursor.rowcount > 0
|
||
return False
|
||
|
||
def list_project_shares(self, project_id: str) -> list[ProjectShare]:
|
||
"""列出项目的所有分享链接"""
|
||
if not self.db:
|
||
return []
|
||
|
||
cursor = self.db.conn.cursor()
|
||
cursor.execute(
|
||
"""
|
||
SELECT * FROM project_shares
|
||
WHERE project_id = ?
|
||
ORDER BY created_at DESC
|
||
""",
|
||
(project_id, ),
|
||
)
|
||
|
||
shares = []
|
||
for row in cursor.fetchall():
|
||
shares.append(
|
||
ProjectShare(
|
||
id=row[0],
|
||
project_id=row[1],
|
||
token=row[2],
|
||
permission=row[3],
|
||
created_by=row[4],
|
||
created_at=row[5],
|
||
expires_at=row[6],
|
||
max_uses=row[7],
|
||
use_count=row[8],
|
||
password_hash=row[9],
|
||
is_active=bool(row[10]),
|
||
allow_download=bool(row[11]),
|
||
allow_export=bool(row[12]),
|
||
)
|
||
)
|
||
return shares
|
||
|
||
# ============ 评论和批注 ============
|
||
|
||
def add_comment(
|
||
self,
|
||
project_id: str,
|
||
target_type: str,
|
||
target_id: str,
|
||
author: str,
|
||
author_name: str,
|
||
content: str,
|
||
parent_id: str | None = None,
|
||
mentions: list[str] | None = None,
|
||
attachments: list[dict] | None = None,
|
||
) -> Comment:
|
||
"""添加评论"""
|
||
comment_id = str(uuid.uuid4())
|
||
now = datetime.now().isoformat()
|
||
|
||
comment = Comment(
|
||
id=comment_id,
|
||
project_id=project_id,
|
||
target_type=target_type,
|
||
target_id=target_id,
|
||
parent_id=parent_id,
|
||
author=author,
|
||
author_name=author_name,
|
||
content=content,
|
||
created_at=now,
|
||
updated_at=now,
|
||
resolved=False,
|
||
resolved_by=None,
|
||
resolved_at=None,
|
||
mentions=mentions or [],
|
||
attachments=attachments or [],
|
||
)
|
||
|
||
if self.db:
|
||
self._save_comment_to_db(comment)
|
||
|
||
# 更新缓存
|
||
key = f"{target_type}:{target_id}"
|
||
if key not in self._comments_cache:
|
||
self._comments_cache[key] = []
|
||
self._comments_cache[key].append(comment)
|
||
|
||
return comment
|
||
|
||
def _save_comment_to_db(self, comment: Comment) -> None:
|
||
"""保存评论到数据库"""
|
||
cursor = self.db.conn.cursor()
|
||
cursor.execute(
|
||
"""
|
||
INSERT INTO comments
|
||
(id, project_id, target_type, target_id, parent_id, author, author_name,
|
||
content, created_at, updated_at, resolved, resolved_by, resolved_at,
|
||
mentions, attachments)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
""",
|
||
(
|
||
comment.id,
|
||
comment.project_id,
|
||
comment.target_type,
|
||
comment.target_id,
|
||
comment.parent_id,
|
||
comment.author,
|
||
comment.author_name,
|
||
comment.content,
|
||
comment.created_at,
|
||
comment.updated_at,
|
||
comment.resolved,
|
||
comment.resolved_by,
|
||
comment.resolved_at,
|
||
json.dumps(comment.mentions),
|
||
json.dumps(comment.attachments),
|
||
),
|
||
)
|
||
self.db.conn.commit()
|
||
|
||
def get_comments(
|
||
self, target_type: str, target_id: str, include_resolved: bool = True
|
||
) -> list[Comment]:
|
||
"""获取评论列表"""
|
||
if not self.db:
|
||
return []
|
||
|
||
cursor = self.db.conn.cursor()
|
||
if include_resolved:
|
||
cursor.execute(
|
||
"""
|
||
SELECT * FROM comments
|
||
WHERE target_type = ? AND target_id = ?
|
||
ORDER BY created_at ASC
|
||
""",
|
||
(target_type, target_id),
|
||
)
|
||
else:
|
||
cursor.execute(
|
||
"""
|
||
SELECT * FROM comments
|
||
WHERE target_type = ? AND target_id = ? AND resolved = 0
|
||
ORDER BY created_at ASC
|
||
""",
|
||
(target_type, target_id),
|
||
)
|
||
|
||
comments = []
|
||
for row in cursor.fetchall():
|
||
comments.append(self._row_to_comment(row))
|
||
return comments
|
||
|
||
def _row_to_comment(self, row) -> Comment:
|
||
"""将数据库行转换为Comment对象"""
|
||
return Comment(
|
||
id=row[0],
|
||
project_id=row[1],
|
||
target_type=row[2],
|
||
target_id=row[3],
|
||
parent_id=row[4],
|
||
author=row[5],
|
||
author_name=row[6],
|
||
content=row[7],
|
||
created_at=row[8],
|
||
updated_at=row[9],
|
||
resolved=bool(row[10]),
|
||
resolved_by=row[11],
|
||
resolved_at=row[12],
|
||
mentions=json.loads(row[13]) if row[13] else [],
|
||
attachments=json.loads(row[14]) if row[14] else [],
|
||
)
|
||
|
||
def update_comment(self, comment_id: str, content: str, updated_by: str) -> Comment | None:
|
||
"""更新评论"""
|
||
if not self.db:
|
||
return None
|
||
|
||
now = datetime.now().isoformat()
|
||
cursor = self.db.conn.cursor()
|
||
cursor.execute(
|
||
"""
|
||
UPDATE comments
|
||
SET content = ?, updated_at = ?
|
||
WHERE id = ? AND author = ?
|
||
""",
|
||
(content, now, comment_id, updated_by),
|
||
)
|
||
self.db.conn.commit()
|
||
|
||
if cursor.rowcount > 0:
|
||
return self._get_comment_by_id(comment_id)
|
||
return None
|
||
|
||
def _get_comment_by_id(self, comment_id: str) -> Comment | None:
|
||
"""根据ID获取评论"""
|
||
cursor = self.db.conn.cursor()
|
||
cursor.execute("SELECT * FROM comments WHERE id = ?", (comment_id, ))
|
||
row = cursor.fetchone()
|
||
if row:
|
||
return self._row_to_comment(row)
|
||
return None
|
||
|
||
def resolve_comment(self, comment_id: str, resolved_by: str) -> bool:
|
||
"""标记评论为已解决"""
|
||
if not self.db:
|
||
return False
|
||
|
||
now = datetime.now().isoformat()
|
||
cursor = self.db.conn.cursor()
|
||
cursor.execute(
|
||
"""
|
||
UPDATE comments
|
||
SET resolved = 1, resolved_by = ?, resolved_at = ?
|
||
WHERE id = ?
|
||
""",
|
||
(resolved_by, now, comment_id),
|
||
)
|
||
self.db.conn.commit()
|
||
return cursor.rowcount > 0
|
||
|
||
def delete_comment(self, comment_id: str, deleted_by: str) -> bool:
|
||
"""删除评论"""
|
||
if not self.db:
|
||
return False
|
||
|
||
cursor = self.db.conn.cursor()
|
||
# 只允许作者或管理员删除
|
||
cursor.execute(
|
||
"""
|
||
DELETE FROM comments
|
||
WHERE id = ? AND (author = ? OR ? IN (
|
||
SELECT created_by FROM projects WHERE id = comments.project_id
|
||
))
|
||
""",
|
||
(comment_id, deleted_by, deleted_by),
|
||
)
|
||
self.db.conn.commit()
|
||
return cursor.rowcount > 0
|
||
|
||
def get_project_comments(
|
||
self, project_id: str, limit: int = 50, offset: int = 0
|
||
) -> list[Comment]:
|
||
"""获取项目下的所有评论"""
|
||
if not self.db:
|
||
return []
|
||
|
||
cursor = self.db.conn.cursor()
|
||
cursor.execute(
|
||
"""
|
||
SELECT * FROM comments
|
||
WHERE project_id = ?
|
||
ORDER BY created_at DESC
|
||
LIMIT ? OFFSET ?
|
||
""",
|
||
(project_id, limit, offset),
|
||
)
|
||
|
||
comments = []
|
||
for row in cursor.fetchall():
|
||
comments.append(self._row_to_comment(row))
|
||
return comments
|
||
|
||
# ============ 变更历史 ============
|
||
|
||
def record_change(
|
||
self,
|
||
project_id: str,
|
||
change_type: str,
|
||
entity_type: str,
|
||
entity_id: str,
|
||
entity_name: str,
|
||
changed_by: str,
|
||
changed_by_name: str,
|
||
old_value: dict | None = None,
|
||
new_value: dict | None = None,
|
||
description: str = "",
|
||
session_id: str | None = None,
|
||
) -> ChangeRecord:
|
||
"""记录变更"""
|
||
record_id = str(uuid.uuid4())
|
||
now = datetime.now().isoformat()
|
||
|
||
record = ChangeRecord(
|
||
id=record_id,
|
||
project_id=project_id,
|
||
change_type=change_type,
|
||
entity_type=entity_type,
|
||
entity_id=entity_id,
|
||
entity_name=entity_name,
|
||
changed_by=changed_by,
|
||
changed_by_name=changed_by_name,
|
||
changed_at=now,
|
||
old_value=old_value,
|
||
new_value=new_value,
|
||
description=description,
|
||
session_id=session_id,
|
||
reverted=False,
|
||
reverted_at=None,
|
||
reverted_by=None,
|
||
)
|
||
|
||
if self.db:
|
||
self._save_change_to_db(record)
|
||
|
||
return record
|
||
|
||
def _save_change_to_db(self, record: ChangeRecord) -> None:
|
||
"""保存变更记录到数据库"""
|
||
cursor = self.db.conn.cursor()
|
||
cursor.execute(
|
||
"""
|
||
INSERT INTO change_history
|
||
(id, project_id, change_type, entity_type, entity_id, entity_name,
|
||
changed_by, changed_by_name, changed_at, old_value, new_value,
|
||
description, session_id, reverted, reverted_at, reverted_by)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
""",
|
||
(
|
||
record.id,
|
||
record.project_id,
|
||
record.change_type,
|
||
record.entity_type,
|
||
record.entity_id,
|
||
record.entity_name,
|
||
record.changed_by,
|
||
record.changed_by_name,
|
||
record.changed_at,
|
||
json.dumps(record.old_value) if record.old_value else None,
|
||
json.dumps(record.new_value) if record.new_value else None,
|
||
record.description,
|
||
record.session_id,
|
||
record.reverted,
|
||
record.reverted_at,
|
||
record.reverted_by,
|
||
),
|
||
)
|
||
self.db.conn.commit()
|
||
|
||
def get_change_history(
|
||
self,
|
||
project_id: str,
|
||
entity_type: str | None = None,
|
||
entity_id: str | None = None,
|
||
limit: int = 50,
|
||
offset: int = 0,
|
||
) -> list[ChangeRecord]:
|
||
"""获取变更历史"""
|
||
if not self.db:
|
||
return []
|
||
|
||
cursor = self.db.conn.cursor()
|
||
|
||
if entity_type and entity_id:
|
||
cursor.execute(
|
||
"""
|
||
SELECT * FROM change_history
|
||
WHERE project_id = ? AND entity_type = ? AND entity_id = ?
|
||
ORDER BY changed_at DESC
|
||
LIMIT ? OFFSET ?
|
||
""",
|
||
(project_id, entity_type, entity_id, limit, offset),
|
||
)
|
||
elif entity_type:
|
||
cursor.execute(
|
||
"""
|
||
SELECT * FROM change_history
|
||
WHERE project_id = ? AND entity_type = ?
|
||
ORDER BY changed_at DESC
|
||
LIMIT ? OFFSET ?
|
||
""",
|
||
(project_id, entity_type, limit, offset),
|
||
)
|
||
else:
|
||
cursor.execute(
|
||
"""
|
||
SELECT * FROM change_history
|
||
WHERE project_id = ?
|
||
ORDER BY changed_at DESC
|
||
LIMIT ? OFFSET ?
|
||
""",
|
||
(project_id, limit, offset),
|
||
)
|
||
|
||
records = []
|
||
for row in cursor.fetchall():
|
||
records.append(self._row_to_change_record(row))
|
||
return records
|
||
|
||
def _row_to_change_record(self, row) -> ChangeRecord:
|
||
"""将数据库行转换为ChangeRecord对象"""
|
||
return ChangeRecord(
|
||
id=row[0],
|
||
project_id=row[1],
|
||
change_type=row[2],
|
||
entity_type=row[3],
|
||
entity_id=row[4],
|
||
entity_name=row[5],
|
||
changed_by=row[6],
|
||
changed_by_name=row[7],
|
||
changed_at=row[8],
|
||
old_value=json.loads(row[9]) if row[9] else None,
|
||
new_value=json.loads(row[10]) if row[10] else None,
|
||
description=row[11],
|
||
session_id=row[12],
|
||
reverted=bool(row[13]),
|
||
reverted_at=row[14],
|
||
reverted_by=row[15],
|
||
)
|
||
|
||
def get_entity_version_history(self, entity_type: str, entity_id: str) -> list[ChangeRecord]:
|
||
"""获取实体的版本历史(用于版本对比)"""
|
||
if not self.db:
|
||
return []
|
||
|
||
cursor = self.db.conn.cursor()
|
||
cursor.execute(
|
||
"""
|
||
SELECT * FROM change_history
|
||
WHERE entity_type = ? AND entity_id = ?
|
||
ORDER BY changed_at ASC
|
||
""",
|
||
(entity_type, entity_id),
|
||
)
|
||
|
||
records = []
|
||
for row in cursor.fetchall():
|
||
records.append(self._row_to_change_record(row))
|
||
return records
|
||
|
||
def revert_change(self, record_id: str, reverted_by: str) -> bool:
|
||
"""回滚变更"""
|
||
if not self.db:
|
||
return False
|
||
|
||
now = datetime.now().isoformat()
|
||
cursor = self.db.conn.cursor()
|
||
cursor.execute(
|
||
"""
|
||
UPDATE change_history
|
||
SET reverted = 1, reverted_at = ?, reverted_by = ?
|
||
WHERE id = ? AND reverted = 0
|
||
""",
|
||
(now, reverted_by, record_id),
|
||
)
|
||
self.db.conn.commit()
|
||
return cursor.rowcount > 0
|
||
|
||
def get_change_stats(self, project_id: str) -> dict[str, Any]:
|
||
"""获取变更统计"""
|
||
if not self.db:
|
||
return {}
|
||
|
||
cursor = self.db.conn.cursor()
|
||
|
||
# 总变更数
|
||
cursor.execute(
|
||
"""
|
||
SELECT COUNT(*) FROM change_history WHERE project_id = ?
|
||
""",
|
||
(project_id, ),
|
||
)
|
||
total_changes = cursor.fetchone()[0]
|
||
|
||
# 按类型统计
|
||
cursor.execute(
|
||
"""
|
||
SELECT change_type, COUNT(*) FROM change_history
|
||
WHERE project_id = ? GROUP BY change_type
|
||
""",
|
||
(project_id, ),
|
||
)
|
||
type_counts = {row[0]: row[1] for row in cursor.fetchall()}
|
||
|
||
# 按实体类型统计
|
||
cursor.execute(
|
||
"""
|
||
SELECT entity_type, COUNT(*) FROM change_history
|
||
WHERE project_id = ? GROUP BY entity_type
|
||
""",
|
||
(project_id, ),
|
||
)
|
||
entity_type_counts = {row[0]: row[1] for row in cursor.fetchall()}
|
||
|
||
# 最近活跃的用户
|
||
cursor.execute(
|
||
"""
|
||
SELECT changed_by_name, COUNT(*) as count FROM change_history
|
||
WHERE project_id = ?
|
||
GROUP BY changed_by_name
|
||
ORDER BY count DESC
|
||
LIMIT 5
|
||
""",
|
||
(project_id, ),
|
||
)
|
||
top_contributors = [{"name": row[0], "changes": row[1]} for row in cursor.fetchall()]
|
||
|
||
return {
|
||
"total_changes": total_changes,
|
||
"by_type": type_counts,
|
||
"by_entity_type": entity_type_counts,
|
||
"top_contributors": top_contributors,
|
||
}
|
||
|
||
# ============ 团队成员管理 ============
|
||
|
||
def add_team_member(
|
||
self,
|
||
project_id: str,
|
||
user_id: str,
|
||
user_name: str,
|
||
user_email: str,
|
||
role: str,
|
||
invited_by: str,
|
||
permissions: list[str] | None = None,
|
||
) -> TeamMember:
|
||
"""添加团队成员"""
|
||
member_id = str(uuid.uuid4())
|
||
now = datetime.now().isoformat()
|
||
|
||
# 根据角色设置默认权限
|
||
if permissions is None:
|
||
permissions = self._get_default_permissions(role)
|
||
|
||
member = TeamMember(
|
||
id=member_id,
|
||
project_id=project_id,
|
||
user_id=user_id,
|
||
user_name=user_name,
|
||
user_email=user_email,
|
||
role=role,
|
||
joined_at=now,
|
||
invited_by=invited_by,
|
||
last_active_at=None,
|
||
permissions=permissions,
|
||
)
|
||
|
||
if self.db:
|
||
self._save_member_to_db(member)
|
||
|
||
return member
|
||
|
||
def _get_default_permissions(self, role: str) -> list[str]:
|
||
"""获取角色的默认权限"""
|
||
permissions_map = {
|
||
"owner": ["read", "write", "delete", "share", "admin", "export"],
|
||
"admin": ["read", "write", "delete", "share", "export"],
|
||
"editor": ["read", "write", "export"],
|
||
"viewer": ["read"],
|
||
"commenter": ["read", "comment"],
|
||
}
|
||
return permissions_map.get(role, ["read"])
|
||
|
||
def _save_member_to_db(self, member: TeamMember) -> None:
|
||
"""保存成员到数据库"""
|
||
cursor = self.db.conn.cursor()
|
||
cursor.execute(
|
||
"""
|
||
INSERT INTO team_members
|
||
(id, project_id, user_id, user_name, user_email, role, joined_at,
|
||
invited_by, last_active_at, permissions)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
""",
|
||
(
|
||
member.id,
|
||
member.project_id,
|
||
member.user_id,
|
||
member.user_name,
|
||
member.user_email,
|
||
member.role,
|
||
member.joined_at,
|
||
member.invited_by,
|
||
member.last_active_at,
|
||
json.dumps(member.permissions),
|
||
),
|
||
)
|
||
self.db.conn.commit()
|
||
|
||
def get_team_members(self, project_id: str) -> list[TeamMember]:
|
||
"""获取团队成员列表"""
|
||
if not self.db:
|
||
return []
|
||
|
||
cursor = self.db.conn.cursor()
|
||
cursor.execute(
|
||
"""
|
||
SELECT * FROM team_members WHERE project_id = ?
|
||
ORDER BY joined_at ASC
|
||
""",
|
||
(project_id, ),
|
||
)
|
||
|
||
members = []
|
||
for row in cursor.fetchall():
|
||
members.append(self._row_to_team_member(row))
|
||
return members
|
||
|
||
def _row_to_team_member(self, row) -> TeamMember:
|
||
"""将数据库行转换为TeamMember对象"""
|
||
return TeamMember(
|
||
id=row[0],
|
||
project_id=row[1],
|
||
user_id=row[2],
|
||
user_name=row[3],
|
||
user_email=row[4],
|
||
role=row[5],
|
||
joined_at=row[6],
|
||
invited_by=row[7],
|
||
last_active_at=row[8],
|
||
permissions=json.loads(row[9]) if row[9] else [],
|
||
)
|
||
|
||
def update_member_role(self, member_id: str, new_role: str, updated_by: str) -> bool:
|
||
"""更新成员角色"""
|
||
if not self.db:
|
||
return False
|
||
|
||
permissions = self._get_default_permissions(new_role)
|
||
cursor = self.db.conn.cursor()
|
||
cursor.execute(
|
||
"""
|
||
UPDATE team_members
|
||
SET role = ?, permissions = ?
|
||
WHERE id = ?
|
||
""",
|
||
(new_role, json.dumps(permissions), member_id),
|
||
)
|
||
self.db.conn.commit()
|
||
return cursor.rowcount > 0
|
||
|
||
def remove_team_member(self, member_id: str, removed_by: str) -> bool:
|
||
"""移除团队成员"""
|
||
if not self.db:
|
||
return False
|
||
|
||
cursor = self.db.conn.cursor()
|
||
cursor.execute("DELETE FROM team_members WHERE id = ?", (member_id, ))
|
||
self.db.conn.commit()
|
||
return cursor.rowcount > 0
|
||
|
||
def check_permission(self, project_id: str, user_id: str, permission: str) -> bool:
|
||
"""检查用户权限"""
|
||
if not self.db:
|
||
return False
|
||
|
||
cursor = self.db.conn.cursor()
|
||
cursor.execute(
|
||
"""
|
||
SELECT permissions FROM team_members
|
||
WHERE project_id = ? AND user_id = ?
|
||
""",
|
||
(project_id, user_id),
|
||
)
|
||
|
||
row = cursor.fetchone()
|
||
if not row:
|
||
return False
|
||
|
||
permissions = json.loads(row[0]) if row[0] else []
|
||
return permission in permissions or "admin" in permissions
|
||
|
||
def update_last_active(self, project_id: str, user_id: str) -> None:
|
||
"""更新用户最后活跃时间"""
|
||
if not self.db:
|
||
return
|
||
|
||
now = datetime.now().isoformat()
|
||
cursor = self.db.conn.cursor()
|
||
cursor.execute(
|
||
"""
|
||
UPDATE team_members
|
||
SET last_active_at = ?
|
||
WHERE project_id = ? AND user_id = ?
|
||
""",
|
||
(now, project_id, user_id),
|
||
)
|
||
self.db.conn.commit()
|
||
|
||
|
||
# 全局协作管理器实例
|
||
_collaboration_manager = None
|
||
|
||
|
||
def get_collaboration_manager(db_manager=None) -> None:
|
||
"""获取协作管理器单例"""
|
||
global _collaboration_manager
|
||
if _collaboration_manager is None:
|
||
_collaboration_manager = CollaborationManager(db_manager)
|
||
return _collaboration_manager
|