全书完整目录请见:Odoo 14开发者指南(Cookbook)第四版
在第五章 基本服务端开发中,我们学习了如何在模型类中编写方法、如何扩展所继承模型的方法以及如何处理记录集。本章将讲解更为高级的话题,比如使用记录集的环境、对按钮点击调用方法和操作onchange方法。本章中的各小节有助于处理更复杂的业务问题。我们将学习如何创建交互性更强的应用。
本章中,我们将讲解如下内容:
- 更改执行动作的用户
- 使用变更的上下文调用方法
- 执行原生SQL查询
- 编写向导来引导用户
- 定义onchange方法
- 在服务端调用onchange方法
- 借助compute方法调用onchange
- 定义基于SQL视图的模型
- 添加自定义设置选项
- 实现init钩子
本章的技术要求包含Odoo在线平台。
本章中所使用的所有代码可通过GitHub仓库进行下载:https://github.com/alanhou/odoo14-cookbook/tree/main/Chapter08
在编写业务逻辑代码时,可能要通过不同的权限上下文来执行一些动作。典型的用例是跳过权限检查的以及超级用户的权限执行一个动作。这类需求源自有些用户需要操作自己不具有权限的记录。
本节将向展示如何让普通用户通过使用sudo()来修改图书的借出状态。简单地说,我们将允许用户在没有创建出借记录权限时仍能自己借书。
为更易于理解,我们将添加一个管理图书借出的新模型。新增一个名为library.book.rent的模型。可参考如下定义来添加这个模型:
class LibraryBookRent(models.Model):
_name = 'library.book.rent'
book_id = fields.Many2one('library.book', 'Book', required=True)
borrower_id = fields.Many2one('res.partner', 'Borrower',
required=True)
state = fields.Selection([('ongoing', 'Ongoing'),
('returned', 'Returned')],
'State', default='ongoing',
required=True)
rent_date = fields.Date(default=fields.Date.today)
return_date = fields.Date()
需要添加一个表单视图、一个动作以及一个通过用户界面查看新模型的菜单项。还需要为librarian添加一个权限规则,这样他们可以发布供出借的图书。如果不了解如保添加,请参见第三章 创建Odoo插件模块。
同时,也可以使用GitHub上已经编写好的初始模块代码示例来节约一点时间。该模块位于Chapter08/00_initial_module文件夹下。GitHub 代码示例请参见https://github.com/alanhou/odoo14-cookbook/tree/main/Chapter08/00_initial_module。
如果你测试过该模型,会发现只有拥有访问权限的图书管理员才能将图书标记为已借阅。非管理员用户无法自行借阅图书,他们需要求助图书管理员才能借书。假设我们需要为非管理员用户添加一个自行为自己借书的功能。我们将不为这些用户添加library.book.rent模型的访问权限并实现这一功能。
需要执行如下步骤来让普通用户可以借书:
-
在 library.book模型中添加book_rent()方法:
class LibraryBook(models.Model): _name = 'library.book' ... def book_rent(self):
-
在该方法中,确保我们是对单条记录进行操作:
self.ensure_one()
-
如果图书不可借阅则抛出一个警告(要确保在文件头部导入UserError):
if self.state != 'available': raise UserError(_('Book is not available for renting'))
-
使用超级用户来获取library.book.rent的空记录集:
rent_as_superuser = self.env['library.book.rent'].sudo(
-
通过相应的值新建一条图书借阅记录:
rent_as_superuser.create({ 'book_id': self.id, 'borrower_id': self.env.user.partner_id.id, })
-
在图书的表单视图中添加按钮来通过用户界面调用这一方法:
<button name="book_rent" string="Rent this book" type="object" class="btn-primary"/>
重启服务并更新my_library来应用这些修改。更新后,我们在图书的表单视图中会看到一个Rent this book 按钮。点击按钮会新建一条借阅记录。非管理员用户可进行同样的操作。我们可以使用demo用户访问Odoo进行测试。
前3步中,我们新增了一个方法book_rent()。该方法会在用户在图书表单视图中点击Rent this book按钮时进行调用。
在第4步中,我们使用了sudo()。该方法返回一条带有修改后环境的新记录集,其中的用户具有超级用户权限。使用sudo()调用记录集时,环境变量中会将environment属性修改为su,表示为环境的超级用户状态。可通过recordset.env.su访问这一状态。所有通过这一sudo记录集的方法调用都具有超级用户权限。要更好地了解这点,从方法中删除.sudo()然后再点击Rent this book按钮。它会抛出Access Error并且用户无法再访问该模型。简言之,sudo()可以跳过所有权限规则。
如果需要一个具体的用户,可以传递一个包含该用户或用户数据库 id 的记录集。如下:
public_user = self.env.ref('base.public_user')
public_book = self.env['library.book'].sudo(public_user)
public_book.search([('name', 'ilike', 'cookbook')])
这段代码让我们可以使用公共用户搜索可见的图书。
📝在使用sudo()时,对由谁创建或更新记录的动作都不进行跟踪记录。本节中公司的最后一次修改人为Administrator,而非最初调用create的用户。
位于https://github.com/OCA/server-backend/的社区插件base_suspend_security可用于处理这一问题。
使用sudo() 时,我们跳过了访问权限及权限访问规则。有时可访问独立存在的多条记录,比如多公司环境中不同公司的记录。sudo() 记录集跳过Odoo的所有权限规则。
如果你不小心,这一环境中搜索的记录会与数据库中存在的任意公司相关联,这表示可能会向真实用户泄漏信息,更糟糕的是可能通过将记录关联到不同的公司而默默地致使数据库的损坏。
**📝重要:**使用sudo()时,应反复确认search()的调用不依赖于标准记录规则来过滤结果,
参考如下内容获取更多信息:
- 如果想要学习更多有关环境的知识,参见第五章 基本服务端开发中为不同模型获取一个空记录集一节。
- 更多有关访问控制列表和记录规则的知识,请参见第十章 权限安全。
上下文是记录集环境的一部分。它用于从用户界面传递用户的时区和语言等信息,以及动作中所指定的上下文参数。Odoo标准插件中的很多方法使用该上下文来根据这些值适配业务逻辑。有时会需要修改记录集的上下文来从方法调用获取所需要的结果或计算字段的所需结果。
本节将展示如何借助于环境上下文从改变的方法的行为。
本节中我们将使用前面小节的my_library模块。在library.book.rent模型的表单视图中,我们将添加一个按钮来将图书标记为遗失,以备普通用户遗失图书时使用。注意我们在图书的表单视图中已经有相同的按钮了,但此处我们将使用稍稍不同的行为在理解Odoo中上下文的使用。
我们需要执行如下步骤来添加按钮:
-
更新state字段的定义来添加一个遗失状态:
state = fields.Selection([('ongoing', 'Ongoing'), ('returned', 'Returned'), ('lost', 'Lost')], 'State', default='ongoing', required=True)
-
在library.book.rent的表单视图中添加一个Mark as lost按钮:
<button name="book_lost" string="Lost the Book" states="ongoing" type="object"/>
-
在library.book.rent模型中添加book_lost()方法:
def book_lost(self): ...
-
在这个方法中,确保我们操作的是单条房记录,然后修改其状态:
self.ensure_one() self.state = 'lost'
-
在方法中添加如下代码来修改环境的上下文并调用方法来改变图书的状态为遗失:
book_with_different_context = self.book_id.with_context(avoid_deactivate=True) book_with_different_context.make_lost()
-
更新 library.book模型的make_lost()方法来产生不同的行为:
def make_lost(self): self.ensure_one() self.state = 'lost' if not self.env.context.get('avoid_deactivate'): self.active = False
第1步中我们为图书添加了一种新状态。这一状态表示图书遗失。
第2步中,我们新增了一个按钮Mark as lost。用户可使用该按钮报告图书遗失。
第3和第4步中,我们添加了在点击Mark as lost按钮时调用的方法。
第5步使用一些关键词参数调用self.book_id.with_context()。它返回一个带有更新了上下文的新版本book_id记录集。这里我们只添加了一个键,avoid_deactivate=True,但是可以添加多个键。我们还使用了 sudo()来让非图书管理员可以将图书报告为遗失。
第6步中,我们查看了该上下文中avoid_deactivate键是否为真值。这样避免了取消图书,即便是遗失了图书管理员仍然可以看到它们。
现在图书管理员在图书表单视图中报告为遗失时,图书记录的状态会变为 lost,书会被存档。但普通用户在租借记录中报告图书为遗失时,记录状态会变为 lost,图书不会存档,这样管理员可以稍后查看。
这仅仅是有关变更上下文的一个简单示例,读者可以基于自己的要求在ORM(对象关系映射)中的任何地方使用它。
还可以将字典传递给with_context()。此时,该字典用作新的上下文,它会覆盖当前上下文。因此第5步可以这样编写:
new_context = self.env.context.copy()
new_context.update({'avoid_deactivate': True})
book_with_different_context = self.book_id.with_context(new_context)
book_with_different_context.make_lost()
使用with_context()包含创建一个新的环境实例。该环境会有一个初始空记录集缓存,它独立于self.env缓存进行增长。这会导致不必要的数据库查询。这种情况下,应避免在循环内新建环境并将这些环境的创建移到尽可能外面的层。
参见如下小节来了解Odoo中更多有关上下文的知识:
- 第五章 基本服务端开发中为不同模型获取一个空记录集一节讲解了环境是什么。
- 第九章 后端视图中向表单和动作传递参数 - 上下文一节讲解了如何在动作定义中修改上下文。
- 第五章 基本服务端开发中搜索记录一节讲解了active记录。
大多数时候,可以通过Odoo的ORM执行所需操作。例如,使用search()方法来获取记录。但是有时我们的所需不止于此,抑或是无法使用domain语法(有些操作非常烧脑甚至是完全不可能)来进行表达,又或是查询需要对search()进行多次调用,进而导致低效。
本节将展示如何使用原生SQL查询来获取用户借阅某本书天数的平均值。
本节中我们将使用前面小节的my_library模块。为进行简化,我们只会在日志中打印结果,但在真实场景中,会需要在业务逻辑中使用查询结果。在第九章 后端视图中,我们将在用户界面中展示查询的结果。
需要执行如下步骤来获取用户保留某本书的平均天数:
-
在library.book中添加average_book_occupation() 方法:
def average_book_occupation(self): ...
-
在方法中添加如下代码推送所有待更新内容:
self.flush()
-
在该方法中,编写如下SQL查询:
sql_query = """ SELECT lb.name, avg((EXTRACT(epoch from age(return_date, rent_date)) / 86400))::int FROM library_book_rent AS lbr JOIN library_book as lb ON lb.id = lbr.book_id WHERE lbr.state = 'returned' GROUP BY lb.name;"""
-
执行该查询:
self.env.cr.execute(sql_query)
-
获取结果并进行日志记录(注意应导入logger):
result = self.env.cr.fetchall() logger.info("Average book occupation: %s", result)
-
在library.book模型的表单视图中添加一个按钮来触发我们的方法:
<button name="average_book_occupation" string="Log Average Occ." type="object" />
别忘了在这个文件中导入logger。然后重启并更新my_library模块。
第1步中,我们添加了average_book_occupation()方法,在用户点击Log Average Occ.按钮时会进行调用。
在第2步中,我们使用flush()方法。Odoo v13开始ORM中大量使用了缓存。ORM对每个事务使用一个全局缓存。这样数据库中的记录与ORM缓存中的记录数据可能会不同。在执行查询前使用flush()方法可以确保缓存中的所有修改被推送到数据库中。
第3步中,我们声明了一条SELECT查询SQL。这会返回用户持有某本书的平均天数。如果在PostgreSQL命令行运行这条查询,根据数据库中的图书数据会得到类似下面的结果:
+---------------------------------------+-------+
| name | avg |
|---------------------------------------+-------|
| Odoo 12 Development Cookbook | 33 |
| PostgreSQL 10 Administration Cookbook | 81 |
+---------------------------------------+-------+
第4步对存储在self.env.cr中的数据库游标调用execute(方法。这会发送查询到PostgreSQL并进行执行。
第5步使用游标的 fetchall()方法来获取由查询所得到的行的列表。该方法返回一个行的列表,本例中为 [('Odoo 12 Development Cookbook', 33), ('PostgreSQL 10 Administration Cookbook', 81)]。通过我们执行的查询,可以知道每行有两个值,第一个为书名,另一个是用户持有这本书的平均天数。然后我们进行了日志记录。
第6步我们添加了按钮来处理用户动作。
📝**重要提示:**如果执行UPDATE查询,需要手动将缓存置为无效,因为Odoo ORM的缓存对UPDATE查询所做的改变是没有感知的。要将缓存置为无效,可使用self.invalidate_cache()。
self.env.cr中的对象是对psycopg2 游标的轻度封装。以下的方法是最常执行的一部分方法:
-
execute(query, params):通过params参数值元组替换查询中标记为%s的参数来执行SQL查询。
⚠️ **警告:**不要自行替换,保持使用%s这样的格式化选项,因为如果使用字符串拼接这类技术会带来SQL注入的风险。 -
fetchone():从数据库返回一行,以元组进行封装(即便是仅查询了一个字段)。
-
fetchall():以元组列表从数据库中返回所有行。
-
dictfetchall():以列名对值的映射字典列表返回数据库中的所有行。
在处理原生SQL查询时要非常小心:
- 你跳过了所有应用层面的权限。请确保调用search([('id', 'in', tuple(ids)]) 时传递id 列表过滤掉所有用户无权访问的记录。
- 你所做的修改不受插件模块中所设置的约束限制,除在数据库层面所强制的NOT NULL, UNIQUE和FOREIGN KEY约束。对于计算字段的重新计算触发器也是如此,因而可能会导致数据库的崩溃。
- 避免使用INSERT/UPDATE语句,因为通过语句插入或更新记录不会执行create() 和 write()中重写的业务逻辑。它不会更新已存储的计算字段并且会跳过ORM约束。
- 有关访问权限管理,请参见第十章 权限安全。
在第四章 应用模型的为可复用模型功能使用抽象模型一节中,介绍了models.TransientModel基类。这个类与常规的Model有很多的共通之处,不同点在于临时模型的记录会在数据库中定期地进行清除,因此称其为临时模型。它用于创建向导或对话框,来由用户在用户界面中填入并且通常用作执行对数据库中持久化记录的动作。
本节中,我们将使用前面小节中的my_library模块。本节会添加一个向导,通过这一向导,图书管理员可以同时处理多本图书。
按照如下步骤来新建一个创建图书租赁记录的向导:
-
通过如下定义向模块添加一个新的临时模型:
class LibraryRentWizard(models.TransientModel): _name = 'library.rent.wizard' borrower_id = fields.Many2one('res.partner', string='Borrower') book_ids = fields.Many2many('library.book', string='Books')
-
添加在临时模型上添加执行动作的回调方法。向LibraryRentWizard类添加如下代码:
def add_book_rents(self): rentModel = self.env['library.book.rent'] for wiz in self: for book in wiz.book_ids: rentModel.create({ 'borrower_id': wiz.borrower_id.id, 'book_id': book.id })
-
为模型创建一个表单视图。在模块视图中添加如下视图定义:
<record id='library_rent_wizard_form' model='ir.ui.view'> <field name='name'>library rent wizard form view</field> <field name='model'>library.rent.wizard</field> <field name='arch' type='xml'> <form string="Borrow books"> <sheet> <group> <field name='borrower_id'/> </group> <group> <field name='book_ids'/> </group> </sheet> <footer> <button string='Rent' type='object' name='add_book_rents' class='btn-primary'/> <button string='Cancel' class='btn-default' special='cancel'/> </footer> </form> </field> </record>
-
创建一个动作以及展示该向导的菜单入口。向模块菜单文件添加如下声明:
<act_window id="action_wizard_rent_books" name="Give on Rent" res_model="library.rent.wizard" view_mode="form" target="new" /> <menuitem id="menu_wizard_rent_books" parent="library_base_menu" action="action_wizard_rent_books" sequence="20" />
-
在ir.model.access.csv文件中对library.rent.wizard添加访问权限:
acl_library_rent_wizard,library.library_rent_wizard,model_library_rent_wizard,group_librarian,1,1,1,1
更新my_library模块应用修改。
第1步定义了一个新的模型。除基类使用TransientModel代替Model外它与其它模型并无分别。TransientModel和Model又有一个共同的基类,名为BaseModel,如果查阅Odoo的源代码,会发现99%的工作都在BaseModel中执行,Model和TransientModel的内容都很少。
TransientModel只存在如下的变化:
- 记录在数据库中会定时地删除,因此临时模型的数据表不会随时间不停增大。
- 在引用普通模型的TransientModel实例中不能定义One2many字段,因为这将在持久化模型中添加关联到临时数据的列。在这种情况下使用Many2many关联。当然,可以在临时模型之间定义Many2one和One2many字段关联。
我们在模型中定义了两个字段:一个用于存储借书的成员,另一个存储所借阅的书籍。我们可以添加其它标量字段,来记录预计归还日期等。
第2步为点击第3步中所定义按钮调用的向导类添加了代码。这段代码从向导中读取值并为每本书创建library.book.rent记录。
第3步为我们的向导定义了一个视图,参见第九章 后端视图中定义文档样式表单一节获取更多详情。这里的重点是footer中的按钮,type属性设置为了object,表示在用户点击该按钮时,会调用按钮name属性所指定的同名方法。
第4步保障我们在应用的菜单中有一个向导的入口。我们在动作中使用了target='new' ,这样该表单视图会在当前表单之上以对话框形式展示。参见第九章 后端视图中的添加菜单项和窗口动作一节来了解详情。
第5步中我们为library.rent.wizard模型添加了访问权限。这们图书管理员就具备了library.rent.wizard模型的所有操作权限。
📝注:Odoo v14之前,TransientModel无需配置权限规则。所有人都可以创建记录,且仅能访问自己创建的记录。而在TransientModel中TransientModel强制要求设置访问权限。
下面是一些优化向导的贴士:
我们所展示的向导要求用户在表单中输入会员名。在网页客户端中有一个功能可用于快速输入。在执行动作时,上下文中会更新一些值,并可由向导所使用:
键 | 值 |
---|---|
active_model | 这是与动作相关联的模型名。通常为在屏幕上所展示的模型。 |
active_id | 这表明单条记录是活跃的并且提供了该记录的 ID。 |
active_ids | 如果选择了多条记录,这将是带有 ID 的列表。这在树状视图中选择多项并触发动作时产生。在表单视图中,所获取的为 [active_id]。 |
active_domain | 这是向导将要操作的其它域。 |
这些值可用于计算模型的默认值,甚或直接在按钮的调用方法中进行计算。我们来改进一下本节中的示例,如果 res.partner 模型的表单视图中展示了一个启动向导的按钮,向导创建的上下文中会包含{'active_model': 'res.partner', 'active_id': }。这种情况下,我们应定义member_id字段来获取由如下方法所计算的默认值:
def _default_member(self):
if self.context.get('active_model') == 'res.partner':
return self.context.get('active_id', False)
第2步中,我们可以删除向导中的for循环,并在假定len(self) 为1时,我们可以在方法的开头添加self.ensure_one()如下:
def add_book_rents(self):
self.ensure_one()
rentModel = self.env['library.book.rent']
for book in self.book_ids:
rentModel.create({
'borrower_id': self.borrower_id.id,
'book_id': book.id
})
在方法的开头添加self.ensure_one()会确保self中的记录条数为1。如果self中的记录条数多于1会抛出错误。
我们推荐使用这一小节中的代码,因为这让我们可以通过对向导创建记录复用代码中其它部分的向导,将它们放在单个记录集中(参见第五章 基本服务端开发中的合并记录集一节了解如何实现),然后对记录集调用add_book_rents()。这里的代码很简单,无需执行完所有循环来记录某些书由不同成员借阅。但是在Odoo实例中,有些操作非常的复杂,带有可以实现正确操作的向导会非常的好。在使用这些向导时,务必检查源代码中是否使用了上下文中的active_model/active_id/active_ids keys。如果使用了,需要传递一个自定义上下文(参见使用变更的上下文调用方法一节)。
第2步中的方法没有任何返回。这会导致向导对话框在执行完动作后关闭。另一种可能是让方法返回带有ir.action中字段的字典。此时,网页客户端会像用户点击了菜单入口那样处理该动作。BaseModel类中定义的get_formview_action()方法可用于这一实现。例如,如果我们想要展示刚刚借阅书籍的会员的表单视图,可以编写如下代码:
def add_book_rents(self):
rentModel = self.env['library.book.rent']
for wiz in self:
for book in wiz.book_ids:
rentModel.create({
'borrower_id': wiz.borrower_id.id,
'book_id': book.id
})
members = self.mapped('borrower_id')
action = members.get_formview_action()
if len(members.ids) > 1:
action['domain'] = [('id', 'in', tuple(members.ids))]
action['view_mode'] = 'tree,form'
return action
这会通过该向导创建一个借阅了图书的成员列表(实例情况中,在用户界面中调用该向导时仅会有一个成员)并创建一个动态的动作,来使用指定的 ID 展示成员。
重定向用户技术可用于创建包含多个逐一执行步骤的向导。向导中的每一步可使用前面小步中的值。通过提供Next按钮来调用向导中定义了更新向导某些字段的方法,并返回在相同更新后向导中重新展示的动作以及准备好下一个步骤。
- 参见第九章 后端视图中定义文档样式表单一节获取更多对向导定义视图的详情
- 参见第九章 后端视图中的添加菜单项和窗口动作一节了解有关视图和调用服务端方法的知识
- 参见第五章 基本服务端开发中的合并记录集一节了解为向导创建记录和将它们放入单个记录集的详情
在编写业务逻辑时,常常会有一些字段交叉关联。我们在第四章 应用模型中的向模型添加约束验证一节中学习过如何在字段中指定约束。本节讲解稍有不同的概念。这里在用户界面中一个字段被修改时调用onchange,来在网页客户端中更新记录其它字段的值,这通常是在表单视图中。
我们将通过提供类似编写向导来引导用户一节中所定义的向导进行讲解,但它可用于记录图书的归还。在向导中设置成员时,图书的列表会更新为成员当前所借阅的书籍。虽然我们在TransientModel上演示onchange方法,但这些功能也可以用于普通Model。
本节中我们将使用编写向导来引导用户一节中的my_library模块。我们会创建一个向导来归还所借阅图书。这会添加一个onchange方法,在图书管理员选择成员字段时自动填入图书。
需要通过为向导定义如下临时模型来进行准备:
class LibraryReturnWizard(models.TransientModel):
_name = 'library.return.wizard'
borrower_id = fields.Many2one('res.partner', string='Member')
book_ids = fields.Many2many('library.book', string='Books')
def books_returns(self):
loanModel = self.env['library.book.rent']
for rec in self:
loans = loanModel.search(
[('state', '=', 'ongoing'),
('book_id', 'in', rec.book_ids.ids),
('borrower_id', '=', rec.borrower_id.id)]
)
for loan in loans:
loan.book_return()
最后,需要为向导定义视图、动作和菜单入口。这些部分读者可作为练习自行添加。
在用户修改时自动跳出要归还的书籍,我们需要在LibraryReturnsWizard中添加onchange方法,定义如下:
@api.onchange('borrower_id')
def onchange_member(self):
rentModel = self.env['library.book.rent']
books_on_rent = rentModel.search(
[('state', '=', 'ongoing'),
('borrower_id', '=', self.borrower_id.id)]
)
self.book_ids = books_on_rent.mapped('book_id')
onchange方法使用@api.onchange装饰器,传递变化的字段名,因而会触发该方法的调用。本例中,我们告诉用户界面在borrower_id发生变更时应调用该方法。
在方法体中,我们搜索当前由指定成员借阅的图书,并且我们使用属性赋值来更新向导中的book_ids属性。
ℹ️@api.onchange装饰器处理发往网页客户端的视图变更来为字段添加一个on_change属性。这在老API中为手动操作。
onchange的基础用法是在用户界面中其它字段发生变化时为字段计算新值,本节中我们也看到了。
在方法体中,我们获取对记录当前视图中展示的字段的访问,但不一定需要模型的所有字段。这是因为可在用户界面中创建记录并还未存储到数据库中时调用。在onchange方法内,self 处于一种特殊状态,表现为self.id其实并不是整型,而是odoo.models.NewId的实例。因此我们不应在onchange方法内对数据库进行任何修改,原因在于用户可能会取消记录的创建,而在编辑的过程中并不会对onchange方法所做的修改进行回滚。
此外,onchange方法可以返回一个Python字典。字典中可以有如下的键:
- warning:值应为一个字典,其中有title和message键,分别包含着对话框的标题和内容,在onchange方法运行时会进行展示。这在发生不连续或潜在问题时用于引起用户的注意。
- domain:值应为将字段名映射到域的字典。用于在想要根据另一个字段的值修改One2many字段的域时。
例如,假定我们在 library.book.rent模型中对expected_return_date有一个固定值的集合,并且希望在成员有延迟未还的图书时显示警告。我们还会希望将图书的选择限制为用户当前所借阅的图书。可以重写onchange方法如下:
@api.onchange('member_id')
def onchange_member(self):
rentModel = self.env['library.book.rent']
books_on_rent = rentModel.search(
[('state', '=', 'ongoing'),
('borrower_id', '=', self.borrower_id.id)]
)
self.book_ids = books_on_rent.mapped('book_id')
result = {
'domain': {'book_ids': [
('id', 'in', self.book_ids.ids)]
}
}
late_domain = [
('id', 'in', books_on_rent.ids),
('expected_return_date', '<', fields.Date.today())
]
late_books = rentModel.search(late_domain)
if late_books:
message = ('Warn the member that the following '
'books are late:\n')
titles = late_books.mapped('book_id.name')
result['warning'] = {
'title': 'Late books',
'message': message + '\n'.join(titles)
}
return result
这段代码在借阅者有迟还的书时显示警告,但这类警告更像是通知。不能用于验证,因为那样会中断业务流。
onchange方法存在一个局限性:在服务端执行操作时不会进行调用。onchange仅在所依赖操作通过Odoo用户界面执行时自动调用。但在一些情况下,调用onchange方法非常的重要,因为它在所创建或更新记录中更新了某些重要字段。当然, 你可以自己完成所需计算,但有时可能做不到,因为onchange可由你不知道的安装在实例中的第三方插件所添加或修改。
本节讲解如何通过在创建记录前手动触发onchange方法对记录的onchange方法进行调用。
在更改执行动作的用户一节中,我们添加了一个Rent this book按钮来让非图书管理员用户来自己借阅图书。现在我们需要对归还图书做相同操作,但不是编写归还图书的逻辑,而是仅仅使用定义onchange方法一节中所创建的图书归还向导。
本节中,我们将手动创建library.return.wizard模型的一条记录。我们希望onchange方法来为我们计算归还的图书。需要执行如下步骤来进行实现:
-
在library_book.py文件中导入tests工具集中的Form:
from odoo.tests.common import Form
-
在library.book模型中创建return_all_books方法:
def return_all_books(self): self.ensure_one()
-
为library.return.wizard获取一个空记录集:
wizard = self.env['library.return.wizard']
-
创建向导Form代码块如下:
with Form(wizard) as return_form:
-
通过赋值借阅者触发onchange,然后归还图书:
return_form.borrower_id = self.env.user.partner_id record = return_form.save() record.books_returns()
有关第1至3步的讲解,参见第五章 基本服务端开发中新建记录一节。
第4步创建了一个虚拟表单来处理onchange规范,就像GUI那样。
第5步中包含归还所有图书的完整逻辑。第一行中我们对向导中的borrower_id赋值。这会触发library.return.wizard模型中的onchange方法(更多有关onchange方法的知识,参见前面小节中的onchange方法定义)。然后我们调用了表单中的save()方法,它返回一条向导记录。之后调用了books_returns()方法来执行归还所有书籍的逻辑。
模型的onchange(values, field_name, field_onchange) 方法,有三个参数:
values:我们要对记录设置的值的列表。你需要为所有需由onchange方法修改的字段提供值。本节中,基于这一原因我们将book_ids设置为False。field_name:我们希望触发onchange方法的字段列表。你可以传递一个空列表,ORM会使用值中所定义的字段。但是,我们会经常想要手动指定这一列表来在不同的字段更新相同的字段时手动控制运行的顺序。- field_onchange:第4步中所计算的onchange详情。这一方法查找应调用哪些onchange方法,以及调用的顺序,它返回一个字典,其中包含如下键:
value:这是一个新计算字段值的字典。这个字典仅包含传递给onchange()的values参数的键。注意Many2one字段会映射到包含 (id, display_name)的元组来进行对网页客户端的优化。warning:这是一个包含在网页客户端展示给客户的警告信息字典。domain:这是一个映射字段名到新有效域的字典。
onchange方法大多在用户界面中调用。但本节中,我们学习了如何在服务端使用/触发onchange的业务逻辑。这样,可以在创建记录时不跳过任何业务逻辑。
如果想要了解创建和更新记录的更多知识,参见第五章 基本服务端开发的新建记录和更新记录集中记录值小节。
在上两小节中,我们学习了如何定义及调用onchange方法。我们还了解到了其局限性,即仅能从用户界面中自动调用。为解决这一问题,Odoo v13中引入了一种全新定义onchange行为的方式。本节中,我们就来学习如何使用compute方法来产生像onchange方法那样的行为。
本节我们使用前一小节中的my_library模块。我们会将library.return.wizard中的onchange方法替换为compute方法。
按如下步骤使用compute方法来修改onchange方法:
-
使用compute替换onchange_member()方法中的api.onchange如下:
@api.depends('borrower_id') def onchange_member(self): ...
-
在字段的定义中添加compute参数如下:
book_ids = fields.Many2many('library.book', string='Books', compute="onchange_member", readonly=False)
升级my_library模块应用代码,然后测试归还图书向导来查看变化。
在功能上,我们计算的onchange与普通的onchange方法相似。唯一的不同在于现在onchange在后台修改时也会触发。
第1步中,我们使用@api.compute替换了@api.onchange。这要求在字段值变化时重新计算方法。
第2步中,我们通过字段注册了compute方法。细心的你会发现在定义compute属性的同时使用了readonly=False。默认compute方法为只读,但通过设置readonly=False,我们可以保证该字段是可编辑并存储的。
参见第四章 应用模型中的向模型添加计算字段一节学习更多有关计算字段的知识。
因计算onchange在后台也可以使用,我们就无需在return_all_books()方法再使用Form类。可以将相应代码修改如下:
def return_all_books(self):
self.ensure_one()
wizard = self.env['library.return.wizard']
wizard.create({
'borrower_id': self.env.user.partner_id.id
}).books_returns()
这段代码无需使用Form类即可返回用户所借出的书籍。使用常规的onchange方法,需要创建Form对象,但使用计算的onchange,则无需再创建Form对象。在记录创建时会调用相应的onchange方法。
参见第四章 应用模型中的向模型添加计算字段一节学习更多有关计算字段的知识。
在进行插件模块的设计时,我们对类中的数据建模由Odoo的ORM映射到数据表中。我们应用了一些知名的设计原则,比如关注点分离(separation of concerns)和数据归一化(data normalization)。但在模块设计的后续阶段,它可用于为同一张表中对多个模型进行数据聚合,顺便对它们执行一些运算,尤其是报告或生成仪表盘。要进行简化,以及利用Odoo中PostgreSQL数据库引擎底层的强大之处,可以定义基于PostgreSQL视图的只读模型而非数据表。
本节中,我们将复用本章中编写向导来引导用户一节的租借模型,并且我们会新建一个模型来让收集图书及作者数据更为容易。
本节中,我们将使用前一节中的my_library模块。我们会创建一个名为library.book.rent.statistics的新模型来保留统计数据。
按照如下步骤创建基于PostgreSQL视图的新模型:
-
新建一个模型并将_auto类属性设置为False:
class LibraryBookRentStatistics(models.Model): _name = 'library.book.rent.statistics' _auto = False
-
声明想要在该模型中看到的字段,将它们设置为readonly:
book_id = fields.Many2one('library.book', 'Book', readonly=True) rent_count = fields.Integer(string="Times borrowed", readonly=True) average_occupation = fields.Integer(string="Average Occupation (DAYS)", readonly=True)
-
定义init()方法来创建视图:
@api.model_cr def init(self): tools.drop_view_if_exists(self.env.cr, self._table) query = """ CREATE OR REPLACE VIEW library_book_rent_statistics AS ( SELECT min(lbr.id) as id, lbr.book_id as book_id, count(lbr.id) as rent_count, avg((EXTRACT(epoch from age(return_date, rent_date)) / 86400))::int as average_occupation FROM library_book_rent AS lbr JOIN library_book as lb ON lb.id = lbr.book_id WHERE lbr.state = 'returned' GROUP BY lbr.book_id ); """ self.env.cr.execute(query)
-
现在可以为新模型定义视图了。透视表视图对于挖掘数据尤为有用(参见第九章 后端视图)。
-
不要忘记为新模型定义一些权限规则(参见第十章 权限安全)。
通常,Odoo会为通过为数据列使用字段定义的模型新建一张表。这是因为在BaseModel类中,_auto属性默认为True。在第1步中,通常将这个类属性设为False,我们告诉Odoo我们自己管理它。
第2步中,我们定义将由Odoo使用的一些字段来生成数据表。将它们标记为readonly=True,这样视图不会启用无法保存的修改,因为PostgreSQL的视图是只读的。
第3步中定义了init()方法。这个方法通常什么也不做,它在_auto_init()之后调用(在_auto = True时负责数据表的创建,否则什么也不做),并且我们使用它来创建一个新的SQL视图(或在模块升级时更新已有视图)。视图创建查询必须创建一个包含匹配Model字段名的字段名的视图。
**📝重要贴士:**这是一个常见错误,这种情况下,忘记在视图定义查询中重命名该字段,会在Odoo无法查找到该字段时产生错误信息。
注意我们还需要加入一个名为ID包含唯一值的整型列。
还可以在这种模型中包含一些计算和关联字段。唯一的限制是这些字段不能被存储(因此不能使用它们来对记录分组或进行搜索)。但是在前例中,我们可通过添加一个字段来让图书可编辑,定义如下:
publisher_id = fields.Many2one('res.partner', related='book_id.publisher_id', readonly=True)
如果需要使用publisher分组,需要通过在视图定义中添加该字段进行存储,而不是使用关联字段。
在Odoo中,可以通过Settings选项设置可选功能。用户可以随时启用或禁用该选项。本节中我们将描述如何创建设置选项。
在前面的小节中,我们添加了一些按钮,这样非图书管理员的用户可以借阅及归还图书。并非每个图书馆都是如此,因此我们会创建设置选项来启用及禁用这一功能。我们将通过隐藏这些按钮来实现。本节中,我们会使用前面小节中相同的my_library模块。
按照如下步骤来创建自定义设置选项:
-
在my_library/security/groups.xml文件中添加一个新分组:
<record id="group_self_borrow" model="res.groups"> <field name="name">Self borrow</field> <field name="users" eval="[(4, ref('base.user_admin'))]"/> </record>
-
通过继承res.config.settings模型来添加新字段:
class ResConfigSettings(models.TransientModel): _inherit = 'res.config.settings' group_self_borrow = fields.Boolean(string="Self borrow", implied_group='my_library.group_self_borrow')
-
通过xpath在已有的settings视图中添加这一字段(更多详情,请参见
第九章 后端视图
):
<record id="res_config_settings_view_form" model="ir.ui.view"> <field name="name">res.config.settings.view.form.inherit.library</field> <field name="model">res.config.settings</field> <field name="priority" eval="5"/> <field name="inherit_id" ref="base.res_config_settings_view_form"/> <field name="arch" type="xml"> <xpath expr="//div[hasclass('settings')]" position="inside"> <div class="app_settings_block" data-string="Library" string="Library" datakey="my_library" groups="my_library.group_librarian"> <h2>Library</h2> <div class="row mt16 o_settings_container"> <div class="col-12 col-lg-6 o_setting_box" id="library"> <div class="o_setting_left_pane"> <field name="group_self_borrow"/> </div> <div class="o_setting_right_pane"> <label for="group_self_borrow"/> <div class="text-muted"> Allow users to borrow and return books by themself </div> </div> </div> </div> </div> </xpath> </field> </record>
-
为Settings添加一个菜单及一些动作:
<record id="library_config_settings_action" model="ir.actions.act_window"> <field name="name">Settings</field> <field name="type">ir.actions.act_window</field> <field name="res_model">res.config.settings</field> <field name="view_id" ref="res_config_settings_view_form"/> <field name="view_mode">form</field> <field name="target">inline</field> <field name="context">{'module' : 'my_library'}</field> </record> <menuitem name="Settings" id="library_book_setting_menu" parent="library_base_menu" action="library_config_settings_action" sequence="50"/>
-
修改图书表单视图中的按钮并添加一个my_library.group_self_borrow分组:
<button name="book_rent" string="Rent this book" type="object" class="btn-primary" groups="my_library.group_self_borrow"/> <button name="return_all_books" string="Return all book" type="object" class="btn-primary" groups="my_library.group_self_borrow"/>
重启服务并更新my_library模型来应用修改。
Odoo中所有的设置选项都在res.config.settings模型中进行添加。res.config.settings 是一个临时模型。第1步中,我们创建了一个新的权限组。我们将使用这个组来创建隐藏和显示按钮。
第2步中,我们通过继承它在res.config.settings模型中添加了一个新的布尔字段。我们添加了一个implied_group属性,值为my_library.group_self_borrow。这个分组会在admin通过该布尔字段启用或禁用选项时被分配给所有的odoo用户。
Odoo设置使用一个表单视图来在用户界面中展示settings选项。所有这些选项通过外部ID,base.res_config_settings_view_form在单个表单视图中进行添加。第3步中,我们通过继承这一个设置表单视图来在用户界面中添加选项,这里使用了xpath 添加设置选项。第九章 后端视图中,我们将进行更深入的学习。在表单定义中,会发现这一选项的属性数据键值为模块名称。仅在向Settings添加在整个新的选项卡才会需要。否则,只需通过xpath对已有模块的Settings选项卡中添加选项。
第4步中,我们添加了一个动作及一个菜单来通过用户界面访问配置选项。需要传递动作中的 {'module' : 'my_library'}上下文来在点击菜单时默认打开my_library模块的Settings选项卡。
第5步中,我们向按钮添加了my_library.group_self_borrow组。借助这个组,Borrow和Return按钮会根据settings选项隐藏或显示。
然后,会看到一个该图书馆的单独Settings选项卡,其中,可以看到一个布尔字段用于启用或禁用自借阅选项。在启用或禁用这一选项时,背后Odoo会对所有odoo用户应用或删除implied_group。因为我们在按钮上添加了组,按钮会在用户拥有组时显示,而在用户没有组时隐藏。在第十章 权限安全中,我们会深入地学习权限组。
还有一些其它方式来管理设置选项。其中之一是分离功能到新模块中并通过选项安装或卸载这些模块实现。需要使用以module_为前缀加模块名的名称来添加一个布尔字段进行实现。例如,我们新建一个名为my_library_extras的模块,添加一个如下的布尔字段:
module_my_library_extras = fields.Boolean(string='Library Extra Features')
在启用或禁用该选项时,odoo会安装或卸载my_libarary_extras模块。
另一种管理设置的方法是使用系统参数。这类数据存储在ir.config_parameter模型中。以下为如何创建系统级全局参数:
digest_emails = fields.Boolean(
string="Digest Emails",
config_parameter='digest.default_digest_emails')
字段中的config_parameter属性将保障用户数据存储在系统参数中,位于Settings > Technical > Parameters > System Parameters菜单下。这一数据存储在digest.default_digest_emails键下。
设置选项用于让应用更通用。这些选项给你取用户充分自由,让他们可以随时启用或禁用一些功能。在将功能转化为选项时,可以使用同一个模块服务多个客户,并且客户可以在想要的时候启用该功能。
在第六章 管理模块数据中,我们学习了如何通过XML或CSV文件添加、更新及删除记录。但有时,业务用例非常复杂,无法通过数据文件进行解决。这类情况下,可以在声明文件中使用init钩子来执行所需要的操作。
我们将使用前一节中相同的my_library模块。本节中为进行简化,仅通过post_init_hook来创建一些图书记录。
按照如下步骤来添加post_init_hook:
-
在__manifest__.py文件中通过post_init_hook键来注册这个钩子:
... 'post_init_hook': 'add_book_hook', ...
-
在__init__.py文件中添加add_book_hook()方法:
def add_book_hook(cr, registry): env = api.Environment(cr, SUPERUSER_ID, {}) book_data1 = {'name': 'Book 1', 'date_release': fields.Date.today()} book_data2 = {'name': 'Book 2', 'date_release': fields.Date.today()} env['library.book'].create([book_data1, book_data2])
在第一步中,我们在声明文件文件中通过add_book_hook注册了post_init_hook。这表示在模块安装之后,Odoo会在__init__.py中查找add_book_hook方法。如果找到,它会使用数据库游标和 registry调用该方法。
第2步中,我们声明了add_book_hook()方法,在模块安装后会被调用。我们通过该方法创建了两条记录。在实际场景中,可以在此处编写复杂的业务逻辑。
本例中,我们学习了post_init_hook,但Odoo还支持另外两种钩子:
- pre_init_hook:这个钩子会在开始安装模块时调用。它与post_init_hook正好相反,会在当前模块安装前触发。
- uninstall_hook:这个钩子会在你卸载该模块时调用。它多用于模块需要有垃圾回收机制时。