先说结论
Odoo 的活动问卷不是“在报名表上多挂几个文本框”,而是一套题目、候选答案、报名人答案、事件问题集合分层建模的结构。
核心模型至少有四个:
event.question:题目本体event.question.answer:选择题候选项event.registration.answer:报名人实际作答event.event.question_ids:某场活动启用了哪些题目
再加上 once_per_order 这个字段,系统还能继续把题目拆成两类:
- 针对整个订单只问一次
- 针对每个参会人分别作答
所以这套设计真正解决的问题不是“怎么展示几个问题”,而是:
一场活动里的问题,哪些是共用信息,哪些是参会人级信息;题库能不能复用;答案录入后还能不能随便改模型。
第一层:为什么题目要独立成 event.question
event.question 不是某场活动里的临时字段,它本身就是独立模型。
字段设计已经说明了这一点:
titlequestion_typeevent_type_idsevent_idsis_defaultis_reusableanswer_idsonce_per_orderis_mandatory_answer
这意味着 Odoo 不把问题当作一次性表单片段,而是当作可沉淀、可复用、可挂到事件模板和具体活动上的题库元素。
所以一个问题完全可以:
- 成为默认问题
- 被多个 event type 复用
- 被多个活动共享
- 在选择题场景下拥有自己的候选答案列表
这比“活动表上直接加几个 JSON 配置”更重,但也更稳。
第二层:为什么答案不直接写回 event.registration 字段
很多初学者会本能地想:
- 姓名、邮箱都在
event.registration - 那活动问卷答案是不是也顺手加几列就行
但官方没有这么做,而是专门建了 event.registration.answer。
这个模型最关键的字段是:
question_idregistration_idquestion_typevalue_answer_idvalue_text_box
它表达了一个很清楚的思想:
报名人对题目的作答,是一张独立事实表。
这样设计有几个明显好处:
- 一个报名人可以答很多题,不需要在报名表上无限加列
- 同一套题库可以复用于不同活动
- 选择题和文本题可以共用一张答案表,只是值落在不同字段
- 后续统计、透视、图表分析更自然
这也是为什么 action_view_question_answers() 能直接把问题答案转成分析视图,而不是先去拼一堆动态字段。
第三层:为什么选择题还要再拆一张 event.question.answer
如果题目是 simple_choice,系统不会把候选值直接写死在字符串里,而是再用 event.question.answer 存。
这张表非常轻,但作用很大:
namequestion_idsequence
它保证了选择题选项本身也有结构化身份,而不是纯文本。
这带来两个重要结果:
- 可以稳定统计每个选项被选了多少次
- 可以控制删除边界
event_question_answer.py 里 _unlink_except_selected_answer() 明确限制:
- 如果某个候选项已经被报名人选过,就不能删
这和 event.question.write() 里“不允许把已有人作答的题改 question_type”是一套思路:
一旦真实业务答案已经落库,题目结构就不能任性改。
第四层:once_per_order 为什么是这套建模里最值钱的字段之一
event.question.once_per_order 的 help 写得很直白:
- 像 Company Name 这种问题,答案对同一订单里的所有人都一样
- 这种题不需要对每个参会人都重复问
在 event.event 上,系统进一步把问题分成:
general_question_ids:once_per_order = Truespecific_question_ids:once_per_order = False
这说明 Odoo 很清楚活动报名里存在两层信息:
订单级信息
比如:
- 公司名
- 开票抬头相关信息
- 团队统一说明
参会人级信息
比如:
- 姓名
- 邮箱
- 手机
- 饮食偏好
- 尺码
- 个人问题答案
如果不区分这两层,团体报名时表单会非常笨重:
- 同一个公司名要填 N 次
- 用户体验差
- 后台数据也显得重复
所以 once_per_order 不是小优化,而是报名问卷有没有业务感的关键。
第五层:为什么切换 event type 时,旧问题不会被粗暴清空
event.event._compute_question_ids() 很值得看。
当活动切换 event_type_id 时,系统不是简单把旧题全删、新题全覆盖,而是先保留:
- 这场活动里已经有报名答案的题目
源码里用的是:
event.registration_ids.registration_answer_ids.question_id & self._origin.question_ids
也就是说,只要某个题已经被真实报名人回答过,它就会被列入 questions_tokeep_ids。
这条规则非常稳。
因为一旦有人答过:
- 这题已经成为历史数据结构的一部分
- 直接删掉会让旧答案失去语义挂点
- 后续统计和回溯都会变脏
所以 Odoo 选择的是:
- 没有历史答案的题,可以随着模板切换而移除
- 已有历史答案的题,优先保留
这不是保守,而是数据建模应该有的边界感。
第六层:为什么默认问题和可复用问题不是一回事
event.question 里还有两个容易混淆的概念:
is_defaultis_reusable
同时模型约束里明确要求:
- default question 必须 reusable
这意味着:
- 默认问题:系统新建活动时默认带上
- 可复用问题:以后还允许被其它活动继续选用
默认问题一定要可复用,因为默认配置本来就是为了反复用。
但可复用问题不一定非要是默认问题。
这是一个很典型的“默认集”和“可选题库”的区分。
最容易踩的 4 个坑
1)把活动问卷理解成动态字段拼装
不对。
官方设计更接近“问卷题库 + 答案事实表”。
2)已经有人作答后,还想改 question_type
源码明确禁止。
因为文本题改成选择题,或者反过来,都会让已有答案失去匹配关系。
3)删掉已经被选过的候选项
也不行。
因为历史答案还挂在 value_answer_id 上。
4)团体报名时忘了区分 once_per_order
结果就是同一类共用信息被迫重复填很多次,前台很难用,后台数据也很臃肿。
实战里怎么设计活动问题更合理
可以按下面这条线做:
放到 once_per_order = True 的问题
- 公司名称
- 团队统一联系人信息
- 开票或组织级补充信息
放到 once_per_order = False 的问题
- 每位参会人的称呼
- 手机 / 邮箱
- 餐饮偏好
- 个人岗位、经验、尺码等
选择题优先用候选答案建模的场景
- 你以后需要做统计
- 你希望答案可排序
- 你希望保留选项级历史一致性
排查“为什么问题切模板后还留着”的顺序
推荐这样看:
- 这个题是不是已经有
event.registration.answer - 它是不是来自旧的
question_ids - 它是不是默认问题或模板问题
- 它属于
general_question_ids还是specific_question_ids
很多人以为这是“缓存没清掉”,其实往往是源码故意保留历史题。
最后一句
Odoo 活动问卷设计得好的地方,不是能问问题,而是它把:
- 题目复用
- 选项结构化
- 报名人答案落库
- 订单级 / 参会人级边界
- 历史答案保护
放进了同一套模型里。
所以真正需要理解的不是“表单怎么显示”,而是:
这个问题到底属于活动题库、订单信息,还是参会人级事实;一旦有人答过,哪些结构还能改,哪些已经不能乱动。
看懂这层,活动问卷就不再只是前端表单,而是一套非常像样的数据模型。
DISCUSSION
评论区