Browse Source
[ADD] base_export_security: Create module (#917)
[ADD] base_export_security: Create module (#917)
* [ADD] base_export_security: Create module * Add Export Rights security group, access rights * Add user rights checks to prevent unauthorized exports and hide Export menu item from unauthorized users * Add Export model for logging data export activity * Add views, menu link for Export Logs * Add data exports discussion channel, notifications of exports * [IMP] base_export_security: Make requested changes * Fix manifest website and depends * Fix xml structure * Rename model 'export' -> 'export.event' * Simplify date calculation * Use % instead of .format * Avoid translating variables * Assign recordsets to variables instead of ids * Use (6, _, ids) command instead of (4, id, _) * Clean up syntax redundancies * Remove unnecessary sort * Override export_data method with inheritance/super * [FIX] base_export_security: Fix eslint errors * Fix prefer-rest-params * Fix no-prototype-builtins * Fix comma-dangle * Fix dot-location * Fix unnecessary apply * [IMP] base_export_security: Update readme * Add three screenshots from PR to readmepull/1029/merge
Brenton Hughes
7 years ago
committed by
Dave Lasley
17 changed files with 586 additions and 0 deletions
-
92base_export_security/README.rst
-
5base_export_security/__init__.py
-
25base_export_security/__manifest__.py
-
12base_export_security/data/export.xml
-
6base_export_security/models/__init__.py
-
26base_export_security/models/base.py
-
81base_export_security/models/export.py
-
13base_export_security/security/export_security.xml
-
2base_export_security/security/ir.model.access.csv
-
BINbase_export_security/static/description/export_log.png
-
BINbase_export_security/static/description/export_menu_item_hidden.png
-
BINbase_export_security/static/description/export_notification.png
-
35base_export_security/static/src/js/base_export_security.js
-
71base_export_security/static/tests/js/base_export_security.js
-
5base_export_security/tests/__init__.py
-
123base_export_security/tests/test_export.py
-
90base_export_security/views/export_view.xml
@ -0,0 +1,92 @@ |
|||
.. image:: https://img.shields.io/badge/licence-LGPL--3-blue.svg |
|||
:target: http://www.gnu.org/licenses/lgpl |
|||
:alt: License: LGPL-3 |
|||
|
|||
=============== |
|||
Export Security |
|||
=============== |
|||
|
|||
This module adds the following security features to Odoo's data exporting: |
|||
|
|||
#. A security group for restricting users' ability to export data |
|||
#. A log of exports detailing the user responsible, as well as the model, records, and fields exported |
|||
#. A `#data exports` channel for export activity notifications |
|||
|
|||
Screenshots |
|||
=========== |
|||
|
|||
Hidden Export Menu Item |
|||
----------------------- |
|||
|
|||
.. image:: /base_export_security/static/description/export_menu_item_hidden.png?raw=true |
|||
:alt: Hidden Export Menu Item |
|||
:width: 600 px |
|||
|
|||
Export Log |
|||
---------- |
|||
|
|||
.. image:: /base_export_security/static/description/export_log.png?raw=true |
|||
:alt: Export Log |
|||
:width: 600 px |
|||
|
|||
Channel Notification |
|||
-------------------- |
|||
|
|||
.. image:: /base_export_security/static/description/export_notification.png?raw=true |
|||
:alt: Export Notification |
|||
:width: 500 px |
|||
|
|||
Configuration |
|||
============= |
|||
|
|||
To configure this module, you need to: |
|||
|
|||
#. Add users who require data export rights to the `Export Rights` security group, found in Settings/Users (requires developer mode to be active) |
|||
#. Invite users who should receive export activity notifications to the `#data exports` notification channel |
|||
|
|||
Usage |
|||
===== |
|||
|
|||
To view detailed Export Logs, go to Settings/Data Exports/Export Logs |
|||
|
|||
.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas |
|||
:alt: Try me on Runbot |
|||
:target: https://runbot.odoo-community.org/runbot/149/10.0 |
|||
|
|||
Bug Tracker |
|||
=========== |
|||
|
|||
Bugs are tracked on `GitHub Issues |
|||
<https://github.com/OCA/server-tools/issues>`_. In case of trouble, please |
|||
check there if your issue has already been reported. If you spotted it first, |
|||
help us smash it by providing detailed and welcomed feedback. |
|||
|
|||
Credits |
|||
======= |
|||
|
|||
Images |
|||
------ |
|||
|
|||
* Odoo Community Association: `Icon <https://github.com/OCA/maintainer-tools/blob/master/template/module/static/description/icon.svg>`_. |
|||
|
|||
Contributors |
|||
------------ |
|||
|
|||
* Brent Hughes <brent.hughes@laslabs.com> |
|||
|
|||
Do not contact contributors directly about support or help with technical issues. |
|||
|
|||
Maintainer |
|||
---------- |
|||
|
|||
.. image:: https://odoo-community.org/logo.png |
|||
:alt: Odoo Community Association |
|||
:target: https://odoo-community.org |
|||
|
|||
This module is maintained by the OCA. |
|||
|
|||
OCA, or the Odoo Community Association, is a nonprofit organization whose |
|||
mission is to support the collaborative development of Odoo features and |
|||
promote its widespread use. |
|||
|
|||
To contribute to this module, please visit https://odoo-community.org. |
@ -0,0 +1,5 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# Copyright 2017 LasLabs Inc. |
|||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
|||
|
|||
from . import models |
@ -0,0 +1,25 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# Copyright 2017 LasLabs Inc. |
|||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
|||
|
|||
{ |
|||
"name": "Export Security", |
|||
"summary": "Security features for Odoo exports", |
|||
"version": "10.0.1.0.0", |
|||
"category": "Extra Tools", |
|||
"website": "https://laslabs.com/", |
|||
"author": "LasLabs, Odoo Community Association (OCA)", |
|||
"license": "LGPL-3", |
|||
"application": False, |
|||
"installable": True, |
|||
"depends": [ |
|||
"mail", |
|||
"web", |
|||
], |
|||
"data": [ |
|||
"data/export.xml", |
|||
"security/export_security.xml", |
|||
"security/ir.model.access.csv", |
|||
"views/export_view.xml", |
|||
], |
|||
} |
@ -0,0 +1,12 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<!-- Copyright 2017 LasLabs Inc. |
|||
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). --> |
|||
|
|||
<odoo noupdate="1"> |
|||
<record id="export_channel" model="mail.channel"> |
|||
<field name="name">data exports</field> |
|||
<field name="public">private</field> |
|||
<field name="channel_partner_ids" eval="[(4, ref('base.partner_root'))]"/> |
|||
<field name="description">Notifications of data export activity</field> |
|||
</record> |
|||
</odoo> |
@ -0,0 +1,6 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# Copyright 2017 LasLabs Inc. |
|||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
|||
|
|||
from . import base |
|||
from . import export |
@ -0,0 +1,26 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# Copyright 2017 LasLabs Inc. |
|||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
|||
|
|||
from odoo import _, api, models |
|||
from odoo.exceptions import AccessError |
|||
|
|||
|
|||
class Base(models.AbstractModel): |
|||
_inherit = 'base' |
|||
|
|||
@api.multi |
|||
def export_data(self, fields_to_export, raw_data=False): |
|||
if self.env.user.has_group('base_export_security.export_group'): |
|||
field_names = map( |
|||
lambda path_array: path_array[0], map( |
|||
models.fix_import_export_id_paths, |
|||
fields_to_export, |
|||
), |
|||
) |
|||
self.env['export.event'].log_export(self, field_names) |
|||
return super(Base, self).export_data(fields_to_export, raw_data) |
|||
else: |
|||
raise AccessError( |
|||
_('You do not have permission to export data'), |
|||
) |
@ -0,0 +1,81 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# Copyright 2017 LasLabs Inc. |
|||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
|||
|
|||
from odoo import _, api, fields, models |
|||
|
|||
|
|||
class Export(models.Model): |
|||
_name = 'export.event' |
|||
_description = 'Data Export Record' |
|||
|
|||
name = fields.Char() |
|||
model_id = fields.Many2one( |
|||
'ir.model', |
|||
string='Exported Model', |
|||
readonly=True, |
|||
) |
|||
user_id = fields.Many2one( |
|||
'res.users', |
|||
string='Exported by', |
|||
readonly=True, |
|||
) |
|||
field_ids = fields.Many2many( |
|||
'ir.model.fields', |
|||
string='Exported Fields', |
|||
readonly=True, |
|||
) |
|||
record_ids = fields.Many2many( |
|||
'ir.model.data', |
|||
string='Exported Records', |
|||
readonly=True, |
|||
) |
|||
|
|||
@api.model |
|||
def log_export(self, recordset, field_names): |
|||
date = fields.Datetime.now() |
|||
model_name = recordset._name |
|||
model = self.env['ir.model'].search([('model', '=', model_name)]) |
|||
user = self.env.user |
|||
name_data = {'date': date, 'model': model.name, 'user': user.name} |
|||
name = '%(date)s / %(model)s / %(user)s' % name_data |
|||
exported_fields = self.env['ir.model.fields'].search([ |
|||
('model', '=', model_name), |
|||
('name', 'in', field_names), |
|||
]) |
|||
records = self.env['ir.model.data'].search([ |
|||
('model', '=', model_name), |
|||
('res_id', 'in', recordset.ids), |
|||
]) |
|||
export = self.create({ |
|||
'name': name, |
|||
'model_id': model.id, |
|||
'field_ids': [(6, 0, exported_fields.ids)], |
|||
'record_ids': [(6, 0, records.ids)], |
|||
'user_id': user.id, |
|||
}) |
|||
export.sudo().post_notification() |
|||
return export |
|||
|
|||
@api.multi |
|||
def post_notification(self): |
|||
channel = self.env.ref('base_export_security.export_channel') |
|||
field_labels = ', '.join( |
|||
self.field_ids.mapped('field_description'), |
|||
) |
|||
message_data = { |
|||
'records': len(self.record_ids), |
|||
'model': self.model_id.name, |
|||
'user': self.user_id.name, |
|||
'fields': field_labels, |
|||
} |
|||
message_body = _( |
|||
'%(records)d <b>%(model)s</b> records exported by <b>%(user)s' |
|||
'</b>.<br><b>Fields exported:</b> %(fields)s' |
|||
) % message_data |
|||
message = channel.message_post( |
|||
body=message_body, |
|||
message_type='notification', |
|||
subtype='mail.mt_comment', |
|||
) |
|||
return message |
@ -0,0 +1,13 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<!-- Copyright 2017 LasLabs Inc. |
|||
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). --> |
|||
|
|||
<odoo> |
|||
<record id="export_group" model="res.groups"> |
|||
<field name="name">Export Rights</field> |
|||
<field name="comment"> |
|||
The user will be able to export data. |
|||
</field> |
|||
<field name="users" eval="[(4, ref('base.user_root'))]"/> |
|||
</record> |
|||
</odoo> |
@ -0,0 +1,2 @@ |
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink |
|||
export_rights,export rights,model_export_event,base_export_security.export_group,1,1,1,1 |
After Width: 1000 | Height: 655 | Size: 263 KiB |
After Width: 1000 | Height: 297 | Size: 100 KiB |
After Width: 1000 | Height: 137 | Size: 95 KiB |
@ -0,0 +1,35 @@ |
|||
/* Copyright 2017 LasLabs Inc. |
|||
* License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). */
|
|||
|
|||
odoo.define('base_export_security', function(require){ |
|||
'use strict'; |
|||
|
|||
var Model = require('web.Model'); |
|||
var ListView = require('web.ListView'); |
|||
var core = require('web.core'); |
|||
|
|||
ListView.include({ |
|||
render_sidebar: function($node) { |
|||
var exportLabel = core._t('Export'); |
|||
var users = new Model('res.users'); |
|||
var res = this._super($node); |
|||
|
|||
if (this.sidebar && Object.prototype.hasOwnProperty.call(this.sidebar.items, 'other')) { |
|||
users.call('has_group', ['base_export_security.export_group']).then(function(result){ |
|||
if(!result){ |
|||
var filteredItems = this.sidebar.items.other.filter( |
|||
function(item){ |
|||
return item.label !== exportLabel; |
|||
} |
|||
); |
|||
this.sidebar.items.other = filteredItems; |
|||
this.sidebar.redraw(); |
|||
} |
|||
}.bind(this)); |
|||
} |
|||
|
|||
return res; |
|||
} |
|||
}); |
|||
|
|||
}); |
@ -0,0 +1,71 @@ |
|||
/* Copyright 2017 LasLabs Inc. |
|||
* License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). */
|
|||
|
|||
odoo.define_section('base_export_security', |
|||
['web.core', 'web.data', 'web.data_manager', 'web.ListView'], |
|||
function(test, mock) { |
|||
'use strict'; |
|||
|
|||
function setup (authorization) { |
|||
mock.add('test.model:read', function () { |
|||
return [{ id: 1, a: 'foo', b: 'bar', c: 'baz' }]; |
|||
}); |
|||
|
|||
mock.add('res.users:has_group', function () { |
|||
return authorization; |
|||
}); |
|||
} |
|||
|
|||
function fields_view_get () { |
|||
return { |
|||
type: 'tree', |
|||
fields: { |
|||
a: {type: 'char', string: 'A'}, |
|||
b: {type: 'char', string: 'B'}, |
|||
c: {type: 'char', string: 'C'} |
|||
}, |
|||
arch: '<tree><field name="a"/><field name="b"/><field name="c"/></tree>' |
|||
}; |
|||
} |
|||
|
|||
function labelCount (exportLabel) { |
|||
var $fix = $('#qunit-fixture'); |
|||
var exportItems = $fix.find('.btn-group a[data-section="other"]').filter( |
|||
function(i, item){ |
|||
return item.text.trim() === exportLabel; |
|||
} |
|||
); |
|||
|
|||
return exportItems.length; |
|||
} |
|||
|
|||
function renderView (data, data_manager, ListView) { |
|||
var $fix = $('#qunit-fixture'); |
|||
var dataset = new data.DataSetStatic(null, 'test.model', null, [1]); |
|||
var fields_view = data_manager._postprocess_fvg(fields_view_get()); |
|||
var listView = new ListView({}, dataset, fields_view, {sidebar: true}); |
|||
|
|||
listView.appendTo($fix).then(listView.render_sidebar($fix)); |
|||
} |
|||
|
|||
test('It should display the Export menu item to authorized users', |
|||
function(assert, core, data, data_manager, ListView) { |
|||
var exportLabel = core._t('Export'); |
|||
setup(true); |
|||
renderView(data, data_manager, ListView); |
|||
|
|||
assert.strictEqual(labelCount(exportLabel), 1); |
|||
} |
|||
); |
|||
|
|||
test('It should not display the Export menu item to unauthorized users', |
|||
function(assert, core, data, data_manager, ListView) { |
|||
var exportLabel = core._t('Export'); |
|||
setup(false); |
|||
renderView(data, data_manager, ListView); |
|||
|
|||
assert.strictEqual(labelCount(exportLabel), 0); |
|||
} |
|||
); |
|||
|
|||
}); |
@ -0,0 +1,5 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# Copyright 2017 LasLabs Inc. |
|||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
|||
|
|||
from . import test_export |
@ -0,0 +1,123 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# Copyright 2017 LasLabs Inc. |
|||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
|||
|
|||
import mock |
|||
|
|||
from odoo import _ |
|||
from odoo.exceptions import AccessError |
|||
from odoo.tests.common import HttpCase, TransactionCase |
|||
|
|||
|
|||
class TestExport(TransactionCase): |
|||
def setUp(self): |
|||
super(TestExport, self).setUp() |
|||
export_group = self.env.ref('base_export_security.export_group') |
|||
self.authorized_user = self.env['res.users'].create({ |
|||
'login': 'exporttestuser', |
|||
'partner_id': self.env['res.partner'].create({ |
|||
'name': "Export Test User" |
|||
}).id, |
|||
'groups_id': [(4, export_group.id, 0)], |
|||
}) |
|||
self.unauthorized_user = self.env['res.users'].create({ |
|||
'login': 'unauthorizedexporttestuser', |
|||
'partner_id': self.env['res.partner'].create({ |
|||
'name': "Unauthorized Export Test User" |
|||
}).id, |
|||
}) |
|||
self.model_name = 'ir.module.module' |
|||
self.recordset = self.env[self.model_name].search([]) |
|||
self.field_names = ['name', 'id'] |
|||
self.model = self.env['ir.model'].search([ |
|||
('model', '=', self.model_name), |
|||
]) |
|||
self.records = self.env['ir.model.data'].search([ |
|||
('model', '=', self.model_name), |
|||
('res_id', 'in', self.recordset.ids), |
|||
]) |
|||
self.fields = self.env['ir.model.fields'].search([ |
|||
('model', '=', self.model_name), |
|||
('name', 'in', self.field_names), |
|||
]) |
|||
|
|||
def test_log_export(self): |
|||
"""It should create a new Export record with correct data""" |
|||
log = self.env['export.event'].sudo(self.authorized_user).log_export( |
|||
self.recordset, |
|||
self.field_names, |
|||
) |
|||
self.assertEqual( |
|||
[log.model_id, log.field_ids, log.record_ids, log.user_id], |
|||
[self.model, self.fields, self.records, self.authorized_user], |
|||
'Log not created properly', |
|||
) |
|||
|
|||
def test_log_export_posts_notification(self): |
|||
"""It should call post_notification method""" |
|||
post_notification_mock = mock.MagicMock() |
|||
self.env['export.event']._patch_method( |
|||
'post_notification', |
|||
post_notification_mock, |
|||
) |
|||
self.env['export.event'].sudo(self.authorized_user).log_export( |
|||
self.recordset, |
|||
self.field_names, |
|||
) |
|||
post_notification_mock.assert_called_once_with() |
|||
self.env['export.event']._revert_method('post_notification') |
|||
|
|||
def test_post_notification(self): |
|||
"""It should post a notification with appropriate data |
|||
to the #data export channel""" |
|||
export = self.env['export.event'].create({ |
|||
'model_id': self.model.id, |
|||
'field_ids': [(4, self.fields.ids)], |
|||
'record_ids': [(4, self.records.ids)], |
|||
'user_id': self.authorized_user.id, |
|||
}) |
|||
message = export.sudo().post_notification() |
|||
field_labels = ', '.join( |
|||
self.fields.sorted().mapped('field_description'), |
|||
) |
|||
message_data = { |
|||
'records': len(self.records.ids), |
|||
'model': self.model.name, |
|||
'user': self.authorized_user.name, |
|||
'fields': field_labels, |
|||
} |
|||
message_body = _( |
|||
'<p>%(records)d <b>%(model)s</b> records exported by <b>%(user)s' |
|||
'</b>.<br><b>Fields exported:</b> %(fields)s</p>' |
|||
) % message_data |
|||
self.assertEqual( |
|||
[message.body, message.message_type, message.model], |
|||
[message_body, 'notification', 'mail.channel'], |
|||
'Message not posted properly', |
|||
) |
|||
|
|||
def test_export_data_access(self): |
|||
"""It should raise AccessError if user does not have export rights""" |
|||
with self.assertRaises(AccessError): |
|||
self.env[self.model_name].sudo( |
|||
self.unauthorized_user |
|||
).export_data(self, None) |
|||
|
|||
def test_export_data_calls_log_export(self): |
|||
"""It should call log_export if user has export rights""" |
|||
log_export_mock = mock.MagicMock() |
|||
self.env['export.event']._patch_method('log_export', log_export_mock) |
|||
model = self.env[self.model_name] |
|||
model.sudo(self.authorized_user).export_data(self.field_names) |
|||
log_export_mock.assert_called_once_with(model, self.field_names) |
|||
self.env['export.event']._revert_method('log_export') |
|||
|
|||
|
|||
class TestJS(HttpCase): |
|||
def test_export_visibility(self): |
|||
"""Test visibility of export menu item""" |
|||
self.phantom_js( |
|||
"/web/tests?debug=assets&module=base_export_security", |
|||
"", |
|||
login="admin", |
|||
) |
@ -0,0 +1,90 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<!-- Copyright 2017 LasLabs Inc. |
|||
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). --> |
|||
|
|||
<odoo> |
|||
<record id="export_view_form" model="ir.ui.view"> |
|||
<field name="name">Export Log</field> |
|||
<field name="model">export.event</field> |
|||
<field name="arch" type="xml"> |
|||
<form string="Export Log" create="false"> |
|||
<sheet> |
|||
<group> |
|||
<field name="name"/> |
|||
<field name="model_id"/> |
|||
<field name="user_id"/> |
|||
<field name="create_date" |
|||
string="Export Date" |
|||
readonly="1"/> |
|||
</group> |
|||
<notebook> |
|||
<page string="Exported Fields"> |
|||
<field name="field_ids"/> |
|||
</page> |
|||
<page string="Exported Records"> |
|||
<field name="record_ids"/> |
|||
</page> |
|||
</notebook> |
|||
</sheet> |
|||
</form> |
|||
</field> |
|||
</record> |
|||
|
|||
<record id="export_view_tree" model="ir.ui.view"> |
|||
<field name="name">Export Logs</field> |
|||
<field name="model">export.event</field> |
|||
<field name="arch" type="xml"> |
|||
<tree string="Export Logs" create="false" default_order='create_date desc'> |
|||
<field name="model_id"/> |
|||
<field name="user_id"/> |
|||
<field name="create_date" string="Export Date"/> |
|||
</tree> |
|||
</field> |
|||
</record> |
|||
|
|||
<record id="export_view_search" model="ir.ui.view"> |
|||
<field name="name">Export Logs Search</field> |
|||
<field name="model">export.event</field> |
|||
<field name="arch" type="xml"> |
|||
<search string="Export Logs"> |
|||
<field name="user_id"/> |
|||
<field name="create_date" string="Export Date"/> |
|||
<field name="model_id"/> |
|||
<field name="field_ids"/> |
|||
<field name="record_ids"/> |
|||
</search> |
|||
</field> |
|||
</record> |
|||
|
|||
<record id="export_action_log" model="ir.actions.act_window"> |
|||
<field name="name">Export Logs</field> |
|||
<field name="res_model">export.event</field> |
|||
<field name="view_type">form</field> |
|||
<field name="view_mode">tree,form</field> |
|||
<field name="search_view_id" ref="export_view_search"/> |
|||
</record> |
|||
|
|||
<menuitem id="export_category" |
|||
name="Data Exports" |
|||
parent="base.menu_administration"/> |
|||
|
|||
<menuitem id="export_menu" |
|||
name="Export Logs" |
|||
sequence="7" |
|||
parent="export_category" |
|||
action="export_action_log"/> |
|||
|
|||
<template id="assets_backend" inherit_id="web.assets_backend"> |
|||
<xpath expr="."> |
|||
<script type="text/javascript" |
|||
src="/base_export_security/static/src/js/base_export_security.js"/> |
|||
</xpath> |
|||
</template> |
|||
|
|||
<template id="qunit_suite" inherit_id="web.qunit_suite"> |
|||
<xpath expr="//t[@t-set='head']" position="inside"> |
|||
<script type="application/javascript" |
|||
src="/base_export_security/static/tests/js/base_export_security.js"/> |
|||
</xpath> |
|||
</template> |
|||
</odoo> |
Write
Preview
Loading…
Cancel
Save
Reference in new issue