在前面的文章中,我们学习了如何使用模型层构建应用数据结构,然后使用ORM API 和记录集查看和操作这些数据。
本章中,我们把前面所学串到一起实现一些应用中常见的逻辑模式。我们会学习一起触发业务逻辑的方式,以及支撑这些方式的一些常见模式。我们还将学习一些重要的开发技巧,如打日志、调试和测试。
本章的主要内容有
- 学习项目-图书借阅模块
- 触发业务逻辑的方式
- 理解记录集的ORM方法装饰器
- 探讨一些数据模型模式
- 使用ORM内置方法
- 添加onchange用户界面逻辑
- 消息和活动功能
- 创建向导
- 抛出异常
- 编写单元测试
- 使用日志消息
- 学习一些开发工具
学完本章后,读者可以自如地设计、实现业务逻辑自动化以及了解如何测试、调试代码。
开发准备
本章中我们会创建一个library_checkout插件模块。它依赖于前面章节中所创建的library_app和library_member插件模块。
这些插件模块的代码请参见GitHub 仓库的ch08目录。
这些插件模块要放到Odoo的插件路径中,这样才能安装使用。
学习项目–图书借阅模块
图书应用的主数据结构已就绪。现在需要对系统添加交易了。让图书会员可借阅书籍。也即我们要跟踪图书是否可借阅以及是否归还。
每本书的借阅都有一个生命周期,从图书登记到图书被归还。这可通过看板视图表示为简单工作流,看板视图中每个阶段(stage)可展现为一列,工作项和借阅请求流从左侧列到右侧列,直至完成为止。
在本章中,我们集中学习实现这一功能的数据模型和业务逻辑。
用户界面部分的详情将在第十章 Odoo 15开发之后台视图 – 设计用户界面中讨论,看板视图在第十一章 Odoo 15开发之看板视图和用户端 QWeb中讨论。我们来快速过一遍数据模型吧。
准备数据模型
首先我们就规则图书借阅功能所需的数据模型。
图书借阅模型应包含如下字段:
- 借阅图书的会员(必填)
- 借阅请求日期(默认为当天)
- 负责借阅的用户(默认为当前用户)
- 借阅路线,包含请求借阅的一本或多本图书
要支撑借阅生命周期,需要添加如下内容:
- 请求的阶段:已选中、可借阅、已借出、已归还或已取消
- 待归还日期,图书应当归还的日期
- 归还,图书归还的日期
我们先新建library_checkout模块并实现图书借阅模型的初始版本。与此前章节相比此处并没有引入新的知识,用于提供一个基础供本章后续构建新功能。
创建模块
和前面章节一样,需要创建library_checkout模块。按照如下的步骤:
- 在其它图书插件模块的同级路径下创建一个library_checkout目录。后续的文件都在这个目录中添加。
- 在
__manifest__.py
文件中加入如下内容:
1234567891011{'name': 'Library Book Borrowing','description': 'Members can borrow books from the library.','author': 'Alan Hou','depends': ['library_member'],'data':['security/ir.model.access.csv','views/library_menu.xml','views/checkout_view.xml',],} - 在模块目录下创建
__init__.py
文件,并添加如下代码:
1from . import models - 创建
models/__init__.py
文件并添加:
1from . import library_checkout - 添加模型定义文件
models/library_checkout.py
并加入如下代码:
12345678910111213141516from odoo import fields, modelsclass Checkout(models.Model):_name = "library.checkout"_description = "Checkout Request"member_id = fields.Many2one("library.member",required=True,)user_id = fields.Many2one("res.users","Librarian",default=lambda s: s.env.user,)request_date = fields.Date(default=lambda s: fields.Date.today(),)
下面就要添加数据文件了,添加访问规则、菜单项和一些基础视图,这样模块就可以使用了。
- 在security/ir.model.access.csv文件中添加访问权限配置:
12id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlinkcheckout_user,Checkout User,model_library_checkout,library_app.library_group_user,1,1,1,1 - 接下来添加views/library_menu.xml实现菜单项:
1234567891011<odoo><record id="action_library_checkout" model="ir.actions.act_window"><field name="name">Checkouts</field><field name="res_model">library.checkout</field><field name="view_mode">tree,form</field></record><menuitem id="menu_library_checkout"name="Checkout"action="action_library_checkout"parent="library_app.menu_library" /></odoo> - 在views/checkout_view.xml文件中实现的视图:
123456789101112131415161718192021222324252627<odoo><record id="view_tree_checkout" model="ir.ui.view"><field name="name">Checkout Tree</field><field name="model">library.checkout</field><field name="arch" type="xml"><tree><field name="request_date" /><field name="member_id" /></tree></field></record><record id="view_form_checkout" model="ir.ui.view"><field name="name">Checkout Form</field><field name="model">library.checkout</field><field name="arch" type="xml"><form><sheet><group><field name="member_id" /><field name="request_date" /><field name="user_id" /></group></sheet></form></field></record></odoo>
既然模块已经包含以上这些文件了,就可以在开发数据库中进行安装了:
触发业务逻辑的方式
准备好数据模型后,就需要业务逻辑来执行一些自动化操作。业务逻辑可直接由用户发起,比如点击按钮,或根据事件自动触发,如在记录中写入。
这类业务逻辑大多涉及读取和写入记录节。详细的技巧在第七章 Odoo 15开发之记录集 – 使用模型数据中进行了讨论,在其中我们提供了实现实际业务逻辑的工具。
下一个问题是业务逻辑应怎样触发。这取决于何时以及为什么触发业务逻辑。下面进行了一部分总结。
部分业务逻辑与模型字段定义紧密关联。一些模型定义相关的业务逻辑实例如下:
- 数据验证规则:强制数据满足某些条件。这类方法使用@api.constrains装饰。
- 自动计算:以字段(虚拟或存储字段)进行实现,其值由方法计算。这类方法使用@api.depends装饰再赋值给compute字段属性。
- 默认值:可动态计算,这类方法使用@api.model装饰再赋值给default字段属性。
模型定义逻辑在第六章 Odoo 15开发之模型 – 结构化应用数据中进行了讨论。相关的例子可见数据模型模式一节。记录集的ORM方法装饰器一节提供对一些这里所说的ORM装饰器的回顾。
还有模型事件相关业务逻辑,与业务工作流相关。可与以下记录相关事件进行关联:
- 可对这些事件添加创建、写入、删除业务逻辑,而其它更优雅的方法则无法使用。
- 对用户界面视图可应用Onchange逻辑,这样一些字段的值可以在其它字段发生变化时做出改变。
对于直接由用户发起的动作,有以下选项:
- button视图元素用于调取对象方法。按钮可位于表单或列表的看板视图中。
- server动作可在菜单项或Action上下文菜单中使用。
- 用于打开向导表单的window动作,可由用户输入,按钮会调用业务逻辑。这使得用户交互更丰富。
这些技巧在本章都会用到。支持的方法通常使用API装饰器,因此理解它们的不同很重要。为了能拨云见雾,下一节中进行综述。
理解记录集的ORM方法装饰器
方法定义前可添加@,对方法进行装饰。这些装饰器对方法添加特定的行为,根据方法的作用不同,可使用不同的装饰器。
计算字段和验证方法装饰器
- @api.depends(fld1,…)用于计算字段函数,标记(重新)计算应触发什么样的修改。必须设置在计算字段值上,否则会报错。
- @api.constrains(fld1,…)用于模型验证函数并在任意参数中包含的字段修改时执行检查。不应在数据中写入修改,如检查失败,则抛出异常。
在第六章 Odoo 15开发之模型 – 结构化应用数据中进行过详细的讨论。
另一组装饰器影响self记录集的行为,与实现的业务逻辑相关。
影响self记录集的装饰器
默认方法应作为于由第一个参数self所提供的记录集。方法代码中通常包含for语句循环self记录集中的每条记录。
ODOO 14中的变化
Odoo 14中删除了@api.multi装饰器。此前的版本中,使用它来显示标记所装饰的方法在self参数中应传记录集。它早已是方法的默认行为,因此添加上仅为清晰起见。在Odoo 9中已经废弃了@api.one装饰器, 因而在Odoo 14中也删去了它。它为理处理记录遍历,这样会对每条记录调用该方法,self参数总是个单体。从Odoo 14起,这两个装饰器都从代码中删除了,不再进行支持。
某些情况下,方法需要对类进行操作,而不是某个具体的记录,像静态方法那样。这些方法使用@api.model装饰,这时self方法参数应为模型的指针,不包含记录。
例如,create()方法使用@api.model装饰器,它并不需要输入记录,仅要一个值字典,用于创建并返回记录。用于计算默认值的方法也要使用@api.model装饰器。
在进一步研究业务逻辑实现之前,我们必须更深入了解数据模型,在此过程中,提供一些通用数据模型模式的示例。
探讨一些数据模型模式
模型用于表示业务文档有一些所需的数据结构。在一些Odoo应用可以看到,如销售订单或发票。
常见的模式是头部/分行数据结构。在借阅请求是会使用到,这样可以借多本书。另一种模式是使用状态或者阶段。这两者存在不同,我们会稍后会讨论并提供参考实现。
最后,ORM API提供一些与用户界面相关的方法。本节中会进行讨论。
使用头部和分行模型
表单视图常见的需求是头部-分行数据结构。例如,销售订单包含多行订单项。对于借阅功能,借阅请求可以有多个请求行,每行为一个借阅项。
在Odoo中实现很简单。需要两个模型来实现头部分行表单视图,一个用于文档头部,另一个用于文档分行。分行模型是一个多对一字段,用于标识所属的头部,而头部模型有一个一对多字段,列举文档中的分行。
在借阅模型中已添加了library_checkout模块,现在需要添加分行。操作步骤如下:
- 编辑models/library_checkout.py文件,为借阅分行添加一对多字段:
12345line_ids = fields.One2many('library.checkout.line','checkout_id',string="Borrowed Books",) - 在
models/__init__.py
中添加新模型的文件,如下:
12from . import library_checkoutfrom . import library_checkout_line - 然后,添加声明借阅分行模型的Python文件
models/library_checkout_line.py
,内容如下:
12345678910from odoo import api, exceptions, fields, modelsclass CheckoutLine(models.Model):_name = "library.checkout.line"_description = "Checkout Request Line"checkout_id = fields.Many2one("library.checkout",required=True,)book_id = fields.Many2one("library.book", required=True)note = fields.Char("Notes") - 我们必须添加访问权限配置。编辑security/ir.model.access.csv文件,添加如下高亮部分内容:
123id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlinkcheckout_user,Checkout User,model_library_checkout,library_app.library_group_user,1,1,1,1checkout_line_user,Checkout Line User,model_library_checkout_line,library_app.library_group_user,1,1,1,1 - 然后,我们要对表单添加借阅行。将其添加为notebook微件的第一页。编辑views/checkout_view.xml 文件,在
</sheet>
元素前添加如下代码:
12345678910<notebook><page name="lines"><field name="line_ids" ><tree editable="bottom"><field name="book_id" /><field name="note" /></tree></field></page></notebook>
借阅表单更新如下:
分行一对多字段显示为父表单视图中的一个列表视图。默认,Odoo会查找用于渲染的列表视图定义,这是任意列表视图的典型定义。如未发现定义,会自动生成一个默认定义。
可以在<field>
内声明具体的视图。在以上代码就这么做的。在line_ids字段元素内,有一个内嵌的<tree>
视图定义在该表单中使用。
使用以文档为中心工作流的阶段和状态
在 Odoo 中,我们可以实现以文档(document)为中心的工作流。我们这里说的文档包括销售订单、项目任务或人事申请。所有这些都遵循一个特定的生命周期,它们都在完成时才被创建。每个工作项在文档中记录,按照一系列阶段推进,直至完成。
如果把各阶段以列展示在面板中,把文档作为这些列中的工作项,就可以得到一个看板(Kanban),一个快速查看工作进度的视图。
实现这些进度步骤有两种方法-状态和阶段:
- 状态为预定义的闭合选项列表。它便于实现业务规则,因其可事先固化。模型和视图对 state 字段有特别的支持,让其易于使用。封闭状态列表有一个劣势,它无法适配自定义流程需求步骤。
- 阶段通过关联模型实现,是一个灵活的步骤列表,可进行配置来满足流程需求。通常使用stage_id字段名实现。阶段列表可以轻易修改,因为我们可以对其删除、添加或重排序。它的劣势是对流程自动化不可靠。因为阶段列表可被修改,自动化规则就无法依赖于具体的阶段 ID 或描述。
在设计数据模型时,我们需要决定是否应使用阶段或状态。如果触发业务逻辑比配置流程更为重要,应优先使用状态,否则应优先选择阶段。
如果无法抉择,有一个两全其美的方法:我们可以使用阶段,并将阶段映射到相应的状态。流程步骤列表可由用户轻松配置,因每个阶段会与一些可靠的状态码相关联,可自如地用于自动化业务逻辑。
图书借阅功能会使用这种方法。为实现借阅阶段,我们要添加library.checkout.stage模型。描述阶段所需的字段如下:
- Name或标题
- Sequence, 用于对阶段列排序
- Fold, 用于看板视图,决定默认是否折叠该列。这通常用于非活跃项目列,如已完成或已取消。
- Active, 允许存档或不再使用的阶段,以妨流程变更 。
- State, 封闭选择列表,用于将每个阶段映射到固定的状态。
实现以上字段我们应添加一个Stages模型,包含模型定义、视图、菜单和访问权限:
- 添加models/library_checkout_stage.py文件并包含如下模型定义代码:
12345678910111213141516from odoo import fields, modelsclass CheckoutStage(models.Model):_name = "library.checkout.stage"_description = "Checkout Stage"_order = "sequence"name = fields.Char()sequence = fields.Integer(default=10)fold = fields.Boolean()active = fields.Boolean(default=True)state = fields.Selection([("new", "Requested"),("open", "Borrowed"),("done", "Returned"),("cancel", "Canceled")],default="new",)
以上代码读者应该习以为常了。阶段有一个逻辑排序,所以呈现的顺序很生育。这借由_order=”sequence”来实现。我们还看到state字段将每个阶段与一个基本状态相映射,打王者在业务逻辑中可安全使用。 - 老规矩,在models/__init__.py文件中添加新的代码文件,如下:
123from . import library_checkout_stagefrom . import library_checkoutfrom . import library_checkout_line - 同样需要访问权限规则。阶段包含设置数据,应仅由管理员用户组编辑。普通用户有只读权限。因此,在security/ir.model.access.csv文件中添加如下高亮代码:
12345id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlinkcheckout_user,Checkout User,model_library_checkout,library_app.library_group_user,1,1,1,1checkout_line_user,Checkout Line User,model_library_checkout_line,library_app.library_group_user,1,1,1,1checkout_stage_user,Checkout Stage User,model_library_checkout_stage,library_app.library_group_user,1,0,0,0checkout_stage_manager,Checkout Stage Manager,model_library_checkout_stage,library_app.library_group_manager,1,1,1,1 - 接下来需要一个菜单项来浏览阶段的设置。它应当位于应用的配置菜单下。library_app模块暂无此菜单,所以需要对其编辑添加。编辑library_app/views/library_menu.xml文件并新增如下XML:
1234<menuitem id="menu_library_configuration"name="Configuration"parent="menu_library"/> - 现在将Stages菜单项加到配置菜单下。编辑library_checkout/views/library_menu.xml文件添加如下XML:
123456789<record id="action_library_stage" model="ir.actions.act_window"><field name="name">Stages</field><field name="res_model">library.checkout.stage</field><field name="view_mode">tree,form</field></record><menuitem id="menu_library_stage"name="Stages"action="action_library_stage"parent="library_app.menu_library_configuration" /> - 我们需要一些操作的阶段,因此在模块中添加一些默认数据。创建data/library_checkout_stage.xml文件加入如下代码:
12345678910111213141516171819202122<odoo noupdate="1"><record id="stage_new" model="library.checkout.stage"><field name="name">Draft</field><field name="sequence">10</field><field name="state">new</field></record><record id="stage_open" model="library.checkout.stage"><field name="name">Borrowed</field><field name="sequence">20</field><field name="state">open</field></record><record id="stage_done" model="library.checkout.stage"><field name="name">Completed</field><field name="sequence">90</field><field name="state">done</field></record><record id="stage_cancel" model="library.checkout.stage"><field name="name">Canceled</field><field name="sequence">95</field><field name="state">cancel</field></record></odoo> - 需要在
library_checkout/__manifest__.py
文件中进行添加才会生效,如下:
123456'data':['security/ir.model.access.csv','views/library_menu.xml','views/checkout_view.xml','data/library_checkout_stage.xml',],
成功更新后阶段列表应当如下图所示:
这就是library_checkout中所需添加的阶段模型,用户也可进行配置。
添加阶段工作流支持模型
下面应在图书借阅模型中添加阶段字段。为保障用户体验,还要再处理两个问题:
- 默认阶段应赋值为new状态。
- 在对阶段分组时,所有可用的阶段都应在场,即使是那些没有借阅的阶段也如此。
这些应该在library_checkout/models/library_checkout.py文件的Checkout类中添加。
查找默认阶段的函数应返回用于默认值的记录:
1 2 3 4 |
@api.model def _default_stage_id(self): Stage = self.env["library.checkout.stage"] return Stage.search([("state", "=", "new")], limit=1) |
这会返回阶段模型中的第一条记录。因阶段模型按序号排序,它会返回序号最低的那条记录。
在按阶段分组时,会会看到所有的阶段,而不仅仅是那些有借阅记录的。使用的方法返回分组使用的记录集。本例中,返回所有活跃阶段是恰当的:
1 2 3 |
@api.model def _group_expand_stage_id(self, stages, domain, order): return stages.search([], order=order) |
最后,我们希望添加到借阅模型的stage_id字段,可对default和group_expand属性使用以上的方法:
1 2 3 4 5 |
stage_id = fields.Many2one( "library.checkout.stage", default=_default_stage_id, group_expand="_group_expand_stage_id") state = fields.Selection(related="stage_id.state") |
ODOO 10中的变化
在Odoo 10中引入了group_expand属性,在此前版本中没有该属性。
group_expand参数重载了字段分组的方式。分组操作的默认行为是仅查看使用中的阶段,那些没有借阅文档的阶段不显示。但对于stage_id字段,我们希望可用阶段都显示,不管有没有内容。
_group_expand_stage_id()帮助方法返回分组操作使用的分组记录列表。本例中,它返回所有存在的阶段,不管是否包含图书记录。
注:group_expand属性必须为方法名字符串。它不同于其它属性,比如default属性可直接引用方法名或使用字符串。
还添加了state字段。它只是模型中存在阶段相关的state字段,以在视图中使用。这会使用视图中可用的state的特殊支持。
用户界面的支持方法
以下方法最常用于网页客户端中渲染用户界面和执行基础交互:
- name_get()计算显示名称,为在视图中显示关联记录使用的表示每条记录的文件。它返回ID和(ID, name)元组列表.它是display_name值的默认教育处,可扩展用于实现自定义显示,如显示记录名和标识码。
- name_search(name=”, args=None, operator=’ilike’, limit=100)对显示名称执行搜索。用于用户在视图的关联字段中输入生成匹配所输文本推荐记录的列表。它返回一个元组(ID, name)列表。
- name_create(name)创建一条仅带有输入名称的新记录。它在看板视图中配合on_create=”quick_create”使用,在其中通过提供名称可快速创建关联记录。可扩展来为通过此功能创建的新记录提供指定默认值。
- default_get([fields])以字典返回待创建新记录的默认值。默认值依赖于变量,如当前用户或会话上下文。可对其扩展添加其它默认值。
- fields_get()用于描述模型字段的定义,在开发者菜单的View Fields选项中也可以看到。
- fields_view_get()在网页客户端中用于获取要渲染的 UI视图的结构。可传入视图的 ID或想要使用的视图类型(view_type=’form’)作为参数。例如self.fields_view_get(view_type=’tree’)返回为self模型渲染的树状视图XML结构。。
这些内置的ORM方法可作为实现模型业务逻辑的扩展点。
下一节中我们讨论记录操作(如创建或写入记录)如何触发业务逻辑。
使用 ORM 内置方法
模型定义相关方法可以完成很多任务,但却无法实现有些业务逻辑,所以需要使用到ORM记录写入操作。
ORM 提供对模型数据执行增删改查(CRUD)操作的方法。下面我们来探讨这些写操作以及如何进行扩展支持自定义逻辑。
读取数据的主要方法search()和browse()在第七章 Odoo 15开发之记录集 – 使用模型数据中已进行讨论。
写入模型数据的方法
ORM 为三种基本写操作提供了三个方法,如下所示:
- <Model>.create(values)在模型上创建新记录,values为字典或在批量创建记录时为字典列表。
- <Recordset>.write(values) 使用values字典更新记录集,不返回值。
- <Recordset>.unlink()从数据库中删除记录,不返回值。
values参数是一个字典,映射要写入的字段名和值。这些方法由@api.multi装饰,但create()方法使用@api.model装饰。
Odoo 12中的变化
create()可访问字典列表,而不只是单个字典对象,这在Odoo 12引入 。这样我们也可批量创建数据。这一功能同@api.model_create_multi装饰器进行支持。
有些情况下,我们需要扩展这些方法来在触发方法时运行一些特定的业务逻辑。这种业务逻辑可在主方法执行前或执行后霆。
继承create()方法的示例
我们来学习一个实例。我们不允许新创建借阅记录直接变为已借出或已归还状态。通常应用使用@api.constrains装饰的某个方法来实现验证。但本例只创建记录事件相绑定,很难由常规的验证实现。
编辑library_checkout/models/library_checkout.py文件并添加create()继承方法:
1 2 3 4 5 6 7 8 |
@api.model def create(self, vals): # 创建前执行的代码,应使用vals字典 new_record = super().create(vals) # 创建后执行的代码,应使用new_record if new_record.stage_id.state in ('open', 'close'): raise exceptions.UserError("State not allowed for new checkouts.") return new_record |
新记录由super().create()调用创建。在此之前,业务逻辑中无法使用新记录,仅可使用values字典,也无法修改以在待创建记录中强制一些值。
super().create()之后的代码可访问新创建记录,且可使用记录功能,如使用点号标记链访问关联记录。上例使用new_record.stage_id.state访问新记录阶段对应的状态。状态是不可由用户配置的,为业务逻辑提供了稳定的值列表。因此,我们可以查找open或done状态并在找到时抛出错误。
继承write()的示例
我们再看一个例子。Checkout模型应记录图书借出的日期,借阅日期,以及归还的日期,关闭日期。这无法使用计算字段实现。而是应继承write()方法来监测借阅状态的变化,然后在相应时刻(变为open或done状态之时)更新日期。
在实现这一逻辑之前,需要创建两个日期字段。编辑library_checkout/models/library_checkout.py文件、添加如下代码:
1 2 |
checkout_date = fields.Date(readonly=True) close_date = fields.Date(readonly=True) |
在记录发生修改时,在借阅记录进入相应状态时应分别设置checkout_date和close_date字段。为此,我们需要自定义write()方法如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
def write(self, vals): # 写入之前的代码,self为老值 if "stage_id" in vals: Stage = self.env["library.checkout.stage"] old_state = self.stage_id.state new_state = Stage.browse(vals["stage_id"]).state if new_state != old_state and new_state == "open": vals["checkout_date"] = fields.Date.today() if new_state != old_state and new_state == "done": vals["close_date"] = fields.Date.today() super().write(vals) # 写入之后的代码,可使用更新后的self return True |
上例中,扩展代码加在super()调用之前,因而是在写入self记录之前。要知道对记录准备做的修改,可以查看vals参数。字典vals中的stage_id是一个ID号,不是记录,所以需要进行扫描获取相应记录,然后读取对应的状态。
比较老状态和新状态来在相应的时刻触发日期值更新。如若可能,我们更希望在super().write()之前修改修改写入的值、更新vals字典,而不是直接设置字段值。下一节中会讲解原因。
继承write()设置字段值的示例
前面的代码仅修改用于写入的值,它不直接对模型字段赋值。这样做安全,但在某些情况下不够。
在write()方法内对某个模型字段赋值会导致无限循环:赋值会再次触发写方法,然后又重复赋值,周而复始。直至Python返回递归错误为止。
有一种避免递归循环的技巧,让write()方法可以对记录字段设置值。这种技巧是设置值前在环境上下文中设置唯一标识,仅在没有标记时才运行设置值的代码。
看实例会更清楚。我们重写前面的示例,让更新在调用super()之后进行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
def write(self, vals): # 写入之前的代码,self为老值 old_state = self.stage_id.state super().write(vals) # 写入之后的代码,可使用更新后的self new_state = self.stage_id.state if not self.env.context.get("_checkout_write"): if new_state != old_state and new_state == "open": self.with_context(_checkout_write=True).write( {"checkout_date": fields.Date.today()}) if new_state != old_state and new_state == "done": self.with_context(_checkout_write=True).write( {"close_date": fields.Date.today()}) return True |
通过这一技巧,扩展代码由一条if语句守护,仅在上下文不存在指定标记时执行。此外,使用了带with_context方法的self.write() 操作执行写入。这组合确保了if语句中代码仅运行一次,在未来的write()调用中不会再次触发,避免了无限循环。
何时(不)继承create()和write()方法
继承create()和write()方法时应认真考虑。
大部分情况下,在保存记录时必须执行一些验证,或是自动计算某些值。对于这些常见情况,有一些更好的选择,列举如下:
- 对根据其它字段自动计算的字段值,使用计算字段。例如在各行值发生变化时计算头部的总计。
- 对于非固定字段默认值,使用方法作为默认字段值。会在赋默认值时进行运算。
- 在一个字段值变化时修改其它字段值,如在用户界面完成,使用onchange方法,如在服务端完成,使用一个新的可写入计算字段。例如,在用户选择客户时,虽然价格列表可由用户稍后修改,我们可以自动设置为客户的价格列表。在使用onchange方法时,仅对表单视图交互有效,无法用于直接写入,但可写入计算字段适用这两种情况。在添加onchange用户界面逻辑一节中进行了更详细的讨论。
- 验证使用constraint方法。在字段值修改时会自动触发,不满足验证条件时会抛出错误。
还有一些情况这些选择都不适用,则需要继承create()或write() ,比如所设置的默认值依赖所创建记录的其它字段时。这时,无法使用默认值方法,因为它无法访问新记录的其它字段。
数据导入、导出方法
导入、导出操作在第五章 Odoo 15开发之导入、导出以及模块数据已做讨论,也可以通过 ORM API 中的如下方法操作:
- load([fields], [data]) 用于导入数据,由Odoo在导入CSV或数据表数据时使用。第一个参数是导入的字段列表,与 CSV 的第一行对应。第二个参数是记录列表,每条记录是一个待解析和导入的字符串列表。与 CSV 数据中的行和列直接对应,它实现了 CSV 数据导入的功能,比如对外部标识符的支持。
- export_data([fields], raw_data=False)用于网页客户端的导出函数。它返回一个字典,带有包含datas(一个行列表)的数据键。字段名可使用 CSV 文件使用的.id和/id后缀,数据格式与需导入的 CSV 文件兼容。可选raw_data参数让数据值与 Python 类型一同导出,而不是 CSV 文件中的字符串形式。
也可在用户编辑数据时实现对用户界面的自动化。我们在下一节中进行学习。
添加onchange用户界面逻辑
可在用户编辑时网页客户端视图做出修改。这种机制是onchange。由带@api.onchange装饰器的方法实现,在用户编辑某个具体字段时由用户界面视图触发。
从Odoo 13开始,同样的效果使用计算字段的特定表单实现,称为可写计算字段。这种ORM改进旨在避免经典的onchange机制所带来的限制,从长期来看,会完全进行替换。
经典onchange 方法
onchange方法可修改表单中其它值、执行验证、对用户显示消息或对关联字段设置域过滤器,限制可选项。
onchange方法异步调用,返回由网页客户端使用的数据更新当前视图中的字段。
onchange与所触发字段关联,字段以参数传递给@api.onchange(“fld1”, “fld2”, …)装饰器。
注:api.onchange参数不支持点号标记,例如“partner_id.name”。如若使用,会进行忽略。
在该方法内,self 参数是包含当前表单数据的一条虚拟记录。其为虚拟的原因是它可以在编辑但未存入数据库之时为新记录或修改后的记录。如对self记录设置值,则会在用户界面表单中修改。注意它不写入到数据库记录中,而是提供信息在UI表单中修改数据。
注:onchange方法还有一些其它限制,请参见官方文档。可写计算字段可用作对onchange方法的全功能替代。详细信息参见带可写计算字段的新onchange一节。
不需要返回值,但可能会返回带警告消息的dict结构在用户界面中显示,或是对表单所设置的域过滤器。
我们做个实例吧。在借阅表单中,选中了图书会员时,请求日期会修改为当天。如果日期发生变化时,会对用户显示一条警告消息,通知用户发生了改变。
实现这一逻辑,编辑library_checkout/models/library_checkout.py文件并添加如下代码:
1 2 3 4 5 6 7 8 9 10 11 |
@api.onchange("member_id") def onchange_member_id(self): today_date = fields.Date.today() if self.request_date != today_date: self.request_date = today_date return { "warning": { "title": "Changed Request Date", "message": "Request date changed to today!", } } |
以上onchange方法在用户界面中设置了member_id字段时触发。实际的方法名无关紧要,但按惯例是在字段名前加onchange_前缀。
在onchange方法内,self表示包含在编辑记录时当前设置的所有记录,我们可与其进行交互 。
方法代码查看当前的request_date是否需要修改。如是,将request_date设置为当天,这样用户可在表单中看到修改。然后向用户返回非阻塞的警告消息。
onchange无需返回任何内容,但可返回包含警告或作用域键的字段,如下:
- 警告的键应描述显示在对话框中的消息,如{‘title’: ‘Message Title’, ‘message’: ‘Message Body’}
- 作用域键可设置或修改其它字段的域属性。通过让对多字段仅展示在当下有意义的字段,会使得用户界面更加友好。作用域键类似这样:{‘user_id’: [(’email’, ‘!=’, False)]}
带可写计算字段的新onchange
经典onchange机制在由Odoo框架提供的用户体验中扮演着关键角色。但存在着一些重要的缺陷。
其一是它与服务端事件无关联。onchange仅在表单视图请求时起作用,但在实际write()值变更时不会调用。这要求服务端业务逻辑重关相关的onchange方法。
另一不足是 onchange与触发字段相关,但受修改的字段无绑定。对一些小用例,很难扩展,追踪修改源也很困难。
为解决这些问题,Odoo框架扩充了计算字段的功能,让它可以处理onchange用例。我们称这一技巧为可写计算字段。当前仍支持经典onchange,但在未来的版本中会由计算字段替换并进行废弃。
ODOO 13中的变化
可写计算字段在Odoo 13中引入,可在该版本及之后的版本中使用
可写计算字段拥有赋值给它们的计算方法,必须进行存储,必须有readonly=False属性。
我们换由这种方法实现前面的onchange。request_date字段应这样修改:
1 2 3 4 5 6 |
request_date = fields.Date( default=lambda s: fields.Date.today(), compute="_compute_request_date_onchange", store=True, readonly=False, ) |
这是常规的可存储可写字段,但绑定了可由指定条件触发的计算方法。例如,计算方法应在member_id改变时触发。
计算方法_compute_request_date_onchange的代码如下:
1 2 3 4 5 6 7 8 9 10 11 |
@api.depends("member_id") def _compute_request_date_onchange(self): today_date = fields.Date.today() if self.request_date != today_date: self.request_date = today_date return { "warning": { "title": "Changed Request Date", "message": "Request date changed to today!", } } |
@api.depends像对计算字段一样,声明要监测变化的字段。实际提供的字段列表与经典@api.onchange所使用的一致。
方法代码也与onchange方法很相近。在某些情况下完全一致。注意计算字段不保证在每次方法调用时设置。仅在满足某些条件时发生。本例中为原请求日期与当天日期不同。这不同于普通的计算字段规则 ,但可用于可写计算字段。
业务流程强相关的功能有发送邮件或通知用户。下一节中讨论Odoo为此所提供的功能。
消息和活动(activity)功能
Odoo 自带全局的消息和活动规划功能,由 Discuss 应用提供,技术名称为 mail。
mail.thread模型提供消息功能,在表单视图中有一个消息微件,也称之为聊天器(Chatter)。该微件让我们可以记录笔记或向其它用户发送消息。还保存发出的历史消息,同时由自动流程用于记录过程追踪消息。
同一应用还由mail.activity.mixin模型提供活动管理功能。可对表单视图添加活动我邮件,让用户可以计划、追踪活动历史。
添加消息和活动功能
mail 模块提供包含mail.thread抽象类,用于对任意模型添加消息功能。还提供mail.activity.mixin用于添加规划活动功能。在第四章 Odoo 15开发之模块继承中已讲解了如何使用mixin 抽象类的继承来为模型添加这些继承功能。
执行必要的步骤如下:
- 编辑
library_checkout/__manifest__.py
文件中的depends键来对library_checkout插件模型添加mail模块依赖,如下:
1'depends': ['library_member', 'mail'], - 要令library.checkout模型继承消息和活动抽象类,编辑library_checkout/models/library_checkout.py文件如下:
1234class Checkout(models.Model):_name = "library.checkout"_description = "Checkout Request"_inherit = ["mail.thread", "mail.activity.mixin"] - 在借阅表单视图中添加消息和活动字段,编辑 library_checkout/ and views/checkout_view.xml文件:
12345678910111213141516171819202122232425262728293031323334353637<record id="view_form_checkout" model="ir.ui.view"><field name="name">Checkout Form</field><field name="model">library.checkout</field><field name="arch" type="xml"><form><sheet><group><group><field name="member_id" /><field name="request_date" /><field name="user_id" /></group><group><field name="stage_id" /><field name="checkout_date" /><field name="close_date" /></group></group><notebook><page name="lines"><field name="line_ids" ><tree editable="bottom"><field name="book_id" /><field name="note" /></tree></field></page></notebook></sheet><div class="oe_chatter"><field name="message_follower_ids" widget="mail_followers" /><field name="activity_ids" widget="mail_activity" /><field name="message_ids" widget="mail_thread" /></div></form></field></record>
完成这些之后,借阅模型就具有了消息和活动字段,并可以使用相应的功能了。
消息和活动字段与模型
消息和活动功能对继承了mail.thread和mail.activity.mixin类的模型添加了新字段,以及所有支持这些功能的模型。以下是添加了的基本数据结构。
mail.thread mixin类添加了两个新字段:
- 关注者:message_follower_ids与mail.follower有着一对多关联,存储接收通知的消息关注者。关注者可以是用户或频道。partner表示某个人或组织。channel不是某个人,而是一个订阅列表。
- 消息:message_ids与mail.message记录存在一对多关联,列出记录历史消息。
mail.activity.mixin mixin类新增如下字段:
- 活动:activity_ids与mail.activity存在一对多关联,存储已完成或已规划的活动。
消息子类型
消息可添加子类型。子类型用于标识指定活动,如所创建或关闭的任务,对于精准控制将什么消息发送给何人很有用。
子类型存储在mail.message.subtype模型中,可通过Settings > Technical > Email > Subtypes菜单配置。
基本的三种消息子类型如下:
- 讨论:带有mail.mt_comment XML ID,用于由消息微件中Send message所发送的消息,默认会发送通知给关注者。
- 笔记:带有mail.mt_note XML ID,用于创建带有Log note XML ID的消息,默认不会发送通知。
- 活动:带有mail.mt_activities XML ID,用于创建带有Schedule activity链接的消息,不发送通知。
应可添加自己的子类型,通常与相关的活动关联。例如,Sales应用添加了两个子类型:报价已发送和销售订单已确认。在消息历史中记录这些事件时会由应用的业务逻辑所用。
子类型允许我们决定何时发送通知以及发送给谁。消息微件右上角的关注者菜单允许我们新增或删除关注者,以及选择接收通知的子类型。下图展示了某一关注者Gemini Furniture的子类型选择表单:
图8.4:选取活跃消息子类型的关注者微件
子类型订阅标记可拖动编辑,其默认值在编辑子类型定义查看Default字段时配置。一经设置,新记录的关注者默认会收到通知。
除内置子类型,插件模块可添加自己的子类型。子类型可以是全局的,也可以只针对指定模型,对于后者,子类型的res_model字段标记其所应用的模型。
发送消息
模块业务逻辑可利用这个消息系统向用户发送通知。
可使用message_post() 方法来发送通知。示例如下:
1 |
self.message_post('Hello!') |
以上代码添加一条普通文本消息,但不会向关注者发送通知。这是因为默认消息使用Log a Note发布,带有subtype=”mail.mt_note”参数。
要让消息还发送通知,需要使用mail.mt_comment子类型,如下例所示:
1 2 3 4 |
self.message_post( 'Hello again!', subject='Hello', subtype='mail.mt_comment') |
消息体是HTML格式的,所以我们可以添加标记来实现文本效果,如<b>为加粗,<i>为斜体。
出于安全原因消息体会被清洗,所以有些 HTML 元素可能最终无法出现在消息中。
添加关注者
从业务逻辑角度来看还有一个有用的功能:可以向文档添加关注者,这样他们可以获取相应的通知。我们有以下几种方法来添加关注者:
- message_subscribe(partner_ids=<整型 id 列表>)添加partner
- message_subscribe(channel_ids=<整型 id 列表>) 添加频道
- message_subscribe_users(user_ids=<整型 id 列表>) 添加用户
默认的子类型会作用于每个订阅者。强制订阅指定的子类型列表,可添加subtype_ids=<整型 id 列表>属性,来列出在订阅中使用指定子类型。如使用该属性,还会重置已有的关注者订阅子类型为所指定的子类型。
创建向导
向导是为用户提供丰富交互的用户界面模式,常用于提供自动化流程的输入。
例如,我们的借阅模块为图书用户提供一个向导,对借阅者批量发送邮件。比如可选取图书最早的那些借阅者,向他们发送消息要求归还图书。
我们的用户开始从借阅列表中选择待使用的记录,然后从Action上下文菜单中选择Send Messages选项。这会打开向导表单,让用户写入消息主题和内容。点击Send按钮会向每个借阅所选图书的人发送邮件。
向导模型
向导对用户显示为一个表单视图,通常是一个对话框,可填入一些字段,还有触发一些业务逻辑的按钮。随后会在向导逻辑中使用。
它通过普通视图同样的模型/视图结构实现,但支持的模型继承的是models.TransientMode而不是models.Model。这种类型的模型也会在数据库体现,用于存储状态。向导的数据是临时的,让向导可完成其任务。有一个调度任务会定期清除向导数据表中的老数据。
我们将使用wizard/checkout_mass_message.py 文件来定义与用户交互的字段:通知的借阅记录列表,标题和消息体。
按如下步骤对library_checkout模块添加向导;
- 首先编辑
library_checkout/__init__.py
文件并导入wizard/子目录,如下:
12from . import modelsfrom . import wizard - 添加
wizard/__init__.py
文件并加入如下代码:
1from . import checkout_mass_message - 然后创建实际的wizard/checkout_mass_message.py文件,内容如下:
12345678910from odoo import api, exceptions, fields, modelsclass CheckoutMassMessage(models.TransientModel):_name = "library.checkout.massmessage"_description = "Send Message to Borrowers"checkout_ids = fields.Many2many("library.checkout",string="Checkouts")message_subject = fields.Char()message_body = fields.Html()
这样我们就准备好了向导所需的基本数据结构。
注意普通模型不应存在关联临时模型的字段。
结果就是临时模型不能与普通模型存在一对多关联。原因是临时模型的一对多关联会要求普通模型存在反向的多对一关联,这在自动清理临时记录会产生问题。
替代方案是使用多对多关联。多对多关联存在单独的表中,关联某一方删除时都会自动删除该表中的对应行。
向导访问权限
和普通模型一样,临时模型也需要定义访问权限规则。实现方式与普通模型相同,即通常在security/ir.model.access.csv文件中实现。
ODOO 13中的变化
截至Odoo 12,临时模型无需访问权限规则。这在Odoo 13中进行了调整,因此现在临时模型和普通模型一样需要权限规则。
对向导模型添加访问权限列表,编辑security/ir.model.access.csv文件,添加高亮部分的代码:
1 2 3 4 5 6 |
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink checkout_user,Checkout User,model_library_checkout,library_app.library_group_user,1,1,1,1 checkout_line_user,Checkout Line User,model_library_checkout_line,library_app.library_group_user,1,1,1,1 checkout_stage_user,Checkout Stage User,model_library_checkout_stage,library_app.library_group_user,1,0,0,0 checkout_stage_manager,Checkout Stage Manager,model_library_checkout_stage,library_app.library_group_manager,1,1,1,1 checkout_massmessage_user,Checkout Mass Message User,model_library_checkout_massmessage,library_app.library_group_user,1,1,1,1 |
一行足以对图书用户组添加完整权限,图书管理员组无需要指定权限。
向导表单
向导表单视图与普通模型相同,只是它有两个特定元素:
- 可使用<footer>元素来替换操作按钮
- special=”cancel”按钮用于中断向导,不执行任何操作
wizard/checkout_mass_message_wizard_view.xml文件的内容如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
<odoo> <record id="view_form_checkout_message" model="ir.ui.view"> <field name="name">Library Checkout Mass Message Wizard</field> <field name="model">library.checkout.massmessage</field> <field name="arch" type="xml"> <form> <group> <field name="message_subject" /> <field name="message_body" /> <field name="checkout_ids" /> </group> <footer> <button type="object" name="button_send" string="Send Messages" /> <button special="cancel" string="Cancel" class="btn-secondary" /> </footer> </form> </field> </record> <record id="action_checkout_message" model="ir.actions.act_window"> <field name="name">Send Messages</field> <field name="res_model">library.checkout.massmessage</field> <field name="view_mode">form</field> <field name="binding_model_id" ref="model_library_checkout" /> <field name="binding_view_types">form,list</field> <field name="target">new</field> </record> </odoo> |
以上的XML代码添加了两个数据记录,一个用于向导表单视图,另一个用于打开向导的动作。
ir.actions.act_window窗口动作记录使用binding_model_id字段值在Action上下文菜单中添加。
别忘记在声明文件中添加该文件:
1 2 3 4 5 6 7 |
'data':[ 'security/ir.model.access.csv', "wizard/checkout_mass_message_wizard_view.xml", 'views/library_menu.xml', 'views/checkout_view.xml', 'data/library_checkout_stage.xml', ], |
向导表单如下:
译者注:此时更新模块会报错,因为我们尚未实现button_send方法,可先添加一个空方法完成安装
打开该向导,用户应在借阅列表视图中选中一条或多条记录,通过位于列表视图上方的Action菜单选择Send Messages选项。
向导业务逻辑
至此我们可打开向导表单,但还不能对记录执行任何操作。首先我们希望向导展示我们在借阅列表视图中选中的记录列表。
打开向导表单时,它显示一个空表单。它还不是条记录,仅在点击按钮调用方法时才会成为记录。
注:打开向导表单时,我们有一条空记录。还未调用create()方法,这需要按下按钮。因此,它不能用于设置在向导表单中展示的值。
可以通过对字段设置默认值来为空表单添加数据。default_get()是负责计算记录默认值的ORM API 方法。可对其扩展添加业务逻辑,如下:
1 2 3 4 5 |
@api.model def default_get(self, field_names): defaults_dict = super().default_get(field_names) # 在此处为defaults_dict添加值 return defaults_dict |
以上方法可用于为checkout_ids字段添加默认值。但我们还需要知道如何访问原列表视图中所选中的记录列表。
在从一个客户端窗口进入一个窗口时,网页客户端会在环境的context中存储原视图的一些数据。数据如下:
- active_model:该模型的技术名
- active_id:表单活跃记录或所访问列表视图中第一条记录的 ID
- active_ids:包含所选记录的列表(如果是表单则只有一个元素)
- active_domain:在表单视图中触发了该操作时
本例中,active_ids可用于获取列表视图中所选中的记录ID,并对checkout_ids字段设置默认值。此时default_get方法如下:
1 2 3 4 5 6 |
@api.model def default_get(self, field_names): defaults_dict = super().default_get(field_names) checkout_ids = self.env.context["active_ids"] defaults_dict["checkout_ids"] = [(6, 0, checkout_ids)] return defaults_dict |
首先,super()用于调用框架的default_get()实现,它返回包含默认值的字典。然后对defaults_dict添加checkout_id键,从环境上下文中读取active_ids值。
这样在打开向导表单时,checkout_ids字段会自动将所选择的记录加入进业。接下来需要实现表单的Send Messages按钮的功能。
查看表单的XML代码,我们可以看到按钮调用一个名为button_send的方法。应在wizard/checkout_mass_message.py文件中定义如下:
1 2 3 4 5 6 7 8 9 |
def button_send(self): self.ensure_one() for checkout in self.checkout_ids: checkout.message_post( body=self.message_body, subject=self.message_subject, subtype_xmlid ="mail.mt_comment" ) return True |
这个方法用于操作单条记录,在self是记录集是无法正常使用。这里使用self.ensure_one()进了显示的说明。
这里的 self 表示在点击按钮时创建的向导记录数据。它包含在向导表单中输入的数据。执行了验证在确保用记输入了消息体文本。
访问了checkout_ids字段,并遍历其中的每条记录。对单条借阅记录,使用mail.thread API提交消息。向记录关注者发送通知邮件必须使用mail.mt_comment子类型。消息内容和主题从self记录字段中提取。
让方法返回内容是一种良好实践,至少返回True。原因是有些XML-RPC客户端不支持None值。在Python方法没有显式的return时,会隐式地返回None。在实践中可能不会碰到这个问题,因为网页客户端使用的是JSON-RPC,而不是XML-RPC,但仍是一种良好实践。
向导是我们业务逻辑工作箱以及本单详细讲解的技巧中最为复杂的工具。
业务逻辑还包括在执行某些操作的前后测试是否满足条件。下一节中讲解如何在不满足条件时抛出异常。
抛出异常
有时输入的内容不适于所执行任务,代码需要警告用户给出错误消息并中断程序执行。这通过抛出异常来实现。Odoo提供的异常类在此时使用。
最常用的Odoo异常有:
1 2 3 |
from odoo import exceptions raise exceptions.ValidationError("数据不一致") raise exceptions.UserError("输入错误") |
ValidationError异常用于 Python 代码中的验证,比如对@api.constrains装饰的方法。
UserError应该用在其它所有操作不被允许的情况,因为这不符合业务逻辑。
Odoo 9中的变化
引用了UserError异常来替换掉Warning异常,淘汰掉 Warning 异常的原因是因为它与 Python 内置异常冲突,但 Odoo 保留了它以保持向后兼容性。
通常所有在方法执行期间的数据操作在同一个数据库事务中,发生异常时会进行回滚。也就是说在抛出异常时,所有此前对数据的修改都会取消。
下面就使用本例向导button_send方法来进行举例说明。试想一下如果执行发送消息逻辑时没有选中任何借阅文档是不是不合逻辑?同样如果没有消息体就发送消息也不合逻辑。下面就来在发生这些情况时向用户发出警告。
编辑button_send()方法加入如下高亮部分的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
def button_send(self): self.ensure_one() if not self.checkout_ids: raise exceptions.UserError("No checkouts were selected") if not self.message_body: raise exceptions.UserError("A message body is required") for checkout in self.checkout_ids: checkout.message_post( body=self.message_body, subject=self.message_subject, subtype_xmlid="mail.mt_comment" ) return True |
使用异常时,请确保在代码文件顶部添加from odoo import exceptions导入。添加验证只需要查看是否满足某些条件,在不满足时抛出异常。
下一节讨论每个Odoo开发者都应当非常熟悉的开发工具。我们先从自动化测试开始。
编写单元测试
自动化测试是广泛接受的软件开发最佳实践。不仅可以帮助我们确保代码正确实现,更重要的是为我们未来的代码修改和重写提供了一个安全保障。
对于 Python 这样的动态编程语言,因为没有编译这一步,语法错误经常不容易注意到。这也使得检测代码错误的单元测试愈发重要,比如类型错误的标识符名称。
以下两个目标是我们编写测试时的灯塔。测试的第一个目标应是测试覆盖率:编写测试用例运行所有代码行。
这通常对于每二个目标有很大的促进,也即代码的正确性。这是因为在提高测试覆盖率后,我们对大型用例构建测试用例就有基础了。
ℹ️Odoo 12中的变化
在该版本之前,Odoo 还支持通过 YAML格式的数据文件进行测试。Odoo 12中删除了YAML数据文件引擎,不再支持该格式,有关该格式的最后一个文档请见官方网站。
接下来我们将学习如何为模块添加测试用例并运行。
添加单元测试
插件模块的测试必须放在tests/子目录下。测试执行器会自动在该目录下查找测试文件,并且在模块的顶级__init__.py
中不应导入该目录。
要对library_checkout模块的向导逻辑添加测试,我们可以创建tests/__init__.py
,在其中导入测试文件。本例中应包含如下代码:
1 |
from . import test_checkout_mass_message |
然后,我们要创建tests/test_checkout_mass_message.py文件,并确保其符合单元测试代码的基本结构:
1 2 3 4 5 6 7 8 9 10 11 12 |
from odoo import exceptions from odoo.tests import common class TestWizard(common.SingleTransactionCase): def setUp(self,*args, **kwargs): super(TestWizard, self).setUp(*args, **kwargs) # 在此处添加测试配置代码 def test_01_button_send(self): """发送按钮应对借阅记录创建消息""" # 添加测试代码 |
Odoo 提供了一些供测试使用的类:
- TransactionCase为每个测试使用不同的事务,在测试结束时自动回滚。
- SingleTransactionCase将所有测试放在一个事务中运行,在最后一条测试结束后才进行回滚。这可以大幅提升测试速度,但每条测试需要按照其兼容的方式编写。
这些测试类是对Python标准库unittest测试用例的封装。更详细的内容请参见官方文档。
setUp()方法用于准备测试数据并将其存储在类属性中,以后测试方法们使用。
测试以类方法进行实现,如以示例代码中的est_01_button_send() 。测试用例方法名必须以test_为前缀。这样才能被测试执行器发现。测试方法按方法名称的顺序执行。
docstring方法在运行测试时在服务端日志中打印,用于提供对所执行测试的简短描述。
运行测试
写好测试后就可以运行了。此时必须要升级或安装模块(-I 或 -u)并在Odoo服务端命令中添加--test-enable
选项。
命令如下:
1 |
(env15) $ odoo -c library.conf --test-enable -u library_checkout --stop-after-init |
仅会测试安装或升级的模块,这也是使用-u选项的原因。如果需要安装某些依赖,也会运行其测试。如果不希望测试依赖,就先安装新模块,然后在升级模块(-u) 时运行测试。
虽然模块包含测试代码,但此处的代码没有执行任何测试,会成功运行。如果仔细查看服务端日志的话,会有报告测试运行的INFO消息,类似下面这样:
1 |
INFO library odoo.modules.module: odoo.addons.library_checkout.tests.test_checkout_mass_message running tests. |
测试代码基本结构已就位。下面我们添加实际测试代码。首先应配置数据。
配置测试
编写测试的第一步是准备所使用的数据。这一般在setUp方法中完成。本例需要一条借阅记录,在测试向导时使用。
使用指定用户执行测试操作很便捷,这样可以同时测试权限控制是否正常配置。这通过sudo(<user>)模型方法来实现。记录集中携带这一信息,因此在使用 sudo()创建后,相同记录集后续的操作都会使用相同上下文执行。
以下是setUp方法中的代码:
1 2 3 4 5 6 7 8 9 |
class TestWizard(common.SingleTransactionCase): def setUp(self,*args, **kwargs): super(TestWizard, self).setUp(*args, **kwargs) # 配置测试数据 admin_user = self.env.ref("base.user_admin") self.Checkout = self.env["library.checkout"].with_user(admin_user) self.Wizard = self.env["library.checkout.massmessage"].with_user(admin_user) a_member = self.env["library.member"].create({"partner_id": admin_user.partner_id.id}) self.checkout0 = self.Checkout.create({"member_id": a_member.id}) |
此时我们就可以在测试中使用self.checkout0记录和self.Wizard模型了。
编写测试用例
现在让我们来扩展一下初始框架中的test_button_test()方法吧。
基本的测试是运行测试对象中的部分代码,获取结果,然后使用断言语句来与预期结果进行对比。消息发送逻辑未返回任何值,因而需要其它方法。
button_send()方法对消息历史添加一条消息。一种确定是否发生的方式是计算方法执行前后的消息数。测试代码可在向导前后计算消息数据。如下代码添加了这一逻辑:
1 2 3 4 5 6 7 8 9 10 |
def test_01_button_send(self): """发送按钮应对借阅记录创建消息""" count_before = len(self.checkout0.message_ids) # TODO: 运行向导 count_after = len(self.checkout0.message_ids) self.assertEqual( count_before + 1, count_after, "Expected one additional message in the Checkout.", ) |
这一检测在self.assertEqual语句中验证测试成功还是失败。它对比运行向导前后的消息数,预期会比运行前多一条消息。最后一个参数在测试失败时作为信息提示,它是可选项,但推荐使用。
assertEqual方法仅是断言方法的一种,我们应根据具体用例选择合适的断言方法。unittest文档提供对所有这些方法的说明,参见 Python 官方文档。
运行向导不够直接,需要模拟用户界面工作流。还记得环境上下文使用active_ids对向导传递数据。我们必须使用填入向导表单的数据创建一条向导记录,数据为button_send方法所使用的消息标题和内容。
完整代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
def test_01_button_send(self): """发送按钮应对借阅记录创建消息""" count_before = len(self.checkout0.message_ids) Wizard0 = self.Wizard.with_context(active_ids=self.checkout0.ids) wizard0 = Wizard0.create({ "message_subject": "Hello", "message_body": "This is a message.", }) wizard0.button_send() count_after = len(self.checkout0.message_ids) self.assertEqual( count_before + 1, count_after, "Expected one additional message in the Checkout.", ) |
with_context模型方法用于对环境上下文添加active_ids。然后create()方法用于创建向导记录及添加用户输入的数据。最后调用button_send方法。
更多的测试用例可使用测试类中的其它方法添加。记住使用TransactionCase测试,每次测试结束时会回滚,测试中所执行的操作都会撤销。SingleTransactionCase中测试是叠加的,测试顺序非常重要。因为测试是按照字母顺序执行的,所选的测试方法就有关了。为保持清晰,在测试方法名前添加数字是一种良好实践,前例中正是这么做的。
测试异常
有时代码应抛出异常,这也应该进行水岸东方。例如,我们可以测试验证的执行是否正确。
继续对向导进行测试,对消息体是否为空执行了检测。可添加测试来检测是否正确执行了验证。
检查是否抛出了导演,相应的代码应放在with self.assertRaises()代码块中。
再添加一个测试如下:
1 2 3 4 5 6 |
def test_02_button_send_empty_body(self): """消息体而空时发送按钮报错""" Wizard0 = self.Wizard.with_context(active_ids=self.checkout0.ids) wizard0 = Wizard0.create({}) with self.assertRaises(exceptions.UserError) as e: wizard0.button_send() |
果button_send()方法没有抛出UserException,测试会失败。如果抛出了异常则测试成功。所抛出的异常存储在变量e中,可供其它方法命令检查,例如验证错误消息的内容。
使用日志消息
向日志文件写入消息有助于监控和审计运行的系统。它还有助于代码维护,在无需修改代码的情况下可以从运行的进程中轻松获取调试信息。
要Odoo代码中使用日志功能,首先要准备一个日志记录器(logger)对象。在library_checkout/wizard/checkout_mass_message.py文件的头部添加如下代码:
1 2 |
import logging _logger = logging.getLogger(__name__) |
这里使用了 Python标准库logging模块。_logger通过当前代码文件名__name__
来进行初始化。这样日志信息就会带有生成日志文件的信息。
有以下几种级别的日志信息:
1 2 3 4 |
_logger.debug('DEBUG调试消息') _logger.info('INFO信息日志') _logger.warning('WARNING警告消息') _logger.error('ERROR错误消息') |
现在就可以使用logger向Odoo服务端日志中写入消息了。
我们来为button_send向导方法来添加日志。在文件最后一行return True前添加如下代码:
1 2 3 4 5 |
_logger.info( 'Posted %d messages to Checkouts: %s', len(self.checkout_ids), str(self.checkout_ids), ) |
这样在使用向导发送消息时,服务端日志中会打印类似如下消息:
1 |
INFO library odoo.addons.library_checkout.wizard.checkout_mass_message: Posted 2 messages to the Checkouts: [3, 4] |
注意我们没有在日志消息中使用 Python 内插字符串,也即%运算符。具体来说,我们没使用_logger.info(‘Hello %s’ % ‘World’),而是使用了类似_logger.info(‘Hello %s’, ‘World’)。不使用内插使我们的代码少执行一个任务,让日志记录更为高效。因此我们应一直为额外的日志参数传入变量。
服务器端日志的时间戳总是使用 UTC 时间。这可能会让你意外,但Odoo服务内部都是使用 UTC 来处理日期的。
对于调试级别日志,我们使用_logger.debug()。例如,可以在checkout.message_post() 命令后添加如下调试日志消息:
1 2 3 4 |
_logger.debug( 'Message on %d to followers: %s', checkout.id, checkout.message_follower_ids) |
这不会在服务器日志中显示任何消息,因为默认的日志级别是INFO。需要将日志级别设置为DEBUG才会输出调试日志消息。
Odoo 命令行选项--log-level
可用于设置通用日志级别。例如使用--log-level=debug
在命令行启用调试日志消息。
我们还可以对指定模块设置日志级别。要开启向导的调试消息,使用--loghandler
选项,该选项还可重复多次来对多个模块设置日志级别。
例如我们的向导的 Python 模块是odoo.addons.library_checkout.wizard.checkout_mass_message,这在 INFO 日志消息中也可以看到。要将其设置为调试日志级别,使用如下命令行参数:
1 |
--loghandler=odoo.addons.library_checkout.wizard.checkout_mass_message:DEBUG |
有关 Odoo 服务器日志选项的完整手册可参见官方文档。
小贴士:如果想要了解原始的 Python 日志细节,可先参见Python 官方文档。
日志是有用的工具,但在进行调试时就捉襟见肘了。还有一些工具和技巧可辅助开发者的工作。我们在下一节中学习。
学习一些开发工具
有一些工具可缓解开发者的工作。本书曾介绍过用户界面的开发者模式,就是其中之一。也可以在服务端使用该选项来提供对开发者更友好的功能。下面会进行详细说明。然后我们会讨论如何对服务端代码进行调试。
服务端开发选项
Odoo服务提供一个--dev
选项用于开启开发者功能、加速开发流程,比如:
- 在发现插件模块中有异常时进入调试器。这通过配置调试器实现。默认为pdb。
- Python 文件保存时自动重新加载代码,避免反复手动重启服务。这可通过reload选项实现。
- 直接从 XML 文件中读取视图定义,无需手动更新模块。这可通过xml选项实现。
- 在网页中直接使用的Python调试界面。这通过werkzeug选项实现。
--dev
参数接收一个逗号分隔列表选项。可使用--dev=all
开启所有这些选项。
启用了调试器时,Odoo服务端默认使用的是 ,如果系统中安装了其它调试器也可以用其它选项。支持的调试器如下:
- ipdb:参见 https://pypi.org/project/ipdb 了解详情
- pudb:参见 https://pypi.org/project/pudb 了解详情
- wdb:参见https://pypi.org/project/wdb 了解详情
在编辑Python 代码时,每次代码修改都需重启服务来重新加载代码在Odoo中使用。--dev=reload
选项自动进行重新加载。启用后Odoo服务端会监测代码文件所做的修改,自动触发代码重新加载,让代码修改立即生效。
要正常运行,要求安装watchdog Python包,可通过如下命令来安装:
1 |
(env15) $ pip3 install watchdog |
--dev=all
选项也可启用重新加载,大多情况下都使用它:
1 |
(env15) $ odoo -c library.conf --dev=all |
注意这仅对 Python 代码的修改有益。对于其它修改,如模型数据结构,需要进行模块升级,仅仅重新加载是不够的。
调试
开发者的大部分工作都是调试代码。打断点再单步调试会很方便。
Odoo为运行Python代码等待客户端请求的服务端,请求由相应的服务端代码处理,然后对客户端返回响应。也就是Python代码的调试在服务端完成。断点在服务端中启用,暂停该行代码的服务端执行。因此,设置断点以及操作调试器都需要开发者在终端窗口中完成。
Python 调试器
最简单的调试工具是Python集成的调试器pdb。但其它选项的用户界面更加丰富,接近于高级IDE。
有两种方式触发调试器弹窗。
一种是抛出未处理的异常且启用了--dev=all
选项。调试器还会在导致异常的命令处停止代码执行。开发者可查看当前的变量和程序语句,来更好地了解背后的原因。
另一种是编辑代码拖动打断点,在需要执行暂停的地方加入如下行:
1 |
import pdb; pdb.set_trace() |
这不需要开启–dev模式。需要重新加载Odoo服务端来使用修改后的代码。在执行程序到达pdb.set_trace()命令时,会在服务端终端窗口显示一个(pdb) Python弹窗,等待输入。
(pdb) 弹窗以Python的shell运行,可在执行任意表达式或当前执行上下文的命令。也就是说可以查看甚至修改当前变量。
有一些与调试器相关的命令。最重要的命令如下:
- h (help) 显示可用 pdb 命令的汇总
- p (print) 运行并打印表达式
- pp (pretty print) 有助于打印数据结构,如字典或列表
- l (list) 列出下一步要执行代码的周边代码
- n (next) 进入下一条命令
- s (step) 进入当前命令
- c (continue)继续正常执行
- u (up) 在执行栈中上移
- d (down)在执行栈中下移
- bt (backtrace)显示当前执行栈
Python官方文档中包含了对pdb命令完整的描述。
示例调试会话
要理解如何使用调试器功能,我们来看看调试器会话长什么样。
在button_send()向导方法的第一行添加一个调试器断点,如下:
1 2 3 4 |
def button_send(self): import pdb; pdb.set_trace() self.ensure_one() # ... |
在重新执行服务加载后,打开Send Message向导表单,点击Send Messages按钮。这会在服务端触发button_send()方法,在断点会暂停。网页客户端会处于Loading…状态,等待服务端响应。
此时,服务端所运行的终端会显示类似如下信息:
1 2 3 |
> /home/vagrant/odoo-dev/custom-addons/library_checkout/wizard/checkout_mass_message.py(24)button_send() -> self.ensure_one() (Pdb) |
这是pdb调试器对话框,前两行提供有关Python代码执行暂停的相关信息。第一行显示文件、行号和函数名,第二行为下面要执行的代码。
小贴士:在调试会话中,服务端日志消息会乱入。大部分都来自werkzeug模块。可通过Odoo命令行的
--log-handler=werkzeug:WARNING
选项静默显示。另一个简化通用日志的选项是--log-level=warn
。
The p debug command prints out the result of an expression, while pp does the same but formats the output to be more readable, especially the dict and list data structures. For example, to print the value for the checkout_ids field that’s used in the wizard, type the following:
此时输入 h,可以看到可用命令的一个快速指南。输入 l 显示当前行代码,以及其周边的代码。
输入 n 会运行当前行代码并进入下一行。如果只按下 Enter,会重复上一条命令。
p调试命令会打印表达式结果,而pp效果桢,便输出格式可读性更强,对字典和列表数据结构尤其如此。例如,打印向导中使用的checkout_ids字段,输入如下命令:
1 2 |
(Pdb) p self.checkout_ids library.checkout(30,) |
调试对话框可运行Python命令和表达式。支持任意 Python 表达式,甚至是分配赋值。
在使用完成调试会话后,按c回到正常程序执行。有时可能希望中断执行,可按q退出。
我们可以逐行调试,在任意时刻按下 c 继续正常运行。
小贴士:在由调试器回到终端窗口时,如果终端不响应,在终端中的输入不显示。可通过reset命令解决输入<enter>reset<enter>。
其它 Python 调试器
pdb 的优势是“开箱即用”,它简单但粗暴,还有一些使用上更舒适的选择。
ipdb(Iron Python debugger)是一个常用的选择,它使用和 pdb 一样的命令,但做了一些改进,比如添加 tab 补全和语法高亮来让使用更舒适。可通过如下命令安装:
1 |
pip3 install ipdb |
使用如下命令添加断点:
1 |
import ipdb; ipdb.set_trace() |
另一个可选调试器是pudb,它也支持和pdb相同的命令,仅用在文本终端中,但使用了类似 IDE 调试器的图形化显示。当前上下文的变量及值这类有用信息,在屏幕上它自己的窗口中显示。
1 2 |
sudo apt-get install python-pudb # 使用Debian系统包 pip3 install pudb # 使用 pip,可在虚拟环境中 |
添加断点与pdb没什么分别:
1 |
import pudb; pudb.set_trace() |
也可以使用更短更简短的方式:
1 |
import pudb; pu.db |
上面的代码输起来更快,还达到了希望的效果-添加代码执行断点。
注:从Python 3.7开始,断点可使用breakpoint() 方法来代替pdb.set_trace()。调试库可重载breakpoint()的行为直接调用。但是,在写本书时,pudb和ipdb还没这么做,因此使用breakpoint()没有什么好处。
打印消息和日志
有时我们只需要查看一些变量的值或者检查一些代码段是否执行。Python的print()命令可以在不中断执行流的情况下完美解决这些问题。打印的内容发送至标准输出,不会存储到服务端的日志文件中。
print()函数仅用于辅助开发,不应出现在最终部署的代码中。如果print语句还可帮助查看生产中的问题,考虑将其换成调试级别的日志消息。
查看和关闭运行进程
还有一些查看 Odoo 运行中进程的小技巧。
首先我们需要找到相应的进程ID (PID)。在每条日志消息时间戳之后都打印该数字。另一种查找PID的方式是在另一个终端窗口中运行如下命令:
1 |
ps ax | grep odoo-bin |
示例输出如下:
1 2 |
2650 pts/5 S+ 0:00 grep --color=auto odoo 21688 pts/4 Sl+ 0:05 python3 /home/daniel/work15/env15/bin/odoo |
输出的第一列是进程的PID,上例中Odoo的进程PID为21688。
知道了进程PID后,可对Odoo服务端进程发送信息。使用kill命令发送信息。默认kill发送信号终止进程,但也可发送其它更友好的信号。
如果发送SIGQUIT或-3信号Odoo服务端会打印代码执行处的栈追踪:
1 |
kill -3 <PID> |
在发送SIGQUIT后,Odoo服务端日志会显示一个栈追踪记录。对于了解当前执行的代码很有用。这一信息为为每个所使用的纯种打印。
这用于一些代码性能分析中,追踪服务端时间消耗在何处,对代码执行性能分析。有关代码性能分析的资料可参见官方文档。
其它可向 Odoo 服务端进程发送的信号有:HUP重新加载服务,INT或TERM强制关闭服务,如下:
1 2 |
kill -HUP <PID> kill -TERM <PID> |
HUP信号对于不停止服务又重新加载Odoo配置尤为有用。
总结
本章中我们探讨了ORM API的各种功能以及以及如何使用这些功能来创建动态应用与用户互动,这可以帮助用户避免错误并自动化一些单调的任务。
模型验证和计算字段可以处理很多用例,但并不是所有的。我们学习了如何继承API的create, write和unlink 方法来处理更多用例。
对更丰富的用户交互,我们使用了 mail 内核插件 mixin 来为用户添加功能,方便他们围绕文档和活动规划进行交流。向导让应用可以与用户对话,收集所需数据来运行具体进程。异常允许应用终止错误操作,告知用户存在的问题并回滚中间的修改,保持系统的一致性。
我们还讨论了开发者可用于创建和维护应用的工具:记录日志消息、调试工具和单元测试。
在下一章中,我们还将使用 ORM,但会从外部应用的视角来操作,将 Odoo 服务端作为存储数据和运行业务进程的后端。
扩展阅读
以下是本文所讨论的内容相关参考材料: