Alan Hou的个人博客

Odoo服务端开发基础

这是Odoo系列文章的第六篇,完整目录请见最好用的免费ERP系统Odoo 11开发指南

以下开发均假设读者已完成第五篇的代码,并且所有代码更新后均需自行更新方会在客户端看到变化。如未阅读该篇,请参考代码:Chapter 5

定义模型方法和使用API装饰器

前一篇中主要介绍了如何在自定义模块中声明和继承业务模型,并讲解了书写可计算字段以及对字段值的约束。本文主要针对Odoo方法定义、记录集操控和扩展继承的服务端开发基础。

在模型类中可通过定义方法来进行自定义的行为操作,接下来我们书写一个方法,可以通过用户界面的按钮或应用中代码段调用。代码用于修改所选书籍的状态

1.添加帮助方法检查是否允许状态变换

2.添加方法将书籍修改为传参的新状态

很多装饰器是在 Odoo 9.0引入的,用于将老的 API 调用转为新 API,现在老 API 已不再被支持,但装饰器成为了新 API 的核心部分。

写新的方法时通常使用@api.multi装饰器,表示该方法用于在记录集上执行。方法中的self是一个任意指定数量的数据记录的记录集(包含空记录),代码会遍历记录并对单条记录进行操作。@api.model作用相似,但用于以模型为核心而非对记录集内容进行操作的方法,这个概念有点类似Python的@classmethod装饰器。

change_state()方法的调用示例如下

小技巧:

以上操作可通过 Odoo Shell 进行操作(红色部分分别为配置文件和数数据库名称)

./odoo-bin shell -c myodoo.cfg -d odoo-dev

注意在进行操作后需要执行self.env.cr.commit()来进行写入,returned_book_ids也可以使用列表[1,2],下例中状态修改为 Lost 会被执行,但再更改为 Borrowed 由于不支持将不被执行

调用change_state()是,self是一个包含library.book模型记录的记录集。该方法内部遍历self记录集中的每一本书来进行操作。change_state()方法内部调用is_allowed_transition(),调用通过本地变量book,但可以用于library.book模型的任何记录集。比如 self,因is_allowed_transition()使用@api.model 装饰,允许转换时会为数据集的属性赋予一个新的状态值。我们通过遍历 self 来确保数据集的长度为1,只有这样才能执行前述赋值

扩展知识

在读源码时还会遇到@api.one,该装饰器因初学者容易摸不清头脑已被弃用(deprecated)。同时@api.multi看起来只允许调用方法作用在一个大小为1的记录集上,但事实并非如此。在记录集长度上,@api.one与@api.multi相似,只是在方法外部对记录集进行for循环,并将遍历返回值累积成一个列表返回给调用者。

避免在代码中使用@api.one,可以使用语法更为清晰的foreach装饰器(参见oca-decorators)。可以参考第十章后台视图中的为表单添加按钮部分来了解如何通过用户界面来调用方法。

向用户报告错误

执行方法时如遇到错误条件通常需要终止进程,下面介绍在向磁盘写入文件遇到错误时向终端用户显示有用的错误信息。

准备工作

首先添加以下代码

以上方法因权限问题、磁盘空间不足或非法名称等原因会造成 IOError 或 OSError 等异常的抛出。在碰到错误条件时向用户显示错误信息需执行如下步骤

1.在 Python 代码的头部添加忙下引入

2.修改前述方法来捕获异常并抛出 UserError 异常

Python 中抛出异常时会推到调用栈中确保执行完成。Odoo 响应 web 客户端调用的 RPC 层捕获所有异常,并且取决于异常类会在客户端上触发不同的行为。odoo.exceptions 中未定义的异常会通过栈追踪作为服务器内部错误(返回码500)处理。UserError 会在用户界面显示一条错误信息。代码中将 OSError 转为 UserError 是为了消息显示地更为友好。所有这些情况下数据库的事务都会执行回滚。

异常的捕获不一定需要使用 try…except,通过条件判断也是完全可以的。上述代码中还包含了一个特别的方法名_(),该方法在 odoo.tools.translate中定义。它标记字符串为可翻译,并在运行时根据终端用户执行上下文语言获取翻译字段,第十二章 Odoo的国际化语言配置中会有更深入的介绍。

小技巧:在使用_()方法时确保通过内插占位符而非内插字符串来传递字符串内容

扩展知识:

odoo.exceptions 中还定义了更多的异常类,都可得到老的except_orm 异常类。这些大多数用于内部,有别于:

从其它模型获取空字符集

写 Odoo 代码时,当前模型中的方法可通过 self 获取,但要使用另一个模型时则无法直接实例化该模型的类,此时要获取记录集来使用模型。

我们依然使用前几个章节中的 my_module 插件模块,在 library.book 模型中写一个小方法搜索 library.members模型,首先我们要获取一个 library.members 空记录集,代码如下

启动时 Odoo会载入所有模块并合并从模型中获取的各个类,然后定义或继承指定模型。这些类存储在 Odoo 按类索引的注册表(registry),所有记录集的 self.env,是在 odoo.api 模块中定义的 Environment 类的实例,这个类在 Odoo 开发中起着核心作用:

search()的调用会在后面讲到。

创建新记录

在写业务逻辑方法时常常需要创建新记录,下面就介绍如何创建Odoo 的 base 插件模型中定义的 res.partner 模型的记录。我们将创建一个新的表示公司的partner,以及一些联系人信息。

首先我们需要了解所需创建记录的模型结构,尤其是它们的名称、类型以及这些字段的约束。Odoo 中定义的 res.partner 模型有大量的字段,为了简化我们仅使用部分字段,以下我们会用到的模型的定义:

要创建包含联系人的 partner,需要采取以下步骤:

  1. 在需要创建新 partner 的方法内,获取 create()所需的当前日期格式字符串
  2. 准备第一个联系人的字段值字典
  3. 准备第二个联系人的字段值字典
  4. 准备公司的字段值字典
  5. 调用 create()方法创建新记录

为模型创建一条新记录,我们可以在任何与模型相关的记录集上调用 create(values)方法,该方法返回一条包含字典中指定的字段值的新记录,记录集的长度为1。

在字典中,键为字段的名称,值与字段值对应,根据字段类型需为值传递不同的 Python 类型

(0, 0, dict_val)创建一个与主记录相关联的新记录

(6, 0, id_list)创建一个新建记录与已有记录(ID 在 id_list 中的一个 Python 列表)的关联(注意:在使用 One2many 时会删除之前关联的记录)

在前述 create()被调用时,创建了三条记录:由 create 返回的一条主 partner 公司记录,通过 record.child_ids 获取的两条联系人记录。

扩展知识:

如果模型定义了字段的缺省值,无需执行任何特别操作,create()会自动计算字典中未提供值的字段的默认值。另外,onchange 方法不会被 create()调用,因为它是在记录初始版本时由 网页客户端调用。一些方法会为某些字段计算缺省值,在手动创建记录时,需要自己提供具体值或调用 onchange 方法。在第9章服务端调用 onchange 方法部分会进行介绍。

更新记录集中的记录值

业务中通常需要更改某些字段的值来更新记录,接下来我们为 partner 添加联系人并修改日期字段。我们依然使用简化版的 res.partner,下面写一个 add_contact()方法来更新 partner:

上面的方法首先通过 ensure_one()检测传入参数是否为一条记录,如果不是,则会抛出一个异常并停止执行,需要这么做是因为不能同时向多个 partner 添加相同联系人。接下来会检查 contacts 记录集是否为空,最后该方法修改 partner 记录的属性值,这里使用 |=来完成与当前联系人的合并。

可以看到并没有使用 self,因而可以在任意模型类中定义该方法。

扩展知识:

向记录字段写新值有三种选择:

(0, 0, dict_val)创建一条与主记录相关联的新记录

(1, id, dict_val)更新指定 ID 的记录

(2, id)从相关的记录和数据库中删除指定 ID 的记录

(3, id)从相关的记录中删除指定 ID 的记录,但不在数据库中进行删除

(4, id)向关联记录列中添加指定 ID 的已有记录

(5, )删除所有关联记录,类似于对所有相关记录调用(3, id)

(6, 0, id_list)创建一个被更新记录与已有记录的关联,这些 ID 都在 id_list 这一 Python 列表中

注意:官方文档当前仍未正确更新,上面说3, 4, 5, 6仍不支持 One2many字段,现在并非如此。但确实在某些情况下以上不支持 One2many 字段,这取决于模型的约束,比如在需要反向的 Many2one 关联是,3会因生成未设定 Many2one 关联而产生错误。

搜索记录

方法中也会经常遇到搜索记录的情况,本例中介绍通过公司名查找 Partner 公司及其联系人,我们依然彩精简版的 res.partner,然后写一个 find_partners_and_contact(self, name)的方法:

  1. 获取一个 res.partner 空记录集
  2. 按照所需数据写一个搜索domain
  3. 使用上述 domain调用 search()方法返回数据集

以上代码中通过 search()方法返回一个所有匹配 domain 条件记录的记录集,该记录集还可以进行进一步处理。这里方法中只包含了 domain,但还支持一些关键字参数:

小技巧:推荐使用 search_count(domain)方法,而不是 search(domain, count=True),虽然返回结果相同,但前者名称的语义更明确

扩展知识:

我们说 search()方法返回所有匹配 domain 的记录,但事实是该方法仅返回执行用户拥有访问权限部分的匹配数据。此外,如果模型中有一个名为 active 的布尔字段并且没有在搜索中添加该字段的条件,隐含的条件是只返回 active=True 的记录。

如果你需要用原生 SQL 语句,要保证应用安全规则要在获取到 ID后使用 self.env[‘record.model’].search([(‘id’, ‘in’, tuple(ids))]).ids,尤其是对多公司(multicompany) Odoo 实例,此时记录规则用于区分不同的公司。

合并记录集

  1. 合并两个记录集并保留顺序,使用 result = recordset1 + recordset2
  2. 合并两个记录集并确保结果中没有重复内容,使用 result = recordset1 | recordset2
  3. 获取两个记录集中相同的部分,使用 result = recordset1 & recordset2

记录集类对 Python 中的各种操作符进行了重新的定义,以下是主要的可以用于记录集的操作符:

R1 + R2: 返回一个 R1后紧跟着 R2的记录集,记录集可能包含重复记录

R1 – R2:返回在 R1中且不在 R2中的记录,顺序不变

R1 & R2:返回既在 R1中又在 R2中的记录,顺序不保留

R1 | R2:返回一个 R1或 R2中所包含记录的记录集,顺序不保留,没有重复数据

R1 == R2:如果两条记录集包含相同记录则返回 True

R1 <= R2(R1 in R2):如果 R1中的记录在 R2中都存在则返回 True

R1 >= R2(R2 in R1):如果 R2中的记录在 R1中都存在则返回 True

R1 != R2:如果 R1和 R2中不包含相同记录则返回 True

sorted()方法可用于对记录集中的记录排序,不传参时会使用模型中的_order 属性。也可以 像 Python 内置的 sorted(sequence, key)一样传入函数做键的对比,reverse 也被支持。

有关性能:使用模型默认的_order 参数时,排序由数据库通过 SELECT 来获取排序。其它情况下是由 Odoo 来完成,随操作内容和字符集大小的不同性能消耗也不同。

过滤记录集

有时在获取到数据集后,需仅对其中的部分记录进行操作,遍历数据集并按条件判断不失为一种方法,这里我们来学习使用 filter()方法,依然采用前面的简版 res.partner,创建一个方法来从记录集中获取有 email 的 partner:

  1. 定义一个接收原始数据集的方法
  2. 在内部定义一个 predicate 函数
  3. 调用 filter()

对记录集应用 filter()方法创建一个空记录集,用于添加所有给 predicate 函数判定是否为 True 的记录,然后返回新的记录集,原始的排序仍被保留。上面使用了一个简单的内部函数,像这种情况可以使用 lamba 匿名函数:

其实对于 Python 中为真(非空字符串,非零的数字,非空容器等)的记录集过滤,可以直接传入字段名,如:partners.filter(’email’)。

扩展知识:

请注意filter()在内存中执行,如需在关键路径上对方法的性能进行优化,应使用搜索 domain 甚至是 SQL,当然易读性就会变差。

遍历记录集关联

在对长度为1的记录集操作时,各字段可以记录属性的方式获取。关联属性(One2many, Many2one和 Many2many)在值也为记录集中同样可以获取到。但在数据集多于1条记录时,就无法使用属性了。

接下我们来了解如何使用 mapped()方法来遍历记录集关联,通过写两个方法来执行下述操作:

依然使用简版的 Partner 模型,步骤哪下:

  1. 创建一个 get_email_addresses 方法
  2. 调用 mapped() 从其它 partner 的联系人中获取邮箱
  3. 创建一个 get_companies 方法
  4. 调用 mapped() 从其它partner 获取不同的公司

第1、3步很容易理解,在第2、4步中调用了 mapped(path)方法来遍历记录集中的字段,path 是一个以点分隔的字段名,对于其中的每一个字段,mapped() 生成一个包含该字段所有记录的新记录集,如果 path 的最后一个字段是关联字段 ,则返回记录集,否则返回一个 Python 列表。

mapped() 方法包含两个显著属性:

第二个属性对于应用@api.multi 装饰器的方法非常有用,这种情况要对 self 中由 Many2many 指向的所有记录进行操作,并确保只执行一次。

扩展知识

请注意 mapped() 在 Odoo 服务器的内存中遍历关联生成 SQL 查询,因而效率并不高,但优点是精炼且易读。如果要对实例性能关键路径进行方法的优化,则需要重写 mapped()并将 search()采用合适的 domain 来表达,甚至采用 SQL。

mapped()方法也可以作为参数被函数调用,此时它返回包含应用于 self 每条记录结果的列表,或在函数返回记录集时返回记录集的合集。

继承模型中定义的业务逻辑

定义一个继承另一模型的模型时,通常要自定义原模型中定义的一些方法,这一强大特性在 Odoo 可以轻松实现。

创建一个依赖 my_module 的新插件模块 library_loan_return_date,在这个模块中,继承 library.book.loan 模型:

然后继承 library.member 模型:

expected_return_date 是一个必填字段并且没有默认值,记录借书的向导会因未提供值而止运行。

要 library.loan.wizard模型中继承业务逻辑,需完成如下步骤:

  1. 在 my_module中修改 LibraryLoanWizard 类中的 record_loans()方法,
  2. 在 library_loan_return_date 中创建一个继承 library.loan.wizard 的类并定义_prepare_loan 方法

第一步的代码来自对第9章代码的修改,使用了 library.book.loan。字典的创建被提取成了另一个方法而非对 create() 的调用。第二步对业务逻辑进行了继承,继承了 library.loan.wizard 并且重写了_prepare_loan()方法,并且首先调用了父类,Odoo 模型的的父类和常识理解的不一样。框架动态生成一个类级别,父类是依赖模块中模型的定义。所以,调用 super()返回 my_module 的 library.loan.wizard 的运用,其中,_prepare_loan()返回一个包含 member_id 和 book_id 的字典,在返回前对字典进行添加 expected_return_date 键的添加。

扩展知识:

这里选择了通过调用正常实现并随后修改返回的结果来继承行为,也可在其之前做一些行为。但本部分中需要重写代码来提取继承点来分离方法并在继承模块中重载新方法。

你可能会倾向于重写整个方法,这类行为一定要格外小心,如果不调用方法的 super()实现,则会打破继承机制并破坏插件,同一方法不会再被调用。除非明确知道安装了哪些插件并检查过插件未被破坏。

在调用原方法实现之前或之后要做什么呢?包括但不限于:

继承 write()和 create()

前述继承模型中定义的业务逻辑部分显示了如何继承模型类中的方法,仔细想想父类中定义的方法也是模型的一部分。也就是说 models.Model(实际上是其父类 models.BaseModel)基方法都可用并可被继承。这个部分我们通过继承 create()和 write()来控制记录中各字段的访问。

我们还是使用 my_module 插件模型来进行继承,首先要建立安全组,修改 security/ir.model.access.csv 如下:

在 library.book 模型中添加 manager_remarks 字段,仅让 Library Managers 组中的成员来写该字段:

为避免 Library Managers 组以外的用户修改 manager_remarks 的值,需要执行以下步骤:

  1. 继承 create()方法
  2. 继承 write()方法
  3. 继承 fields.get()方法

第一步重写了 create()方法,在调用create()的基本实现前,使用 user_has_groups()来检查用户是否属于 library.group_library_manager 组(XML ID)。如果不属于该组又传入了 manager_remarks,则抛出 UserError 异常、中断记录的创建。

第二步对 write()进行类似上一步中的操作。

第三步中的 fields.get()方法由 web 客户端用于查询模型的字段和属性,返回一个与字段属性相映射的 Python 字典,如 display 或 help 字符串。比较有意思的是其中的 readonly 属性,当用户不为该组时强制赋值为 True,这样在未授权用户编辑时就会报错。

注意:将 readonly 赋值为空无法阻止 RPC 调用的写入,所以我们继承了 create()和 write()

扩展知识:

继承 write()时,注意在调用 write()的 super()实现之前,self 保持不被修改。上面我们抛出了异常,也可以选择从字典中删除非法字段然后不做该字段的更新:

在调用 super().write()之后,如果想要执行其它动作,请注意可能会再次调用 write()甚至会导致无限循环。解决方法是在上下文中加一个标记进行检查来中止循环:

自定义记录的搜索

第四章中介绍了 name_get()方法用于计算各处记录的表现,包括用于在Web 客户端显示 Many2one 的组件。这里学习如何允许在 Many2one 组件中通过在 name_search 中重定义的标题、作者或 ISBN 来搜索书本。

我们使用如下的模型定义:

使用这一模型时,Many2one 组件中的书本显示为 Book Title(Author1, Author2…)。用户期望可以输入作者名然后过滤找出列表,但并不会生效,因为 name_search 的默认实现仅使用模型类_rec_name 属性引用的属性,这里就是 name,面对高级用户我们还要允许使用 ISBN。

要使用书名、作者或 ISBN 搜索书本,需要在 LibraryBook 类中定义一个_name_search()方法:

默认的 name_search()实现实际上是调用了_name_search()方法,这一方法有一个额外的参数 name_get_uid,用于个别情况用 sudo()计算结果。大多数接收到的参数都未被更改的传入到方法的 super()实现中:

我们的实现所做的有:

  1. 若 args 为 None 生成一个空列表,不为None 则做一个 args 的拷贝(避免修改对调用者产生负面效果)
  2. 然后 name 是否非空或 operator 是否不是 ilike,主要避免生成[(‘name’, ilike, ”)]这种不进行任何过滤的域。此时直接调用 super()实现
  3. 如果有 name 或operator 是否不是 ilike,为 args 添加过滤标准。这里添加语句用于搜索书名、ISBN 或作者名
  4. 最后,在 args 中的修改 后 domain调用 super() 实现,强制给 name 赋值”,给 operator 赋值 ilike。这样使用_name_search()的默认实现不会更改接收到的 domain,而使用我们指定的那个。

我们提到这个方法用于 Many2one 组件,完整地说它还可以用于 Odoo 的以下部分:

本章代码:Chapter 6

退出移动版