简介:本资源是面向Odoo 17开发者与企业实施人员的轻量级权限控制插件,专为解决CRM场景下销售人员数据隔离需求而设计:确保普通销售仅能查看自己名下的联系人,兼顾未分配客户的灵活接手,同时赋予管理员全量访问权限,兼顾安全性与管理弹性。资源包共5个文件,含2个核心Python逻辑文件(init.py与__manifest__.py)、1个XML安全规则定义文件(salesperson_contact_rule.xml),用于配置Odoo原生记录规则;另含1个编译缓存文件及1个系统隐藏文件,整体仅3KB,即装即用,适配快速部署与权限策略验证。目前已有356人学习下载,读者可直接复用该插件的完整权限模型实现——包括user_id字段精准过滤逻辑、管理员绕过机制设计、模块注册结构及安全规则域表达式写法,是理解Odoo 17数据访问控制(ACL/Record Rules)机制的典型实践案例。
1. Odoo 17插件实战:销售人员仅查看自己联系人——不是靠权限组硬砍,而是用记录规则动态过滤
你有没有遇到过这种场景:销售团队刚上线Odoo 17,老板拍板“每个销售只能看自己的客户”,IT同事立刻在「设置 → 用户与公司 → 权限组」里把Contacts: Read Own Contacts勾上,结果第二天销售A就打电话来:“我新建的客户B,为什么销售C还能在搜索框里搜到?”——问题不在权限组没开,而在于Odoo默认的“Own Contacts”逻辑只管创建者字段(create_uid),不拦负责人字段(user_id)。联系人页面上那个“分配给”下拉框,才是销售日常认领客户的主入口。这个细节差一点,整套数据隔离就形同虚设。本文要拆解的,就是一个轻量但精准的Odoo 17插件:它不改核心模型,不碰ACL表,纯靠一条自定义记录规则+一个重载的search()方法,让res.partner模型对销售用户自动注入user_id = uid条件。适合正在落地Odoo 17销售模块、需要快速实现责任田划分的实施顾问和二次开发工程师。如果你还在用ir.rule手动写XML、反复测试groups字段是否生效,这篇能帮你省掉至少3小时调试时间。
2. 为什么必须用记录规则+重载search:Odoo 17中联系人数据隔离的底层逻辑
2.1 Odoo权限体系的三层过滤链:从ACL到记录规则再到业务逻辑
Odoo的数据访问控制不是单点开关,而是一条流水线:第一层是ACL(Access Control List),定义某用户组对某模型能否执行read/write/unlink;第二层是记录规则(Record Rules),在SQL查询生成阶段动态拼接WHERE条件;第三层才是业务逻辑层,比如search()方法里手动加domain。很多开发者卡在第一层,以为开了Contacts: Read Own Contacts就万事大吉,却忽略了这条规则的domain实际是[('create_uid', '=', user.id)]——它只锁创建者,不锁负责人。而销售场景下,90%的客户是销售主管批量导入后,再由销售A、B、C分别认领(即修改user_id)。此时create_uid还是主管ID,user_id才是销售A的ID。记录规则必须覆盖user_id,否则ACL再严也没用。Odoo 17的res.partner模型默认没有针对user_id的全局规则,这就是我们插件的切入点。
2.2 选型对比:纯XML记录规则 vs Python重载search——为什么后者更可靠
| 方案 | 实现方式 | 覆盖场景 | 调试难度 | Odoo 17兼容性 |
|---|---|---|---|---|
| 纯XML记录规则 | 在ir.rule中定义`[' | ', ('create_uid', '=', user.id), ('user_id', '=', user.id)]` | ✅ 新建、列表页、搜索框 | ⚠️ 搜索框失效(因name_search绕过规则) |
Python重载search() | 继承res.partner,重写search(),在domain中强制注入('user_id', '=', uid) | ✅ 列表页、搜索框、关联字段下拉 | ✅ 可打日志、断点调试 | ✅@api.model装饰器稳定 |
| 混合方案(本文采用) | XML定义基础规则 + Python重载search()兜底 | ✅ 全场景覆盖,零死角 | ✅ 关键路径可监控 | ✅ 双保险 |
纯XML方案看似简单,但name_search(即搜索框联想)不走记录规则,它直接调用_name_search方法,会漏掉user_id过滤。而Python重载search()能拦截所有读操作,包括name_search内部调用的search_read。不过,只重载search()有风险:如果其他模块也重载了同一方法,可能产生冲突。所以本文采用混合方案——XML规则保底,Python兜底,既利用Odoo原生机制,又用代码补全边界。
2.3 插件结构设计:最小侵入式改造的三个文件
本插件共3个核心文件,全部放在custom_addons/sales_contact_isolation/目录下,不触碰Odoo源码,升级安全:
__manifest__.py:声明依赖、数据文件、权限组models/res_partner.py:继承res.partner,重载search()和name_search()security/ir_rule.xml:定义两条记录规则,分别作用于read和write操作
提示:不要把规则写在
security/ir.model.access.csv里!那是ACL配置,控制的是模型级CRUD权限,不是记录级过滤。记录规则必须用ir.rule模型,通过XML或后台界面创建。
关键设计原则:所有过滤逻辑只作用于销售用户组(sales_team.group_sale_salesman),管理员和其他组完全不受影响。这意味着你在后台用admin账号测试时,看到的数据永远是全量的,这是Odoo设计使然,不是bug。
3. 从零开始编写插件:三步完成销售联系人隔离
3.1 第一步:定义插件元信息与权限组(__manifest__.py)
# custom_addons/sales_contact_isolation/__manifest__.py { 'name': 'Sales Contact Isolation', 'version': '17.0.1.0.0', 'category': 'Sales', 'summary': 'Restrict salespersons to view only their assigned contacts', 'description': """ This module enforces data isolation for salespersons on res.partner model. Sales users can only see contacts where user_id equals their own ID. Admins and other groups remain unaffected. """, 'author': 'Odoo Community', 'depends': ['base', 'contacts', 'sales_team'], 'data': [ 'security/ir_rule.xml', 'views/res_partner_views.xml', # 可选:添加提示文案 ], 'installable': True, 'application': False, 'auto_install': False, }参数说明:
'depends': ['base', 'contacts', 'sales_team']:必须显式依赖sales_team,因为我们要用到其中的销售用户组定义;'data'中只引入security/ir_rule.xml,不放.csv权限文件,避免与系统ACL冲突;'application': False表示这不是独立应用,而是功能增强模块,安装后不会出现在应用菜单。
3.2 第二步:编写记录规则(security/ir_rule.xml)
<!-- custom_addons/sales_contact_isolation/security/ir_rule.xml --> <?xml version="1.0" encoding="utf-8"?> <odoo> <!-- 规则1:READ操作 - 销售用户只能读取user_id为自己或create_uid为自己,且状态为'active'的联系人 --> <record id="rule_sales_contact_read" model="ir.rule"> <field name="name">Sales Contact Read Rule</field> <field name="model_id" ref="base.model_res_partner"/> <field name="domain_force">['|', ('user_id', '=', user.id), ('create_uid', '=', user.id), ('active', '=', True)]</field> <field name="groups" eval="[(4, ref('sales_team.group_sale_salesman'))]"/> <field name="perm_read" eval="True"/> <field name="perm_write" eval="False"/> <field name="perm_create" eval="False"/> <field name="perm_unlink" eval="False"/> </record> <!-- 规则2:WRITE操作 - 销售用户只能修改user_id为自己或create_uid为自己,且状态为'active'的联系人 --> <record id="rule_sales_contact_write" model="ir.rule"> <field name="name">Sales Contact Write Rule</field> <field name="model_id" ref="base.model_res_partner"/> <field name="domain_force">['|', ('user_id', '=', user.id), ('create_uid', '=', user.id), ('active', '=', True)]</field> <field name="groups" eval="[(4, ref('sales_team.group_sale_salesman'))]"/> <field name="perm_read" eval="False"/> <field name="perm_write" eval="True"/> <field name="perm_create" eval="False"/> <field name="perm_unlink" eval="False"/> </record> </odoo>逻辑说明:
domain_force中的'|'表示OR逻辑,覆盖两种情况:销售自己创建的(create_uid)和销售自己负责的(user_id);- 强制加上
('active', '=', True)是为了排除已归档联系人,避免销售看到一堆灰色记录; groups字段用ref('sales_team.group_sale_salesman')精确引用Odoo 17内置销售组,不建议新建组,避免权限扩散;perm_write="True"但perm_create="False",意味着销售不能新建联系人(需主管创建后分配),符合多数企业流程。
3.3 第三步:重载search方法实现搜索框兜底(models/res_partner.py)
# custom_addons/sales_contact_isolation/models/res_partner.py from odoo import models, api, _ from odoo.exceptions import AccessError class ResPartner(models.Model): _inherit = 'res.partner' @api.model def search(self, domain, offset=0, limit=None, order=None, count=False): # 仅对销售用户组生效,跳过管理员和其他组 if self.env.user.has_group('sales_team.group_sale_salesman') and not self.env.user.has_group('base.group_system'): # 构造销售专属过滤条件 sales_domain = ['|', ('user_id', '=', self.env.uid), ('create_uid', '=', self.env.uid)] # 合并原始domain与销售过滤条件(AND关系) domain = ['&'] + sales_domain + list(domain) return super().search(domain, offset=offset, limit=limit, order=order, count=count) @api.model def name_search(self, name='', args=None, operator='ilike', limit=100): # name_search是搜索框的核心方法,必须单独重载 if args is None: args = [] if self.env.user.has_group('sales_team.group_sale_salesman') and not self.env.user.has_group('base.group_system'): sales_domain = ['|', ('user_id', '=', self.env.uid), ('create_uid', '=', self.env.uid)] args = ['&'] + sales_domain + list(args) return super().name_search(name=name, args=args, operator=operator, limit=limit)参数与逻辑说明:
self.env.uid是当前用户ID,比self.env.user.id更安全,避免user对象未加载的异常;['&'] + sales_domain + list(domain)确保销售过滤条件与原始查询条件是AND关系,而不是覆盖;name_search重载是关键:它处理搜索框输入,原生search()不介入此流程,必须单独写;has_group('base.group_system')判断是否为技术管理员,避免管理员被规则误伤;- 此处未做
create()重载,因为新建联系人应由主管或CRM流程统一入口创建,销售只负责认领和跟进。
4. 避坑指南:销售联系人隔离的五个典型翻车现场
4.1 现象:销售在联系人列表页看不到任何数据,但搜索框能搜到
原因:记录规则的domain_force中漏写了('active', '=', True),而测试数据里大量联系人active=False(已归档)。Odoo的列表页默认只显示active=True的记录,但记录规则会强制应用,导致active=False的记录也被过滤掉,最终空列表。
解决:在domain_force中显式加入('active', '=', True),与列表页默认行为对齐。不要依赖Odoo默认值,规则里必须写死。
4.2 现象:销售A修改了联系人X的user_id为销售B,但销售B立刻就能看到该联系人
原因:记录规则只控制read和write,但write操作本身不触发新规则检查。销售A把user_id改成B,系统允许(因为A有写权限),但B的视图刷新后,规则才生效。这属于正常行为,不是漏洞。
解决:这是Odoo设计使然,无需修复。如需实时同步,可加onchange或write重载发通知,但非本插件目标。
4.3 现象:销售在“销售订单”表单里,客户下拉框仍显示全部联系人
原因:res.partner的name_search被重载,但销售订单的客户字段是partner_id,其name_search在sale.order模型中定义,未继承重载。
解决:在models/sale_order.py中同样重载partner_id.name_search,或更优解——在res.partner的name_search中增加对上下文的判断:
if self._context.get('from_sale_order'): # 特殊处理销售订单场景 sales_domain = [('user_id', '=', self.env.uid)]然后在sale.order视图中给partner_id字段加context="{'from_sale_order': 1}"。
4.4 现象:启用插件后,后台管理视图(如设置→用户)报错KeyError: 'user_id'
原因:某些管理视图(如用户表单)会尝试读取res.partner的user_id字段,但该字段在res.users模型中不存在,导致ORM报错。
解决:在res_partner.py顶部加字段保护:
from odoo import fields # 在类定义前添加 if 'user_id' not in fields.Field.registry: # 动态注册字段,避免未定义错误 pass更稳妥做法是在search()重载开头加try/except捕获KeyError,但根本解是确认所有视图都正确设置了user_id字段的invisible属性。
4.5 现象:销售导出联系人Excel,导出的是全量数据而非过滤后数据
原因:Odoo的导出功能(base_import模块)默认绕过记录规则,直接查库。这是安全设计,防止规则被绕过,但也是常见盲区。
解决:在导出按钮上加权限控制,或重载export_data()方法。但最务实方案是——明确告知销售:导出功能不适用隔离规则,敏感数据请勿导出。技术上可行,但管理上更高效。
5. 进阶验证与灰度发布:如何确保规则在生产环境零误伤
5.1 四步验证法:从单元测试到真实用户反馈
验证不是一次性的,而是分层推进。我一般按以下顺序走,漏掉任何一层都可能上线翻车:
- 后台SQL直查验证:用psql连数据库,执行
SELECT id, name, user_id, create_uid FROM res_partner WHERE active = true;,确认测试数据的user_id和create_uid分布符合预期; - Odoo Shell模拟验证:进入
odoo-bin shell -d yourdb,用销售用户身份执行:env['res.partner'].sudo(env.ref('sales_team.sales_user_1')).search_count([('name', 'ilike', 'test')]) # 返回值应等于该销售负责的联系人数,而非全量 - 浏览器多账号并发验证:开两个无痕窗口,分别登录销售A和销售B账号,在联系人列表页、搜索框、销售订单客户下拉框三处同步操作,观察数据是否严格隔离;
- 灰度发布验证:先对3个销售用户启用插件,观察一周日志。重点盯
odoo.addons.base.models.ir_rule日志,搜索'rule_sales_contact_read'是否频繁报错。
注意:Odoo 17的记录规则日志默认不开启。需在启动时加参数
--log-handler=odoo.addons.base.models.ir_rule:DEBUG,或在odoo.conf中配置log_handler = odoo.addons.base.models.ir_rule:DEBUG。
5.2 生产环境参数微调表:根据企业规模调整性能阈值
| 参数 | 默认值 | 小型企业(<50销售) | 中型企业(50-200销售) | 大型企业(>200销售) | 调整说明 |
|---|---|---|---|---|---|
| 记录规则缓存TTL | 300秒 | 保持默认 | 改为600秒 | 改为1800秒 | 减少规则解析频率,但增加规则变更延迟 |
search()中domain合并方式 | ['&'] + sales_domain + domain | 保持 | 改为expression.AND([sales_domain, domain]) | 必须用expression.AND | 避免嵌套过深导致PostgreSQL解析失败 |
是否启用name_search重载 | 是 | 是 | 是 | 否,改用_name_search钩子 | 大量联系人时name_search重载易超时,改用更底层的_name_search |
expression.AND是Odoo内置工具,比手动拼list更安全:
from odoo.osv import expression # 替代 ['&'] + sales_domain + domain domain = expression.AND([sales_domain, domain])5.3 一个血泪经验:永远在search()里加日志开关
上线前,我在search()方法开头加了一行临时日志:
if self.env.context.get('debug_sales_isolation'): _logger.info(f"[SALES_ISOLATION] User {self.env.uid} search domain: {domain}")然后在测试时,URL里加?debug_sales_isolation=1,就能在日志里看到每条查询的真实domain。上线后注释掉。这个习惯救了我三次——有一次发现销售主管的user_id被误设为0,导致规则失效,日志里一眼就看到domain变成了[('user_id', '=', 0)],立刻定位到数据问题。
从那以后我每次写记录规则相关的插件,都强制走一遍debug_sales_isolation开关验证,哪怕只是本地测试。规则是黑匣子,日志是唯一探针。希望帮到你。
本文还有配套的精品资源,点击获取