这是Odoo系列文章的第九篇,完整目录请见最好用的免费ERP系统Odoo 11开发指南
以下开发均假设读者已完成第八篇的代码,并且所有代码更新后均需自行更新方会在客户端看到变化。如未阅读该篇,请参考代码:Chapter 8
本文主要内容有
- 修改运行指定动作用户
- 以变更的上下文调用方法
- 执行原生 SQL 语句
- 为用户编写向导
- 定义 onchange 方法
- 在服务端调用 onchange 方法
- 基于 SQL 视图定义模型
修改运行指定动作用户
在书写业务逻辑代码时,常会需要在不同权限上下文中操作动作,比如使用管理员权限绕过权限检查。下面我们来看一下普通用户如何使用 sudo()来修改公司电话号码。默认仅 Administration/Access Rights 用户组的用户可修改 res.company 记录。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
from odoo import models, api class ResCompany(models.Model): _inherit = 'res.company' @api.multi def update_phone_number(self, new_number): # 仅操作一条记录 self.ensure_one() # 修改用户环境,self.sudo()会返回新上下文而非 self下的记录集,sudo()不加参就默认使用超级管理员,因而也就具备了管理员权限 company_as_superuser = self.sudo() # 如需使用特定用户,传入在数据库中的用户id # 如以下代码段可允许 public 用户搜索可见书籍 # public_user = self.env.ref('base.public_user') # public_book = self.env['library.book'].sudo(public_user) # 写入新电话号 company_as_superuser.phone = new_number |
注意:使用 sudo()时的操作是不可追踪的,所以使用 update_phone_number 后查看到的最后修改人仍是管理员,OCA的 base_suspend_security 可用于突破这一限制。
扩展知识
使用 sudo()不加参用户的上下文会变成 Odoo 超级管理员,该用户不受任何权限控制列表(acl)和记录集的权限规则限制。默认该用户有一个 company_id 字段设置为实例的主公司(ID 为1),这对于多公司的场景会存在问题:
- 如若不小心,该环境中创建的新记录会被关联到超级管理员的公司
- 不当操作会导致在该上下文中搜索到的记录有可能与当前数据库中的任一公司关联,进而导致向用户泄漏信息,甚至会在经意间因把不同公司的记录相互关联而导致数据库的损坏
小贴士:使用 sudo()时,反复确认调用 search()时不依赖标准记录集过滤结果,并确保在执行 create()时不使用当前用户字段 如 company_id 所计算的默认值。
使用 sudo()也会创建一个新的 Environment 实例,该环境初始带有一个空的记录集缓存 ,它与 self.env 的缓存是独立开来的。这可能会导致伪造数据查询,请避免在循环内创建新环境,并且越靠外层越好。
以变更的上下文调用方法
上下文是记录集环境的一部分,用于传递时区、用户界面语言、及动作中指定的上下文参数等信息。标准插件中的很多方法都使用上文来根据这些值来调整行为,有时需要变更记录集上下文来从方法调用获取预期结果或从可计算字段获取预期值。以下讨论在给定的 stock.location 中读取 product.product 的仓储级别。
以下会使用到 stock 和 product 两个 addon
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
from odoo import models, api class ProductProduct(models.Model): _inherit = 'product.product' @api.model def stock_in_location(self, location): # 修改上下文 product_in_loc = self.with_context( location=location.id, active_test=False ) # 搜索所有产品 all_products = product_in_loc.search([]) # 使用指定位置所有产品的产品名和仓储级别创建一个数组 stock_levels = [] for product in all_products: if product.qty_available: stock_levels.append((product.name, product.qty_available)) return stock_levels |
以上 self.with_context()传递了一些参数,它返回一个新的带有键值的 self 版本(product.product 记录集),两个键分别为:
- location:在product.product 方法计算 qty_available 字段的帮助文档部分有提及
- active_test:使用该键并赋值 False,search()方法不会为搜索域自动添加(‘active’,’=’,True),它可以确保后面获取到所有产品(包括 disable 状态的)
扩展知识
也可为 self.with_context()传入字典,此时字典会覆盖原有环境成为新的上下文,上述对应代码可修改为
1 2 3 4 |
new_context = self.env.context.copy() new_context.update({'location': location.id, 'active_test': False}) product_in_loc = self.with_context(new_context) |
同样的使用 with_context()会创建一个新的 Environment 实例,该环境初始带有一个空的记录集缓存 ,它与 self.env 的缓存是独立开来的。这可能会导致伪造数据查询,请避免在循环内创建新环境,并且越靠外层越好。
执行原生 SQL 语句
大多数情况下,可以使用 search()方法执行操作,但有时这并不够,比如使用域的句法无法达到要求,或者一些查询需要多次调用 search()而导致效率低下。
以下我们将使用原生 SQL 查询来读取按国家分组的 res.partner 记录。
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 |
from odoo import models, api class ResPartner(models.Model): _inherit = 'res.partner' @api.model def partners_by_country(self): # 原生查询语句:使用了 id 字段和 country_id 外键(指向 res_country 表),array_agg 是 PostgreSQL 对 SQL的扩展,将信息放入数组 sql = ('SELECT country_id, array_agg(id) ' 'FROM res_partner ' 'WHERE active=true AND country_id IS NOT NULL ' 'GROUP BY country_id') # 执行语句 self.env.cr.execute(sql) # 遍历查询结果来生成结果集字典 country_model = self.env['res_country'] result = {} for country_id, partner_ids in self.env.cr.fetchall(): country = country_model.browse(country_id) partners = self.search( [('id', 'in', tuple(partner_ids))] ) result[country] = partners return result |
扩展知识
self.env.cr 是 包裹psycopg2游标(cursor)的装饰器,以下是常用的一些方法:
- execute(query, params):参数为元组,替换查询语句中的%s 生成查询再执行(不要自己进行替换,这会使代码面临 SQL注入的风险)
- fetchone():以元组的形式从数据库中返回一行
- fetchall():以元组列表形式返回数据库的所有行
- fetchalldict():以列名和值组成的键值对字典列表返回数据库的所有行
处理原生 SQL 查询时应注意:
- 这会越过应用的权限限制,确保使用 search([‘id’, ‘in’, tuple(ids)])来过滤掉用户无权访问的记录
- 任何修改都会绕过插件设置的约束,除 NOT NULL, UNIQUE 和 FOREIGN KEY 约束外,这些是在数据库级别强加的,重新计算触发的字段也是如此,因而可能会导致数据库的崩溃
为用户编写向导
我们在第五篇中曾介绍过 models.TransientModel 基类,该类与很多常规类相似,不同之处在于会在数据库中定期清理,所以才会被称为临时模型。一般用于创建向导或对话框,由用户在界面中填写然后再向数据库的持久记录操作。
下面我们向之前的模块添加记录借书的向导
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 |
from odoo import models, api, fields class LibraryBookLoan(models.Model): _name = 'library.book.loan' book_id = fields.Many2one('library.book', 'Book', required=True) member_id = fields.Many2one('library.member', 'Borrower', required=True) state = fields.Selection([('ongoing', 'Ongoing'), ('done', 'Done')], 'State', default='ongoing', require=True) class LibraryLoanWizard(models.TransientModel): # 创建临时模型: Model 和 TransientModel 的公有基类是 BaseModel,Odoo 源码中99%都使用 BaseModel _name = 'library.loan.wizard' # 以下两个字段分别存储借书人和所借的书 member_id = fields.Many2one('library.member', string='Member') book_ids = fields.Many2many('library.book', string='Books') # 添加方法在临时模型上执行操作 @api.multi def record_loans(self): loan = self.env['library.book.loan'] for wizard in self: member = wizard.member_id books = wizard.book_ids for book in books: loan.create({'member_id': member.id, 'book_id': book.id}) |
然后在 xml 添加对应的菜单设置即可。TransientModel 的不同之处在于
- 数据库中记录会定期移除,因而临时模型数据表不会越变越大
- 无法对临时模型设置权限,任何人都可以创建记录,但只有创建者才能查看和使用该记录
- 不就为临时模型指向常规模型的字段设置 One2many 类型,这样会在常规模型中添加列来与临时数据关联。这种情况可以使用 Many2many,但可以为在临时模型之间使用 Many2one 和 One2many 字段。
xml 文件中 button 类型设置为 object 表明在点击按钮时会调用name 属性所赋值的方法,操作中的target=’new’会在当前表单之上显示一个对话框。
1 2 3 4 5 6 7 8 9 10 11 |
<act_window id="action_wizard_loan_books" name="Record Loans" res_model="library.loan.wizard" view_mode="form" target="new" /> <menuitem id="menu_wizard_loan_books" parent="library_book_menu" action="action_wizard_loan_books" sequence="20" /> |
扩展知识
以下可用于增强向导的功能:
使用上下文计算默认值
以上的向导要求用户填写姓名,web 客户端的特性可以让用户少打一些字,在操作执行时,上下文可以更新一些值给向导使用
Key | Value |
active_model | 与操作相关的模型,通常是屏幕上显示的模型 |
active_id | 表明单条记录处于活跃状态,提供出该记录的 ID |
active_ids | 在选择多条记录时,则会是一个 ID 列表(树状视图中选择多条),在表单视图中得到[active_id] |
active_domain | 向导所操作的额外的域 |
这些值可用于计算模型的默认值,甚至直接通过按钮给方法调用。假如在 library.member 模型的表单视图中有一个按钮启动向导,向导创建的上下文中会包含{‘active_model’:’library.member’, ‘active_id’:<member id>},这时可以使用如下方法定义 member_id 字段来计算默认值
1 2 3 |
def _default_member(self): if self.context.get('active_model') == 'library_member': return self.context.get('active_id', False) |
向导和代码复用
在方法中我们可以将 for wizard 设为自循环,假设 len(self)为1,可以在方法最前面调用 self.ensure_one()
1 2 3 4 5 6 7 8 |
@api.multi def record_borrows(self): self.ensure_one() member = self.member_id books = self.book_ids loan = self.env['library.book.loan'] for book in books: loan.create({'member_id': member.id, 'book_id': book.id}) |
推荐使用这段代码,因为这样可以通过为向导创建记录在其它部分的代码中复用这个向导,放到一个单独的记录集中然后调用记录集中的 record_loans()。确实在此处代码有些琐碎并且无需通过所有的不同成员借用某些书的循环。但在 Odoo 实例中,有些操作更为复杂,通常有向导来做“对的事”会比较好。使用这类向导时,确保查看上下文中任何使用 active_model/active_id/active_ids 键的源代三,这时应传入自定义的上下文。
重定向用户
正在更新中…