做Odoo二次开发,模型、视图、数据这三块绕不开。前面几讲把模型继承和基础开发过了一遍,这一讲集中解决两个最实际的问题:怎么继承标准模块已有的视图,不改源码就把界面改成客户要的样子;怎么用模块的数据文件,把菜单、字段、动作这些已有记录安全地改成目标状态。这两件事配合起来,就是实施项目里“把标准流程改成客户习惯”的那道核心工序。
这一讲的内容,适合正在做真实Odoo项目、不再满足于“只会加新模块”的开发者。无论是给销售订单扩展字段、调整某个视图的布局,还是想覆盖第三方模块的默认行为,你都绕不开这里面的机制。我按实际操作顺序,把视图继承的xpath写法、数据更新的几种途径、noupdate的坑、以及常见的排查手段一次性讲透。
1. 继承的本质:为什么改标准模块一定要走继承路线
1.1 客户需求拆解背后的三层继承
实施项目里最常见的场景,是客户说:“销售订单上帮我加个计划发货日期,金额字段名字改成‘含税合计’,订单菜单给我换个别名。”这三句话听起来简单,动手的时候会发现自己面临的是三种不同层面的改动:
加字段,是模型层面的扩展;改界面布局,是视图层面的调整;换菜单名字、改字段显示名,是数据层面的更新。
这三个层面,正好对应Odoo模块继承的三种手段。模型继承用_inherit加字段或者扩展方法;视图继承用inherit_id配合xpath改界面结构;数据更新则直接对已有的ir.ui.view、ir.ui.menu、ir.model.fields这些记录做修改。很多人一上来就想着直接编辑标准模块的源码文件,改起来确实痛快,但后果很严重。
1.2 直接改源码的代价
我见过不止一个项目,为了一个字段直接改sale模块的视图文件。当时看着简单,等到标准模块升级,或者另一个模块和你的改动产生冲突,问题就爆了。源码被修改后,升级会被直接覆盖,第三方模块基于标准视图的继承也会和你改出来的结构打架。最要命的是,你改动的代码在团队里没有任何隔离,别人一拉代码就懵。
继承路线则完全规避了这些。你的所有改动都在自己的模块里,标准模块始终保持原样,升级不冲突,第三方模块的继承链也不容易断裂。Odoo官方设计这套继承机制,就是让你用“叠加”而不是“修改”的方式去做二次开发。
1.3 三层继承的配合关系
需要说明的是,这三层往往不是孤立使用的。给销售订单加一个“计划发货日期”字段,模型层加字段只是一个开始。字段加完,如果想让用户在界面上看到、能填写,就还得写一个视图继承,把这个字段嵌到表单或者列表的合适位置。如果还想让这个字段在订单确认的时候参与校验,那又要涉及方法扩展。
这一讲的后半部分会用一个完整案例把这三层串起来。现在先把每一层的核心技术点拆开。
2. 修改视图的正确姿势:inherit_id、xpath与position
2.1 视图继承的三要素
在Odoo里写一个视图继承,核心就是一个<record>声明,里面有三个字段必须搞清楚:
model:目标模型,比如sale.order;inherit_id:被继承视图的外部标识,比如sale.view_order_form是销售订单表单视图;arch:继承后的结构修改,里面放一个个<xpath>。
基本结构长这样:
<record id="view_order_form_inherit_custom_sale" model="ir.ui.view"> <field name="name">sale.order.form.inherit.custom.sale</field> <field name="model">sale.order</field> <field name="inherit_id" ref="sale.view_order_form"/> <field name="arch" type="xml"> <xpath expr="//field[@name='partner_id']" position="after"> <field name="x_planned_date"/> </xpath> </field> </record>inherit_id用ref属性指向目标视图的external id。要特别注意,如果引用错了模块或者写错了外部标识,升级模块时直接报错。判断一个视图的external id最快的方式,是在开发者模式下进入“设置 -> 技术 -> 视图”,找到对应视图记录,看“外部标识”字段。
2.2 xpath表达式:定位节点的关键
xpath的expr属性用来在目标视图的arch中定位节点。对于初学者,记住三个最常用的模式就够了:
- 按字段名定位:
//field[@name='partner_id'] - 按结构路径定位:
//form/sheet/group/group[2] - 按标签内容定位:
//label[@for='partner_id']
//表示在整个视图结构中查找,[@name='...']是属性筛选条件,这是Odoo视图继承里用到最多的写法。写expr的时候,我习惯先用开发者模式打开目标视图的Arch字段,复制出原始XML,然后数清楚节点层级,再写表达式。盲写的xpath有相当概率在升级时给你弹一个“Element cannot be located”的红色大报错。
有一个非常实用的点:expr里是可以写多级路径的。比如要在表单的页签里、第二个页面内、某个字段后面插入内容,可以这样写:
<xpath expr="//notebook/page[2]/group/field[@name='note']" position="after"> <field name="x_planned_date"/> </xpath>这种写法比单纯用//field[@name='note']精准得多,如果视图里有多个同名节点,按路径定位能避免改错位置。
2.3 position的六种用法
position属性决定在定位到的节点上做什么操作。默认值是inside,也就是在节点内部追加内容。日常开发中,这几种用法必须烂熟于心:
inside:在目标节点内部追加,常用于向notebook添加一个<page>;before/after:在目标节点前后插入新节点;replace:替换目标节点;attributes:给目标节点修改属性;move:把已有节点移动到新位置。
先看一段综合示例,同时演示加字段和改属性:
<xpath expr="//field[@name='partner_id']" position="after"> <field name="x_planned_date" string="计划发货日期"/> </xpath> <xpath expr="//field[@name='amount_total']" position="attributes"> <attribute name="string">订单总金额(含税)</attribute> <attribute name="readonly">0</attribute> </xpath>注意,attributes里用<attribute name="属性名">属性值</attribute>来修改节点的属性,比如string、readonly、required、invisible。改invisible为1就是隐藏字段,改成0就是取消隐藏。修改字段的required为1表示该字段在当前视图下必填。
2.4 position="move" 的使用要谨慎
move这个用法从Odoo 13开始支持,用来把某个节点从原位置移到另一个位置。常见的写法是在两个<xpath>里配合:一个xpath标记要移动的节点,另一个xpath在目标位置插入这个节点。
<xpath expr="//field[@name='note']" position="move"/> <xpath expr="//page[@name='other']" position="inside"> <field name="note"/> </xpath>这个功能听着方便,但实际使用中小版本之间有细微差异。如果你在某个版本上发现move的行为不符合预期,一个更稳妥的替代方案是先replace删除原节点,再在新位置插入一个新字段,代价是需要重新设置字段属性。我的建议是:move先在测试环境验证,正式模块里尽量少用这种“乾坤大挪移”,除非你对目标版本的解析逻辑非常熟悉。
2.5 视图继承的优先级
一个视图可能被多个模块叠加继承,这就涉及应用顺序问题。Odoo按ir.ui.view的sequence字段排序继承视图,sequence小的先应用,后应用的会覆盖先应用的修改。如果你发现自己的继承改动没生效,往往是某个第三方模块的继承视图把你的覆盖了。
解决办法很简单:把你自己的继承视图的sequence调大,比如设成99:
<record id="view_order_form_inherit_custom_sale" model="ir.ui.view"> <field name="name">sale.order.form.inherit.custom.sale</field> <field name="model">sale.order</field> <field name="inherit_id" ref="sale.view_order_form"/> <field name="sequence">99</field> <field name="arch" type="xml"> <!-- ... --> </field> </record>这招在处理“多个模块改同一个字段”的场景下非常有效。另外要养成好习惯,name字段按“模块名.模型名.视图类型.用途”的格式写,方便后续在视图列表里快速定位。
3. 修改数据的四种途径:record、function、字段定义与动作
3.1 什么时候需要“修改数据”
很多刚开始做Odoo模块开发的人,只在XML里写<record>创建新数据,一旦遇到“修改已有数据”就不知道怎么办了。实际上,在Odoo里视图、菜单、动作、字段定义全都是数据库里的记录。要改标准模块默认的菜单名称、改某个字段的显示名、替换默认筛选的domain,本质上都是在改这些ir.*模型下的记录。
Odoo的XML数据文件提供了多种修改已有数据的途径,下面这四种覆盖了绝大多数场景。
3.2 途径一:用record覆盖更新已有记录
最直接的方式,就是用<record>声明一个和已有记录相同external id的记录,然后在<field>里写上要修改的字段。比如修改销售订单菜单的名字:
<record id="sale.menu_sale_order" model="ir.ui.menu"> <field name="name">客户销售单</field> </record>关键在于id必须指向那个已经存在的external id,model必须和原记录一致。Odoo加载数据文件时发现这个id已经存在,就会走更新逻辑而不是新增。
这个方案也能修改视图、动作的其他字段。例如修改一个窗口动作的默认搜索过滤:
<record id="sale.action_orders" model="ir.actions.act_window"> <field name="domain">[('state', 'in', ['draft', 'sent'])]</field> <field name="context">{'search_default_state': ['draft']}</field> </record>需要注意,如果这个记录在它所属的模块里被标记了noupdate="1",那这种覆盖方式在模块升级时是无效的。这个坑下面单独讲。
3.3 noupdate机制与强制刷新技巧
noupdate是Odoo数据加载里的一个关键概念。XML中:
<record id="xxx" model="yyy" noupdate="1">表示这条记录在“安装时如果不存在则创建”,但“升级时如果已存在则跳过更新”。很多标准模块的菜单、视图、字段都有noupdate="1"标记,用于防止其他模块或手工修改被版本升级冲掉。这本来是保护机制,但它同样会拦住你合法的修改需求。
遇到这种记录,有两条路可以走。
第一,调整外部标识符的noupdate标记。在开发者模式下打开“设置 -> 技术 -> 序列与标识符 -> 外部标识符”,搜索对应的id记录,把“无更新”勾选取消。然后在你的模块里重新升级一次,你的<record>覆盖就能生效了。如果嫌界面操作麻烦,直接SQL也行:
UPDATE ir_model_data SET noupdate = FALSE WHERE module = 'sale' AND name = 'menu_sale_order';然后重新升级你的模块。
第二,用<function>直接调用ORM的write方法,绕过noupdate检查。我常用这种写法:
<function id="custom_sale_extend.update_menu_name" model="ir.ui.menu" method="write" eval="[ref('sale.menu_sale_order'), {'name': '客户销售单'}]"/>注意eval里的ref('sale.menu_sale_order')会解析出该外部标识对应的数据库ID,然后调用ir.ui.menu.write强制写入字段。这个方法不管记录是不是noupdate都会生效,是实施中的“强制刷新”手段。
3.4 途径二:function调用模型方法做批量数据更新
如果更新逻辑比较复杂,比如要根据现有记录的值计算一个新字段,或者要批量改一批记录,<record>就不够用了。这时候用<function>调模型方法来处理。
<function id="custom_sale_extend.update_planned_dates" model="sale.order" method="_update_planned_dates"/>然后在模型代码里实现:
def _update_planned_dates(self): for order in self.search([('x_planned_date', '=', False)]): order.x_planned_date = order.create_date.date()这种方式的优点是逻辑可以很复杂,查询条件、循环、异常处理都能写。缺点是要保证方法在模块安装/升级时的执行时机正确,而且方法里的数据操作要幂等,也就是说重复执行不会产生副作用。别在<function>里放那种执行一次就算计数的逻辑,因为模块升级可能触发多次执行环境。
3.5 途径三:修改ir.model.fields字段定义
有些场景下,需要修改的不是视图,而是字段本身。比如想把某个标准字段的标签统一改成公司内部叫法,或者给字段加个帮助提示。标准字段的定义也是记录,存在ir.model.fields里。可以这样改:
<record id="sale.field_sale_order__amount_total" model="ir.model.fields"> <field name="string">订单总金额(含税)</field> <field name="help">系统根据订单明细自动计算</field> </record>注意这个external id的规则:<模块名>.field_<模型名>__<字段名>,模型名里的点号换成下划线,字段名和模型名之间用双下划线连接。所以销售订单的amount_total字段通常是sale.field_sale_order__amount_total。实操里我不建议凭记忆写,去开发者模式的“技术 -> 字段”菜单里搜一下模型,能把准确的id复制出来。
但这里有一个必须提醒的坑:ir.model.fields上的required是模型层的必填,会作用于所有视图和所有写入场景。如果你只是想让某个字段在某个表单里必填,请用视图继承里的required属性,不要轻易改模型层的字段定义。模型层required一旦开启,历史数据里为空时会直接阻止保存,影响面极大。
3.6 途径四:修改视图、菜单、动作自身的属性
前面说过,视图自身也是记录。所以你可以通过数据文件去修改一个继承视图的sequence、修改动作的名称、修改菜单的父级关系等。比如调整前面那个继承视图的优先级:
<record id="view_order_form_inherit_custom_sale" model="ir.ui.view"> <field name="sequence">99</field> </record>这种修改思路在模块间互相影响布局时特别有用。你也可以用一个模块去覆盖另一个模块发布的按钮、页签、按钮组等。核心原则只有一个:一切皆记录,所有界面元素无非是ir.ui.view、ir.ui.menu、ir.actions.act_window等模型的记录。
4. 综合实操:给销售订单加字段、改视图、调数据
4.1 场景定义与目标
用一个模拟项目来收拢前面的知识。假设客户要求:
- 销售订单表单上增加“计划发货日期”和“交货备注”两个字段;
- 表单里客户字段显示为必填;
- 金额字段在界面上显示成“订单总金额(含税)”;
- 销售订单的主菜单改名为“客户销售单”。
这套需求横跨模型继承、视图继承、数据修改三个层面。我给这个模块命名为custom_sale_extend,所有代码都放在自己的目录下,不动任何标准模块文件。
4.2 模型继承代码
models/sale_order.py:
from odoo import models, fields class SaleOrder(models.Model): _inherit = 'sale.order' x_planned_date = fields.Date(string='计划发货日期', tracking=True) x_delivery_note = fields.Char(string='交货备注', tracking=True)这里用_inherit = 'sale.order'而不是新建模型,所以不需要额外的访问权限配置。tracking=True是为了让字段变更记录进消息日志,方便客户在订单里看到变更历史。这一步完成之后,字段在数据库层面已经存在了,但界面上还看不到。
4.3 视图继承XML
views/sale_order_views.xml:
<?xml version="1.0" encoding="utf-8"?> <odoo> <record id="view_order_form_inherit_custom_sale" model="ir.ui.view"> <field name="name">sale.order.form.inherit.custom.sale</field> <field name="model">sale.order</field> <field name="inherit_id" ref="sale.view_order_form"/> <field name="sequence">99</field> <field name="arch" type="xml"> <xpath expr="//field[@name='partner_id']" position="after"> <field name="x_planned_date"/> <field name="x_delivery_note"/> </xpath> <xpath expr="//field[@name='amount_total']" position="attributes"> <attribute name="string">订单总金额(含税)</attribute> </xpath> <xpath expr="//field[@name='partner_id']" position="attributes"> <attribute name="required">1</attribute> </xpath> </field> </record> </odoo>三个xpath分别实现了加字段、改标签、设必填。如果项目用的是Odoo 17,还想在订单列表里显示计划发货日期,需要再写一个列表视图继承。注意Odoo 17里列表视图的根节点是<list>,不再是<tree>:
<record id="view_order_tree_inherit_custom_sale" model="ir.ui.view"> <field name="name">sale.order.tree.inherit.custom.sale</field> <field name="model">sale.order</field> <field name="inherit_id" ref="sale.view_order_tree"/> <field name="arch" type="xml"> <xpath expr="//list" position="inside"> <field name="x_planned_date"/> </xpath> </field> </record>如果是Odoo 16及更早版本,把expr="//list"改成expr="//tree"。这个差异看起来小,但升级模块时定位不到节点就直接报错。
4.4 数据更新XML
views/sale_menu_views.xml:
<?xml version="1.0" encoding="utf-8"?> <odoo> <record id="sale.menu_sale_order" model="ir.ui.menu"> <field name="name">客户销售单</field> </record> <record id="sale.field_sale_order__amount_total" model="ir.model.fields"> <field name="string">订单总金额(含税)</field> <field name="help">系统根据订单明细自动计算</field> </record> </odoo>这个文件里两条<record>都在更新已有数据。sale.menu_sale_order在标准模块里是不是noupdate="1",不同版本不一样。如果升级后菜单名字没变,回到3.3节讲的强制刷新手段处理。
这里我额外提醒一点,修改ir.model.fields的string会影响所有视图中该字段的默认标签。如果你在某个视图里用<attribute name="string">改过标签,那个视图的优先级高于字段定义,最终显示以视图为准。改字段定义属于“兜底”方案,适合统一改名,局部调整还是用视图继承。
4.5 模块加载与验证
__manifest__.py里把两个XML文件按依赖顺序写入data列表:
{ 'name': 'Custom Sale Extend', 'version': '1.0', 'depends': ['sale'], 'data': [ 'views/sale_order_views.xml', 'views/sale_menu_views.xml', ], }然后重启Odoo服务并升级模块:
odoo-bin -c odoo.conf -d 数据库名 --update=custom_sale_extend升级成功后,打开销售订单表单,应该能看到新字段在客户字段下方,金额字段标签已经变化,客户字段有必填标记。如果界面上没变化,先在“技术 -> 视图”里找到view_order_form_inherit_custom_sale,点开看“组合”,确认继承有没有被应用,再排查前端缓存。
5. 高频问题排查:视图不生效、xpath报错、数据改不动
5.1 xpath定位失败的排查思路
升级时报Element ... cannot be located in parent view,是最常见的继承视图错误。报错信息里通常带着完整的父视图arch片段,第一反应是把目标字段放进去搜一下,看是不是节点名字写错了、字段在这个视图里根本不存在、或者多模块继承后节点已经被替换了。
有一种情况很隐蔽:你要定位的字段,在标准视图里不是直接存在,而是另一个模块通过继承加进去的。如果你的模块depends里没有声明依赖那个模块,那对方的视图还没加载,你的xpath自然找不到。检查depends是否完整,是排查这类问题时优先级最高的步骤。
5.2 继承视图不生效的原因
视图文件在XML里写了,模块升级也没报错,但界面上就是没反应。按优先级排查:
- 模块data列表里有没有包含这个XML文件;
inherit_id的ref路径是否正确,目标视图的external id有没有少写模块前缀;- 继承视图的
sequence是否被其他模块覆盖,尝试调大sequence到99; - 是否有其他模块也修改了同一个节点,且它的加载顺序靠后;
- 前端缓存。浏览器强刷一次,或者加
?debug=1进入开发者模式查看组合视图。
5.3 修改数据不生效的常见原因
想改的标准菜单、字段、动作,升级后纹丝不动,先确认它是不是noupdate="1"。标准模块里很多核心数据都带着这个标记。处理方法回到3.3节,取消外部标识符的无更新标记,或者改用<function>强制写。
还有一类情况是你的external id写错了。比如把sale.menu_sale_order写成了sale.menu_sale_orders,升级时Odoo找不到这个id就会报“Record does not exist or has been deleted”,或者更隐蔽的是:Odoo允许你不写模块前缀直接声明一个同名id,结果变成创建了一条新记录而不是更新原记录。所以每次改数据前,先确认外部标识符的真实存在性和模块前缀。
5.4 数据加载顺序与依赖问题
XML数据文件的加载顺序由__manifest__.py的data列表决定。如果你在第2个文件里引用第3个文件才创建的记录,升级就会报id不存在。解决办法是把被引用的文件放在前面。跨模块依赖,则必须在depends里声明目标模块。
视图继承没有强制顺序要求,因为Odoo会先加载所有视图记录,再统一计算组合arch。但数据更新不一样,比如你想修改某个动作的domain,而那个动作的记录还没创建,就会失败。所以数据文件里最常见的问题是顺序,而不是语法。
5.5 版本差异速查表
| 项目 | Odoo 13-16 | Odoo 17 |
|---|---|---|
| 列表视图根节点 | <tree> | <list>,tree已移除 |
| 视图继承xpath | 支持 | 支持 |
| position="move" | 支持(13+) | 支持 |
| 开发者模式查看组合视图 | 技术 -> 视图 | 技术 -> 视图 |
写继承视图之前,先搞清项目用的Odoo版本。用旧版本的XML套到新版本里,最常见的翻车点就是列表视图的节点名和部分attrs写法。Odoo 17里attrs的动态属性写法基本被直接属性取代,写的时候尽量跟官方文档保持一致。
这周把视图和数据两块的继承操作梳理完,下一讲可以往更复杂的模型方法扩展走。如果这套操作练熟了,实施项目里七成以上的标准功能调整都能在不动源码的前提下搞定。
我个人在做项目时最大的体会是:继承视图里能少写一个<xpath>就少写一个。每个<xpath>都是一个锚点,锚点越多,被其他模块影响的风险越大。尽量用同一个<xpath>完成对同一节点的连续操作,属性修改合并到同一段attributes里,字段插入按顺序一次完成,这样后面排查冲突会轻松很多。最后再分享一个小技巧:在写视图继承之前,把目标视图的arch复制出来,当作一个树状图来数清楚每个节点,比反复试expr快得多,也稳得多。