昨天梳理了odoo权限控制中最基础的组类、组和用户之间的关系。今天结合一个房产公司管理模块介绍odoo通过组控制菜单、视图、模型、记录和字段的方法。
首先建立大局观,odoo权限机制分两大部分,一是视图级权限,二是模型层权限。具体如下图所示。从下往上,控制粒度更精细,上层权限在下层权限的过滤结果中进行再过滤。视图级权限仅对登录odoo平台的用户起控制作用,模型级权限还能对RPC调用起控制作用。开发一个odoo应用时,对于权限的设计需要有全局观,可以按照下图由下往上的顺序设计应用的权限控制方案。
案例想实现的效果如下:
- 二级菜单Advertisements打开一个房产信息列表,该列表中的房产都被设置了广告属性,用于显示房产公司所有已对外做广告的房产信息,该菜单应该对所有内部用户和外部用户都可见,且在打开的视图中不可创建、修改和删除记录;
- 三级菜单My Properties用于列出房产销售员或者经理名下托管的房产信息,该菜单只有销售员和经理可见,且在打开的页面可增删改查记录;
- 三级菜单All Properties用于列出公司所有房产信息,该菜单只对经理可见,且在打开的页面中,经理可以增删改查所有记录。
由于有Advertisements这个需求,销售员在Advertisements中要能看到不仅是自己创建的房产广告信息,还要能看到其他用户创建的广告。为了让这个案例尽可能囊括权限控制的所有方面,我并没有为广告信息单独建立模型,我仅在房产模型中设置了一个advertisement字段,用于判断是否将该条记录作为广告。这样,同一个房产模型将对应三个视图(实际项目中这样建模是不好的,权限控制也更复杂,而这正是我设计这个案例的目的)。
下图是我以经理角色登录后的最终效果图。
1. 菜单、动作、视图、视图字段的权限控制
菜单通过动作打开视图,它们之间的关联关系见下图。
1.1 菜单
ir_ui_menu存储了系统中所有的菜单项,通过action字段关联一个动作(图中只给出了窗口动作,除此之外还有url动作、报表动作等都类似)。在odoo14中,menu的定义有一个更简捷的方式:级联定义法。下面的代码定义了前文所说案例的菜单项结构:一级菜单Real Estate,其下有两个二级菜单Advertisements和Manage Properties, 在Manage Properties目录下又定义了两个三级菜单My Properties和All Properties。
<menuitem id="menu_real_estate" name='Real Estate' sequence='0'>
<menuitem id='menu_advertisements' name='Advertisements' action='action_menu_advertisements' groups="base.group_user,base.group_public"/>
<menuitem id='menu_manage_properties' name='Manage Properties'>
<menuitem id='menu_my_properties' name='My Properties' action='action_menu_property_list_saler' groups="group_saler"/>
<menuitem id='menu_all_properties' name='All Properties' action='action_menu_property_list_manager' groups="group_manager"/>
</menuitem>
</menuitem>
Advertisements菜单通过action属性关联XML ID为action_menu_advertisements的动作,并通过groups属性关联了两个基本用户组:内部用户组和外部用户组。groups属性也是menu级联定义法中的一种简化写法,实际上,ir_ui_menu模型有一个many2many字段groups_id,odoo引擎在加载这段record时,会将groups属性解析为在ir_ui_menu模型的groups_id字段执行eval="[(6,0,[ref('base.group_user'),ref('base.group_public')])]",从而为菜单关联用户组,而many2many关联的中间表则为ir_ui_menu_group_rel。
回到上图看一下,大家可能已经掌握了这个套路,无论是菜单、动作、视图,都是通过这种方式来建立与用户组的关联,只是动作和视图没有类似menu这样的简写法,需要明确通过eval的方法来建立关联。后文将会看到。
当然了,这段菜单定义中引用到了三个action和两个自定义用户组:group_saler和group_manager,这意味着要将这些action和用户组的定义写在菜单定义之前,因为odoo是按序加载xml数据的。用户组的定义昨天已经介绍了,紧接着介绍如何定义action。
1.2 动作
动作起到了中间桥梁的作用,一头挑起菜单,一头挑起视图。而动作和视图之间的关联关系又是通过ir_act_window_view这张中间表来保存。虽然在ir_act_window模型中也有一个view_id字段直接建立动作与视图的关联关系,但该字段是一个many2one字段,只能关联一张视图,若对某动作定义了多种视图类型(如view_mode字段里包括了列表视图、Form视图、看板视图等),则无法手动一一指定与视图的关联关系。为了避免odoo自动加载视图时可能造成的混乱,我们可以通过ir_act_window_view来手动为动作和视图一一建立关联,这是本人认为的较好的odoo编程习惯。
以下代码中,第一条记录为房产模型建立了一个动作(通过res_model字段关联到房产模型),后面两个记录为该动作分别关联了两个视图。
第一条记录通过view_mode指定了该动作将关联tree和form两种视图类型,若不自定义视图,odoo会给该动作关联tree和form类型的默认视图。若自定义视图,odoo会按视图类型和优先级(sequence)自动加载,也可以通过后面的两个ir.actions.act_window.view记录手动指定关联。这里与权限相关的有两个字段:domain字段和groups_id字段。groups_id字段的含义1.2节中已经介绍了。关于domain字段,可以用来给设置过滤,甚至可以通过重写模型的函数来更自由地进行记录过滤。下文我们来探究一下这个domain字段的用法。
<record id="action_menu_advertisements" model="ir.actions.act_window">
<field name='name'>Advertisement</field>
<field name="res_model">estate.estate_property</field>
<field name='type'>ir.actions.act_window</field>
<field name='domain'>[('advertisement','=',True)]</field>
<field name="view_mode">tree,form</field>
<field name="groups_id" eval="[(6,0,[ref('base.group_user'),ref('base.group_public')])]"/>
</record>
<record id='actionview_menu_advertisements_tree_properties' model='ir.actions.act_window.view'>
<field name='view_mode'>tree</field>
<field name='act_window_id' ref='action_menu_advertisements'/>
<field name='view_id' ref='view_tree_properties'/>
</record>
<record id='actionview_menu_advertisements_form_property' model='ir.actions.act_window.view'>
<field name='view_mode'>form</field>
<field name='act_window_id' ref='action_menu_advertisements'/>
<field name='view_id' ref='view_form_property'/>
</record>
action的domain字段用法:
- 行过滤
- _where_calc()方法重载
行过滤
上面代码中domain字段值为[('advertisement','=',True)],目的是在打开的页面中过滤出advertisement字段为True的行。其中可以指定模型的任意字段作为过滤条件,还可以通过多个字段的条件组合来进行组合过滤。
这里的domain字段还有一个常用的过滤规则,即只过滤出由用户本人所创建的记录,可以这么写[('create_uid','=',uid)],注意这里的uid是一个前端环境变量,不能使用env、self等后端环境变量。如下图所示,跟踪调试了前端js代码,可以看到此时前端环境变量中可以获取到uid字段值。
_where_calc()方法重载
这里的domin字段是由前端向后端发起请求时附带的过滤条件,后端接受到请求后会通过模型的_where_calc()方法将domain过滤条件转化为搜索条件。明白了这个调用过程,我们就可以通过domain字段做更多过滤控制。比如下面这段代码,我重写了房产模型的_where_calc()方法,通过domain给_where_calc()传递控制信息:['is_saler','=',True],若检测到这条控制信息,则重写domain过滤规则,按模型字段进行更自由的条件搜索。这里实际上是实现了类似前文所介绍的[('create_uid','=',uid)]过滤效果,相信读者读到这里也明白,重写_where_calc()的作用远不至于此。
@api.model
def _where_calc(self, domain, active_test=True):
"""Computes the WHERE clause needed to implement an OpenERP domain.
:param domain: the domain to compute
:type domain: list
:param active_test: whether the default filtering of records with ``active``
field set to ``False`` should be applied.
:return: the query expressing the given domain as provided in domain
:rtype: osv.query.Query
"""
userid=self.env.uid
print('当前用户为:%d'%userid)
print(domain)
if len(domain)!=0 and domain[0][0]=='is_saler':
domain[0][0]='create_uid'
domain[0][1]='='
domain[0][2]=userid
print(domain)
return super()._where_calc(domain,active_test)
除了前面给出的advertisement动作,还有两个菜单动作也一并给出,它们定义了My Propertites和All Propertites菜单对应的动作,这些动作也各自关联了各自的视图。
action_menu_property_list_saler动作关联了自定义用户组group_saler,且其domain用到了前文所讲的过滤由本人所创建记录的方法。被注释掉的domain可以配合上面那段重写的_where_calc()方法达到一样的效果。
action_menu_property_list_manager动作则关联了自定义用户组group_manager。在昨天的组定义中,group_manager是继承于group_saler,group_saler继承于base.group_user。因此,属于group_saler组的成员也具有advertisement动作的权限,而属于group_manager的成员也自动拥有group_saler组的所有权限。
<record id="action_menu_property_list_saler" model="ir.actions.act_window">
<field name='name'>My Properties</field>
<field name="res_model">estate.estate_property</field>
<field name='type'>ir.actions.act_window</field>
<field name="view_mode">tree,form</field>
<field name="groups_id" eval="[(6,0,[ref('group_saler')])]"/>
<!-- <field name='domain'>[('is_saler','=',True)]</field> -->
<field name='domain'>[('create_uid','=',uid)]</field>
</record>
<record id='actionview_menu_property_list_saler_tree_properties_saler' model='ir.actions.act_window.view'>
<field name='view_mode'>tree</field>
<field name='act_window_id' ref='action_menu_property_list_saler'/>
<field name='view_id' ref='view_tree_properties_saler'/>
</record>
<record id='actionview_menu_property_list_saler_form_property_saler' model='ir.actions.act_window.view'>
<field name='view_mode'>form</field>
<field name='act_window_id' ref='action_menu_property_list_saler'/>
<field name='view_id' ref='view_form_property_saler'/>
</record>
<record id="action_menu_property_list_manager" model="ir.actions.act_window">
<field name='name'>All Properties</field>
<field name="res_model">estate.estate_property</field>
<field name='type'>ir.actions.act_window</field>
<field name="view_mode">tree,form</field>
<field name="groups_id" eval="[(6,0,[ref('group_manager')])]"/>
</record>
<record id='actionview_menu_property_list_manager_tree_properties_manager' model='ir.actions.act_window.view'>
<field name='view_mode'>tree</field>
<field name='act_window_id' ref='action_menu_property_list_manager'/>
<field name='view_id' ref='view_tree_properties_manager'/>
</record>
<record id='actionview_menu_property_list_manager_form_property_manager' model='ir.actions.act_window.view'>
<field name='view_mode'>form</field>
<field name='act_window_id' ref='action_menu_property_list_manager'/>
<field name='view_id' ref='view_form_property_manager'/>
</record>
1.3 视图和视图字段
前文在介绍动作和视图关联时已经涉及到了三组共6个视图,它们分别与三个动作相关联的tree和form视图。再次强调,前文的动作记录引用了这里将要介绍的视图记录,那在实际代码中,这些视图记录一定要在前文的动作记录加载前加载。这些视图记录可以按三个组分别放置到三个xml文件中,并放置在模块的view目录下。比如我将前文的menu、action记录放置在/view/menu.xml文件中,将Advertisement视图记录放置在/view/view_public.xml文件中,将My Properties视图记录放置在/view/view_saler.xml文件中,将All Properties视图记录放置在/view/view_manager.xml文件中。下面分别给出这三个文件的内容。这些视图记录都有一个groups_id字段,用于关联用户组,与前文menu和action中的用法一致。
1.3.1 /view/view_public.xml
view_public.xml中的两个视图被关联到最基础的内部用户组base.group_user和外部用户组base.group_public,从而可以让其中列出的房产广告开放给内部用户或者未来通过门户网站登录的外部用户查看。在tree视图和form视图定义时,注意create="0"和edit="0"这两个属性,添加了这两个属性,则打开的页面是只读,没有修改和新建按钮。
<odoo>
<record id="view_tree_properties" model='ir.ui.view'>
<field name='name'>estate.view.tree.properties</field>
<field name='model'>estate.estate_property</field>
<field name='groups_id' eval="[(6,0,[ref('base.group_public'),ref('base.group_user')])]"/>
<field name='arch' type='xml'>
<tree string='Properties' create="0" edit="0">
<field name="name"/>
<field name="description"/>
<field name="date_availability"/>
<field name="expected_price"/>
</tree>
</field>
</record>
<record id="view_form_property" model='ir.ui.view'>
<field name='name'>estate.view.form.property</field>
<field name='model'>estate.estate_property</field>
<field name='groups_id' eval="[(6,0,[ref('base.group_public'),ref('base.group_user')])]"/>
<field name='arch' type='xml'>
<form string='Property' create="0" edit="0">
<sheet>
<group>
<field name='name'/>
<field name='description'/>
</group>
<notebook>
<page string='Proprity Detail'>
<group col='4'>
<field name="postcode"/>
<field name="bedrooms"/>
<field name="living_area"/>
<field name="facades"/>
<field name="garage"/>
<field name="garden"/>
<field name="garden_area"/>
<field name="garden_orientation"/>
</group>
</page>
<page string='Sales Information'>
<group col='4'>
<field name="date_availability"/>
<field name="expected_price"/>
<field name="selling_price"/>
</group>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
</odoo>
1.3.2 /view/view_saler.xml
这是列出销售员或经理自己所创建的房产记录视图,其关联到group_saler用户组,group_manager继承于group_saler,也自动包含了该视图的权限。在tree视图中,增加了一个advertisement字段,并且设置了invisible隐藏。这样做的目的是为<tree>的属性decoration-bf="advertisement==True"提供判断字段。decoration-bf是odoo用来设置tree行颜色的css属性,一共有如下几个。我这里让所有已经设置为广告的记录加粗显示,以做甄别。与前面的广告视图不同的是,这两个视图都可编辑、新增、修改和删除。
视图字段权限控制
值得注意的是form视图定义中,有一个字段<field name="advertisement" groups="estate.group_manager"/>,这是在视图上进行字段级别的权限控制。后文还要讲模型级字段权限控制。
<odoo>
<record id="view_tree_properties_saler" model='ir.ui.view'>
<field name='name'>estate.view.tree.properties.saler</field>
<field name='model'>estate.estate_property</field>
<field name='groups_id' eval="[(6,0,[ref('group_saler')])]"/>
<field name='arch' type='xml'>
<tree string='Proprities' decoration-bf="advertisement==True">
<field name="name"/>
<field name="description"/>
<field name="date_availability"/>
<field name="expected_price"/>
<field name="advertisement" invisible="True"/>
</tree>
</field>
</record>
<record id="view_form_property_saler" model='ir.ui.view'>
<field name='name'>estate.view.form.property.saler</field>
<field name='model'>estate.estate_property</field>
<field name='groups_id' eval="[(6,0,[ref('group_saler')])]"/>
<field name='arch' type='xml'>
<form>
<sheet>
<group>
<field name='name'/>
<field name='description'/>
</group>
<notebook>
<page string='Proprity Detail'>
<group col='4'>
<field name="postcode"/>
<field name="bedrooms"/>
<field name="living_area"/>
<field name="facades"/>
<field name="garage"/>
<field name="garden"/>
<field name="garden_area"/>
<field name="garden_orientation"/>
</group>
</page>
<page string='Sales Information'>
<group col='4'>
<field name="advertisement" groups="estate.group_manager"/>
<field name="date_availability"/>
<field name="expected_price"/>
<field name="selling_price"/>
</group>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
</odoo>
1.3.3 /view/view_manager.xml**
这个视图直接继承自view_saler,用户组关联到group_manager。如果需要对继承的视图进行修改,可以通过xpath语法定位锚点,并在锚点的前、后、属性或内部进行内容增加、修改或移除。xpath的语法简单易用,可参考官方手册。
<odoo>
<record id="view_tree_properties_manager" model='ir.ui.view'>
<field name='name'>estate.view.tree.properties.manager</field>
<field name='model'>estate.estate_property</field>
<field name='groups_id' eval="[(6,0,[ref('group_manager')])]"/>
<field name='inherit_id' ref='view_tree_properties_saler'/>
</record>
<record id="view_form_property_manager" model='ir.ui.view'>
<field name='name'>estate.view.form.property.manager</field>
<field name='model'>estate.estate_property</field>
<field name='groups_id' eval="[(6,0,[ref('group_manager')])]"/>
<field name='inherit_id' ref='view_form_property_saler'/>
</record>
</odoo>
至此,我们定义了菜单、动作、视图,为它们都指定了用户组,并在动作记录的domain字段进行了行级过滤。
2. 模型级权限控制
菜单、动作、视图、视图字段的权限控制仅对登录odoo系统的用户有效,若通过XML RPC或者JSON RPC直接调用odoo控制器,则需要通过模型级的权限控制来管理。模型级的权限控制包括了模型、行、字段三个层次的控制粒度,下面将分别介绍。
与模型级的权限控制相关的核心模型如下。
- ir_model_access表中记录了odoo中模型与用户组的关联关系,并通过perm_read、perm_write、perm_create、perm_unlink来设置关联用户组对模型的读改增删权限;
- ir_model_fields_group_rel表中记录了具体某个字段的操作权限,并关联到用户组。
- ir_rule表中有一个特殊字段domain_force,也是使用前文介绍的domain语法,对关联模型进行行级过滤,为所过滤出来的记录关联用户组,与模型权限和字段权限稍有差别,行权限的具体设置并不在rule_group_rel这张关联表中,而是直接写在行规则表ir_rule中。
2.1 模型权限控制
模型权限控制是粒度最粗的权限控制机制,因此往往在这个粒度上会尽可能多给权限,为更细粒度的权限控制留下余地。模型权限一般通过csv格式的文件导入系统。csv适用于导入大量简单结构的数据,比xml文件结构简洁,每行就是一条数据,可以与excel表格或数据库表直接对应。odoo用csv导入数据时,文件名即为数据所要导入模型的名字,因此模型权限控制文件以ir.model.access.csv命名,放在模块security目录下,并在__manifest.py__中注册。
该文件的表头是固定的(第一行),对应了ir.model.access模型的字段。其中id字段是外部标识符,name可以与id一致(实际应用中没有什么用),model_id:id和group_id:id是指ir.model.access模型的model_id字段和group_id字段,这两个字段都是many2one类型,这里直接填写model的外部标识符和group的XML标识符就可以,odoo会对其自动解析为内部id。值得注意的是,model的外部标识符是在定义model时按一定规则对_name字段的扩展,比如我定义的房产模型 _name="estate.estate_property",其对应的外部标识符为:model_estate_estate_property,将_name中的.改为下划线,并添加model前缀。group的XML标识符比较简单,直接就是定义组的XML文件中record的id属性。perm_read、perm_write、perm_create、perm_unlink则分别表示读改增删四种操作的权限。
下面的这个csv文件定义了base.group_public、base.group_user、estate.group_saler和estate.group_manager这四个用户组对房产模型estate.estate_property的访问权限。其中,base.group_public和base.group_user只拥有对模型的读权限,estate.group_saler和estate.group_manager拥有对模型的所有权限。
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_estate_estate_property_public,access_estate_estate_property_public,model_estate_estate_property,base.group_public,1,0,0,0
access_estate_estate_property_user,access_estate_estate_property_user,model_estate_estate_property,base.group_user,1,0,0,0
access_estate_estate_property_saler,access_estate_estate_property_saler,model_estate_estate_property,estate.group_saler,1,1,1,1
access_estate_estate_property_manager,access_estate_estate_property_manager,model_estate_estate_property,estate.group_manager,1,1,1,1
2.2 行权限控制
行权限控制在模型权限控制的基础上提供了粒度更细的规则控制,可以通过规则从模型中过滤出特定行,并将这些行的权限关联到用户组。
如下代码,第一个rule record为group_saler用户组定义了行级过滤规则,销售员需要看到所有自己创建的记录,且还要能看到别人设置为广告的记录,因此使用['|',('create_uid','=', user.id),('advertisement','=',True)]来进行行过滤,这是domain的两条规则相或的写法。
由于group_manager继承自group_saler,但经理应该能看到所有的记录,因此需要取消对group_manager的过滤规则,在第二个rule record中通过一个恒等的domain_force覆盖了继承的过滤规则。
值得注意
domain_force中可以使用user.id来获得当前登录用户id号,在前文提到的通过action的domain字段进行行过滤时,只能通过uid获得当前登录用户id号,这涉及到后台和前台的不同机制,注意甄别,调试程序时发生相关错误记得回来再看看。
<!-- 行级权限 -->
<record model="ir.rule" id="rule_estate_property_saler">
<field name="name">estate property rule for saler</field>
<field name="model_id" ref="model_estate_estate_property"/>
<field name="domain_force">['|',('create_uid','=', user.id),('advertisement','=',True)]</field>
<field name="groups" eval="[(6,0,[ref('group_saler')])]"/>
</record>
<record model="ir.rule" id="rule_estate_property_manager">
<field name="name">estate property rule for saler</field>
<field name="model_id" ref="model_estate_estate_property"/>
<field name="domain_force">[(1,'=', 1)]</field>
<field name="groups" eval="[(6,0,[ref('group_manager')])]"/>
</record>
2.3 字段权限控制
区别于前文提到的视图级字段控制,这里的字段控制是在模型层面,在定义模型的python代码中直接为模型字段分配用户组。比如,房产模型中有一个字段为:
postcode=fields.Char(groups="estate.group_manager")
这样就为该字段分配了group_manager用户组,如果有多个用户组,之间用逗号隔开,且这里要带上模块名前缀。有了模型级的字段权限控制,即使视图中包含了该字段,未授权的用户组依然无法看到该字段,通过XML RPC调用,也没有对该字段的访问权限。
我层对odoo的这个模型级字段权限控制存有疑问:如果这里要用到数据文件中定义的用户组,那在安装模块时应该先导入数据文件。但矛盾又出现了,数据文件中往往会有对模型的引用,在模型还未在数据库中建立时,又如何向数据库导入数据文件呢?这似乎是个死锁。
请教了朋友,他也理解了我的这个疑问,并给出了使用两个模块的解决方案,第一个模块只用来导入用户组数据,第二个模块依赖第一个模块,这样就可以在安装第二个模块时提前通过第一个模块导入用户组数据。唯一的瑕疵是在第二个模块中使用自定义用户组时,需要带上第一个模块的名字作为前缀。
朋友又做了进一步研究,通过查看odoo自带模块的写法,发现不需要通过两个模块的方式,odoo在模型定义中对字段添加用户组可以直接引用数据文件中的XML ID。这个底层机制是什么呢,难道odoo在安装模块前会先导入用户组数据?
其实并不是这样的,odoo的模型级字段控制与其他权限实现机制不太一样。按理说,ir.model.fields模型上这个groups字段是many2many类型,我在模型字段上定义了groups,则中间表ir_model_fields_group_rel应该会建立many2many的关联。然而,这张关联表是空的,不仅没有我定义的字段关联组数据,odoo自带模块中很多定义了模型级字段权限的模型都没有在这张表中出现关联数据。再打开Field类,也没有通过构造函数将groups字段值写入数据库的操作。暂时只能这样认为:模型字段上的groups应该是在CRUD操作时实时解析,可能就是为了避免前面提到的安装模块和导入数据文件存在死锁的问题。
这段内容不理解也没关系,总之不管其底层实现原理,模型级字段权限控制用起来很容易。
3. 总结
至此,关于odoo权限控制的原理就讲完了。再总结一下:
- 组类、组和用户的关联关系是odoo权限控制的基础;
- odoo权限控制分为视图级和模型级两类;
- 视图级权限控制包括菜单、动作、视图、action中domain过滤的行、视图级字段的权限控制,这些只能在用户登录odoo页面系统才能起作用,对于RPC调用不起作用;
- 模型级权限控制包括模型、ir.rule中domain_force过滤的行、字段的权限控制,这类控制比视图级控制更底层,对RPC调用也能起控制作用;
- 注意区分action和ir.rule中domain使用的区别。