Alan Hou的个人博客

用行动赢得尊重

Alan Hou的个人博客

本文为最好用的免费ERP系统Odoo 12开发手册系列文章第十篇。

本文将学习如何为用户创建图形化界面来与图书应用交互。我们将了解不同视图类型和小组件(widgets)之间的差别,以及如何使用它们来提供更优的用户体验。

本文主要内容有:

  • 菜单项
  • 窗口操作(Window Actions)
  • 表单视图结构
  • 字段
  • 按钮和智能按钮
  • 动态视图元素
  • 列表视图
  • 搜索视图
  • 其它视图类型

开发准备

我们将继续使用library_checkout插件模块,它已经有了模型层,现在需要视图层来实现用户界面。本文中的代码基于第八章 Odoo 12开发之业务逻辑 - 业务流程的支持,相关代码请参见 GitHub 仓库,本章完成后代码也请参见 GitHub仓库

菜单项

用户界面的入口是菜单项,菜单项形成一个层级结构,最顶级项为应用,其下一级为每个应用的主菜单。还可以添加更深的子菜单。可操作菜单与窗口操作关联,它告诉客户端在点击了菜单项后应执行什么操作。

菜单项存储在ir.ui.menu模型中,可通过Settings > Technical > User Interface > Menu Items菜单进行查看。

library_app模块为图书创建了一个顶级菜单,library_checkout插件模块添加了借阅和借阅阶段的菜单项。在library_checkout/views/library_menu.xml文件中,借阅的菜单项 XML 代码如下:

1
2
3
4
<menuitem id="menu_library_checkout"
name="Checkout"
action="action_library_checkout"
parent="library_app.menu_library" />

这里有一个快捷元素,提供了一种定义菜单项的简写方式,比原生的元素要更为便捷。以上使用的属性有:

  • name是展示在用户界面中的菜单项标题
  • action是点击菜单项时运行的窗口操作的XML ID
  • parent是父级菜单项的XML ID。本例中父级项由其它模块创建,因此们使用了完整的XML ID, .进行引用。

还有以下可用属性:

  • sequence设置一个数字来在展示菜单项时进行排序,如sequence=”10”
  • groups是一个逗号分隔的可访问菜单项安全组的XML ID列表,如groups=”library_app.library_group_user, library_app.library_group_manager”
  • web_icon是菜单项的图标,仅用于企业版的顶级菜单项,如web_icon=”library_app,static/description/icon.png”

窗口操作(Window Actions)

窗口操作给 GUI(图形化用户界面)客户端操作指令,通常用于菜单项或视图中的按钮。它告诉 GUI 所作用的模型以及要显示的视图。这些操作可以通过域过滤器过滤出可用记录,设置默认值以及从上下文属性中过滤。窗口操作存储在ir.actions.act_window模型中,可通过Settings > Technical > Actions > Window Actions菜单进行查看。

在library_checkout/views/library_menu.xml文件中,我们可以找到借阅菜单项中使用的窗口操作,我们需要对其进行修改来启用本文中将添加的视图类型:

1
2
3
4
<act_window id="action_library_checkout"
name="Checkouts"
res_model="library.checkout"
view_mode="tree,form,activity,calendar,graph,pivot" />

窗口操作通常像以上这样使用快捷标签创建。这里修改”tree, form”为更大的列表”tree, form, activity, calendar, graph, pivot”。以上使用的窗口操作属性有:

  • name是通过操作打开的视图中显示的标题
  • res_model是目标模型的标识符
  • view_mode是一个逗号分隔的可用视图类型列表。第一项为默认打开时的视图。

窗口操作还有一些其它属性:

  • target:如果设置为 new,会在弹出的对话框窗口中打开视图,例如target=”new”。默认值是current,在主内容区行内打开视图。
  • context:为目标视图设置上下文信息,可设置默认值或启用过滤器等,例如context=”{‘default_user_id’: uid}”。
  • domain:是对可在打开视图中浏览的记录强制过滤的域表达式,例如domain=”[(‘user_id’, ‘=’, uid)]”。
  • limit:列表视图中每页显示的记录数,例如limit=”80”。

做了这些修改后,在选择Checkouts菜单项并浏览相应的列表视图时,右上角在列表和表单按钮后会增加一些按钮。但在我们创建对应视图前并不能使用,本文将一一学习。窗口操作还可在列表和表单视图的上方的 Action 菜单按钮中使用,它在 Fitlers 按钮旁。要使用这个,我们需要在元素中添加以下两个属性:

  • src_model设置Action所作用的模型,例如src_model=”library.checkout”
  • multi=”true”也启用列表视图中的Action,这样它可以作用于多个已选记录。否则仅在表单视图中可用,并且一次只能应用于一条记录。

补充:此时打开借阅表单会提示Insufficient fields for Calendar View!,在编写日历视图前最好选视图模式里删除 calendar 来进行效果查看

表单视图结构

表单视图要么按照简单布局,要么按与纸质文档相似的业务文档布局。我们将学习如何设计这些业务文档布局以及使用可用的元素和组件。要进行这一学习,我们重新查看并扩展第八章 Odoo 12开发之业务逻辑 - 业务流程的支持中创建的图书借阅表单。

业务文档视图

业务应用中记录的很多数据可以按纸质文档那样展示。表单视图可模仿这些纸质文档来提供更直观的用户界面。例如,在我们的应用中,可以把一次借阅看作填写一张纸,我们将编写一个遵循这一设计的表单视图。编辑library_checkout/views/chceckout_view.xml文件并修改表单视图记录来带有业务文档视图的基本框架:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    <record id="view_form_checkout" model="ir.ui.view">
<field name="model">library.checkout</field>
<field name="arch" type="xml">
<form>
<header>
<!--以下仅供查看效果使用-->
<field name="state" widget="statusbar" clickable="True" />
</header>
<sheet>
...
</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>

视图名称是可选的,在不写时会自动生成。为简便以上利用了这一点,在视图记录中省略了元素。可以看到业务文件视图通常使用三大区域:

  • header状态栏
  • sheet主内容
  • 底部交流区,也称作chatter

底部的交流区使用了 mail 插件模块中提供的社交网络组件。可使用这些,我们的模型需要继承mail.thread和mail.activity.mixin,可参见第八章 Odoo 12开发之业务逻辑 - 业务流程的支持

Odoo 12文档视图

头部 Header

头部header 通常用于文档所走过的生命周期或步骤,还包含相关的操作按钮。这些按钮是普通表单按钮,最重要的下一步可以高亮显示。

头部按钮

编辑表单视图中的

版块,我们添加一个按钮来更易于设置归还的借阅为完成(done):

1
2
3
4
5
6
7
8
<header>
<field name="state" invisible="True" />
<button name="button_done"
string="Return Books"
attrs="{'invisible':
[('state', 'in', ['new', 'done'])]}"
class="oe_highlight" />
</header>

这里我们在头部添加了一个Return Books 按钮,在点击时调用button_done模型方法。注意可使用class=”oe_highlight”来对用户高亮显示操作。例如,在有几个可选按钮时,我们可以高亮显示主操作或下一步要执行的“更多”操作。attrs用于在 New 和 Done 状态时隐藏该按钮。实现这点的条件使用了不会在表单显示的 state 字段。要使条件生效,我们需要将使用的所有值在网页客户端中加载。我们不打算向终端用户显示 state 字段,因此使用 invisible 将其添加为不可见字段。

ℹ️domain 或 attrs 表达式中使用的字段必须在视图中加载,作用于它们的元素。如果字段不对用户可见,则必须以不可见字段元素对其进行加载。

本例中我们使用的是 state 字段,相同的效果可通过 states 字段属性实现。虽然没有 attrs 属性灵活,但它更为精简。可将 attrs 一段替换为如下代码:

1
2
3
4
5
<button name="button_done"
type="object"
string="Returned"
states="open,cancel"
class="oe_highlight" />

attrs和states元素可见功能也可用于其它视图元素,如 field。本文后续会深入讨论。要让按钮可以运作,我们还需要实现调用的方法。在library_checkout/models/library_checkout.py file文件的借阅类里添加以下方法:

1
2
3
4
5
6
7
8
def button_done(self):
Stage = self.env['library.checkout.stage']
done_stage = Stage.search(
[('state', '=', 'done')],
limit=1)
for checkout in self:
checkout.stage_id = done_stage
return True

该方法首先查找 done 阶段的记录来使用,然后对 self 记录集中的每条记录,设置其 stage_id 值为完成阶段。

Odoo 12高亮显示按钮

阶段管道

下面我们为头部添加状态条组件,显示文档所在阶段。从代码层面说,是使用statusbar组件的stage_id字段的元素:

1
2
3
4
5
6
7
                <header>
...
<field name="stage_id"
widget="statusbar"
clickable="True"
options="{'fold_field': 'fold'}" />
</header>

这会在头部添加一个阶段管道组件,它在表示文档当前所在生命周期点的字段上使用了statusbar组件。通常是一个状态选项字段或阶段many-to-one字段。这两类字段在 Odoo 核心模块中多次出现。clickable属性让用户可通过点击状态条来修改文档阶段。一般需要开启它,但有时又不需要,比如需要对工作进行更强的控制,并且要求用户仅使用可用的操作按钮来进入下一步。这种方法允许在切换阶段时进行指定验证。

对阶段使用状态条组件时,我们可将很少使用的阶段隐藏(折叠)在 More 阶段组中。对应的阶段模型必须要有一个标记来配置需隐藏的阶段,通常命名为 fold。然后statusbar组件使用 options 属性来将这一字段名提供给fold_field选项,如以上代码所示。

Odoo 12阶段折叠

使用状态代替阶段

阶段是一个使用了模型来设置进度步骤的many-to-one字段。因此终端用户可对其动态配置来符合他们具体的业务流程以及支持看板的完美展示。我们将在图书借阅中使用到state。

状态是一个包含了流程中相当稳定步骤的选择列表,如新建、处理中和完成。终端用户无法对其进行配置,因为它是静态的,更易于在业务逻辑中使用。视图字段对状态甚至还有特别的支持:状态字段属性仅在记录处理特定状态才对用户开放。

ℹ️阶段引入的时间要晚于状态。两者现在共存,在 Odoo 内核的趋势是使用阶段来替代状态。但如前所述,状态仍提供一些阶段所不具备的功能。

可通过将阶段映射到状态中来同时获得两者的优势。在借阅模型中我们通过向借阅阶段中添加一个状态字段来实现,借阅文档通过一个关联字段来使用状态。使用状态代替阶段的模型中,我们也可以使用进度条管道。这种情况下要在进度条中列出状态,需要使用statusbar_visible属性来替换fold_field选项。具体代码如下:

1
2
3
4
<field name="state"
widget="statusbar"
clickable="True"
statusbar_visible="draft,open,done" />

注意在我们实际的图书借阅项目中并不能这么使用,因为它是阶段驱动的,而非状态驱动。

Odoo 12状态

文档表单

表单画布是表单的主区域,这里放置实际的数据元素,设计上类似一张真实的纸质文档,通常 Odoo 中的这些记录也会被称为文档。通常文档表单结构包含如下区域:

  • 左上角文档标题和副标题
  • 右上角按钮区
  • 其它文档头部字段
  • 底部笔记区,将附加字段组织成选项卡或页面

文档各行通常在笔记区的第一页,在表单之后,通常有一个 chatter 组件,带有文档订阅者、讨论消息和活动规划。下面逐一了解这些区域。

补充:关于sheet的翻译Alan的理解sheet 仅为单(据),但出于行文习惯一律使用表单

标题和副标题

一个元素之外的字段不会自动带有渲染它们的标签。对于标题元素就是如此,因此该元素应用来对其进行渲染。虽然要花费额外的工作量,但这样的好处是对标签显示控制有更好的灵活性。常规 HTML,包括 CSS 样式元素,可用于美化标题。一般标题放在oe_title类中。以下为扩展后的元素,它包含标题以及一些额外字段如副标题:

1
2
3
4
5
6
7
8
9
10
11
12
13
<sheet>
<field name="member_image" widget="image" class="oe_avatar" />
<div class="oe_title">
<label for="member_id" class="oe_edit_only" />
<h1><field name="member_id" /></h1>
<h3>
<span class="oe_read_only">By </span>
<label for="user_id" class="oe_edit_only" />
<field name="user_id" class="oe_inline" />
</h3>
</div>
<!-- More elements will be added from here... -->
</sheet>

此处可以看到我们使用了div, span, h1和h3这些常规 HTML 元素。

我们还可在表单左上角标题旁包含展示图像。它用在 parnter 或产品这类模型的表单视图中。作为示例,我们在标题区前添加了一个member_image字段,它使用图像组件widget=”image”,以及特定的 CSS 类class=”oe_avatar”。该字段尚未添加至模型中,下面我们就来添加,我们使用关联字段来将会员的图片显示在借阅文档中。编辑library_checkout/models/library_checkout.py文件并在借阅类中添加如下字段:

1
member_image = fields.Binary(related='member_id.partner_id.image')

Odoo 12图片-标题

表单内容分组

表单主内容区应通过标签来进行组织。标签在画布中插入了两列。默认在这些列中标签会在字段旁显示,因此又占据两列。字段加标签会占据 一行,下一个字段和标签又会另起一行,垂直排列。Odoo表单的常见布局是带标签的字段并排成两列。达到这一效果,我们只需要添加两个嵌入顶部的标签。

继续修改表单视图,在主内容区标题

后添加如下代码:

1
2
3
4
5
6
7
8
9
10
<group name="group_top">
<group name="group_col1">
<field name="user_id" />
<field name="checkout_date" />
</group>
<group name="group_col2">
<field name="state" />
<field name="closed_date" />
</group>
</group>

为 group 标签分配name是一个好的编码实践,这样在其它模块中继承时会更易于对它们进行引用。还可设置 string 属性,一旦设置将作为该部分的标题来显示。

ℹ️Odoo 11中的修改
string 属性不能作为继承的锚点,因为在应用继承前会对其进行翻译。这时应使用 name 属性来代替它。

在 group 内,元素会强制在新的一行,下一个元素会渲染到组的第一列。附加的版块标题可通过组内元素添加,如果带有 string 属性也会显示标题标签。要更好地控制元素布局,我们可以使用col和colspan属性。

col 属性可用于元素中来自定义包含的列数。如前所述,默认为两列,但可修改为任意其它数字。双数效果更佳,因为默认每个添加的字段会占据两列:字段标签和字段值。按照以下代码我们通过colspan=”2” 来在一个组内将4个字段放在两列中显示:

1
2
3
4
5
6
7
8
9
10
11
12
<group name="group_top">
<group name="group_col1"
col="4"
colspan="2"
string="Group 1">
<field name="user_id" />
<field name="state" />
<field name="checkout_date" />
<field name="closed_date" />
</group>
<group name="group_col2" string="Group2" />
</group>

以上我们使用 string 属性为组添加了标题,来更清楚地看组所在位置。注意字段的顺序不同,它们先是从左到右,然后从上到下。元素可以使用 colspan 属性来设置它所占用的具体列数。默认和带标签的字段一样为两列。可以修改以上代码中 col 和 colspan 的值来在表单中查看不同的效果。比如 col=”6” colspan=”4”的效果是什么样的?可以试一试(见下图)。

Odoo 12 colspan 示例

选项卡笔记本(Tabbed notebooks)

另一种组织内容的方式是 notebook 元素,一个包含多个称为页面(page)的选项卡分区的容器。它们可以让不常用的内容在不使用时隐藏起来,或者用于按话题组织大量字段。

我们将在借阅表单中添加一个带有已借图书列表的notebook 元素。在前面的元素后可添加如下代码:

1
2
3
4
5
<notebook>
<page string="Borrowed Books" name="page_lines">
<field name="line_ids" />
</page>
</notebook>

本例中笔记本仅有一个页面。添加更多,我们需在

元素内添加更多的版块。页面画布默认不会渲染字段标签,如需显示,需像表单主画布那样将字段放在版块内。本例中我们在页面中添加了one-to-many字段line_ids,我们已经有了页面标题,因此不需要标签。page支持以下属性:

  • string:选项卡的标题(必填)
  • attrs:不可见属性与表达式映射的字典
  • accesskey:HTML访问密钥

Odoo 12选项卡笔记本

字段

视图字段有一些可用属性。大部分从模型定义中获取值,但可在视图中覆盖。以下来快速查看字段的可用属性:

  • name标识字段数据库中名称
  • string用于想要覆盖模型中标签文本的标签文本
  • help是鼠标悬停在字段上显示的提示文本,它允许我们覆盖模型定义中提供的帮助文本
  • placeholder是在字段中显示的提示文本
  • widget让我们可以覆盖字段的默认组件,一会儿我们就会讲到可用的组件
  • options是一个带有组件附加数据的JSON数据结构,值随各组件的不同支持而不同
  • class是用于字段 HTML 渲染的CSS类
  • nolabel=”True”阻止自动字段标签的展示。仅对元素内的字段有作用,通常与
  • invisible=”True”让字段不可见,但仍会从服务端获取数据并可在表单中使用
  • readonly=”True”让表单中该字段不可编辑
  • required=”True”让表单中该字段为必填

一些特定字段的属性如下:

  • password=”True”用于文本字段。显示为密码项,隐藏所输入文字
  • filename用于二进制字段,它是用于存储上传文件名的模型字段的名称

字段标签

1
<label for="name" class="oe_edit_only" />

这么做时,如果字段在元素内部,我们通常还要对其设置nolabel=”True”。class=”oe_edit_only”可用于应用 CSS 样式,让标签仅在编辑模式下可见。

字段组件

每个字段类型都会使用相应的默认组件在表单中显示。但还有一些替代组件可以使用。对于文本字段,有如下组件:

  • email用于让 email 文本成为可操作的”mail-to”地址
  • url用于将文本格式化为可点击的URL
  • html用于将文本渲染为HTML内容;在编辑模式下,它显示为一个WYSIWYG(所见即所得)编辑器,可在不使用 HTML 代码的情况下格式化内容。

对于数字字段,有以下组件:

  • handle在列表视图中作为一个排序字段,显示一个句柄来让我们可以拖放进行自定义排序
  • float_time将一个浮点型字段格式化为带有小时和分钟的值
  • monetary将一个浮点型字段显示为货币金额。它与currency_id字段一起使用,还可以通过options=”{‘currency_field’: ‘currency_id’}”来使用另一个字段名
  • progressbar将一个浮点值显示为进度条百分比,有助于将字段展示为完成率
  • percentage和percentpie组件可用于浮点型字段

对于关联和选择项字段,有以下附加组件:

  • many2many_tags将值显示为按钮标签列表
  • many2many_checkboxes将选项值显示为一个复选框列表
  • selection对many-to-one字段使用选择字段组件
  • radio以单选按钮显示选择字段选项
  • priority将选项字段显示为一个可点击星形列表。选择项目通常是数值。
  • state_selection将看板状态选择列表显示为信号灯。普通状态显示为灰色,完成显示为绿色,其它状态显示为红色。
  • pdf_viewer是一个二进制字段(在 Odoo 12中引入)。

ℹ️Odoo 11中的修改
state_selection在 Odoo11中引入来替换掉kanban_state_selection。后者被淘汰,但为保持向后兼容性,还支持使用。

关联字段

在关联字段中,我们可让用户操作做一些额外控制。默认用户从这些字段中创建新记录(也称作“快速创建”)并打开关联记录表单。可通过options字段属性来关闭:

1
options="{'no_open': True, 'no_create': True}"

context和domain也是字段属性并对于关联字段特别有用。context可定义关联字记录默认值,domain 可限制可选记录。常见的示例为让一个字段依赖其它字段值来产生选择项。domain可在模型中直接定义,但也可在视图中进行覆盖。

在to-many字段中,我们还可使用 mode 属性来更改用于显示记录的视图类型。默认为 tree,但还有其它选项:form, kanban或graph。关联字段可定义行内指定视图来使用。这些视图在元素中的嵌套视图定义中声明。例如,在line_ids借阅中,我们可以为这些线路定义特定的列表和表单视图:

1
2
3
4
5
6
7
8
9
10
11
12
<notebook>
<page string="Borrowed Books" name="page_lines">
<field name="line_ids">
<tree>
<field name="book_id" />
</tree>
<!--form>
<field name="book_id" />
</form-->
</field>
</page>
</notebook>

线路列表将带有给定的定义。当我们与线路交互时,弹出一个表单对话框,在

定义中包含该结构。

小贴士: 如果想要在列表视图的表单弹出窗口中直接编辑one-to-many路线,应使用

Odoo 12内联列表、表单视图

按钮

按钮支持这些属性:

  • string是按钮文本标签或使用图标时的 HTML alt 文本

  • type是执行操作的类型,有以下值:

    • object用于调用 Python 方法
    • action用于运行窗口操作
  • name标识按所选类型要操作的具体的操作,要么是模型方法名,要么是要运行的窗口操作的数据库 ID。可使用%(xmlid)d方程式来将XML ID转换成加载视图时所需的数据库 ID。

  • args在类型为 object 时用于向方法传递额外的参数,须是在形成方法调用参数的记录 ID 之后所添加的纯静态 JSON 参数。

  • context在上下文中添加值,可在窗口操作或 Python 代码方法调用之后产生效果。

  • confirm在运行相关操作之前显示确认消息框,显示的内容是属性中分配的文本。special=”cancel”用于向导表单。

  • icon是按钮所显示的图标。可用的按钮来自Font Awesome图标集,版本为4.7.0,应通过对应的 CSS 类来指定,如icon=”fa-question”。更多信息可访问Font Awesome

ℹ️Odoo 11中的修改
在 Odoo 11之前,按钮图标是来自GTK客户端库的图片,并且仅限于addons/web/static/src/img/icons中所保存图片。

ℹ️Odoo 11中的修改
在 Odoo 11中工作流引擎被淘汰并删除。此前的版本中,在支持工作流的地方,按钮可通过type=”workflow”来触发工作流引擎信号。这时name属性用于工作流的信号名。

智能按钮

在右上角版块中带有智能按钮(smart button)也很常见。智能按钮显示为带有数据指示的矩形,在点击时可进入。

Odoo 中使用的 UI样式是在放置智能按钮的地方带有一个隐藏框,按钮框通常是的第一个元素,在

元素前(以及头像),类似这样:

1
2
3
<div name="button_box" class="oe_button_box">
<!-- Smart buttons will go here... -->
</div>

按钮的容器是一个带有oe_button_box类的 div 元素。在 Odoo 11.0以前,可能需要添加一个oe_right类来确保按钮框在表单中右对齐。在我们的应用中,我们将在按钮中显示图书会员待归还的其它借阅的总数,点击按钮会进入这些项的列表中。

所以我们需要该会员处于 open 状态的借阅记录,排除掉当前借阅。对于按钮统计,我们应创建一个计算字段来在library_checkout/models/library_checkout.py文件的借阅类中进行计数:

1
2
3
4
5
6
7
8
9
10
num_other_checkouts = fields.Integer(
compute='_compute_num_other_checkouts')

def _compute_num_other_checkouts(self):
for rec in self:
domain = [
('member_id', '=', rec.member_id.id),
('state', 'in', ['open']),
('id', '!=', rec.id)]
rec.num_other_checkouts = self.search_count(domain)

下一步我们可以添加按钮框并在其中添加按钮。在版块的上方,替换上面的按钮框占位符为以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
<div name="button_box" class="oe_button_box">
<button class="oe_stat_button"
icon="fa-tasks"
help="Other checkouts pending return."
type="action"
name="%(action_other_checkouts_button)d"
context="{'default_member_id': member_id}">
<field string="To Return"
name="num_other_checkouts"
widget="statinfo" />
</button>
</div>

按钮元素本身是一个带有显示数据字段的容器。这些数据是使用statinfo特定组件的普通字段。该字段通常是作用于模型中定义的计算字段。除字段外,在按钮中还可以使用静态文本,如

Other Checkouts
。其它待借阅的数量展示在按钮定义中的num_other借阅字段中。

智能按钮必须带有class=”oe_stat_button” CSS样式,并应使用 icon 属性来带有一个图标。它有一个type=”action”,表示点击按钮时将运行通过 name 属性标识的窗口操作。%(action_other_checkouts_button)d表达式返回要运行的操作的数据库 ID。

在点击按钮时,我们要查看当前会员的其它借阅列表。这可通过action_other_checkouts_button窗口操作来实现。该操作会使用合适的域过滤器打开一个图书借阅列表。操作和相应的域过滤器在表单上下文之外处理,无法访问表单数据。因此按钮必须在上下文中设置当前member_id 来供窗口操作随后使用。使用的窗口操作必须在表单之前定义,因此我们应在 XML 文件根元素中的最上方添加以下代码:

1
2
3
4
5
6
7
<act_window id="action_other_checkouts_button"
name="Open Other Checkouts"
res_model="library.checkout"
view_mode="tree,form"
domain="[('member_id', '=', default_member_id),
('state', 'in', ['open']),
('id', '!=', active_id)]"/>

注意我们在域过滤器中如何使用default_member_id上下文键。该键还会点击按钮链接创建新任务时为member_id字段设置默认值。域过滤器也需要当前 ID。这无需在上下文中明确设置,因为网页客户端会在active_id上下文键中自动进行设置。

Odoo 12智能按钮

以下是可在智能按钮中添加的属性,供您参考:

  • class=”oe_stat_button”渲染的不是普通按钮而是一个矩形
  • icon从Font Awesome图标集中选择图标来使用。访问Font Awesome查看有哪些图标。
  • type和name是按钮类型以及触发的操作名。对于智能按钮,类型通常是 action,指定窗口操作,名称为所要执行操作的 ID。应传入真实数据库 ID,因此我们要使用方程式来将XML ID转换为数据库 ID:”%(actionxmlid)d”。这一操作应该会打开带有关联记录的视图。
  • string为按钮添加标签文本,这里没有使用因为所包含的字段中已经提供了文本。
  • context应用于为目标视图设置默认值,用于点击按钮后视图上新建的记录。
  • help在鼠标悬停在按钮上显示帮助提示信息

动态视图元素

视图元素还支持一些允许视图按字段值动态变更外观或行为的属性。我们可以有onchange 事件来在编辑表单数据时修改其它字段值,或在满足特定条件时让字段为必填或显示。

onchange 事件

onchange机制允许我在某一特定字段变更时修改其它表单字段。例如一个商品字段的 onchange可以在商品被修改时设置价格字段为默认值。在老版本中,onchange 事件在视图级别定义,但8.0之后直接在模型层中定义,无需在视图上做任何特定标记。这通过使用@api.onchange(‘field1’, ‘field2’, …) 装饰器创建模型,来对一些字段绑定 onchange 逻辑。onchange 模型方法在第八章 Odoo 12开发之业务逻辑 - 业务流程的支持中详细讨论过,其中还有相关示例。

onchange 机制还可以在用户输入时即时反馈进行计算字段的自动重算。继续使用商品来举例,如果在修改商品时价格字段变化了,它还会根据新的价格自动更新计算后的总金额字段。

动态属性

一些属性允许我们根据记录的值来动态变更视图元素的显示。指定用户界面元素的可见性可通过如下属性很方便地控制:

  • groups可根据当前用户所属安全组来让元素可见。仅指定组的成员可看到该元素。它的值应为一个逗号分隔的XML ID列表
  • states可根据记录的状态字段来让元素可见。它的值为一个逗号分隔的状态列表,仅对带有state 字段的模型生效。

除这些以外,我们有一些灵活的方法来根据客户端动态生成的表达式设置元素可见性。它是一个特别属性 attrs,它的值为一个映射invisible属性值与表达式结果的字典。例如,要让closed_date字段在new和open状态时不可见,可使用如下代码:

1
2
<field name="closed_date"
attrs="{'invisible':[('state', 'in', ['new', 'open'])]}"/>

invisible不只在字段中可用,在任意元素中均可用。例如,它可用于 notebook 页面和group元素中。attrs属性也可为其它两个属性设置值:readonly和required。它们仅对数据字段有意义,通过二者来让字段可编辑或为必填。这让我们可以实现一些基础客户端逻辑,如根据其它字段值(如 state)来让字段设为必填。

列表视图

学到这里可能不太需要介绍列表视图了,但它还一些有趣的额外属性可以讨论。下面我们修改library_checkout/views/checkout_view.xml文件来改进第八章 Odoo 12开发之业务逻辑 - 业务流程的支持中的版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<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
decoration-muted="state in ['done', 'cancel']"
decoration-bf="state=='open'">
<field name="state" invisible="True" />
<field name="request_date" />
<field name="member_id" />
<field name="checkout_date" />
<field name="stage_id" />
<field name="num_books" sum="# Books" />
</tree>
</field>
</record>

行文本颜色和字体可根据 Python 表达式计算结果来动态变化。这通过decoration–NAME属性带上计算字段属性的表达式来实现。NAME可以是bf或it,分别表示粗体和斜体,也可以是其它Bootstrap文本上下文颜色:danger, info, muted, primary, success或warning。Bootstrap文档中有相关显示示例。

ℹ️Odoo 9中的修改
decoration-NAME 属性在 Odoo 9中引入。在 Odoo 8中使用是 colors 和 fonts 属性。

记住表达式中使用的字段必须要在字段中声明,这样网页客户端才知道要从服务端获取该列。如果不想对用户显示,应对其使用invisible=”1”属性。其它 tree 元素的相关属性有:

  • default_order让我们可以覆盖模型中的默认排序,它的值和模型中定义的排序格式相同。
  • create, delete和edit,如果设为 false(字母小写),会禁用列表视图中的相应操作。
  • editable让记录在列表视图中可直接被编辑。可用值有 top 和 bottom,表示新记录添加的位置。

列表视图可包含字段和按钮,表单中的大部分属性对它们也有效。在列表视图中,数值字段可显示为对应列的汇总值。为字段添加一个累加属性(sum, avg, min或max)会为其分配汇总值的标签文本。我们在 num_books 字段中添加了一个示例:

1
<field name="num_books" sum="# Books" /

num_books字段计算每个借阅中的图书数量,它是一个计算字段,我们需要在模型进行添加:

1
2
3
4
5
6
num_books = fields.Integer(compute='_compute_num_books')

@api.depends('line_ids')
def _compute_num_books(self):
for book in self:
book.num_books = len(book.line_ids)

Odoo 12列表视图数量累加

搜索视图

可用的搜索选项通过视图类型来定义。我们可以选择在搜索框中输入时自动搜索的字段。还可以预置过滤器,通过点击启用,以及在列表视图中的预置分组选项。图书借阅的搜索视图可设置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<record id="view_filter_checkout" model="ir.ui.view">
<field name="model">library.checkout</field>
<field name="arch" type="xml">
<search>
<field name="member_id" />
<field name="user_id" />
<filter name="filter_not_done"
string="To Return"
domain="[('state','=','open')]" />
<filter name="filter_my_checkouts"
string="My Checkouts"
domain="['user_id', '=', uid]" />
<filter name="group_user"
string="By Member"
context="{'group_by': 'member_id'}" />
</search>
</field>
</record>

视图定义中,可以看到两个member_id和user_id的简单元素,当用户在搜索框中输入时,推荐下拉框中会显示对这些字段的匹配。然后有两个使用域过滤器的预置过滤器。可在搜索框下方的 Filter 按钮下选择。第一个过滤器是 To Return 图书,也就还处于 open 状态的图书。第二个过滤器是当前图书管理员处理的图书,通过当前用户的 user_id (可在上下文的 uid 键中获取)过滤。

这两个过滤器可以分别被启用并以 OR运算符连接。以元素分隔的整块过滤器以 AND 运算符连接。

第三个过滤器仅设置 group by 上下文键,它让视图按照字段来对记录分组,本例中为 member_id 字段。

字段元素可使用如下属性:

  • name标识要使用的字段
  • string用作标签文本,它会替换默认值
  • operator用于修改默认的运算符(默认值:数值字段=,其它字段类型ilike)
  • filter_domain设置搜索使用的特定域表达式,为 operator 属性提供一种灵活的替代方式。搜索文本在表达式中通过 self 引用。一个简单示例:filter_domain=”[(‘name’, ‘ilike’, self)]”
  • groups让对该字段的搜索仅向安全组内成员开发,它的值是一个逗号分隔的XML ID列表

过滤元素有以下可用属性:

  • name用作后续继承/扩展或通过窗口操作启用的标识符。这不是必填项,但包含该属性是一个不错的编码习惯。
  • string是过滤器显示的标签文本,必填
  • domain是加入当前域的域表达式
  • context是加入当前上下文的上下文字典。通常使用group_id作为键,用于对记录分组的字段名作为值
  • groups让该字段的搜索仅对安全组列表(XML IDs)成员开放

Odoo 12搜索过滤

其它视图类型

表单、列表和搜索视图是最常用的视图类型。但还有一些其它的视图类型可用于设计用户界面。对于前述三种基本视图类型我们已经很熟悉了,在第十一章 Odoo 12开发之看板视图和用户端 QWeb中将详细介绍看板视图,它会将记录可视化为卡片形式,甚至会按列组织为看板。下面我们将学习一些其它视图类型:

  • activity将计划活动显示为有组织的汇总
  • calendar基于所选日期字段以日历格式展示数据
  • diagram展示记录间的关系,当前不在 Odoo 中使用

以下两种视图类型用于显示累加数据:

  • graph用于图表展示
  • pivot用于交互的数据透视表

还有更多的视图类型,但仅在 Odoo 企业版中可用。因为我们整个系列的文章是基于社区版的,所以无法为这些视图提供示例:

  • dashboard使用透视表和图表这类子视图展示累加数据
  • cohort用于显示在不同时期数据如何变化
  • gantt以甘特图显示日期计划信息,常用于项目管理
  • grid通过行和列网格组织数据进行展示

官方文档中提供了对所有视图和可用属性很好的参考,这里就不再重复。我们集中于提供一些基础使用示例,这样可以对这些视图入门。这样应该可以提供一个很好的基础,然后可进一步探索每个视图的所有功能。

小贴士: 可通过社区插件模块查看其它视图类型。OCA 管理的网页客户端插件请见 GitHub 仓库。例如,web_timeline模块提供了一个时间线视图类型,也可像甘特图那样展示计划信息,它是社区版的 gantt 视图类型。

活动视图

活动视图类型是内置的计划活动汇总板,帮助用于可视化活动任务。由 mail 模块提供,因此需要先安装该模块才能使用这一视图类型。要使用这一类型,只需在窗口操作的 view_code 属性的视图列表中添加活动视图类型即可。实际的视图定义会自动生成,我们也可以手动进行添加,唯一的选项是修改 string 属性,但在UI 中并不使用。

作为参考,活动视图的定义类似这样:

1
<activity string="Activities"/>

日历视图

从名称可以看出,该视图类型在日历中展示记录,可通过不同时间区间浏览:按月、按周或按日。以下是我们图书借阅的日历视图,根据请求日期在日历上显示各项:

1
2
3
4
5
6
7
8
9
10
<record id="view_calendar_checkout" model="ir.ui.view">
<field name="model">library.checkout</field>
<field name="arch" type="xml">
<calendar date_start="request_date"
color="user_id">
<field name="member_id" />
<field name="stage_id" />
</calendar>
</field>
</record>

补充:请记得在菜单xml文件中加回前文删除的 calendar 类型

基础的日历属性有:

  • date_start是开始日期字段(必填)
  • date_end是结束日期字段(可选)
  • date_delay是天数字段,用于代替date_end
  • all_day传入一个布尔字段名,用于标识全天活动。这类活动会忽略时长。
  • color用于为一组日历项添加颜色。每个不同值都会被分配一种颜色,它的所有项都会显示为相同颜色。
  • mode是日历视图的默认显示模块,可以是天、周或月。

ℹ️Odoo 11中的修改
dipsplay 日历属性在 Odoo 11中删除。此前的版本中,它用于自定义日历项标题文本的格式,例如display=”[name], Stage [stage_id]”。

Odoo 12日历视图

透视表视图

还可通过透视表查看数据,它是一个动态分析矩阵。为此我们可使用透视表视图。

ℹ️Odoo 9中的修改
透视表在 Odoo 8中就已存在,作为一个图表视图功能。在 Odoo 9中,它成为一个独立的视图类型。同时也增强了透视表功能、优化了透视表数据的获取。

数据累加仅对数据库中存储的字段可用。我们将使用num_books字段来展示一些借书数量的统计。它是一个计算字段,还没有存储在数据库中。要在这些视图中使用,需要通过添加store=True属性先将其存储在数据库中:

1
2
3
num_books = fields.Integer(
compute='_compute_num_books',
store=True)

使用如下代码来为图书借阅添加数据透视表:

1
2
3
4
5
6
7
8
9
10
11
<record id="view_pivot_checkout" model="ir.ui.view">
<field name="model">library.checkout</field>
<field name="arch" type="xml">
<pivot>
<field name="stage_id" type="col" />
<field name="member_id" />
<field name="request_date" interval="week" />
<field name="num_books" type="measure" />
</pivot>
</field>
</record>

图表和透视表视图应包含描述轴和度量的字段元素,两者的属性大多数都通用:

  • name像其它视图一样标识图表中使用的字段
  • type是指如何使用字段,行分组(默认)、度量(measure)或列(仅针对透视表,用于列分组)
  • interval用于日期字段,是对时间数据的分组间隔:按天、按周、按月、按季度或按年

Odoo 12透视表视图

图表视图

图表视图将数据累加展示为图表,可以使用柱状图、线状图和饼图。下面来为图书借阅添加图表视图:

1
2
3
4
5
6
7
8
9
<record id="view_graph_checkout" model="ir.ui.view">
<field name="model">library.checkout</field>
<field name="arch" type="xml">
<graph type="bar">
<field name="stage_id" />
<field name="num_books" type="measure" />
</graph>
</field>
</record>

图表视图元素可带有一个type属性,值可为 bar(默认), pie或line。对于 bar,可使用额外的stacked=”True”属性来让柱状图叠放起来。图表使用两种类型字段:

  • type=”row”是默认值,设置累加值的条件
  • type=”measure”用于作为实际累加值的度量字段

图表和透视表视图应包含描述需使用的轴和度量的字段元素。大多数图表视图中的属性同样可在透视表视图中使用。

Odoo 12图表视图

总结

本文中我们学习了更多创建用户界面的 Odoo 视图。我们深入讲解了表单视图,然后一起概览了其它视图类型,包括列表视图和搜索视图。我们还学习了如何向视图元素添加动态行为。

下一篇文章中,我们将学习本文中未涉及到的视图:看板视图以及它使用的模板语言 QWeb。

 

☞☞☞第十一章 Odoo 12开发之看板视图和用户端 QWeb

 

扩展阅读

以下本文中所讨论的话题的附加参考和补充材料:

本文首发地址:Alan Hou 的个人博客

本文为最好用的免费ERP系统Odoo 12开发手册系列文章第九篇。

Odoo 服务器端带有外部 API,可供网页客户端和其它客户端应用使用。本文中我们将学习如何在我们的客户端程序中使用 Odoo 的外部 API。为避免引入大家所不熟悉的编程语言,此处我们将使用基于 Python 的客户端,但这种 RPC 调用的处理方法也适用于其它编程语言。

我们将一起了解如何使用 Odoo RPC调用,然后根据所学知识使用 Python创建一个简单的图书命令行应用。

本文主要内容有:

  • 在客户端机器上安装 Python
  • 使用XML-RPC连接 Odoo
  • 使用XML-RPC运行服务器端方法
  • 搜索和读取 API 方法
  • 图书客户端XML-RPC 接口
  • 图书客户端用户界面
  • 使用OdooRPC库
  • 了解ERPpeek客户端

开发准备

本文基于第三章 Odoo 12 开发之创建第一个 Odoo 应用创建的代码,具体代码请参见 GitHub 仓库。应将library_app模块放在addons路径下并进行安装。为保持前后一致,我们将使用第二章 Odoo 12开发之开发环境准备所进行安装并使用12-library数据库。本章完成后的代码请参见 GitHub 仓库

补充:因原书前后曾使用过多个数据库,本文中Alan将使用系列中一直使用的 dev12数据库,并在前一篇文章的基础上进行开发。

学习项目-图书目录客户端

本文中,我们将开发一个简单的客户端应用来管理图书目录。这是一个命令行接口(CLI) 应用,使用 Odoo 来作为后端。应用的功能非常有限,这样我们可以聚焦在用于与 Odoo服务端交互的技术,而不是具体应用的实现细节。我们的简单应用可以完成如下功能:

  • 通过标题搜索并列出图书
  • 向目录添加新标题
  • 修正图书标题
  • 从目录中删除图书

这个应用是一个 Python 脚本,等待输入命令来执行操作。使用会话示例如下:

1
2
3
4
5
$ python3 library.py add "Moby-Dick"
$ python3 library.py list "moby"
60 Moby-Dick
$ python3 library.py set-title 60 "Moby Dick"
$ python3 library.py del 60

在客户端机器上安装 Python

Odoo API 可以在外部通过两种协议访问:XML-RPC和JSON-RPC。任意外部程序,只要能实施其中一种协议的客户端,就可以与 Odoo 服务端进行交互。为避免引入其它编程语言,我们将保持使用 Python 来研究外部 API。

到目前为止我们仅在服务端运行了 Python 代码。现在我们要在客户端上使用 Python,所以你可能需要在电脑上做一些额外设置。要学习本文的示例,你需要能在操作电脑上运行 Python 文件。可通过在命令行终端运行python3 –version命令来进行确认。如果没有安装,请参考官方网站针对你所使用的平台的安装包

对于 Ubuntu,你可能已经安装了 Python 3,如果没有安装,可通过以下命令进行安装:

1
sudo apt-get install python3 python3-pip

如果你使用的是 Windows 并且已安装了 Odoo,可能会奇怪为什么没有 Python 解释器,还需要进行额外安装。简单地说是因为 Odoo 安装带有一个内嵌的 Python 解释器,无法在外部使用。

使用XML-RPC连接 Odoo API

访问服务的最简单方法是使用XML-RPC,我们可以使用 Python 标准库中的xmlrpclib来实现。不要忘记我们是在编写客户端程序连接服务端,因此需运行 Odoo 服务端实例来供连接。本例中我们假设 Odoo 服务端实例在同一台机器上运行,但你可以使用任意运行服务的其它机器,只要能连接其IP 地址或服务器名。

让我们来初次连接 Odoo 外部 API。打开 Python 3终端并输入如下代码:

1
2
3
4
5
>>> from xmlrpc import client
>>> srv = 'http://localhost:8069'
>>> common = client.ServerProxy('%s/xmlrpc/2/common' % srv)
>>> common.version()
{'server_version': '12.0', 'server_version_info': [12, 0, 0, 'final', 0, ''], 'server_serie': '12.0', 'protocol_version': 1}

这里我们导入了xmlrpc.client库,然后创建了一个包含服务地址和监听端口信息的变量。请根据自身状况进行修改(如 Alan 使用srv = ‘http://192.168.16.161:8069')。

下一步访问服务端公共服务(无需登录),在终端地址/xmlrpc/2/common上暴露。其中一个可用方法是version(),用于查看服务端版本。我们使用它来确认可与服务端进行通讯。

另一个公共方法是authenticate()。你可能你会以为它会创建会话,但实际上不会。该方法仅仅确认用户名和密码可被接受,请求不使用用户名而是它返回的用户 ID。示例如下:

1
2
3
4
5
>>> db = 'dev12'
>>> user, pwd = 'admin', 'admin'
>>> uid = common.authenticate(db, user, pwd, {})
>>> print(uid)
2

首先创建变量 db,来存储使用的数据库名。本例中为 dev12,但可以修改为任意其它你所使用的数据库名。如果登录信息错误,将不会返回用户 ID,而是返回 False 值。authenticate()最后一个参数是用户代理(User Agent)环境,用于提供客户端的一些元数据(metadata),它是必填的,但可以是一个空字典。

Odoo 12 Python 客户端访问XML RPC

使用XML-RPC运行服务器端方法

使用XML-RPC,不会维护任何会话,每次请求都会发送验证信息。这让协议过重,但使用简单。下一步我们设置访问需登录才能访问的服务端方法。暴露的终端地址为/xmlrpc/2/object,示例如下:

1
2
3
>>> api = client.ServerProxy('%s/xmlrpc/2/object' % srv)
>>> api.execute_kw(db, uid, pwd, 'res.partner', 'search_count', [[]])
48

此处我们第一次访问了服务端 API,执行了Partner 记录的计数。通过 execute_kw() 方法来调用方法,接收如下参数:

  • 连接的数据库名
  • 连接用户ID
  • 用户密码
  • 目标模型标识符名称
  • 调用的方法
  • 位置参数列表
  • 可选的关键字参数字典(本例中未使用)

上面的例子中对res.partner模型调用了search_count方法,仅一个位置参数[],没有关键字参数。该位置参数是一个搜索域,因我们传入的是一个空列表,它对所有伙伴进行计数。常用的操作有搜索和读取。在使用RPC调用时,search方法返回一个区块的 ID 列表。browse方法不可用于RPC,而应使用read来得到记录 ID 列表并获取相应数据,示例如下:

1
2
3
4
5
>>> domain = [('is_company', '=', True)]
>>> api.execute_kw(db, uid, pwd, 'res.partner', 'search', [domain])
[14, 10, 11, 15, 12, 13, 9, 1]
>>> api.execute_kw(db, uid, pwd, 'res.partner', 'read', [[14]], {'fields': ['id', 'name', 'country_id']})
[{'id': 14, 'name': 'Azure Interior', 'country_id': [233, 'United States']}]

对于 read 方法,我们使用了一个位置参数[14]来作为 ID 列表,以及一个关键字参数fields。还可以看到many-to-one关联字段如country_id,被成对获取,包含关联的记录 ID 和显示名称。在代码中处理数据时应记住这一点。

经常会使用search和 read 的组合,所以提供了一个search_read方法来在同一步中执行两者的操作。可通过如下命令来获取以上两段代码的同样结果:

1
api.execute_kw(db, uid, pwd, 'res.partner', 'search_read', [domain], {'fields': ['id', 'name', 'country_id']})

补充:以上代码会为 read 方法传入所有 search 方法的结果,因此内容要较仅传入[14]多

search_read方法和 read 相似,但需要 domain代替 id 列表来作为第一个位置参数。需要说明在 read 和search_read中fields参数并非必须。如果不传入,则获取所有字段。这可能会带来对函数字段的大量计算,并且获取大量可能从来都不会用到的数据,所以通常建议明确传入字段列表。

搜索和读取 API 方法

在第七章 Odoo 12开发之记录集 - 使用模型数据我们学习了用于生成记录的最重要的模型方法以及代码书写。但还有一些其它模型方法可用于更具体的操作,如:

  • read([fields]) 类似于browse方法,但返回的不是记录集,而是包含按参数指定的字段的各行数据列表。每一行是一个字典。它提供可供 RPC 协议使用的数据的序列化展示,设计用于客户端程序中而非服务端逻辑中。
  • search_read([domain], [fields], offset=0, limit=None, order=None)在读取结果记录列表之后执行搜索操作。设计用于 RPC 客户端,避免了反复进行读取结果和搜索的操作。

所有其它模型方法都对 RPC 暴露,但以下划线开头的除外,这些是私有方法。也就是说我们可以像下面这样使用create, write,和unlink修改服务端数据:

1
2
3
4
5
6
7
8
9
10
11
>>> x = api.execute_kw(db, uid, pwd, 'res.partner', 'create', [{'name': 'Packt Pub'}])
>>> print(x)
69
>>> api.execute_kw(db, uid, pwd, 'res.partner', 'write', [[x], {'name': 'Packt Publishing'}])
True
>>> api.execute_kw(db, uid, pwd, 'res.partner', 'read', [[x], ['id', 'name']])
[{'id': 69, 'name': 'Packt Publishing'}]
>>> api.execute_kw(db, uid, pwd, 'res.partner', 'unlink', [[x]])
True
>>> api.execute_kw(db, uid, pwd, 'res.partner', 'read', [[x], ['id', 'name']])
[]

XML-RPC的一个缺陷是它不支持 None 值。有一个XML-RPC扩展可以支持 None 值,但这取决于我们客户端所依赖的具体XML-RPC库。不返回任何值的方法不能在XML-RPC中使用,因为默认返回的是 None。这也是为什么方法在结束时至少会带有一个return True语句。另一个方案是使用 Odoo 同时支持的JSON-RPC。OdooRPC对其进行运行,在稍后的使用OdooRPC库一节会进行使用。

应反复强调 Odoo 的外部 API 可在大部分编程语言中使用。官方文档中我们可以看到Ruby, PHP和Java实际示例。

ℹ️以下划线开头的模块方法被认为是私有方法,不对XML-RPC暴露。

图书客户端XML-RPC 接口

下面就来实现图书客户端应用。我们将使用两个文件:一个处理服务端的接口:library_api.py,另一个处理应用的用户界面:library.py。然后我们会使用现有的OdooRPC库来提供一个替代的实现方法。

我们将创建类来配置与 Odoo 服务端的连接,以及读取/写入图书数据。这将暴露基本的增删改查方法:

  • search_read()获取图书数据
  • create()创建图书
  • write()更新图书
  • unlink()删除图书

选择一个目录来放置应用文件并创建library_api.py文件。首先添加类的构造方法,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
from xmlrpc import client

class LibraryAPI():
def __init__(self, srv, port, db, user, pwd):
common = client.ServerProxy(
'http://%s:%d/xmlrpc/2/common' % (srv, port))
self.api = client.ServerProxy(
'http://%s:%d/xmlrpc/2/object' % (srv, port))
self.uid = common.authenticate(db, user, pwd, {})
self.pwd = pwd
self.db = db
self.model = 'library.book'

此处我们存储了所有创建执行模型调用的对象的所有信息:API引用、uid、密码、数据库名和要使用的模型。接下来我们定义一个帮助方法来执行调用。有赖于前面对象存储的数据该方法可以很精炼:

1
2
3
4
def execute(self, method, arg_list, kwarg_dict=None):
return self.api.execute_kw(
self.db, self.uid, self.pwd, self.model,
method, arg_list, kwarg_dict or {})

现在就可以使用它来实现更高级的方法了。search_read()方法接收一个可选的 ID 列表来获取数据。如果没传入数据,则返回所有记录:

1
2
3
4
def search_read(self, text=None):
domain = [('name', 'ilike', text)] if text else []
fields = ['id', 'name']
return self.execute('search_read', [domain, fields])

create()方法用于创建给定书名的新书并返回所创建记录的 ID:

1
2
3
def create(self, title):
vals = {'name': title}
return self.execute('create', [vals])

write()方法中传入新书名和图书 ID 作为参数,并对该书执行写操作:

1
2
3
def write(self, title, id):
vals = {'name': title}
return self.execute('write', [[id], vals])

然后我们可以实现unlink()方法,非常简单:

1
2
def unlink(self, id):
return self.execute('unlink', [[id]])

在该Python文件最后添加一段测试代码在运行时执行:

1
2
3
4
5
6
7
if __name__ == '__main__':
# 测试配置
srv, db, port = 'localhost', 'dev12', 8069
user, pwd = 'admin', 'admin'
api = LibraryAPI(srv, port, db, user, pwd)
from pprint import pprint
pprint(api.search_read())

如果执行以上 Python 脚本,我们可以打印出图书的内容:

1
2
3
4
5
6
7
8
$ python3 library_api.py
[{'id': 56, 'name': 'Brave New World'},
{'id': 40, 'name': 'Hands-On System Programming with Linux'},
{'id': 41, 'name': 'Lord of the Flies'},
{'id': 39, 'name': 'Mastering Docker - Third Edition'},
{'id': 38, 'name': 'Mastering Reverse Engineering'},
{'id': 55, 'name': 'Odoo 11 Development Cookbook'},
{'id': 54, 'name': 'Odoo Development Essentials 11'}]

现在已经有了对 Odoo 后端的简单封装,下面就可以处理命令行用户界面了。

图书客户端用户界面

我的目标是学习如何写外部应用和 Odoo 服务之间的接口,前面已经实现了。但不能止步于此,我们还应让终端用户可以使用它。为使设置尽量简单,我们将使用 Python 内置功能来实现这个命令行应用。该功能是标准库自带的,因此不需要进行额外的安装。

在library_api.py 同目录,新建一个library.py文件。首先导入命令行参数解析器,然后导入LibraryAPI类,代码如下:

1
2
from argparse import ArgumentParser
from library_api import LibraryAPI

下面我们来看看参数解析器接收的命令,有以下四个命令:

  • 搜索并列出图书
  • 添加图书
  • 设置(修改)书名
  • 删除图书

在命令行解析器中添加这些命令的代码如下:

1
2
3
4
5
6
parser = ArgumentParser()
parser.add_argument(
'command',
choices=['list', 'add', 'set-title', 'del'])
parser.add_argument('params', nargs='*') # 可选参数
args = parser.parse_args()

这里 args 是一个包含传入脚本参数的对象,args.command是提供的命令,args.params是可选项,用于存放命令所需的其它参数。如果使用了不存在或错误的命令,参数解析器会进行处理并提示用户应输入的内容。有关argparse更完整的说明,请参考官方文档

下一步是执行所计划的操作。首先为 Odoo服务准备连接:

1
2
3
srv, port, db = 'localhost', 8069, 'dev12'
user, pwd = 'admin', 'admin'
api = LibraryAPI(srv, port, db, user, pwd)

第一行代码设置服务实例的一些固定参数以及要连接的数据库。本例中,我们连接 Odoo 服务本机(localhost),监听8069默认端口,并使用 dev12数据库。如需连接其它服务器和数据库,请对参数进行相应调整。

这里硬编码了服务器地址并且密码使用了明文,显然与最佳实施相差甚远。我们应该包含配置步骤让客户提供相关设置信息,并以安全的方式进行存储。但此处我们的目标是学习使用 Odoo RPC,所以可把它看作概念代码,而非已完成的产品。下面写代码来利用 api 对象处理所支持的命令。我们可以先写list命令来列出图书:

1
2
3
4
5
if args.command == 'list':
text = args.params[0] if args.params else None
books = api.search_read(text)
for book in books:
print('%(id)d %(name)s' % book)

这里我们使用了LibraryAPI.search_read()来从服务端获取图书记录列表。然后遍历列表中每个元素并打印。我们使用 Python 字符串格式化来向用户显示每条图书记录,记录是一个键值对字典。下面添加add命令,这里使用了额外的书名作为参数:

1
2
3
4
if args.command == 'add':
for title in args.params:
new_id = api.create(title)
print('Book added with ID %d.' % new_id)

因为主要的工作已经在LibraryAPI对象中完成,下面我们只要调用write()方法并向终端用户显示结果即可。 set-title命令允许我们修改已有图书的书名,应传入两个参数,新的书名和图书的 ID:

1
2
3
4
5
6
7
if args.command == 'set-title':
if len(args.params) != 2:
print("set command requires a title and ID.")
else:
book_id, title = int(args.params[0]), args.params[1]
api.write(title, book_id)
print('Title set for Book ID %d.' % book_id)

最终我们要实现 del 命令来删除图书记录,学到这里应该不再有任何挑战性了:

1
2
3
4
if args.command == 'del':
for param in args.params:
api.unlink(int(param))
print('Book with ID %s deleted.' % param)

到这里我们就完成了基础的 API CLI (命令行接口)了,读者可以尝试执行命令来查看效果。比如,我们可以运行本文开头学习项目-图书目录客户端中的命令。通过普通客户端来访问图书中的数据也会有助于确认该CLI是否如预想般运行。这是一个非常基础的应用,查看代码你应该可以想到一些改进它的方式。但要记住我们这里的目的是以相对有趣的方式举例说明Odoo RPC API的使用。

Odoo 12 客户端RPC命令行接口

使用OdooRPC库

另一个可以考虑的客户端库是OdooRPC。它是一个更流行的客户端库,使用JSON-RPC 协议而不是XML-RPC。事实上 Odoo 官方客户端使用的就是JSON-RPC,XML-RPC更多是用于支持向后兼容性。

ℹ️OdooRPC库现在由 OCA 管理和持续维护。了解更多请参见OCA

OdooRPC库可通过PyPI安装:

1
pip3 install --user odoorpc

不管是使用JSON-RPC还是XML-RPC,Odoo API的使用方式并没什么分别。所以在下面我们可以看一些细节可能有区别,但这些客户端库的使用方式并没有什么分别。

OdooRPC库在创建新的odoorpc.ODOO对象时建立服务端连接,然后应使用ODOO.login()方法来创建用户会话。和服务端相似,会话有一个带有会话环境的 env 属性,包含用户 ID-uid 和上下文。我们可以使用OdooRPC来重新实现library_api.py对服务端的接口。它应提供相同的功能,但使用JSON-RPC代替XML-RPC来实施。在相同目录下创建library_odoorpc.py文件并加入如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from odoorpc import ODOO

class LibraryAPI():
def __init__(self, srv, port, db, user, pwd):
self.api = ODOO(srv, port=port)
self.api.login(db, user, pwd)
self.uid = self.api.env.uid
self.model = 'library.book'
self.Model = self.api.env[self.model]

def execute(self, method, arg_list, kwarg_dict=None):
return self.api.execute(
self.model,
method, *arg_list, **kwarg_dict)

OdooRPC库实现Model和Recordset对象来模仿服务端对应的功能。目标是在客户端编程与服务端编程应基本一致。客户端使用的方法将通过存储在self.Model属性中的图书模型来利用这点。这里实现的execute()方法并不会在我们客户端中使用,仅用于与本文中讨论的其它实现进行对比。

下面我们来实现search_read(), create(), write()和unlink()这些客户端方法。在相同文件的LibraryAPI()类中添加如下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def search_read(self, text=None):
domain = [('name', 'ilike', text)] if text else []
fields = ['id', 'name']
return self.Model.search_read(domain, fields)

def create(self, title):
vals = {'name': title}
return self.Model.create(vals)

def write(self, title, id):
vals = {'name': title}
self.Model.write(id, vals)

def unlink(self, id):
return self.Model.unlink(id)

注意这段代码和 Odoo 服务端代码相似,因为它使用了与 Odoo 中插件写法相近的 API。然后可以将library.py文件中的from library_api import LibraryAPI一行修改为library_odoorpc import LibraryAPI。现在再次运行library.py客户端应用进行测试,执行的效果和之前应该一致。

了解ERPpeek客户端

ERPpeek是一个多功能工具,既可以作为交互式命令行接口(CLI)也可以作为 Python库,它提供了比xmlrpc库更便捷的 API。它在PyPi索引中,可通过如下命令安装:

1
pip3 install --user erppeek

ERPpeek不仅可用作 Python 库,它还可作为 CLI 来在服务器上执行管理操作。Odoo shell 命令在主机上提供了一个本地交互式会话功能,而erppeek库则为网络上的客户端提供了一个远程交互式会话。打开命令行,通过以下命令可查看能够使用的选项:

1
erppeek --help

下面一起来看看一个示例会话:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ erppeek --server='http://192.168.16.161:8069' -d dev12 -uadmin
Usage (some commands):
models(name) # List models matching pattern
model(name) # Return a Model instance
...

Password for 'admin':
Logged in as 'admin'
dev12 >>> model('res.users').count()
3
dev12 >>> rec = model('res.partner').browse(14)
dev12 >>> rec.name
'Azure Interior'

如上所见,建立了服务端的连接,执行上下文引用了model() 方法来获得模型实例并对其进行操作。连接使用的erppeek.Client实例也可通过客户端变量来使用。 值得一提的是它可替代网页客户端来管理所安装的插件模块:

  • client.modules()列出可用或已安装模块
  • client.install()执行模块安装
  • client.upgrade()执行模块升级
  • client.uninstall()卸载模块

因此ERPpeek可作为 Odoo 服务端远程管理的很好的服务。有关ERPpeek的更多细节请见 GitHub

Odoo 12 ERPpeek

总结

本文的目标是学习外部 API 如何运作以及它们能做些什么。一开始我们通过一个简单的Python XML-RPC客户端来进行探讨,但外部 API 可用于其它编程语言。事实上官方文档中包含了Java, PHP和Ruby的代码示例。

有很多库可处理XML-RPC或JSON-RPC,有些是通用的,有些仅适用于 Odoo。我们使用了一个指定库OdooRPC。

以上我们就完结了本文有关编程 API 和业务逻辑的学习。是时候深入视图和用户界面了。在下一篇文章中,我们进一步学习网页客户端所提供的后台视图和用户体验。

 

☞☞☞第十章 Odoo 12开发之后台视图 - 设计用户界面

 

扩展阅读

以下参考资料可用于补充本文所学习的内容:

本文首发地址:Alan Hou 的个人博客

本文为最好用的免费ERP系统Odoo 12开发手册系列文章第八篇。

在前面的文章中,我们学习了模型层、如何创建应用数据结构以及如何使用 ORM API 来存储查看数据。本文中我们将利用前面所学的模型和记录集知识实现应用中常用的业务逻辑模式。

本文的主要内容有:

  • 以文件为中心工作流的阶段(stage)
  • ORM 方法装饰器:@api.multi, @api.one和@api.model
  • 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 12开发之后台视图 - 设计用户界面和第十一章 Odoo 12开发之看板视图和用户端 QWeb中讨论。

图书借阅模型包含:

  • 借阅图书的会员(必填)
  • 借阅请求日期(默认为当天)
  • 负责借阅请求的图书管理员(默认为当前用户)
  • 借阅路线,包含请求借阅的一本或多本图书

要支持并存档借阅生命周期,需要添加如下内容:

  • 请求的阶段:已选中、可借阅、已借出、已归还或已取消
  • 借阅日期,图书借出的日期
  • 关闭日期,图书归还的日期

我们将开始创建一个新的模块library_checkout并实现图书借阅模型的初始版本。与此前章节相比此处并没有引入新的知识,用于提供一个基础供本文后续创建新功能。

在其它图书插件模块的同级路径下创建一个library_checkout目录:

1、首先添加__manifest__.py文件并加入如下内容:

1
2
3
4
5
6
7
8
9
10
11
{
'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文件,并添加如下代码:

1
from . import models

3、创建models/init.py文件并添加:

1
from . import library_checkout

4、在models/library_checkout.py中添加如下代码:

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
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文件:

1
2
3
4
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实现:

1
2
3
4
5
6
7
8
9
10
<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文件实现:

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
28
29
30
<?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 工作数据库中安装这个模块,并准备开始添加更多功能了。

1
~/odoo-dev/odoo/odoo-bin -d dev12 -i library_checkout

以文档为中心工作流的阶段(stage)

在 Odoo 中,我们可以实现以文档(document)为中心的工作流。我们这里说的文档包括销售订单、项目任务或人事申请。所有这些都遵循一个特定的生命周期,它们都在完成时才被创建。它们都被记录在一个文档中,按照一系列可能的阶段推进,直至完成。

如果把各阶段以列展示在面板中,把文档作为这些列中的工作项,就可以得到一个看板(Kanban),一个快速查看工作进度的视图。实现这些进度步骤有两种方法,通常称为状态和阶段。

状态通过预定义的闭合选项列表来实现。它便于实现业务规则,并且模型和视图对 state 字段有特别的支持,根据当前状态来带有必填和隐藏属性集。状态列表有一个劣势,就是它是预定义并且闭合的,因此无法按具体流程需求来做调整。

阶段通过关联模型实现,阶段列表是开放的,可被配置来满足当前流程需求。可以轻易地修改引用阶段列表:删除、添加或渲染这些阶段。它的劣势是对流程自动化不可靠,因为阶段列表可被修改,自动化规则就无法依赖于具体的阶段 ID 或描述。

获取两种方法优势的方式是将阶段映射到状态中。文档组织到可配置的阶段中,然后间接关联到对于自动化业务逻辑可靠的状态码中。我们将在library_checkout/models/library_checkout_stage.py文件中实现library.checkout.stage模型,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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文件中,当前内容为:

1
2
from . import library_checkout_stage
from . import library_checkout

下一步,我们需要向图书借阅模型添加阶段字段stage。编辑library_checkout/models/library_checkout.py文件,在 Checkout 类的最后面(line_ids 字段后)添加如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@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文件中加入对应的安全权限,代码如下:

1
2
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文件并加入如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<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文件中添加该文件:

1
2
3
4
    'data':[
...
'data/library_checkout_stage.xml',
],

Odoo 12图书项目 stages

备注:上图为通过开发者菜单中Edit View: Form编辑添加了 stage_id 后的效果。

ORM 方法装饰器

就我们目前碰到的 Odoo 中 Python 代码,装饰器,如@api.multi通常用于模型方法中。这对 ORM 非常重要,允许它给这些方法特殊用法。下面就来看看有哪些 ORM 装饰器以及如何使用。

记录集方法:@api.multi

大多数情况下,我们需要一个自定义方法来对记录集执行一些操作。此时就需要使用@api.multi,并且此处self参数就是要操作的记录集。方法的逻辑通常会包含对 self 的遍历。@api.multi是最常用的装饰器。

小贴士: 如果模型方法没有添加装饰器,默认就使用@api.multi。

单例记录方法:@api.one

有些情况下方法用于操作单条记录(单例),此时可使用@api.one装饰器。现在仍可使用@api.one,但在 Odoo 9中已声明为弃用。它包裹装饰的方法,进行 for 循环遍历,它调用装饰方法,一次一条记录,然后返回一个结果列表。因此在@api.one装饰的方法内,self 一定是单例。

小贴士: @api.one的返回值有些搞怪,它返回一个列表,而不实际方法返回的数据结构。比如方法代码如果返回字典,实际返回值是一个字典值列表。这种误导性也是该方法被弃用的主要原因。

对于要操作单条记录的方法,我们应还是使用@api.multi,在代码顶部添加一行self.ensure_one(),来确保操作的是单条记录。

类静态方法:@api.model

有时方法需要在类级别而不是具体记录上操作。面向对象编程语言中,这称之为静态方法。这些类级别的静态方法应由@api.model装饰。在这些情况下,self 应作为模型的引用 ,无需包含实际记录。

ℹ️@api.model装饰的方法无法用于用户界面按钮,在这种情况下,应使用@api.multi。

onchange 方法

onchange由用户界面表单视图触发,当用户编辑指定字段值时,立即执行一段业务逻辑。这可用于执行验证,向用户显示消息或修改表单中的其它字段。支持该逻辑的方法就使用@api.onchange(‘fld1’, ‘fld2’, …)装饰。装饰器的参数是用户界面通过编辑需触发方法的字段名。

小贴士: 通过为字段添加属性on_change=”0”可在特定表单中关闭 on change 行为,比如

在方法内,self 参数是带有当前表单数据的一条虚拟记录。如果在记录上设置了值,就会在用户界面表单中被修改。注意它并没有向数据库实际写入记录,而是提供信息来修改 UI表单中的数据。无需返回信息,但可以返回一个字典结构的警告信息来显示在用户界面中。

作为示例,我们可以使用它来执行借阅表单中的部分自动化:在图书会员变更时,请求日期设置为当天,并且显示一个警告信息告知用户。下面我们就在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 = 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 12开发之模型 - 结构化应用数据中进行了详细讨论。罗列如下供您参考:

  • @api.depends(fld1,…)用于计算字段函数,来识别(重新)计算应触发什么样的修改。必须设置在计算字段值上,否则会报错。
  • @api.constrains(fld1,…)用于模型验证函数并在任意参数中包含的字段修改时执行检查。它不应向数据库写入修改,如检查失败,则抛出异常。

使用 ORM 内置方法

上一部分讨论的装饰器允许我们为模型添加一些功能,如实施验证或自动运算。

ORM 提供对模型数据执行增删改查(CRUD)操作的方法。下面我们来探讨如何扩展写操作来支持自定义逻辑。读取数据的主要方法search()和browse()在中第七章 Odoo 12开发之记录集 - 使用模型数据已进行讨论。

写入模型数据的方法

ORM 为三种基本写操作提供了三个方法,如下所示:

  • .create(values)在模型上创建新记录,它返回所创建记录。
  • .write(values) 更新记录集中的字段值,它不返回值。
  • .unlink()从数据库中删除记录,它不返回值。

values参数是一个字典,映射要写入的字段名和值。这些方法由@api.multi装饰,除create()方法使用@api.model装饰器外。

ℹ️Odoo 12中的修改
create()现在也可批量创建数据,这通过把单个字典对象修改为字典对象列表来传参进行实现。这由带有@api.model_create_multi装饰器的create() 方法来进行支持。

有些情况下,我们需要扩展这些方法来添加一些业务逻辑,在这些操作执行时触发。通过将逻辑放到自定义方法的适当位置,我们可以让代码在主操作执行之前或之后运行。

我们将使用借阅模型类创建一个示例:添加两个日期字段来记录进入 open 状态的时间和进入 closed 状态的时间。这是计算字段所无法实现的,我们还将添加一个检查来阻止对已为 done 状态的创建借阅。

因此我们应在 Checkout 类中添加两个新字段,在library_checkout/models/library_checkout.py文件中添加如下代码:

1
2
checkout_date = fields.Date(readonly=True)
closed_date = fields.Date(readonly=True)

现在就可以创建自定义的create()方法来设置checkout_date了,如果状态正确则创建,而如果已经是完成状态则不予创建,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@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() 方法,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
@api.multi
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()方法在同一模型中有其它的写操作,会导致递归循环,它在工作进程资源耗尽后结束并报错。请考虑是否需要这么做,如果需要,避免递归循环的一个技巧是在上下文中添加一个标记。作为示例,我们添加类似如下代码:

1
2
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 12开发之导入、导出以及模块数据已做讨论,也可以通过 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 12 开发之模块继承中已讲解了如何从 mixin 抽象类中继承功能。

要添加这些功能,我们需要在library_checkout中先添加对 mail 的依赖,然后在图书借阅模型中继承抽象类中提供的这些功能。编辑library_checkout/manifest.py文件,在 depends 键下添加 mail 模块:

1
'depends': ['library_member', 'mail'],

然后编辑library_checkout/models/library_checkout.py文件来继承 mixin 抽象模型,代码如下:

1
2
3
4
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字段中。

Odoo 12消息子类型

发送消息

我们的业务逻辑可利用这个消息系统来向用户发送通知。可使用message_post() 方法来发送通知,示例如下:

1
self.message_post('Hello!')

这会添加一个普通文本消息,但不会向follower发送通知。这是因为默认由mail.mt_note子类型发送消息。但我们可以通过指定的子类型来发送消息。要添加一条向follower发送通知的消息,应使用mt_comment子类型。另一个可选属性是消息标题,使用这两项的示例如下:

1
self.message_post('Hello again!', subject='Hello', subtype='mail.mt_comment')

消息体是HTML格式的,所以我们可以添加标记来实现文本效果,如为加粗,为斜体。

ℹ️出于安全原因消息体会被清洗,所以有些 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/子目录:

1
2
from . import models
from . import wizard

添加wizard/init.py文件并加入如下代码:

1
from . import checkout_mass_message

然后创建实际的wizard/checkout_mass_message.py文件,内容如下:

1
2
3
4
5
6
7
8
9
10
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文件的内容如下:

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
28
29
30
31
32
33
34
<?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"
src_model="library.checkout"
res_model="library.checkout.massmessage"
view_type="form"
view_mode="form"
target="new"
multi="True"
/>
</odoo>

XML 中的窗口操作使用src_model属性向图书借阅的Action按钮添加了一个选项。target=”new”属性让它以对话窗口形式打开。打开向导,我们可以从借阅列表中选择一条或多条记录,然后从Action菜单中选择 Send Messages 选项,Action 菜单显示在列表顶部的Filters菜单旁。

Odoo 12图书项目发送消息菜单

现在这会打开向导表单,但从列表中所选的记录会被忽略。如果能在向导中任务列表中显示预选的记录会很棒。表单会调用default_get() 方法来计算要展示的默认值,这正是我们需要的功能。注意在打开向导表单时,有一条空记录并且还没有使用create()方法,该方法仅在点击按钮时才会触发,所以暂不能满足我们的需求。

Odoo 视图向上下文字典添加一些元素,可在点击操作或跳到其它视图时使用。它们分别是:

  • active_model:带有视图模型的技术名
  • active_id:带有表单活跃记录或表中第一条记录的 ID
  • active_ids:带有一个列表中活跃记录的列表(如果是表单则只有一个元素)
  • active_domain:如果在表单视图中触发了该操作

本例中,active_ids中保存任务列表中所选记录的 ID,可使用这些 ID 作为向导task_ids字段的默认值,相关代码如下(izard/checkout_mass_message.py):

1
2
3
4
5
6
@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文件中使用如下代码定义:

1
2
3
4
5
6
7
8
9
10
@api.multi
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文件的头部添加如下代码:

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向日志中写入消息了,让我们为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 dev12 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() 命令后添加如下调试日志消息:

1
2
3
4
_logger.debug(
'Message on %d to followers: %s',
checkout.id,
checkout.message_follower_ids)

这不会在服务器日志中显示任何消息,因为默认的日志级别是INFO。需要将日志级别设置为DEBUG才会输出调试日志消息。

1
DEBUG dev12 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 选项,该选项还可重复多次来对多个模块设置日志级别,示例如下:

1
--loghandler=odoo.addons.library_checkout.wizard.checkout_mass_message:DEBUG

有关 Odoo 服务器日志选项的完整手册可参见官方文档。如果想要了解原始的 Python 日志细节,可参见Python 官方文档

抛出异常

在操作和预期不一致时,我们可能需要通知用户并中断程序,显示错误信息。这可通过抛出异常来实现。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()方法,在self.ensure_one()一行后加入如下代码:

1
2
3
4
5
6
if not self.checkout_ids:
raise exceptions.UserError(
'请至少选择一条借阅记录来发送消息!')
if not self.message_body:
raise exceptions.UserError(
'请填写要发送的消息体!')

补充:经测试发现消息体不填内容并不会抛出异常,因为默认的会发送


这段 html 标签

Odoo 12图书项目异常测试

单元测试

自动化测试是广泛接受的软件开发最佳实践。不仅可以帮助我们确保代码正确实施,更重要的为我们未来的代码修改和重写提供了一个安全保障。对于 Python 这样的动态编程语言,因为没有编译这一步,语法错误经常不容易注意到。这也使得单元测试愈发重要,覆盖的代码行数越多越好。

以上两个目标是我们编写测试时的灯塔。测试的第一个目标应是提供更好的测试覆盖:设置测试用例运行所有代码行。单单这个就会为第二个目标迈出很大一步:显示代码有无功能性错误,因为在这之后,我们一定可以很好地开始为不显著的使用特例添加测试用例。

ℹ️Odoo 12中的修改
在该版本之前,Odoo 还支持通过 YAML格式的数据文件进行测试。Odoo 12中删除了YAML数据文件引擎,不再支持该格式,有关该格式的最后一个文档请见官方网站

添加单元测试

Python 测试文件添加在模块的tests/子目录下,测试执行器会自动在该目录下查找测试文件。为测试library_checkout模块向导逻辑,我们可以创建tests/test_checkout_mass_message.py,老规矩,需要添加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
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选项即可。具体命令如下:

1
~/odoo-dev/odoo/odoo-bin --test-enable -u library_checkout --stop-after-init

仅在安装或升级模块时才会运行测试,这也就是为会什么添加了-u 选项。如果需要安装一些依赖,它的测试也会运行。想要避免这一情况,可以像平常那样测试安装模块,然后在升级(-u)模块时运行测试。以上测试中实际没有做任何测试,但应该可以正常运行。仔细查看服务器日志可看到报告测试运行的INFO信息,例如:

1
INFO dev12 odoo.modules.module: odoo.addons.library_checkout.tests.test_checkout_mass_message running tests.

配置测试

我们应开始在setUp方法中准备测试中将使用的数据。这里我们要创建一条在向导中使用的借阅记录。使用指定用户执行测试操作会很便捷,这样可以同时测试权限控制是否正常配置。这通过sudo()模型方法来实现。记录集中携带这一信息,因此在使用 sudo()创建后,相同记录集后续的操作都会使用相同上下文执行。以下是setUp方法中的代码:

1
2
3
4
5
6
7
8
9
10
11
12
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'].sudo(admin_user)
self.Wizard = self.env['library.checkout.massmessage'].sudo(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方法。完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 的异常类:

1
from odoo import exceptions

然后,在测试类中添加含有测试用例的另一个方法:

1
2
3
4
5
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 12图书项目单元测试

开发工具

开发者应学习一些技巧有协助开发工作。本系列曾介绍过用户界面的开发者模式。也可以在服务端使用该选项来提供对开发者更友好的功能。这一部分就来进行详细说明。然后我们会讨论另一个开发者相关话题:如何对服务端代码进行调试。

服务端开发选项

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 选项:

1
~/odoo-dev/odoo/odoo-bin --dev=all

要正常运行,要求安装watchdog Python包,可通过 pip 命令来执行安装:

1
pip install watchdog

注意这仅对 Python 代码和 XML 文件中视图结构的修改有益。对于其它修改,如模型数据结构,需要进行模块升级,仅仅重新加载是不够的。

调试

我们都知道开发者的大部分工作是调试代码。我们通常使用代码编辑器打断点,运行程序来进行单步调试。如果使用 Windows 系统来开发,配置可运行 Odoo 源码的环境可不是个简单的工作。 Odoo是一个等待客户端调用的服务,然后才进行操作,这一事实让 Odoo 的调试与客户端程序截然不同。

Python 调试器

对于初学者可能有点高山仰止的感觉,最实际的方法是使用 Pyhton 集成的调试器pdb来对 Odoo 进行调试。我们会介绍它的扩展,会提供丰富的用户界面,类似于高级 IDE那样。

要使用调试器,最好的方法是在需要查看的代码(通常是模型方法)处插入断点。这通过在具体位置添加如下行来实现:

1
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对话框,和前述的一样,允许我们检查在发现错误那一刻的程序状态。

Odoo 12调试 pdb

示例调试会话

让我们来看看一个简单调试会长什么样。可以在button_send向导方法的第一行添加调试器断点:

1
2
3
4
    def button_send(self):
import pdb; pdb.set_trace()
self.ensure_one()
...

现在重启服务,打开一个发送消息向导表单并点击 Send 按钮。这会触发服务器上的button_send ,客户端会保持在Still 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模块。我们可通过–log-handler=werkzeug:CRITICAL 选项来停止日志输出。如果这还不够,可以使用–log-level=warn来降低通用日志级别。另一种方法是启用–logfile=/path/to/log选项,这样会将日志消息从标准输出重定向到文件中。

小贴士: 如果终端不响应,在终端中的输入不被显示,这可能与终端会话的显示问题有关,通过输入reset有可能解决这一问题。

此时输入 h,可以看到可用命令的一个快速指南。输入 l 显示当前行代码,以及其周边的代码。输入 n 会运行当前行代码并进入下一行。如果只按下 Enter,会重复上一条命令。执行三次应该就可以进入方法的 return 语句。我们可以查看任意变量或属性的内容,如向导中使用的checkout_ids字段:

1
2
(Pdb) p self.checkout_ids
library.checkout(30,)

它允许使用任何 Python 表达式,甚至是分配变量。我们可以逐行调试,在任意时刻按下 c 继续正常运行。

其它 Python 调试器

pdb 的优势是“开箱即用”,它简单但粗暴,还有一些使用上更舒适的选择。

ipdb(Iron Python debugger)是一个常用的选择,它使用和 pdb 一样的命令,但做了一些改进,比如添加 Tab 补全和语法高亮来让使用更舒适。可通过如下命令安装:

1
sudo pip install ipdb

使用如下行添加断点:

1
import ipdb; ipdb.set_trace()

Odoo 12调试工具 ipdb

另一个可选调试器是pudb,它也支持和pdb相同的命令,仅用在文本终端中,但使用了类似 IDE 调试器的图形化显示。当前上下文的变量及值这类有用信息,在屏幕上它自己的窗口中显示。可通过系统包管理器或 pip 来进行安装:

1
2
sudo apt-get install python-pudb # 使用Debian系统包
sudo pip install pudb # 使用 pip,可在虚拟环境中

添加pudb断点和其它调试器没什么分别:

1
import pudb; pudb.set_trace()

也可以使用更短更易于记忆的方式:

1
import pudb; pu.db

Odoo 12调试工具 pudb

打印消息和日志

有时我们只需要查看一些变量的值或者检查一些代码段是否被执行。Python的print()函数可以在不中断执行流的情况下完美解决这些问题。因为我们在服务器窗口中运行,打印的内容会显示在标准输出中,但如果日志是写入文件的打印内容不会存储到服务器日志中。

print()仅用于开发辅助,不应出现最终部署的代码中。如果你可能需要代码执行的更多细节,请使用debug 级别日志消息。在代码敏感点添加调试级别日志消息让我们可以在部署实例中调查问题。只需将服务器日志级别提升为 debug,然后查看日志文件。

查看和关闭运行进程

还有一些查看 Odoo 运行进程的小技巧。首先我们需要找到相应的进程ID (PID),要找到 PID,再打开一个终端窗口并输入如下命令:

1
ps ax | grep odoo-bin

输入的第一列是进程的PID,记录下要查看进程的 PID,在下面会使用到。现在,我们要向进程发送信号。使用的命令是 kill,默认它发送一个终止进程的信号,但也可以发送其它更友好的信号。知道了运行中的 Odoo 服务进程 PID,我们可以通过向进程发送SIGQUIT信号打印正在执行的代码踪迹,命令如下:

1
kill -3 <PID>

然后如果我们查看终端窗口或写入服务输出的日志文件,就可以看到正常运行的一些线程的信息,以及它们运行所在行代码的细节栈踪迹。这用于一些代码性能分析中,追踪服务时间消耗在何处,来将代码执行性能归类。有关代码profiling的资料可参见官方文档。其它可向 Odoo 服务器进程发送的信号有:HUP来重新加载服务,INT或TERM强制服务关闭:

1
2
kill -HUP <PID>
kill -TERM <PID>

总结

我们详细解释了ORM API 的功能,以及如何使用这些功能来创建动态应用与用户互动,这可以帮助用户避免错误并自动化一些单调的任务。

模型验证和计算字段可以处理很多用例,但并不是所有的。我们学习了如何继承API的create, write和unlink 方法来处理更多用例。

对更丰富的用户交互,我们使用了 mail 内核插件 mixin 来为用户添加功能,方便他们围绕文档和活动规则进行交流。向导让应用可以与用户对话,收集所需数据来运行具体进程。异常允许应用终止错误操作,通知用户存在的问题并回滚中间的修改,保持系统的一致性。

我们还讨论了开发者可使用来创建和维护应用的工具:记录日志消息、调试工具和单元测试。

在下一篇文章中,我们还将使用 ORM,但会从外部应用的视角来操作,将 Odoo 服务器作为存储数据和运行业务进程的后端。

 

☞☞☞ 第九章 Odoo 12开发之外部 API - 集成第三方系统

 

扩展阅读

以下是本文所讨论的内容相关参考材料如下:

本文首发地址:Alan Hou 的个人博客

本文为最好用的免费ERP系统Odoo 12开发手册系列文章第七篇。

在上一篇文章中,我们概览了模型创建以及如何从模型中载入和导出数据。现在我们已有数据模型和相关数据,是时候学习如何编程与其进行交互 了。模型的 ORM(Object-Relational Mapping)提供了一些交互数据的方法,称为 API(Application Programming Interface)。这包括基本的增删改查(CRUD)操作,也包括一些其它操作,如数据导入导出,以及改善用户界面和体验的工具方法。它还包含一些我们在前面文章中所看到的装饰器。这些都让我们可以通过添加新的方法来调用 ORM 进行相关操作。

本文主要内容有:

  • 使用 shell 命令交互式地学习 ORM API
  • 理解执行环境和上下文
  • 使用记录集和作用域(domain)查询数据
  • 在记录集中访问数据
  • 在记录中写入
  • 编写记录集
  • 使用底层 SQL 和数据库事务

开发准备

本文代码使用交互式 shell 命令行执行,无需使用前面章节的代码。

使用 shell 命令行

Python带有命令行界面,是研究其语法一个很好的方式。Odoo 也有类似的功能,可以交互式的测试命令的执行效果,这就是 shell 命令行。在命令行中执行以下命令并指定数据库即可使用:

1
~/odoo-dev/odoo/odoo-bin shell -d dev12

此时在终端上可以看到正常的服务启动信息,等到出现>>>Python提示符时即为完成,可以输入命令了。

ℹ️Odoo 9中的修改
shell 功能在9.0中才添加。Odoo 8.0可使用社区模块来添加这一功能。只需下载并放入 addons 路径即可使用,下载请见应用市场

此处 self 表示管理员用户的记录,可通过如下命令进行确认:

1
2
3
4
5
6
>>> self
res.users(1,)
>>> self._name
'res.users'
>>> self.login
'__system__'

在以上 shell 会话中,我们检查了自己的环境:

  • self命令表示res.users记录集,仅包含一条 id 为1的记录
  • 查看self._name获得记录集模型名,你可能猜到了,是’res.users’
  • 记录的 name 值为OdooBot
  • 记录的 login 字段值为__system__

ℹ️Odoo 12中的修改
id 号为1的超级用户由原来的 admin 变成无法直接登录的内部系统用户。现在 admin 的 id 号为 2并且不是超级用户,但默认各应用会将其加入所有安全组。主要原因是避免用户使用超级用户账号来执行日常操作。这样的风险是该用户会跳过权限规则并导致数据的不一致,比如跨公司(cross-company)关联。现在超级用户仅用于检测问题或具体的跨公司操作。

和 Python 一样,可通过 Ctrl + D退出该命令行。此时会结束服务并返回到系统shell 命令行。

Odoo 12 shell 命令行

执行环境

Odoo shell 中包含一个 self 引用,类似于在res.users模型的方法中看到的那样。如我们所见,self 是一个记录集。记录集自带环境信息,包括浏览信息的用户以及其它上下文信息,如语言和时区。下面我们会学习执行环境中可用的属性、环境上下文的用处以及如何修改该上下文。

环境属性

我们可通过如下代码查看当前环境:

1
2
>>> self.env
<odoo.api.Environment object at 0x7f78a26026a0>

self.env 中的执行环境中有以下属性:

  • env.cr是正在使用的数据库游标(cursor)
  • env.user是当前用户的记录
  • env.uid是会话用户 id,与env.user.id相同
  • env.context是会话上下文的不可变字典

环境还提供对带有所有已安装模型注册表的访问,如self.env[‘res.partner’]返回一条对 partner 模型的引用。然后我们还可以对其使用search()或browse()方法来获取记录集:

1
2
>>> self.env['res.partner'].search([('name', 'like', 'Ad')])
res.partner(10, 35, 3)

上例中返回的res.partner模型记录集包含三条记录,id 分别为10, 35和3。记录集并没有按 id 排序,因为使用了相应模型的默认排序。就 partner 模型而言,默认的_order为display_name。

环境上下文

环境上下文是一个带有会话数据的字典,可用于客户端用户界面以及服务端 ORM 和业务逻辑中。在客户端中,它可以把信息从一个视图带到另一个视图中,比如前一个视图中活跃的记录 id,通过点击链接或按钮,可将默认值带入到下一个视图中。在服务端中,一些记录集的值会依赖于上下文提供的本地化设置。具体的例子有lang键影响可翻译字段的值。上下文还可为服务端代码提供信号。比如active_test键在设为 False 时,会改变ORM中search()方法的行为,它会忽略记录中的active标记,inactive(假删除)的记录也会被返回。

客户端的初始上下文长这样:

1
{'lang': 'en_US', 'tz': 'Europe/Brussels', 'uid': 2}

补充:服务端查看上下文命令为self.context_get()或self.env.context

其中 lang 键为用户语言,tz 为时区信息,uid 为当前用户 id。记录中的内容随当前依赖的上下文可能会不同:

  • translated字段根据活跃的 lang 语言不同值也会不同
  • datetimep字段根据活跃的的 tz 时区不同时间会不同

在上一个视图中点击链接或按钮打开表单时,一个active_id键会被加入上下文,它带有原表单我们所在位置记录的 id。以列表视图为例,active_ids上下文键中包含上一个列表中所选择的记录 id 列表。

在客户端中,上下文可用于使用default_或default_search_前缀在目录视图上设置默认值或启动默认过滤器。举例如下:

  • 设置当前用户为user_id字段默认值,使用{‘default_user_id’: uid}
  • 在目标视图上默认启动filter_my_books过滤器,使用{‘default_search_filter_my_tasks’: 1}

修改记录集执行环境

记录集执行环境是不可变的,因此不能被修改,但我们可以创建一个变更环境并使用它来执行操作。我们通过如下方法来实现:

  • env.sudo(user)中传入一条用户记录并返回该用户的环境。如未传入用户,则使用__system__超级用户root,这时可绕过安全规则执行指定操作。
  • env.with_context() 替换原上下文为新的上下文
  • env.with_context(key=value,…)修改当前上下文,为一些键设置值

此外还有一个env.ref()函数,传入一个外部标识符字符串并返回它的记录,请参见:

1
2
>>> self.env.ref('base.user_root')
res.users(1,)

使用记录集和作用域(domain)查询数据

在方法或 shell 会话中,self表示当前模型,并且我们仅能访问该模型的记录。要访问其它模型就需要使用self.env。例如self.env[‘res.partner’]返回一条对 Partner 模型的引用(也是一个空记录集)。我们可以使用search()或browse()来获取记录集,其中search()方法使用域表达式来定义记录选择范围。

创建记录集

search()方法接收一个域表达式并返回符合条件记录的记录集。空域[] 将返回所有记录。

ℹ️如果模型有特殊字段 active,默认只有active=True的记录才在选择范围内

还可以使用以下关键字参数:

  • order是一个数据库查询语句中ORDER BY使用的字符串,通常是一个逗号分隔的字段名列表。每个字段都可接DESC关键字,用于表示倒序排列。
  • limit设置获取记录的最大条数
  • offset忽略前 n 前记录,可配合limit使用来一次查询指定范围记录

有时我们只要知道满足某一条件的记录条数,这时可使用search_count()来返回记录条数而非记录集。这节约了先获取记录列表再记数的开销,在还没有获取记录集且仅想知道记录条数时这样会更高效。

browse()方法接收一个 ID 列表或单个ID并返回这些记录的记录集。在我们知道 ID 并想要获取记录时这就非常方便了。

一些使用示例如下:

1
2
3
4
>>> self.env['res.partner'].search([('name', 'like', 'Pac')])
res.partner(42, 62)
>>> self.env['res.partner'].browse([42, 62])
res.partner(42, 62)

域表达式

域(domain)用于过滤数据记录。它使用一个特殊语法来供 Odoo ORM解析,生成数据库查询中的 WHERE 表达式。域表达式是一组条件组成的列表,每个条件都是一个 (‘字段名’, ‘运算符’, ‘值’) 组成的元组,例如,[(‘is_done’,’=’,False)]是仅带有一个条件的有效域表达式。以下是对各个元素的说明:

  • 字段名:是一个待过滤字段,可使用点号标记来表示关联模型中的字段

  • 值:在 Python 表达式中运行。可使用字面值,如数字、布尔值、字符串和列表,也可使用运行上下文中的字段和标识符。针对域其实有两种运行上下文:

    • 在窗口操作或字段属性等客户端中使用时,可使用原生字段值来渲染当前可用视图,但不能对其使用点标记符
    • 在服务端使用时,如安全记录规则或服务端 Python 代码中,可以对字段使用点标记符,因为当前记录是一个对象
  • 运算符:可以是以下中的一个

    • 常用比较运算符有<, >, <= , >=, =和!=。
    • ‘=like’和’=ilike’匹配某一模式,这里下划线_匹配单个字符,百分号%匹配任意一组字符。
    • ‘like’匹配’%value%’模式,’ilike’与其相似但忽略大小写。还可以使用’not like’和’not ilike’运算符。
    • ‘child of’在配置支持层级关联的模型中查找层级关系中的子级值。
    • ‘in’ 和’not in’用于查看给定列表的包含,所以其值为一个列表。用于to-many关联字段时,in运算符和contains运算符一样。
    • ‘not in’是in的反向运算,用于查看不在列表中的值。

域表达式是一个列表并且包含多个条件元组。默认这些条件使用AND逻辑运算符连接,也就是说它仅返回满足所有条件的记录。也可以使用显式逻辑运算符 - ‘&’符号表示 AND 运算符(默认值),管道运算符’|’表示OR运算符。这两个运算符会作用于接下来的两项,递归执行。后面我们会一起来详细了解。

ℹ️域表达式使用了更为正式的定义方式:前缀标记法,也称波兰表达式(Polish notation):运算符放在运算项之前。AND和OR是二元运算符,而NOT是一元运算符。

感叹号’!’表示NOT运算符,可用于下一项的运算,因此要放执行的否定项之前。例如[‘!’, (‘is_done’,’=’,True)]将过滤出所有未完成(not-don e)的记录。

下一项本身也可以是一个作用其后续项的运算符,形成一个嵌套条件。下例可以有助于我们进行理解。在服务端记录规则中,可以找到类似下面这样的域表达式:

1
2
3
4
5
6
['|',
('message_follower_ids', 'in', [user.partner_id.id]),
'|',
('user_id', '=', user.id),
('user_id', '=', False)
]

这个域过滤出当前用户在follower列表中并且是负责人用户,或者没有负责人用户的用户集。第一个’|’或运算符作用于 follower 条件以及下一个条件的结果。下一个条件是后面两个条件的并集:用户ID是当前会话用户或未进行设置。下图是上例域表达式的抽象语法树表示:

Odoo 12域表达式抽象语法树

在记录集中访问数据

一旦获取了数据集,就可以查看其中包含的数据了。下面的几个部分中我们就来看看如何访问记录集中的数据。我们可以获取单条记录的字段值,称为单例(singleton)。关联字段带有特殊属性,我们可通过点号标记来查看关联记录。最后我们一起思考处理日期和时间记录并进行格式转换。

访问记录中数据

记录集的一个特例是仅有一条记录,称为单例。单例仍是记录集,在需要记录集的地方均可使用。与多元素记录集不同,单例可使用点号标记访问它的字段,如:

1
2
>>> print(self.name)
OdooBot

下个例子中我们看看同一个 self 单例和记录集相同的行为,我们可对其进行遍历。它只有一条记录,所以只会打印出一个名称:

1
2
3
4
>>> for rec in self:
... print(rec.name)
...
OdooBot

尝试访问有多条记录的记录集字段值会产生错误,所以在不确定操作的是否为单例数据集时就会产生问题。对于设计仅操作单例的方法,可在开头处使用self.ensure_one(),如果 self 不是单例时将抛出错误。

ℹ️空记录也是单例。这样很方便,因为访问字段会返回 None 而非抛出错误。对于关联字段同样如此,使用点号标记访问关联记录也不会抛出错误。

访问关联字段

如前面所见,模型可包含关联字段:many-to-one, one-to-many和many-to-many。这些字段类型的值为记录集。

对于many-to-one,其值可以是单例或空记录集。两种情况下都可以直接访问字段值。如下例中的命令是正确并安全的:

1
2
3
4
5
6
7
8
>>> self.company_id
res.company(1,)
>>> self.company_id.name
'YourCompany'
>>> self.company_id.currency_id
res.currency(1,)
>>> self.company_id.currency_id.name
'EUR'

为避免麻烦,空记录可像单例一样操作,访问其字段值不会返回错误而是返回 False。所以我们可以使用点号标记来遍历字段,而无需担心因其值为空而报错,如:

1
2
3
4
>>> self.company_id.parent_id
res.company()
>>> self.company_id.parent_id.name
False

访问时间和日期值

在记录集中,日期和日期时间值以原生 Python 对象展示,例如,在查询上次 admin 用户登录日期时:

1
2
>>> self.browse(2).login_date
datetime.datetime(2019, 1, 8, 9, 2, 54, 45546)

因为日期和日期时间是 Python 对象,它们可使用这些对象的所有功能。

ℹ️Odoo 12中的修改
date和datetime字段值以 Python 对象表示,而此前 Odoo 版本中它们以文本字符串表示。这些字段类型值仍可像此前 Odoo 版本中那样使用文本表示。

日期和时间在数据库中以原生的世界标准时间(UTC) 格式存储,不受时区影响。 在记录集中看到的datetime值也是 UTC格式,在客户端中向用户展示时,datetime值会根据当前会话的时间设置来转换成用户的时区。这一设置存储在上下文的tz键中,如{‘tz’: ‘Europe/Brussels’}。这一转换由客户端负责,而不是由服务端完成。

例如在布鲁塞尔(UTC+1)的用户输入12:00 AM数据库中会存储为10:00 AM UTC,而在纽约(UTC-4) 的用户查看时则为06:00 AM。

补充:请不要怀疑作者的数学是不是体育老师教的😂,布鲁塞尔为东一区,纽约为西五区,但冬令时和夏令时让这个问题变复杂了。将12:00修改为11:00应该就正确了。

ℹ️Odoo 服务日志消息时间戳使用UTC时间而非本地服务器时间

相反的转换,由会话时区转换为UTC,也需由客户端在将用户输入的datetime传回服务器时完成。日期对象可进行比较和相减来获取两个日期的时间差,时间差是一个timedelta对象。timedelta可通过date运算对date和datetime对象进行加减。这些对象由 Python 标准库datetime模块提供,以下是使用它进行的基本运算示例:

1
2
3
4
5
6
7
8
>>> from datetime import date
>>> date.today()
datetime.date(2019, 1, 12)
>>> from datetime import timedelta
>>> timedelta(days=7)
datetime.timedelta(7)
>>> date.today() + timedelta(days=7)
datetime.date(2019, 1, 19)

对于date, datetime和timedelta数据类型的完整参考请见Python 官方文档。Odoo 还在odoo.tools.date_utils模块中提供了一些额外的便利函数,这些函数有:

  • start_of(value, granularity)是某个特定刻度时间区间的开始时间,这些刻度有year, quarter, month, week, day或hour
  • end_of(value, granularity)是某个特定刻度时间区间的结束时间
  • add(value, kwargs)为指定值加上一个时间间隔。kwargs参数由一个relativedelta对象来定义时间间隔。这些参数可以是years, months, weeks, days, hours, minutes等等
  • subtract(value, **kwargs)为指定值减去一个时间间隔

relativedelta对象来自dateutil库,可使用months或years执行date运算(Python的timedelta标准库仅支持days)。更多内容请见相关文档。以下为上述函数的一些使用示例:

1
2
3
4
5
6
7
8
9
10
11
>>> from odoo.tools import date_utils
>>> from datetime import datetime
>>> date_utils.start_of(datetime.now(), 'week')
datetime.datetime(2019, 1, 7, 0, 0)
>>> date_utils.end_of(datetime.now(), 'week')
datetime.datetime(2019, 1, 13, 23, 59, 59, 999999)
>>> from datetime import date
>>> date_utils.add(date.today(), months=2)
datetime.date(2019, 3, 12)
>>> date_utils.subtract(date.today(), months=2)
datetime.date(2018, 11, 12)

这些工具方法在odoo.fields.Date和the odoo.fields.Datetime对象中也可使用,如:

  • fields.Date.today()返回服务器所需格式的当前日期,它使用UTC作为一个引用。这足以计算默认值,这种情况下只需使用函数名无需添加括号。
  •  fields.Datetime.now() 返回服务器所需格式的当前datetime,它使用UTC作为一个引用。这足以计算默认值,
  • fields.Date.context_today(record, timestamp=None)在会话上下文中返回带有当前日期的字符串。时间从记录上下文中获取。可选项timestamp参数是一个datetime对象,如果传入将不使用当前时间,而使用传入值。
  • fields.Datetime.context_timestamp(record, timestamp)将原生的datetime值(无时区)转换为具体时区的datetime。时区从记录上下文中提取,因此使了前述函数名。

转换文本形式的日期和时间

在Odoo 12以前,在进行运算前我们需要对文本形式的date和datetime进行转换。有些工作可帮助我们完成文本和原生数据类型的相互转换。这在此前的 Odoo 版本中都非常有用并且在 Odoo 12中也仍然相关:我们要将给到的日期格式化为文本。为便于格式之间的转换,fields.Date和fields.Datetime都提供了如下函数:

  • to_date将字符串转换为date对象
  • to_datetime(value)将字符串转换为datetime对象
  • to_string(value)将date或datetime对象转换为 Odoo 11及之前版本Odoo服务所需的字符串格式

函数所需的文本格式由 Odoo 通过如下方式默认预置:

  • odoo.tools.DEFAULT_SERVER_DATE_FORMAT
  • odoo.tools.DEFAULT_SERVER_DATETIME_FORMAT

它们分别与%Y-%m-%d和%Y-%m-%d %H:%M:%S相对应。from_string用法示例如下:

1
2
3
>>> from odoo import fields
>>> fields.Datetime.to_datetime('2019-01-12 13:48:50')
datetime.datetime(2019, 1, 12, 13, 48, 50)

对于其它的日期和时间格式,可使用datetime对象中的strptime方法:

1
2
3
>>> from datetime import datetime
>>> datetime.strptime('1/1/2019', '%d/%m/%Y')
datetime.datetime(2019, 1, 1, 0, 0)

在记录中写入

有两种写入记录的方式:使用对象形式直接分配和使用write() 方法。第一种很简单但一次只能操作一条记录,效率较低。因为每次分配都执行一次写操作,会产生冗余的重复计算。第二种要求写入关联字段时使用特殊语法,但每条命令可写入多个字段和记录,记录计算更为高效。

使用对象形式分配值写入

记录集实施活跃记录模式。也就是说我们可以为其分配值,并且会将这些修改在数据库中持久化存储。这是一种操作数据的易于理解和便捷的方式,但一次只能操作一个字段和一条记录。如:

1
2
3
4
5
6
>>> root = self.env['res.users'].browse(1)
>>> print(root.name)
OdooBot
>>> root.name = 'Superuser'
>>> print(root.name)
Superuser

虽然使用的是活跃记录模式,也可以通过分配记录值来设置关联字段。对于many-to-one字段,分配的值必须是单条记录(单例)。对于to-many字段,也可以通过一条记录集分配,来替换关联记录列表为新列表(如果有的话),这里允许任何大小的记录集。

通过 write()方法写入

我们还可以使用write()方法来同时更新多条记录中的多个字段,仅需一条数据库命令。所以在重视效果时就应优先考虑这一方式。write() 接收一个字典来进行字段和值的映射。这会更新记录集中的所有记录并且没有返回值,如:

1
2
3
4
>>> Partner = self.env['res.partner']
>>> recs = Partner.search( [('name', 'ilike', 'Azure')] )
>>> recs.write({'comment': 'Hello!'})
True

与对象形式的分配不同,使用write() 方法时我们不能直接为关联字段分配记录集对象。取而代之的是,我们需要使用所需的记录ID来从记录集中进行提取。在写入many-to-one字段时,写入的值必须是关联记录的ID。例如,我们不用self.write({‘user_id’: self.env.user}),而应使用self.write({‘user_id’: self.env.user.id})。

在写入to-many字段时,写入的值必须使用和 XML 数据文件相同的特殊语法,这在第五章 Odoo 12开发之导入、导出以及模块数据中有介绍。比如,我们设置图书作者列表为author1和author2,这是两条 Partner 记录。| 管道运算符可拼接记录来创建一个记录集,因此使用对象形式的分配可以这么写:

1
publisher.child_ids = author1 | author2

使用write()方法,同样的操作如下:

1
book.write( { 'child_ids': [(6, 0, [author1.id, author2.id])] } )

回顾第五章 Odoo 12开发之导入、导出以及模块数据的写入语法,最常用的命令如下:

  • (4, id, _)添加一条记录
  • (6, _, [ids])替换关联记录列表为所传入的列表

写入日期和时间值

从 Odoo 12开始,不论是直接分配还是使用 write()方法,日期和时间字段都可以 Python 原生数据类型写入。我们仍可以使用文本形式值写入日期和时间:

1
2
3
4
5
6
>>> demo = self.search([('login', '=', 'demo')])
>>> demo.login_date
False
>>> demo.login_date = '2019-01-01 09:00:00'
>>> demo.login_date
datetime.datetime(2019, 1, 1, 9, 0)

创建和删除记录

write()方法用于向已有记录写入日期,但我们还需要创建和删除记录。这通过create()和unlink()模型方法实现。create()接收所需创建记录字段和值组成的字典,语法与 write()一致。没错,默认值会被自动应用,如下所示:

1
2
3
4
5
6
>>> Partner = self.env['res.partner']
>>> new = Partner.create({'name': 'ACME', 'is_company': True})
>>> print(new)
res.partner(64,)
>>> print(new.customer) # customer标记默认为 True
True

unlink()方法会删除记录集中的记录,如下所示:

1
2
3
4
5
6
7
>>> rec = Partner.search([('name', '=', 'ACME')])
>>> rec.unlink()
2019-01-12 06:32:48,601 2612 INFO dev12 odoo.models.unlink: User #1 deleted mail.message records with IDs: [28]
2019-01-12 06:32:48,651 2612 INFO dev12 odoo.models.unlink: User #1 deleted ir.attachment records with IDs: [416, 415, 414]
2019-01-12 06:32:48,655 2612 INFO dev12 odoo.models.unlink: User #1 deleted res.partner records with IDs: [64]
2019-01-12 06:32:48,666 2612 INFO dev12 odoo.models.unlink: User #1 deleted mail.followers records with IDs: [7]
True

以上我们看到日志中几条其它记录被删除的消息,这些是所删除 partner 关联字段的串联删除。

还有copy()模型方法可用于复制已有记录,它接收一个可选参数来在新记录中修改值,如复制demo 用户创建一个新用户:

1
2
>>> demo = self.env.ref('base.user_demo')
>>> new = demo.copy({'name': 'Daniel', 'login': 'daniel', 'email': ''})

带有copy=False属性的字段不会被自动拷贝。to-many关联字段带有该标记时默认被禁用,因此也不可拷贝。

重构记录集

记录集还支持一些其它运算。我们可查看一条记录是否在记录集中。如果x是一个单例,并且my_recordset是一个包含多条记录的记录集,可使用如下代码:

  • x in my_recordset
  • x not in my_recordset

还能使用如下运算:

  • recordset.ids 返回记录集元素的ID列表
  • recordset.ensure_one()检查是否为单条记录(单例);若不是,则抛出ValueError异常
  • recordset.filtered(func)返回一个过滤了的记录集,func可以是一个函数或一个点号分隔的表达式来表示字段路径,可参见下面的示例。
  • recordset.mapped(func)返回一个映射值列表。除函数外,还可使用文本字符串作为映射的字段名。
  • recordset.sorted(func)返回一个排好序的记录值。除函数外,文本字符串可用作排序的字段名。reverse=True是其可选参数。

以下是这些函数的使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
>>> rs0 = self.env['res.partner'].search([])
>>> len(rs0)
48
>>> starts_A = lambda r: r.name.startswith('A')
>>> rs1 = rs0.filtered(starts_A)
>>> print(rs1)
res.partner(63, 59, 14, 35)
>>> rs1.sorted(key=lambda r: r.id, reverse=True)
res.partner(63, 59, 35, 14)
>>> rs2 = rs1.filtered('is_company')
>>> print(rs2)
res.partner(14,)
>>> rs2.mapped('name')
['Azure Interior']
>>> rs2.mapped(lambda r: (r.id, r.name))
[(14, 'Azure Interior')]

我们势必会对这些关联字段中的元素进行添加、删除或替换的操作,那么就带来了一个问题:如何操作这些记录集呢?

记录集是不可变的,也就是说不能直接修改其值。那么修改记录集就意味着在原有的基础上创建一个新的记录集。一种方式是使用所支持的集合运算:

  • rs1 | rs2是一个集合的并运算,会生成一个包含两个记录集所有元素的记录集
  • rs1 + rs2是集合加法运算,会将两个记录集拼接为一个记录集,这可能会带来集合中有重复记录
  • rs1 & rs2是集合的交集运算,会生成一个仅在两个记录集中同时出现元素组成的数据集
  • rs1 - rs2是集合的差集运算,会生成在rs1中有但rs2中没有的元素组成的数据集

还可以使用分片标记,例如:

  • rs[0]和rs[-1]分别返回第一个和最后一个元素
  • rs[1:]返回除第一元素外的记录集拷贝。其结果和rs - rs[0]相同,但保留了排序

ℹ️Odoo 10中的修改
从Odoo 10开始,记录集操作保留了排序。此前的 Odoo 版本中,记录集操作不一定会保留排序,虽然加运算和切片已知是保留排序的。

我们可以用如下运算通过删除或添加元素来修改记录集:

  • self.author_ids |= author1:如果不存在author1,它会将author1加入记录集
  • self.author_ids -= author1:如果author1存在于记录集中,会进行删除
  • self.author_ids = self.author_ids[:-1]删除最后一条记录

关联字段包含记录集值。many-to-one 可包含单例记录集,to-many字段包含任意数量记录的记录集。

使用底层 SQL 和数据库事务

数据库引入运算在一个数据库事务上下文中执行。通常我们无需担心这点,因为服务器在运行模型方法时会进行处理。但有些情况下,可能需要对事务进行更精细控制。这可通过数据库游标self.env.cr来实现,如下所示:

  • self.env.cr.commit()执行事务缓冲的写运算
  • self.env.cr.rollback()取消上次 commit之后的写运算,如果尚未 commit,则回滚所有操作

小贴士: 在shell会话中,直到执行self.env.cr.commit()时数据操作才会在数据库中生效

通过游标execute() 方法,我们可以直接在数据库中运行 SQL 语句。它接收一个要运行的SQL 语句,以及第二个可选参数:一个用作 SQL 参数值的元组或列表。这些值会用在%s占位符之处。

  • ℹ️注意:
    在cr.execute() 中我们不应直接编写拼接参数的SQL查询。众所周知这样做会带来SQL注入攻击的安全风险。保持使用%s占位符并通过第二个参数来传值。

如果使用SELECT查询,会获取到记录。fetchall() 函数以元组列表的形式获取所有行,dictfetchall()则以字典列表的形式获取,示例如下:

1
2
3
4
5
6
>>> self.env.cr.execute("SELECT id, login FROM res_users WHERE login=%s OR id=%s", ('demo',1))
>>> self.env.cr.fetchall()
[(1, '__system__'), (6, 'demo')]
>>> self.env.cr.execute("SELECT id, login FROM res_users WHERE login=%s OR id=%s", ('demo',1))
>>> self.env.cr.dictfetchall()
[{'id': 1, 'login': '__system__'}, {'id': 6, 'login': 'demo'}]

还可以使用数据操纵语言(DML) 来运行指令,如UPDATE和INSERT。因为服务器保留数据缓存,这可能导致与数据库中实际数据的不一致。出于这个原因,在使用原生DML后,应使用self.env.cache.invalidate()清除缓存。

ℹ️注意:
直接在数据库中执行SQL语句可能会导致数据不一致,请仅在确定时进行该操作。

总结

在本文中,我们学习了如何操作模型数据以及执行 CRUD 运算:创建、读取、更新和删除数据。这是实现我们的业务逻辑和自动化的基石。

对于ORM API的测试,我们使用了Odoo交互式 shell 命令行。我们通过self.env环境运行了命令,该环境可访问模型注册表并提供命令运行相关信息的上下文,如当前语言 lang 和时区 tz。

记录集使用search()或browse([])ORM 方法创建。之后可对其进行遍历访问每个单例(一条独立的记录)。我们还可以使用对象样式的点号标记在单例中获取和设置记录值。

除直接为单例分配值外,我们还可以使用write()来通过单条命令更新记录集中的所有元素。create(), copy()和unlink()命令用于创建、拷贝和删除记录。

记录集可被检查和操作,检查运算符包含in和not in。重构运算符包含并集的|,交集的&以及切片:。可用的转换包含提取 ID 列表的.ids、.mapped()、.filtered() 或.sorted()。

最后,通过self.env.cr中暴露的游标对象可控制底层 SQL 运行和事务控制。

在下一篇文章中,我们将为模型添加业务逻辑层,实现通过ORM API来自动化操作的模型方法。

 

☞☞☞第八章 Odoo 12开发之业务逻辑 - 业务流程的支持

本文首发地址:Alan Hou 的个人博客

本文为最好用的免费ERP系统Odoo 12开发手册系列文章第六篇。

在本系列文章第三篇Odoo 12 开发之创建第一个 Odoo 应用中,我们概览了创建 Odoo 应用所需的所有组件。本文及接下来的一篇我们将深入到组成应用的每一层:模型层、视图层和业务逻辑层。

本文中我们将深入学习模型层,以及学习如何使用模型来设计应用所需的数据结构。我们会探索模型和字段的各项作用,包括定义模型关系、添加计算字段、创建数据约束。

本文的主要内容有:

  • 学习项目 - 优化图书馆应用
  • 创建模型
  • 创建字段
  • 模型间的关系
  • 计算字段
  • 模型约束
  • 了解 Odoo的 base 模型

开发准备

本文代码基于第三章 Odoo 12 开发之创建第一个 Odoo 应用中所创建的代码。相关代码参见 GitHub 仓库,本文学习完成项目请参见GitHub 仓库。相关代码需放在一个 addons 路径中,然后在 Odoo中安装了 library_app 模型,本文中例子将会对该模块修改和新增代码。

学习项目 - 优化图书应用

在第三章 Odoo 12 开发之创建第一个 Odoo 应用中,我们创建了一个library_app插件模块,实现了一个简单的library.book模型用于展示图书目录。本文中,我们将回到该模块来丰富图书数据。我们将添加一个分类层级,添加如下用作图书分类:

  • Name:分类标题
  • Parent:所属父级分类
  • Subcategories:将此作为父级分类的子分类
  • Featured book或author: 此分类中所选图书或作者

图书模型中已有一些基本信息字段,我们会添加一些字段来展示 Odoo中的数据类型。我们还会为图书模型添加一些约束:

  • 标题和出版日期应唯一
  • 输入的ISBN应为有效

创建模型

模型是 Odoo 框架的核心,它们描述应用的数据结构,是应用服务和数据库存储之间的桥梁。可围绕模型实现业务逻辑来为应用添加功能,用户界面也建立在模型之上。下面我们将学习模型的通用属性,用于影响行为,以及几种模型类型:普通(regular)、临时(transient)和抽象(abstract)类型。

模型属性

模型类可以使用控制其部分行为的额外属性,以下是最常用的属性:

  • _name 是我们创建的 Odoo 模型的内部标识符,在创建新模型时为必填。
  • _description是对用户友好的模块记录标题,在用户界面中查看模型时显示。可选但推荐添加。
  • _order设置浏览模型记录时或列表视图的默认排序。其值为 SQL 语句中 order by 使用的字符串,所以可以传入符合 SQL 语法的任意值,它有智能模式并支持可翻译及many-to-one字段名。

我们的图书模型中已使用了_name 和_description属性,可以添加一个_order属性来默认以图书名排序,然后按出版日期倒序排(新出版在前)。

1
2
3
4
class Book(models.Model):
_name = 'library.book'
_description = 'Book'
_order = 'name, date_published desc'

在高级用例中还会用到如下属性:

  • _rec_name在从关联字段(如many-to-one关联)中引用时作为记录描述。默认使用模型中常用的 name字段,但可以指定任意其它字段。
  • _table是模型对应的数据表名。默认表名由 ORM 通过替换模块名中的点为下划线来自动定义,但是可通过该属性指定表名。
  • _log_access=False用于设置不自动创建审计追踪字段:create_uid, create_date, write_uid和write_date。
  • _auto=False 用于设置不自动创建模型对应的数据表。如有需要,可通过重载init()方法来创建数据库对象:数据表或视图。

还有用于继承模块的_inherit和_inherits属性,在本文后续会深入学习。

模型和 Python 类

Odoo 模型以 Python 类的形式展现,在前面的代码中,有一个继承了 models.Model类的 Python 类:Book,创建了新 Odoo 模型:library.book。Odoo的模型保存在中央注册表(central registry)中,可通过 env 环境对象(老 API 中称为 pool)获取。 它是一个数据库保存所有可用模型类引用的字典,其中的词条可通过模型名引用 。具体来说,模型方法中的代码可使用self.env[‘library.book’]来获取表示 library.book模型的模型类。

可以看出模型名非常重要,因为它是访问该注册表的关键。模型名的规则是以点号连接的小写单词,如library.book或library.book.category。内核模块中的其它示例有project.project, project.task和project.task.type。模型名应使用单数,如library.book而非library.books。

ℹ️由于历史原因,有些内核模型没有遵循这一规则,如res.users。

模型名必须全局唯一,因此第一个单词应使用模块关联的主应用对应,以图书应用而言,模型名前缀使用 library。其它示例如内核模块的project, crm和sale。另一方面 Python 类仅为所声明文件本地内容,名称仅需在代码文件中唯一即可。因为类名不会与其它模块中的类产生冲突,也就不需为其添加主应用相关的前缀。

类的命名规范是使用驼峰命名法(CamelCase),这与 Python 标准的 PEP8编码规范一致。

临时(Transient)模型和抽象模型

在前述代码中以及在大多数据 Odoo 模型中的类会继承models.Model类。这类模型在数据库中持久化存储:会为模型创建数据表并存储记录直至删除。但 Odoo 中还有另外两种模型类型:临时模型和抽象模型。

临时模型继承models.TransientModel类,用于向导式的用户交互。这类数据会存储在数据库中,但仅是临时性的。会定时运行清空 job 来清除这些表中的老数据。比如Settings > Translations菜单下的Load a Language对话窗口,就使用了临时模型来存储用户选择并实现向导逻辑。在第八章 Odoo 12开发之业务逻辑 - 业务流程的支持中会有讨论临时模型的示例。

抽象模型继承models.AbstractModel类,它不带有数据存储。抽象模型用作可复用的功能集,与使用 Odoo 继承功能的其它模型配合使用。例如mail.thread是 Discuss 应用中的一个抽象模型,用于为其它模型添加消息和follower 功能。

检查已有模型

通过 Python 类创建的模型和字段在用户界面中有自己的元标签。启动开发者模式,访问菜单Settings > Technical > Database Structure > Models,这里有数据库中的所有模型。点击列表中的模型会打开详情表单:

Odoo 12图书模型

这是一个检查模型结构很好的工具,因为在这里可以看到不同模块所有自定义结果。上图中在右上角 In Apps字段中可以看到library.book模型的定义来自library_app和library_member两个模块。下方区域中还有几个包含附加信息的标签:

  • Fields可快速查看模型字段
  • Access Rights是授予不同权限组的访问控制规则
  • Views显示模型所带的视图列表

我们可以通过开发者菜单下的View Metadata选项查看模型的外部标识符。模型的外部标识符或XML ID由 ORM 自动生成,但根据规则可预知,如library.book模型的外部标识符为model_library_book。在定义安全访问控制列表经常在 CSV 文件中使用到这些XML ID。

小贴士: 如第一章 使用开发者模式快速入门 Odoo 12中所见,模型表单是可编辑的。通过这里是可以创建并修改模型、字段和视图的。可在此处创建原型然后在插件模块中实现。

创建字段

创建新模型后的第一步是添加字段。Odoo 支持我们能想到的所有基本数据类型,如文本字符串、整型、浮点型、布尔型、日期、日期时间以及图片或二进制数据。下面就来看看 Odoo 中一些可用的字段类型吧。

基本字段类型

我们将为图书模型添加几种可用的字段类型,编辑library_app/models/library_book.py文件后 Book 类会长这样:

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
28
29
30
31
32
class Book(models.Model):
...
# String fields
name = fields.Char('Title', required=True)
isbn = fields.Char('ISBN')
book_type = fields.Selection(
[('paper', 'Paperback'),
('hard', 'Hardcover'),
('electronic', 'Electronic'),
('other', 'Other')],
'Type')
notes = fields.Text('Internal Notes')
descr = fields.Html('Description')

# Numeric fields:
copies = fields.Integer(default=1)
avg_rating = fields.Float('Average Rating', (3,2))
price = fields.Monetary('Price', 'currency_id')
currency_id = fields.Many2one('res.currency') # price helper

# Date and time fields
date_published = fields.Date()
last_borrow_date = fields.Datetime(
'Last Borrowed On',
default=lambda self: fields.Datetime.now())

# Other fields
active = fields.Boolean('Active?', default=True)
image = fields.Binary('Cover')

# Relational Fields
...

此处是 Odoo 中所带的非关联字段示例,每个字段都带有所需的位置参数。

ℹ️Python 中有两类参数:位置参数和关键字参数。位置参数需按指定顺序使用。例如,f(x, y)应以f(1, 2)方式调用。关键字参数通过参数名传递。如同一个例子,可使用f(x=1, y=2)甚至是f(1, y=2)两种传参方式混用。更多有关关键字参数知识参见 Python 官方文档

对于大多数非关联字段,第一个参数是字段标题,与字符串字段参数相对应。它用作用户界面标签的默认文本。这个参数是可选的,如未传入,会根据字段名将下划线替换为空格并将单词首字母大写来自动生成。以下为可用的非关联字段类型以及其对应的位置参数:

  • Char(string)是一个单行文本,唯一位置参数是string字段标签。
  • Text(string)是一个多行文本,唯一位置参数是string字段标签。
  • Selection(selection, string)是一个下拉选择列表。选项位置参数是一个[(‘value’, ‘Title’),]元组列表。元组第一个元素是存储在数据库中的值,第二个元素是展示在用户界面中的描述。该列表可由其它模块使用selection_add关键字参数扩展。
  • Html(string)存储为文本字段,但有针对用户界面 HTML 内容展示的特殊处理。出于安全考虑,该字段会被清洗,但清洗行为可被重载。
  • Integer(string)仅需字段标题字符串参数。
  • Float(string, digits)带有第二个可选参数digits,该字段是一个指定字段精度的(x,y)元组,x 是数字总长,y 是小数位。
  • Monetary(string, currency_field)与浮点字段类似,但带有货币的特殊处理。第二个参数currency_field用于存储所使用货币,默认应传入currency_id字段。
  • Date(string)和Datetime(string)字段只需一个字符串文本位置参数。
  • Boolean(string)的值为True 或False,可传入一个字符串文本位置参数。
  • Binary(string)存储文件类二进制文件,只需一个字符串文本位置参数。它可由Python使用 base64编码字符串进行处理。

ℹ️Odoo 12中的修改
Date和Datetime 字段现在 ORM 中作为日期对象处理。此前的版本中作为文本字符串处理,进行操作时需与 Python 日期对象间进行转换。

文本字符串:Char, Text和Html有一些特有属性:

  • size (Char)设置最大允许尺寸。无特殊原因建议不要使用,例如可用于带有最大允许长度的社保账号。
  • translate使用得字段内容可翻译,带有针对不同语言的不同值。
  • trim默认值为 True,启动在网络客户端中自动去除周围的空格。可通过设置trim=false来取消。

ℹ️Odoo 12中的修改
trim字段属性在 Odoo 12中引入,此前版本中文本字段保存前后的空格。

除这些以外,还有在后面会介绍到的关联字段。不过, 我们还要先了解下有关字段属性的其它知识。

常用字段属性

字段还有一些其它属性供我们定义其行为。以下是常用的属性,通常都作为关键字参数:

  • string是字段的默认标签,在用户界面中使用。除Selection和关联字段外,它都是第一个位置参数,所以大多数情况下它用作关键字参数。如未传入,将由字段名自动生成。
  • default设置字段默认值。可以是具体值(如 active字段中的default=True),或是可调用引用,有名函数或匿名函数均可。
  • help提供 UI 中鼠标悬停字段向用户显示的提示文本。
  • readonly=True会使用户界面中的字段默认不可编辑。在 API 层面并没有强制,模型方法中的代码仍然可以向其写入。仅针对用户界面设置。
  • required=True使得用户界面中字段默认必填。这通过在数据库层面为列添加NOT NULL 约束来实现。
  • index=True为字段添加数据库索引,让搜索更快速,但同时也会部分降低写操作速度。
  • copy=False让字段在使用 ORM copy()方法复制字段时忽略该字段。除 to-many 关联字段外,其它字段值默认会被复制。
  • groups可限制字段仅对一些组可访问并可见。值为逗号分隔的安全组XML ID列表,如groups=’base.group_user,base.group_system’。
  • states传入依赖 state字段值的 UI 属性的字典映射值。可用属性有readonly, required和invisible,例如states={‘done’:[(‘readonly’,True)]}。

ℹ️注意states 字段等价于视图中的 attrs 属性。同时注意视图也支持 states 属性,但用途不同,传入逗号分隔的状态列表来控制元素什么时候可见。

以下为字段属性关键字参数的使用示例:

1
2
3
4
5
6
7
8
9
name = fields.Char(
'Title',
default=None,
index=True,
help='Book cover title',
readonly=False,
required=True,
translate=False,
)

如前所述,default 属性可带有固定值,或引用函数来自动计算默认值。对于简单运算,可使用 lambda 函数来避免过重的有名函数或方法的创建。以下是一个计算当前日期和时间默认值的常用示例:

1
2
3
4
last_borrow_date = fields.Datetime(
'Last Borrowed On',
default=lambda self: fields.Datetime.now(),
)

默认值也可以是一个函数引用,或待定义函数名字符串:

1
2
3
4
5
6
7
last_borrow_date = fields.Datetime(
'Last Borrowed On',
default='_default_last_borrow_date',
)

def _default_last_borrow_date(self):
return fields.Datetime.now()

当模块数据结构在不同版本中变更时以下两个属性非常有用:

  • deprecated=True在字段被使用时记录一条 warning 日志
  • oldname=’field’是在新版本中重命名字段时使用,可在升级模块时将老字段中的数据自动拷贝到新字段中

特殊字段名

一些字段名很特别,可能是因为它们出于特殊目的作为 ORM 保留字,或者是由于内置功能使用了一些默认字段名。id 字段保留以用作标识每条记录的自增数字以及数据库主键,每个模型都会自动添加。

以下字段只要模型中没设置_log_access=False都会在新模型中自动创建:

  • create_uid为创建记录的用户
  • create_date是记录创建的日期和时间
  • write_uid是最后写入记录的用户
  • write_date是最后修改记录的日期和时间

每条记录的这些字段信息都可通过开发者菜单下的View Metadata进行查看。一些内置 API 功能默认需要一些指定字段名。避免在不必要的场合使用这些字段名会让开发更轻松。其中有些字段名被保留并且不能在其它地方使用:

  • name (通常为 Char)默认作为记录的显示名称。通过是一个 Char,但也可以是 Text 或Many2one字段类型。用作显示名的字段可修改为_rec_name模型属性。
  • active (Boolean型)允许我们关闭记录。带有active=False的记录会自动从查询中排除掉。可在当前上下文中添加{‘active_test’: False} 来关闭这一自动过滤。可用作记录存档或假删除(soft delete)。
  • state (Selection类型) 表示记录生命周期的基本状态。它允许使用states字段属性来根据记录状态以具备不同的 UI 行为。动态修改视图:字段可在特定记录状态下变为readonly, required或invisible。
  • parent_id和parent_path Integer和Char型)对于父子层级关系具有特殊意义。本文后续会进行讨论。

ℹ️Odoo 12中的修改
层级关联现在使用parent_path字段,它替代了老版本中已淘汰的parent_left和 parent_right字段(整型)。

到目前为止我们讨论的都是非关联字段。但应用数据结构中很大一部分是描述实体间关联的。下面就一起来学习。

模型间的关系

中、大型业务应用有一个结构数据模型,需要关联所涉及到的不同实体间的数据。要实现这点,需要使用关联字段。再来看看我们的图书应用,图书模型中有如下关系:

  • 每本书有一个出版商。这是一个many-to-one 关联,在数据库引擎中通过外键实现。反过来则是one-to-many关联,表示一个出版商可出版多本书。
  • 每本书可以有多名作者。这是一个many-to-many关联,反过来还是many-to-many关联,因为一个作者也可以有多本书。

下面我们就会分别讨论这些关联。具体的用例就是层级关联,即一个模型中的记录与同模型中的其它记录关联。我们将引入一个图书分类模型解释这一情况。最后,Odoo 框架还支持弹性关系,即一个字段可指向其它表中的字段,这称为引用字段。

Many-to-one关联

many-to-one关联是对其它模型中记录的引用,例如在图书模型中,publisher_id表示图书出版商,是对partner记录的一个引用:

1
2
publisher_id = fields.Many2one(
'res.partner', string='Publisher')

与所有关联字段一样,Many2one字段的第一个位置参数是关联模型(comodel关键字参数)。第二位置参数是字段标签(string关键字参数),但这和其它关联字段不同,所以推荐使用像以上代码一样一直使用string关键字参数。

many-to-one模型字段在数据表中创建一个字段,并带有指向关联表的外键,其中为关联记录的数据库 ID。以下是many-to-one字段可用的关键字参数:

  • ondelete定义关联记录删除时执行的操作:

    • set null (默认值): 关联字段删除时会置为空值
    • restricted:抛出错误阻止删除
    • cascade:在关联记录删除时同时删除当前记录
  • context是一个数据字典,可在浏览关联时为网页客户端传递信息,比如设置默认值。第八章 Odoo 12开发之业务逻辑 - 业务流程的支持中会做深入说明。

  • domain是一个域表达式:使用一个元组列表过滤记录来作为关联记录选项,第八章 Odoo 12开发之业务逻辑 - 业务流程的支持中会详细说明。

  • auto_join=True允许ORM在使用关联进行搜索时使用SQL连接。使用时会跳过访问安全规则,用户可以访问安全规则不允许其访问的关联记录,但这样 SQL 的查询会更有效率且更快。

  • delegate=True 创建一个关联记录的代理继承。使用时必须设置required=True和ondelete=’cascade’。代理继承更多知识参见第四章 Odoo 12 开发之模块继承

One-to-many反向关联

one-to-many关联是many-to-one的反向关联。它列出引用该记录的关联模型记录。比如在图书模型中,publisher_id与 parnter 模型是一个many-to-one关联。这说明partner与图书模型可以有一个one-to-many的反向关联,列出每个出版商出版的图书。

要让关联可用,我们可在 partner 模型中添加它,在library_app/models/res_partner.py文件中添加如下代码:

1
2
3
4
5
6
7
8
from odoo import fields, models

class Partner(models.Model):
_inherit = 'res.partner'
published_book_ids = fields.One2many(
'library.book', # related model
'publisher_id', # fields for "this" on related model
string='Published Books')

我们向模块添加了新文件,所以不要忘记在library_app/models/init.py中导入该文件:

1
2
from . import library_book
from . import res_partner

One2many字段接收三个位置参数:

  • 关联模型 (comodel_name关键字参数)
  • 引用该记录的模型字段 (inverse_name关键字参数)
  • 字段标签 (string关键字参数)

其它可用的关键字参数与many-to-one字段相同:context, domain和ondelete(此处作用于关联中的 many 这一方)。

Many-to-many关联

在两端都存在to-many关联时使用many-to-many关联。还是以我们的图书应用为例,书和作者之间是many-to-many关联:一本书可以有多个作者,一个作者可以有多本书。图书端有一个library.book模型:

1
2
3
4
5
class Book(models.Model):
_name = 'library.book'
...
author_ids = fields.Many2many(
'res.partner', string='Authors')

在作者端,我们也可以为res.partner添加一个反向关联:

1
2
3
4
class Partner(models.Model):
_inherit = 'res.partner'
book_ids = fields.Many2many(
'library.book', string='Authored Books')

Many2many最少要包含一个关联模型位置参数(comodel_name关键字参数),推荐为字段标签提供一个string参数。

在数据库层面上,many-to-many关联不会在已有表中添加任何列。而是自动创建一个关联表来存储记录间的关联,该表仅有两个 ID 字段,为两张关联表的外键。默认关联表名由两个表名中间加下划线并在最后加上_rel 来组成。我们图书和作者关联,表名应为library_book_res_partner_rel。

有时我们可能需要重写这种自动生成的默认值。一种情况是关联模型名称过长,导致关联表名的长度超出PostgreSQL数据库63个字符的上限。这时就需要手动选择一个关联表名来符合字符数据要求。另一种情况是我们需要在相同模型间建立第二张many-to-many关联表。这时也需要手动提供一个关联表名来避免与已存在的第一张表名冲突。

有两种方案来重写关联表名:位置参数或关键字参数。通过字段位置参数定义示例如下:

1
2
3
4
5
6
7
# Book <-> Authors关联(使用位置参数)
author_ids = fields.Many2many(
'res.partner', # 关联模型(尾款)
'library_book_res_partner_rel', # 要使用的关联表名
'a_id', # 本记录关联表字段
'p_id', # 关联记录关联表字段
'Authors') # string标签文本

要使可读性更强,也可使用关键字参数:

1
2
3
4
5
6
7
# Book <-> Authors关联(使用关键字参数)
author_ids = fields.Many2many(
comodel_name='res.partner', # 关联模型(必填)
relation='library_book_res_partner_rel', # 关联表名
column1='a_id', # 本记录关联表字段
column2='p_id', # 关联记录关联表字段
string='Authors') # string标签文本

与one-to-many relational字段相似,many-to-many 字段还可以使用context, domain和auto_join这些关键字参数。

ℹ️在创建抽象模型时,many-to-many中不要使用column1和column2属性。在 ORM 设计中对抽象模型有一个限制,如果指定关联表列名,就无法再被正常继承。

层级关联

父子树状关联使用同一模型中many-to-one关联表示,来将每条记录引用其父级。反向的one-to-many关联对应记录的子级。Odoo 通过域表达式附加的child_of和parent_of操作符改良了对这些层级数据结构的支持。只要这些模型有parent_id字段(或_parent_name有效模型定义)就可以使用这些操作符。

通过设置_parent_store=True和添加parent_path帮助字段可加快层级树的查询速度。该字段存储用于加速查询速度的层级树结构信息。

ℹ️Odoo 12中的修改
parent_path帮助字段在 Odoo 12中引入。此前版本中使用parent_left和parent_right整型字段来实现相同功能,但在 Odoo 12中淘汰了这些字段。

注意这些附加操作会带来存储和执行速度的开销,所以最好是用到读的频率大于写的情况下,比如本例中的分类树。仅在优化多节点深度层级时才需要使用,对于小层级或浅层级的可能会被误用。

为演示层级结构,我们将为图书应用添加一个分类树,用于为图书分类。在library_app/models/library_book_category.py文件中添加如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from odoo import api, fields, models

class BookCategory(models.Model):
_name = 'library.book.category'
_description = 'Book Category'
_parent_store = True

name = fields.Char(translate=True, required=True)
# Hierarchy fields
parent_id = fields.Many2one(
'library.book.category',
'Parent Category',
ondelete='restrict')
parent_path = fields.Char(index=True)

# Optional but good to have:
child_ids = fields.One2many(
'library.book.category',
'parent_id',
'Subcategories')

这里定义了一个基本模型,包含引用父级记录的parent_id字段。为启用层级索引来加快树级搜索,添加了一个_parent_store=True 模型属性。使用该属性必须要添加且必须要索引parent_path字段。引用父级的字段名应为parent_id,但如果声明了可选的_parent_name模型属性,则可以使用任意其它字段名。

添加字段列出直接的子级非常方便,即为上述代码中的one-to-many反向关联。还有不要忘记在library_app/models/init.py文件中添加对以上代码的引用:

1
2
3
from . import library_book
from . import res_partner
from . import library_book_category

使用引用字段的弹性关联

普通关联字段指定固定的引用co-model模型,但Reference字段类型不受这一限制,它支持弹性关联,因此相同字段不用限制只指向相同的目标模型。作为示例,我们使用图书分类模型来添加引用重点图书或作者。因此该字段可引用图书或 partner:

1
2
3
4
5
6
class BookCategory(models.Model):
...
highlighted_id = fields.Reference(
[('library.book', 'Book'), ('res.partner', 'Author')],
'Category Highlight'
)

该字段定义与 selection 字段相似,但这里选择项为该字段中可以使用的模型。在用户界面中,用户会先选择列表中的模型,然后选择模型中的指定记录。

ℹ️Odoo 12中的修改
删除了可引用模型配置表。在此前版本中,可用于配置在 Reference 字段中可用的模型。通过菜单Settings > Technical > Database Structure可进行查看。这些配置可在 Reference 字段中使用odoo.addons.res.res_request.referenceable_models函数来替代模型选择列表。

以下为有关引用字段的一些其它有用技术细节:

  • 引用字段在数据库中以model,id字符串形式存储
  • read()方法供外部应用使用,以格式化的(‘model_name’, id)元组返回,而不是常用的many-to-one字段的(id, ‘display_name’)形式

计算字段

字段值除普通的读取数据库中存储值外,还可自动由函数计算。计算字段的声明和普通字段相似,但有一个额外的compute参数来定义用于计算的函数。大多数情况下,计算字段包含书写业务逻辑。因此要完全使用这一功能,还要学习第八章 Odoo 12开发之业务逻辑 - 业务流程的支持。此处我们将解释计算字段用法,但会使用简单的业务逻辑。

图书有出版商,我们的例子是在图书表单中添加出版商的国别。实现该功能,我们会使用基于publisher_id的计算字段,将会从出版商的country_id字段中获取值。

编辑library_app/models/library_book.py文件中的图书模型,代码如下:

1
2
3
4
5
6
7
8
9
10
11
class Book(models.Model):
...
publisher_country_id = fields.Many2one(
'res.country', string='Publisher Country',
compute='_compute_publisher_country'
)

@api.depends('publisher_id.country_id')
def _compute_publisher_country(self):
for book in self:
book.publisher_country_id = book.publisher_id.country_id

以上代码添加了一个publisher_country_id字段,和一个计算其值的_compute_publisher_country方法。方法名作为字符串参数传入字段中,但也可以传递一个可调用引用(方法标识符,不带引号)。但这时需确定Python 文件中方法在字段之前定义。

计算如果依赖其它字段的话就需要使用@api.depends装饰器,通常都会依赖其它字段。它告诉服务器何时重新计算或缓存值。参数可接受一个或多个字段名,点号标记可用于了解字段关联。本例中,只要图书publisher_id的country_id变更了就会重新进行计算。

和平常一样,self 参数是要操作的字符集对象。我们需要对其遍历来作用于每条记录。计算值通过常用(写)操作来设置,本例中计算相当简单,我们为其分配当前图书的publisher_id.country_id值。

同样的计算方法可用于一个以上字段。这时同一方法在多个compute 字段参数中使用,计算方法将为所有计算字段分配值。

小贴士: 计算函数必须为一个或多个字段分配值用于计算。如果计算方法有 if 条件分支,确保每个分支中为计算字段分配了值。否则在未分配置值的分支中将会报错。

现在我们还不会修改该模块的视图,但可通过在图书表单视图中点击开发者菜单中的Edit View选项,直接在表单 XML 中添加该字段查看效果。不必担心出问题,在下次模块升级时会进行覆盖。

Odoo 12图书项目出版商国家

搜索和写入计算字段

我们刚刚创建的计算字段可读取但不可搜索或写入。默认情况下计算字段是实时计算,而不存储在数据库中。这也是无法像普通字段那样进行搜索的原因。

我们可通过实现特殊方法来开启搜索和写入操作。计算字段可与 compute 方法一起设置实现搜索逻辑的 search 方法,以及实现写入逻辑的 inverse 方法。使用这些方法,计算字段可修改如下:

1
2
3
4
5
6
7
8
9
class Book(models.Model):
...
publisher_country_id = fields.Many2one(
'res.country', string='Publisher Country',
compute='_compute_publisher_country',
# store = False, # 默认不在数据库中存储
inverse='_inverse_publisher_country',
search='_search_publisher_country',
)

计算字段中的写入是计算的反向(inverse)逻辑。因此处理写入操作的方法称为 inverse,本例中 inverse 方法很简单。计算将book.publisher_id.country_id 的值复制给book.publisher_country_id,反向操作是将写入book.publisher_country_id的值拷贝给book.publisher_id.country_id field字段:

1
2
3
def _inverse_publisher_country(self):
for book in self:
book.publisher_id.country_id = book.publisher_country_id

注意这会修改出版商partner记录数据,因此也会修改相同出版商图书的相关字段。常规权限控制对这类写操作有效,因此仅有对 partner 模型有写权限的当前用户才能成功执行操作。

要为计算字段开启搜索操作,需要实现search 方法。为此我们需要能够将计算字段的搜索转换为使用常规存储字段的搜索域。本例中,实际的搜索可通过关联的publisher_id Partner 记录的country_id来实现:

1
2
def _search_publisher_country(self, opearator, value):
return [('publisher_id.country_id', operator, value)]

在模型上执行搜索时,域表达式用作实施过滤的参数。域表达式在第八章 Odoo 12开发之业务逻辑 - 业务流程的支持会做详细讲解,现在我们应了解它是一系列(field, operator, value)条件。

当域表达式的条件中出现该计算字段时就会调用这个搜索方法。它接收搜索的操作符和值,并将原搜索元素转换为一个域搜索表达式。country_id字段存储在关联的partner模型中,因此我们的搜索实现仅需修改原搜索表达式来使用publisher_id.country_id字段。

存储计算字段

通过在定义时设置store = True还可以将计算字段值保存到数据库中。在任意依赖变更时值就会重新计算。因为值已被存储,所以可以像普通字段一样被搜索,也就不需要使用 search 方法了。

关联字段

前面我们实现的计算字段仅仅是从关联记录中将值拷贝到模型自己的字段中。这种常用情况可以由 Odoo 使用关联字段功能自动处理。关联字段通过关联模型的字段可在模型中直接可用,并且可通过点号标记法直接访问。这样在点号标记法不可用时(如 UI 表单视图)也可以使用该字段。

要创建关联字段,我们像普通计算字段那样声明一个所需类型的字段,但使用的不是 compute 属性,而是 related属性,设置用点号标记链来使用所需字段。我们可以使用引用字段来获取与上例publisher_country_id计算字段相同的效果:

1
2
3
4
publisher_country_id = fields.Many2one(
'res.country', string='Publisher Country',
related='publisher_id.country_id',
)

本质上关联字段仅仅是快捷实现 search 和 inverse 方法的计算字段。也就是说可以直接对其进行搜索和写入,而无需书写额外的代码。默认关联字段是只读的,因inverse写操作不可用,可通过readonly=False字段属性来开启写操作。

ℹ️Odoo 12中的修改
现在关联字段默认为只读:readonly=True。此前版本中它默认可写,但事实证明这是一个默认值,因为它可能会允许修改配置或主数据这些不应被修改的数据。

还应指出这些关联字段和计算字段一样可使用store=True来在数据库中存储。

模型约束

通常应用需保证数据完整性,并执行一些验证来保证数据是完整和正确的。PostgreSQL数据库管理器支持很多可用验证:如避免重复,或检查值以符合某些简单条件。模型为此可声明并使用 PostgreSQL约束。一些检查要求更复杂的逻辑,最好是使用 Python 代码来实现。对这些情况,我们可使用特定的模型方法来实现 Python 约束逻辑。

SQL模型约束

SQL约束加在数据表定义中,并由PostgreSQL直接执行。它由_sql_constraints类属性来定义。这是一个元组组成的列表,并且每个元组的格式为(name, code, error):

  • name是约束标识名
  • code是约束的PostgreSQL语法
  • error是在约束验证未通过时向用户显示的错误消息

我们将向图书模型添加两个SQL约束。一条是唯一性约束,用于通过标题和出版日期是否相同来确保没有重复的图书;另一条是检查出版日期是否为未出版:

1
2
3
4
5
6
7
8
9
10
class Book(models.Model):
...
_sql_constraints = [
('library_book_name_date_uq', # 约束唯一标识符
'UNIQUE (name, date_published)', # 约束 SQL 语法
'Book title and publication date must be unique'), # 消息
('library_book_check_date',
'CHECK (date_published <= current_date)',
'Publication date must not be in the future.'),
]

更多有关PostgreSQL约束语法,请参见官方文档

Python模型约束

Python 约束可使用自定义代码来检查条件。检查方法应添加@api.constrains装饰器,并且包含要检查的字段列表,其中任意字段被修改就会触发验证,并且在未满足条件时抛出异常。就图书应用来说,一个明显的示例就是防止插入不正确的 ISBN 号。我们已经在_check_isbn()方法中书写了 ISBN 的校验逻辑。可以在模型约束中使用它来防止保存错误数据:

1
2
3
4
5
6
7
8
9
from odoo.exceptions import ValidationError

class Book(models.Model):
...
@api.constrains('isbn')
def _constrain_isbn_valid(self):
for book in self:
if book.isbn and not book._check_isbn():
raise ValidationError('%s is an invalid ISBN' % book.isbn)

了解 Odoo的 base 模型

在前面文章中,我们一起创建了新模型,如图书模型,但也使用了已有的模型,如  Odoo 自带的Partner 模型。下面就来介绍下这些内置模型。Odoo 内核中有一个base插件模块。它提供了 Odoo 应用所需的基本功能。然后有一组内置插件模块来提供标准产品中的官方应用和功能。base模块中包含两类模型:

  • 信息仓库(Information Repository), ir.*模型
  • 资源(Resources), res.*模型

信息仓库用于存储 Odoo 所需数据,以知道如何作为应用来运作,如菜单、视图、模型、Action 等等。Technical菜单下的数据通常都存储在信息仓库中。相关的例子有:

  • ir.actions.act_window用于窗口操作
  • ir.ui.menu用于菜单项
  • ir.ui.view用于视图
  • ir.model用于模型
  • ir.model.fields用于模型字段
  • ir.model.data用于XML ID

资源包含基本数据,基本上用于应用。以下是一些重要的资源模型:

  • res.partner用于业务伙伴,如客户、供应商和地址等等
  • res.company用于公司数据
  • res.currency用于货币
  • res.country用于国家
  • res.users用于应用用户
  • res.groups用于应用安全组

这些应该有助于你在未来遇到这些模型时理解它们来自何处。

总结

学习完本文,我们熟悉了模型带给我们构造数据模型的可能性。我们看到模型通常继承models.Model类,但还可使用models.Abstract来创建可复用的 mixin 模型、使用models.Transient来创建向导或高级用户对话。我们还学习了常见的模型属性,如_order 用于排序,_rec_name用于记录展示的默认值。

模型中的字段定义了所有它存储的数据。我们了解了可用的非关联字段类型以及它们支持的属性。我们还学习了关联字段的几种类型:many-to-one, one-to-many和many-to-many,以及它们如何定义模型间的关系,包括层级父子关系。

大多数字段在数据库中存储用户的输入,但字段也可以通过 Python 代码自动计算值。我们看到了如何实现计算字段,以及一些高级用法,如使计算字段可写及可搜索。

还有模型定义的一部分是约束,保持数据一致性和执行验证,可以通过PostgreSQL或Python代码实现。

一旦我们创建了数据模型,就应该为它提供一些默认和演示数据。在下一篇文章中我们将学习如何使用数据文件在系统中导入、导出和加载数据。

 

☞☞☞第七章 Odoo 12开发之记录集 - 使用模型数据

本文首发地址:Alan Hou 的个人博客

本文为最好用的免费ERP系统Odoo 12开发手册系列文章第五篇。

大多数Odoo 模块的定义,如用户界面和安全规则,实际是存储在对应数据表中的数据记录。模块中的 XML 和 CSV 文件不是 Odoo 应用运行时使用,而是载入数据表的手段。正是因为这个原因,Odoo 模块的一个重要部分是在文件中放入数据以在插件安装时将其载入数据库。

模块可以包含初始数据和演示数据,可通过数据文件将它们加入模块。此外,了解 Odoo 数据的格式对于在项目实施上下文中导入导出业务数据也非常重要。

本文的主要内容有:

  • 理解外部标识符的概念
  • 导入导出数据文件
  • 使用 CSV 文件
  • 添加模块数据
  • 使用 XML 数据文件

开发准备

本文要求读者可以运行Odoo 服务并已安装前面我们此前开发的图书应用。相关代码请见GitHub 仓库。你可能也同时安装了第四章 Odoo 12 开发之模块继承中创建的library_member模块,但本文并不要求使用该模型。

本文的更新后的代码请见GitHub 仓库

理解外部标识符的概念

外部标识符,也称为XML ID,是用于唯一标识 Odoo 中特定记录的有可读性的字符串标识符。在Odoo 中加载数据时它们就很重要了,这样可以对已有数据记录进行修改或在其它数据记录中引用它。

首先我们将讨论外部标识符的工作原理以及如何对其进行检查。然后我们会学习如何使用网页客户端来查找指定数据记录的外部标识符,在创建插件模块或继承已有模块时需要经常用到。

外部标识符的工作原理

记录在数据库中的真实标识符是自动分配的序列号,在安装模块时没法预先知道将要分配的具体ID的。外部标识符让我们无需知道真实的数据库 ID便可以引用一条相关记录。XML ID 为数据库 ID 提供了一个方便的别名,藉于此我们可以在任何时刻引用某一指定记录。

Odoo 模块数据文件中使用XML ID来定义记录。其中一个原因是避免在升级模块时创建重复的记录,在升级时会再次将数据文件加载到数据库中。我们要检测已有记录来进行更新,而不是重复创建记录。另一个原因是使用XML ID来支持交叉数据:即需引用其它数据记录的数据记录。因为我们无法知道真实数据库 ID,使用XML ID来由 Odoo 框架来进行相应的转换。

Odoo 处理由外部标识符向所分配的真实数据库 ID 的转换。背后的机制相当简单:Odoo 维护一张外部标识符和对应数据库 ID 的映射表:ir.model.data model。

我们需启用开发者模式才能访问下文中的菜单。可通过在右上角头像左侧查看是否有调试图标,如果没有需在 Settings菜单页启用,具体方法可参照第一章 使用开发者模式快速入门 Odoo 12中的内容。

通过菜单访问Settings > Technical > Sequences & Identifiers > External Identifiers可查看已有映射。例如访问外部标识符列表并过滤出library_app模块,将可以看到该模块生成的外部标识符:

Odoo 12图书项目外部标识符

可以看到外部标识符有Complete ID标签。注意其组成部分为:模块名+.+标识符名,如library_app.action_library_book。

外部标识符仅需在 Odoo 模块内唯一,两个模块中使用相同标识符不会产生冲突。全局唯一标识符是由模块名和外部标识符共同组成的,在上图Complete ID项中可以看到。

在数据文件中使用外部标识符,我们可以选择完整的标识符或仅外部标识符部分。通常仅使用外部标识符会更简单,但使用完整标识符时我们可以引用其它模块中的数据记录。做引用时不要忘记在模块依赖中加入这些模块以确保在我们的记录之前加载这些记录。

小贴士: 有时即便引用相同模块中的XML ID也需使用完整标识符

在上图列表最上方可以看到library_app.action_library_book完整标识符。这是我们在模块中创建的菜单操作,在相应的菜单项中引用。点击进入表单视图查看详情。图中可以看出library_app模块中的action_library_book外部标识符映射到ir.actions.act_window模型中的记录 ID,此处为85:

 Odoo 12图书项目外部标识符视图表单

除了作为其它应用引用记录的一种方式外,外部标识符还可以避免重复导入带来的重复数据。一旦外部标识符已存在,则会在原有记录上更新,避免了重复数据的新建。

查找外部标识符

在为我们的模块写入数据记录时,经常需要查找已有外部标识符来作引用。一种方式是访问菜单Settings > Technical > Sequences & Identifiers > External Identifiers,前面已经演示过。另一种方法是使用开发者菜单。在第一章 使用开发者模式快速入门 Odoo 12中介绍了如何激开发者模式。

要查找一个数据记录的外部标识符,我们应打开对应的表单视图,在开发者菜单中选择View Metadata选项。此时会显示一个带有记录数据库 ID 和外部标识符(也称作XML ID)的对话框。比如要查看 demo 用户 ID,需通过 Settings > Users & Companies > Users 进入用户表单视图,然后点击开发者工具菜单中的View Metadata选项。此时可以看到XML ID是base.user_demo,数据库 ID 是6:

Odoo 12图书项目 demo 用户 Metadata

查看表单、列表、搜索或 action 视图中的外部标识符,都可以使用开发者菜单。下面我们通过Edit View选项来打开相应视图的详情表单。此时可以查看到External ID字段,其值即为外部标识符。例如在下图中,可以看到图书表单视图的External ID为library_app.view_form_book:

Odoo 图书项目图书表单视图

导入导出 CSV 数据文件

导出数据文件并查看文件结构的简易方式是使用内置的导出功能。通过生成 CSV 文件,我们可以了解手动导入系统所需的格式,或编辑该文件批量导入,甚至是使用它生成我们插件模块的演示数据。

下面我们一起来学习从 Odoo 用户界面导入和导出的基础知识。

导出数据

数据导出是表单视图中的标准功能。要使用该功能, 需要勾选左侧的复选框来选择需导出的行,然后在上方的 Action 菜单中点击 Export 选项。首先我们要在图书应用中添加一些带有出版商和作者的图书。下例中我使用此前添加的书籍。

我们还需要安装 Contacts 应用,这样可以看到 Partner 的列表视图,可从该处导出记录。注意其默认视图为带有名片的看板视图,需要先切换为列表视图:

Odoo 12 Contacts导出

可通过勾选列头的筛选框来选择所有匹配当前搜索条件的记录。

ℹ️Odoo 9中的修改
在 Odoo 更早的版本中,只有屏幕上显示(当页)的记录能被导出。Odoo 9做出了修改,勾选列头的复选框可导出当前过滤的所有匹配记录,而不仅仅是当前显示。这对导出屏幕上无法展示全的大量记录非常有用。

点击 Export 选项进入Export Data 对话表单,可选择导出方式。我们比较关注的是导出方式可以让我们通过手动或插件模块来导入该文件:

Odoo 12导出数据对话框

 

在对话表单最上方,有两个选项:

  • What do you want do do?(老版本中为Export type),选择Import-Compatible Export选项,这样导出数据在以后导入时格式更友好。
  • Export formats:可选择CSV或Excel,我们将选择 CSV 格式来更好理解原始导出格式,在很多表单应用中都能被读取。

下一步选取要导出的列,本例中简化操作,仅选择External ID和Name。如果我们点击Export To File按钮,就会下载带有导出数据的文件。最终的 CSV 内容类似:

1
2
3
4
5
"id","name"
"__export__.res_partner_45_5b73e404","Kaiwan N Billimoria"
"__export__.res_partner_42_49816b0d","Packt"
"__export__.res_partner_44_9e374a59","Russ McKendrick"
"__export__.res_partner_43_e38db1b7","Scott Gallagher"

补充: 伸手党请注意这里及后续的 ID 字段都与导出的系统有关,不应直接使用

第一行中包含列名,导入时会使用它们自动匹配目录列。导出内容有两列:

  • id:为每条记录分配的外部 ID,如果不存在,会在模块名处使用__export__ 作为前缀自动生成一条新ID。
  • name: 联系人/Partner 名称

带有外部 ID 使我们可以编辑导出数据并重新导入来把修改更新到记录中。

小贴士: 由于会自动生成记录 id,导出或导入功能可用于批量编辑 Odoo 数据:将数据导出至 CSV,使用表单软件批量编辑数据,再导入 Odoo。

导入数据

首先应确认开启了导入功能,默认是开启的。如果没有,进入Settings > General Settings,在 Users 版块下勾选Import & Export选项即可。启用该选项后,列表视图上方 Create 按钮旁就会显示一个 Import按钮。

注意: Import & Export 设置安装base_import模块,该模块用于提供这一功能。

下面我们尝试批量编辑Contact或Partner数据。使用电子表单或文本编辑器打开CSV并修改几个值。将 id 栏留空即可新增行。前文已经提到第一列 id 作为每行的唯一标识符,这让已有记录可以被更新,而不会因重新导入数据重复创建。我们在导出表中编辑任意字段在导入时对应记录就会被更新。

对于要加入 CSV 文件的新行,我们可以自己添加外部标识符或将 id 列留空。两种方式都会创建新的记录。作为示例,我们添加一行id 留空、name 为Phillip K. Dick,来在数据库中新建这一记录。在 CSV文件中进行保存,点击 Import(Create 按钮旁),然后点击 Load File 按钮选择磁盘中 CSV 的路径就出会出现如下导入助手:

Odoo 12图书项目 Test Import

点击右上角的Test Import按钮,检查数据正确性。由于导入的文件是在 Odoo 中导出文件基础上修改的,正常会有效并且各列会自动与数据库中对应字段匹配。因编辑所使用的软件各异,有可能需对分隔符和编码进行处理。现在可以点击 Import 按钮,修改和新建记录就会被载入到 Odoo 中。

Odoo 12 CSV 新增数据

CSV 数据文件中的关联记录

前面的示例非常简单,一旦我们开使用关联多张表的关联字段时,数据文件就会变得更为复杂。我们处理过图书中的 Partner 记录,下面就看一下如何在图书 CSV 文件中表示对这些 Partner 的引用。具体来说,有一个出版商(publisher_id字段)的many-to-one(或外键)关联,以及一个作者(author_ids字段)的many-to-many关联。

CSV 文件的表头行中关联列应在名称后添加一个/id。它将使用外部标识符来引用关联记录。本例中,我们将在publisher_id/id字段中加载图书出版商,使用关联 Partner 的外部 ID 作为其值。

注意: 可使用/.id来进行替代来使用数据库中的真实 ID(自动分配的数字 id),但极少使用到。除非有特别原因,否则请使用外部 ID 而非数据库ID。同时要记住数据库 ID 针对具体的数据库,所以如果导入到非原始数据库中这种操作大多数情况下都会失败。

CSV 数据文件中也可导入many-to-many字段,这和添加带双引号并由逗号分隔的外部 ID 列表一样简单。例如,要载入图书作者,将需要一个author_ids/id列,并使用一个关联 Partner外部 ID 的逗号分隔列表作为其值:

1
2
id, name, author_ids/id
book_docker, "Mastering Docker - Third Edition","__export__.res_partner_44_767f4606,__export__.res_partner_43_b97c9264"

Odoo 12 CSV 导入 Many2many

One-to-many 字段通常是表头和行或父子关系,对于这类关系有特别的支持方式:对于同一条父记录可以有多个关联行。此处我们在 Partner 模型中有一个 one-to-many字段的例子:公司可带有多个联系人。如果从 Partner 模型中导出数据并包含Contacts/Name 字段,就可以看到要导入此类型数据的格式(Contacts 中选择Azure Interior:默认应为第一条,并执行前述的导出步骤):

id name child_ids/id child_ids/name
base.res_partner_12 Azure Interior base.res_partner_address_15 Brandon Freeman
base.res_partner_address_28 Colleen Diaz
base.res_partner_address_16 Nicole Ford

Odoo 12 Partner 模型导出

id和 name 列为父记录的,child_ids两列为子记录的。注意第一行记录以下父记录部分留空。上表中CSV 文件形式显示为:

1
2
3
4
"id","name","child_ids/id","child_ids/name"
"base.res_partner_12","Azure Interior","base.res_partner_address_15","Brandon Freeman"
"","","base.res_partner_address_28","Colleen Diaz"
"","","base.res_partner_address_16","Nicole Ford"

可以看到id和name这两列第一行有值,后两行都为空。其中的父记录为联系人的公司信息。另两行的前缀都是child_ids/并且在三行中都有数据。这些是父公司的联系人信息。第一行包含公司和第一个联系人,其余行仅包含联系人这一子信息。

添加模块数据

模块使用数据文件来加载默认数据、演示数据、用户界面定义和其它需存入数据库的配置。可以选择使用 CSV 或 XML 文件。

ℹ️Odoo 12中的修改
Odoo 11及之前版本支持YAML格式文件,但在 Odoo 12移除了相关支持。相关使用示例可参考 Odoo 11官方模块l10n_be,更多YAML格式相关信息,可访问http://yaml.org/。

模块所使用的 CSV 和我们前述使用导入功能时用的文件是一样的。在模块中使用这些文件时,文件名须与要导入数据的模型名一致。例如,导入library.book模型的 CSV 数据文件名应为library.book.csv。CSV 数据文件经常用作导入ir.model.access模型来获取权限定义,通常放在security/子目录下并命名为ir.model.access.csv。

演示数据

Odoo插件模块可安装演示数据,推荐支持该安装。为模块提示使用示例和测试用的数据集会非常有用。模块的演示数据通过__manifest__.py文件中的 demo 属性来声明。和 data 属性一样,后接一个包含模块相对路径的文件名列表。我们应为library.book模块添加一些演示数据,一个简易方式是从安装了模块的开发数据库中导出数据。

按惯例数据文件放在data/子目录下,应以data/library.book.csv保存在library_app模块下。因这个数据为模块所有,应在导出的数据中将标识符的前缀__export__去除。

例如res.partner.csv文件可能长这样:

1
2
3
4
5
id,name
res_partner_alexandre,"Alexandre Fayolle"
res_partner_daniel,"Daniel Reis"
res_partner_holger,"Holger Brunn"
res_partner_packt,"Packt Publishing"

那么图书演示数据文件library.book.csv就应该是这样的:

1
2
3
4
"id","name","date_published","publisher_id/id","author_ids/id"
library_book_ode11,"Odoo Development Essentials 11","2018-03-01",res_partner_packt,res_partner_daniel
library_book_odc11,"Odoo 11 Development Cookbook","2018-01-01",res_partner_packt,"res_partner_alexandre,res_partner
_holger"

注意文件中同一条数据因显示原因可能在不同行中,但实际是不能换行的。还应记得在__manifest__.py的 demo 属性中声明数据文件:

1
2
3
4
'demo': [
'data/res.partner.csv',
'data/library.book.csv',
],

文件会以声明的顺序来加载,这个很重要,因为文件可能会因为未被安装而无法引用其记录。只要启用了安装演示数据,下次更新时,这些内容就会被导入。

ℹ️数据文件会在模块升级时重新导入,但演示文件则并非如此,它们仅在安装时导入。

当然 XML 文件也可用于加载或初始化数据,还可使用普通 CSV 文件所不具备的功能。

使用 XML 数据文件

CSV 文件是一种展示数据方便简洁的格式,但 XML 文件更为强大,可在加载过程中提供更多的控制。比如,其文件名无需与所导入到的模型名称一致。因为XML格式通过文件内的XML元素可以提供更丰富的信息、更多的内容。

在前面的文章中我们已经使用过XML数据文件。视图和菜单项这类用户界面组件实际上都是存储在系统模型中的记录。模块中的XML文件是将这些记录加载到实例数据库的方式。我们将在library_app模块中添加一个数据文件data/book_demo.xml来作为展示,文件内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?xml version="1.0"?>
<odoo noupdate="1">
<!-- Data to load -->
<record model="res.partner" id="res_partner_huxley">
<field name="name">Aldous Huxley</field>
</record>
<record model="library.book" id="library_book_bnw">
<field name="name">Brave New World</field>
<field name="author_ids"
eval="[(4, ref('res_partner_huxley'))]" />
<field name="date_published">1932-01-01</field>
</record>
</odoo>

老规矩,新的数据文件应在__manifest__.py中声明:

1
2
3
4
    'demo': [
...
'data/book_demo.xml',
],

类似 CSV 文件,该文件也会将数据加载到图书模型中。

Odoo 12使用 XML 导入数据

XML文件包含一个外层<odoo>元素,内部可包含多个<record>元素与对应 CSV 数据行。

ℹ️数据文件中的外层元素在9.0中才引入用于替换此前的标签。现在仍支持外层元素内的标签,为可选项。事实上现在是等价的,我们可以在数据文件中使用任意一个作为外层元素。

元素有两个强制属性: model 和作为记录外部标识符的 id,每个字段使用一个标签来进行写入。注意此处字段名内不可使用斜杠标记,如不可使用。应使用 ref 属性来引用外部标识符,一会儿就会讨论到关联 to-many 字段。

你可能注意到在外层元素中使用了noupdate=”1”属性。这防止了在模块升级时数据记录的载入,不至于在后续编辑中丢失数据。

noupdate 数据属性

升级模块时,会重新加载数据并重写模块记录。要谨记这可能意味着在升级模块时会重写任何对模块数据的手动更改。

小贴士: 值得注意的是,手动对视图所做的自定义修改会在下一次模块升级时丢失。避免这一问题正确的方法是创建继承视图来引入要做的修改。

这种重写行为是默认的,但可以修改有些数据仅在安装时导入,后续模块更新时则予以忽略,这正是通过<odoo><data>元素中的noupdate=”1”来实现的。

这对于需初始化配置且预期需自定义的数据来说非常有用,因为这些手动修改在模块更新时是安全的。例如在记录访问规则中经常使用,可以适应具体的实施需求。

在同一 XML 文件中可以有多个<data>版块。可通过这个来分隔仅需导入一次的数据(noupdate=”1”)和需在每次更新时重新导入的数据(noupdate=”0”)。noupdate=”0”是默认值,所以可以省略不写。注意还必须要有一个外层 XML 元素,就这个例子而言,使用两个<data>标签,并在外层包裹一个<odoo><data>元素。

小贴士: noupdate属性在开发模块时可能会引起不适,因为会忽略后续修改。一个解决方案是,使用-i 参数重新安装模块而不是使用-u 参数进行更新。命令行中使用-i 参数重新安装会忽略数据记录中的noupdate标记。

noupdate标记存储在每条记录的外部标识符信息中。可通过 Technical 菜单中的External Identifiers表单手动编辑,勾选Non Updatable 复选框即可。

ℹ️Odoo 12中的修改
点击开发者菜单中的View Metadata时,现在在弹出的对话框中 XML ID 下面还会显示No Update的值。并且在该处可通过点击来修改该标记的值。

在 XML 中定义记录

在 XML 文件中,每个<record>元素有两个基本属性:id 和 model,并包含为对应列设置的值。 id 属性对应记录外部标识符,model 对应目标模型。元素有几种分配值的方法,下面一起来看看。

直接为字段设置值

元素的 name 属性标识要写入的字段。写入的值是元素内容:字段开、闭标签之间的文本。对于 date 和datetime,带有返回 date 或 datetime 对象表达式的 eval 属性可进行设置。返回的”YYYY-mm-dd”和”YYYY-mm-dd HH:MM:SS”字符串会进行转化。对于布尔字段,”0” and “False”都会转换成 False,而任意非空值都会转换成 True。

小贴士:Odoo 10中的修改
Odoo 10中改进了从数据文件中读取布尔值 False的方式。在老版本中,包含”0” and “False”在内的非空值都会转换成 True,直至 Odoo 9,布尔值仍需使用 eval 属性进行设置,如 eval=”False”。

通过表达式设置值

设置字段值更复杂的方式是通过 eval 属性,它会运行 Python 表达式并将结果分配给字段。表达式通过 Python 内置的以及一些其它可创建表达式标识符的上下文求值。

可使用如下 Python 模块来处理日期:time, datetime, timedelta和relativedelta。通过它们可以计算日期值,在演示和测试数据经常会用到,以让日期和模块安装日期较近。关于 Python 模块更多这类知识,请参考官方文档

比如,把值设为前一天,可使用如下代码:

1
2
<field name="date_published"
eval="(datetime.now() + timedelta(-1))" />

求值上下文还可使用ref()函数,用于将外部标识符转换为对应的数据库 ID。这可用于为关联字段设置值。比如,可以使用它为publisher_id设置值:

1
<field name="publisher_id" eval="ref('res_partner_packt')" />

在 many-to-one 关联字段上设置值

对于many-to-one关联字段,要写入的是关联记录的数据库 ID。在 XML 文件中,我们一般会知道记录的XML ID,然后就需要把它转换成实际的数据库 ID。

一种方式是像前文那样使用带有 ref()函数的 eval 属性。更简单的替代方式是使用在元素中可用的ref 属性,使用它设置publisher_id many-to-one字段的值,我们可以这么写:

1
<field name="publisher_id" ref="res_partner_packt" />

在 to-many 关联字段上设置值

对于one-to-many和many-to-many字段,设置的不是单个 ID,而是一组关联 ID。并且还进行几种操作-我们可能需要将当前的关联记录列表替换成另外一个,或为其添加几次记录,甚至是删除其中的一些记录。

要让to-many字段支持写操作,我们要在 eval 属性中使用一种特殊的语法。我们使用一个元组组成的列表来写入to-many字段。每个元组有三个元素,构成一个写入命令,根据第一个元素中的代码进行对应操作。要重写图书作者列表,要使用如下代码:

1
2
3
4
5
6
7
<field
name = "author_ids"
eval = "[(6, 0,
[ref('res_partner_alexandre'),
ref('res_partner_holger')]
)]"
/>

要往当前图书作者列表添加关联记录,需要添加如下代码:

1
2
3
<field name="author_ids"
eval="[(4, ref('res_partner_daniel'))]"
/>

上述的例子非常常见。这里仅使用了一个命令,但在外层列中可以串联多条命令。添加(4)和 替换(6)是最常用的命令。在进行添加(4)时,不需要使用最后一个元素,因此在以上代码中省略了。

完整的可用命令如下:

  • (0, _ , {‘field’: value})新建一条记录并将其与之关联
  • (1, id, {‘field’: value})更新已关联记录的值
  • (2, id, _)移除关联并删除 id 关联的记录
  • (3, id, _)移除关联但不删除 id 关联的记录。通常使用它来删除many-to-many字段的关联记录
  • (4, id, _)关联已存在记录,仅适用于many-to-many字段
  • (5, _, _)删除所有关联,但不删除关联记录
  • (6, _, [ids])替换已关联记录列表为此处的列表

上述下划线_字符代表非关联值,通常填入 o 或 False。

小贴士: 后面的非关联值可以放心地省略掉,如(4, id, _) 可使用(4, id)

常用模型的简写

如果回到第三章 Odoo 12 开发之创建第一个 Odoo 应用,我们在 XML 中还发现<record>之外的元素,如<act_window><menuitem>。这些是常用模型的简写方式,是比常用的<record>更为简练的符号。它们用于向 base 模型加载数据、组成用户界面,在第十章 Odoo 12开发之后台视图 - 设计用户界面会作更详细的探讨。

为便于查看,以下是可用的简写元素以及加载数据的对应模型:

  • <act_window>是窗口操作模型ir.actions.act_window
  • <menuitem>是菜单项模型ir.ui.menu
  • <report>是报表操作模型ir.actions.report.xml
  • <template>是存储在ir.ui.view模型中的 QWeb 模板

ℹ️Odoo 11中的修改
<url>标签已被淘汰并删除。此前的版本中它用作为 URL 操作模型ir.actions.act_url加载记录。

应当注意在用于修改已有记录时,简写元素会覆盖所有字段。这与仅写入所提供字段的<record>基础元素不同。因此在需修改用户界面元素指定字段时,应使用<record>元素。

XML 文件中的其它操作

截至目前我们了解了如何使用 XML 文件添加和更新数据。但也可以通过 XML 文件删除数据以及执行指定模型方法。对更复杂的数据场景会非常有用。

删除记录

我们可以使用<delete>元素删除数据记录,使用 ID 或搜索域来定位要删除的记录。例如,使用搜索域查找记录并删除:

1
2
3
4
<delete
model="res.partner"
search="[('id','=',ref('library_app.res_partner_daniel'))]"
/>

如果知道要删除记录的具体 ID,可使用 id 属性。上例还可以写成这样:

1
<delete model="res.partner" id="library_app.res_partner_daniel" />

调用模型方法

XML 文件还可以通过<function>元素在加载过程中执行任意方法,可用于设定演示和测试数据。比如 Odoo 捆绑的 Notes 应用,使用它来设定演示数据:

1
2
3
4
5
6
<data noupdate="1">
<function
model="res.users"
name="_init_data_user_note_stages"
eval="[]" />
</data>

这会调用res.users模型中的_init_data_user_note_stages方法,不传任何参数。由参数列表eval传递,此处为空列表。

总结

本文中我们学习了如何在文件文中展示数据。可用作手动向 Odoo 导入数据,或放在插件模块中作为默认或演示数据。通过学习我们可以通过网页界面导出并导入 CSV 数据文件了,以及通过外部 ID 来检测并更新数据库中已有的记录。也可用作批量编辑数据,只需编辑导出的 CSV 文件再重新导入即可。

我们还详细学习了 XML 数据文件的结构以及所提供功能。不仅可以为字段设置值,还可以执行删除记录和调用方法一类的操作。

下一篇文章中,我们将集中学习如何使用记录来与模型中所含数据协作。这些工具可供我们实现应用的业务逻辑和规则。

☞☞☞第六章 Odoo 12开发之模型 - 结构化应用数据

学霸专区

  1. XML ID 与外部 ID 的区别是什么?

  2. 插件模块中可使用什么类型的数据文件?

  3. 以下 XML 片段有什么问题?

    1
    <field name="user_id">[(4, 0, [ref(base.user_demo)])]</field>?
  4. 一个插件模块中的数据文件是否可以覆盖另一个模块中创建的记录?

  5. 插件模块升级时,是否所有数据记录都会被重写为模块默认值?

扩展阅读

Odoo 官方文档中提供了有关数据文件的更多资料。

本文首发地址:Alan Hou 的个人博客

本文为最好用的免费ERP系统Odoo 12开发手册系列文章第四篇。

Odoo 的一个强大功能是无需直接修改底层对象就可以添加功能。这是通过其继承机制来实现的,采取在已有对象之上修改层来完成。这种修改可以在不同层上进行-模型层、视图层和业务逻辑层。我们创建新的模块来做出所需修改而无需在原有模块中直接修改。

上一篇文章中我们从零开始创建了一个新应用,本文中我们学习如何通过继承已有的核心应用或第三方模块来创建新的模块。实现以上本文将主要涵盖:

  • 原模型扩展,为已有模型添加功能
  • 修改数据记录来继承视图,添加功能或修改数据来修改其它模块创建的数据记录
  • 其它模型继承机制,如代理继承和 mixin 类
  • 继承 Python 方法来为应用业务逻辑添加功能
  • 继承 Web 控制器和模板来为网页添加功能

开发准备

本文要求可通过命令行来启动 Odoo 服务。代码将在第三章 Odoo 12 开发之创建第一个 Odoo 应用的基础上进行修改。通过该文的学习现在我们已经有了library_app模块。本系列文章代码请参见 GitHub 仓库

学习项目-继承图书馆应用

在第三章 Odoo 12 开发之创建第一个 Odoo 应用中我们创建了一个图书应用的初始模块,可供查看图书目录。现在我们要创建一个library_member模块,来对图书应用进行扩展以让图书会员可以借书。它继承 Book 模型,并添加一个图书是否可借的标记。该信息将在图书表单和图书目录页显示。

应添加图书会员主数据模型Member,类似 Partner 来存储个人数据,如姓名、地址和 email,还有一些特殊字段,如图书会员卡号。最有效的方案是代理继承,自动创建图书会员记录并包含关联 Partner 记录。该方案使得所有的Partner 字段在 Member 中可用,没有任何数据结构上的重复。

我们还要在借书表单中为会员提供消息和社交功能,包括计划活动组件来实现更好地协作。我们还要添加会员从图书馆中借书的功能,但暂不涉及。以下是当前所要修改内容的总结:

  • 图书

    • 添加一个Is Available? 字段。现在通过手动管理,以后会自动化
    • 扩展 ISBN 验证逻辑来同时支持10位数的ISBN
    • 扩展图书目录页来分辨不可借阅图书并允许用户过滤出可借图书
  • 会员

    • 添加一个新模型来存储姓名、卡号和 Email、地址一类的联系信息
    • 添加社交讨论和计划活动功能

首先在library_app同级目录创建一个library_member目录来作为扩展模块,并在其中添加两个文件,一个__init__.py空文件和一个包含如下内容的__manifest__.py文件:

1
2
3
4
5
6
7
{
'name': 'Library Members',
'description': 'Manage people who will be able to borrow books.',
'author': 'Alan Hou',
'depends': ['library_app'],
'application': False,
}

原模型继承

第一步我们来为Book模型添加is_available布尔型字段。这里使用经典的 in-place 模型继承。该字段值可通过图书借出和归还记录自动计算,但现在我们先使用普通字段。要继承已有模型,需要在 Python 类中添加一个_inherit 属性来标明所继承的模型。新类继承父 Odoo 模型的所有功能,仅需在其中声明要做的修改。在任何地方使用该模型修改都可用,可以认为这类继承是对已有模型的引用并在原处做了一些修改。

为模型添加字段

通过 Python 类来新建模型,继承模型同样是通过 Python 以及 Odoo 自有的继承机制,即_inherit 类属性。该属性标明所继承的模型。新的类继承父 Odoo 模型的所有功能,仅需声明要做修改的部分。编码指南推荐为每个模型创建一个 Python 文件,因此我们添加library_member/models/library_book.py文件来继承原模型,首先创建__init__.py文件来导入该文件:

1、添加library_member/init.py文件来导入 models 子文件夹

1
from . import models

2、添加library_member/models/init.py文件子文件夹中的代码文件:

1
from . import library_book

3、创建library_member/models/library_book.py文件来继承library.book模型:

1
2
3
4
5
from odoo import fields, models

class Book(models.Model):
_inherit = 'library.book'
is_available = fields.Boolean('Is Available?')

使用_inherit类属性来声明所继承模型。注意我们并没有使用到其它类属性,甚至是_name 也没使用。除非想要做出修改,否则不需要使用这些属性。

ℹ️_name是模型标识符,如果修改会发生什么呢?其实你可以修改,这时它会创建所继承模型的拷贝,成为一个新模型。这叫作原型继承,本文后面会讨论。

可以把这个想成是对模型定义的一个引用,在原处做了一个修改。可以添加字段、修改已有字段、修改模型类属性甚至是包含业务逻辑的方法。要在数据表中添加新的模型字段需要安装该模块。如果一切顺利,通过Settings > Technical > Database Structure > Models菜单查看library.book模型即可看到该字段。

1
~/odoo-dev/odoo/odoo-bin -d dev12 -i library_member

Odoo 12图书项目is_available字段添加

修改已有字段

通过上面部分可以看到向已有模型添加新字段非常简单。有时还要对已有字段进行修改,也非常简单。在继承模型时,可对已有字段叠加修改,也就是说仅需定义要增加或修改的字段属性。

我们将对原来创建的library_app模块的 Book模型做两处简单修改:

  • 为isbn字段添加一条提示,说明同时支持10位数的 ISBN(稍后会实现该功能)
  • 为publisher_id字段添加数据库索引,以提升搜索效率

编辑library_member/models/library_book.py文件,并在library.book 模型中添加如下代码:

1
2
3
4
class Book(models.Model):
...
isbn = fields.Char(help="Use a valid ISBN-13 or ISBN-10.")
publisher_id = fields.Many2one(index=True)

这会对字段进行指定属性修改,未涉及的属性不会被修改。升级模块,进入图书表单,将鼠标悬停在 ISBN 字段上,就可以看到所添加的提示信息了。index=True这一修改不太容易发现,通过Settings > Technical > Database Structure > Models菜单下的字段定义中可进行查看。

Odoo 12图书项目 ISBN 提示

修改视图和数据

模块中视图和其它数据构件也可通过继承来修改。就视图而言,通常需要添加功能。视图的展示结构在 arch 字段中使用 XML定义。这一 XML 数据可通过定位到所需修改的地方来进行继承,然后声明需执行的操作,如在该处添加 XML 元素。对于剩余的数据元素,它们代表写入数据库中的记录,继承模型可通过写操作来修改它们的值。

继承视图

表单、列表和搜索视图通过arch XML结构定义。要继承视图,就要一种修改 XML 的方式,也即定位 XML 元素然后对该处进行修改。视图继承的 XML 记录和普通视图中相似,多一个 inherit_id属性来引用所要继承的视图。下面我们来继承图书视图并添加is_available字段。

首先要查找待继承的视图的XML ID,通过Settings > Technical > User Interface > Views菜单来查看。图书表单的XML ID是library_app.view_form_book。然后还要找到要插入修改的XML元素,我们在 ISBN 字段之后添加Is Available?通常通过name 属性定位元素,此处为

我们添加views/book_view.xml文件来继承 Partner 视图,加入如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?xml version="1.0"?>
<odoo>
<record id="view_form_book_extend" model="ir.ui.view">
<field name="name">Book: add Is Available? field</field>
<field name="model">library.book</field>
<field name="inherit_id" ref="library_app.view_form_book" />
<field name="arch" type="xml">
<field name="isbn" position="after">
<field name="is_available" />
</field>
</field>
</record>
</odoo>

以上代码中,我们高亮显示了继承相关的元素。inherit_id记录字段通过 ref 属性指向继承视图的外部标识符,我们将在第五章 Odoo 12开发之导入、导出以及模块数据讨论外部标识符详情。视图使用 XML 定义并存储在结构字段 arch 中。要继承一个视图,先定位要扩展的节点,然后执行要做的操作,如添加 XML 元素。

定位节点的最简单方法是使用唯一标识属性,通常是 name。然后添加定位属性,声明要做的修改。本例中继承节点是name=”isbn”元素,修改是在选定元素后加一段 XML:

1
2
3
<field name="isbn" position="after">
<!-- 此处添加修改内容 -->
</field>

除string 属性外的任意 XML 元素和属性可作为继承节点,字符串属性会被翻译成用户所使用的语言,因此不能作为节点选择器。

ℹ️在9.0以前,string 属性(显示标签文本)也可作为继承定位符。在9.0之后则不再允许。这一限制主要源自这些字符串的语言翻译机制。

一旦 XML 节点被选为继承点,需要指明要执行的继承操作。这通过 position 属性实现:

  • inside(默认值):在所选节点内添加内容,这一节点应是一类的容器
  • after:在选定节点之后向父节点添加内容
  • before:在选定节点之前向父节点添加内容
  • replace:替换所选节点。若使用空元素则会删除该元素。Odoo 之后还允许使用其它标记来包裹元素,通过在内容中使用$0来表示被替换的元素。
  • attributes:修改匹配元素属性值。内容中应包含带有一个或多个<attribute **name=”attr-name”** >value元素。如True,若不带内容,如则 attribute 会从所选元素中删除。

小贴士: 通过position=”replace”可删除 XML 元素,但应避免这么做。这么做会破坏其它依赖所删除节点、将其作为占位符添加元素的模块。一个替代方案是,让该元素不可见。

除了attributes定位,上述定位符可与带position=”move”的子元素合并。效果是将子定位符目标节点移到父定位符目录位置。

ℹ️Odoo 12中的修改
position=”move”子定位符是 Odoo 12中新增的,之前的版本中没有

例如:

1
2
3
<field name="target_field" position="after">
<field name="my_field" position="move"/>
</field>

其它视图类型,如列表和搜索视图,也有 arch 字段,可以表单视图同样的方式被继承。

在声明文件data 中加入该视图文件并更新模块即可:

Odoo 12图书项目添加 is_available

使用 XPath 选取继承点

有时可能没有带唯一值的属性来用作 XML 节点选择器。在所选元素没有 name 属性时可能出现这一情况,如视图元素。另外就是有多个带有相同 name 属性的元素,比如在看板 QWeb 视图中相同字段可能在同一 XML 模板中被多次包含。

在这些情况下我们就需要更高级的方式来定位待扩展 XML 元素。定位 XML 中元素的一种自然方式是 XPath 表达式。以上一篇文章中定义的 Book 表单视图为例,定位元素的 XPath 表达式是//field[@name]=’isbn’。该表达式查找 name 属性等于 isbn 的元素。

前一部分对图书表单视图继承的 XPath 写法是:

1
2
3
<xpath expr="//field[@name='isbn']" position="after">
<field name="is_available" />
</xpath>

XPath 语法的更多知识请见 Python 官方文档

如果 XPath 表达式匹配到了多个元素,仅会选取第一个作为扩展目录。所以表达式应越精确越好,使用唯一属性。name 属性最易于确保找到精确元素作为扩展点,因此在创建视图 XML 元素时添加唯一标识符就非常重要。

修改数据

普通数据记录不同于视图,它没有 XML arch 结构,也不能使用 XPath 来进行扩展。但还是可以通过替换字段值来进行修改。

数据加载元素实际是对 y 模型进行插入或更新操作。若不存在记录 x,则被创建,否则被更新/覆盖。其它模块中的记录可通过.全局标识符访问,因此可以在我们的模块中重写其它模块中已写入的数据。

ℹ️点号是保留符号,用于分隔模块名和对象标识符,所以在标识符名中不要使用点号,而应使用下划线字符。

举个例子,我们将 User 安全组的名称修改为 Librarian,对应修改library_app.library_group_user记录。添加library_member/security/library_security.xml并加入如下代码:

1
2
3
4
5
6
<odoo>
<!-- Modify Group name -->
<record id="library_app.library_group_user" model="res.groups">
<field name="name">Librarian</field>
</record>
</odoo>

这里我们使用了一个元素,仅写了 name 字段。可以认为这是对所选字段的一次写操作。

小贴士: 使用元素时,可以选择要执行写操作的字段,但对 shortcut 元素则并非如此,如。它们需要提供所有的属性,漏写任何一个都会将对应字段置为空值。但可使用为原本通过 shortcut 元素创建的字段设置值。

在声明文件data 中加入security/library_security.xml并更新模块即可看到效果。

Odoo 12图书项目 Librarian

其它模型继承机制

前面我们介绍了模型的基本继承,在官方文档中称为经典继承。这是最常用的继承方式,最容易想到的就是in-place继承。获取模型并对其继承。添加的新功能会自动添加到已有模型中,而不会创建新模型。

可以为_inherit 属性传入多个值来继承多个父模型。大多数情况下这通过 mixin 类完成,mixin类是实现可复用的通用功能。也可以像普通模型那样独立使用,像是一个功能容器,可随时加到其它模型中。

如在使用_inherit 属性的同时还使用了与父模型不同的_name属性,此时会复用所继承并创建一个新的模型,并带有自己的数据表和数据。官方文档称其为原型(prototype)继承。下面我们会拿一个模型,并为其创建一个拷贝。在添加新功能时,只会被加到新模型中,而不会变更原模型。

此外还有代理(delegation)继承,通过_inherits 属性来使用(注意最后有一个 s)。这允许我们创建一个包含和继承已有模型的新模型。新模型创建新记录时,在原模型中也会被创建并使用many-to-one 字段关联。查看新模型的人可以看到所有原模型和新模型中的字段,但在后台两个模型分别处理各自的数据。

下面我们一起来了解详情。

使用原型继承拷贝功能

前文我们继承模型时使用了_inherit 属性,创建一个类继承library.book 并添加了一些功能。类中没有使用_name属性,不指明即使用library.book。如果设置了不个不同值的_name 属性,会通过从所继承的模型拷贝功能创建新模型。

在实际开发中,这类继承一般通过抽象 mixin 类,很少这样直接继承普通模型,因为这样会创建冗余的数据结构。Odoo 还有一种代理继承机制可避免这类数据结构冗余,所以普通模型通常会使用这种方法来做继承。

使用代理继承内嵌模型

使用代理继承无需复制数据即可在数据库中复用数据结构,这通过将一个模型嵌入另一个来实现。UML 中这种称作组合(composition)关系:父类无需子类即可存在,而子类必须要有父类才能存在。

比如,对于内核 User模型,每条记录包含一条 Partner 记录,因此包含 Partner 中的所有字段以及User自身的一些字段。

在图书项目中,我们要添加一个图书会员模型。会员有会员卡并通过会员卡借阅读书。我们要记录卡号,还要存储email 和地址这类个人信息。Partner 模型已包含联系和地址信息,所以最好是进行复用,而不去创建重复的数据结构。

为会员模型创建library_member/models/library_member.py文件并加入如下代码:

1
2
3
4
5
6
7
8
9
10
11
from odoo import fields, models

class Member(models.Model):
_name = 'library.member'
_description = 'Library Member'
card_number = fields.Char()
partner_id = fields.Many2one(
'res.partner',
delegate=True,
ondelete='cascade',
required=True)

使用代理继承,library.member 中嵌入了继承模型res.partner,因此在创建会员记录时,一个关联的 Partner 会自动被创建并通过partner_id字段引用。

ℹ️Odoo 8中的修改
在新的 API 中引入了delegate=True字段属性。在那之前,代理继承通过模型属性来定义,类似_inherits = {‘res.partner’: ‘partner_id’}。现在仍支持这一写法,官网中还有相应介绍,但delegate=True 字段属性可起到相同效果且使用更简单。

透过代理机制,嵌套模型的所有字段就像父模型字段一样自动可用。本例中,会员卡模型可使用 Partner 中的所有字段,如 name, address和 email,以及会员自身的独有字段,如card_number。在后台中,Partner 字段存储在关联的 Partner 记录,没有重复的数据结构。

ℹ️对于模型方法则并非如此,Partner 模型中的方法在 Member 模型中不可使用。

与原型继承相比,代理继承的好处在于无需跨表重复像地址这样的数据。任何需包含地址的新模型通过代理嵌入了 Partner 模型。如果在 Partner 中修改 address字段,在所有嵌入的模型中可以马上使用。

小贴士: 代理继承可通过如下组合来进行替代:

  • 父模型中的一个 many-to-one 字段
  • 重载 create()方法自动创建并设置父级记录
  • 父字段中希望暴露的特定字段的关联字段

有时这比完整的代理继承更为合适。例如res.company并没有继承res.partner,但使用到了其中好几个字段。

不要忘记在library_member/model/init.py文件中加入:

1
2
from . import library_book
from . import library_member

要使用我们创建的 Member 模型,还要完成以下步骤:

  • 添加安全权限控制列表(ACL)
  • 添加菜单项
  • 添加表单和列表视图
  • 更新manifest文件来声明这些新增数据文件

读者可以先尝试自己添加,再来看下面的详细步骤:

要创建安全ACL,创建library_member/security/ir.model.access.csv文件并加入如下代码:

1
2
3
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_member_user,Member User Access,model_library_member,library_app.library_group_user,1,1,1,0
access_member_manager,Member Manager Access,model_library_member,library_app.library_group_manager,1,1,1,1

要添加菜单项,创建library_member/views/library_menu.xml文件并加入如下代码:

1
2
3
4
5
6
7
8
9
<odoo>
<act_window id="action_library_member"
name="Library Members"
res_model="library.member"
view_mode="tree,form" />
<menuitem id="menu_library_member"
action="action_library_member"
parent="library_app.menu_library" />
</odoo>

要添加视图,创建library_member/views/member_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
<?xml version="1.0" ?>
<odoo>
<record id="view_form_member" model="ir.ui.view">
<field name="name">Library Member Form View</field>
<field name="model">library.member</field>
<field name="arch" type="xml">
<form>
<group>
<field name="name" />
<field name="email" />
<field name="card_number" />
</group>
</form>
</field>
</record>
<record id="view_tree_member" model="ir.ui.view">
<field name="name">Library Member List View</field>
<field name="model">library.member</field>
<field name="arch" type="xml">
<tree>
<field name="name" />
<field name="card_number" />
</tree>
</field>
</record>
</odoo>

最后,编辑manifest文件来声明这三个新文件:

1
2
3
4
5
6
    'data':[
...
'security/ir.model.access.csv',
'views/library_menu.xml',
'views/member_view.xml',
]

如果编写正确,在进行模型更新后即可使用新的图书会员模型了。

1
~/odoo-dev/odoo/odoo-bin -d dev12 -u library_member

Odoo 12图书项目图书会员

使用 mixin类继承模型

原型继承主要用于支持 mixin 类。mixin 是基于 models.Abstract 的抽象的模型(而不是models.Model),它在数据库中没有实际的体现,而是提供功能供其它模型复用(混合 mixed in)。Odoo 插件提供多种 mixin,最常的两种由 Discuss 应用(mail 模块)提供:

  • mail.thread提供在许多文档表单下方或右侧的消息面板功能,以及消息和通知相关逻辑。这在我们自己的模型中将经常会添加,下面就来一起学习下。
  • mail.activity.mixin模型提供待办任务计划。

ℹ️Odoo 11中的修改
mail 模块现在通过mail.activity.mixin抽象模型提供Activities任务管理功能。该功能在 Odoo 11中才添加,此前的版本中没有。

我们一起来为 Member 模型添加上述两种 mixin。社交消息功能由 mail 模块的mail.thread模型提供,要将其加入自定义模型,应进行如下操作:

  1. 通过 mixin 模型 mail 为插件模块添加依赖
  2. 让类继承mail.thread和mail.activity.mixin两个 mixin 类
  3. 将message_follower_ids, message_ids和activity_id这些 mixin 的数据字段添加到表单视图

对于第一步扩展模型需要在__manifest__.py文件中添加对 mail 的依赖。

1
'depends': ['library_app', 'mail'],

第二步中对 mixin 类的继承通过_inherit属性完成,应编辑library_member/models/library_member.py并添加如下代码:

1
2
3
4
5
class Member(models.Model):
_name = 'library.member'
_description = 'Library Member'
_inherit = ['mail.thread', 'mail.activity.mixin']
...

通过添加额外的这行代码,我们的模型就会包含这些 mixin 的所有字段和方法。

第三步中向表单视图添加相关字段,编辑library_member/views/member_view.xml文件并在表单最后添加如下代码:

1
2
3
4
5
6
7
8
9
10
11
<odoo>
...
<form>
...
<!-- mail mixin fields -->
<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>

mail 模块还为这些字段提供了一些特定的网页组件,以上代码中已使用到。在升级模块后会员表单将变成这样:

Odoo 12图书项目使用 Mixin 类继承

有时普通用户仅能访问正在 follow 的记录。在这些情况下我们应添加访问记录规则来让用户可以看到 follow 的记录。本例中用不到这一功能,但可通过[(‘message_partner_ids’, ‘in’, [user.partner_id.id])]或来进行添加。

继承 Python 方法

Python 方法中编写的业务逻辑也可以被继承。Odoo 借用了 Python 已有的父类行为的对象继承机制。

作为一个实际的例子,我们将继承图书 ISBN 验证逻辑。在图书应用中仅能验证13位的 ISBN,但老一些的图书可能只有10位数的 ISBN。我们将继承_check_isbn()方法来完成这种情况的验证。在library_member/models/library_book.py文件中添加如下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from odoo import api, fields, models

class Book(models.Model):
...

@api.multi
def _check_isbn(self):
self.ensure_one()
isbn = self.isbn.replace('-', '')
digits = [int(x) for x in isbn if x.isdigit()]
if len(digits) == 10:
ponderators = [1, 2, 3, 4, 5, 6, 7, 8, 9]
total = sum(a * b for a, b in zip(digits[:9], ponderators))
check = total % 11
return digits[-1] == check
else:
return super()._check_isbn()

要继承方法,我们要重新定义该方法,可以使用 super()来调用已实现的部分。在这个方法中我们验证是否为10位数 ISBN,然后插入遗失的验证逻辑。若不是10位,则进入原有的13位验证逻辑。

如果想要进行测试甚至是书写测试用例,可使用0-571-05686-5作为例子,该书是威廉·戈尔丁的《蝇王》。

ℹ️Odoo 11中的修改
从 Odoo 11开始,支持的主Python版本为 Python 3(Odoo 12中为 Python 3.5)。而此前的 Odoo 版本使用 Python 2,其中 super()需传入类名和 self 两个参数,那么,上例中的代码应修改为super(Book, self)._check_isbn()。

Odoo 12图书项目10位 ISBN 验证

继承 Web 控制器和模板

Odoo 中的所有功能都带有扩展性,web 功能也不例外,所以已有控制器和模块都能被继承。

作为示例,我们将继承图书目录网页,加入前面添加的图书可用性信息:

  • 在控制器端添加对查询参数的支持,访问/library/books?available=1过滤出可借阅图书
  • 在模板端,添加一个图书不可用的表示

继承网页控制器

网页控制器不应包含实际业务逻辑,仅集中于展示逻辑。我们可能会需要添加对额外 URL 参数甚至是路由的支持,来改变网页的展示。我们将扩展/library/books来支持available=1参数,以过滤出可借阅图书。

要继承已有控制器,需导入对应对象,然后用方法新增逻辑来进行实现。下面新增ibrary_member/controllers/main.py文件并加入如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
from odoo import http
from odoo.addons.library_app.controllers.main import Books

class BookExtended(Books):
@http.route()
def list(self, **kwargs):
response = super().list(**kwargs)
if kwargs.get('available'):
Book = http.request.env['library.book']
books = Book.search([('is_available', '=', True)])
response.qcontext['books'] = books
return response

我们要继承的Books控制器在library_app/controllers/main.py中定义。因此需要通过odoo.addons.library_app.controllers.main导入。这和模型不同,模型可以通过 env 对象中的central registry 来引用任意模型类,而无需了解实现它的文件。控制器没有这个,我们需要知道实现需继承控制器的模块和文件。

然后基于Books声明了一个BooksExtended类,类名不具关联性,仅用于继承和扩展原类中定义的方法。

再后我们(重)定义了一个控制器方法 list()。它至少需要一个简单的@http.route()装饰器来保持路径活跃。如果不带参数,将会保留父类中定义的路由。但也可以为@http.route() 装饰器添加参数,来重新定义或替换类路由。

在继承的 list()方法中,一开始使用了 super()来运行已有代码。处理结果返回一个 Response 对象,Response 带有模块要渲染的属性 template,以及渲染使用的上下文qcontext。但还需要生成 HTML,仅会在控制器结束运行时生成。这也让我们可以在最终渲染完成之前可以修改 Response 属性。

list()方法带有**kwargs参数,捕获所有kwargs字典中的参数。这些是 URL 中的参数,如?available=1。方法检测kwargs中available键的值,检测到后改变qcontext来获取仅为可借阅图书的图书记录集。

还要记得让模块知道这个新 Python 文件,需通过将 controllers 子文件夹中添加到library_member/init.py中:

1
2
from . import models
from . import controllers

在library_member/controllers/init.py文件中添加一行代码:

1
from . import main

然后更新模板并访问http://:8069/library/books?available=1 将仅显示勾选了Is Available? 的图书

Odoo 12图书项目可借阅图书

继承 QWeb 模板

要修改网页的实际展示,就需要继承所使用的 QWeb 模板。我们将继承library_app.book_list_template来展示更多有关不可借阅图书的信息。添加library_member/views/book_list_template.xml文件并加入如下代码:

1
2
3
4
5
6
7
8
9
10
11
<odoo>
<template id="book_list_extended"
name="Extended Book List"
inherit_id="library_app.book_list_template">
<xpath expr="//span[@t-field='book.publisher_id']" position="after">
<t t-if="not book.is_available">
<b>(Not Available)</b>
</t>
</xpath>
</template>
</odoo>

网页模板像其它 Odoo 视图类型一样是 XML 文件,同样也可以使用 XPath 来定位元素并对它们进行操作。所继承模型通过在元素中的inherit_id来指明。

小贴士: 在前例中使用了灵活性很强的 XPath 标记,但这里也可以使用等价的简化标记:

然后在 library_member/manifest.py文件中加入该文件的声明:

1
2
3
4
    'data':[
...
'views/book_list_template.xml',
]

然后访问http://:8069/library/books即可对不可借阅图书展示额外的(Not Available)信息。

Odoo 12图书项目不可借阅图书

总结

扩展性是 Odoo 框架的一个重要功能。我们可以创建插件来为需要实现功能的多个层的已有插件修改或添加功能。

模型层中,我们使用_inherit模型属性来引用已有模型,然后在原处执行修改。模型内的字段对象还支持叠加定义,这样可对已有字段重新声明,仅修改属性。

其它的模型继承机制允许我们利用数据结构和业务逻辑。代理继承通过多对一关联字段上的delegate=True属性(或老式的 inherits 模型属性),来让所有关联模块的所有字段可用,并复用它们的数据结构。原型继承使用_inherit属性,来复制其它模型的功能(数据结构定义和方法),并启用抽象 mixin 类,提供一系列像文档讨论消息和 follower 的可复用功能。

视图层中,视图结构通过 XML 定义,(使用 XPath 或 Odoo 简化语法)定位 XML 元素来进行继承及添加 XML 片断。其它由模块创建的记录已可由继承模块修改,仅需引用 对应的完整 XML ID 并在设想的字段上执行写操作。

业务逻辑层中,可使用模型继承相同的机制来进行继承,以及重新声明要继承的方法。在方法内,Python 的super()函数可用于调用所继承方法的代码,添加代码可在其之前或之后运行。

对于前端网页,控制器中的展示逻辑继承方式和模型方法相似,网页模板也是包含 XML 结构的视图,因此可以像其它视图类型一样的被继承。

下一篇文章中,我们将更深入学习模型,探索模型提供给我们的所有功能。

☞☞☞第五章 Odoo 12开发之导入、导出以及模块数据

学霸专区

  1. 如何继承已有模型来添加 mixin,如mail.thread?
  2. 要在会员表单视图中添加Phone字段需要做哪些修改?
  3. 如果创建一个与继承属性的属性名不同的模型类会发生什么(例如_name=’y’ and _inherit=’x’)?
  4. XPath是否可用于修改其它模块的数据记录?
  5. 继承一个模型时,是否可扩展其方法但不使用 super()调用所继承的原始代码?
  6. 如何在不引用任何特定字段名的情况下继承图书目录页并在末行添加 ISBN 字段?

扩展阅读

以下是对官方文档的其它引用,可对模块的扩展和继承机制的知识进行补充:

本文首发地址:Alan Hou 的个人博客

本文为最好用的免费ERP系统Odoo 12开发手册系列文章第三篇。

Odoo 开发通常都需要创建自己的插件模块。本文中我们将通过创建第一个应用来一步步学习如何在 Odoo 中开启和安装这个插件。我们将从基础的开发流学起,即创建和安装新插件,然后在开发迭代中更新代码来进行升级。

Odoo 采用类 MVC(Model-View-Controller)的结构,我们将深入到各层来实施一个图书应用。本文主要内容有:

  • 创建一个新的模块,用来实施相关功能
  • 添加应用的特性功能:顶级菜单项和安全组
  • 添加一个一开始会失败但在项目完成时成功运行的自动化测试
  • 实施模型层,定义应用的数据结构和相关访问权限
  • 实施后台视图层,编写内部用户界面
  • 实施业务逻辑层,支持数据验证和自动化
  • 实施 web 层,展示访客和内部用户的用户界面

系统准备

本文要求安装了 Odoo 服务并可通过命令行启动服务来进行模块安装和运行测试之类的操作。如果还没有相关环境,请参照本系列文章第二篇Odoo 12开发之开发环境准备

本文中我们将从零开始创建第一个 Odoo 应用,无需额外的代码。本文代码可通过 GitHub 仓库进行查看。

概览图书项目

为更好地在本文中探讨,我们将使用一个现实中可以使用的学习项目。一起来创建一个管理图书库的 Odoo 应用。该项目将在后续文章中持续使用,每篇文章都会进行一次迭代,为应用添加新的功能。本文中将创建图书应用的第一个版本,第一个功能是实现图书目录。

图书将包含如下数据:

  • 标题
  • 作者
  • 出版社
  • 发行日期
  • 封面图
  •  ISBN:包含检查 ISBN是否有效的功能
  • 有效性标记;标识图书是否已对公众发布

图书目录可由图书管理员编辑,对图书操作者则仅有可读权限。该目录可通过公共网页访问,仅显示已发布图书。就是这样一个简单的项目,但提供有用的功能,足以让我们了解 Odoo 应用的主要构件。

创建新的插件模块

一个插件模块是包含实现一些 Odoo 功能的文件夹,可以添加新功能或修改已有的功能。插件目录必须含有一个声明或描述文件__manifest__.py,以及其它模块文件。

一部分模块插件在 Odoo 中以app的形式出现,通常都会带有顶级菜单项。它们为 CRM 或 HR 这样的功能区添加核心元素,因此在 Odoo 应用菜单中会高亮显示。另外还有一些非应用模块插件一般为这些应用添加功能。如果你的模块为 Odoo 添加新的或重要的功能,一般应该是app。而如果模块仅修改应用的功能,那么就是一个普通的插件模块。

要创建新模块,需要:

  1. 确保操作的目录是 Odoo 的 addons 路径
  2. 创建模块目录,并包含声明文件
  3. 可选择为模块添加一个图标
  4. 如打算对外发布,为模块选择一个证书

然后我们就可以安装模块了,确定模块在 Odoo 服务中可见并正确安装它。

准备 addons 路径

一个插件模块是一个含有 Odoo 声明文件的目录,它创建一个新应用或为已有应用添加功能。addons模块的路径是一系列目录,Odoo 服务可以在这里查找插件。默认addons包含odoo/addons 中存放的 Odoo 自带的官方应用,以及在odoo/odoo/addons目录中提供核心功能的 base 模块。

我们应将自己创建的或应用市场及其它地方下载的模块放到指定的目录中。要使得 Odoo 服务能够找到这些应用,需要这些目录添加到 Odoo 的 addons 路径中。

根据我们在Odoo 12开发之开发环境准备所创建的项目,Odoo 的代码存放在/odoo-dev/odoo/目录下。最佳实践告诉我们应在自有目录下添加代码,而不应与 Odoo 源代码混在一起。所以要添加自定义模块,我们将在 Odoo 同级创建目录/odoo-dev/custom-addons并添加到 addons 路径中。要添加该目录至 addons 路径,执行如下命令

1
2
cd ~/odoo-dev
./odoo/odoo-bin -d dev12 --addons-path="custom-addons,odoo/addons" --save

注:如有报错参照Odoo常见问题汇总开发类错误处理001

–save 参数将选项保存至配置文件中,这样我们就无需在每次启动服务时输入参数,只需运行./odoo-bin 即可使用上次使用的参数。可以通过-c 参数指定文件来使用或保存配置项。仔细查看输出的日志,可以看到INFO ? odoo: addons paths:[…] 一行中包含custom-addons目录。

如需使用其它目录也请添加至 addons 路径,比如有~/odoo-dev/extra 目录中包含需用到的目录,则需通过如下方式设置–addons-path参数:

1
--addons-path="custom-addons,extra,odoo/addons"

现在我们需要让 Odoo 实例能识别新模块。

小贴士: 以上使用的是相对路径,但在配置文件中需使用绝对路径,–save 参数会自行进行转化。

创建模块目录和声明文件

现在就准备好了~/odoo-dev/custom-addons目录,已正确添加至 addons 路径,Odoo 也就可以找到这里的模块。Odoo 自带一个scaffold命令可自动创建新模块目录,其中会包含基础结构。此处并不会使用该命令,而是手动创建。通过以下命令可以了解scaffold用法:

1
~/odoo-dev/odoo/odoo-bin scaffold --help

Odoo 模块目录需包含一个__manifest__.py描述性文件,同时还需要是可导入的包,所以还应包含__init__.py文件。

ℹ️在老版本中,该描述性文件为__openerp__.py或__odoo__.py,这些名称已过时但仍可使用。

模块目录名是其技术名称,我们使用library_app,技术名称应是有效 Python 标识符,即以字母开头且仅能包含字母、数字和下划线。执行如下步骤来初始化新模块:

1、通过命令行,我们可以添加一个空的__init__.py 文件来初始化模块:

1
2
mkdir -p ~/odoo-dev/custom-addons/library_app
touch ~/odoo-dev/custom-addons/library_app/__init__.py

2、下面添加声明文件,其中应包含一个 Python 字典,有几十个可用属性。其中仅 name属性为必填,但推荐同时添加 description 和 author 属性。在__init__.py 同级创建__manifest__.py 文件,添加以下内容:

1
2
3
4
5
6
7
{
'name': 'Library Management',
'description': 'Manage library book catalogue and lending.',
'author': 'Alan Hou',
'depends': ['base'],
'application': True,
}

depends 属性可以是一个包含所用到的模块列表。Odoo 会在模块安装时自动安装这些模块,这不是强制属性,但建议使用。如果没有特别的依赖,可以添加内核 base 模块。应注意将所有依赖都在此处列明,否则,模块会因缺少依赖而报错或出现加载错误(如果碰巧依赖模块在随后被加载了)。

我们的应用无需依赖其它模块,所以本处使用了 base。为保持简洁,这里仅使用了几个基本描述符键:

  • name:插件模块标题字符串
  • description:功能描述长文件,通常为RST格式
  • author:作者姓名,本处为一个字符串,可以是逗号分隔的一系列姓名
  • depends:一个依赖插件模块列表,在模块安装时会先安装这些插件
  • application:一个布尔型标记,代表模块是否在应用列表中以 app 展现

description可由模块顶层目录中的README.rst或README.md代替,如果两者都不存在,将使用声明文件中的description。

在真实场景中,建议也同时使用其它属性名,因它们与 Odoo 的应用商店有关:

  • summary:显示为模块副标题的字符串
  • version::默认为1.0,应遵守版本号规则。建议在模块版本号前加上 Odoo 版本,如12.0.1.0
  • license::默认为LGPL-3
  • website:了解模块更多信息的 URL,可以帮助人们查看更多文档或提供文件 bug 和建议的跟踪
  • category::带有模块功能性分类字符串,缺省为Uncategorized。已有分类可通过安全组表单(位于Settings > Users & Companies > Groups)的 Application字段下拉列表查看(需开启调试模式)

模块已有分类信息

还有以下描述符键:

  • installable:默认为 True,但可以通过设置为 False 来禁用模块
  • auto_install:若设置为 True,在其依赖已安装时会自动安装,用于胶水模块,用于同一实例上两个模块安装后功能的连接。

添加图标

模块可选择添加图标,这对于作为 app 的模块尤其重要,因为在应用菜单中一般都应有图标。要添加图标,需要在模块中添加static/description/icon.png文件。

为简化操作,我们可以复用 accounting 应用的图标,把odoo/addons/account/static/description/icon.png文件拷贝至customaddons/library_app/static/description目录。可通过如下命令完成:

1
2
3
cd ~/odoo-dev
mkdir -p ./custom-addons/library_app/static/description
cp ~/odoo-dev/odoo/addons/note/static/description/icon.png ./custom-addons/library_app/static/description

补充:开启开者发者模式(修改 URL 中web#为web?debug#),点击 Apps > Update Apps List即可搜到我们创建的应用(下图我使用了自定义的图标)

Odoo 12添加图书 icon

选择证书(开源协议)

为开发的模块选择证书(开源协议)非常重要,应谨慎考虑其代表着什么。Odoo 模块最常用的协议是LGPL(GNU Lesser General Public License)第3版(LGPL v3.0)和AGPL(Affero General Public License)。

LGPL 授权更广,它允许在无需分享相应源码的情况下对代码作出商业修改。AGPL则是一个更严格的开源证书,它要求派生代码及服务托管者分享源码。

了解更多有关 GNU 证书请访问GNU官网

安装新模块

现在我们已经有了一个简化的模块,还没有任何功能,但我们可以通过安装它来检查各项是否正常。

要进行这一操作,模块所有的插件目录应对 Odoo 服务可见。可以通过启动 Odoo 服务来进行确认,可以在输出第一行看到显示为odoo: addons paths: xxx 字样,其中会显示在用的插件路径。更多有关插件路径的知识,参见本系列文章第二篇 Odoo 12开发之开发环境准备

要安装新的模块,我们应在启动服务时指定-d 和-i 参数,-d 指定应使用的数据库,-i 可接收一个逗号分隔的多个待安装模块名。假定开发数据库为dev12,则使用如下命令进行安装:

1
~/odoo-dev/odoo/odoo-bin -d dev12 -i library_app

仔细看日志输出可确定模块是否能被找到并安装,正确安装对应日志: odoo.modules.registry: module library_app: creating or updating database tables。

更新模块

开发模块是一个不断迭代的过程,我们会需要应用更新所修改代码并在 Odoo 中可见。可以在后台界面Apps中搜索对应模块并点击 Upgrade 按钮。但如果修改的是 Python 代码,点击升级不会生效,需要先重启服务方可生效。这是因为 Odoo 仅会加载一次 Python 代码,此后的修改就要求进行重启才会生效。

有时,模块中既修改了数据文件又修改了 Python 代码,那么就需要同时进行如上两种操作。这是 Odoo 开发者的常见困惑。幸好还有更好的方式,最保险的方式是重启 Odoo 实例并应用升级至开发数据库。通过Ctrl + C停止服务实例,然后通过如下命令启动服务并升级library_app模块:

1
~/odoo-dev/odoo/odoo-bin -d dev12 -u library_app

-u(或全称–update)要求使用-d 参数并接收一个逗号分隔的待升级模块集。例如可以使用-u library_app,mail。模块升级后,所有依赖该模块的模块也会被升级。这是保持用于扩展功能的继承机制完整性的基础。

Odoo 11中的修改:
直到 Odoo 10.0,要安装新的插件模块,需要在后台客户端菜单中手动更新以对 Odoo 可见。从 11.0开始,模块列表在模块安装或更新时会自动更新。

在本系列文章中,如需应用对模块代码的修改:

  • 添加模型字段时需进行升级。修改 Python 代码(含 manifest 文件)时需要重启服务。
  • 修改XML或CSV文件时,需进行升级。在不确定时,同时重启服务并升级模块。

在不确定时,最保险的方式是通过-u参数来重启 Odoo 实例,按下键盘上、下方向键可在使用过的命令间切换。进行这一操作时,我们经常会使用到 Ctrl+C,向上方向键和Enter 键。

或者要避免这种重复的停止/启动操作,可使用dev=all选项。这样在保存XML 和 Python文件修改时会自动进行重载,参见本系列文章第二篇 Odoo 12开发之开发环境准备了解更多详情。

创建新应用

一些 Odoo 模块创建新应用,而另一些则对已有应用添加功能或作出修改。虽然两者的技术组件基本相同,但应用会被预期包含一些特征性元素。我们创建的是一个图书应用,所以应包含这些元素,它们是:

  • 图标:用于在应用列表中展示
  • 顶级菜单项:其下放置所有的应用菜单项
  • 应用安全组:通过权限访问仅对指定用户开放

添加图标(icon),仅需在模块目录下static/description/子文件夹中放置icon.png文件,前面已经介绍过了。下面我们来添加应用顶级菜单。

添加应用顶级菜单项

我们创建的是一个新应用,因此应包含主菜单项,在社区版本中,显示在左侧下拉菜单中,而在企业版中,则作为附加图标显示在应用切换器主界面中。

菜单项是使用 XML 文件中添加的视图组件,通过创建views/library_menu.xml来定义菜单项:

1
2
3
4
5
<?xml version="1.0"?>
<odoo>
<!-- Library App Menu -->
<menuitem id="menu_library" name="Library" />
</odoo>

用户界面中的菜单项和操作均存储于数据表中,上面的代码是一个 Odoo 数据文件,描述了要载入 Odoo 数据库的记录。其中的元素是向ir.ui.menu模型写入记录的指示。 id 属性也称作XML ID,用于唯一标识每个数据元素,以供其它元素引用。例如在添加图书子菜单时,就需要引用顶级菜单的XML ID,即menu_library。XML ID是一个重要话题,将在本系列文章第五篇Odoo 12开发之导入、导出以及模块数据中探讨。

此处添加的菜单项非常简单,仅用到了 name 属性。其它常用的属性这里没有使用,没有设置父菜单,因为这是一个顶级菜单。也没有设置 action,因菜单项本身并不做任何事,仅仅用于放置后面要创建的子菜单项。模块还不知道 XML 数据文件的存在,我们需要在__manifest__.py中使用 data 属性来添加安装或更新时需要加载的模块列表以进行声明。在manifest 文件的字典中加入:

1
2
3
'data': [
'views/library_menu.xml',
],

要向Odoo数据库中加载这些菜单设置,需要升级模块。此时还不会有什么显式的效果,因菜单项还不包含可操作子菜单,所以不会显示。在添加好子菜单及合适的访问权限时即可显示。

小贴士: 菜单树中的项目仅在含有可见子菜单项时才会显示。底层包含窗口操作视图的菜单项仅当用户拥有该模型访问权限时才可见。

添加权限组

普通用户在使用功能前需获得相应的权限。Odoo 中使用安全组来实现,权限授予组,组中分配用户。Odoo 应用通常有两个组:针对普通用户的用户组,包含额外应用配置权限的管理员组。

下面我们就来添加这两个安全组。权限安全相关的文件通常放在模块下/security子目录中,这里我们创建security/library_security.xml 文件来进行权限定义。安全组使用分类来更好地组织关联应用。所以第一步我们在ir.module.category模型中创建针对图书应用的分类:

1
2
3
4
5
6
<?xml version="1.0" ?>
<odoo>
<record id="module_library_category" model="ir.module.category">
<field name="name">Library</field>
</record>
</odoo>

下一步,我们要添加两个安全组,首先添加用户组。在以上结束标签前添加如下 XML 代码块:

1
2
3
4
5
6
<!-- Library User Group -->
<record id="library_group_user" model="res.groups">
<field name="name">User</field>
<field name="category_id" ref="module_library_category" />
<field name="implied_ids" eval="[(4, ref('base.group_user'))]" />
</record>

记录在res.groups模型中创建,添加了三个字段:

  • name: 组名
  • category_id: 关联应用,这是一个关联字段,因此使用了 ref 属性来通过 XML ID 连接已创建的分类
  • implied_ids: 这是一个one-to-many关联字段,包含一系列组来对组内用户生效。这里使用了一个特殊语法,在本系列文章第五篇Odoo 12开发之导入、导出以及模块数据中会进行介绍。我们使用了编号4来连接基本内部用户组base.group_user。

然后我们创建管理员组,授予用户组的所有权限以及为应用管理员保留的其它权限:

1
2
3
4
5
6
7
8
9
10
<!-- Library Manager Group -->
<record id="library_group_manager" model="res.groups">
<field name="name">Manager</field>
<field name="category_id" ref="module_library_category" />
<field name="implied_ids" eval="[(4, ref('library_group_user'))]" />
<field name="users" eval="[
(4, ref('base.user_root')),
(4, ref('base.user_admin'))
]" />
</record>

像用户组一样,这里也有name, category_id和implied_ids ,implied_ids关联了图书用户组,以继承其权限。还添加了一个 users 字段,让管理员和内部 root 用户自动成为应用管理员。

ℹ️在 Odoo老版本中,admin 管理员用户同时也是 root 用户。Odoo 12中有一个系统 root用户,在用户列表中不显示,仅在框架需要进行提权(sudo)时在内部使用。admin可以登入系统并应拥有所有功能的访问权限,但不再像系统 root 用户那样可以绕过访问限制。

同样需要在声明文件中添加该 XML 文件:

1
2
3
4
'data': [
'security/library_security.xml',
'views/library_menu.xml',
],

注意library_security.xml 加在library_menu.xml文件之前,数据文件的加载顺序非常重要,因为我们只能引用已经定义过的标识符。菜单项经常引用到安全组,所以建议将安全组定义文件放到菜单和视图文件之前。

Odoo 12图书应用组

添加自动化测试

编程的最佳实践包含代码的自动化测试,对于像 Python 这样的动态语言尤为重要,因为它没有编译这一步,只有在解释器实际运行代码时才会报语法错误。好的编辑器可以让我们提前发现问题,但无法像自动化测试这样帮助我们确定代码如预期般运行。

Odoo 12中的修改
在老版本中,Odoo 使用YAML文件来进行测试,但 Odoo 12中移除了对YAML文件的支持,所以不能再使用该格式文件。

测试驱动开发(TDD -Test-driven Development) 方法让我们先写测试,检查错误,然后开发代码直至通过测试。受此方法启示,在添加实际功能前我们先添加模块测试:

1、测试代码文件名应以test_开头,并通过tests/init.py引用。但测试目录(也即 Python 子模块)不应在模块的外层__init__.py中引入,因为仅在测试执行时才会自动查找和加载它。

2、测试应放在tests/子目录中,在tests/init.py中添加如下代码:

1
from . import test_book

3、在tests/test_book.py文件中添加实际的测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
from odoo.tests.common import TransactionCase

class TestBook(TransactionCase):
def setUp(self, *args, **kwargs):
result = super().setUp(*args, **kwargs)
self.Book = self.env['library.book']
self.book_ode = self.Book.create({
'name': 'Odoo Development Essentials',
'isbn': '879-1-78439-279-6'})
return result
def test_create(self):
"Test Books are active by default"
self.assertEqual(self.book_ode.active, True)

以上代码添加一个简单测试用例,创建一本新书并检测active 字段的值是否正确。

4、使用–test-enable参数在安装或升级模块时进行测试

1
~/odoo-dev/odoo/odoo-bin -d dev12 -u library_app --test-enable

5、Odoo 服务会在升级的模块中查找tests/子目录并运行。现在测试会抛出错误,在输出日志中可看到测试相关的ERROR信息。在为模块添加完图书模型后应该就不再报错。

测试业务逻辑

现在我们应为业务逻辑添加测试了,理想情况下每行代码都应有一个测试用例。在tests/test_book.py文件test_create() 方法再加几行代码:

1
2
3
def test_check_isbn(self):
"Check valid ISBN"
self.assertTrue(self.book_ode._check_isbn)

推荐为每个需检查的操作添加一个测试用例,本条测试与上一条相似,先创建一本新书。因为各个测试用例是相互独立的,用例创建或修改的数据会在测试结束时回滚。然后在创建的记录上调用测试方法来检查所使用 ISBN是否被正确验证。

当然,现在运行测试还是会失败,因为所测试的功能还未被实现。

测试安全权限

也可以对安全权限进行检测,确定是否对用户进行了正确的授权。Odoo 中默认测试由不受权限控制的__system__内部用户执行。所以我们应改变执行测试的用户,来检测是否授予了正确的安全权限。这通过在self.env中修改执行环境来实现,只需把 user 属性修改为希望运行测试的用户即可。修改tests/test_book.py中的setUp方法如下:

1
2
3
4
5
6
7
8
9
def setUp(self, *args, **kwargs):
result = super().setUp(*args, **kwargs)
user_admin = self.env.ref('base.user_admin')
self.env = self.env(user=user_admin)
self.Book = self.env['library.book']
self.book_ode = self.Book.create({
'name': 'Odoo Development Essentials',
'isbn': '879-1-78439-279-6'})
return result

第一条命令调用了父类中的setUp代码,下面一条修改了用于测试的环境self.env为使用 admin 用户的新环境。测试代码的修改到此告一段落。

模型层

既然 Odoo 已经能识别我们的新模块了,下面就添加一个简单的模型。模型描述业务对象,如商机、销售订单或合作伙伴(用户、供应商等)。模型中有一系列属性,也可定义一些特定业务逻辑。

模型通过 Odoo 模板类派生的 Python 类来实现。它直接与数据库对象对应,Odoo 在安装或升级模块时会自动进行处理。框架中负责这部分的是对象关系映射(ORM -Object Relational Mapping)。

我们的模块是一个图书管理应用,第一个功能就是管理图书目录,目前这是我们唯一需要实现的模型。

创建数据模型

Odoo 开发指南中提到模型的 Python 文件应放在models子目录中,每个模型有一个对应文件。因此我们在library_app模块主目录下创建models/library_book.py文件。

ℹ️Odoo 官方编码指南请见 Odoo 官网。另一相关的编码标准文档为 OCA 编码指南

在使用之前,应告知 Python 所需引用的模型目录,仅需在模块主__init__.py文件添加:

1
from . import models

要引用所创建的 Python 代码文件,我们还应添加models/init.py文件:

1
from . import library_book

现在我们可以在models/library_book.py中加入如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
from odoo import fields, models

class Book(models.Model):
_name = 'library.book'
_description = 'Book'
name = fields.Char('Title', required=True)
isbn = fields.Char('ISBN')
active = fields.Boolean('Active?', default=True)
date_published = fields.Date()
image = fields.Binary('Cover')
publisher_id = fields.Many2one('res.partner', string='Publisher')
author_ids = fields.Many2many('res.partner', string='Authors')

第一行是 Python 代码导入语句,让 Odoo 内核的models和fields对象在这里可用。紧接着声明了新的模型,它是models.Model派生出的一个类。然后_name 属性定义了 Odoo 全局对该模型引用的标识符。注意Python 类名 Book 与框架无关,_name 的值才是模型的标识符。

小贴士: 仅有模型名使用点号(.) 来分割关键字,其它如模块、XML 标识符、数据表名等都使用下划线(_)。

注意下面的行都有缩进,对 Python 不熟悉的朋友要知道这很重要:相同缩进代表同一代码块,所以下面的行应采用相同缩进。

_description属性不是必须的,但为模型记录提供了一个用户友好的名称,可用作更好的用户消息。该行之后定义了模型的不同字段 ,值得一提的是name和active为特殊字段名。默认在其它模型中引用模型时,会使用 name 字段作为记录的标题。

active 字段用于激活记录,默认仅 active 记录会显示。对于日期模型这非常有用,隐藏掉那些用户在日常操作中不再使用的记录(因历史原因仍需保留在数据库中)。在本项目中,用于标识图书是否可用。

再来看看其它字段,date_published是一个图书出版日的日期字段,image 是一个存储图书封面的二进制字段。还有一些关联字段:publisher_id是一个出版公司多对一关联,author_ids是作者多对多关联。都是图书与 partner 模型的关联,partner 模型内置于 Odoo 框架中,用户、公司和地址都存储在这里。我们使用它存储出版商和作者。

字段就是这些,要使代码修改生效,需更新模块来触发数据库中相应对象的创建。菜单中还无法访问这一模型,因为我们还没有添加。不过可以通过 Technical 菜单来检查新建模型。访问 Settings > Technical > Database Structure > Models(需开启开发者模式),在列表中搜索library.book,然后点击查看模型定义:

Odoo 12图书模型定义

如查看一切顺利,说明模型和字段都被正常创建,如果你看不到这些,尝试重启服务升级模型。我们还可以看到一些未声明的字段,这些是 Odoo 自动为新模型添加的保留字段,这些字段有:

  • id是模型中每条记录的唯一数字标识符
  • create_date和create_uid分别为记录创建时间和创建者
  • display_name为所使用的记录提供文本显示,如其它记录引用它,它就会被计算并默认使用 name 字段中的文本
  • write_date和write_uid分别表示最后修改时间和修改者
  • __last_update是一个助手字段 ,它不存储在数据库,用于做并发检测

设置访问权限

在加载服务时,你可能会注意到输出日志中有一条警告信息:

The model library.book has no access rules, consider adding one.

提示消息已经很明确了,我们的新模型没有访问规则,所以任何人都可使用。我们已为应用添加了安全组,现在就为模块授权。

ℹ️在 Odoo 12以前,admin 可自动访问所有数据模型,它是一个不受权限控制的超级用户。在 Odoo 12中则不再如此,需要在新模型中设置 ACL才对 admin 可见。

添加访问权限控制

要了解需要哪些信息来为模型添加权限,可访问后台Settings > Technical > Security > Access Rights:

Odoo 12访问权限

这里可以看到一些模型的 ACL(Access Control List),表示允许每个安全组对记录的操作。这一信息需要通过模块中的数据文件提供,然后载入ir.model.access模型。我们将为 employee 组添加该模型的所有权限,内部用户是几乎所有人隶属的基本权限组。

ℹ️Odoo 12中的修改
User 表单现在有一个用户类型,仅在开启开发者模式时显示。它允许互斥的几个选项:内部用户,portal门户用户(外部用户如客户)和public公共用户(网站匿名访客)。这一修改用于避免把内部用户放到 portal 或 public 组中一类的错误配置,那样会导致权限的丧失。

权限通过security/ir.model.access.csv文件来实现,添加该文件并加入如下内容:

1
2
3
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_book_user,BookUser,model_library_book,library_group_user,1,0,0,0
access_book_manager,BookManager,model_library_book,library_group_manager,1,1,1,1

注:应注意该文件第一行后不要留有空格,否则会导致报错

文件名必须与要载入的模型对应,第一行为列名,CSV 文件中有如下列:

  • id是记录的外部标识符(也称为XML ID),需在模块中唯一
  • name是描述性标题,仅在保证唯一时提供有用信息
  • model_id是赋权模型的外部标识符,模型有ORM自动生成的XML ID,对于library.book,标识符为model_library_book
  • group_id指明授权的安全组,我们给前文创建的安全组授权:library_group_user和library_group_manager
  • perm_…字段标记read读, write写, create创建, 或unlink删除权限,我们授予普通用户读权限、管理员所有权限

还应记得在__manifest__.py的 data 属性中添加对新文件的引用,修改后如下:

1
2
3
4
5
'data': [
'security/library_security.xml',
'security/ir.model.access.csv',
'views/library_menu.xml',
],

老规矩升级模块让修改生效,此时警告信息就不见了。我们通过 admin登录来检测权限是否正确,admin 属于图书管理员组。

行级权限规则

我们知道默认 active 标记为 False 的记录不可见,但用户在需要时可使用过滤器来访问这些记录。假设我们不希望普通图书用户访问无效图书,可通过记录规则来实现,即定义过滤器来限制某权限组所能访问的记录。这位于Settings > Technical > Security > Record Rules。

记录规则在ir.rule中定义,和往常一样我们选择一个唯一名称。还应获取操作的模型及使用权限限制的域过滤器。域过滤器使用 Odoo 中常用的元组列表,在第八章 Odoo 12开发之业务逻辑 - 业务流程的支持将讲解域表达式语法。

通常,规则应用于指定安全组,我们这里应用的是雇员组。如果没有指定的安全组,则应用于全局(global 字段自动设为 True)。全局规则不同,它们做的限制非全局规则无法重载。

要添加记录规则,需编辑security/library_security.xml文件添加如下代码:

1
2
3
4
5
6
7
8
9
10
<data noupdate="1">
<record id="book_user_rule" model="ir.rule">
<field name="name">Library Book User Access</field>
<field name="model_id" ref="model_library_book" />
<field name="domain_force">
[('active','=',True)]
</field>
<field name="groups" eval="[(4,ref('library_group_user'))]" />
</record>
</data>

记录规则位于元素中,表示这些记录在模型安装时会被创建,但在模型更新时不会被重写。这么做是允许对规则在后面做自定义但避免在执行模型升级时自定义内容丢失。

小贴士: 开发过程noupdate=”1”会带来麻烦,因为要修复规则时模块更新不会在数据库中重写数据。所以在开发时可以修改为noupdate=”0”来让数据达到预期结果。

在 groups 字段中,会发现有一个特殊表达式,这是一个带有特殊语法的one-to-many关联字段。元组(4, x)表示x应添加到记录中,此处 x 为一个标记为base.group_user的内部用户组引用。针对多个字段的这种特殊语法在第六章 Odoo 12开发之模型 - 结构化应用数据中探讨。

Odoo 12记录规则

视图层

视图层为用户界面的描述,视图用 XML 定义,由网页客户端框架生成数据感知的 HTML 视图。可用菜单项开启渲染视图的操作。比如,Users 菜单项处理一个同样名为 Users 的操作,然后渲染一系列视图。有多种可用视图类型,如 list(因历史原因也称为 tree)列表视图和 form表单视图,以及包含过滤项的右上角搜索框由 search 搜索视图定义。

Odoo 开发指南写到定义用户界面的 XML 文件应放在views/子目录中。接下我们来创建图书应用的用户界面。下面我们会逐步改进并更新模块来使更改生效。可以使用–dev=all参数来在开发时频繁的升级。使用该参数,视图定义会在 XML 文件中直接读取,无需升级模块即可在 Odoo 中即刻生效。

小贴士: 如果因 XML 错误升级失败,不必惊慌!仔细阅读输出日志的错误信息,就可以找到问题所在。如果觉得麻烦,注释掉最近编辑的 XML 版块或删除__manifest__.py中 该XML 文件,重新更新,服务应该就可正确启动了。

添加菜单项

现在有了存储数据的模型,需要添加到用户界面中。首先要做的就是添加相应菜单项。编辑views/library_menu.xml文件,在 XML 元素中定义菜单项以及执行的操作:

1
2
3
4
5
6
7
8
9
10
11
12
<!-- Action to open the Book list -->
<act_window id="action_library_book"
name="Library Books"
res_model="library.book"
view_mode="tree,form"
/>
<!-- Menu item to open the Book list -->
<menuitem id="menu_library_book"
name="Books"
parent="menu_library"
action="action_library_book"
/>

用户界面,包括菜单项和操作,存储在数据表中。在安装或升级插件模块时,XML文件会将这些定义载入数据库中的数据文件。以上代码是一个 Odoo 数据文件,表示两条添加到 Odoo 的记录:

  • 元素定义客户端窗口操作,它按顺序通过启用列表和表单视图打开library.book 模型
  • 定义一个调用前面定义的action_library_book操作的顶级菜单项

现在再次升级模块来让修改生效。然后刷新浏览器页面,就可以看到Library顶级菜单,并包含一个子菜单项。点击该菜单会显示一个基本列表视图,记录可通过一个自动生成的表单视图进行编辑。点击 Create 按钮即可查看:

Odoo 12图书项目表单视图

虽然我们还没有定义用户界面视图,自动生成的列表视图和表单视图也可以使用,允许我们马上编辑数据。

创建表单视图

所有的视图都存储在数据库ir.ui.view模型中。为模型添加视图,我们在 XML文件中声明元素来描述视图,在模块安装时 XML 文件会被载入数据库。

添加views/book_view.xml文件来定义表单视图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0"?>
<odoo>
<record id="view_form_book" model="ir.ui.view">
<field name="name">Book Form</field>
<field name="model">library.book</field>
<field name="arch" type="xml">
<form string="Book">
<group>
<field name="name" />
<field name="author_ids" widget="many2many_tags" />
<field name="publisher_id" />
<field name="date_published" />
<field name="isbn" />
<field name="active" />
<field name="image" widget="image" />
</group>
</form>
</field>
</record>
</odoo>

这个ir.ui.view记录有三个字段值:name, model和 arch。另一个重要元素是记录 id,它定义了一个可在其它记录中引用的XML ID标识符。这是library.book 模型的视图,名为Book Form。这个名称仅用于提供信息,无需唯一,但应易于分辨所引用的记录。其实可以完全省略 name,这种情况下会自动按模型名和视图类型来生成。

最重要的字段是arch,它包含了视图的定义,在 XML 代码中我们做了高亮显示(博客主题问题无法显示)。标签定义了视图类型并包含视图结构。

此处中包含了要在表单中显示的字段。这些字段会自动使用默认的组件,如 date 字段使用日期选择组件。有时我们要使用不同的组件,如以上代码中的author_ids使用了显示标签列表的组件,image字段使用处理图片的相应组件。有关视图元素的详细说明请见第十章 Odoo 12开发之后台视图 - 设计用户界面

不要忘记在声明文件的 data 中加入新建文件,否则我们的模块将无法识别到并加载该文件:

1
2
3
4
5
6
'data': [
'security/library_security.xml',
'security/ir.model.access.csv',
'views/library_menu.xml',
'views/book_view.xml',
],

要使修改载入 Odoo 数据库就需要更新模块。需要重新加载页面来查看修改效果,可以再次点击菜单项或刷新网页(大多数浏览器中快捷键为 F5)。

Odoo 12图书项目修改后表单视图

业务文件表单视图

上面的部分创建了一个基础表单视图,还可以做一些改进。对于文件模型,Odoo 有一个模拟纸张的展示样式,表单包含两个元素:

来包含操作按钮和来包含数据字段。可以修改上一部分的基础定义为:

1
2
3
4
5
6
7
8
9
10
11
<form string="Book">
<header>
<!-- 此处添加按钮 -->
</header>
<sheet>
<group>
<field name="name" />
...
</group>
</sheet>
</form>

添加操作按钮

表单可带有执行操作的按钮。这些按钮可用于运行窗口操作,如打开另一个表单或执行模型中定义的 Python 方法。按钮可以放在表单的任意位置,但对于文件样式表单,推荐的位置是

中。

我们的应用会添加图书 ISBN,和一个用于检测 ISBN 有效性的按钮。代码将放在 Book 模型中,我们将该方法命名为button_check_isbn()。虽然还未创建该方法,我们现在可以在表单中先添加相应按钮:

1
2
3
4
<header>
<button name="button_check_isbn" type="object"
string="Check ISBN" />
</header>

一个按钮的基本属性有:

  • string:定义按钮显示文本
  • type:执行的操作类型
  • name:操作的标识符
  • class:应用 CSS 样式的可选属性,与 HTML 相同

使用组来组织表单

标签可用于组织表单内容。在元素内加会在外层组中创建一个两列布局。推荐在group 元素中添加 name 属性,更易于其它模块对其进行继承。我们使用该标签来组织内容,修改内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<sheet>
<group name="group_top">
<group name="group_left">
<field name="name" />
<field name="author_ids" widget="many2many_tags" />
<field name="publisher_id" />
<field name="date_published" />
</group>
<group name="group_right">
<field name="isbn" />
<field name="active" />
<field name="image" widget="image" />
</group>
</group>
</sheet>

完整表单视图

此时library.book的表单视图代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<form string="Book">
<header>
<button name="button_check_isbn" type="object"
string="Check ISBN" />
</header>
<sheet>
<group name="group_top">
<group name="group_left">
<field name="name" />
<field name="author_ids" widget="many2many_tags" />
<field name="publisher_id" />
<field name="date_published" />
</group>
<group name="group_right">
<field name="isbn" />
<field name="active" />
<field name="image" widget="image" />
</group>
</group>
</sheet>
</form>

按钮还无法使用,需要先添加业务逻辑。

Odoo 12图书项目分组加按钮表单视图

添加列表视图和搜索视图

以列表模式显示模型需要使用视图。树状视图可以按层级显示,但大多数情况下仅需显示为普通列表。

可以在book_view.xml文件中添加视图:

1
2
3
4
5
6
7
8
9
10
11
12
<record id="view_tree_book" model="ir.ui.view">
<field name="name">Book List</field>
<field name="model">library.book</field>
<field name="arch" type="xml">
<tree>
<field name="name" />
<field name="author_ids" widget="many2many_tags" />
<field name="publisher_id" />
<field name="date_published" />
</tree>
</field>
</record>

以上定义了一个含有四列的列表:name, author_ids, publisher_id和 date_published。在该列表的右上角,Odoo 显示了一个搜索框。搜索的字段和可用过滤器也由视图定义。同样还在book_view.xml文件中添加:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<record id="view_search_book" model="ir.ui.view">
<field name="name">Book Filters</field>
<field name="model">library.book</field>
<field name="arch" type="xml">
<search>
<field name="publisher_id" />
<filter name="filter_active"
string="Active"
domain="[('active','=',True)]" />
<filter name="filter_inactive"
string="Inactive"
domain="[('active','=',False)]" />
</search>
</field>
</record>

元素定义在搜索框中输入搜索的字段,这里添加了publisher_id自动提示出版商字段。元素添加预定义过滤条件,用户通过点击来切换,它使用了特殊的语法,在第十章 Odoo 12开发之后台视图 - 设计用户界面中将会进一步介绍。

ℹ️Odoo 12中的修改
现在要求包含name=”…”属性,唯一标识每个过滤器,如果不写,验证会失败,模块将无法安装或升级。

Odoo 12图书应用列表视图

业务逻辑层

业务逻辑层编写应用的业务规则,如验证和自动计算。现在我们来为按钮添加逻辑,通过在模型 Python 类中编写方法来实现。

添加业务逻辑

上文中我们在 Book表单中添加了一个按钮,用于检查 ISBN 是否有效。现代 ISBN 包含13位数字,最后一位是由前12位计算所得的检查位。我们无需深入到算法的细节,这里是一个实现验证的 Python 方法。应当在class Book(…)中进行添加:

1
2
3
4
5
6
7
8
9
10
11
@api.multi
def _check_isbn(self):
self.ensure_one()
isbn = self.isbn.replace('-', '') # 为保持兼容性 Alan 自行添加
digits = [int(x) for x in isbn if x.isdigit()]
if len(digits) == 13:
ponderations = [1, 3] * 6
terms = [a * b for a,b in zip(digits[:12], ponderations)]
remain = sum(terms) % 10
check = 10 - remain if remain !=0 else 0
return digits[-1] == check

图书模型的button_check_isbn()方法应使用该函数来验证 ISBN 字段中的数字,如果验证失败,应向用户显示警告信息。

首先要导入 Odoo API库,添加对应的 import 及 Odoo Warning异常。这需要编辑library_book.py文件修改前两行为:

1
2
from odoo import api, fields, models
from odoo.exceptions import Warning

然后还是在models/library_book.py文件Book 类中加入:

1
2
3
4
5
6
7
8
@api.multi
def button_check_isbn(self):
for book in self:
if not book.isbn:
raise Warning('Please provide an ISBN for %s' % book.name)
if book.isbn and not book._check_isbn():
raise Warning('%s is an invalid ISBN' % book.isbn)
return True

对于记录的逻辑,我们使用@api.multi装饰器。此处 self 表示一个记录集,然后我们遍历每一条记录。其实@api.multi装饰器可以不写,因为这是模型方法的默认值。这里保留以示清晰。代码遍历所有已选图书,对于每本书,如果 ISBN 有值,则检查有效性,若无值,则向用户抛出一条警告消息。

模型方法无需返回值,但此处需至少返回 True 值。因为不是所有XML-RPC客户端实现都支持None/Null空值,这种情况若返回空值则会导致抛出错误。此时可更新模块并再次运行测试,添加–test-enable参数来确定测试是否通过。也可以在线测试,进入 Book 表单使用正确和错误的 ISBN点击按钮进行测试。

Odoo 12自动化测试

网页和控制器

Odoo 还提供了一个 web 开发框架,可用于开发与后台应用深度集成的功能。第一步我们来创建一个显示有效图书列表的简单网页。在请求http:///library/books页面时会进行响应,所以/library/books是用于实施的 URL。这里我们简短地了解下 Odoo 网页开发,这一话题在第十三章 Odoo 12开发之创建网站前端功能中会深入探讨。

Web控制器是负责渲染网页的组件。控制器是http.Controller中定义的方法,与URL链接(endpoint)绑定。 访问 URL 时执行控制器代码,生成向用户展示的 HTML。我们使用 QWeb 模板引擎方便HTML的渲染。

按惯例控制器代码放在/controllers子目录中,首先编辑library_app/init.py导入控制器模块目录:

1
2
from . import models
from . import controllers

然后添加library_app/controllers/init.py文件来让目录可被 Python 导入,并在该文件中添加:

1
from . import main

接下来就要创建真实的控制器文件library_app/controllers/main.py,并添加如下代码:

1
2
3
4
5
6
7
8
9
10
from odoo import http

class Books(http.Controller):

@http.route('/library/books', auth='user')
def list(self, **kwargs):
Book = http.request.env['library.book']
books = Book.search([])
return http.request.render(
'library_app.book_list_template', {'books':books})

这里导入的odoo.http模块,是提供网页相关功能的核心组件。http.Controller是需要继承的类控制器,这里在主控制器类中使用。我们选择的类名和方法并不关联,@http.route装饰器才是重要的部分,它声明了与类方法关联的 URL 地址,此处为/books。默认访问 URL 地址要求客户登录,推荐明确指出访问的授权模式,所以这里添加了auth=’user’参数。要允许公开访问,可为@http.route 添加auth=’public’ 参数。

小贴士: 如果使用auth=’public’,控制器代码中在进行图书搜索前应使用sudo() 进行提权。这部分在第十三章 Odoo 12开发之创建网站前端功能会进一步讨论。

在这个控制器方法中,我们使用http.request.env获取环境,使用它可从目录中获取有效图书记录集。最后一步是使用http.request.render() 来处理 library_app.index_template Qweb 模板并生成输出 HTML。可通过字典向模板传值,这里传递了图书记录集。

这时如果重启 Odoo 服务来重载 Python 代码,并访问/library/books会得到一个错误日志:ValueError: External ID not found in the system: library_app.book_list_template。这是因为我们还没有定义模板。下面就一起来定义模板。

QWeb模板是一个视图类型,应放在/views子目录下,我们来创建views/book_list_template.xml文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="book_list_template" name="Book List">
<div id="wrap" class="container">
<h1>Books</h1>
<t t-foreach="books" t-as="book">
<div class="row">
<span t-field="book.name" />,
<span t-field="book.date_published" />,
<span t-field="book.publisher_id" />
</div>
</t>
</div>
</template>
</odoo>

<template>元素用于声明 QWeb 模板,它事实上是一个存储模块的 base 模型 - ir.ui.view记录的快捷方式。模板中包含要使用的 HTML,并使用 Qweb 的特定属性:t-foreach用于遍历变量 books的每一项,通过控制器的http.request.render()调用来获取;t-field用于渲染记录字段的内容。这里仅简单地使用 QWeb,更多详情见第十三章 Odoo 12开发之创建网站前端功能

在模块的 manifest 中需要声明该 XML 文件来供加载和使用。进行模块升级即可通过http://<my-server>:8069/library/books来访问有效图书的简单列表。

Odoo 12图书项目图书列表

注:以上数据为从 Packt 的 Top 20中添加了当前前3

总结

本文中我们从0开始创建了一个新模块,了解了模块中常用的元素:模型、三个基础视图类型(表单视图、列表视图和搜索视图)、模型方法中的业务逻辑和访问权限。我们还学习了访问权限控制,包括记录规则以及如何使用网页控制器和 Qweb 模板来创建网页。

在学习过程中,我们熟悉了模块开发过程,包含模块升级和应用服务重启来使得修改在 Odoo 中生效。不要忘记在添加模块字段时需要进行更新操作。修改含声明在内的 Python 文件需要重启服务。修改XML或CSV文件需进行更新,一旦不确定,同时进行重启服务和升级模块操作。

我们已经学习创建 Odoo 应用的基本元素和步骤,但大多数情况下,我们的模块都是对已有应用添加功能来进行扩展,我们将在下一篇文章中一起学习。

☞☞☞第四章 Odoo 12 开发之模块继承

学霸专区

  1. library-app是正确的模块名吗?
  2. 模块是否应为其中所有的模型定义访问控制列表(ACL)?
  3. 是否可以让某些用户仅访问一个模型记录的子集?
  4. 关联字段和其它字段类型有什么区别?
  5. Odoo 应用中使用的主要视图组件有哪些?
  6. 后台视图如何定义?
  7. Odoo 应用中的业务逻辑应在哪里实现?
  8. Odoo 使用的网页模板引擎是什么?

扩展阅读

本文中涉及到的所有课题在系列文章后续都会深入介绍。官方文档中的相关资源可以作为补充阅读:

学习 Python 对 Odoo 开发来说也非常重要,在Packt 书录中有一些很好的 Python 图书,如Learn Python Programming – Second Edition

注:本博客新增精通Python自动化脚本-运维人员宝典可用于深入Python 脚本的学习。

本文首发地址:Alan Hou 的个人博客

本文为最好用的免费ERP系统Odoo 12开发手册系列文章第二篇。

在更深入了解 Odoo 开发之前,我们应配置好开发环境并学习相关的基础管理任务。本文中,我们将学习创建 Odoo 应用所需用到的工具和环境配置。这里采用 Ubuntu 系统来作为开发服务器实例的主机,可以是云服务器、本地服务器或者PC 上的虚拟机。

本文主要内容有:

  • 配置主机,可以是 Ubuntu系统或 PC 机上的 Linux 子系统
  • 使用源码安装 Odoo,包括数据库和系统依赖的安装
  • 管理 Odoo 数据库(创建、删除和拷贝)
  • 配置 Odoo 服务器选项
  • 查找并安装社区插件
  • 使用虚拟环境管理 Odoo 不同版本和项目
  • 开启服务端开发者模式简化开发任务

开发准备

本文将介绍如何在开发电脑上使用源码安装 Odoo,建议使用系统是Ubuntu 18.04 ,也可以选择 Windows 10,文中会介绍到如何在 Windows 下安装 Linux 子系统。相关代码可参见 GitHub 仓库

设置 Odoo 服务宿主机

推荐使用Debian/Ubuntu来运行 Odoo 服务。虽然 Odoo 是一个跨平台的系统,可以运行在不同的操作系统上,但事实上 Odoo 的研发(R&D)团队把 Debian 系作为参照部署平台。并且 Odoo 自己的 SaaS 平台也运行在 Debian 之上。这也是社区中最主流的选择,说明使用Debian 或 Ubuntu 会更容易寻求到帮助和建议。你也许仅有 Windows 相关背景,但对Debian 系有一定了解也非常重要。

当然你依然可以选择自己喜欢的系统,如 Windows, Mac 或其它Linux发行版本(如 CentOS)。

ℹ️本文中介绍的是在一个全新的系统中进行开发,如果你在已有系统中开发,请做好备份以在出错时进行恢复

使用 Windows 子系统安装 Linux

在 Windows 系统中,最简单的方案是使用 Windows 10自带的Linux子系统(WSL - Windows Subsystem for Linux)。通过子系统,我们可以在 Windows 内运行 Ubuntu 系统,足以应对 Odoo 开发所需的一切。更多 WSL 相关知识请参考官网

WSL 是Windows 10最近发布中的一个可选功能,使用前需要先启用。启用后即可在商店中安装 Ubuntu,详见官方帮助文档

在写本文时,需要如下步骤来完成安装:

第一步是要确保 WSL 功能已开启,以管理员身份打开 PowerShell 并运行:

1
Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux

以上命令需要在单行执行,然后根据提示重启电脑。然后我们就可以安装Ubuntu Windows应用,最简单地方式是在自带微软商店中搜索 Ubuntu,在写本文时最新的长期支持版本(LTS)是18.04,按照提示进行安装即可。运行 Ubuntu 应用会打开一个bash 命令行,这里可以输入在 Ubuntu 系统中相同的命令。需要记住在安装时配置的用户名和密码,因为在进行提权操作时会要求输入该信息(如运行 sudo 时)。

安装 Linux 服务器

我们还可以选择在电脑上安装 Linux,或在局域网乃至云端安装 Linux 系统。我们需要一台基于 Debian 的服务器用于 Odoo 服务端开发,如果此前你没有接触过 Linux, 请注意 Ubuntu 是一个基于 Debian 的 Linux 发行版本,所以两者极为相似。Odoo 保证可在当前稳定的 Debian 或 Ubuntu 版本上运行,在写本文时,分别为 Debian 9(Stretch)和Ubuntu 18.04 LTS(Bionic Beaver)。

更推荐选择 Ubuntu,因安装上较 Debian 容易。可从 Ubuntu 官网上下载 ISO 镜像,建议使用最新的 LTS 版本。如果你刚刚接触 Linux,使用预配置的镜像会更容易些。TurnKey Linux提供了含 ISO 的多种格式预安装镜像。ISO 格式可以在任意虚拟化软件上使用,即便是裸机。较优的选择是 LAPP 镜像,已安装了 Odoo 所需的Python 和 PostgreSQL。

为能够进行远程操作,通常需安装OpenSSH服务。在 Ubuntu 的设置助手中有这一服务,但也可以通过如下命令来进行安装:

1
sudo apt-get install openssh-server

然后需要使用 SSH(Secure Shell)客户端来连接 Odoo 的宿主机,Windows 中常用的有 PuTTYXShellSecureCRT

可以通过如下命令来查看服务器的 IP 地址:

1
ip addr show

使用 SSH 客户端可以远程操作 Linux 主机,还可以获得比在虚拟机终端操作更好的体验,我们可以更容易的复制粘贴、修改窗口大小、字体等。

补充: 关于虚拟机 Hyper-V,  VMware, VirtualBox和 Vagrant 都是很好的方案,网上有很多资料,限于篇幅本文不再介绍。

源码安装 Odoo

在本系列文件第一篇使用开发者模式快速入门 Odoo 12中,我们介绍了快速运行 Odoo 的各种方式,本文中我们将更深入一步,直接通过源码来安装、运行 Odoo。

Odoo使用Python 编程语言,数据存储使用 PostgreSQL数据库,这是对 Odoo 主机的两大要求。要使用源码运行 Odoo,首先要安装其所依赖的 Python 库。然后从 GitHub 上下载源代码,虽然可以下载 zip 和 tar 文件,但使用 Git版本管理工具获取代码会更优。

ℹ️具体依赖的安装根据操作系统和安装的 Odoo 版本可能会不同。如果在上述步骤中存在问题,请参考官方文档,可切换版本查看其它版本的操作步骤。

安装 PostgreSQL 数据库

Odoo 要使用到 PostgreSQL服务,典型的开发设置是使用安装 Odoo 的同一台机器安装PostgreSQL。下面我们就来安装数据库服务:

1
2
3
sudo apt update
sudo apt install postgresql -y # 安装PostgreSQL
sudo su -c "createuser -s $USER" postgres # 创建数据库超级用户

最后这条命令为当前系统用户创建了一个PostgreSQL用户,用于 Odoo 实例创建或删除数据库时使用。

如果在 WSL内运行 Ubuntu,注意系统服务不会自动启动。也就是说运行任何需要数据库连接的命令(如createuser或启动 Odoo 服务)时都要手动开启PostgreSQL服务,手动启动PostgreSQL服务执行:sudo service postgresql start。

安装 Odoo 系统依赖

要运行 Odoo,我们还需要一些系统包和软件。获取版本控制的源码应安装 Git,安装运行 Odoo要求 Python 3.5或之后的版本、Python 3的 pip 以及一些 Python 包的系统依赖:

1
2
3
4
5
sudo apt update
sudo apt upgrade
sudo apt install git -y # 安装Git
sudo apt install python3-dev python3-pip -y # Python 3 for dev
sudo apt install build-essential libxslt-dev libzip-dev libldap2-dev libsasl2-dev libssl-dev -y

Odoo 9, 10, and 11版要用到less CSS 预处理器,所以对这些版本需要执行如下安装:

1
2
3
sudo apt install npm # 安装Node.js和包管理器
sudo ln -s /usr/bin/nodejs /usr/bin/node # 通过node运行Node.js
sudo npm install -g less less-plugin-clean-css # 安装less

Odoo 12中无需执行如上命令,但通常我们也会用到前述版本,如果有此情况则仍需安装。

ℹ️Odoo 12中的修改
CSS 预处理器由 less 改成了 Sass,Sass 编译器无需进行额外安装,在 Odoo 12的 Python 依赖中已经安装了libsass-python。做出这一更改的原因有:Bootstrap 4由 less 调整为 Sass,已有 Python绑定和避免对 Node.js(或 Ruby)的依赖。

源码安装 Odoo

为便于管理,我们将在家目录下创建一个/odoo-dev目录作为工作目录。在本系列文章中我们均假设 Odoo 安装在/odoo-dev目录下。

Odoo 使用的是 Python 3(3.5或之后的版本),那么在命令行中我们将不再使用python和pip,而是用python3和 pip3。

ℹ️Odoo 11的修改
从版本11开始,Odoo 运行在 Python 3.5及以后的版本上,Odoo 11依然支持 Python 2.7,但 Odoo 12仅能运行在Python 3.5+的环境中。Odoo 10及之前的版本仅可运行在Python 2.7上。

要从源码安装 Odoo,我们首先要从 GitHub 上克隆一套 Odoo 源代码:

1
2
3
mkdir ~/odoo-dev # 创建工作目录
cd ~/odoo-dev # 进入工作目录
git clone https://github.com/odoo/odoo.git -b 12.0 --depth=1 # 获取 Odoo 源码

~是用户家目录的简写,比如/home/alan。

如果使用 WSL Windows 子系统,家目录存放在一个Windows 系统文件夹中一个不易于发现的地方。避免这一情况的一个方法是把工作文件放在常用的文件夹下,然后在子系统中使用软链接(类似 Windows中的快捷方式)。比如mkdir /mnt/c/Users/Public/odoo-dev创建C:\Users\Public\odoo-dev工作目录,然后ln -s /mnt/c/Users/Public/odoo-dev /odoo-dev创建/odoo-dev Linux目录,实际上是 Windows 目录的链接。现在就可以使用~/odoo-dev运行所有的命令,比如前述的 git clone,文件将存放在C:\Users\Public\odoo-dev下。

Git命令中的-b 12.0明确表明要下载 Odoo 12.0分支,在写本文时,这显得有些多余,因为这正是当前默认的分支。–depth=1表示仅下载当前修订版本,而不是所有历史修改记录,这会使下载更快、内容更少。

小贴士: 如需在后续下载历史提交内容,可以运行git fetch –unshallow

在运行 Odoo 之前,应安装requirements.txt中所声明的 Python 依赖:

1
pip3 install -r ~/odoo-dev/odoo/requirements.txt

补充: 安装时如因requirements.txt指定了Babel==2.3.4而报错,请修改Babel==2.3.4为Babel或 Babel>=2.3.4后再重新执行以上命令;另内存过小也可能导致不易察觉报错,测试时1G 内存报错,调整为2G 后正常安装

这其中的很多依赖都可以通过 Debian 包安装,如python3-lxml,所以使用 apt 包管理器是一个有效的替代方案。这样的优势是在必要时可以自动安装系统依赖,使用的依赖列表可以在./odoo/debian/control中找到。

还有一些requirements.txt中未包含的 Python 包,亦可安装来避免警告或用于开启额外的功能:

1
pip3 install num2words phonenumbers psycopg2-binary watchdog xlwt

小贴士: pip3工具可以通过好几种方式安装:系统包的方式和原生 Python 的方式。如果pip3报了 import error,在系统中重新安装或能解决问题。对应的命令为sudo python3 -m pip uninstall pip && sudo apt install python3-pip –reinstall

现在,通过如下命令可启动 Odoo 实例:

1
~/odoo-dev/odoo/odoo-bin

如需停止服务并回到命令行,按下 Ctrl + C。

小贴士:Odoo 10中的修改
现在启动 Odoo的脚本是./odoo-bin,而之前的 Odoo 版本中为./odoo.py

默认Odoo 实例监听8069端口,所以在浏览器中访问http://:8069 就可以访问到 Odoo 实例。对于开发者来说,我们会需要使用到多个数据库,所以在命令行中创建会更为方便,后面会介绍如何实现。现在在命令行终端中按下Ctrl + C 停止 Odoo 服务并回到命令行。

初始化新的 Odoo 数据库

要按照 Odoo 的数据模式创建和初始化 Odoo 数据库,我们应使用-d 参数运行 Odoo 服务:

1
~/odoo-dev/odoo/odoo-bin -d testdb

初始化 testdb 数据库可能要花上一分钟,一般会以Modules loaded的 INFO 日志信息结束。它有可能不是最后一条日志信息,但在倒数三到四行中可以找到。此时,服务器就可以监听用户请求了。

ℹ️Odoo 9中的修改
从 Odoo 9开始,如果数据库不存在会被自动创建。但在 Odoo 8中并非如此,需使用PostgreSQL命令行中的createdb命令手动创建

默认情况下,数据库初始化时会带有演示数据,作为开发数据库这通常很有用。相当于在前端创建数据库时勾选了Load demonstration data。如在初始化时无需带有演示数据,在命令行中添加–without-demo=all。

小贴士: 在写本文时Ubuntu WSL中有一个针对 PostgreSQL 的问题,该环境下无法新建空数据库。一个解决方案是手动通过createdb 12-library命令创建空数据库。这样会重复提示WARNING: could not flush dirty data: Function not implemented。虽然存在警告信息,但数据库正常创建了。按下Ctrl + C停止警告,使用命令行即可启动 Odoo 并初始化数据库。

当前用户需要是 PostgreSQL 的超级用户才能创建新数据库,前文中在安装过程中已经进行相关操作。

小贴士: 对于开发环境,使用数据库超级用户来运行 Odoo 实例毫无问题。但在生产环境中,Odoo最佳安全实践推荐不要使用数据库超级用户权限的用户来运行生产实例。

Odoo 实例已经运行起来了,现在我们可以通过http://:8069在浏览器中进行访问。这时会出现登录界面,如果不知道主机名可以通过 hostname 命令查看或通过 ifconfig 查看 IP 地址。默认管理员的用户名和密码均为 admin。登录后即可看到 Apps菜单,及可安装的应用。

在命令终端中按下Ctrl + C可以停止 Odoo 服务实例并回到命令行。按下上方向键可以回到上一条命令,这样可以通过同样的命令再次快速启动 Odoo。

Odoo 12后台界面

管理 Odoo 数据库

前面我们学习了如何通过命令行创建和初始化 Odoo 数据库。管理数据库还有更多的命令值得我们学习。虽然 Odoo 服务可以自动地进行数据库管理,我们还是可以使用如下命令来手动创建 PostgreSQL 数据库:

1
createdb MyDB

更有趣的是,Odoo 可以通过–template 参数拷贝已有数据库来创建新的数据库。要进行这一操作,被拷贝的数据库不能被连接,所以要确保 Odoo 实例已停止并且没有其它应用连接该数据库。命令如下:

1
createdb --template=MyDB MyDB2

事实上,每当创建数据库时都会使用一个模板,如果没有指定模板,会使用一个名为 template1的预设模板。

要在系统中列出已有数据库,需要使用 PostgreSQL 的 psql 工具及-l 参数:

1
psql -l

执行上述命令会列出我们截至目前所创建的数据库。如果执行了前述操作,可以看到列表中有MyDB和MyDB2。列表中还会显示 每个数据库所使用的编码,默认为UTF-8,这也是 Odoo 所需要的数据库编码。

如需删除数据库,执行dropdb命令:

1
dropdb MyDB2

现在我们已学习了数据库的基本知识。要了解更多 PostgreSQL请参见官方文档PostgreSQL使用汇总

警告: dropdb操作是不可撤销的,在对重要数据库执行该命令时请务必备份数据库

其它服务器配置项

Odoo 服务还支持一些其它参数,可通过–help 来查看更多参数:

1
~/odoo-dev/odoo/odoo-bin --help

我们在下面的部分将会学习一些重要参数,首先我们先学习下如何把当前使用参数保存到配置文件中。

Odoo 服务配置文件

大多数的参数都可以保存到配置文件中。默认 Odoo 使用.odoorc文件。Linux 系统中缺省的路径是在家目录($HOME)中,而在 Windows 中则和运行 Odoo 的可执行文件放在同一个目录中。

ℹ️在老版本的 Odoo/OpenERP 中,默认的配置文件为.openerp-serverrc,为保持向后兼容,存在该文件且没有.odoorc文件的情况下会使用该文件。

一个全新安装中不会自动创建.odoorc配置文件,我们应使用–save参数来保存,如果配置文件不存在则会创建默认配置文件:

1
~/odoo-dev/odoo/odoo-bin --save --stop-after-init

此处我们还使用–stop-after-init参数以在执行结束后停止服务。这一参数常在运行测试或要求运行模块升级来检测是否正确安装时使用。

小贴士:命令行参数可以缩短到保持不混淆的程度,如–stop-after-init可缩写为–stop。

现在可以通过如下命令来查看缺省配置文件:

1
more ~/.odoorc # 显示配置文件

以上命令会显示所有配置项及配认值,编辑文件后在下一次启动 Odoo 实例后生效,输入 q 回到命令行。

我们还可以使用–conf(或-c)参数来指定配置文件。配置文件无需包含所有配置项,仅包含需修改默认值的配置项即可。

修改监听端口

–http-port=(或-p)参数可以修改实例的监听端口(默认端口8069),不同的端口可以让我们在同一台机器上运行多个实例。

Odoo 11的修改: 在 Odoo 11中引入–http-port参数以替代此前版本使用的–xmlrpc-port

下面就可以做个尝试,打开两个终端,第一个中输入

1
~/odoo-dev/odoo/odoo-bin --http-port=8070

第二个中输入

1
~/odoo-dev/odoo/odoo-bin --http-port=8071

此时就在同一台机器上使用不同端口运行了两个 Odoo 实例,这两个实例可以使用同一个数据库或不同数据库。这取决于我们使用的配置参数,并且两个端口上也可以运行相同或不同版本的 Odoo。

小贴士: 不同 Odoo 版本必须使用不同的数据库。尝试在不同版本上使用相同数据库将无法正常运行,因为各大版本采用了不兼容的数据库模式。

数据库选项

进行 Odoo 开发时,经常会使用多个数据库,有时还会用到不同版本。在同一端口上停止、启动不同服务实例,或在不同数据库间切换,会导致网页客户端会话异常。因为浏览器会存储会话的 Cookie。

在浏览器中使用私有模式访问实例可以避免这一问题。另一优良实践是在服务实例上开启数据库过滤器,这样可以确保实例仅允许对指定数据库的请求,而忽略其它请求。

Odoo 11中的修改:
从Odoo 11开始,–database(或-d)参数可接收逗号分隔的多个数据库名,设置–database参数时也会自动设置–db-filter参数,这样仅有这个数据库才能为服务实例使用。对于 Odoo 11之前的版本,我们需要使用–db-filter来限制可访问的数据库。

–db-filter可限制 Odoo 实例所能使用的数据库。它接收一个正则表达式来过滤可用数据库名,要精确匹配一个名称,表达式需要以^开头并以$结束。例如,仅允许testdb数据库,我们可以使用如下命令:

1
~/odoo-dev/odoo/odoo-bin --db-filter=^testdb$

使用–database和–db-filter参数来匹配同一数据库是一个良好的实践。事实上从 Odoo 11开始默认会为–database设置对应的–db-filter。

管理服务器日志消息

–log-level 参数可用于设置日志级别,这有助于了解服务的状况。例如要开始调试日志级别,使用–log-level=debug参数。还有如下选项:

  • debug_sql:查看服务中产生的 SQL 查询
  • debug_rpc:服务器接收到的请求详情
  • debug_rpc_answer:服务器发送的响应详情

其实–log-level是设置Odoo 日志级别的简化方式,此外通过更完整的–log-handler参数可以有选择地打开/关闭日志记录器。默认情况下日志会作为标准输出(打印到屏幕),但是可以通过logfile=参数来指定日志存储文件。

安装第三方插件

在 Odoo 实例中产生新的模块并安装,对于初学者总会容易搞不清。下面一起来熟悉这一点。

查找社区模块

网络上有很多 Odoo 模块,Odoo应用商店可以下载一系列模块安装到系统中。另一个重要的资源是Odoo 社区联盟(OCA - Odoo Community Association)维护的模块,可在 GitHub 上查找。OCA 是一个协调社区贡献的非营利组织,它同时提升软件质量,推广最佳开发实践和开源价值观。可通过https://odoo-community.org/来进一步了解 OCA。

为 Odoo 添加模块,仅需将其拷贝到官方插件的 addons 文件夹中即可,按前述安装即为~/odoo-dev/odoo/addons/。但这不是一个好的实践,我们安装的 Odoo 是由 Git 版本控制的代码仓库,将会与上游 GitHub 仓库保持同步,在其中加入外部插件会不利于管理。

避免这一点,我们可以选取一个或多个存放模块的目录,让 Odoo 服务也会从该目录中查找模块来使用。我们不仅可以把自定义模块放在一个不同的目录下不与官方的混在一起,还可以通过不同目录组织这些模块。

我们可以通过下载系统课程的代码来准备供 Odoo 安装的插件模块,获取 GitHub 上的源码,执行如下命令:

1
2
cd ~/odoo-dev
git clone https://github.com/alanhou/odoo12-development.git library

此时与/odoo同级的/library文件夹中将包含一些模块,现在就需告知 Odoo 这个新的模块目录。

配置插件(add-ons)路径

Odoo 服务有一个addons_path参数可设置查找插件的路径,默认指向Odoo 服务所运行处的/addons文件夹。我们可以指定多个插件目录,这样就可以把自定义模块放到另一个目录下,无需与官方插件混到一起。

通过如下命令可包含新的模块路径来运行服务:

1
2
cd ~/odoo-dev/odoo
./odoo-bin -d 12-library --addons-path="../library,./addons"

仔细看服务日志,会发现有一行报告插件路径,信息类似INFO ? odoo: addons paths: […],确认下里面是否有library/目录。

使用 Python 虚拟环境安装 Odoo

维护多个 Odoo 版本的代码在 Odoo 开发中很常见,需要整理一下来保持项目在同一台开发机器上并行。改变版本有时会需要上下文的切换。比如,现在 Odoo 的启动执行文件是odoo-bin,而在老版本中是odoo.py。迁移到 Python 3后又更易混淆了,我们要知道是选择python/pip还是python3/pip3,这取决于使用的 Odoo 版本。

Python 有一个在同机器上管理独立环境的工具virtualenv。每个环境有自己的Python 可执行文件和库文件,仅需在使用时激活环境,然后python和 pip 无需指定就可以在相应的安装了 Python库的环境下运行。要在Debian/Ubuntu上使用virtualenv,执行如下命令:

1
sudo apt install virtualenv -y

如果我们使用的工作目录是/odoo-dev,并把 Odoo 12源代码克隆到/odoo-dev/odoo目录中,我们可以这样进行虚拟环境的创建:

1
2
virtualenv -p python3 ~/odoo-dev/odoo12env
source ~/odoo-dev/odoo12env/bin/activate

一旦激活了虚拟环境,我们可以在其中安装 Odoo,可以通过 pip 来进行操作:

1
pip install -e ~/odoo-dev/odoo

以上代码会将~/odoo-dev/odoo中的 Odoo源代码安装到虚拟环境中。-e 参数非常重要,这是一个可编辑安装。它不会复制一份代码到虚拟环境中,仅仅只是保留了一个到原地址 Odoo 代码的引用。因为使用到了源代码,源代码的修改在当前环境中也同样生效。

Odoo 的 Python 依赖会被自动安装,所以无需使用requirements.txt来进行手动安装。我们也可以通过 pip 来安装其它所需的 Python 库:

1
pip install phonenumbers num2words psycopg2-binary watchdog xlwt

注意我们无需记住使用的是 Python 2还是 Python 3,这里的pip 命令会指向正确的版本。然后就可以运行 Odoo 了,pip 安装创建了一个bin/odoo命令,可在任何位置运行,无需涉及源代码所在目录。

小贴士: 如果决定使用虚拟环境,任何要使用odoo-bin运行的命令,都可以替换为 odoo

以下命令会启动并关闭所安装版本 Odoo,打印一些日志信息用于确定 Odoo 版本和使用的插件:

1
odoo --stop-after-init

推荐的操作是将配置文件放在虚拟环境文件夹中。以下会为我们的项目初始化一个12-library 数据库,并创建一个对应的12-library.conf 文件:

1
odoo -c ~/odoo-dev/odoo12-env/12-library.conf -d 12-library --addons-path=~/odoo-dev/library,~/odoo-dev/odoo/addons --save --stop

自此开始,我们可通过如下命令启动图书项目 Odoo 服务:

1
odoo -c ~/odoo-dev/odoo12-env/12-library.conf

最后在完成后,通过如下命令来退出环境:

1
deactivate

假设我们要在同一台机器上使用 Odoo 10项目,它使用的是 Python 2.7,通过如下命令创建环境、安装 Odoo:

1
2
3
4
5
6
7
cd ~/odoo-dev
git clone https://github.com/odoo/odoo.git -b 10.0 --depth=1 odoo10
virtualenv odoo10env
source odoo10env/bin/activate
pip install -e ./odoo10
odoo --version
deactivate # To finish working with this env.

要使得在 Odoo 版本间切换更容易,我们可以在~/odoo-dev/odoo10目录下再为10.0分支克隆一份源代码。然后创建虚拟环境,激活环境,使用 pip创建一个 Odoo 10可编辑安装。virtualenv没有使用-p 参数指定 Python 版本,默认为 Python 2,也就是 Odoo 10所需的版本。

如果系统中没有 Python 2,Ubuntu 18.04默认就不带 Python 2,则需要执行如下命令来进行安装:

1
sudo apt install python-dev

使用 PyPI 下载和安装插件模块

社区贡献的插件可以打包成 Python 库,发布到 Python 包索引(PyPI -Python Package Index),然后像其它库一样使用 pip 安装。为了能使用这一方法,Odoo 自动添加了site-packages/文件夹至插件配置路径,用于安装库文件。打包可以通过setuptools-odoo工具。

OCA 项目使用该工具打包并发布插件至 PyPI。因不同 Odoo 版本中存在相同模块,模块名之前会加一个 Odoo 版本前缀。例如odoo12-addon-partner-fax 是Odoo 12的partner_fax PyPI 包,它为 partner 添加了一个传真字段。

如需从 PyPI 下载该模块及依赖,并随后安装至odoo12env环境,使用如下命令:

1
2
3
source ~/odoo-dev/odoo12env/bin/activate
pip install odoo12-addon-partner-fax
odoo -c ~/odoo-dev/odoo12-env/12-library.conf -i partner_fax --stop

服务器端开发者模式

为便于开发者,Odoo 有一个–dev=all参数可激活一些开发者友好的功能。

**Odoo 10中的修改:
**–dev=…参数是在 Odoo 10中引入的,它取代了此前版本中更简单、功能也更少的–debug参数

这启用了一些有用的功能可加快开发流程,最重要的如下:

  • 在保存 Python 文件时自动重载 Python 代码,避免手动重启服务
  • 从 XML 中直接读取 view 定义,避免手动升级模块

–dev=all将在抛出异常时启动 Python调试器(pdb),在服务报错后做后验(postmortem)分析非常有益。注意这一设置对日志输出不产生任何影响。有关 Python 调试器命令详情可参见Python 官方文档

虽然 all 值适用于大多数情况,–dev还可接一串逗号分隔的选项。缺省情况下会使用Python 调试器 pdb。有些人会倾向安装、使用其它调试器,来改善功能和易用性。Odoo 是允许我们指定调试器的,常用的有ipdb和pudb。在本系列第八篇Odoo 12开发之业务逻辑 - 业务流程的支持中,我们将介绍如何在 Odoo 开发中使用调试器。

要自动侦测代码文件的变化 ,服务开发者模式需安装一个额外的依赖python3-watchdog。我们需要在Ubuntu/Debian系统中安装它之后才可使用,命令如下:

1
sudo apt-get install python3-watchdog

对于 Odoo 11之前的版本,使用的是 Python 2,则需安装python-watchdog。同样可使用 pip 安装,命令为pip install watchdog。

总结

在本文中,如们学习了如何在 Ubuntu 系统中安装 Odoo 并从 GitHub 上获取 Odoo源码,以及如何创建Odoo 数据库和运行 Odoo 实例。

现在我们的 Odoo 环境可正常用于开发,并且也可以对数据库和实例进行管理。有了这些,我们可以进行一步的学习了。在下一篇文章中,我们将从零开始创建第一个 Odoo 模块,并理解相关的主要元素。

 

☞☞☞ 接下来请学习Odoo 12 开发之创建第一个 Odoo 应用

本文首发地址:Alan Hou 的个人博客

本文为最好用的免费ERP系统Odoo 12开发手册系列文章第一篇。

Odoo提供了一个快速应用开发框架,非常适合创建商业应用。这类应用通常用于保留业务记录,增删改查操作。Odoo 不仅简化了这类应用的创建,还提供了看板、日历、图表等视图的丰富组件,用于创建好看的用户界面。

本文主要内容有:

  • 引入本文使用的学习项目:to-do (任务清单)应用
  • 理解 Odoo 的结构、版本和发布,了解使用 Odoo 的相关知识
  • 准备一个 Odoo 的基本工作环境,有如下选项:
    • 在线Odoo
    • Windows 一键安装包
    • Docker
  • 激活开发者模式,在用户界面中展示所需使用的工具
  • 修改已有模型,添加字段,常用自定义快速入门
  • 创建自定义数据模型,为我们的应用添加新的数据结构
  • 配置权限,让指定用组组访问应用的功能
  • 创建菜单项,在用户界面中展示新的数据模型
  • 创建用户界面的基本组件:列表、表单、搜索视图

引入 to-do 列表应用

TodoMVC 项目提供一个多 JavaScript 框架实现的 to-do 简单应用类比。下面我们就用 Odoo 来创建一个简单的 to-do 应用。

使用这个应用我们可以添加新的 to-do 项,然后标记完成。比如可在项目中添加买鸡蛋,然后在购买后勾选已完成。To-do 项目应仅对当前用户可见,因而每个人可以获取自己的 to-do 列表。对于一个简易 to-do 应用这已足够,但为增加点趣味性,我们还将取允许 to-do 列表项目包含一组和任务相关的用户,即工作小组。

就该应用我们应考虑以下几层:

  • 数据层:通过模型实现
  • 逻辑层:通过自动化编码实现
  • 展示层:通过视图实现

对于数据层,我们需要一个 To-do 项目模型,我们还将利用内置的 partner(或 contacts)模型来支持工作组的功能。当然还要记得在新的模型中配置访问权限。

逻辑层中我们使用框架处理增删改查(CRUD)基本操作,要使用框架的功能,我们需要在开发者模块中使用 Python 代码。对于初学者,可以使用用户界面开发者菜单automate 工具来实现业务逻辑,后面的例子中会进行说明。

展示层中我们将为应用添加菜单选项,以及 to-do 模型的视图。业务应用的基本视图是用于查看已有记录的列表视图、深入查看记录细节的表单视图。为增强易用性,我们还可以在列表视图中的搜索框预置过滤项。可用搜索选项也被视为一个视图,因而通过搜索视图可进行实现。

以下是创建 to-do 列表应用的步骤

  1. 创建 to-do 项的新模型,然后添加菜单让其可见
  2. 为 to-do 项模型创建列表和表单视图,新模型包含如下字段
    • Description: text 类型
    • Is Done?标记:布尔型

应用的具体功能包含添加执行同一任务的一组用户,因此需要一个表示用户的模型。所幸 Odoo 自带就有这带的模型 - partner 模型(res.partner),它可用于存储个人、公司和地址。并且仅有指定的人可被选择加入工作团队,因此我们需要修改 partner 模型添加Is Work Team?标记。

所以,to-do 项模型还应包含一个工作团队字段,包含一组用户。在添加了Is Work Team?标记后这些关联用户可在 partners/contacts 中进行选取。

综上,我产需要做的有:

  • 为 partner 模型和表单视图添加字段
  • 创建一个 to-do 项模型
  • 创建一个 to-do 应用菜单项
  • 创建 to-do 项用户界面:列表、表单视图以及 UI 中的搜索选项
  • 创建访问权限:组、权限控制列表(ACL)和记录规则

在落地实现之前,我们先要讨论下 Odoo 框架的基本概念,然后学习如何准备工作环境。

基本概念

理解 Odoo 结构中的各个层以及我们要使用的各类型组件的作用大有裨益。下面我们先概览下 Odoo 应用结构,然后把应用开发解构为对应组件。

然后 Odoo 发布有两个版本的定期更新:社区版和企业版,我们应了解两者之前的差别以及大版本发布对开发和部署所带来的变化。首先来看看 Odoo 的应用结构:

Odoo 结构

Odoo 遵循多层结构,即前述的数据层、逻辑层和展示层:

Odoo 的多层结构

数据层是最底端的层,负责数据持久化存储,Odoo 借助 PostgreSQL来实现。Odoo 出于设计考虑仅支持 PostgreSQL 数据库,而不支持MySQL 这一类数据库(有第三方应用可集成 MySQL)。文件附件、图片一类的二进制文件通常存储在一个称为 filestore(目录) 的文件系统中。

小贴士: 也就说 Odoo 实例的完整备份需包含数据库和 filestore 的拷贝。

逻辑层负责与数据层的所有交互,并由 Odoo 服务器完成。通常,底端数据库不应通过这一层获取,只有这样才能保证权限控制和数据一致性。在 Odoo的核心代码中包含供这一接口使用的 ORM (Object-relational Mapping)引擎。ORM 提供插件模块与数据交互的 API。

比如像客户和供应商这样的 partner 数据实体,是通过模型的 ORM 体现的。这一模型是一个 Python 对象,支持多种交互方法:create()方法用于创建新的伙伴记录,read()方法用于查询已有记录和对应数据。通用方法可在特定模型中实现指定业务逻辑,如 create()方法可以设置默认值或强化验证规则,read()方法可支持一些自动计算字段或根据执行操作用户来实施权限控制。

展示层用于展示数据并与用户交互,通过客户端实现用户体验。客户端与 ORM API 交互来读、写、验证或执行其它操作,通过 RPC 调用 ORM API 方法。这些操作发往 Odoo 服务器端操作,然后结果发送回客户端做进一步处理。

对于展示层,Odoo 自带全面功能的 web 客户端。该客户端支持所有业务应用所需功能:登录会话、导航菜单、数据列表、表单等等。全局展示不会像前端工程师所认为的那样可定制,但易于创建功能性和连贯的用户体验。配套的展示层包含网站框架,可像其它 CMS 框架一样灵活地创建网页,当然需要额外的操作和 web 相关知识。网站框架支持通过 web 控制器实现代码来展示特定逻辑,而与模型内在逻辑进行区隔。前端工程师不会有什么操作上的障碍。

Odoo 服务端 API 非常开放,包含所有服务端功能。web 客户端使用的 API 与其它应用的 API 并无不同。因此,其它的客户端实现均可做到,并且可以在任何平台上使用任意编程语言进行实现。可以创建桌面和移动端应用来提供不同用户界面,这充分利用了 Odoo 为业务逻辑和数据持久性而生的数据和逻辑层。

Odoo社区版 vs. 企业版

Odoo 是这款软件的名称,同时也是发布软件的公司。Odoo 采取核心开源的业务模式,社区版(CE)完全免费开源,而企业版(EE)则是一款付费产品。社区版提供了全部的框架功能和大多数与 Odoo 捆绑的业务应用基础功能。Odoo 采取 LGPL 开源协议,允许在开源模块之上添加专属扩展。企业版建立在社区版基础之上,包含社区版所有功能和额外的独有功能。值得一提的是企业版带有一个移动端优化的用户界面,两个版本的用户界面底层完全相同。Odoo 在线 SaaS 服务使用的是企业版,会部署一些企业版大版本发布之后的一些中间版本。

Odoo 的版本政策

在写本文时,Odoo 的稳定版本号是12,在 GitHub 上的分支为12.0,这也是本系列文章所采用的版本。近年来 Odoo 的大版本都是按年发布, Odoo 12是在10月份的 Odoo 体验大会上发布的。官方支持最近的三个稳定版本,在12.0发布时,官方仍然维护11.0和10.0两个版本,而停止对9.0的支持,这也就意味着不再对 bug和安全漏洞进行修复。

应当注意 Odoo 不同大版本间的数据库并不兼容,比如在 Odoo 11服务端运行早前版本的 Odoo 数据库,系统将无法运行。在不同版本间迁移数据库也颇费周折。对于插件模块也是如此,通常老版本中开发的插件无法在新版本中生效,所以在网上下载社区模块时,应注意选择对应的版本。

此外,大版本(如10.0, 11.0)会被频繁的更新,但这些通常仅仅是 bug 的修复。这些修复会确保 API 稳定,也就是模型数据结构和视图元素标识符也会保持稳定。这点非常重要,因为这意味着我们的自定义模块不会因上游核心模块的不兼容修改而崩溃。

Master 分支中的版本将产生下一个稳定的大版本,但在形成稳定版之前,将不会保持 API 稳定,我们应避免使用它来创建自定模块。否则会如同在流沙中行进,我们无法保证什么改变会导致自定义模块的崩溃。

基本工作环境的准备

首先我们需要一个 Odoo 实例来进行学习,本文仅要求运行一个 Odoo 实例,与具体的安装方法无关。想要快速运行,我们可以使用一个预打包的 Odoo 发布,甚或是使用 Odoo SaaS 的试用版本

使用 Odoo SaaS试用版本

这是最为简便的方法,无需进行任何安装,仅需进入官网创建一个试用数据库。这一 Odoo 云端软件是基于企业版的 SaaS 服务,采用独有的中间版本发布。在写本文时,这一服务不仅支持试用,在仅安装一个应用的情况下可以免费使用。SaaS 服务使用原生的 Odoo 企业版,不支持自定模块。在需要进行自定义时,可使用 Odoo.sh 服务,它提供一个可自定义全面功能的开发平台和主机托管方案。

注意: 在 Odoo 早前版本中,社区版和企业版的菜单结构有相当大的差别,而 Odoo 12中,两者这间的结构相近。

在 Odoo 云端 SaaS 创建新数据库,会需要选择一个应用,针对本文可选择任意应用。在对于有选择尴尬症的朋友,可以使用CRM 客户关系管理应用。另外值得一提的是在 Odoo SaaS企业版中可选择 Odoo Studio 应用构建器,因本系列所针对的社区版并不包含,所以我们不会使用到。Odoo Studio 为开发者提供了一个用户友好的使用界面,还有一些其它功能,如导出在模块包中所做自定义修改。但其主要功能都可在开发者模式中获取。

在 Windows 上安装 Odoo

一键安装包可以在Odoo官网上下载,包含各个版本及主分支,这里有 Windows 安装包(.exe)、Debian 安装包(.deb)和 CentOS 安装包(.rpm)。要在 Windows 上安装,仅需在对应版本的 nightly 文件夹中找到.exe 并进行安装。安装包非常方便,它包含所有安装 Odoo 所需的所有部分:Python 3、PostgreSQL 数据库、Odoo 服务端以及其它 Odoo 依赖。安装时会创建一个 Windows 服务在开机时自动启动 Odoo 和 PostgreSQL。

使用 Docker 容器安装 Odoo

Docker是一个便捷运行应用的跨平台解决方案,可在 MacOS, Linux 和 Windows 上使用。与传统的虚拟机相比,容器技术使用更为简单、资源利用率更高。首先需要在操作系统中安装 Docker,可从Docker官网上下载免费使用的Docker CE(社区版),最新安装方法可在 Docker官网上查看。

应该注意虚拟化要求在 BIOS配置中进行开启,并且 Windows 版本的 Docker CE 需要有 Hyper-V,它仅在 Windows 10 企业版或教育版才会带有(Windows 系统要求),而 Mac系统需要为 OS X El Capitan 10.11或更新版本。对于其它的 Windows 和 MacOS 版本,应安装 Docker Toolbox,Docker Toolbox打包了 VirtualBox 并提供了预设置的 shell,用于作为操作 Docker 容器的命令行环境。

在 Odoo 商店中包含 Odoo 镜像,在那里找到对应版本,按照提示进行安装。要使用 Docker运行 Odoo,我们需要两个容器,一个运行 PostgreSQL 数据库,一个运行 Odoo 服务。

安装通过命令行窗口完成,安装 PostgreSQL 容器

1
docker run -d -e POSTGRES_USER=odoo -e POSTGRES_PASSWORD=odoo -e POSTGRES_DB=postgres --name db postgres:10

此时便会从互联网上下载最新的 PostgreSQL 镜像,并在后台开启一个容器来进行运行。

接下来安装 Odoo 服务容器,并且连接刚刚启动的 PostgreSQL 容器,暴露8069端口:

1
docker run -p 8069:8069 --name odoo --link db:db -t odoo

此时便可在终端窗口看到实时的 Odoo 服务器日志,在浏览器中输入http://localhost:8069即可打开 Odoo 实例。

小贴士: 如果8069端口被占用了,则Odoo 服务启动会失败。此时我们需要停止占用端口的服务或者使用-p 参数指定其它端口来运行 Odoo,如修改为8070端口(-p 8070:8069)。此时可能还需要通过-d 参数修改实例所需使用的数据库名称。

以下Docker 的基本指令会有助于管理容器(更多 Docker 知识请参见CI/CD之Docker容器DevOps学习笔记及常见问题):

  • docker stop 停止指定容器
  • docker start 启动指定容器
  • docker start -a 启动容器并附带输出,如命令终端中输出的服务器日志
  • docker attach 重新添加容器输出至当前终端窗口
  • docker ps 列出当前 Docker 容器

以上就是操作 Docker 容器的基本命令,万一在运行容器时出现问题,可以执行如何命令(可省略 container)重新来过:

1
2
3
4
docker container stop db
docker container rm db
docker container stop odoo
docker container rm odoo

Docker 技术的应用非常广泛,更多知识可参见 Docker 官方文档

其它安装选项

Odoo也有 Linux 系统的安装包,包含 Debian 系(如 Ubuntu)和 Red Hat 系(如 CentOS和 Fedora)。官方文档中有相关说明,也可参考:

CentOS 7快速安装配置 Odoo 12

Ubuntu 快速安装配置Odoo 12

对于源码安装会相对复杂,但可变性也更强,在第二章 准备开发环境中还会详细介绍。

创建工作数据库

通过前面的学习,我们应该都有一个 PostgreSQL 数据库和 Odoo 服务器供运行。在开始使用我们的项目前还需要再创建一个 Odoo 数据库。如果您在本地安装 Odoo 并保留了默认设置,则可以通过http://localhost:8069/打开 Odoo。第一次打开时,还没有可用的数据库,此时可以看一个用于创建数据库的页面:

Odoo 12创建数据库

创建数据库需的信息有:

  • Database Name: 数据库的标识名称,在同一台服务器上可以有多个数据库
  • Email: 管理员的登录用户名,可以不是 email 地址
  • Password: 管理员登录的密码
  • Language: 数据库的默认语言
  • Country: 数据库中公司数据所使用的国家,这个是可选项,与发票和财务等带有本地化特征的应用有关
  • Demo data: 勾选此选项会在数据库中创建演示数据,通常在开发和测试环境中可以勾选

如果在 Odoo 的服务端配置中添加了 master password,还会要求输入该密码。这可以阻止未经授权的用户执行相关管理员操作,默认情况下不会设置该密码。 点击 Create database 按钮后,会开始初始化数据库,整个过程可能会需要一到几分钟,结束后会自动登入后台。

Odoo 12后台界面

登录界面底部有一个 Manage Databases 的链接,点击进入会可以看到当前可用的数据库,可对它们进行备份、复制和删除操作,当然还可以创建新的数据库。直接进入的地址为:http://localhost:8069/web/database/manager

Odoo 12登录页

小贴士: 数据库管理器可进行一些管理操作,默认激活且没有保护措施。虽然对于开发来说这样非常方便,但即便是在测试或开发环境,对有包含真实数据的数据库都存在安全风险。建议设置一个复杂的管理密码甚至最好是关闭这一功能(在配置文件中设置 list_db = False)。

Odoo 12管理数据库页

现在我们已有经了 Odoo 实例和数据库,下一步就是开启开发者模式这个用于实现我们项目的工具。

开启开发者模式

要实现我们的项目,这里需要用到开发者模式所提供的工具。开发者模式使用我们可以在用户界面中直接对 Odoo 应用进行自定义操作。这有利于我们快速修改和添加功能,可用于进行一些添加字段的小调整乃至像创建带有多个模型、视图和菜单项的应用这样的复杂自定义开发。

但这种直接在用户界面执行的自定义开发相对于在后续章节讲到的编程工具而言也有其局限性,如它无法添加或扩展默认的 ORM 方法(虽然有时自动化动作足以作为一个替代方案)。它也不易于集成到结构性开发流,包括版本控制、自动化测试、部署到多环境(质量测试、预发布和生产环境)。

本文我们主要使用开发者模式来介绍在 Odoo 框架中应用配置是如何组织的、如何在开发者模式下进行简单的自定义和快速列出所要实现方案的框架或原型。进入 Settings > Dashboard, 即可在右侧下方看到Activate the developer mode链接,点击该链接即可开启开发者模式(Odoo 9.0版本及以前的版本,开发者模式在 User 菜单的 About 对话框窗口中进行开启)。

在该页面我们还会看到一个Activate the developer mode (with assets)的选项,这个用于不对静态文件进行压缩,通常用于调试前端代码,开启后浏览的速度也会略慢。为加快加载速度,客户端中的 JavaScript 和 CSS 文件通常会被压缩。但这也导致我们很难对前端代码进行调试,Activate the developer mode (with assets)选项会把这些静态文件按照独立文件、非压缩的方式进行加载。

我们也可以通过修改当前的 URL 来进入开发者模式,这样就无需进入 Settings 页面,仅需修改链接中的…/web#…为…/web?debug#…或…/web?debug=assets#…,比如修改http:///localhost:8069/web#home为http://localhost_8069/web?debug#home。虽然没有直接的链接来开启前端框架的开发者模式,但也可以通过在前端页面URL上添加?debug=assets 来取消静态文件的压缩, 但需要注意在我们点到其它页面时这个标记可能就会消失。

小贴士: Firefox 和 Chrome均提供开启和取消开发者模式的插件,请在火狐或 Chrome 的应用商店中搜索 Odoo debug 进行安装

开发者模式一经开启,在菜单中就会增加两个选项:

  1. 开发者工具菜单,以调试图标的形式出现在右上角用户名和头像的左侧
  2. Settings 中的 Technical 菜单项

Odoo 12开发者模式

开发者模式还会在表单字段上添加一个额外信息:将鼠标悬停在字段上方,就会显示一些相关技术信息。下一部分我们一起来学习相关的开发者模式功能。

为已有模型添加字段

为已有表单添加字段是种常见的自定义操作,我们无需创建自定义模块即可在用户界面中进行实现。就我们 to-do 应用而言,需要可以选取一组用户对 to-do 项进行协作。我们可以通过在 partner 表单中添加标识来找到这些用户,那么接下来为 partner 模型添加一个Is Work Team?标记。

Partner 模型是 Odoo 内核自带的,无需安装任何应用即可使用,但这样在菜单中就无法查看到。一个简单的方法是安装 Contacts 应用。没安装的朋友可以点击 Apps 菜单搜索该应用并进行安装:

Odoo 12 Contacts 应用

安装完成后即可在顶级菜单中找到 Contacts 项。

为模型添加字段

开启开发者模式后,我们可通过菜单Settings > Technical > Database Structure > Models 来查看模型的定义。这时搜索 res.partner(未安装其它应用的情况下第一个即是),对应的模型描述为 Contact。点击打开表单视图,这时就可以看到 partner 模型的各类信息,包含字段列表:

Odoo 12 Contact 模型

点击 Edit,然后在字段列表的最下端点击 Add a line,即会弹出窗口用于创建新字段,输入:

  • Field Name:  x_is_work_team
  • Field Label:  Is Work Team?
  • Field Type:  boolean

字段名(Field Name)必须以 x_开头,这是在用户界面创建模型和字段强制要求的,通过插件模块的自定开发不受这一限制。只修改添加以上信息点击Save & Close保存即可。 这个模型有80多个字段,我们需要通过右上角的向右箭头浏览到最后一页才能看到新创建的字段。这时再点击左上角的 Save 按钮进行最终的保存。

为表单视图添加字段

我们已经为 partner 模型创建了新字段,但对用户仍不可见,要实现这点我们还在在相应的视图中进行添加操作。再回到前述的 res.partner模型详情页,点击 Views 标签,我们就可以年到模块的各个 view 定义。正所所见,每个视图都是一每次数据库记录,修改或不回视图记录即时生效,在下一次加载视图时即可见:

Odoo 12 Contacts 视图标签

视图列表中有一些需要注意的事项,我们看到有不同的视图类型,如表单视图(Form)、树状列表视图(Tree)、搜索视图(Search)和看板视图(Kanban)。搜索视图指的是右上解搜索框中的过滤选项。其它视图的数据展示方法也各不相同,基本的类型有列表视图和表单视图(用于查看详细信息)。

小贴士: 树状视图(Tree) 和 列表视图(List) 实为同一视图,实际上Odoo 中的为列表视图,树状视图的名称是由历史原因产生的 - 过去列表视图是以树状层级结构来进行展示的。

可以看到同一视图类型存在多个定义,通过 View Type 进行排序可以更清晰地看出。每种视图类型(如表单视图)可以有一个或多个base视图定义(包含空的继承视图字段)。菜单项使用窗口动作可以指定要用到的base视图,如果没有定义,将使用排序值(Sequence)最低的,因而可将其视为默认视图。 点击视图,可以在表单中看到包含排序值在内的所有详情:

Odoo 12基视图详情

每个base视图都可以有多个扩展,称为继承视图。每个继承视图可以对base视图添加修改,如对已有表单添加字段。

小贴士: 继承视图自身也可以被其它视图继承,这时最处层继承所内层继承执行后作用于base视图。

res.partner 模型会包含众多的视图定义,因为类似我们的应用很多应用都需要对其进行扩展。一个替代方法是进入我们需要扩展的某一具体视图,使用开发者工具菜单对其进行编辑。这也可用于了解某一视图在用户界面的某处被使用了。下面我们就来进行操作:

1.点击 Contacts 应用显示联系人名片列表,然后点击任意名片进入相应的表单视图

Odoo 12联系人名片列表

2.在表单视图界面,点击开发者工具菜单(右上解调试图标)并选择编辑视图(Edit View:Form),这时可以看到与前述模型页面相同的视图明细表单,但展示在实际定义使用base视图之上。也就是res.partner.form视图,通过External ID可以查看模块所有者。本处为base.view_partner_form,所以我们知道这个视图属于基模块。在Architecture字段中,我们可以看到base视图定义的 XML 代码。我们可以在这里编辑视图结构并添加我们的新字段,但从长期看这不 一个好办法:这个视图属于一个插件模块,一旦模块被更新,自定义的代码就会被覆盖并丢失。修改视图的正确姿势为创建一个继承视图(Inherited Views)扩展:

表单编辑继承视图

3.使用继承视图标签我们可以为 base 视图添加扩展视图:

I.首先我们需要在原始视图选择一个元素作为扩展点,我们可以通过查看 base视图的结构选择一个包含 name 属性的 XML 元素,大多数情况选择的是一个元素,此外我们选择<field name=”category_id”…>元素:

Odoo 12 表单视图 category_id 字段

II.现在,点击开发者工具菜单,然后点击 编辑视图(Edit View:Form),选择继承视图标签(Inherited Views)标签回到前述的界面,然后点击最下方的 Add a line链接

Odoo 12创建视图扩展

III.此时会弹出名为Create Views which inherit from this one的窗口,填入如下内容

  • View Name: Contacts - Custom “Is Work Team” flag
  • Architecture:输入如下 XML代码
1
2
3
<field name="category_id" position="after">
<field name="x_is_work_team" />
</field>
  • 其它重要字段,如 Model, View Type 和 Inherited View 使用默认值即可

IV.此时点击 Save & Close按钮,然后在Edit View: Form 窗口点击kSave按钮

在保存修改后重载联系人表单视图页面即可查看到变化,在大数浏览器中可以使用 F5快捷键来重载页面。这时打开任意联系名片,可以看到右侧 Tags 字段下会多出一个新字段:

Odoo 12联系人表单视图

创建新的模型(Model)

模型是应用的基本组件,包含了所需使用到的数据结构和存储。接下来我们就为 To-do 项目添加模型,将包含三个字段:

  • Description
  • Is done? 标记
  • Work team 用户列表

如前所述,通过菜单Settings > Technical > Database Structure > Models可进入模型创建页面,步骤如下:

1、进入模型菜单,点击左上角 Create 按钮,在弹出页面填入

  • Model Description: To-do Item
  • Model: x_todo_item

在进一步添加字段之前可以先进行保存。

2、点击 Save 保存然后点击 Edit 再次进入编辑,可以看到 Odoo 自动添加了一些字段,ORM在所有模型中添加了这些字段,可用于审计和记录功能。

Odoo 12 Todo Item

x_name (或Name)字段是在列表中显示记录或其它记录中引用时显示的标题。这 To-do Item标题中将使用它,还可以对其进行编辑将字段标签改为更能表达含义的描述。添加 Is Done? 标记此时就显得非常容易了。

3、在字段列表页底部点击 Add a line 链接创建一个包含如下值的字段:

  • Field Name:  x_is_done
  • Field Label:  Is Done?
  • Field Type:  boolean

该字段的填写效果如下:

Odoo 12 To-do 应用x_is_done

接下来添加 Work Team 字段将更加有挑战性了,不仅因为这是一个指向 res.partner 对应记录的关联字段,它还是一个包含多个值的 selection 字段。在很多框架中这都会颇为复杂,但所幸我们使用的是 Odoo,因为它支持 many-to-many 关联。To-do 应用属于这一情况,因为一条任务可以有多个用户,同时一个用户也可以参与多条任务。

4、再次在字段列表中点击 Add a line,添加一个包含如下值的字段:

  • Field Name:  x_work_team_ids
  • Field Label:  Work Team
  • Field Type:  many2many
  • Object Relation:  res.partner
  • Domain:  [(‘x_is_work_team’, ‘=’, True)]

Odoo 12任务清单应用x_work_team_ids

many-to-many字段有定独有的定义项-Relation Table, Column 1, and Column 2项,这些值会被自动填充,大多数情况下都无需修改。在第六章 模型 - 结构化应用数据中将会有更详细的探讨。 Domain 项为非必填项,这里使用到是因为只有符合条件的用户才可被选取加入工作组,如果不加这项则所有用户均可被选取。

Domain 表达式中对展示的记录进行了过滤,它遵循Odoo 独有的语法-一个包含三个值的元组,第一项为过滤的字段名、第二项为过滤操作符、第三项为过滤作用的值。详细的解释参见第七章 记录集 - 使用模型数据

小贴士:Odoo有一个交互式的 domain 过滤向导可帮助生成 domain 表达式。访问Settings > Technical > User Interface > User-defined Filters,点击 Create选择模型后将会出现 Add filter 按钮,可通过选择字段在下方的文本框中实时生成 domain 表达式。

Odoo 12 domain 过滤表达式生成器

现在我们已经为 To-d0应用创建好了模型,但还无法使用它,在创建模型后,我们需要配置组来使用该模型。

配置安全权限控制

Odoo自带有权限控制机制,用户仅能使用被授权了的功能。这就意味着我们自建的库功能不对普通用户甚至是管理员开放。

注意:Odoo 12的修改 管理员用户现在也像其它用户一样受权限控制所限制。而在此前的 Odoo 版本中,admin 都作为特殊用户不受权限规则控制。而新版中我们需要进行授权管理员才能访问模型数据

Odoo 安全权限通过安全组来设置访问权限。每个用户的权限根据所属组来决定,对于我们的测试项目,我们将创建一个 to-do 用户组,分然后通过组来分配可使用功能的用户。我们通常使用 ACL 为某个组赋予指定模块的读或写权限,就当前项目,我们对添加的 to-do 项模型添加读和写权限。

此外,我们还可以设置用户对指定模型的记录范围的访问规则。本项目中 to-do 项为用户私有,所以用户仅能访问自己创建的记录,这通过安全记录规则实现。

安全组

访问控制基于组,通过安全组对模型设置权限,控制组内用户所能使用的菜单项。要做更精细化的控制,我们可以对指定菜单项、视图、字段甚至是(带有记录规则的)数据记录进行权限控制。

安全组还可以对应用进行管理,通常每个应用有两个组:用户组(Users)可执行日常操作、管理员组(Manager)可对应用执行所有配置。

下面我们新建一个安全组,在菜单中访问Settings > Users & Companies > Groups。点击 Create通过如下值创建一条新记录:

  • Application: 留空
  • Name: To-do User
  • Inherited 标签下: 添加User types / Internal User项

效果如下:

Odoo 12 To-do User安全组

在 Application 下拉列表中还没有to-do 应用,所以我们直接通过组表单进行添加。我们还继承了 Internal User 用户组,那么这个组的成员也会自动(递归)成为所继承组的成员,以获取他们原有的权限。Internal User 是基础权限组,通常应用的安全组都继承它。

注意:Odoo 12的修改 在 Odoo 12之前,内部用户组称作雇员(Employee),这只是表面上的修改,代码中标识符(XML id)仍然和此前版本相同:base.group_user。

安全权限控制列表

现在我们可以对指定组(To-do User)进行指定模型的权限授予,在上述表单的Access Rights标签下添加一条记录,对应的值为:

  • Name: To-do Item User Access
  • Object: 在列表中选择To-do Item
  • 勾选Read Access, Write Access, Create Access, and Delete Access

模型权限也可通过Settings > Technical > Security > Access Rights进行管理。我们无面向 Partner 模型添加权限,因为我们的组继承了内部用户组,已经获取了相应权限。

现在可以将 admin 用户添加到新建组来测试新加的权限设置

1、在菜单中点击Users & Companies > Users,从用户列表中选择Mitchell Admin,然后编辑表单

Odoo 12 管理员权限编辑界面

2、在Access Rights标签下的 Other 版块,会发现一个名为 To-do User 的复选框用于让用户加入权限组,勾选后点击 Save 保存表单。

如果一切操作都正确的话,我们就可以看到 to-do 顶级菜单,用于添加 to-do 项,并且我们只能访问自己的任务清单而看不到其它人的。(请先执行创建菜单项部分再进行查看)

安全记录规则

在对模型赋予访问权限时,默认用户可以访问到它的所有记录。但有时我们要限制每个用户所能访问的特定记录。通过记录规则可以实现这一点,通过定义 domain 过滤器来对读和写操作进行控制。

比如我们这里的 to-do 就用,任务项应为用户私有,我们不希望其他用户看到自己的记录。需要添加记录规则来过滤出创建者自己的记录:

  • 框架会自动添加create_uid字段,并存储创建记录的用户,通过该字段可以确定每条记录的创建者
  • 在user变量中可获取到当前用户,user 变量读取上下文中 domain 过滤器过滤后的对象

通过[(‘create_uid’, ‘=’, user.id)]域表达式可实现这点。通过菜单中的Settings > Technical > Security > Record Rules 进入记录规则设置页,点击 Create 并输入如下值:

  • Name:  一个描述性的标题,这里使用 To-do Own Items
  • Object:  在列表中选择模型,此处为To-do Item
  • Access Rights:  规则所授予的操作,这里保留全部勾选
  • Rule Definition:  域过滤器,填写 [(‘create_uid’, ‘=’, user.id)]
  • Groups:  作用的安全组,选择To-do User组

效果如下:

Odoo 12记录规则

此时就完成了记录规则的设定,现在可以试试用 Admin 和 Demo 用户分别创建几个任务项,各自将只能看到自己创建的任务。记录规则可通过右上角的切换按钮进行关闭,一旦关闭,就可以看到所有用户的任务清单了。

超级用户账号

在此前的 Odoo 版本中,admin 用户是一个特权用户可以不受权限控制。Odoo 12就些做了调整,admin 用户属于所有用户安全组,但只是个普通用户。还是存在一个超级用户不受权限控制,但它无法进行登录。

我们还是可以以超级用户进行操作,当一个用户以 Administration / Settings 用户组登录时,开发者工具菜单中有一个 Become Superuser选项,或者在登录页面开启开发者模式,则会出一个Login as superuser的隐藏按钮。

在激活了超级用户后,右上角的当前用户显示为 OdooBot,该处背景也会变成黄黑间隔的条状,以清楚的告知用户激活了超级用户。仅在绝对必要时才应使用这一操作,超级用户不受权限控制这点会导致数据的不一致,比如在多公司场景下,所以应尽量避免。

Odoo 12激活超级用户

创建菜单项

现在有了存储任务清单的模型,应在用户界面中显示它,添加菜单项可实现这一点。我们这里创建一个顶级菜单项直接打开任务清单,一些像联系人(Contacts)这样的应用采取了这种方式,但另外一些则使用了在顶栏中的子菜单项。

注意:Odoo 12的修改 社区版中第一级以下的菜单项也像企业版中一样显示在了顶栏中,而此前版本社区版的菜单项显示在屏幕的左侧。

点击菜单Settings > Technical > User Interface > Menu Items,点击 Create 即可进入菜单的编辑页面。在该页面中输入如下值:

  1. Menu:  To-do

  2. Parent Menu:  留空

  3. Action:  选择ir.actions.act_window,然后在右侧下拉框中点击Create and Edit打开一个相关的窗口操作表单

  4. 在弹出的表单中填入

    • Action name:  To-do Items

    • Object:  x_todo_item (目标模型的编码标识)

    • 显示效果如下

      Odoo 12窗口操作表单

  5. 保存所有打开的表单,此时即可在菜单中使用 To-do 应用了

要在菜单中显示该项,需要重载客户端页面,大多数浏览中可使用快捷键 F5(强制刷新:Windows: Ctrl+F5, Mac: Cmd+F5)。现在就可以访问菜单项并进行任务清单模型的交互了。虽然我们没有创建视图,但强大的 Odoo 框架自动为我们生成了一个基础视图:

Odoo 12任务清单基础视图

在本例中,在顶级菜单中直接添加了一个操作,而没有子菜单。但菜单可以包含一系列带有父子关系的菜单项,最末级菜单项关联一个操作(Action),定义有选取时执行的行为。 操作名将作为所展示视图的标题。

有很多的操作类型,最重要的有窗口(window)、报表(reports)和服务端(server)操作。窗口操作最常用,用作在客户端中展示视图,报表操作用于运行报表,服务端操作用于定义自动化任务。

截至目前,我们都聚焦在显示视图的窗口操作上,正是使用了直接在菜单项表单中创建的窗口操作来创建了任务清单的菜单项。我们也可以在Settings > Technical > Actions中查看和编辑操作,在本例中仅需使用窗口操作。

小贴士: 很多情况下使用开发者工具中的 Edit Action 选项更为方便,它提供一个编辑当前视图窗口操作的快捷方式。

接下来我们进入到下一部分,创建我们的视图。

创建视图

前面我们创建了一个任务清单模型通过菜单项在用户界面中显示,接下来我们为它创建两个基本视图:列表视图和表单视图。

列表视图

创建列表视图步骤如下:

1、点击Settings > Technical > User Interface > Views,点击 Create 进入视图编辑页面,填入如下值:

  • View Name:  To-do List View
  • View Type:  Tree
  • Model:  x_todo_item

效果如下:

Odoo 12为任务清单创建列表视图

2、在Architecture标签下,使用 XML 书写视图的结构,代码如下:

1
2
3
4
<tree>
<field name="x_name" />
<field name="x_is_done" />
</tree>

列表视图的基本结构非常简单:一个包含列表视图中显示的一个或多个列元素的元素(element)。在列表视图还有一些有意思的选项,将在第十章 后台视图 - 设计用户界面中详细探讨。

表单视图

创建表单视图的步骤如下:

1、创建另一条视图记录,并填入如下值:

  • View Name: To-do Form View
  • View Type: Form
  • Model: x_todo_item

小贴士: 如果不指定 View Type,将会通过视图定义代码来自动识别。

Odoo 12任务清单创建表单视图

2、在Architecture标签下, 输入如下XML 代码:

1
2
3
4
5
6
7
8
9
<form>
<group>
<field name="x_name" />
<field name="x_is_done" />
<field name="x_work_team_ids"
widget="many2many_tags"
context="{'default_x_is_work_team': True}" />
</group>
</form>

表单视图结构根节点有一个元素,包含元素,其它相关元素将在第十章 后台视图 - 设计用户界面中进行学习。这里还有一个针对工具组字段的小组件(widget),以标签按钮而非列表栏显示。这一个按钮状标签通过在工作组字段中添加 widget 属性来实现。

默认情况下,关联字段允许直接创建记录用作关联。也就说可以在工作组字段中直接创建用户,但如果这么做用户将不会带有Is Work Team? 标记,也就产生了不一致性。

为了更好的用户体验,在这种情况下我们可以默认就带有这一标记。这需要通过 context 属性来实现,它向下一个视图传递 session 信息,比如要使用的默认值。在后续章节中会就此进行探讨,现在只要知道这是一个键值对的字典即可。以 default_作为前缀来提供对应字段的默认值。

所以此处要为用户设置Is Work Team? 标记所需的表达式为{‘default_x_is_work_team’: True}。

此时点击 To-do菜单进行创建或打开已有清单则会显示为我们所创建的视图。

搜索视图

我们可以为列表视图右上角的搜索框预设一些过滤项和分组选项,Odoo 把这些也视图元素,所以可以像列表视图和表单视图一样在 Views 中添加记录来定义。想必现在大家已经非常熟悉了,在菜单中点击Settings > Technical > User Interface > Views 或在开发者工具中对应上下文中进行编辑操作均可。我们进入任务清单列表视图,点击开发者工具中的Edit Search View。

当前列表清单模型还未定义过任何搜索视图,所以显示一个空表单用于进行创建,填入如下值并保存:

  1. View Name: 选择一个有意义的描述,此处使用To-do Items Filter
  2. View Type:  Search
  3. Model:  x_todo_item
  4. Architecture:  添加如下XML 代码:

Odoo 12任务清单搜索视图

此时重载任务清单,可以在搜索框下方 Filters 按钮下选择预设的 Not Done 过滤器,在搜索框中输入 Not Done也会提示过滤条件。默认开启过滤器会较便捷,在不需时取消过滤即可。正如默认字段值一样,还是使用 context 属性来设置默认过滤器。

在点击 To-do 菜单时,执行一个窗口操作打开列表视图,该操作可设置一个上下文值,让视图默认开启某一搜索过滤器,操作步骤如下:

  1. 点击To-do 菜单进入任务清单列表
  2. 点击开发者工具图标并选择Edit Action,这时将弹出一个窗口操作界面,在右下角有一个 Filters版块,这里有 Domain 和 Context 字段。

Domain 字段可用于为所显示记录设置固定的过滤器,而且用户无法删除。这不符合我们的场景。我们只是要默认开启item_not_done过滤器,用户可以随时取消选择。默认打开过滤器,添加以search_default_作为前缀的 context 键,这里使用{‘search_default_item_not_done’: True}

这时再点击 To-do 菜单,搜索框中默认就会开启 Not Done 过滤器。

Odoo 12任务清单默认过滤器

总结

在本文中,我们不仅概览了 Odoo 组件的组织方式,还利用开发者模式深入到 Odoo 内部来理解这些组件如何共同协作创建应用。

我们还使用这些工具创建了一个简易的应用,包含模型、视图和对应的菜单。并且掌握了通过开发者工具可以查看当前应用或在用户界面中直接进行快速自定义操作。

下一篇中我们将更深入地了解 Odoo 开发,并学习如何设置和组织开发环境。

本文首发地址:Alan Hou 的个人博客

0%