先说结论
在 Odoo 企业版里,把一份 Documents 里的电子表格 加进 Dashboard,不是“原地多开一个入口”,也不是“同一条记录换个菜单显示”。
从 /home/ubuntu/odoo-temp/enterprise/spreadsheet_dashboard_documents,再串上 documents_spreadsheet、spreadsheet_dashboard_edition 和 spreadsheet_edition 一起看,官方真正做的是一套 受控交接(handoff):
- 先把当前协作状态固化成 snapshot,避免把浏览器里尚未落盘的编辑丢掉;
- 按 dashboard group 和 access groups 新建一个 dashboard 对象,不继续沿用 document 的承载身份;
- 把单元格评论线程从 document_id 改挂到 dashboard_id,让讨论跟着新的承载对象走;
- 把 spreadsheet JSON / snapshot 里的 comments 清空,避免评论既存在线程表里、又残留在数据载荷里;
- 把原 document 归档,明确这不是“双活并行”,而是“从文档协作态切到仪表板消费态”。
所以更准确的理解应该是:
Odoo 把“文档里的协作型电子表格”转换成“仪表板里的发布型电子表格”时,做的是一次对象迁移和治理切换,而不是简单复制。
这也是为什么这个功能虽然挂在 Documents 和 Dashboard 之间,但本质上更像一套独立的轻 BI / 轻治理机制,很适合归到“企业, 其他”,而不是继续塞进通用协同办公主线。
一、入口为什么先判断你能不能“Add to Dashboard”
先看 models/documents_document.py:
return dict(data, can_add_to_dashboard=self.env['spreadsheet.dashboard'].has_access('create'))
这行代码很短,但它揭示了一个关键设计:
- 不是所有能编辑文档的人,天然都能把它升级成 dashboard;
- 文档侧展示“Add to Dashboard”按钮前,系统会先问 你有没有创建 dashboard 的权限。
测试 test_can_add_to_dashboard_admin() / test_can_add_to_dashboard_non_admin() 也把这个边界写死了:
- 有
spreadsheet_dashboard.group_dashboard_manager的用户,接口返回can_add_to_dashboard = true; - 只有普通内部用户 + documents 用户组时,返回就是
false。
这说明官方从一开始就把“文档协作”和“仪表板发布”当作两个治理层级:
- 文档 更像工作区里的可编辑材料;
- 仪表板 更像要被分发、展示、受控访问的发布对象。
所以这个动作不是一个纯 UI convenience,而是一道发布权限闸门。
二、真正的入口不在 Python 向导,而在前端先强制保存 snapshot
如果只看 Python,很容易误以为点击后直接弹一个向导完事。
但在 static/src/bundle/documents_spreadsheet_action.js 里,前端实际先做了一个非常重要的动作:
await this.env.services.orm.call("documents.document", "save_spreadsheet_snapshot", [
resId,
model.exportData(),
]);
也就是说,用户在 spreadsheet 里点 Add to Dashboard 时,系统第一步不是建 dashboard,而是先把浏览器中的当前模型 exportData() 保存成 snapshot。
这背后的原因很现实:
- 电子表格是协作对象,用户当前看到的内容可能还停留在前端内存;
- 如果不先 snapshot,后面新建 dashboard 时拿到的就可能是旧的
spreadsheet_data; - 一旦转成 dashboard,又会归档原文档,丢快照的代价就更高。
所以官方设计不是:
- “我猜你已经保存过了”;
而是:
- “既然你要把它提升成一个 dashboard,我先替你冻结当前可见状态。”
这是很典型的发布前 freeze 思路。
三、为什么 list 视图的“Add a spreadsheet”与文档内的“Add to Dashboard”是两条入口
在 views/spreadsheet_dashboard_views.xml 里,dashboard list 的 control 区加了一个按钮:
Add a spreadsheet- 调用
action_add_document_spreadsheet_to_dashboard - 并把
dashboard_group_id从父对象上下文传进去
而 models/spreadsheet_dashboard.py 返回的是一个 client action:
{
"type": "ir.actions.client",
"tag": "action_dashboard_add_spreadsheet",
"params": {
"dashboardGroupId": self.env.context.get("dashboard_group_id"),
},
}
前端再通过 dashboard_add_spreadsheet_action.js 弹 DocumentSelectorDialog,让你先选文档。
于是这里其实存在两条不同入口:
入口 A:从文档里发起
先在文档内部工作,点 Add to Dashboard,然后命名 dashboard、选 section、设访问组。
入口 B:从 dashboard section 里发起
你已经站在某个 dashboard group 里,点 Add a spreadsheet,再反向挑一个 document 加进来。
这两条入口的共同点是:
- 目标都不是“把文档嵌入 dashboard 菜单”;
- 而是在 dashboard 侧新建一个独立对象。
这也是很多人第一次用时最容易看错的地方:
UI 看着像“添加现有文档”,底层却是“把现有文档交接成新的 dashboard 记录”。
四、向导真正创建的不是链接,而是一张新的 spreadsheet.dashboard
核心逻辑在 wizard/documents_to_dashboard.py 的 create_dashboard()。
它做的事情非常直接:
dashboard = self.env["spreadsheet.dashboard"].create(
{
"name": self.name,
"dashboard_group_id": self.dashboard_group_id.id,
"group_ids": self.group_ids.ids,
"spreadsheet_data": self.document_id._get_spreadsheet_serialized_snapshot(),
}
)
这里至少能读出四个关键信号。
1. dashboard 有自己的名字
向导里的 name 默认取 document 名称,但可以改。
这说明 dashboard 不是 document 的只读镜像,而是允许在发布阶段重新命名的独立对象。
2. dashboard 必须挂进一个 section
dashboard_group_id 是必填。
所以 Dashboard 不是“有了对象再慢慢整理”,而是创建时就要求你明确它属于哪个展示分区。
3. access groups 在创建时重定义
group_ids 默认来自 spreadsheet.dashboard 的默认访问组,而不是简单沿用 document 原权限。
这点很重要。
Documents 世界常见的是:
- 文件夹权限
- internal / link / share 访问
- 文档级编辑语义
Dashboard 世界则更像:
- 面向哪些用户组展示
- 谁能看到这个 section 里的内容
- 谁能进入并继续协作
所以这里不是“继承”,而是重挂治理边界。
4. 创建时吃的是 serialized snapshot,不是随便读个字段
spreadsheet_data 来自 _get_spreadsheet_serialized_snapshot()。
而 spreadsheet_edition/models/spreadsheet_mixin.py 里这方法的逻辑是:
- 如果附件里已有
spreadsheet_snapshot,优先读 snapshot; - 否则才回退到
spreadsheet_data。
这与前端先 save_spreadsheet_snapshot() 完整闭环了:
- 前端先冻结当前视图;
- 后端创建 dashboard 时优先吃这份冻结结果。
这样你拿到的是“用户刚刚确认发布的状态”,而不是数据库里某个更早版本。
五、为什么评论不是复制,而是“线程迁移 + JSON 清洗”
这是整个功能里我最喜欢、也最容易被忽略的一层。
向导在创建 dashboard 后,紧接着会做:
self.env["spreadsheet.cell.thread"].sudo().search([
("document_id", "=", self.document_id.id)
]).write({"dashboard_id": dashboard.id, "document_id": False})
self.document_id._delete_comments_from_data()
也就是说,评论处理不是“保留原样”,而是两步:
- 把所有单元格评论线程从
document_id改挂到dashboard_id; - 再把原 document 数据里的
comments节点清空。
为什么要这么做?
因为 Odoo 的 spreadsheet 评论不是只有一份 JSON 标记,它背后还有 spreadsheet.cell.thread 这张真实模型表,分别可挂到:
document_iddashboard_idtemplate_id
一旦你把 spreadsheet 的承载对象从 document 换成 dashboard,如果线程还留在旧对象上,就会出现三个问题:
- dashboard 打开后评论找不到归属;
- 文档与 dashboard 可能各自都像“拥有”这串评论;
- JSON 里的 comments 与数据库线程状态会发生双写或悬挂。
而 _delete_comments_from_data() 的做法更狠也更干净:
spreadsheet_data里每个 sheet 的comments设成{};spreadsheet_snapshot里也同步清空 comments。
换句话说,官方明确不想让“评论内容”继续作为 spreadsheet 数据载荷的一部分保留,而是要让它彻底回归到线程模型与访问规则上。
这是一种典型的“把协作元数据从业务载荷里剥离出来”的工程思路。
六、为什么直接添加到 section 的那条路径,还要复制 revisions
除了向导 create_dashboard(),models/spreadsheet_dashboard.py 里还有一个更底层的方法:
add_document_spreadsheet_to_dashboard(dashboard_group_id, document_id)
它创建 dashboard 时,不是只写 spreadsheet_data,而是:
spreadsheet_snapshot = document.spreadsheet_snapshotspreadsheet_binary_data = document.datas- 然后调用
document._copy_revisions_to(dashboard)
再把 document 归档,最后 dashboard._delete_comments_from_data()。
这里透露的重点是:
1. 不是只有最终快照,连 revision 历史也会被带过去
_copy_revisions_to() 会复制 spreadsheet_revision_ids,并把:
res_modelres_idparent_revision_id
全部改挂到新的 dashboard 上。
2. 复制 revision 时还会删掉评论类命令
在 spreadsheet_mixin.py 里,_copy_revisions_to() 会先走 _delete_comments_from_commands(),把:
ADD_COMMENT_THREADDELETE_COMMENT_THREADEDIT_COMMENT_THREAD
从 revision commands 中剔掉。
这和前面的线程迁移逻辑是完全一致的。
官方态度很明确:
电子表格的公式、单元格结构、数据演进可以跟着 dashboard 走;但评论行为不应继续以命令流方式混在 revision 历史里。
所以评论相关元数据会被治理性地抽走。
七、为什么原 document 一定会被 archive,而不是保留“双活”
无论走向导还是走 dashboard model 里的添加方法,都会看到同一个动作:
self.document_id.action_archive()
或:
document.action_archive()
测试也明确断言:
The original document should be archived
这句话其实非常值得品。
如果官方允许 document 和 dashboard 长期双活,会发生什么?
- 用户可能继续在 document 里改;
- 管理层在 dashboard 里看的是另一套节奏;
- 评论线程与 revision 归属会持续分叉;
- 你根本说不清“哪一个才是正式发布对象”。
所以 Odoo 直接给出产品立场:
- 文档态 适合协作准备;
- dashboard 态 适合发布展示;
- 一旦切换,就让原文档进归档,不鼓励并行漂移。
这其实很像内容管理里的“草稿转发布版”,只是这里的对象不是网页,而是电子表格。
八、为什么 dashboard 侧的评论权限反而更严格
tests/test_access_rights.py 很有意思。
它证明了:
对 document 侧评论线程
普通内部用户在文档可访问的前提下,可以:
- 读
message_ids message_post- 新建 thread
但不能随便 unlink()。
对 dashboard 侧评论线程
如果 dashboard 的 group_ids 不包含该用户,则普通内部用户:
- 不能读消息
- 不能发消息
- 不能删线程
- 不能新建线程
只有 dashboard 对其可访问时,才允许创建评论线程。
这说明“转成 dashboard”不只是换展示入口,而是通常伴随着更收敛的讨论边界。
文档世界偏开放协作; Dashboard 世界偏受控消费与定向协作。
所以在实施里,如果你发现:
- “原来文档里大家都能聊,为什么进 dashboard 后就不行了?”
答案多半不在 bug,而在这里:
因为评论线程已经跟着对象迁移到了 dashboard,而 dashboard 的访问规则本来就不是 documents 的那套。
九、新建空白 dashboard 的方法,也能看出“仪表板是第一类对象”
action_open_new_dashboard() 里,官方甚至直接可以:
- 新建一个
Untitled dashboard - 返回
action_edit_dashboard
这意味着 dashboard 从来不是 document 的附属壳子。
它是一个可以独立创建、独立编辑、独立授权的第一类对象。
spreadsheet_dashboard_documents 这个模块做的,只是把 document → dashboard 的交接路径补齐,而不是把 dashboard 降级成 document 的一个视图模式。
这点一旦看明白,很多误解都会消失。
十、最容易误解的四个点
误区一:这是“复制一份电子表格”
不准确。它同时牵涉 snapshot、revision、评论线程、权限组和归档动作,是一套迁移链,而不是 copy/paste。
误区二:文档权限会自动原样继承到 dashboard
不一定。dashboard 创建时会重新挂 group_ids,治理边界是重建的。
误区三:评论还留在 spreadsheet JSON 里
不是。线程会迁移,JSON / snapshot 里的 comments 会被清空。
误区四:原文档会继续和 dashboard 长期同步
不会。官方明确 archive 原 document,不鼓励双活。
实战上该怎么理解最稳
如果你在做企业版 Documents + Spreadsheet + Dashboard 方案,最稳的理解是:
- 把 document 当作协作编辑阶段:多人整理、补注释、迭代公式;
- 把 add to dashboard 当作发布动作:先冻结快照,再选 section 与可见组;
- 不要期待双向同步:转过去后,默认以 dashboard 作为展示对象;
- 评论问题先查线程归属:看
spreadsheet.cell.thread究竟挂在document_id还是dashboard_id; - 权限问题先查
group_ids:别只盯 documents 文件夹权限。
这样你在做培训、实施或排错时,就不会把它误教成“文档多一个菜单入口”。
最后总结
spreadsheet_dashboard_documents 最有价值的地方,不是让 Dashboard 能“挑一个现成文档来显示”,而是把一份协作中的 spreadsheet,正式交接成一个受 section、group、revision 和评论线程共同治理的 dashboard 对象。
所以这套机制真正讲的不是“显示位置变化”,而是:
同一份电子表格,从协作工件变成发布工件时,Odoo 企业版如何完成一次对象身份、访问边界和讨论归属的整体切换。
这才是它最值得读源码的地方。
DISCUSSION
评论区