在前面的文章中,我们学习了模型层、如何创建应用数据结构以及如何使用 ORM API 来存储查看数据。本文中我们将利用前面所学的模型和记录集知识实现应用中常用的业务逻辑模式。
本文的主要内容有:
- 以单据为中心工作流的阶段(stage)
- onchange方法,与用户即时交互
- 使用 ORM 内置方法,如create, write 和 unlink
- Mail 插件提供的消息和活动功能
- 创建向导来帮助用户执行复杂操作
- 使用日志消息优化系统监测
- 抛出异常以在出错时给用户反馈
- 使用单元测试来进行代码质量检查
- 开发工具,调试器等开发者工具
开发准备
本文中我们将创建一个依赖于之前文章创建的library_app和library_member模块的library_checkout插件模块。这些模块的代码请参见 GitHub 仓库。这两个插件模块都应放置在add-ons路径中(参见命令行–addons-path或~/.odoorc 配置文件中的addons_path),这样我们才能安装和使用。本文完成后的代码请见 GitHub 仓库。
学习项目 – library_checkout模块
在前面章节的学习中,我们为图书应用搭建了主数据结构。现在需要为图书会员添加借书的功能了。也就是说需要追踪图书是否可借以及归还的记录。每本书的借阅都有一个生命周期,从图书登记草稿到图书被归还。这是一个可通过看板视图表示的简单工作流,看板视图中每个阶段(stage)可展现为一列,工作项和借阅请求流从左侧列到右侧列,直至完成为止。
在本文中,我们集中学习实现这一功能的数据模型和业务逻辑。用户界面部分的详情将在第十章 Odoo 13开发之后台视图 – 设计用户界面和第十一章 Odoo 13开发之看板视图和用户端 QWeb中讨论。
图书借阅模型包含:
- 借阅图书的会员(必填)
- 借阅请求日期(默认为当天)
- 负责借阅请求的图书管理员(默认为当前用户)
- 借阅路线,包含请求借阅的一本或多本图书
要支持并存档借阅生命周期,需要添加如下内容:
- 请求的阶段:草稿、开放、借出、归还或取消
- 借阅日期,图书借出的日期
- 关闭日期,图书归还的日期
我们将开始创建一个新的模块library_checkout并实现图书借阅模型的初始版本。与此前章节相比此处并没有引入新的知识,用于提供一个基础供本文后续创建新功能。
在其它图书插件模块的同级路径下创建一个library_checkout目录:
1、首先添加manifest.py文件并加入如下内容:
{
'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',
],
}
2、在模块目录下创建init.py文件,并添加如下代码:
from . import models
3、创建models/init.py文件并添加:
from . import library_checkout
4、在models/library_checkout.py中添加如下代码:
from odoo import api, exceptions, fields, models
class 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.uid)
request_date = fields.Date(
default=lambda s: fields.Date.today())
line_ids = fields.One2many(
'library.checkout.line',
'checkout_id',
string='Borrowed Books',)
class CheckoutLine(models.Model):
_name = 'library.checkout.line'
_description = 'Borrow Request Line'
checkout_id = fields.Many2one('library.checkout')
book_id = fields.Many2one('library.book')
下面就要添加数据文件了,添加访问规则、菜单项和一些基础视图,这样模块可以最小化的运行起来。
5、添加security/ir.model.access.csv文件:
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,0
checkout_line_user,Checkout Line User ,model_library_checkout_line,library_app.library_group_user,1,1,1,1
checkout_manager,Checkout Manager,model_library_checkout,library_app.library_group_manager,1,1,1,1
6、菜项项通过views/library_menu.xml实现:
<odoo>
<act_window id="action_library_checkout"
name="Checkouts"
res_model="library.checkout"
view_mode="tree,form" />
<menuitem id="menu_library_checkout"
name="Checkout"
action="action_library_checkout"
parent="library_app.menu_library" />
</odoo>
7、视图通过views/checkout_view.xml文件实现:
<?xml version="1.0" ?>
<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" />
<field name="line_ids" />
</group>
</sheet>
</form>
</field>
</record>
</odoo>
现在就可以在我们的 Odoo 工作数据库中安装这个模块,并准备开始添加更多功能了。
~/odoo-dev/odoo/odoo-bin -d dev13 -i library_checkout
以文档为中心工作流的阶段(stage)
在 Odoo 中,我们可以实现以文档(document)为中心的工作流。我们这里说的文档包括销售订单、项目任务或人事申请。所有这些都遵循一个特定的生命周期,它们都在完成时才被创建。它们都被记录在一个文档中,按照一系列可能的阶段推进,直至完成。
如果把各阶段以列展示在面板中,把文档作为这些列中的工作项,就可以得到一个看板(Kanban),一个快速查看工作进度的视图。实现这些进度步骤有两种方法,通常称为状态和阶段。
-
状态通过预定义的闭合选项列表来实现。它便于实现业务规则,并且模型和视图对 state 字段有特别的支持,根据当前状态来带有必填和隐藏属性集。状态列表有一个劣势,就是它是预定义并且闭合的,因此无法按具体流程需求来做调整。
-
阶段通过关联模型实现,阶段列表是开放的,可被配置来满足当前流程需求。可以轻易地修改引用阶段列表:删除、添加或渲染这些阶段。它的劣势是对流程自动化不可靠,因为阶段列表可被修改,自动化规则就无法依赖于具体的阶段 ID 或描述。
获取两种方法优势的方式是将阶段映射到状态中。文档组织到可配置的阶段中,然后间接关联到对于自动化业务逻辑可靠的状态码中。我们将在library_checkout/models/library_checkout_stage.py文件中实现library.checkout.stage模型,代码如下:
from odoo import fields, models
class CheckoutStage(models.Model):
_name = 'library.checkout.stage'
_description = 'Checkout Stage'
_order = 'sequence,name'
name = fields.Char()
sequence = fields.Integer(default=10)
fold = fields.Boolean()
active = fields.Boolean(default=True)
state = fields.Selection(
[('new', 'New'),
('open', 'Borrowed'),
('done', 'Returned'),
('cancel', 'Cancelled')],
default='new',
)
这里我们可以看到 state 字段,允许每个阶段与四个基本状态映射。sequence字段很重要,要配置顺序,阶段应在看板和阶段选择列表中展示。fold 布尔字段是看板用于将一些列默认折叠,这样其内容就不会马上显示出来。折叠通常用于已完成或取消的阶段。新的代码一定不要忘记加入到models/__init__.py
文件中,当前内容为:
from . import library_checkout_stage
from . import library_checkout
下一步,我们需要向图书借阅模型添加阶段字段stage。编辑library_checkout/models/library_checkout.py文件,在 Checkout 类的最后面(line_ids 字段后)添加如下代码:
@api.model
def _default_stage(self):
Stage = self.env['library.checkout.stage']
return Stage.search([], limit=1)
@api.model
def _group_expand_stage_id(self, stages, domain, order):
return stages.search([], order=order)
stage_id = fields.Many2one(
'library.checkout.stage',
default=_default_stage,
group_expand='_group_expand_stage_id')
state = fields.Selection(related='stage_id.state')
stage_id是一个与阶段模型的 many-to-one关联。我们还添加了 state 字段,这是一个让阶段的 state 字段在当前模型中可用的关联字段,这样才能在视图中使用。阶段的默认值由_default_stage() 函数来计算,它返回阶段模型的第一条记录。因为阶段模型已通过 sequence 排序,所以返回的是 sequence 值最小的一条记录。
group_expand参数重载字段的分组方式,默认的分组操作行为是仅能看到使用过的阶段,而不带有借阅文档的阶段不会显示。在我们的例子中,我们想要不同的效果:我们要看到所有的阶段,哪怕它没有文档。_group_expand_stage_id() 帮助函数返回分组操作需使用组记录列表。本例中返回所有已有阶段,不论其中是否包含图书借阅记录。
ℹ️Odoo 10中的修改 group_expand字段在Odoo 10中引入,但在官方文档中没有介绍。使用示例在 Odoo 的源代码中可以找到,比如在 Project 应用中:GitHub 仓库。
既然我们添加了新模块,就应该在security/ir.model.access.csv文件中加入对应的安全权限,代码如下:
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
我们需要一组阶段来进行操作,所以下面来为模块添加默认数据。创建data/library_checkout_stage.xml文件并加入如下代码:
<odoo noupdate="1">
<record id="stage_10" model="library.checkout.stage">
<field name="name">Draft</field>
<field name="sequence">10</field>
<field name="state">new</field>
</record>
<record id="stage_20" model="library.checkout.stage">
<field name="name">Borrowed</field>
<field name="sequence">20</field>
<field name="state">open</field>
</record>
<record id="stage_90" model="library.checkout.stage">
<field name="name">Completed</field>
<field name="sequence">90</field>
<field name="state">done</field>
</record>
<record id="stage_95" model="library.checkout.stage">
<field name="name">Cacelled</field>
<field name="sequence">95</field>
<field name="state">cancel</field>
</record>
</odoo>
要使文件生效,需先在library_checkout/__manifest__.py
文件中添加该文件:
'data':[
...
'data/library_checkout_stage.xml',
],
onchange 方法
onchange由用户界面表单视图触发,当用户编辑指定字段值时,立即执行一段业务逻辑。这可用于执行验证,向用户显示消息或修改表单中的其它字段。支持该逻辑的方法就使用@api.onchange(‘fld1’, ‘fld2’, …)
装饰。装饰器的参数是用户界面通过编辑需触发方法的字段名。
小贴士:通过为字段添加属性on_change=”0″可在特定表单中关闭 on change 行为,比如
<field name=”fld1″ on_change=”0″ />
在方法内,self 参数是带有当前表单数据的一条虚拟记录。如果在记录上设置了值,就会在用户界面表单中被修改。注意它并没有向数据库实际写入记录,而是提供信息来修改 UI表单中的数据。无需返回信息,但可以返回一个字典结构的警告信息来显示在用户界面中。
作为示例,我们可以使用它来执行借阅表单中的部分自动化:在图书会员变更时,请求日期设置为当天,并且显示一个警告信息告知用户。下面我们就在library_checkout/models/library_checkout.py文件中添加如下代码:
@api.onchange('member_id')
def onchange_member_id(self):
today = fields.Date.today()
if self.request_date != today:
self.request_date = fields.Date.today()
return {
'warning':{
'title': 'Changed Request Date',
'message': 'Request date changed to today.'
}
}
通过用户界面修改member_id字段时,此处使用了@api.onchange装饰器来触发一些逻辑。实际方法不存在关联,但按照惯例名称应以onchange_开头,方法中我们更新了request_date的值并返回警告信息。在onchange方法内,self 表示一条虚拟记录,它包含当前正在编辑的记录的所有字段,我们可以与这些字段进行交互。大多数情况下我们想要根据修改字段设置的值自动在其它字段填充值。本例中,我们将request_date更新为当天。
onchange 方法无需返回任何值,但可以返回一个包含警告或作用域键的字典:
- 警告的键应描述显示在对话框中的消息,如{‘title’: ‘Message Title’, ‘message’: ‘Message Body’}
- 作用域键可设置或修改其它字段的域属性。通过让to-many字段仅展示在当下有意义的字段,会使得用户界面更加友好。作用域键类似这样:{‘user_id’: [(’email’, ‘!=’, False)]}
其它模型方法装饰器
以下装饰器也会经常使用到,它们与模型内部行为有关,在第六章 Odoo 13开发之模型 – 结构化应用数据中进行了详细讨论。罗列如下供您参考:
@api.depends(fld1,…)用于计算字段函数,来识别(重新)计算应触发什么样的修改。必须设置在计算字段值上,否则会报错。 @api.constrains(fld1,…)用于模型验证函数并在任意参数中包含的字段修改时执行检查。它不应向数据库写入修改,如检查失败,则抛出异常。
使用 ORM 内置方法
上一部分讨论的装饰器允许我们为模型添加一些功能,如实施验证或自动运算。
ORM 提供对模型数据执行增删改查(CRUD)操作的方法。下面我们来探讨如何扩展写操作来支持自定义逻辑。读取数据的主要方法search()和browse()在中第七章 Odoo 13开发之记录集 – 使用模型数据已进行讨论。
写入模型数据的方法
ORM 为三种基本写操作提供了三个方法,如下所示:
<Model>.create(values)
在模型上创建新记录,它返回所创建记录。 <Recordset>.write(values)
更新记录集中的字段值,它不返回值。 <Recordset>.unlink()
从数据库中删除记录,它不返回值。
values参数是一个字典,映射要写入的字段名和值。
ℹ️Odoo 12中的修改
create()现在也可批量创建数据,这通过把单个字典对象修改为字典对象列表来传参进行实现。这由带有@api.model_create_multi装饰器的create() 方法来进行支持。
有些情况下,我们需要扩展这些方法来添加一些业务逻辑,在这些操作执行时触发。通过将逻辑放到自定义方法的适当位置,我们可以让代码在主操作执行之前或之后运行。
我们将使用借阅模型类创建一个示例:添加两个日期字段来记录进入 open 状态的时间和进入 closed 状态的时间。这是计算字段所无法实现的,我们还将添加一个检查来阻止对已为 done 状态的创建借阅。
因此我们应在 Checkout 类中添加两个新字段,在library_checkout/models/library_checkout.py文件中添加如下代码:
checkout_date = fields.Date(readonly=True)
closed_date = fields.Date(readonly=True)
现在就可以创建自定义的create()方法来设置checkout_date了,如果状态正确则创建,而如果已经是完成状态则不予创建,代码如下:
@api.model
def create(self, vals):
# Code before create: should use the `vals` dict
if 'stage_id' in vals:
Stage = self.env['library.checkout.stage']
new_state = Stage.browse(vals['stage_id']).state
if new_state == 'open':
vals['checkout_date'] = fields.Date.today()
new_record = super().create(vals)
# Code after create: can use the `new_record` created
if new_record.state == 'done':
raise exceptions.UserError(
'Not allowed to create a checkout in the done state.')
return new_record
注意在实际新记录创建之前,不存在其它记录,仅带有用于创建记录的值的字典。这也就是我们使用browse()来获取新记录stage_id的原因,然后对值进行相应的检查。作为对比,一旦创建了新记录,相应的操作就变简单了,使用对象的点号标记即可:new_record.state。在执行super().create(vals)命令之前可以对值字典进行修改,我们使用它在状态合适的情况下写入checkout_date。
ℹ️Odoo 11中的修改 Python 3中有一种super()的简写方式,我们上例中使用的就是这种方式。而在 Python 2中则写成super(Checkout, self).create(vals),其中 Checkout 为代码所在的 Python 类名。在 Python 3这种语法仍然可用,但同时带有简写语法:super().create(vals)。
修改记录时,如果订阅进入的是合适的状态我们需要更新checkout_date和closed_date。实现这一功能需要使用自定义的write() 方法,代码如下:
def write(self, vals):
# Code before write: can use `self`, with the old values
if 'stage_id' in vals:
Stage = self.env['library.checkout.stage']
new_state = Stage.browse(vals['stage_id']).state
if new_state == 'open' and self.state != 'open':
vals['checkout_date'] = fields.Date.today()
if new_state == 'done' and self.state != 'done':
vals['closed_date'] = fields.Date.today()
super().write(vals)
# Code after write: can use `self`, with the updated values
return True
我们一般会尽量在super().write(vals)之前修改写入的值。如果write()方法在同一模型中有其它的写操作,会导致递归循环,它在工作进程资源耗尽后结束并报错。请考虑是否需要这么做,如果需要,避免递归循环的一个技巧是在上下文中添加一个标记。作为示例,我们添加类似如下代码:
if not self.env.context.get('_library_checkout_writing'):
self.with_context(_library_checkout_writing=True).write(some_values)
通过这个技巧,具体的逻辑受到 if 语句的保护,仅在上下文中出现指定标记时才会运行。再深入一步,self.write()操作应使用with_context来设置标记。这种组合确保 if 语句中自定义登录(login)只执行一次,并且不会触发更多的write()调用,避免进入无限循环。
在write()内运行write()方法会导致无限循环。要避免这一循环,我们需要在上下文中设置标记值来在代码中进行检查避免进入循环。
应仔细考虑是否需要对create或write方法进行扩展。大多数情况下我们只需要在保存记录时执行一些验证或自动计算某些值:
对于根据其它字段自动计算的字段值,我们应使用计算字段。这样的例子有在各行值修改时对头部汇总的计算。
要使字段默认值动态计算,我们可以将字段赋值的默认值修改为一个函数绑定。
要让字段根据其它字段的修改来设置值,我们可以使用 onchange 函数。举个例子,在选定客户时,将用户的币种设置为文档的币种,但随后可由用户手动修改。记住 onchange 仅用于表单视图的交互,不直接进行写入调用。
对于验证,我们应使用由@api.constraints(fld1,fld2,…)装饰的约束函数。这和计算字段相似,但不同处在于它会抛出错误。
数据导入、导出方法
导入、导出操作在第五章 Odoo 13开发之导入、导出以及模块数据已做讨论,也可以通过 ORM API 中的如下方法操作:
- load([fields], [data]) 用于导入从 CSV 文件中获取的数据。第一个参数是导入的字段列表,与 CSV 的第一行对应。第二个参数是记录列表,每条记录是一个待解析和导入的字符串列表,与 CSV 数据中的行和列直接对应。它实现了 CSV 数据导入的功能,比如对外部标识符的支持。它用于网页客户端的导入函数。
- export_data([fields], raw_data=False)用于网页客户端导出函数。它返回一个字典,带有包含数据(一个行列表)的数据键。字段名可使用 CSV 文件使用的.id和/id后缀,数据格式与需导入的 CSV 文件兼容。可选raw_data参数让数据值与 Python 类型一同导出,而不是 CSV 文件中的字符串形式。
用户界面的支持方法
以下方法最常用于网页客户端中渲染用户界面和执行基础交互:
- name_get()返回一个表示每条记录的文本的元组(ID, name)列表。它默认用于计算display_name值,来提供关联字段的文本表示。可扩展它来实现自定义的显示方式,如将仅显示名称改为显示记录编号和名称。
- name_search(name=”, args=None, operator=’ilike’, limit=100)返回一个元组(ID, name)列表,其显示名与 name 参数的文本相匹配。它用于 UI 中,在关联字段中通过输入来生成带有匹配所输入文本推荐记录的列表。例如,它可用于在挑选产品的字段中输入时,实现通过名称和引用来查找产品。
- name_create(name)创建一条仅带有要使用的标题名的新记录。它用于在 UI 中快速创建(quick-create)功能,这里我们可以仅提供名称快速创建一条关联记录。可扩展来为通过此功能创建的新记录提供指定默认值。
- default_get([fields])返回一个带有要创建的新记录默认值的字典。默认值可使用变量,如当前用户或会话上下文。
- fields_get()用于描述模型字段的定义,在开发者菜单的View Fields选项中也可以看到。
- fields_view_get()在网页客户端中用于获取要渲染的 UI视图的结构。可传入视图的 ID或想要使用的视图类型(view_type=’form’)作为参数。例如可使用self.fields_view_get(view_type=’tree’)。
消息和活动(activity)功能
Odoo 自带全局的消息和活动规划功能,由 Discuss 应用提供,技术名称为 mail。mail 模块提供包含mail.thread抽象类,它让在任意模型中添加消息功能都变得很简单。还提供mail.activity.mixin用于添加规划活动功能。在第四章 Odoo 13 开发之模块继承中已讲解了如何从 mixin 抽象类中继承功能。
要添加这些功能,我们需要在library_checkout中先添加对 mail 的依赖,然后在图书借阅模型中继承抽象类中提供的这些功能。编辑library_checkout/manifest.py文件,在 depends 键下添加 mail 模块:
'depends': ['library_member', 'mail'],
然后编辑library_checkout/models/library_checkout.py文件来继承 mixin 抽象模型,代码如下:
class Checkout(models.Model):
_name = 'library.checkout'
_description = 'Checkout Request'
_inherit = ['mail.thread', 'mail.activity.mixin']
然后我们的模型就会添加三个新字段,每条记录(有时也称文档)都包含:
- mail_follower_ids:存储 followers 和相应的通知首选项
- mail_message_ids:列出所有包含关联活动规划的关联messages.activity_id
follower 可以是伙伴(partner)或频道(channel)。partner表示一个具体的人或组织,频道不是具体的人,而是体现为订阅列表。每个follower还有一个他们订阅的消息类型列表,仅有已选消息类型才会发送通知。
消息子类型
一些消息类型称为子类型,它们存储在mail.message.subtype模型中,可通过Settings > Technical > Email > Subtypes菜单访问。默认我们有如下三种消息子类型:
- Discussions:带有mail.mt_comment XML ID,用于创建带有Send message链接的消息,默认会发送通知。
- Activities:带有mail.mt_activities XML ID,用于创建带有Schedule activity链接的消息,默认不会发送通知。
- Note:带有mail.mt_note XML ID,用于创建带有Log note链接的消息,默认不会发送通知。
子类型默认通知设置如上所述,但用户可以就具体文档来进行调整,比如关闭他们不感兴趣的讨论的通知。除内置子类型之外,我们还可以添加自己的子类型并在应用中自定义通知。子类型既可以是通用的也可以只针对具体模型。对于后者,我们应将其所作用的模型名填入子类型的res_model字段中。
发送消息
我们的业务逻辑可利用这个消息系统来向用户发送通知。可使用message_post() 方法来发送通知,示例如下:
self.message_post('Hello!')
这会添加一个普通文本消息,但不会向follower发送通知。这是因为默认由mail.mt_note子类型发送消息。但我们可以通过指定的子类型来发送消息。要添加一条向follower发送通知的消息,应使用mt_comment子类型。另一个可选属性是消息标题,使用这两项的示例如下:
self.message_post('Hello again!', subject='Hello', subtype='mail.mt_comment')
消息体是HTML格式的,所以我们可以添加标记来实现文本效果,如<b>
为加粗,<i>
为斜体。
ℹ️出于安全原因消息体会被清洗,所以有些 HTML 元素可能最终无法出现在消息中。
添加 follower
从业务逻辑角度来看还有一个有意思的功能:可以向文档添加 follower,这样他们可以获取相应的通知。我们有以下几种方法来添加 follower:
message_subscribe(partner_ids=<整型 id 列表>)
添加伙伴message_subscribe(channel_ids=<整型 id 列表>)
添加频道message_subscribe_users(user_ids=<整型 id 列表>)
添加用户
默认的子类型会作用于每个订阅者。强制订阅指定的子类型列表,可添加subtype_ids=<整型 id 列表>属性,来列出在订阅中使用指定子类型。
创建向导
假定我们的图书馆用户需要向一组借阅者发送消息。比如他们可选择某本书最早的借阅者,向他们发送消息要求归还图书。这可通过向导来实现。向导是接受用户输入的一系列表单,然后使用输入来做进一步操作。
我们的用户开始从借阅列表中选择待使用的记录,然后从视图顶级菜单中选择 wizard 选项。这会打开向导表单,可填入消息主题和内容。一旦点击 Send 就将会向所有已选借阅者发送消息。
向导模型
向导对用户显示为一个表单视图,通常是一个对话窗口,可填入一些字段。这些字段会随后在向导逻辑中使用。这通过普通视图同样的模型/视图结构实现,但支持的模型继承的是models.TransientMode而不是models.Model。这种类型的模型也会在数据库体现并存储状态,但数据仅在向导完成操作前有用。定时 job 会定期清除向导数据表中的老数据。
我们将使用wizard/checkout_mass_message.py 文件来定义与用户交互的字段:通知的订阅者列表,标题和消息体。
首先编辑library_checkout/__init__.py
文件并导入wizard/子目录:
from . import models
from . import wizard
添加wizard/__init__.py
文件并加入如下代码:
from . import checkout_mass_message
然后创建实际的wizard/checkout_mass_message.py文件,内容如下:
from odoo import api, exceptions, fields, models
class 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()
值得注意的是普通模型中的one-to-many关联不能在临时模型中使用。这是因为那样就会要求普通模型中添加与临时模型的反向many-to-one关联。但这是不允许的,因为那样普通记录的已有引用会阻止对老的临时记录的清除。替代方案是使用many-to-many关联。
ℹ️Many-to-many关联存储在独立的表中,会在关联任意一方被删除时自动删除表中对应行。
临时模型无需安全规则 ,因为它们是用于辅助执行的一次性记录。那么也就不需要添加ecurity/ir.model.access.csv权限控制列表文件。
向导表单
向导表单视图与普通模型相同,只是它有两个特定元素:
- 可使用元素来替换操作按钮
- special=”cancel”按钮用于中断向导,不执行任何操作
wizard/checkout_mass_message_wizard.xml文件的内容如下:
<?xml version="1.0"?>
<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 Message" />
<button special="cancel"
string="Cancel"
class="btn-secondary" />
</footer>
</form>
</field>
</record>
<act_window id="action_checkout_message"
name="Send Messages"
binding_model="library.checkout"
res_model="library.checkout.massmessage"
view_mode="form"
target="new"
/>
</odoo>
XML 中的窗口操作使用src_model属性向图书借阅的Action按钮添加了一个选项。target=”new”属性让它以对话窗口形式打开。打开向导,我们可以从借阅列表中选择一条或多条记录,然后从Action菜单中选择 Send Messages 选项,Action 菜单显示在列表顶部的Filters菜单旁。
现在这会打开向导表单,但从列表中所选的记录会被忽略。如果能在向导中任务列表中显示预选的记录会很棒。表单会调用default_get() 方法来计算要展示的默认值,这正是我们需要的功能。注意在打开向导表单时,有一条空记录并且还没有使用create()方法,该方法仅在点击按钮时才会触发,所以暂不能满足我们的需求。
Odoo 视图向上下文字典添加一些元素,可在点击操作或跳到其它视图时使用。它们分别是:
- active_model:带有视图模型的技术名
- active_id:带有表单活跃记录或表中第一条记录的 ID
- active_ids:带有一个列表中活跃记录的列表(如果是表单则只有一个元素)
- active_domain:如果在表单视图中触发了该操作
本例中,active_ids中保存任务列表中所选记录的 ID,可使用这些 ID 作为向导task_ids字段的默认值,相关代码如下(izard/checkout_mass_message.py):
@api.model
def default_get(self, field_names):
defaults = super().default_get(field_names)
checkout_ids = self.env.context.get('active_ids')
defaults['checkout_ids'] = checkout_ids
return defaults
我们首先使用了super()来调用标准的default_get()运算,然后向默认值添加了一个checkout__id,而active_ids值从环境下文中读取。
下面我们需要实现点击表单中Send按钮的操作。
向导业务逻辑 除了无需进行任何操作仅仅关闭表单的 Cancel 按钮外,我们还有一个Send按钮的操作需要实现。该按钮调用的方法为button_send,需要在wizard/checkout_mass_message.py文件中使用如下代码定义:
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='mail.mt_comment',
)
return True
我们的代码一次仅需处理一个向导实例,所以这里通过self.ensure_one()以示清晰。这里的 self 表示向导表单里显示的数据。以上方法遍历已选借阅记录并向其中的每个借阅者发送消息。这里使用mt_comment子类型,因此会向每个 follower 发送消息通知。
ℹ️让方法至少返回一个 True 值是一个很好的编程实践。主要是因为有些XML-RPC协议不支持 None 值,所以对于这些协议就用不了那些方法了。在实际工作中,我们可能不会遇到这个问题,因为网页客户端使用JSON-RPC而不是XML-RPC,但这仍是一个可遵循的良好实践。
使用日志消息
向日志文件写入消息有助于监控和审计运行的系统。它还有助于代码维护,在无需修改代码的情况下可以从运行的进程中轻松获取调试信息。要让我们的代码能使用日志功能,首先要准备一个日志记录器(logger),在library_checkout/wizard/checkout_mass_message.py文件的头部添加如下代码:
import logging
_logger = logging.getLogger(__name__)
这里使用了 Python标准库logging模块。_logger通过当前代码文件名name来进行初始化。这样日志信息就会带有生成日志文件的信息。有以下几种级别的日志信息:
- _logger.debug(‘DEBUG调试消息’)
- _logger.info(‘INFO信息日志’)
- _logger.warning(‘WARNING警告消息’)
- _logger.error(‘ERROR错误消息’)
现在就可以使用logger向日志中写入消息了,让我们为button_send向导方法来添加日志。在文件最后的return True前添加如下代码:
_logger.info(
'Posted %d messages to Checkouts: %s',
len(self.checkout_ids),
str(self.checkout_ids),
)
这样在使用向导发送消息时,服务器日志中会出现类似如下消息:
INFO dev13 odoo.addons.library_checkout.wizard.checkout_mass_message: Posted 1 messages to Checkouts: library.checkout(30,)
注意我们没有在日志消息中使用 Python 内插字符串。我们没使用_logger.info(‘Hello %s’ % ‘World’),而是使用了类似_logger.info(‘Hello %s’, ‘World’)。不使用内插使我们的代码少执行一个任务,让日志记录更为高效。因此我们应一直为额外的日志参数传入变量。
ℹ️服务器日志的时间戳总是使用 UTC 时间。因此打印的日志消息中也是 UTC 时间。你可能会觉得意外 ,但 Odoo服务内部都是使用 UTC 来处理日期的。
对于调试级别日志,我们使用_logger.debug()。例如,可以在checkout.message_post() 命令后添加如下调试日志消息:
_logger.debug(
'Message on %d to followers: %s',
checkout.id,
checkout.message_follower_ids)
这不会在服务器日志中显示任何消息,因为默认的日志级别是INFO。需要将日志级别设置为DEBUG才会输出调试日志消息。
DEBUG dev13 odoo.api: call library.checkout(30,).read(['request_date', 'member_id', 'checkout_date', 'stage_id'])
Odoo 命令行选项–log-level=debug可用于设置通用日志级别。我们还可以对指定模块设置日志级别。我们的向导的 Python 模块是odoo.addons.library_checkout.wizard.checkout_mass_message,这在 INFO 日志消息中也可以看到。要开启向导的调试消息,使用–loghandler 选项,该选项还可重复多次来对多个模块设置日志级别,示例如下:
--loghandler=odoo.addons.library_checkout.wizard.checkout_mass_message:DEBUG
有关 Odoo 服务器日志选项的完整手册可参见官方文档。如果想要了解原始的 Python 日志细节,可参见Python 官方文档。
抛出异常
在操作和预期不一致时,我们可能需要通知用户并中断程序,显示错误信息。这可通过抛出异常来实现。Odoo 中提供了一些异常类供我们使用。插件模块中最常用的 Odoo 异常有:
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()方法,在self.ensure_one()一行后加入如下代码:
if not self.checkout_ids:
raise exceptions.UserError(
'请至少选择一条借阅记录来发送消息!')
if not self.message_body:
raise exceptions.UserError(
'请填写要发送的消息体!')
补充:经测试发现消息体不填内容并不会抛出异常,因为默认的会发送<p><br></p>
这段 html 标签
单元测试
自动化测试是广泛接受的软件开发最佳实践。不仅可以帮助我们确保代码正确实施,更重要的为我们未来的代码修改和重写提供了一个安全保障。对于 Python 这样的动态编程语言,因为没有编译这一步,语法错误经常不容易注意到。这也使得单元测试愈发重要,覆盖的代码行数越多越好。
以上两个目标是我们编写测试时的灯塔。测试的第一个目标应是提供更好的测试覆盖:设置测试用例运行所有代码行。单单这个就会为第二个目标迈出很大一步:显示代码有无功能性错误,因为在这之后,我们一定可以很好地开始为不显著的使用特例添加测试用例。
ℹ️Odoo 12中的修改 在该版本之前,Odoo 还支持通过 YAML格式的数据文件进行测试。Odoo 12中删除了YAML数据文件引擎,不再支持该格式,有关该格式的最后一个文档请见官方网站。
添加单元测试
Python 测试文件添加在模块的tests/子目录下,测试执行器会自动在该目录下查找测试文件。为测试library_checkout模块向导逻辑,我们可以创建tests/test_checkout_mass_message.py,老规矩,需要添加tests/init.py文件,内容如下:
from . import test_checkout_mass_message
tests/test_checkout_mass_message.py代码的基础框架如下:
from odoo.tests.common import TransactionCase
class TestWizard(TransactionCase):
def setUp(self, *args, **kwargs):
super(TestWizard, self).setUp(*args, **kwargs)
# Add test setup code here...
def test_button_send(self):
"""Send button should create messages on Checkouts"""
# Add test code
Odoo 提供了一些供测试使用的类:
- TransactionCase测试为每个测试使用不同的事务,在测试结束时自动回滚。
- SingleTransactionCase将所有测试放在一个事务中运行,在最后一条测试结束后才进行回滚。在每条测试的最终状态需作为下一条测试的初始状态时这会非常有用。
setUp()方法用于准备数据以及待使用的变量。通常我们将数据和变量存放在类属性中,这样就可在测试方法中进行使用。测试应使用类方法实现,如test_button_send()。测试用例方法名必须以test_为前缀。这些方法被自动发现,该前缀就是用于辨别是否为实施测试用例的方法。根据测试方法名的顺序来运行。
在使用TransactionCase类时,在每个测试用例运行完后都会进行回滚。在测试运行时会显示方法的文档字符串(docstring),因此可以使用它来作为所执行测试的简短描述。
ℹ️这些测试类是对Python 标准库中unittest测试用例的封装。有关unittest详细内容,请参见官方文档。
运行测试
下面就来运行已书写的测试。我们仅需在安装或升级(-i或-u)模块时在 Odoo 服务启动命令中添加– test-enable选项即可。具体命令如下:
~/odoo-dev/odoo/odoo-bin --test-enable -u library_checkout --stop-after-init
仅在安装或升级模块时才会运行测试,这也就是为会什么添加了-u 选项。如果需要安装一些依赖,它的测试也会运行。想要避免这一情况,可以像平常那样测试安装模块,然后在升级(-u)模块时运行测试。以上测试中实际没有做任何测试,但应该可以正常运行。仔细查看服务器日志可看到报告测试运行的INFO信息,例如:
INFO dev13 odoo.modules.module: odoo.addons.library_checkout.tests.test_checkout_mass_message running tests.
配置测试
我们应开始在setUp方法中准备测试中将使用的数据。这里我们要创建一条在向导中使用的借阅记录。使用指定用户执行测试操作会很便捷,这样可以同时测试权限控制是否正常配置。这通过sudo(<user>)
模型方法来实现。记录集中携带这一信息,因此在使用 sudo()创建后,相同记录集后续的操作都会使用相同上下文执行。以下是setUp方法中的代码:
class TestWizard(TransactionCase):
def setUp(self, *args, **kwargs):
super(TestWizard, self).setUp(*args, **kwargs)
# Setup test data
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({'name': 'John'})
self.checkout0 = self.Checkout.create({
'member_id': a_member.id})
此时我们就可以在测试中使用self.checkout0记录和self.Wizard模型了。
编写测试用例
现在让我们来扩展一下初始框架中的test_button_test()方法吧。最简单的测试是运行测试对象中的部分代码,获取结果,然后使用断言语句来与预期结果进行对比。
要测试发送消息的方法,测试计算向导运行前后的消息条数来确定有没有增加新消息。要运行向导,需要在上下文中设置active_ids,像 UI 表单一样,创建带有填写向导表单(至少是消息体)的向导记录,然后运行button_send方法。完整代码如下:
def test_button_send(self):
"""Send button should create messages on Checkouts"""
# Add test code
msgs_before = len(self.checkout0.message_ids)
Wizard0 = self.Wizard.with_context(active_ids=self.checkout0.ids)
wizard0 = Wizard0.create({'message_body': 'Hello'})
wizard0.button_send()
msgs_after = len(self.checkout0.message_ids)
self.assertEqual(
msgs_after,
msgs_before+1,
'Expected on additional message in the Checkout.')
这一检测在self.assertEqual语句中验证测试成功还是失败。它对比运行向导前后的消息数,预期会比运行前多一条消息。最后一个参数在测试失败时作为信息提示,它是可选项,但推荐使用。
assertEqual方法仅是断言方法的一种,我们应根据具体用例选择合适的断言方法,这样才更易于理解导致测试错误的原因。单元测试文档提供对所有这些方法的说明,参见 Python 官方文档。
要添加新的测试用例,在类中添加另一个实现方法。要记住TransactionCase测试,每次测试结束后都会回滚。因此,前一次测试的操作会被撤销,我需要重新打开向导表单。然后模拟用户填写消息内容,执行消息发送。最后检测消息条数来进行验证。
测试异常
有时我们需要测试来检查是否生成了异常,常用的情况是测试是否正确地进行了验证。本例中,我们可以测试向导的一些验证。例如,我们可以测试空消息体抛出错误。要检查是否抛出异常,我们将相应代码放在self.assertRaises()代码块中。
首先在文件顶部导入 Odoo 的异常类:
from odoo import exceptions
然后,在测试类中添加含有测试用例的另一个方法:
def test_button_send_empty_body(self):
"Send button errors on empty body message"
wizard0 = self.Wizard.create({})
with self.assertRaises(exceptions.UserError) as e:
wizard0.button_send()
如果button_send()没有抛出异常,则检测失败。如果抛出了异常,检测成功并将异常存储在 e 变量中,我们可以使用它来做进一步的检测。
开发工具
开发者应学习一些技巧有协助开发工作。本系列曾介绍过用户界面的开发者模式。也可以在服务端使用该选项来提供对开发者更友好的功能。这一部分就来进行详细说明。然后我们会讨论另一个开发者相关话题:如何对服务端代码进行调试。
服务端开发选项
Odoo服务提供一个–dev选项来开启开发者功能、加速开发流程,比如:
- 在发现插件模块中有异常时进入调试器
- Python 文件保存时自动重新加载代码,避免反复手动重启服务
- 直接从 XML 文件中读取视图定义,无需手动更新模块
–dev参数接收一个逗号分隔列表选项,通常 all 选项可适用大多数情况。我们可以指定想要用的调试器。默认使用Python 调试器pdb,有些人可能喜欢安装使用其它调试器,Odoo 对ipdb和pudb都予以支持。
ℹ️Odoo 9中的修改 Odoo 9之前的版本中,可使用–debug 选项来对某一模块异常打开调试器。从Odoo 9开始不再支持改选项,改用–dev=all选项了。
在使用 Python 代码时,每次代码修改都需重启服务来重新加载代码。–dev命令选项会处理重新加载,在服务监测到 Python 代码被修改时,自动重复服务加载序列,让代码修改立即生效。使用它仅需在服务命令后添加–dev=all 选项:
~/odoo-dev/odoo/odoo-bin --dev=all
要正常运行,要求安装watchdog Python包,可通过 pip 命令来执行安装:
pip install watchdog
注意这仅对 Python 代码和 XML 文件中视图结构的修改有益。对于其它修改,如模型数据结构,需要进行模块升级,仅仅重新加载是不够的。
调试
我们都知道开发者的大部分工作是调试代码。我们通常使用代码编辑器打断点,运行程序来进行单步调试。如果使用 Windows 系统来开发,配置可运行 Odoo 源码的环境可不是个简单的工作。 Odoo是一个等待客户端调用的服务,然后才进行操作,这一事实让 Odoo 的调试与客户端程序截然不同。
Python 调试器
对于初学者可能有点高山仰止的感觉,最实际的方法是使用 Pyhton 集成的调试器pdb来对 Odoo 进行调试。我们会介绍它的扩展,会提供丰富的用户界面,类似于高级 IDE那样。
要使用调试器,最好的方法是在需要查看的代码(通常是模型方法)处插入断点。这通过在具体位置添加如下行来实现:
import pdb; pdb.set_trace()
现在重启服务来加载修改代码。一旦程序运行到该行,服务运行窗口就会进入一个(pdb)Python 命令对话框,等待输入。这个对话框和 Python shell 一样,你可以输入当前执行上下文的任何表达式或命令来运行。这意味着可以检查甚至修改当前变量,以下是最常用的快捷命令:
- h (help) 显示可用 pdb 命令的汇总
- p (print) 运行并打印表达式
- pp (pretty print) 有助于打印数据结构,如字典或列表
- l (list) 列出下一步要执行的代码及周边代码
- n (next) 进入下一条命令
- s (step) 进入当前命令
- c (continue)继续正常执行
- u (up) 在执行栈中上移
- d (down)在执行栈中下移
- bt (backtrace)显示当前执行栈
如果启动服务时使用了dev=all选项,抛出异常时服务在对应行进行后验模式。这是一个pdb对话框,和前述的一样,允许我们检查在发现错误那一刻的程序状态。
示例调试会话
让我们来看看一个简单调试会长什么样。可以在button_send向导方法的第一行添加调试器断点:
def button_send(self):
import pdb; pdb.set_trace()
self.ensure_one()
...
现在重启服务,打开一个发送消息向导表单并点击 Send 按钮。这会触发服务器上的button_send ,客户端会保持在Still loading…的状态,等待服务端响应。查看运行服务的终端窗口,可看到类似如下信息:
> /home/vagrant/odoo-dev/custom-addons/library_checkout/wizard/checkout_mass_message.py(24)button_send()
-> self.ensure_one()
(Pdb)
这是pdb调试器对话框,第一行告诉我们 Python 代码执行的位置以及所在的函数名,第二行是要运行的下一行代码。在调试会话中,可能会跳出一些日志消息。这对于调试没有伤害,但会打扰到我们。可以通过减少日志输出来避免这一情况。大多数据情况下日志消息都来自werkzeug模块。我们可通过–log-handler=werkzeug:CRITICAL 选项来停止日志输出。如果这还不够,可以使用–log-level=warn来降低通用日志级别。另一种方法是启用–logfile=/path/to/log选项,这样会将日志消息从标准输出重定向到文件中。
小贴士:如果终端不响应,在终端中的输入不被显示,这可能与终端会话的显示问题有关,通过输入
<enter>reset<enter>
有可能解决这一问题。
此时输入 h,可以看到可用命令的一个快速指南。输入 l 显示当前行代码,以及其周边的代码。输入 n 会运行当前行代码并进入下一行。如果只按下 Enter,会重复上一条命令。执行三次应该就可以进入方法的 return 语句。我们可以查看任意变量或属性的内容,如向导中使用的checkout_ids字段:
(Pdb) p self.checkout_ids
library.checkout(30,)
它允许使用任何 Python 表达式,甚至是分配变量。我们可以逐行调试,在任意时刻按下 c 继续正常运行。
其它 Python 调试器
pdb 的优势是“开箱即用”,它简单但粗暴,还有一些使用上更舒适的选择。
ipdb(Iron Python debugger)是一个常用的选择,它使用和 pdb 一样的命令,但做了一些改进,比如添加 Tab 补全和语法高亮来让使用更舒适。可通过如下命令安装:
sudo pip install ipdb
使用如下行添加断点:
*import ipdb; ipdb.set_trace()
另一个可选调试器是pudb,它也支持和pdb相同的命令,仅用在文本终端中,但使用了类似 IDE 调试器的图形化显示。当前上下文的变量及值这类有用信息,在屏幕上它自己的窗口中显示。可通过系统包管理器或 pip 来进行安装:
sudo apt-get install python-pudb # 使用Debian系统包
sudo pip install pudb # 使用 pip,可在虚拟环境中
添加pudb断点和其它调试器没什么分别:
import pudb; pudb.set_trace()
也可以使用更短更易于记忆的方式:
import pudb; pu.db
打印消息和日志
有时我们只需要查看一些变量的值或者检查一些代码段是否被执行。Python的print()函数可以在不中断执行流的情况下完美解决这些问题。因为我们在服务器窗口中运行,打印的内容会显示在标准输出中,但如果日志是写入文件的打印内容不会存储到服务器日志中。
print()仅用于开发辅助,不应出现最终部署的代码中。如果你可能需要代码执行的更多细节,请使用debug 级别日志消息。在代码敏感点添加调试级别日志消息让我们可以在部署实例中调查问题。只需将服务器日志级别提升为 debug,然后查看日志文件。
查看和关闭运行进程
还有一些查看 Odoo 运行进程的小技巧。首先我们需要找到相应的进程ID (PID),要找到 PID,再打开一个终端窗口并输入如下命令:
ps ax | grep odoo-bin
输入的第一列是进程的PID,记录下要查看进程的 PID,在下面会使用到。现在,我们要向进程发送信号。使用的命令是 kill,默认它发送一个终止进程的信号,但也可以发送其它更友好的信号。知道了运行中的 Odoo 服务进程 PID,我们可以通过向进程发送SIGQUIT信号打印正在执行的代码踪迹,命令如下:
kill -3 <PID>
然后如果我们查看终端窗口或写入服务输出的日志文件,就可以看到正常运行的一些线程的信息,以及它们运行所在行代码的细节栈踪迹。这用于一些代码性能分析中,追踪服务时间消耗在何处,来将代码执行性能归类。有关代码profiling的资料可参见官方文档。其它可向 Odoo 服务器进程发送的信号有:HUP来重新加载服务,INT或TERM强制服务关闭:
kill -HUP <PID>
kill -TERM <PID>
总结
我们详细解释了ORM API 的功能,以及如何使用这些功能来创建动态应用与用户互动,这可以帮助用户避免错误并自动化一些单调的任务。
模型验证和计算字段可以处理很多用例,但并不是所有的。我们学习了如何继承API的create, write和unlink 方法来处理更多用例。
对更丰富的用户交互,我们使用了 mail 内核插件 mixin 来为用户添加功能,方便他们围绕文档和活动规则进行交流。向导让应用可以与用户对话,收集所需数据来运行具体进程。异常允许应用终止错误操作,通知用户存在的问题并回滚中间的修改,保持系统的一致性。
我们还讨论了开发者可使用来创建和维护应用的工具:记录日志消息、调试工具和单元测试。
在下一篇文章中,我们还将使用 ORM,但会从外部应用的视角来操作,将 Odoo 服务器作为存储数据和运行业务进程的后端。
☞☞☞ 第九章 Odoo 13开发之外部 API – 集成第三方系统