先说结论
Odoo 的在线课程并不是“名单 + 链接”模型,而是一个带状态机的成员关系模型。
真正承载课程运营的,不只是 slide.channel,而是 slide.channel.partner 这张成员表。它把一个学员和一门课之间的关系拆成四种核心状态:
invited:已经被邀请,但还没真正入班joined:已经成为学员,可以开始学习ongoing:已经开始学习,但还没学完completed:课程完成
这意味着 Odoo 在设计时就没把“邀请”和“加入”视为同一件事。
第一层:为什么 invite 和 enroll 要分开
在 slide_channel.py 里,action_channel_invite() 和 action_channel_enroll() 虽然都打开邀请向导,但内部语义并不一样。
invite只是把人加入课程可访问名单,成员状态通常是invitedenroll则会直接把人当成正式学员处理,成员状态走向joined
这个差异很重要,因为很多企业学习场景并不希望“收到邮件的人自动算正式学员”。
现实里常见的三种场景分别是:
- 先发邀请,让学员自己确认加入
- 培训管理员直接批量入班
- 群组成员自动同步进课程
如果这三种动作都只落成一条“加学员”逻辑,后续的访问权限、完成率和提醒邮件就会混在一起。
第二层:为什么 _action_add_members() 要先找旧记录、再决定是恢复还是新建
_action_add_members() 是整个成员编排的主链路。
它不是无脑 create(),而是先做三步判断:
- 过滤当前用户是否有权给这门课加成员
- 查找已存在但可能被归档的
slide.channel.partner - 决定是恢复旧关系、升级状态,还是新建关系
这套流程说明 Odoo 把“一个人和一门课的关系”视为可恢复的历史实体,而不是一次性记录。
所以当一个被归档的老学员再次被加入课程时,系统优先 action_unarchive(),而不是重新建一条全新 membership。这样做有两个好处:
- 老的学习进度还能接上
- 关系唯一性不会被破坏
这也是为什么 slide.channel.partner 上有唯一约束:同一个 partner 在同一门课里只能有一条成员关系。
第三层:为什么 invited 升级成 joined 时要重算完成度
很多人会觉得,member_status 不就是个显示字段吗?其实不是。
_action_add_members() 里有个很关键的动作:当一个人从 invited 升级成 joined 时,系统会调用 _recompute_completion()。
原因很简单:
invited只表示“你可以进课程外壳”joined才表示“你开始拥有课程内容访问权”
一旦升级成正式学员,系统就必须重新看这位学员已经完成了多少 slide、当前进度是多少、是否已经直接满足完课条件。
这也是 Odoo 设计得比较细的一点:成员状态和学习进度不是分离的,两者会互相驱动。
第四层:为什么 _recompute_completion() 不会去动 invited 和 completed
slide_channel_partner.py 里的 _recompute_completion() 非常值得细看。
它明确跳过两类记录:
invitedcompleted
跳过 invited 很好理解:既然还没正式入班,就不该把课程内容完成度算进来。
跳过 completed 则体现了 Odoo 的运营选择:一门课一旦完成,不应该因为中途课程结构小改动,就把所有完课用户立刻打回“未完成”。
换句话说,Odoo 默认把“完课”当成一个相对稳定的业务结果,而不只是实时计算值。
当然,如果进度真的需要回退,系统也保留了把 finished membership 重新标记成未完成的能力,并在 _post_completion_update_hook() 里同步调整 karma 奖励。
第五层:为什么访问申请不是直接加人,而是先建 activity
action_request_access() 和 _action_request_access() 体现了另一层设计:
- 公开用户不能直接申请,必须先登录
- 未发布课程不能申请
- 已经是成员的人不能重复申请
- 对
enroll == 'invite'的课程,申请不会直接通过,而是给课程负责人创建待办 activity
这代表 Odoo 把“申请访问”视为审批流入口,不是权限写入动作本身。
好处是非常现实的:
- 课程负责人可以决定谁能进
- 重复申请会被拦住,不会刷出大量重复工单
- 通过与拒绝都能留下明确反馈
真正批准时,action_grant_access() 才会调用 _action_add_members(),并把对应 activity 标记为 Access Granted。
第六层:为什么群组同步走 _add_groups_members(),而不是直接暴力写 Many2many
课程支持把若干用户组的人批量带进课程。
但 _add_groups_members() 最终仍然回到 _action_add_members(),而不是单独写一套批处理逻辑。这个决定很重要,因为它保证了群组同步和人工邀请共享同一套规则:
- 同样的权限过滤
- 同样的归档恢复
- 同样的状态升级
- 同样的完成度重算
- 同样的 chatter 订阅
这让“手工入班”和“批量入班”不会在数据层长出两套结果。
第七层:为什么 invitation hash 只是入口凭证,不是成员关系本身
slide.channel.partner 的 invitation_link 是通过 _get_invitation_hash() 生成的。
它的意义是:
- 给受邀人一条可验证的进入课程入口
- 避免单纯依赖可猜测参数
- 把邀请链接和 partner + channel 绑定起来
但这个哈希不是成员关系本身,只是访问入口的证明。真正的业务事实仍然保存在 slide.channel.partner 里。
这区分非常关键:
- 链接可以过期、重发、再次计算
- 成员关系不能因此丢失
- 课程进度也不能跟着链接生命周期一起重置
实战里最容易踩的坑
1. 把 invited 当成已报名
很多实现会把“发出邀请邮件”直接当成培训完成率统计的分母,这会导致课程数据虚高。
2. 直接删除成员关系
如果粗暴删掉 slide.channel.partner,再重新创建,你很可能会把旧进度、完课判断和 karma 记录割裂掉。
3. 忽视 controlled access
_filter_add_members() 明确区分 public enrollment 和受控课程。对受控课程,只有有写权限的人才能真正加成员。
4. 忽略状态升级后的副作用
从 invited 升为 joined,不只是改字段,还会触发进度重算和 chatter 订阅。
结论
Odoo eLearning 真正成熟的地方,不在页面长得像不像 LMS,而在它把“邀请、申请、审批、入班、学习、完课”拆成了一条完整成员生命周期。
所以做二开时,最稳的思路不是直接改前台按钮,而是尊重这几个核心模型与方法:
action_request_access()/_action_request_access():负责审批入口_action_add_members():负责统一加人逻辑_recompute_completion():负责状态和进度联动_get_invitation_hash():负责邀请入口校验
只要你沿着这条链路扩展,课程权限和学习进度才不会越改越乱。
DISCUSSION
评论区