Browse Source
Merge pull request #1144 from hbrunn/10.0-users_ldap_groups
Merge pull request #1144 from hbrunn/10.0-users_ldap_groups
[10.0][MIG] users_ldap_groupspull/1231/head
Daniel Reis
7 years ago
committed by
GitHub
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 279 additions and 224 deletions
-
36users_ldap_groups/README.rst
-
24users_ldap_groups/__init__.py
-
38users_ldap_groups/__manifest__.py
-
7users_ldap_groups/models/__init__.py
-
47users_ldap_groups/models/res_company_ldap.py
-
33users_ldap_groups/models/res_company_ldap_group_mapping.py
-
43users_ldap_groups/models/res_company_ldap_operator.py
-
27users_ldap_groups/models/res_users.py
-
4users_ldap_groups/tests/__init__.py
-
65users_ldap_groups/tests/test_users_ldap_groups.py
-
128users_ldap_groups/users_ldap_groups.py
-
27users_ldap_groups/users_ldap_groups.xml
-
24users_ldap_groups/views/base_config_settings.xml
@ -1,22 +1,4 @@ |
|||||
# -*- coding: utf-8 -*- |
# -*- coding: utf-8 -*- |
||||
############################################################################## |
|
||||
# |
|
||||
# OpenERP, Open Source Management Solution |
|
||||
# This module copyright (C) 2012 Therp BV (<http://therp.nl>). |
|
||||
# |
|
||||
# This program is free software: you can redistribute it and/or modify |
|
||||
# it under the terms of the GNU Affero General Public License as |
|
||||
# published by the Free Software Foundation, either version 3 of the |
|
||||
# License, or (at your option) any later version. |
|
||||
# |
|
||||
# This program is distributed in the hope that it will be useful, |
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
||||
# GNU Affero General Public License for more details. |
|
||||
# |
|
||||
# You should have received a copy of the GNU Affero General Public License |
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
||||
# |
|
||||
############################################################################## |
|
||||
|
|
||||
from . import users_ldap_groups |
|
||||
|
# Copyright 2012-2018 Therp BV <https://therp.nl> |
||||
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). |
||||
|
from . import models |
@ -1,39 +1,19 @@ |
|||||
# -*- coding: utf-8 -*- |
# -*- coding: utf-8 -*- |
||||
############################################################################## |
|
||||
# |
|
||||
# OpenERP, Open Source Management Solution |
|
||||
# This module copyright (C) 2012 Therp BV (<http://therp.nl>). |
|
||||
# |
|
||||
# This program is free software: you can redistribute it and/or modify |
|
||||
# it under the terms of the GNU Affero General Public License as |
|
||||
# published by the Free Software Foundation, either version 3 of the |
|
||||
# License, or (at your option) any later version. |
|
||||
# |
|
||||
# This program is distributed in the hope that it will be useful, |
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
||||
# GNU Affero General Public License for more details. |
|
||||
# |
|
||||
# You should have received a copy of the GNU Affero General Public License |
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
||||
# |
|
||||
############################################################################## |
|
||||
|
|
||||
|
# Copyright 2012-2018 Therp BV <https://therp.nl> |
||||
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). |
||||
{ |
{ |
||||
"name": "Groups assignment", |
|
||||
"version": "8.0.1.2.0", |
|
||||
|
"name": "LDAP groups assignment", |
||||
|
"version": "10.0.0.0.0", |
||||
"depends": ["auth_ldap"], |
"depends": ["auth_ldap"], |
||||
"author": "Therp BV,Odoo Community Association (OCA)", |
|
||||
|
"author": "Therp BV, Odoo Community Association (OCA)", |
||||
"license": "AGPL-3", |
"license": "AGPL-3", |
||||
"summary": """ |
|
||||
Adds user accounts to groups based on rules defined by the administrator. |
|
||||
""", |
|
||||
"category": "Tools", |
|
||||
|
"summary": "Adds user accounts to groups based on rules defined " |
||||
|
"by the administrator.", |
||||
|
"category": "Authentication", |
||||
"data": [ |
"data": [ |
||||
'users_ldap_groups.xml', |
|
||||
|
'views/base_config_settings.xml', |
||||
'security/ir.model.access.csv', |
'security/ir.model.access.csv', |
||||
], |
], |
||||
'installable': False, |
|
||||
"external_dependencies": { |
"external_dependencies": { |
||||
'python': ['ldap'], |
'python': ['ldap'], |
||||
}, |
}, |
||||
|
@ -0,0 +1,7 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# Copyright 2012-2018 Therp BV <https://therp.nl> |
||||
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). |
||||
|
from . import res_company_ldap |
||||
|
from . import res_company_ldap_operator |
||||
|
from . import res_company_ldap_group_mapping |
||||
|
from . import res_users |
@ -0,0 +1,47 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# Copyright 2012-2018 Therp BV <https://therp.nl> |
||||
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). |
||||
|
from logging import getLogger |
||||
|
from odoo import api, fields, models |
||||
|
_logger = getLogger(__name__) |
||||
|
|
||||
|
|
||||
|
class ResCompanyLdap(models.Model): |
||||
|
_inherit = 'res.company.ldap' |
||||
|
|
||||
|
group_mapping_ids = fields.One2many( |
||||
|
'res.company.ldap.group_mapping', |
||||
|
'ldap_id', 'Group mappings', |
||||
|
help='Define how Odoo groups are assigned to ldap users', |
||||
|
) |
||||
|
only_ldap_groups = fields.Boolean( |
||||
|
'Only ldap groups', default=False, |
||||
|
help='If this is checked, manual changes to group membership are ' |
||||
|
'undone on every login (so Odoo groups are always synchronous ' |
||||
|
'with LDAP groups). If not, manually added groups are preserved.', |
||||
|
) |
||||
|
|
||||
|
@api.model |
||||
|
def get_or_create_user(self, conf, login, ldap_entry): |
||||
|
op_obj = self.env['res.company.ldap.operator'] |
||||
|
user_id = super(ResCompanyLdap, self).get_or_create_user( |
||||
|
conf, login, ldap_entry |
||||
|
) |
||||
|
if not user_id: |
||||
|
return user_id |
||||
|
this = self.browse(conf['id']) |
||||
|
user = self.env['res.users'].browse(user_id) |
||||
|
if this.only_ldap_groups: |
||||
|
_logger.debug('deleting all groups from user %d', user_id) |
||||
|
user.write({'groups_id': [(5, False, False)]}) |
||||
|
|
||||
|
for mapping in this.group_mapping_ids: |
||||
|
operator = getattr(op_obj, mapping.operator) |
||||
|
_logger.debug('checking mapping %s', mapping) |
||||
|
|
||||
|
if operator(ldap_entry, mapping): |
||||
|
_logger.debug( |
||||
|
'adding user %d to group %s', user, mapping.group_id.name, |
||||
|
) |
||||
|
user.write({'groups_id': [(4, mapping.group_id.id)]}) |
||||
|
return user_id |
@ -0,0 +1,33 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# © 2012-2018 Therp BV <https://therp.nl> |
||||
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). |
||||
|
from odoo import fields, models |
||||
|
|
||||
|
|
||||
|
class ResCompanyLdapGroupMapping(models.Model): |
||||
|
_name = 'res.company.ldap.group_mapping' |
||||
|
_rec_name = 'ldap_attribute' |
||||
|
_order = 'ldap_attribute' |
||||
|
|
||||
|
ldap_id = fields.Many2one( |
||||
|
'res.company.ldap', 'LDAP server', required=True, ondelete='cascade', |
||||
|
) |
||||
|
ldap_attribute = fields.Char( |
||||
|
'LDAP attribute', |
||||
|
help='The LDAP attribute to check.\n' |
||||
|
'For active directory, use memberOf.') |
||||
|
operator = fields.Selection( |
||||
|
lambda self: [ |
||||
|
(o, o) for o in self.env['res.company.ldap.operator'].operators() |
||||
|
], |
||||
|
'Operator', |
||||
|
help='The operator to check the attribute against the value\n' |
||||
|
'For active directory, use \'contains\'', required=True) |
||||
|
value = fields.Char( |
||||
|
'Value', |
||||
|
help='The value to check the attribute against.\n' |
||||
|
'For active directory, use the dn of the desired group', |
||||
|
required=True) |
||||
|
group_id = fields.Many2one( |
||||
|
'res.groups', 'Odoo group', oldname='group', |
||||
|
help='The Odoo group to assign', required=True) |
@ -0,0 +1,43 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# © 2012-2018 Therp BV <https://therp.nl> |
||||
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). |
||||
|
from logging import getLogger |
||||
|
from odoo import api, models |
||||
|
from string import Template |
||||
|
_logger = getLogger(__name__) |
||||
|
|
||||
|
|
||||
|
class ResCompanyLdapOperator(models.AbstractModel): |
||||
|
"""Define operators for group mappings""" |
||||
|
|
||||
|
_name = "res.company.ldap.operator" |
||||
|
_description = "Definition op LDAP operations" |
||||
|
|
||||
|
@api.model |
||||
|
def operators(self): |
||||
|
"""Return names of function to call on this model as operator""" |
||||
|
return ('contains', 'equals', 'query') |
||||
|
|
||||
|
@api.model |
||||
|
def contains(self, ldap_entry, mapping): |
||||
|
return mapping.ldap_attribute in ldap_entry[1] and \ |
||||
|
mapping.value in ldap_entry[1][mapping.ldap_attribute] |
||||
|
|
||||
|
def equals(self, ldap_entry, mapping): |
||||
|
return mapping.ldap_attribute in ldap_entry[1] and \ |
||||
|
unicode(mapping.value) == unicode( |
||||
|
ldap_entry[1][mapping.ldap_attribute] |
||||
|
) |
||||
|
|
||||
|
def query(self, ldap_entry, mapping): |
||||
|
query_string = Template(mapping.value).safe_substitute({ |
||||
|
attr: ldap_entry[1][attr][0] for attr in ldap_entry[1] |
||||
|
}) |
||||
|
_logger.debug( |
||||
|
'evaluating query group mapping, filter: %s' % query_string |
||||
|
) |
||||
|
results = mapping.ldap_id.query( |
||||
|
mapping.ldap_id.read()[0], query_string |
||||
|
) |
||||
|
_logger.debug(results) |
||||
|
return bool(results) |
@ -0,0 +1,27 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# © 2018 Therp BV <https://therp.nl> |
||||
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). |
||||
|
from odoo import SUPERUSER_ID, api, models, registry |
||||
|
|
||||
|
|
||||
|
class ResUsers(models.Model): |
||||
|
_inherit = 'res.users' |
||||
|
|
||||
|
@classmethod |
||||
|
def _login(cls, db, login, password): |
||||
|
user_id = super(ResUsers, cls)._login(db, login, password) |
||||
|
if not user_id: |
||||
|
return user_id |
||||
|
with registry(db).cursor() as cr: |
||||
|
env = api.Environment(cr, SUPERUSER_ID, {}) |
||||
|
user = env['res.users'].browse(user_id) |
||||
|
# check if this user came from ldap, rerun get_or_create_user in |
||||
|
# this case to apply ldap groups if necessary |
||||
|
ldaps = user.company_id.ldaps |
||||
|
if user.active and any(ldaps.mapped('only_ldap_groups')): |
||||
|
for conf in ldaps.get_ldap_dicts(): |
||||
|
entry = ldaps.authenticate(conf, login, password) |
||||
|
if entry: |
||||
|
ldaps.get_or_create_user(conf, login, entry) |
||||
|
break |
||||
|
return user_id |
@ -0,0 +1,4 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# Copyright 2018 Therp BV <https://therp.nl> |
||||
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). |
||||
|
from . import test_users_ldap_groups |
@ -0,0 +1,65 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# Copyright 2018 Therp BV <https://therp.nl> |
||||
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). |
||||
|
from mock import Mock, patch |
||||
|
from odoo.tests.common import TransactionCase |
||||
|
|
||||
|
|
||||
|
@patch('ldap.initialize', return_value=Mock( |
||||
|
search_st=Mock(return_value=[ |
||||
|
('cn=hello', {'name': ['hello', 'hello2']}) |
||||
|
]), |
||||
|
)) |
||||
|
class TestUsersLdapGroups(TransactionCase): |
||||
|
def test_users_ldap_groups(self, ldap_initialize): |
||||
|
# _login does its work in a new cursor, so we need one too |
||||
|
with self.env.registry.cursor() as cr: |
||||
|
env = self.env(cr=cr) |
||||
|
group_contains = env['res.groups'].create({'name': 'contains'}) |
||||
|
group_equals = env['res.groups'].create({'name': 'equals'}) |
||||
|
group_query = env['res.groups'].create({'name': 'query'}) |
||||
|
env.ref('base.main_company').write({'ldaps': [(0, 0, { |
||||
|
'ldap_server': 'localhost', |
||||
|
'ldap_filter': '(&(objectClass=*),(uid=%s))', |
||||
|
'ldap_base': 'base', |
||||
|
'only_ldap_groups': True, |
||||
|
'group_mapping_ids': [ |
||||
|
(0, 0, { |
||||
|
'ldap_attribute': 'name', |
||||
|
'operator': 'contains', |
||||
|
'value': 'hello3', |
||||
|
'group_id': env.ref('base.group_system').id, |
||||
|
}), |
||||
|
(0, 0, { |
||||
|
'ldap_attribute': 'name', |
||||
|
'operator': 'contains', |
||||
|
'value': 'hello2', |
||||
|
'group_id': group_contains.id, |
||||
|
}), |
||||
|
(0, 0, { |
||||
|
'ldap_attribute': 'name', |
||||
|
'operator': 'equals', |
||||
|
'value': 'hello', |
||||
|
'group_id': group_equals.id, |
||||
|
}), |
||||
|
(0, 0, { |
||||
|
'ldap_attribute': '', |
||||
|
'operator': 'query', |
||||
|
'value': 'is not run because of patching', |
||||
|
'group_id': group_query.id, |
||||
|
}), |
||||
|
], |
||||
|
})]}) |
||||
|
|
||||
|
self.env['res.users']._login(self.env.cr.dbname, 'demo', 'wrong') |
||||
|
with self.env.registry.cursor() as cr: |
||||
|
env = self.env(cr=cr) |
||||
|
demo_user = env.ref('base.user_demo') |
||||
|
# this asserts group mappings from demo data |
||||
|
groups = demo_user.groups_id |
||||
|
self.assertIn(group_contains, groups) |
||||
|
self.assertNotIn(group_equals, groups) |
||||
|
self.assertIn(group_query, groups) |
||||
|
self.assertNotIn(env.ref('base.group_system'), groups) |
||||
|
# clean up |
||||
|
env.ref('base.main_company').write({'ldaps': [(6, False, [])]}) |
@ -1,128 +0,0 @@ |
|||||
# -*- coding: utf-8 -*- |
|
||||
############################################################################## |
|
||||
# |
|
||||
# OpenERP, Open Source Management Solution |
|
||||
# This module copyright (C) 2012 Therp BV (<http://therp.nl>). |
|
||||
# |
|
||||
# This program is free software: you can redistribute it and/or modify |
|
||||
# it under the terms of the GNU Affero General Public License as |
|
||||
# published by the Free Software Foundation, either version 3 of the |
|
||||
# License, or (at your option) any later version. |
|
||||
# |
|
||||
# This program is distributed in the hope that it will be useful, |
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
||||
# GNU Affero General Public License for more details. |
|
||||
# |
|
||||
# You should have received a copy of the GNU Affero General Public License |
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
||||
# |
|
||||
############################################################################## |
|
||||
|
|
||||
from openerp import models |
|
||||
from openerp import fields |
|
||||
from openerp import api |
|
||||
import logging |
|
||||
from string import Template |
|
||||
|
|
||||
|
|
||||
class LDAPOperator(models.AbstractModel): |
|
||||
_name = "res.company.ldap.operator" |
|
||||
|
|
||||
def operators(self): |
|
||||
return ('contains', 'equals', 'query') |
|
||||
|
|
||||
def contains(self, ldap_entry, attribute, value, ldap_config, company, |
|
||||
logger): |
|
||||
return (attribute in ldap_entry[1]) and \ |
|
||||
(value in ldap_entry[1][attribute]) |
|
||||
|
|
||||
def equals(self, ldap_entry, attribute, value, ldap_config, company, |
|
||||
logger): |
|
||||
return attribute in ldap_entry[1] and \ |
|
||||
unicode(value) == unicode(ldap_entry[1][attribute]) |
|
||||
|
|
||||
def query(self, ldap_entry, attribute, value, ldap_config, company, |
|
||||
logger): |
|
||||
query_string = Template(value).safe_substitute(dict( |
|
||||
[(attr, ldap_entry[1][attribute][0]) for attr in ldap_entry[1]] |
|
||||
) |
|
||||
) |
|
||||
logger.debug('evaluating query group mapping, filter: %s' % |
|
||||
query_string) |
|
||||
results = company.query(ldap_config, query_string) |
|
||||
logger.debug(results) |
|
||||
return bool(results) |
|
||||
|
|
||||
|
|
||||
class CompanyLDAPGroupMapping(models.Model): |
|
||||
_name = 'res.company.ldap.group_mapping' |
|
||||
_rec_name = 'ldap_attribute' |
|
||||
_order = 'ldap_attribute' |
|
||||
|
|
||||
def _get_operators(self): |
|
||||
op_obj = self.env['res.company.ldap.operator'] |
|
||||
operators = [(op, op) for op in op_obj.operators()] |
|
||||
return tuple(operators) |
|
||||
|
|
||||
ldap_id = fields.Many2one('res.company.ldap', 'LDAP server', required=True) |
|
||||
ldap_attribute = fields.Char( |
|
||||
'LDAP attribute', |
|
||||
help='The LDAP attribute to check.\n' |
|
||||
'For active directory, use memberOf.') |
|
||||
operator = fields.Selection( |
|
||||
_get_operators, 'Operator', |
|
||||
help='The operator to check the attribute against the value\n' |
|
||||
'For active directory, use \'contains\'', required=True) |
|
||||
value = fields.Char( |
|
||||
'Value', |
|
||||
help='The value to check the attribute against.\n' |
|
||||
'For active directory, use the dn of the desired group', |
|
||||
required=True) |
|
||||
group = fields.Many2one( |
|
||||
'res.groups', 'OpenERP group', |
|
||||
help='The OpenERP group to assign', required=True) |
|
||||
|
|
||||
|
|
||||
class CompanyLDAP(models.Model): |
|
||||
_inherit = 'res.company.ldap' |
|
||||
|
|
||||
group_mappings = fields.One2many( |
|
||||
'res.company.ldap.group_mapping', |
|
||||
'ldap_id', 'Group mappings', |
|
||||
help='Define how OpenERP groups are assigned to ldap users') |
|
||||
only_ldap_groups = fields.Boolean( |
|
||||
'Only ldap groups', |
|
||||
help='If this is checked, manual changes to group membership are ' |
|
||||
'undone on every login (so OpenERP groups are always synchronous ' |
|
||||
'with LDAP groups). If not, manually added groups are preserved.') |
|
||||
|
|
||||
_default = { |
|
||||
'only_ldap_groups': False, |
|
||||
} |
|
||||
|
|
||||
@api.model |
|
||||
def get_or_create_user(self, conf, login, ldap_entry): |
|
||||
op_obj = self.env['res.company.ldap.operator'] |
|
||||
id_ = conf['id'] |
|
||||
this = self.browse(id_) |
|
||||
user_id = super(CompanyLDAP, self).get_or_create_user( |
|
||||
conf, login, ldap_entry) |
|
||||
if not user_id: |
|
||||
return user_id |
|
||||
userobj = self.env['res.users'] |
|
||||
user = userobj.browse(user_id) |
|
||||
logger = logging.getLogger('users_ldap_groups') |
|
||||
if self.only_ldap_groups: |
|
||||
logger.debug('deleting all groups from user %d' % user_id) |
|
||||
user.write({'groups_id': [(5, )]}) |
|
||||
|
|
||||
for mapping in this.group_mappings: |
|
||||
operator = getattr(op_obj, mapping.operator) |
|
||||
logger.debug('checking mapping %s' % mapping) |
|
||||
if operator(ldap_entry, mapping['ldap_attribute'], |
|
||||
mapping['value'], conf, self, logger): |
|
||||
logger.debug('adding user %d to group %s' % |
|
||||
(user_id, mapping.group.name)) |
|
||||
user.write({'groups_id': [(4, mapping.group.id)]}) |
|
||||
return user_id |
|
@ -1,27 +0,0 @@ |
|||||
<?xml version="1.0"?> |
|
||||
<openerp> |
|
||||
<data> |
|
||||
<record model="ir.ui.view" id="company_form_view"> |
|
||||
<field name="name">res.company.form.inherit.users_ldap_groups</field> |
|
||||
<field name="model">res.company</field> |
|
||||
<field name="inherit_id" ref="auth_ldap.company_form_view"/> |
|
||||
<field name="arch" type="xml"> |
|
||||
|
|
||||
<xpath expr="//form[@string='LDAP Configuration']" position="inside"> |
|
||||
<group string="Map User Groups" > |
|
||||
<field name="only_ldap_groups" /> |
|
||||
<field name="group_mappings" colspan="4" nolabel="1"> |
|
||||
<tree editable="top"> |
|
||||
<field name="ldap_attribute" attrs="{'required': [('operator','not in',['query'])], 'readonly': [('operator','in',['query'])]}" /> |
|
||||
<field name="operator" /> |
|
||||
<field name="value" /> |
|
||||
<field name="group" /> |
|
||||
</tree> |
|
||||
</field> |
|
||||
</group> |
|
||||
</xpath> |
|
||||
|
|
||||
</field> |
|
||||
</record> |
|
||||
</data> |
|
||||
</openerp> |
|
@ -0,0 +1,24 @@ |
|||||
|
<?xml version="1.0"?> |
||||
|
<odoo> |
||||
|
<record model="ir.ui.view" id="company_form_view"> |
||||
|
<field name="model">base.config.settings</field> |
||||
|
<field name="inherit_id" ref="auth_ldap.view_general_configuration_form_inherit_auth_ldap"/> |
||||
|
<field name="arch" type="xml"> |
||||
|
<xpath expr="//field[@name='ldaps']/form/group" position="after"> |
||||
|
<group string="Map User Groups" > |
||||
|
<field name="only_ldap_groups" /> |
||||
|
<label for="group_mapping_ids" /> |
||||
|
<field name="group_mapping_ids" nolabel="1"> |
||||
|
<tree editable="top"> |
||||
|
<field name="ldap_attribute" attrs="{'required': [('operator','not in',['query'])], 'readonly': [('operator','in',['query'])]}" /> |
||||
|
<field name="operator" /> |
||||
|
<field name="value" /> |
||||
|
<field name="group_id" /> |
||||
|
</tree> |
||||
|
</field> |
||||
|
</group> |
||||
|
</xpath> |
||||
|
|
||||
|
</field> |
||||
|
</record> |
||||
|
</odoo> |
Write
Preview
Loading…
Cancel
Save
Reference in new issue