我们在第四章 应用模型中学习了如何在自定义模块中声明或扩展业务模型。其中还介绍了为计算字段编写方法以及限制字段值的方法。本章重点讲解Odoo服务端编程的基础知识,包括方法声明、记录集操作以及扩展继承方法。读者可以使用这些知识在Odoo模块中创建或修改业务逻辑。
本章主要讲解以下内容:
- 指定模型方法并实现API装饰器
- 向用户报告错误
- 为不同模型获取空记录集
- 创建新记录
- 更新记录集的值
- 搜索记录
- 合并记录集
- 过滤记录集
- 遍历记录集关系
- 排序记录集
- 扩展模型的既有业务逻辑
- 扩展write()和create()方法
- 自定义记录的搜索方式
- 使用read_group()按组获取数据
技术要求
学习本章要求已安装好Odoo。
可以从以下GitHub仓库获取本章所使用的所有代码:https://github.com/alanhou/odoo-cookbook/tree/main/Chapter05。
指定模型方法并使用API装饰器
在Odoo模型中,类包含业务逻辑方法和字段声明。我们在第四章 应用模型中学习了如何向模型添加字段。接下来我们将了解如何在模型中包含业务逻辑和方法。
本节中,我们将学习如何创建一个可用于应用程序用户界面按钮或其他代码部分的函数。该方法将对HostelRoom进行操作并采取必要步骤修改多个房间的状态。
准备工作
本节假定您已经准备好一个实例,并且按照第三章 创建Odoo插件模块中描述的步骤,创建了my_hostel插件模块。我们需要向HostelRoom模型添加一个状态字段,定义如下:
1 2 3 4 5 6 7 8 |
from odoo import api, fields, models class HostelRoom(models.Model): # [...] state = fields.Selection([ ('draft', 'Unavailable'), ('available', 'Available'), ('closed', 'Closed')], 'State', default="draft") |
更多相关信息,请参阅第三章 创建Odoo插件模块中的添加模型一节。
如何操作…
在模型定义中添加以下代码为公寓房间定义一个方法来改变房间的状态:
- 添加一个辅助方法来检查是否允许状态转换:
123456@api.modeldef is_allowed_transition(self, old_state, new_state):allowed = [('draft', 'available'),('available', 'closed'),('closed', 'draft')]return (old_state, new_state) in allowed - 添加一个方法来将房间的状态更改为参数指定的新状态:
1234567def change_state(self, new_state):for room in self:if room.is_allowed_transition(room.state,\new_state):room.state = new_stateelse:continue - 添加一个方法,通过调用
change_state
方法来改变房间状态:
1234def make_available(self):self.change_state('available')def make_closed(self):self.change_state('closed') - 在
<form>
视图中添加按钮和状态栏。这将帮助我们从用户界面触发这些方法:
1234567<form>...<button name="make_available" string="Make Available" type="object"/><button name="make_closed" string="Make Borrowed" type="object"/><field name="state" widget="statusbar"/>...</form>
更新或安装模块来获取这些更新。
实现原理…
以上代码中定义了几个方法。它们是典型的Python方法,第一个参数是 self
,并且可以接收其他参数。部分方法使用 odoo.api
模块的装饰器进行装饰。
小贴士:在Odoo 9.0中,首次添加了API装饰器,同时支持旧框架和新框架。自Odoo 10.0起,旧API不再提供支持,但某些装饰器(如
@api.model
)仍在使用。
编写新方法时,如果不使用装饰器,则该方法在记录集上执行。在这种方法中,self
是一个记录集,可引用任意数量的数据库记录(包括空记录集),代码通常会遍历self
中的记录,以对每条记录执行某些操作。
@api.model
装饰器类似,仅用于对模型重要的方法,这些方法不对记录集的内容进行操作。这个概念类似于Python的 @classmethod
装饰器。
在第1步中,我们创建了 is_allowed_transition()
方法。此方法验证从一个状态到另一个状态的转换是否有效。allowed
列表中的元组是可用的转换。例如,我们不允许从 closed
转换到 available
,所以没有包含 ('closed', 'available')
。
在第2步中,我们创建了change_state()
方法。此方法用于更改房间的状态。调用该方法时,它会将房间的状态更改为new_state
参数给定的状态。只有在允许转换时,才会更改房间状态。我们在这里使用了一个for
循环,因为self
可以包含多个记录集。
在第3步中,我们创建了通过调用change_state()
方法来改变房间状态的方法。在本例中,这些方法将由添加到用户界面的按钮触发。
在第4步中,我们在<form>
视图中添加了<button>
。点击此按钮时,Odoo Web客户端将调用name
属性中包含了Python函数。请参阅第九章 后端视图中的向表单添加按钮一节,了解如何从用户界面调用此类方法。我们还添加了使用statusbar
小部件的state
字段,在<form>
视图中显示房间的状态。
用户从界面点击按钮时,将调用第3步中的一个方法。此时,self
是包含hostel.room
模型记录的记录集。之后,我们调用change_state()
方法,并根据点击的按钮传递适当的参数。
调用change_state()
时,self
为相同的 hostel.room
模型的记录集。change_state()
方法的主体循环遍历self
以处理记录集中的每个房间。初看起来在self
上循环很奇怪,但很快就会习惯这种模式。
在循环内部,change_state()
调用is_allowed_transition()
。调用是使用局部变量room
进行的,但它可以在hostel.room
模型的任何记录集上进行调用,例如self
,因为is_allowed_transition()
使用了@api.model
装饰器。如果允许转换,change_state()
会通过为记录集的属性赋值来分配新状态。它只在长度为1的记录集上有效,而在遍历self
时是可以保证这一点的。
向用户报告错误
有时,由于用户的操作无效或达到了错误条件,可能需要在方法执行期间停止处理。本节通过显示详细的错误信息,演示了如何处理这类情况。
UserError 异常通常用于通知用户错误或异常情况。在用户输入未能满足预期标准或由于特定条件无法执行某个操作时,通常会使用该异常。
准备工作
本节要求按照之前的说明,设置一个安装了 my_hostel 插件模块的实例。
如何操作…
我们将对上一节中的 change_state 方法进行修改,并在用户尝试更改状态但 is_allowed_transition 方法不允许时显示一条提示信息。操作步骤如下:
- 在 Python 文件的开头添加以下导入:
12from odoo.exceptions import UserErrorfrom odoo.tools.translate import _ - 修改 change_state 方法,并在 else 分支抛出 UserError 异常:
12345678def change_state(self, new_state):for room in self:if room.is_allowed_transition(room.state, new_state):room.state = new_stateelse:msg = _('Moving from %s to %s is notallowed') % (room.state, new_state)raise UserError(msg)
工作原理…
在 Python 中抛出异常时,它会沿着调用栈向上传播,直到被处理。在 Odoo 中,响应由 Web 客户端发起的调用的远程过程调用(RPC)层会捕获所有异常,并根据异常类在 Web 客户端上触发不同的行为。
任何未在 odoo.exceptions 中定义的异常都将被处理为内部服务器错误(HTTP 状态 500),并带有堆栈追踪。UserError 会在用户界面中显示一条错误信息。本节中的代码抛出 UserError,以确保按用户友好的方式显示消息。在这种情况下,当前的数据库事务都会回滚。
我们使用了一个奇怪的函数,_(),它在 odoo.tools.translate 中定义。该函数用于将字符串标记为可翻译字符串,并在运行时根据执行上下文中找到的终端用户的语言检索翻译后的字符串。相关更多信息,请参阅第十一章 国际化。
重要提示
使用 _() 函数时,请确保只传递带有插值占位符的字符串,而不是整个插值字符串。例如,
_('Warning: could not find %s') % value
是正确的,但_('Warning: could not find %s' % value)
是错误的,因其无法在翻译数据库中找到替换值的字符串。
更多…
有时,我们正在处理易出错的代码,这意味着正在执行的操作可能会产生错误。Odoo 会捕获此错误并向用户显示追踪信息。如果不想向用户显示完整的错误日志,可以捕获错误并抛出一个含有意义消息的自定义异常。在提供的示例中,我们在 try…catch 块中生成 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_hostel 插件模块中的宾馆馆示例设置。
我们将在 hostel.room 模型中编写一个小方法,并搜索所有 hostel.room.members。为此,我们需要获取 hostel.room.members 的空记录集。确保已添加 hostel.room.members 模型及其访问权限。
操作步骤
要在 hostel.room 的方法中为 hostel.room.members 获取记录集,需要执行以下步骤:
图5.1:log_all_room_members
- 在HostelRoom类中,编写一个名为log_all_room_members的方法:
12345678class HostelRoom(models.Model):# ...def log_all_room_members(self):# This is an empty recordset of model hostel.room.memberhostel_room_obj = self.env['hostel.room.member']all_members = hostel_room_obj.search([])print("ALL MEMBERS:", all_members)return True - 在
<form>
视图中添加按钮来调用该方法:
1<button name="log_all_room_members" string="Log Members" type="object"/>
更新模块应用更改。之后,您将在房间的 <form>
视图中看到Log Members按钮。可通过点击该按钮在服务器日志中查看成员的记录集。
工作原理
启动时,Odoo 会加载所有模块,并组合继承自 Model 的各种类,同时定义或扩展给定的模型。这些类存储在 Odoo 注册表中,并按名称索引。任一记录集的 env 属性(通过 self.env 访问)是 odoo.api 模块中定义的 Environment 类的一个实例。
Environment 类在 Odoo 开发中起着核心作用:
- 它通过模拟 Python 字典提供对注册表的快捷访问。如果知道要查找的模型的名称,self.env[model_name]可获取该模型的空记录集。此外,记录集将与 self 共享环境。
- 它有一个 cr 属性,这是数据库的游标,可以使用它传递原始 SQL 查询。有关这方面的更多信息,请参阅第八章 高级服务器端开发技术中的执行原生 SQL 查询一节。
- 它有一个 user 属性,这是对执行调用的当前用户的引用。有关此内容的更多信息,请参阅第八章 高级服务器端开发技术中的更改执行操作的用户一节。
- 它有一个 context 属性,这是一个包含调用上下文的字典。包括用户的语言、时区和当前选定的记录等信息。有关此内容的更多信息,请参阅第 8 章《高级服务器端开发技术》中的使用修改后的上下文调用方法一节。
search() 的调用将在后续的搜索记录小节中讲解。
其它
有时,我们可能需要使用修改后的环境。例如,可能希望使用具有不同用户和语言的环境。在第八章 高级服务器端开发技术中,您将学习如何在运行时修改环境。
创建新记录
在编写业务逻辑时,创建新记录是很常见的需求。本节将介绍如何为 hostel.room.category 模型构建记录。我们将向 hostel.room.category 模型添加一个函数,以便为我们的示例生成虚拟类别。我们会添加 <form>
视图来使用这一方法。
准备工作
需要了解所创建记录的模型的结构,尤其是它们的名称和类型,以及这些字段上存在的约束(例如,其中一些是否是必填项)。
在本节中,我们将复用第四章 应用模型中的 my_hostel 模块。请通过以下示例快速回顾 hostel.room.category 模型:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
class RoomCategory(models.Model): _name = 'hostel.room.category' _description = 'Hostel Room Category' name = fields.Char('Category') description = fields.Text('Description') parent_id = fields.Many2one( 'hostel.room.category', string='Parent Category', ondelete='restrict', index=True ) child_ids = fields.One2many( 'hostel.room.category', 'parent_id', string='Child Categories') |
确保已为 hostel.room.category 模型添加了菜单、视图和访问权限。
操作步骤
要创建一个包含子类别的类别,需要执行以下步骤:
图 5.2:创建类别
- 在 hostel.room.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['hostel.room.category'].create(parent_category_val) - 在
<form>
视图中添加按钮在用户界面中调用 create_categories 方法:
1<button name="create_categories" string="Create Categories" type="object"/>
工作原理
要为模型添加新记录,我们可以在与该模型相关的任意记录集上调用 create(values) 方法。此方法返回一个长度为 1 的新记录集,并包含新记录,记录中的字段值由 values 字典指定。
字典中的键通过名称识别字段,而相应的值反映了字段的值。根据字段类型,需要为这些值传递不同的 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:关联字段写入
在本节中,我们为要创建的公寓房间中的两个类别创建字典,然后在创建公寓房间类别的字典的 child_ids 中使用这些字典,使用我们之前讲解过的 (0, 0, dict_val)
语法。
当在第 5 步中调用 create() 时,将创建三条记录:
- 一个是父类别记录,由 create 返回
- 两个是子类别记录,可通过 record.child_ids 访问
更多信息
如果模型为某些字段定义了默认值,则不需要做特别的处理。create() 将负责计算字典中未包含的字段的默认值。
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['hostel.room.category'].create([categ1, categ2]) |
这段代码将返回创建的公寓房间类别的记录集。
更新记录集的值
业务逻辑通常需要我们通过更改某些字段的值来更新记录。本节将展示如何修改 partner 的 room_no 字段。
准备工作
本节将使用与创建新记录一节教程相同的简化 hostel.room 定义。可以参考这个简化定义了解字段的内容。
在 hostel.room 模型中,我们有 room_no 字段。为进行演示,我们将通过点击按钮来写入这个字段。
操作步骤
- 要更新房间的 room_no 字段,可以编写一个名为 update_room_no() 的新方法,定义如下:
123def update_room_no(self):self.ensure_one()self.room_no = "RM002" - 然后,可以在
<form>
视图的xml添加按钮,如下所示:
1<button name="update_room_no" string="Update Room No" type="object"/> - 重启服务并更新 my_hostel 模块以查看更改。点击Update Room No按钮后,room_no 将会被更改。
工作原理
该方法首先通过调用 ensure_one() 检查以self传入的房间记录集是否刚好包含一条记录。如若不是,程序将生成一个异常,并停止处理。这很必要,因为我们不想更改多条记录的房间号。如果要更新多个值,可以删除 ensure_one() 并通过遍历记录集来更新属性。
最后,该方法修改房间记录的属性值。它使用定义的房间号更新 room_no 字段。仅通过修改记录集的字段属性,即可执行写操作。
更多信息
如果要向记录的字段添加新值,有三种方式:
- 第一种是本节中讲解的方法。通过直接将值赋给表示记录字段的属性来在所有上下文中操作。它无法一次性为所有记录集元素赋值,因此,除非确定只处理单个记录,否则需要遍历记录集。
- 第二种是使用 update() 方法,通过传递将字段名称映射到所要设置的值的字典来实现。这也仅适用于长度为 1 的记录集。当需要同时更新同一记录的多个字段的值时,可以节省一些输入。以下是使用些方式重写第二步的代码:
1234567def change_room_no(self):self.ensure_one()self.update({'room_no': "RM002",'another_field': 'value'...}) - 第三种是调用 write() 方法,并传递一个字典,该字典将字段名称映射到所希望设置的值。此方法适用于任意大小的记录集,并在一次数据库操作中更新所有记录,而前两种会为每个记录和每个字段执行一次数据库调用。不过,它也有一些限制:如果记录尚未存在于数据库中,将无法使用(更多信息,请参阅第八章 高级服务器端开发技术中的在更改方法上写入一节)。此外,在写入关系字段时,需要一种特殊的格式,类似于 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:关系字段更新
重要提示:操作类型1、2、3和5不能用于 create() 方法。
搜索记录
在业务逻辑方法中,搜索记录也是一个常见操作。在许多情况下,我们需要根据不同的条件搜索数据。本节将演示如何根据名称和类别查找房间。
准备工作
本教程将使用与创建新记录一节中相同的 hostel.room 定义。我们将在一个名为 find_room(self) 的方法中编写代码。
操作步骤…
要查找房间,需要执行以下步骤:
- 将 find_room 方法添加到 hostel.room 模型中:
12def find_room(self):... - 为我们的条件编写搜索作用域:
1234567domain = ['|','&', ('name', 'ilike', 'Room Name'),('category_id.name', 'ilike', 'Category Name'),'&', ('name', 'ilike', 'Second Room Name 2'),('category_id.name', 'ilike', 'SecondCategory Name 2')] - 使用该域调用 search() 方法,该方法将返回记录集:
1rooms = self.search(domain)
rooms 变量将包含已搜索到的房间记录集。可以打印或记录该变量,以便在服务器日志中查看结果。
操作原理…
步骤1以 def 关键字定义了的方法名称。
步骤2在一个局部变量中创建了一个搜索域。通常,会看到这一创建直接内联在 search 的调用中,但对于复杂的作用域,最好单独定义。
要了解搜索域语法的详细说明,请参阅第九章 后端视图中的在记录列表上定义过滤器–作用域一节。
步骤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', 'SerpentCS'), ('company_id.name', '=', 'SCS') ] partner = PartnerObj.search(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 < R2 | 如果 R1为 R2的子集中返回 True。两种语法异曲同工。 |
R1 >= R2 R1 > R2 | 如果 R1的是R2的超集返回 True。两种语法异曲同工。 |
R1 != R2 | 如果R1和 R2不包含相同记录返回 True。 |
R1 in R2 | 如果R1(必须为一条记录)属于R2则返回True。 |
R1 not in R2 | 如果R1(必须为一条记录)不属于R2则返回True。 |
表5.3:域所使用的操作符
还有一些复合运算符 +=
、-=
、&=
和 |=
,它们会修改左运算项,而不会创建一个新的记录集。在更新记录的 One2many 或 Many2many 字段时,这些操作符非常有用。请参阅更新记录集的值一节,了解相关示例。
过滤记录集
有时,我们已经有了一个记录集,只需要处理其中的一部分记录。当然,可以遍历记录集,每次检查条件并根据检查的结果采取相应的操作。不过,构建一个仅包含所需记录的新记录集并对其执行一次操作可能更简单,并且在某些情况下更高效。
本节将展示如何使用 filter()
方法根据条件提取记录集的子集。
准备工作
我们将复用在创建新记录一节中展示的简化版 hostel.room 模型。本节定义了一个方法,用于从提供的记录集中提取有多个成员的房间。
如何操作…
要从记录集中提取有多个成员的记录,需执行以下步骤:
- 定义过滤记录集的方法:
123def filter_members(room):all_rooms = self.search([])filtered_rooms = self.rooms_with_multiple_members(all_rooms)
- 定义接收原始记录集的方法:
12@api.modeldef room_with_multiple_members(self, all_rooms): - 定义一个内嵌predicate函数:
1234def predicate(room):if len(room.member_ids) > 1:return Truereturn False - 调用
filter()
,如下所示:
1return all_room.filter(predicate)
这个过程的结果可以打印或记录,以写入到服务器日志中。相关更多信息,请参阅本节的示例代码。
工作原理…
通过filter()
方法初始创建的记录集是空的。这个空记录集接收谓词函数返回为 True 的所有记录。最后,将新创建的记录集返回。原始记录集中的记录仍按相同顺序保留。
在本节中使用了一个内部具名函数。经常会看到使用匿名 Lambda 函数来处理这种简单的谓词:
1 2 3 |
@api.model def room_with_multiple_rooms(self, all_rooms): return all_rooms.filter(lambda b: len(b.member_ids) > 1) |
实际上,需要根据字段值在 Python 逻辑中为真(非空字符串、非零数字、非空容器等)来过滤记录集。因此,如果想过滤那些设置了类别的记录,可以这样将字段名称传递给过滤器:all_rooms.filter(‘category_id’)。
更多…
请记住,filter()
需要使用内存。在关键路径上的方法中,为提升速度,可以使用搜索作用域,甚至切换到 SQL,这样做的代价是降低了可读性。
遍历记录集关系
处理长度为1的记录集时,可通过记录属性使用各个字段。也可以使用关系属性(One2many、Many2one和Many2many),并且其值也是记录集。举个例子,假设我们想在hostel.room模型的记录集中访问类别的名称。可以通过Many2one字段的category_id字段来访问类别名称,如:room.category_id.name
。但在处理包含多个记录的记录集时,不能直接使用属性。
本节演示如何使用mapped()
函数遍历记录集的关系。我们将创建一个函数,从提供的房间列表中提取成员的姓名。
准备工作
我们将复用在本章创建新记录一节中展示的 hostel.room 模型。
操作步骤
需要执行以下操作从房间记录集中获取成员的姓名:
- 定义一个名为
get_members_names()
的方法:
12@api.modeldef get_members_names(self, rooms): - 调用
mapped()
函数获取成员的姓名:
1return rooms.mapped('member_ids.name')
工作原理
我们在步骤1中定义了该方法。在步骤2中,通过调用mapped(path)
函数遍历记录集的字段;其中path
是一个由点分隔的字段名称组成的字符串。对于路径中的每个字段,mapped()
会为当前记录集中的每个元素创建一个新的记录集,并将路径中的下一个元素应用于该新记录集。如果路径中的最后一个字段是关系字段,则mapped()
返回一个记录集;否则,返回一个Python列表。
mapped()
方法有两个有用的属性:
- 当路径是一个单一的标量字段名时,返回的列表与处理的记录集按相同的时间顺序排列。
- 如果路径中存在关系字段,不会被保留结果的顺序,但会删除重复项。
重要
第二个属性非常有用,在希望对
self
中的所有记录所指向的Many2many字段中的所有记录执行操作时,它可以确保操作仅执行一次(即使self
中的两个记录共享同一个目标记录)。
更多信息
使用mapped()
时,请留意它在Odoo服务器存中操作,通过不断遍历关联并执行SQL查询,效率可能不高。但代码简洁且富有表现力。如果尝试优化实例性能的关键路径上的方法,可能需要重写mapped()
调用并将其表达为带有适当域的search()
,或者甚至转移到SQL(以可读性为代价)。
mapped()
方法也可以接收一个函数作为参数。此时,它返回一个列表,该列表包含对self
的每个记录应用函数后的结果,或者若函数返回一个记录集,则返回记录集的并集。
其它
相关更多信息,请参考以下内容:
- 本章中的搜索记录一节
- 第八章 高级服务器端开发技术中的执行原生 SQL 查询一节
排序记录集
在使用search()
方法获取记录集时,可以传递一个可选参数order
,以获得按特定顺序排列的记录集。如果已从先前代码中获取了一个记录集,并希望对其进行排序,这会非常有用。但如果使用集合操作将两个记录集组合在一起,可能会导致顺序丢失,这时也可能需要排序。
本节将向你展示如何使用sorted()
方法对现有记录集进行排序。我们将按评分对房间进行排序。
准备工作
我们将复用本章创建新记录一节中展示的hostel.room
模型。
操作步骤
需要执行以下步骤来根据评分获取已排序的房间记录集:
- 定义一个名为
sort_rooms_by_rating()
的方法:
12@api.modeldef sort_rooms_by_rating(self, rooms): - 使用
sorted()
方法,如下例所示,按room_rating
字段对房间记录进行排序:
1return rooms.sorted(key='room_rating')
工作原理
步骤1中定义了方法。在步骤2中,我们使用房间记录集的sorted()
函数。以key
参数提供的字段将由sorted()
函数内部获取数据,然后使用Python原生的sorted
方法返回一个已排序的记录集。
此外,sorted
还有一个可选参数reverse=True
,可以按相反的顺序返回记录集。使用方式如下:
1 |
rooms.sorted(key='room_rating', reverse=True) |
更多信息
sorted()
方法将对记录集中的记录进行排序。如不传递任何参数,将使用模型的_order
属性。否则,可以传递一个函数来计算比较键,就像Python内置的sorted(sequence, key)
函数一样。
重要
使用模型的默认
_order
参数时,排序会委托给数据库,并执行一个新的SELECT
操作获取顺序。否则,排序将由Odoo执行。根据所操作的数据和记录集的大小,可能会有重要的性能差异。
扩展模型的既有业务逻辑
将应用程序功能划分为不同模块是Odoo中常见的实践。通过安装或卸载应用程序,可以轻松启用或禁用功能。此外,为现有应用程序添加新功能时,需要修改某些预定义方法的行为。有时,向旧模型添加新字段会带来益处。这是Odoo底层框架的一个非常有用的功能,并且过程相当简单。
本节中,我们将学习如何通过一个模块的方法扩展另一个模块中的业务逻辑。此外,我们还将使用新模块向现有模块添加新字段。
准备工作
本节中,我们将继续使用上一节中的my_hostel
模块。确保my_hostel
模块中有hostel.room.category
模型。
本节中我们将新建一个模块my_hostel_terminate
,它依赖于my_hostel
模块。在这个模块中,我们将管理公寓的结束日期,并根据类别自动计算退房日期。
在第四章 应用模型的使用继承向模型添加功能一节中,我们已经展示了如何向现有模型添加字段。在这一模块中,我们将按如下方式扩展hostel.room
模型:
1 2 3 |
class HostelRoom(models.Model): _inherit = 'hostel.room' date_terminate = fields.Date('Date of Termination') |
然后再扩展hostel.room.category
模型,如下所示:
1 2 3 4 5 6 |
class RoomCategory(models.Model): _inherit = 'hostel.room.category' max_allow_days = fields.Integer( 'Maximum allows days', help="For how many days room can be borrowed", default=365) |
要在视图中添加此字段,你需要参考第九章 后端视图中的修改已有视图 – 视图继承一节。你可以在这里找到完整的代码示例。
操作步骤
执行以下步骤扩展hostel.room
模型中的业务逻辑,:
- 在
my_hostel_terminate
中,我们希望在将房间状态更改为关闭时设置房间记录中的date_terminate
。为此,我们将重写my_hostel_terminate
模块中的make_closed
方法:
1234def make_closed(self):day_to_allocate = self.category_id.max_allow_days or 10self.date_return = fields.Date.today() + timedelta(days=day_to_allocate)return super(HostelRoom, self).make_closed() - 我们还希望在房间退还并可供租用时重置
date_terminate
,因此我们将重写make_available
方法来重置日期:
123def make_available(self):self.date_terminate = Falsereturn super(HostelRoom, self).make_available()
工作原理
在上面步骤1和2中,我们扩展了业务逻辑。我们定义了一个扩展hostel.room
的模型,并重新定义了make_closed()
和make_available()
方法。在这两个方法的最后一行中,返回了父类实现的结果:
1 |
return super(HostelRoom, self).make_closed() |
Odoo模型的父类和Python类定义中的并不一样。框架为我们的记录集动态生成了一个类等级结构,父类是我们所依赖模块中的模型定义。因此,调用super()
会返回my_hostel
中hostel.room
的实现。在这个实现中,make_closed()
方法将房间状态更改为关闭。因此,调用super()
将调用父方法并将房间状态设置为关闭。
更多信息
本节中,我们选择扩展方法的默认实现。在make_closed()
和make_available()
方法中,我们在调用super()
之前修改了返回的结果。请注意,调用super()
时将执行默认实现。还可以在super()
调用后执行一些操作。当然,也可以同时在前后进行操作。
但在中途改变方法行为更加具有挑战性。要做到这一点,我们必须重构代码,以便将扩展点提取到另一个函数中,然后我们可以在扩展模块中覆盖该函数。
读者可能会有从头重写函数的冲动。务必谨慎行事。如不使用super()
实现你的方法,那么扩展机制和可能扩展该方法的附加组件将会失效,这意味着扩展方法永远不会被调用。除非你在受控环境中工作,并且确定哪些附加组件已安装,并且已经验证了你不会破坏它们,否则应避免这样做。此外,如有必要,请确保清晰地记录所做的一切修改。
在调用方法的原始实现之前和之后,可以做很多事情,包括(但不限于)以下内容:
- 修改发送到初始实现的参数(前)
- 修改之前提供给原始实现的上下文
- 修改原始实现返回的结果(后)
- 调用另一个方法(前和后)
- 创建记录(前和后)
- 在禁止的情况下抛出
UserError
错误以取消执行(前和后) - 将
self
拆分为较小的记录集,并以不同方式在每个子集中调用原始实现(前)
扩展write()和create()方法
对本章中定义的业务逻辑进行扩展,展示了如何扩展模型类上定义的方法。细想一下,模型父类中定义的方法也是模型的一部分。这意味着所有在 models.Model
(实际上是其父类 models.BaseModel
)上定义的基本方法也都可以使用用,并且可以进行扩展。
本节将展示如何扩展 create()
和 write()
方法,来控制对某些记录字段的访问。
准备工作
我们将扩展第三章 创建Odoo插件模块中的 my_hostel
插件模块中的公寓示例。
在 hostel.room
模型中添加一个备注字段。我们只希望公寓管理员组的成员能够写入该字段:
1 2 3 4 |
from odoo import models, api, exceptions class HostelRoom(models.Model): _name = 'hostel.room' remarks = fields.Text('Remarks') |
在 view/hostel_room.xml
文件的 <form>
视图中添加remarks字段,以便可以在用户界面访问该字段:
1 |
<field name="remarks"/> |
修改 security/ir.model.access.csv
文件,给予公寓用户写入权限:
1 2 |
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_hostel,hostel.room.user,model_hostel_room,base.group_user,1,1,0,0 |
实现步骤
为防止非管理员组的用户修改 remarks
字段的值,需要执行以下步骤:
- 扩展
create()
方法:
123456789@api.modeldef create(self, values):if not self.user_has_groups('my_hostel.group_hostel_manager'):if values.get('remarks'):raise UserError('You are not allowed to modify ''remarks')return super(HostelRoom, self).create(values) - 扩展
write()
方法如下:
12345678def write(self, values):if not self.user_has_groups('my_hostel.group_hostel_manager'):if values.get('remarks'):raise UserError('You are not allowed to modify ''manager_remarks')return super(HostelRoom, self).write(values)
安装模块后,只有管理员类型的用户可以修改 remarks
字段。要测试此实现,可以以demo用户登录,或撤销当前用户的管理员权限。
工作原理
在上述步骤中,首先重写了 create()
方法。在调用 create()
方法的基类实现之前,我们使用 user_has_groups()
方法检查用户是否属于 my_hostel.group_hostel_manager
组(这是组的 XML ID)。如果不是且传递了 remarks
的值,则会抛出 UserError
异常,阻止记录的创建。此检查在调用基类实现之前执行。
第二步对 write()
方法执行相同的操作。在写入之前,我们检查用户组以及 values
中是否存在该字段,以便可以进行写入,并在出现问题时抛出 UserError
异常。
重要提示: 即使在 Web 客户端中将字段设置为只读,也不能防止通过 RPC 调用的写入。这就是我们扩展
create()
和write()
的原因。
本节中,我们已了解了如何重写 create()
和 write()
方法。但请注意,并不仅限于 create()
和 write()
方法,还可以重写其它模型方法。例如,假设你想在记录被删除时执行某些操作。这时需要重写 unlink()
方法(记录被删除时会调用 unlink()
方法)。以下是一个重写 unlink()
方法的小代码片段:
1 2 3 |
def unlink(self): # your logic return super(HostelRoom, self).unlink() |
警告:
在 Odoo 中重写方法时,千万别忘记调用
super()
方法,否则会遇到问题。因为如果不使用super()
方法,原方法中的代码将永远不会执行到。在我们之前的代码片段中,如果没有调用super(…).unlink()
,就不会删除记录。
更多
在扩展 write()
方法时,注意在调用 super()
的 write()
实现之前,尚未修改self
。可以利用这一点,比较字段的当前值与 values
字典中的值。
本节中,我们选择了抛出异常,但我们也可以选择从 values
字典中删除违规字段,并在记录中静默跳过对该字段的更新:
1 2 3 4 5 |
def write(self, values): if not self.user_has_groups('my_hostel.group_hostel_manager'): if values.get('remarks'): del values['remarks'] return super(HostelRoom, 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()
方法,它将不会进入无限循环。
自定义记录的搜索方式
在第四章 应用模型中的定义模型表示和顺序一节中,介绍了 name_get()
方法,该方法用于在各种地方计算记录的表示,包括在 Web 客户端中显示 Many2one 关联的小部件中。
本节将展示如何通过重新定义 name_search
,在 Many2one 小部件中按房间号和名称搜索房间。
准备工作
本节中,我们将使用以下模型定义:
1 2 3 4 5 6 7 8 |
class HostelRoom(models.Model): def name_get(self): result = [] for room in self: member = room.member_ids.mapped('name') name = '%s (%s)' % (room.name, ', '.join(member)) result.append((room.id, name)) return result |
使用此模型时,Many2one 小部件中的房间将显示为房间标题(成员1,成员2,…)。用户希望可以通过输入成员的姓名来筛选列表,但由于 name_search
的默认实现仅使用模型类的 _rec_name
属性(本例中是 name),因此它将不起作用。我们还希望通过房间号进行筛选
如何操作…
我们需要执行以下步骤来完成本节:
- 要能够通过房间名称、成员或房间号搜索
hostel.room
,我们需要在HostelRoom
类中定义_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(HostelRoom, self)._name_search(name=name, args=args, operator=operator,limit=limit, name_get_uid=name_get_uid) - 在
hostel.room
模型中添加previous_room_id
Many2one 字段来测试_name_search
的实现:
1previous_room = fields.Many2one('hostel.room', string='Previous Room') - 在用户界面中添加以下字段:
1<field name="previous_room_id" /> - 重新启动并更新模块让更改生效。
可通过搜索previous_room_id
Many2one 字段来调用_name_search
方法。
工作原理…
name_search()
的默认实现实际上只是调用 _name_search()
方法,后者执行实际操作。这个 _name_search()
方法有一个额外的参数 name_get_uid
,它在某些特殊情况下使用,例如在想使用 sudo()
或不同用户计算结果时。
我们将大多数接收到的参数原封传递给 super()
的实现:
name
是一个包含用户输入值的字符串。args
是一个用于预筛选可能记录的搜索域(它可能来自 Many2one 关系的domain
参数)。operator
是一个字符串,表示匹配运算符。通常为 ilike 或 =。limit
是要检索的最大行数。name_get_uid
可用于在调用name_get()
计算要在小部件中显示的字符串时指定不同的用户。
我们的方法实现执行以下操作:
- 如果
args
为None
,则生成一个新的空列表,否则复制args
。我们复制列表是为了避免对其的修改对调用者产生副作用。 - 然后我们检查
name
是否为空字符串,或者运算符是否为 ilike。这样可以避免生成无效的域,如[('name', ilike, '')]
,它不会过滤任何内容。这时,我们直接跳转到super()
调用实现。 - 如果我们有
name
,或者operator
不是 ilike,那么我们会向args
添加一些筛选条件。在本例中,我们添加了根据房间标题、房间号或成员名称搜索的子句。 - 最后,我们调用
super()
实现并使用修改后的args
,同时强制name
为空字符串,operator
为 ilike。这样可以确保_name_search()
的默认实现不会更改接收到的域,从而使用我们所指定的域。
更多…
我们在介绍中提到,此方法用于 Many2one 小部件。为完整起见,它还用于 Odoo 的以下部分:
- 在
domain
中使用 One2many 和 Many2many 字段的in
运算符时 - 在
many2many_tags
小部件中搜索记录时 - 在 CSV 文件导入中搜索记录时
另请参阅
第四章 应用模型中的定义模型表示和顺序一节演示了如何定义 name_get()
方法,用于创建记录的文本表示。
第九章 后端视图中的在记录列表上定义过滤器 – 域一节提供了有关搜索域语法的更多信息。
使用read_group()按组获取数据
在前面的小节中,我们学习了如何从数据库中搜索和获取数据。但有时我们需要通过聚合记录来获取结果,比如上个月销售订单的平均成本。通常,我们在 SQL 查询中使用 group by
和聚合函数来获取这种结果。所幸在 Odoo 中提供了 read_group()
方法。本节将讲解如何使用 read_group()
方法来获得聚合结果。
准备工作
本节中,我们将使用第三章 创建Odoo插件模块中的 my_hostel
插件模块。
修改 hostel.room
模型,如下所示:
1 2 3 4 5 |
class HostelRoom(models.Model): _name = 'hostel.room' name = fields.Char('Name', required=True) cost_price = fields.Float('Room Cost') category_id = fields.Many2one('hostel.room.category') |
添加 hostel.room.category
模型。为简化起见,我们将其添加到同一个 hostel_room.py
文件中:
1 2 3 4 |
class HostelCategory(models.Model): _name = 'hostel.room.category' name = fields.Char('Category') description = fields.Text('Description') |
我们将使用 hostel.room
模型,并获取每个类别的平均成本价格。
如何操作…
为提取分组结果,我们将在 hostel.room
模型中添加 _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 的 groupby
和聚合函数来获取数据。传递给 read_group()
方法的最常见参数如下:
- domain: 用于筛选要分组的记录。有关域的更多信息,请参阅第九章 后端视图中的搜索视图一节。
- fields: 用于传递要与分组数据一起获取的字段名称。此参数的可为:
- 字段名: 可将字段名传递给
fields
参数,但如使用此选项,则必须将该字段名传递给groupby
参数,否则会生成错误。 field_name:agg
: 可以将字段名与聚合函数一起传递。例如在cost_price:avg
中,avg
是一个 SQL 聚合函数。可以在 PostgreSQL 文档中找到 PostgreSQL 聚合函数的列表。name:agg(field_name)
: 与前一个相同,但使用此语法可以提供列别名,例如average_price:avg(cost_price)
。
- 字段名: 可将字段名传递给
groupby
: 此参数接受字段描述的列表。记录将根据这些字段进行分组。对于date
和datetime
列,可以传递groupby_function
来基于不同的时长应用日期分组。可以基于月份对date
类型字段进行分组。read_group()
还支持一些可选参数,如下所示:- offset: 指定要跳过的记录数。
- limit: 指定返回的记录数上限。
- orderby: 如果传递此选项,结果将根据给定字段进行排序。
- lazy: 接受布尔值,默认值为
True
。如果传递True
,结果只按第一个groupby
进行分组,剩下的groupby
参数将放入__context
键中。如果为False
,则所有groupby
函数将在一次调用中完成。
性能提示:
read_group()
比从记录集中读取和处理值要快得多。因此,对于关键绩效指标(KPI)或图表,建议始终使用read_group()
。