全书完整目录请见:Odoo 14开发者指南(Cookbook)第四版
在第四章 应用模型中,我们学习了如何在自定义模块中声明或继承业务模型。该章中的各小节涵盖了为计算字段编写方法,以及编写约束字段值的方法。本章中将集中讲解服务端开发的基础,有Odoo方法定义、数据集操作及扩展已继承方法。这样我们就可以在Odoo中添加及修改业务逻辑了。
本章中,我们将讲解如下内容:
- 定义模型方法及使用API装饰器
- 向用户报出错误
- 获取其它模型的空记录集
- 新建记录
- 更新记录集中记录值
- 搜索记录
- 合并记录集
- 过滤记录集
- 遍历记录集关联
- 记录集排序
- 继承模型中定义的业务逻辑
- 继承write()和create()
- 自定义记录的搜索方式
- 使用read_group()从组中获取数据
技术准备
本章的技术要求包括Odoo在线平台。
本章中所使用的代码可以在如下GitHub仓库中下载:https://github.com/alanhou/odoo14-cookbook。
定义模型方法及使用API装饰器
Odoo模型中,类是字段定义和业务逻辑方法的混合体。第四章 应用模型中我们学习了如何在模型中添加字段。下面就来学习如何在模型中添加方法和业务逻辑。
本节中,我们来学习如何编写可由用户界面中按钮或应用其它代码块调用的方法。这一方法会操作图书并执行所需的动作来修改所选图书的状态。
译者注:本节中初始代码为第三章结束时的代码:GitHub传送门。开始本节前请卸载掉之前安装的应用,重新进行安装,否则可能会出现意想不到的各种错误。
准备工作
本节假定你已有准备好了实例,包含第三章 创建Odoo插件模块中所描述的my_library插件模块。需要在LibraryBook模型中添加一个state字段,定义如下:
1 2 3 4 5 6 7 8 9 |
from odoo import models, fields, api class LibraryBook(models.Model): # [...] state = fields.Selection([ ('draft', 'Unavailable'), ('available', 'Available'), ('borrowed', 'Borrowed'), ('lost', 'Lost')], 'State', default="draft") |
参见第三章 创建Odoo插件模块中添加模型一节获取更详细信息。
如何实现…
要定义方法来修改所选图书的状态,你需要在模型定义中添加如下代码:
- 添加一个帮助方法来查看是否允许状态转换:
123456789@api.modeldef is_allowed_transition(self, old_state, new_state):allowed = [('draft', 'available'),('available', 'borrowed'),('borrowed', 'available'),('available', 'lost'),('borrowed', 'lost'),('lost', 'available')]return (old_state, new_state) in allowed - 添加方法来修改一些书籍的状态为所传参数的新状态:
1234567@api.multidef change_state(self, new_state):for book in self:if book.is_allowed_transition(book.state, new_state):book.state = new_stateelse:continue - 添加方法来通过调用change_state方法修改图书状态:
12345678def make_available(self):self.change_state('available')def make_borrowed(self):self.change_state('borrowed')def make_lost(self):self.change_state('lost') - 在<form>视图中添加按钮和状态栏。这会帮助我们从用户界面中触发这些方法:
12345678910<form>...<header><button name="make_available" string="Make Available" type="object"/><button name="make_borrowed" string="Make Borrowed" type="object"/><button name="make_lost" string="Make Lost" type="object"/><field name="state" widget="statusbar"/></header>...</form>
更新或安装该模块来让这些修改生效。
运行原理…
本节中的代码定义了一些方法。它们是常规的Python方法,带有self作为其第一个参数,也可以带有其它参数。这些方法通过odoo.api模块中的装饰器进行装饰。
📝这些中很多装饰器是在Odoo 9.0中引入的,用于兼容老框架和新框架。在Odoo 10.0中,不再支持老的 API 了,但一些装饰器,比如@api.model,仍在使用。
在编写一个新方法时,如果不使用装饰器,方法会对记录集执行。在这类方法中,self是可以引用任意数量数据库记录的记录集(包括空记录集),代码经常会遍历self中的记录来对每条记录进行操作。
@api.model装饰器也类似,但它用于仅仅是模型很重要而非方法所不操作的记录集中的内容的方法。它的概念类似于Python的@classmethod装饰器。
在第1步中,我们创建了is_allowed_transition() 方法。这个方法的目的是验证从一个状态到另一个状态的转换是否有效。allowed列表中的元组即为可使用转换。例如,我们不会允许lost到borrow的转换,因此没有加入(‘lost, ‘borrowed’)。
第2步中,我们创建了 change_state()方法。这个方法的用途是修改图书的状态。调用该方法时,它将图书的状态修改为给定new_state参数的状态。仅在允许进行转换时它才会修改图书状态。这里使用了for循环是因为self可以 包含多个记录集。
在第3步中,我们创建了一些通过调用change_state()方法来修改图书状态的方法。本例中,方法会由添加到用户界面中的按钮所触发。
第4步中,我们添加在<form>视图中添加了<button>。点击这一按钮时,Odoo客户端会触发name属性中所包含的Python函数。参见第九章 后端视图中向表单添加按钮一节来学习如何从用户界面中调用该方法。我们还添加了带有statusbar组件的state字段,来在<form>视图中显示图书的状态。
在用户从用户界面中点击按钮时,会调用第3步中某个方法。这里的self是一个包含library.book模型记录的记录集(可能为空)。然后,我们调用了 change_state()方法并根据所点击按钮传递相应的参数。
在调用change_state() 时,self是library.book模型中的相同数据集。change_state()方法中的内容体对self进行循环来处理记录集中的每一本书。循环self一开始看上去很奇怪,这但快你就会习惯这种模式。
在循环中,change_state() 调用is_allowed_transition()。调用通过本地变量book进行,但也可对library.book模型中的任意记录集进行调用,例如self,因为is_allowed_transition()由@api.model所装饰。如果允许转换,change_state()通过对记录集的属性赋值来为书籍分配一个新的状态。这仅在记录集长度为1时有效,用于确保和遍历self的情况一致。
扩展知识…
在阅读源代码时你可能会碰到@api.one装饰器。该装饰器因其初看起来会引起混淆而被弃用。同时,如果你知道@api.multi的话,可能会想这个装饰器仅在记录集大小为1时允许调用该方法,但并非这样。在记录集大小方面,@api.one和@api.multi是相似的,但它是在方法外对记录集做for循环,并在列表中对循环的每个遍历返回值进行累加,然后返回给调用者。
向用户报出错误
在方法执行期间,因为用户请求的该动作无效或碰到错误条件有时需要停止进程。本节展示如何通过显示有用的错误信息来处理这类情况。
准备工作
本节假定读者已经准备好了一个实例,并包含有前一小节中所描述的my_library插件模块。
如何实现…
我们会对前面小节中的change_state方法作出修改,并对用户尝试修改is_allowed_transition所不允许的状态时显示帮助信息。按照如下步骤来进行操作:
- 在该Python文件的开头添加如下导入语句:
12from odoo.exceptions import UserErrorfrom odoo.tools.translate import _ - 修改change_state方法并在else部分中抛出UserError异常:
12345678@api.multidef change_state(self, new_state):for book in self:if book.is_allowed_transition(book.state, new_state):book.state = new_stateelse:msg = _('Moving from %s to %s is not allowed') % (book.state, new_state)raise UserError(msg)
运行原理…
Python 中抛出异常时,它会延着调用栈进行传递直到被处理为止。在Odoo中,响应网页客户端发出调用的RPC(远程过程调用)层会捕获所有异常,根据异常类的不同来触发网页客户端上的不同行为。
odoo.exceptions中所未定义的异常通过栈追踪按内部服务器错误(HTTP状态码500)来处理。UserError 会在用户界面中显示错误信息。本节中抛出UserError错误的代码用于确保错误消息以用户友好的方式显示。在所有的用例中,当前数据库事务会被回滚。
我使用了一个名称很奇怪的函数,_(),它在odoo.tools.translate中定义。这个函数用于标记字符串为可翻译,并在运行时根据在执行上下文中查找到的终端用户语言获取翻译字符串。更多详情请参见第十一章 国际化。
📝重要贴士:在使用 _()函数时,确保你仅传递带有插值占位符的字符串,而非整个插值字符串。比如,_(‘Warning: could not find %s’) % value是正确的,_(‘Warning: could not find %s’ % value) 就是错误的,因为第一个字符串不会在翻译数据库中找到替换值。
扩展知识…
有时,会用到有错误倾向的代码,表示你所执行的操作可能会产生错误。Odoo会捕获这一错误并对用户显示回溯。如果你不想要向用户显示完整错误日志,可以缓存错误并抛出一个更具含义的自定义异常。在给出的示例中,我们在try…cache代码块中生成了UserError错误来代替显示完整错误日志。现在Odoo会显示有明确含义的警告:
1 2 3 4 5 6 7 8 |
def post_to_webservice(self, data): try: req = requests.post('http://my-test-service.com', data=data, timeout=10) content = req.json() except IOError: error_msg = _("Something went wrong during data submission") raise UserError(error_msg) return content |
在odoo.exceptions中定义了更多的异常类,都从基本的原有except_orm异常类进行派生。它们大多数仅在内部使用,除以下几种:
- ValidationError:这个异常在没有满足Python对字段的约束时抛出。参见第四章 应用模型中向模型添加约束验证一节获取更多信息。
- AccessError:这个错误通常在用户尝试访问未经允许的内容时会自动生成。如果想要显示访问错误可在代码中手动报出错误。
- RedirectWarning:使用这个错误,可以对错误消息显示重定向按钮。需要对这一异常传递两个参数:第一个是动作 ID,第二个参数是错误消息。
- Warning:在Odoo 8.0中,odoo.exceptions.Warning起着和9.0及之后版本中UserError相同的作用。现在它被弃用的原因是名称具有欺骗性(它是一个错误,而非警告),并且它与Python内置的Warning类有冲突。仅为向后兼容进行了保留,应当在代码中使用UserError。
获取其它模型的空记录集
在编写Odoo代码时,当前模型的方法可通过self访问。如果需要操作其它模型,不能直接实例化该模型的类,需要获取该模型的一个数据集再进行操作。
本节展示如何在Odoo中注册的模型方法中获取任意模型的空记录集。
准备工作
本节将复用my_library插件模块中所设置的图书示例。
我们会在library.book模型中编写一个小方法并搜索所有的图书会员。这时需要获取library.members的空记录集。确保添加了library.members模型并对该模型设置了访问权限。
如何实现…
需要按照如下步骤来获取library.book方法中获取library.members的记录集:
- 在LibraryBook类中,编写一个名为get_all_library_members的方法:
1234567class LibraryBook(models.Model):# ...def log_all_library_members(self):library_member_model = self.env['library.member'] # 这是library.member的空记录集all_members = library_member_model.search([])print('ALL MEMBERS:', all_members)return True - 在<form>视图中添加一个按钮调用该方法:
1<button name="log_all_library_members" string="Log Members" type="object"/>
更新模块来应用以上修改。之后就会在书的<form>视图中看到Log Members按钮。点击该按钮,会在服务端日志中看到会员的记录集。
1 |
ALL MEMBERS: library.member() |
运行原理…
在启动时,Odoo加载了所有的模块并合并从Model中所获取的不同类,同时也定义或继承了给定的模型。这些类存储在Odoo仓库中,按名称索引。任意记录集中的env属性,可通过self.env访问,都是定义在odoo.api模块中的Environment类的实例。
这个类在Odoo开发中扮演着中心角色:
- 它通过模拟Python字典来提供对仓库的快速访问。如果你知道所要查找的模型名,self.env[model_name]会获取该模型的空记录集。再者,该记录集会共享self的环境。
- 它有一个cr属性,是可以用于传递原生SQL查询的数据库游标。参见第八章 高级服务端开发技巧中的执行原生SQL查询一节获取更多信息。
- 它有一个user属性,是对执行调用的当前用户的引用。参见第八章 高级服务端开发技巧中的更改执行动作的用户一节获取更多信息。
- 它有一个context属性,是一个包含调用上下文的字典。这包含用户语言、时区、当前记录选项等等的信息。参见第八章 高级服务端开发技巧中的使用变更的上下文调用方法一节获取更多信息。
search()的调用在稍后的搜索记录一节中进行讲解。
其它内容
有时会希望使用环境的变更版本。一个例子是想要有其它用户及语言的环境。第八章 高级服务端开发技巧中的更改执行动作的用户和使用变更的上下文调用方法小节中会讲解有关在运行时修改self.env的知识。
新建记录
在编写业务逻辑方法时一个普遍的要求是新建记录。本节讲解如何创建library.book.category中的记录。在我们的示例中,将会为library.book.category模型添加一个创建dummy分类的方法。要触发这一方法,需要在<form>视图中添加一个按钮。
注:原书中本章从本节开始存在多处错误,笔者尽量进行了规避。
准备工作
读者需要知道想要新建记录的模型结构,尤其是它们的名称和类型,以及各字段可能存在的约束(如有些字段为必填)。本节中,我们将复用第四章 应用模型中的my_library模块。我们看一下以下示例来快速回顾library.book.category模型:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
class BookCategory(models.Model): _name = 'library.book.category' name = fields.Char('Category') description = fields.Text('Description') parent_id = fields.Many2one( 'library.book.category', string='Parent Category', ondelete='restrict', index=True ) child_ids = fields.One2many( 'library.book.category', 'parent_id', string='Child Categories') |
确保已为library.book.category模型添加了目录、视图和访问权限。
如何实现…
需要执行如下步骤来创建带有一些子分类的分类:
- 在library.book.category中创建一个名为create_categories的方法:
12def create_categories(self):...... - 在这一方法体内,准备一个包含第一个子类各字段值的字典:
1234categ1 = {'name': 'Child category 1','description': 'Description for child 1'} - 准备包含第二个子类各字段值的字典:
1234categ2 = {'name': 'Child category 2','description': 'Description for child 2'} - 准备包含父类各字段值的字典:
12345678parent_category_val = {'name': 'Parent category','description': 'Description for parent category','child_ids': [(0, 0, categ1),(0, 0, categ2),]} - 调用create()方法来新建记录:
1record = self.env['library.book.category'].create(parent_category_val) - 在<form>视图中添加用户界面中触发create_categories方法的按钮:
1<button name="create_categories" string="Create Categories" type="object"/>
运行原理…
要为模型新建记录,我们可以对任意与模型关联的记录集调用create(values)方法。该方法返回一个长度为1的新记录集,其中包含带有值字典中所指定字段值的这条新记录。
在字典中,各个键给定字段的名称,相应的值与字段值对应。根据字段类型的不同,需要对值传递不同的Python类型:
- Text字段值给定的类型为Python字符串。
- Float和Integer字段值使用Python的浮点型和整型。
- Boolean字段值最好使用Python布尔类型或整型。
- Date字段值使用Python的datetime.date 对象。
- Datetime字段值使用Python的datetime.datetime对象。
- Binary字段值以Base64编码字符串进行传递。Python标准库中的base64模块提供诸如encodebytes(bytestring)的方法来对字符串以Base64进行编码。
- Many2one字段值给定的为整型,应为关联记录的数据库ID。
- One2many和Many2many字段使用一个特殊语法。值为包含三个元素元组的一个列表,如下:
元组 效果 (0, 0, dict_val) 创建一条与主记录关联的新记录。 (6, 0, id_list) 在所创建记录与已有记录间创建一个关联,它们的 ID 在一个名为 id_list 的 Python 列表中。
注意:在用于 One2many 字段时,这会删除此前关联中的记录。
表5.1
在本节中,我们在所要创建的公司中新建了两个联系人的字典,然后我们使用通过前述(0, 0, dict_val)语法所创建公司字典child_ids键中的这些字典。
在第5步中调用create()时,创建了三条记录:
- 一条是父级图书分类,由create返回
- 两条为子级图书分类,可通过record.child_ids进行获取
扩展知识…
如果该模型为某些字段定义了一些默认值,不需要做什么特别的事情,create()会处理所提供字典中不存在字段默认值的计算。
从Odoo 12开始,create()方法还支持批量创建记录。要批量创建多条记录,需要向create() 方法传递一个多值列表,如下例所示:
1 2 3 4 5 6 7 8 9 |
categ1 = { 'name': 'Category 1', 'description': 'Description for Category 1' } categ2 = { 'name': 'Category 2', 'description': 'Description for Category 2' } multiple_records = self.env['library.book.category'].create([categ1, categ2]) |
更新记录集中记录值
业务逻辑经常要求我们通过修改其中的一些字段的值来更新记录。本节将展示如何修改partner中的date字段。
准备工作
本节将使用新建记录一节中相同的简化library.book定义。可以参照这一简化定义来找到这些字段。
我们在library.book模型中有date_release字段。为进行演示,我们通过点击按钮在这一字段上进行写入。
如何实现…
- 要更新图书的date_release字段,可以编写一个名为change_release_date()的新方法,定义如下:
123def change_release_date(self):self.ensure_one()self.date_release = fields.Date.today() - 然后我们在图书<form>视图的xml中添加一个按钮,如下:
1<button name="change_release_date" string="Update Date" type="object"/> - 重启服务并更新my_library模块来查看变化。在点击Update Date按钮时,date_release会被修改。
运行原理…
该方法通过调用ensure_one()检查self传递的图书记录集是否为一条记录。如不是该方法会抛出一个异常,并停止处理。需要这么做是因为我们不希望修改多条记录的日期。如果你想要更新多条值,可以删除ensure_one()并使用对记录集的循环来更新该属性。
最后,该方法修改图书记录的各属性值。它以当前时间更新date_release字段。通过修改记录集中的字段属性,可以执行写操作。
扩展知识…
如果想要向记录字段写入新值有三种选项:
- 选项一在本节中进行了讲解。它通过直接向代表记录字段的属性分配值在所有上下文中均可使用。也可以一次同时对所有记录集元素分配值,这时需要遍历记录集,除非你非常确定仅处理单条记录。
- 选项二通过使用update()方法将字典映射字段传递给所想要设置的值。它也同样仅可作用于长度为1的记录集。在需要同时对同一记录更新多个字段的值时可以节约一些代码。本节中第二步使用此选项重写如下:
1234567def change_release_date(self):self.ensure_one()self.update({'date_release': fields.Date.today(),'another_field': 'value'...}) - 选项三为调用write()方法,向你所希望设置的值传递一个映射字段名的字典。前面两种选项对每条记录和每个字段均需执行一次数据库调用,该方法一次数据库调用即可操作任意大小的记录集,并以指定值更新所有记录。但是它也有一些限制:
- 如果记录尚不存在于数据库中则无法使用它。(更多相关知识参见第八章 高级服务端开发技巧中的定义onchange方法一节)
- 在写入关联字段时它要求有特殊的格式,类似于create() 方法所使用的格式。查看下表中用于为关联字段生成不同值的格式:
元组 效果 (0, 0, dict_val) 它会新建一条与主记录关联的记录。 (1, id, dict_val) 它会以所提供值更新所指定 ID 的关联记录。 (2, id) 它会从关联记录中删除指定 ID 的记录并在数据库中删除它。 (3, id) 它会从关联记录中删除指定 ID 的记录。记录在数据库中并不会被删除。 (4, id) 它会向关联记录列表添加给定 ID 的已有记录。 (5, ) 它会删除所有关联记录,等价于对每个关联 id 调用(3, id) 。 (6, 0, id_list) 它会创建所更新记录与已有记录间的关联,它们的 ID 在 Python 列表中名为 id_list。
表5.2
📝重要提示:create() 方法无法使用操作类型1, 2, 3和5。
搜索记录
对记录的搜索也是业务逻辑方法中常见的操作。本节将展示如何通过书名和分类查找图书。
准备工作
本节将使用与新建记录一节中相同的library.book定义。我们在名为find_book(self)的方法中编写代码。
如何实现…
需要执行如下步骤来查找书籍:
- 在library.book模型中添加find_book方法:
12def find_book(self):... - 为你的条件编写搜索域:
1234567domain = ['|','&', ('name', 'ilike', 'Book Name'),('category_id.name', 'ilike', 'Category Name'),'&', ('name', 'ilike', 'Book Name 2'),('category_id.name', 'ilike', 'Category Name 2')] - 通过域调用search()方法,它会返回记录集:
1books = self.search(domain)
books变量包含搜索到图书的记录集,可以打印或日志记录下该变量,在服务端日志中查看结果。
1 2 3 4 5 |
<header> ... <button name="find_book" string="Find Book" type="object"/> <field name="state" widget="statusbar"/> </header> |
运行原理…
第1步中定义了该方法。
第2步中在本地变量中创建了一个搜索域。通常你会在对搜索调用的行内看到这一创建,但对于复杂的作用域,最好是分开进行定义。
关于搜索域语法的完整讲解,参见第九章 后端视图中的在记录列表上定义过滤器 – 域一节。
第3步使用该域调用了search()方法。该方法返回包含所有匹配这个作用域的记录的一个记录集,还可以对它进行进一步处理。本节中,我们仅通过该域调用此方法,但同时也支持如下关键词参数:
- offset=N:它用于跳过前 N 条记录来匹配查询。可与limit一起使用来实现分页或减少在处理大量记录时的内存消耗。其默认值为0.
- limit=N:表示最多返回 N 条记录。默认未设置上限。
- order=sort_specification:用于强制所返回记录集的排序。默认,排序通过模型类的_order属性给定。
- count=boolean:若为True,它返回记录数而非实际记录集。默认值为False。
📝重要贴士:我们推荐使用search_count(domain)方法,而非search(domain, count=True),因为它的方法名以更清晰地方式传递了其用途,两者返回的值相同。
有时,需要搜索另一个模型来让对self的搜索返回当前模型的记录集。从另一个模型中进行搜索,我们需要获取该模型的空记录集。例如,假设我们想要搜索联系人。那么,我们需要对res.partner模型使用search()方法。参见下方的代码。这里我们获取了res.partner的空记录集来搜索联系人:
1 2 3 4 5 6 7 |
def find_partner(self): PartnerObj = self.env['res.partner'] domain = [ '&', ('name', 'ilike', 'Parth Gajjar'), ('company_id.name', '=', 'Odoo') ] partner = PartnerObj.search(domain) |
上述代码中,可以在domain 中去掉&,因为在域中未进行指定时,Odoo默认会使用&。
扩展知识…
前面我们说到search()方法返回匹配搜索域的所有记录。但事实并非完全如此。该方法会确保仅返回执行搜索的用户拥有访问权限的记录。此外,如果模型中有名为active的布尔字段,而搜索域中并未指定该字段的搜索条件,那么会隐式地添加一个active=True条件来仅返回这部分记录。因此,如果你想要搜索返回内容而返回了空记录集时,确保检查active的值(如果存在)来检查记录规则。
参见第八章 高级服务端开发技巧中的使用其它上下文调用方法一节,来了解如何不隐式的添加active=True条件。参见第十章 权限安全中的使用记录规则限制记录访问一节有获取有关记录级别方法权限的知识。
如果出于一些原因,你要使用原生SQL查询来查找记录ID,确保获取ID 后使用self.env[‘record.model’].search([(‘id’, ‘in’, tuple(ids))]).ids来应用权限规则。这对于需用记录规则对公司采取区别对待的多公司(租户)Odoo实例尤为重要。
合并记录集
有时,你会发现所获取的记录集并非你真正所需的。本节展示合并它们的不同方式。
准备工作
本节要求在同一个模型中有两个或多个记录集。
如何实现…
按照如下步骤来对记录集执行常用运算:
- 将两个记录集合并为一个并保留排序,使用如下操作:
1result = recordset1 + recordset2 - 使用如下运算合并两个记录集,可确保结果中没有重复内容:
1result = recordset1 | recordset2 - 使用如下运算来查找两个记录集中共同的记录:
1result = recordset1 & recordset2
运行原理…
针对记录集的类实现了很多Python运算符的重定义,在此处进行了使用。以下为可用于记录集的最有用的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。 |
表5.3
还有些复合运算符+=, -=, &=和 |=,它们会修改左侧的运算项而不会新建记录集。在更新记录的One2many或Many2many字段时这些会非常的有用。参见更新记录集中记录值一节来查看此类示例。
过滤记录集
在某些情况下,已有一个记录集,仅需对其中的某些记录进行操作。当然可以遍历记录集并对每条遍历进行条件判断并根据所查看的结果执行操作,构造一个仅包含需操作的记录的新记录集并对该记录集调用同一操作会更容易,在某些情况下还会更高效。
本节展示如何使用 filtered()方法来根据从另一个记录集中提取子集。
准备工作
我们将复用新建记录一节中所展示的简化的library.book 模型。本节定义一个从给定记录集中提取含有多名作者的图书的方法。
如何实现…
执行如下步骤来从一个记录集中提取包含多名作者的记录:
- 定义接收原始记录集的方法:
12@api.modeldef books_with_multiple_authors(self, all_books): - 定义内部的predicate函数:
1234def predicate(book):if len(book.author_ids) > 1:return Truereturn False - 调用filtered(),如下:
1return all_books.filtered(predicate)
可以打印或日志记录该方法的结果 ,在服务端日志中查看。参见本节中的示例代码了解更多。
1 2 3 4 5 |
<header> ... <button name="filter_books" string="Filter Book" type="object"/> <field name="state" widget="statusbar"/> </header> |
运行原理…
filtered()方法的实现创建了一个空记录集,其中添加predicate函数运行结果为True的所有记录。最终返回一个新记录集。保留原记录集中记录的排序。
前面部分使用了一个内部命名函数。对这种简单场景会经常发现使用匿名函数 Lambda:
1 2 3 |
@api.model def books_with_multiple_authors(self, all_books): return all_books.filtered(lambda b: len(b.author_ids) > 1) |
事实上你需要基于 Python 层面为真的字段值(非空字符串,非零数字、非空容器等)进行记录集的过滤。因此如果希望过滤出带有某分类集合的记录,可以传递字段名来进行类似如下过滤:all_books.filtered(‘category_id’)。
扩展知识…
记住filtered()是在内存中进行运算。如果尝试对关键路径上的方法进行性能优化,可能会要使用搜索域或者甚至是转向SQL,代价是损失代码易读性。
遍历记录集关联
在操作长度为1的记录集时,有很多字段可用作记录属性。带有记录值作为值的关联属性(One2many, Many2one和Many2many)也同样可以使用。作为一个示例,我们假定需要访问library.book模型记录集中的分类名称。可以通过遍历many2one字段的category_id来访问分类名,如下:book.category_id.name。但是,在操作带有一条以上记录的记录集时,则不能使用该属性。
本节展示如何使用mapped() 方法来遍历记录集关联,我们会编写一个方法并传递图书参数来获取图书记录集中作者的名字。
准备工作
我们将复用本章中新建记录一节中使用的 library.book模型。
如何实现…
需要执行如下步骤来从图书记录集中获取作者姓名:
- 定义名为get_author_names()的方法:
12@api.modeldef get_author_names(self, books): - 调用mapped()来获取成员联系人的姓名:
1return books.mapped('author_ids.name')
运行原理…
第1步中仅是定义了该方法。第2步中,我们调用了mapped(path) 方法来遍历该记录集中的字段:path是包含以点号分隔字段名的字符串。对于path中的每一个字段,mapped()生成了一个包含该字段所关联当前记录集所有元素的所有记录。然后将其应用于新记录集中path的下一个元素。如果path中的最后一个字段是关联字段,mapped()会返回一个记录集,否则,返回一个Python列表。
mapped()方法有两个有用的属性:
- 如果path是一个标量字段名,那么返回的列表与所处理记录集使用相同的排序。
- 如果path包含一个关联字段,那么不保留排序,复制内容会从结果中进行删除。
📝重要信息:第二个属性在希望对self中所有记录经Many2many字段指向的所有记录执行操作时非常有用,但需要确保操作仅会执行一次(哪怕是self中的两条记录共享同一个目标记录)。
扩展知识…
在使用mapped()时,要记住它在Odoo服务端的内存中进行操作,反复地遍历关联并因此产生SQL查询,这样效率可能不高。但是这种代码很直白且具备表达性。如果你在尝试优化实例关键路径上的方法提高性能的话,可能会要重写调用为mapped()并以相应的域来以search()进行表现,甚至是转向SQL(代价是损失代码易读性)。
mapped()方法也可以通过函数作为参数来进行调用。这种情况下,它返回包含应用于self每条记录的函数的结果列表,或者返回在函数返回的是记录集的情况下由该函数返回的记录集的并集。
其它内容
- 本章中的搜索记录一节
- 第八章 高级服务端开发技巧中的执行原生SQL查询一节
记录集排序
在通过search()方法获取一个记录集时,你可以传递一个可选参数order来以指定排序获取记录集。这对于在此前代码中已获取记录集并想对其排序会非常有用。例如对使用集合运算来合并两个记录集时(会丢失排序)可能也会很有用。
本节展示如何使用sorted()方法来对已有记录集进行排序。我们会对图书进行发行日期的排序。
准备工作
我们将复用本章中新建记录一节中所展示的library.book模型。
如何实现…
需要执行如下步骤来获取基于release_date排序的图书记录集:
- 定义一个名为 sort_books_by_date()的方法:
12@api.modeldef sort_books_by_date(self, books): - 像例中那样,使用sorted()方法来根据release_date字段对图书记录排序:
1return books.sorted(key='release_date')
运行原理…
第1步只是对方法的定义。在第2步中,我们调用了图书记录集中的sorted() 方法。sorted() 方法在内部会获取以参数key进行传递的字段数据。然后,通过使用Python的原生sorted方法返回一个排序后记录集。
它还有一个可选参数reverse=True,以逆向排序返回记录集。reverse的用法如下:
1 |
books.sorted(key='release_date', reverse=True) |
扩展知识…
sorted()方法会在记录集对记录排序。调用时若不传入参数,则会使用模型中的_order属性。另外,可传入一个函数来以Python内置sorted (sequence, key)函数相同的方式计算一个比较键。
📝重要提示:在使用模型的默认_order参数时,排序由数据库来代理,执行了一个新的SELECT函数来获取排序。否则,排序由Odoo来执行。根据所操纵的内容以及记录集的大小的不同,可能会有很大的性能上的不同。
继承模型中定义的业务逻辑
Odoo中将应用功能划分成不同模块是极其常见的实践。这样,可以简单地通过安装/卸载应用来启用/禁用功能。在向已有应用添加功能时,自定义一些由原应用中定义的方法的行为也很有必要。有时,需要向已有模型添加一个新字段。在Odoo中这是一个非常轻松的任务,也是框架底层一个最为强大的功能。
本节我们学习如何继承另一个模块中方法的业务逻辑。我们还会通过新模块向已有模块新增字段。
准备工作
本节中,我们将继续使用上一节中的my_library模块。要确保my_library模块中存在library.book.category 模型。
这一节中我们将新建一个名为my_library_return的模块,它依赖于my_library模块。在这一模块中,我们将管理借阅图书的归还日期。我们还会自动地根据分类来计算归还日期。
在第四章 应用模型中的使用继承向模型添加功能一节,我们学习了如何在已有模型中添加字段。在这个模块中,继承library.book模型如下:
1 2 3 |
class LibraryBook(models.Model): _inherit = 'library.book' date_return = fields.Date('Date to return') |
然后继承library.book.category模型如下:
1 2 3 4 5 6 |
class LibraryBookCategory(models.Model): _inherit = 'library.book.category' max_borrow_days = fields.Integer( 'Maximum borrow days', help="For how many days book can be borrowed", default=10) |
需要按照第九章 后端视图中的修改已有视图 – 视图继承一节来在视图中添加该字段。你可以在https://github.com/alanhou/odoo14-cookbook中查看代码的完整示例。
如何实现…
需要执行如下步骤来在library.book模型中继承这一业务逻辑:
- 在my_library_return中,我们希望在修改图书状态为Borrowed时在图书记录中设置date_return。为此,我们将重载my_library_return模块中的make_borrowed方法:
1234def make_borrowed(self):day_to_borrow = self.category_id.max_borrow_days or 10self.date_return = fields.Date.today() + timedelta(days=day_to_borrow)return super(LibraryBook, self).make_borrowed() - 我们还希望在图书归还、可供借阅时重置date_return,因此我们将重载make_available方法来重置该日期:
123def make_available(self):self.date_return = Falsereturn super(LibraryBook, self).make_available()
运行原理…
第1步和第2步执行对业务逻辑的继承。我们定义了一个继承library.books的模型并且重定义了make_borrowed()和make_available()方法。在这两个方法的最后一行,返回由父类实现的结果:
1 |
return super(LibraryBook, self).make_borrowed() |
在Odoo模型的用例中,父类和你在Python类定义中所看到的并不太一样。框架动态为我们的记录集生成一个类等级,父类由我们所依赖的模块中的模型定义。因此,调用super()返回了my_library模块中library.book的实现。在这一实现中,make_borrowed() 修改图书的状态为Borrowed。因此调用 super 会触发父类方法并且它会设置图书的状态为Borrowed。
扩展知识…
在本节中,我们选择了继承方法的默认实现。在make_borrow()和make_available()方法中,我们在super()的调用前修改了返回的结果。注意在调用super() 时,它会执行其默认实现。也可以在super() 调用之后执行一些动作。当然,也可以同时执行两者。
但是,在方法的中间修改行为会更为困难。这时,我们需要重构代码来提取一个继承点以分隔方法并在继承模块中重载这一新方法。
📝你可能会萌生全新重写一个方法的念头。这么做时一定要小心,如果不调用方法的super() 实现,就在破坏继承机制并可能破坏继承该方法的插件,也即永远不调用该继承方法。除非所使用的环境完全受控,你了解具体安装了哪些插件并查看过不会破坏这些插件,否则不要这么做。同时应该确保用一种可见的方式来以文档记录所做的操作。
在调用方法的原有实现之前和之后你可以做哪些事呢?有很多,包括但不限于如下这些:
- 修改传递给原有实现的参数(之前)
- 修改传递给原有实现的上下文(之前)
- 修改原有实现返回的结果(之后)
- 调用另一个方法(之前和之后)
- 创建记录(之前和之后)
- 抛出一个UserError来在禁止用例中取消执行(之前和之后)
- 分拆self为更小的记录集,并以不同方式调用每个子集的原有实现(之前)
继承write()和create()
本章中的继承模型中定义的业务逻辑一节向我们展示了如何继承模型类中定义的方法。如果你考虑一下,模型的父类中定义的方法也是模型的一部分。这表示models.Model(实际为models.Model的父类models.BaseModel)上定义的所有基础方法都可以使用或被继承。
本节将展示如何继承create()和write() 来控制对记录中某些字段的访问。
准备工作
我们将通过第三章 创建Odoo插件模块中的my_library插件模块扩展图书示例。
在library.book模型中添加一个manager_remarks字段。我们仅希望图书管理员分组中的成员可以写入该字段:
1 2 3 4 |
from odoo import models, api, exceptions class LibraryBook(models.Model): _name = 'library.book' manager_remarks = fields.Text('Manager Remarks') |
在view/library_book.xml文件的<form>中添加manager_remarks字段来通过用户界面访问该字段:
1 |
<field name="manager_remarks"/> |
修改security/ir.model.access.csv文件来给图书用户写入的权限:
如何实现…
要防止非librarian组的成员修改manager_remarks的值,需要执行如下步骤:
- 继承create()方法如下:
123456789@api.modeldef create(self, values):if not self.user_has_groups('my_library.group_librarian'):if 'manager_remarks' in values:raise UserError('You are not allowed to modify ''manager_remarks')return super(LibraryBook, self).create(values) - 继承write()方法如下:
12345678def write(self, values):if not self.user_has_groups('my_library.group_librarian'):if 'manager_remarks' in values:raise UserError('You are not allowed to modify ''manager_remarks')return super(LibraryBook, self).write(values)
运行原理…
第1步中重新定义了create()方法。在调用create() 的基础实现之前,我们的方法使用了user_has_groups() 方法来查看启用是否属于my_library.group_librarian组(这是该组的XML ID)。如果并非如此且向manager_remarks传递了值,则抛出一个UserError异常,阻止止记录的创建。这一检查在基础实现的调用之前执行。
第2步对 write() 执行相同的操作。在写入之前,我们检查组以及写入的值中有哪些字段,在有问题时抛出UserError异常。
📝重要提示:在网页客户端中将该字段设为只读并不会防止RPC调用对其进行写入。这也是为什么我们继承了create()和write()。
本节中,我们学习如何重载create()和write() 方法。但注意这并不仅限于reate()和write() 方法。可以重载任意模型方法。例如,如果希望在记录删除时做一些操作。则需要重载unlink()方法(记录删除时会调用unlink()方法)。以下是一段重载unlink()方法的代码片段。
1 2 3 |
def unlink(self): # your logic return super(LibraryBook, self).unlink() |
⚠️警告:在Odoo中重载方法时,别忘记调用super()方法,否则会碰到问题。这是因为不使用super()方法,原有方法中的代码不会进行执行。如果在以上代码中我们没有调用super(…).unlink(),就会删除记录。
译者注:可使用demo 用户进行测试,然后在Settings > User & Companies > Users选中对应用户点击 Edit,在 Other 中选择勾选 Librarians后再次测试。
扩展知识…
在继承write()时,注意调用write()的super()实现之前,self仍未被修改。你可以使用它对比该字段的当前值和values字典中的字段。
本节中,我们选择抛出异常,但你也可以选择从values字典中删除掉不符合规则的字段,静默地跳过记录中该字段的更新:
1 2 3 4 5 |
def write(self, values): if not self.user_has_groups( 'my_library.group_librarian'): if 'manager_remarks' in values: del values['manager_remarks'] return super(LibraryBook, self).write(values) |
在调用super().write()之后,如果希望执行其它动作,则需要对任何其它再次引发write()的调用保持警惕,否则会造成一个无限递归循环。规避的方法是在上下文中加入标记来进行检查以解除这种递归:
1 2 3 4 5 6 7 8 |
class MyModel(models.Model): def write(self, values): sup = super(MyModel, self).write(values) if self.env.context.get('MyModelLoopBreaker'): return self = self.with_context(MyModelLoopBreaker=True) self.compute_things() # can cause calls to writes return sup |
上例中,我们在调用compute_things()方法之前添加了MyModelLoopBreaker这个键。因此如果再次调用write()方法,就不会进入无限循环。
自定义记录的搜索方式
在第三章 创建Odoo插件模块中定义模型表示及排序一节中引入了name_get()方法,用于计算不同地方记录的展现,包含用于在网页客户端中用于展示Many2one关联的微件。
本节将展示如何通过重新定义name_search在Many2one组件中通过标题、作者或ISBN来搜索图书。
准备工作
本节中,我们将使用如下模型定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
class LibraryBook(models.Model): _name = 'library.book' name = fields.Char('Title') isbn = fields.Char('ISBN') author_ids = fields.Many2many('res.partner', 'Authors') @api.multi def name_get(self): result = [] for book in self: authors = book.author_ids.mapped('name') name = '%s (%s)' % (book.name, ', '.join(authors)) result.append((book.id, name)) return result |
在使用这一模型时,Many2one微件中的图书以图书名(作者1,作者2…)进行显示。用户预设可通过输入作者名查找根据这一姓名过滤出的列表,但并不会这样,因为name_search的默认实现仅使用了模型类中_rec_name属性所引用的属性,本例中为name。我们也希望可通过ISBN号来进行过滤。
如何实现…
执行如下步骤在实现本小节的功能:
- 要对library.book能够使用书名、作者或ISBN号进行搜索,需要在LibraryBook类中定义一个_name_search() 方法。
12345678910111213@api.modeldef _name_search(self, name='', args=None, operator='ilike',limit=100, name_get_uid=None):args = [] if args is None else args.copy()if not(name == '' and operator == 'ilike'):args += ['|', '|', '|',('name', operator, name),('isbn', operator, name),('author_ids.name', operator, name)]return super(LibraryBook, self)._name_search(name=name, args=args, operator=operator,limit=limit, name_get_uid=name_get_uid) - 在library.book模型中添加old_editions Many2one字段来测试 _name_search的实现:
1old_edition = fields.Many2one('library.book', string='Old Edition') - 向用户界面中添加如下字段:
1<field name="old_edition" /> - 重启服务并更新模块来让修改生效。
可以通过在old_edition Many2one字段中进行搜索来调用_name_search方法。
运行原理…
name_search()的默认实现实际上仅仅是调用了_name_search()方法,它执行了真正的任务。_name_search()方法有一个额外的参数name_get_uid,用于一些极端用例中,如你希望使用sudo() 或通过不同的用户来计算结果。
我们将接收到的大部分参数不做修改的传递给该方法的super()实现:
- name包含至此所输入值的字符串。
- args为None或一个用于预过滤可能记录的搜索域。(比如,它可以来自Many2one关联的domain参数。)
- operator是一个包含匹配运算符的字符串。通常有’ilike’ 或 ‘=’。
- limit是要获取的最大行数。
- name_get_uid可在调用 name_get()计算微件中显示字符串时用于指定不同的用户。
我们实现的方法做了如下操作:
- 如果args为None,生成一个新的空列表,否则对args进行拷贝。我们通过做拷贝来避免对列表的修改对调用者产生负面效果。
- 然后,我们查看name是否为非空字符串或者运算符是否不是’ilike’。这用于避免生成无效的域, [(‘name’, ilike, ”)],它并不能过滤任何东西。在这种情况下,我们直接进行super()的调用实现。
- 如果name存在,或者运算符并非’ilike’,那么我们对args添加一些过滤条件。在本例中,我们添加了对所提供名称在图书标题、ISNB 或作者姓名中搜索的语句。
- 最后,我们以args中修改的域调用了super()实现并强制name为”以及运算符为ilike。我们通过这么做来强制_name_search() 的默认实现不对它所接收到的域做任何修改,因而使用我们所指定的域。
扩展知识…
我们在引言中提到这一方法用于Many2one微件。为保持完整性,它还可用于Odoo中如下部分:
- 对域中的One2many和Many2many字段使用in运算符时
- 搜索many2many_tags微件中记录
- 搜索CSV导入文件中的记录
其它内容
在第三章 创建Odoo插件模块中定义模型表示及排序一节中演示了如何定义name_get()方法,该方法用于创建记录的文本表现。
在第九章 后端视图中的在记录列表上定义过滤器 – 域一节,提供了有关搜索域语法的更多内容。
通过read_group()获取组中的数据
在前面的各节中,我们学习了如何从数据库中搜索和获取数据。但有时,会希望通过聚合记录来获取结果,如上个月销售订单的平均成本。在SQL中获取这样的结果我们通常使用group和aggregate函数。所幸的是在Odoo中有read_group() 方法。本节中我们学习如何使用read_group() 方法来获取聚合结果。
准备工作
本小节中,我们将使用第三章 创建Odoo插件模块中的my_library插件模块图书示例。
修改 library.book模型,如下面的模型定义所示:
1 2 3 4 5 6 7 8 9 |
class LibraryBook(models.Model): _name = 'library.book' name = fields.Char('Title', required=True) date_release = fields.Date('Release Date') pages = fields.Integer('Number of Pages') cost_price = fields.Float('Book Cost') category_id = fields.Many2one('library.book.category') author_ids = fields.Many2many('res.partner', string='Authors') |
添加library.book.category模型。为保持简化,我们仅将其添加到同一library_book.py文件中:
1 2 3 4 5 |
class BookCategory(models.Model): _name = 'library.book.category' name = fields.Char('Category') description = fields.Text('Description') |
我们将使用 library.book模型并获取每个分类的平均成本价。
译者注:请注意添加分类模型对应的视图文件,更重要的是添加相应的权限组配置(ir.model.access.csv)。
输出结果示例
1 |
2021-01-10 01:42:44,153 3562 INFO odoo-test odoo.addons.my_library.models.library_book: Grouped Data [{'category_id_count': 2, 'cost_price': 66.5, 'category_id': (1, <odoo.tools.func.lazy object at 0x7f9b38005e58>), '__domain': ['&', ('category_id', '=', 1), ('cost_price', '!=', False)]}, {'category_id_count': 1, 'cost_price': 79.2, 'category_id': (2, <odoo.tools.func.lazy object at 0x7f9b38005cf0>), '__domain': ['&', ('category_id', '=', 2), ('cost_price', '!=', False)]}] |
如何实现…
要提取分组结果,我们在library.book模型中添加_get_average_cost方法,它会使用read_group() 方法来获取分组中的数据:
1 2 3 4 5 6 7 8 |
@api.model def _get_average_cost(self): grouped_result = self.read_group( [('cost_price', "!=", False)], # Domain ['category_id', 'cost_price:avg'], # Fields to access ['category_id'] # group_by ) return grouped_result |
要测试这一实现,需要在用户界面中添加一个按钮来调用该方法。然后,可以在服务端日志中打印出结果。
运行原理…
read_group()方法的内部使用SQL的group by及aggregate函数来获取数据。传递给read_group() 方法的最常用参数如下:
- domain:用于为分组过滤记录。更多有关过滤域的知识,请参见第九章 后端视图中的定义搜索视图一节。
- fields:它传递希望获取的分组数据的字段名称。该参数的值可能如下:
- 字段名:可以向fields参数传递字段名,但如果使用这一选项,还应将该字段名同时传递给groupby参数,否则会产生报错
- field_name:agg:可以传递带有聚合函数的字段名。例如,在cost_price:avg中,avg是一个SQL聚合函数。PostgreSQL中的聚合函数请参见https://www.postgresql.org/docs/current/functions-aggregate.html。
- name:agg(field_name):它与前面一个相同,但使用这种语句,可以给数据列一个别名,例如average_price:avg(cost_price)。
- groupby:这个参数接收一个字段描述列表。记录将根据这些字段分组。对于date和datetime字段,可以传递groupby_function来根据不同的时长应用日期分组,如 date_release:month。这会按月来应用分组。
read_group()还支持一些可选参数,如下:
- offset:表示可以跳过的可选记录数量
- limit:表示可选的返回记录最大数量
- orderby:如果传递了该选项,结果会根据给定字段进行排序
- lazy:它接收布尔值,并且默认值为True。如果传递了True,结果仅通过第一个groupby进行分组,剩余的groupby会被放到__context键中。若为False,所有的groupby在一次调用中完成。
📝性能贴士:read_group()要比从记录集中读取和处理值快速的多。因此对KPI或图表应保持使用read_group()。