Browse Source
[ADD] base_external_system: Implement interface/adapter (#993)
[ADD] base_external_system: Implement interface/adapter (#993)
* [ADD] base_external_system: Implement interface/adapter for external systems * base_external_system: Fix OS model, add inherits, add validate * base_external_system: Usability and private key pass * base_external_system: Use contextmanager in adapter client * base_external_system: Move contextmanager to interface * base_external_system: Include contextmanager on adapter and system * base_external_system: Unify client * Use password widget for password field * Add tests & security * Fix lint * Add plaintext notepull/1035/head
Dave Lasley
7 years ago
committed by
GitHub
16 changed files with 661 additions and 0 deletions
-
96base_external_system/README.rst
-
5base_external_system/__init__.py
-
23base_external_system/__manifest__.py
-
17base_external_system/demo/external_system_os_demo.xml
-
4base_external_system/models/__init__.py
-
125base_external_system/models/external_system.py
-
71base_external_system/models/external_system_adapter.py
-
43base_external_system/models/external_system_os.py
-
3base_external_system/security/ir.model.access.csv
-
BINbase_external_system/static/description/icon.png
-
5base_external_system/tests/__init__.py
-
22base_external_system/tests/common.py
-
54base_external_system/tests/test_external_system.py
-
45base_external_system/tests/test_external_system_adapter.py
-
40base_external_system/tests/test_external_system_os.py
-
108base_external_system/views/external_system_view.xml
@ -0,0 +1,96 @@ |
|||
.. image:: https://img.shields.io/badge/license-LGPL--3-blue.svg |
|||
:target: http://www.gnu.org/licenses/lgpl.html |
|||
:alt: License: LGPL-3 |
|||
|
|||
====================== |
|||
Base - External System |
|||
====================== |
|||
|
|||
This module provides an interface/adapter mechanism for the definition of remote |
|||
systems. |
|||
|
|||
Note that this module stores everything in plain text. In the interest of security, |
|||
it is recommended you use another module (such as `keychain` or `red_october` to |
|||
encrypt things like the password and private key). This is not done here in order |
|||
to not force a specific security method. |
|||
|
|||
Implementation |
|||
============== |
|||
|
|||
The credentials for systems are stored in the ``external.system`` model, and are to |
|||
be configured by the user. This model is the unified interface for the underlying |
|||
adapters. |
|||
|
|||
Using the Interface |
|||
------------------- |
|||
|
|||
Given an ``external.system`` singleton called ``external_system``, you would do the |
|||
following to get the underlying system client: |
|||
|
|||
.. code-block:: python |
|||
|
|||
with external_system.client() as client: |
|||
client.do_something() |
|||
|
|||
The client will be destroyed once the context has completed. Destruction takes place |
|||
in the adapter's ``external_destroy_client`` method. |
|||
|
|||
The only unified aspect of this interface is the client connection itself. Other more |
|||
opinionated interface/adapter mechanisms can be implemented in other modules, such as |
|||
the file system interface in `OCA/server-tools/external_file_location |
|||
<https://github.com/OCA/server-tools/tree/9.0/external_file_location>`_. |
|||
|
|||
Creating an Adapter |
|||
------------------- |
|||
|
|||
Modules looking to add an external system adapter should inherit the |
|||
``external.system.adapter`` model and override the following methods: |
|||
|
|||
* ``external_get_client``: Returns a usable client for the system |
|||
* ``external_destroy_client``: Destroy the connection, if applicable. Does not need |
|||
to be defined if the connection destroys itself. |
|||
|
|||
Configuration |
|||
============= |
|||
|
|||
Configure external systems in Settings => Technical => External Systems |
|||
|
|||
.. 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 |
|||
------------ |
|||
|
|||
* Dave Lasley <dave@laslabs.com> |
|||
|
|||
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,23 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# Copyright 2017 LasLabs Inc. |
|||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
|||
|
|||
{ |
|||
"name": "Base External System", |
|||
"summary": "Data models allowing for connection to external systems.", |
|||
"version": "10.0.1.0.0", |
|||
"category": "Base", |
|||
"website": "https://laslabs.com/", |
|||
"author": "LasLabs, Odoo Community Association (OCA)", |
|||
"license": "LGPL-3", |
|||
"application": False, |
|||
"installable": True, |
|||
'depends': [ |
|||
'base', |
|||
], |
|||
'data': [ |
|||
'demo/external_system_os_demo.xml', |
|||
'security/ir.model.access.csv', |
|||
'views/external_system_view.xml', |
|||
], |
|||
} |
@ -0,0 +1,17 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
|
|||
<!-- |
|||
Copyright 2017 LasLabs Inc. |
|||
License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). |
|||
--> |
|||
|
|||
<odoo> |
|||
|
|||
<record id="external_system_os" model="external.system.os"> |
|||
<field name="name">Example OS Connection</field> |
|||
<field name="system_type">external.system.os</field> |
|||
<field name="remote_path">/tmp</field> |
|||
<field name="company_ids" eval="[(5, 0), (4, ref('base.main_company'))]" /> |
|||
</record> |
|||
|
|||
</odoo> |
@ -0,0 +1,4 @@ |
|||
# -*- coding: utf-8 -*- |
|||
from . import external_system |
|||
from . import external_system_adapter |
|||
from . import external_system_os |
@ -0,0 +1,125 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# Copyright 2017 LasLabs Inc. |
|||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
|||
|
|||
from contextlib import contextmanager |
|||
|
|||
from odoo import api, fields, models, _ |
|||
from odoo.exceptions import ValidationError |
|||
|
|||
|
|||
class ExternalSystem(models.Model): |
|||
|
|||
_name = 'external.system' |
|||
_description = 'External System' |
|||
|
|||
name = fields.Char( |
|||
required=True, |
|||
help='This is the canonical (humanized) name for the system.', |
|||
) |
|||
host = fields.Char( |
|||
help='This is the domain or IP address that the system can be reached ' |
|||
'at.', |
|||
) |
|||
port = fields.Integer( |
|||
help='This is the port number that the system is listening on.', |
|||
) |
|||
username = fields.Char( |
|||
help='This is the username that is used for authenticating to this ' |
|||
'system, if applicable.', |
|||
) |
|||
password = fields.Char( |
|||
help='This is the password that is used for authenticating to this ' |
|||
'system, if applicable.', |
|||
) |
|||
private_key = fields.Text( |
|||
help='This is the private key that is used for authenticating to ' |
|||
'this system, if applicable.', |
|||
) |
|||
private_key_password = fields.Text( |
|||
help='This is the password to unlock the private key that was ' |
|||
'provided for this sytem.', |
|||
) |
|||
fingerprint = fields.Text( |
|||
help='This is the fingerprint that is advertised by this system in ' |
|||
'order to validate its identity.', |
|||
) |
|||
ignore_fingerprint = fields.Boolean( |
|||
default=True, |
|||
help='Set this to `True` in order to ignore an invalid/unknown ' |
|||
'fingerprint from the system.', |
|||
) |
|||
remote_path = fields.Char( |
|||
help='Restrict to this directory path on the remote, if applicable.', |
|||
) |
|||
company_ids = fields.Many2many( |
|||
string='Companies', |
|||
comodel_name='res.company', |
|||
required=True, |
|||
default=lambda s: [(6, 0, s.env.user.company_id.ids)], |
|||
help='Access to this system is restricted to these companies.', |
|||
) |
|||
system_type = fields.Selection( |
|||
selection='_get_system_types', |
|||
required=True, |
|||
) |
|||
interface = fields.Reference( |
|||
selection='_get_system_types', |
|||
readonly=True, |
|||
help='This is the interface that this system represents. It is ' |
|||
'created automatically upon creation of the external system.', |
|||
) |
|||
|
|||
_sql_constraints = [ |
|||
('name_uniq', 'UNIQUE(name)', 'Connection name must be unique.'), |
|||
] |
|||
|
|||
@api.model |
|||
def _get_system_types(self): |
|||
"""Return the adapter interface models that are installed.""" |
|||
adapter = self.env['external.system.adapter'] |
|||
return [ |
|||
(m, self.env[m]._description) for m in adapter._inherit_children |
|||
] |
|||
|
|||
@api.multi |
|||
@api.constrains('fingerprint', 'ignore_fingerprint') |
|||
def check_fingerprint_ignore_fingerprint(self): |
|||
"""Do not allow a blank fingerprint if not set to ignore.""" |
|||
for record in self: |
|||
if not record.ignore_fingerprint and not record.fingerprint: |
|||
raise ValidationError(_( |
|||
'Fingerprint cannot be empty when Ignore Fingerprint is ' |
|||
'not checked.', |
|||
)) |
|||
|
|||
@api.multi |
|||
@contextmanager |
|||
def client(self): |
|||
"""Client object usable as a context manager to include destruction. |
|||
|
|||
Yields the result from ``external_get_client``, then calls |
|||
``external_destroy_client`` to cleanup the client. |
|||
|
|||
Yields: |
|||
mixed: An object representing the client connection to the remote |
|||
system. |
|||
""" |
|||
with self.interface.client() as client: |
|||
yield client |
|||
|
|||
@api.model |
|||
def create(self, vals): |
|||
"""Create the interface for the record and assign to ``interface``.""" |
|||
record = super(ExternalSystem, self).create(vals) |
|||
interface = self.env[vals['system_type']].create({ |
|||
'system_id': record.id, |
|||
}) |
|||
record.interface = interface |
|||
return record |
|||
|
|||
@api.multi |
|||
def action_test_connection(self): |
|||
"""Test the connection to the external system.""" |
|||
self.ensure_one() |
|||
self.interface.external_test_connection() |
@ -0,0 +1,71 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# Copyright 2017 LasLabs Inc. |
|||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
|||
|
|||
from contextlib import contextmanager |
|||
|
|||
from odoo import api, fields, models, _ |
|||
from odoo.exceptions import UserError |
|||
|
|||
|
|||
class ExternalSystemAdapter(models.AbstractModel): |
|||
"""This is the model that should be inherited for new external systems. |
|||
|
|||
Methods provided are prefixed with ``external_`` in order to keep from |
|||
""" |
|||
|
|||
_name = 'external.system.adapter' |
|||
_description = 'External System Adapter' |
|||
_inherits = {'external.system': 'system_id'} |
|||
|
|||
system_id = fields.Many2one( |
|||
string='System', |
|||
comodel_name='external.system', |
|||
required=True, |
|||
ondelete='cascade', |
|||
) |
|||
|
|||
@api.multi |
|||
@contextmanager |
|||
def client(self): |
|||
"""Client object usable as a context manager to include destruction. |
|||
|
|||
Yields the result from ``external_get_client``, then calls |
|||
``external_destroy_client`` to cleanup the client. |
|||
|
|||
Yields: |
|||
mixed: An object representing the client connection to the remote |
|||
system. |
|||
""" |
|||
client = self.external_get_client() |
|||
try: |
|||
yield client |
|||
finally: |
|||
self.external_destroy_client(client) |
|||
|
|||
@api.multi |
|||
def external_get_client(self): |
|||
"""Return a usable client representing the remote system.""" |
|||
self.ensure_one() |
|||
|
|||
@api.multi |
|||
def external_destroy_client(self, client): |
|||
"""Perform any logic necessary to destroy the client connection. |
|||
|
|||
Args: |
|||
client (mixed): The client that was returned by |
|||
``external_get_client``. |
|||
""" |
|||
self.ensure_one() |
|||
|
|||
@api.multi |
|||
def external_test_connection(self): |
|||
"""Adapters should override this method, then call super if valid. |
|||
|
|||
If the connection is invalid, adapters should raise an instance of |
|||
``odoo.ValidationError``. |
|||
|
|||
Raises: |
|||
odoo.exceptions.UserError: In the event of a good connection. |
|||
""" |
|||
raise UserError(_('The connection was a success.')) |
@ -0,0 +1,43 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# Copyright 2017 LasLabs Inc. |
|||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
|||
|
|||
import os |
|||
|
|||
from odoo import api, models |
|||
|
|||
|
|||
class ExternalSystemOs(models.Model): |
|||
"""This is an Interface implementing the OS module. |
|||
|
|||
For the most part, this is just a sample of how to implement an external |
|||
system interface. This is still a fully usable implementation, however. |
|||
""" |
|||
|
|||
_name = 'external.system.os' |
|||
_inherit = 'external.system.adapter' |
|||
_description = 'External System OS' |
|||
|
|||
previous_dir = None |
|||
|
|||
@api.multi |
|||
def external_get_client(self): |
|||
"""Return a usable client representing the remote system.""" |
|||
super(ExternalSystemOs, self).external_get_client() |
|||
if self.system_id.remote_path: |
|||
self.previous_dir = os.getcwd() |
|||
os.chdir(self.system_id.remote_path) |
|||
return os |
|||
|
|||
@api.multi |
|||
def external_destroy_client(self, client): |
|||
"""Perform any logic necessary to destroy the client connection. |
|||
|
|||
Args: |
|||
client (mixed): The client that was returned by |
|||
``external_get_client``. |
|||
""" |
|||
super(ExternalSystemOs, self).external_destroy_client(client) |
|||
if self.previous_dir: |
|||
os.chdir(self.previous_dir) |
|||
self.previous_dir = None |
@ -0,0 +1,3 @@ |
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink |
|||
access_external_system_os_admin,access_external_system_os_admin,model_external_system_os,base.group_system,1,1,1,1 |
|||
access_external_system_admin,access_external_system_admin,model_external_system,base.group_system,1,1,1,1 |
After Width: 128 | Height: 128 | Size: 9.2 KiB |
@ -0,0 +1,5 @@ |
|||
# -*- coding: utf-8 -*- |
|||
|
|||
from . import test_external_system |
|||
from . import test_external_system_adapter |
|||
from . import test_external_system_os |
@ -0,0 +1,22 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# Copyright 2017 LasLabs Inc. |
|||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). |
|||
|
|||
from contextlib import contextmanager |
|||
from mock import MagicMock |
|||
|
|||
from odoo.tests.common import TransactionCase |
|||
|
|||
|
|||
class Common(TransactionCase): |
|||
|
|||
@contextmanager |
|||
def _mock_method(self, method_name, method_obj=None): |
|||
if method_obj is None: |
|||
method_obj = self.record |
|||
magic = MagicMock() |
|||
method_obj._patch_method(method_name, magic) |
|||
try: |
|||
yield magic |
|||
finally: |
|||
method_obj._revert_method(method_name) |
@ -0,0 +1,54 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# Copyright 2017 LasLabs Inc. |
|||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). |
|||
|
|||
from odoo.exceptions import UserError, ValidationError |
|||
|
|||
from .common import Common |
|||
|
|||
|
|||
class TestExternalSystem(Common): |
|||
|
|||
def setUp(self): |
|||
super(TestExternalSystem, self).setUp() |
|||
self.record = self.env.ref('base_external_system.external_system_os') |
|||
|
|||
def test_get_system_types(self): |
|||
"""It should return at least the test record's interface.""" |
|||
self.assertIn( |
|||
(self.record._name, self.record._description), |
|||
self.env['external.system']._get_system_types(), |
|||
) |
|||
|
|||
def test_check_fingerprint_blank(self): |
|||
"""It should not allow blank fingerprints when checking enabled.""" |
|||
with self.assertRaises(ValidationError): |
|||
self.record.write({ |
|||
'ignore_fingerprint': False, |
|||
'fingerprint': False, |
|||
}) |
|||
|
|||
def test_check_fingerprint_allowed(self): |
|||
"""It should not raise a validation error if there is a fingerprint.""" |
|||
self.record.write({ |
|||
'ignore_fingerprint': False, |
|||
'fingerprint': 'Data', |
|||
}) |
|||
self.assertTrue(True) |
|||
|
|||
def test_client(self): |
|||
"""It should yield the open interface client.""" |
|||
with self._mock_method('client', self.record) as magic: |
|||
with self.record.system_id.client() as client: |
|||
self.assertEqual(client, magic().__enter__()) |
|||
|
|||
def test_create_creates_and_assigns_interface(self): |
|||
"""It should create and assign the interface on record create.""" |
|||
self.assertEqual( |
|||
self.record.interface._name, 'external.system.os', |
|||
) |
|||
|
|||
def test_action_test_connection(self): |
|||
"""It should proxy to the interface connection tester.""" |
|||
with self.assertRaises(UserError): |
|||
self.record.system_id.action_test_connection() |
@ -0,0 +1,45 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# Copyright 2017 LasLabs Inc. |
|||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). |
|||
|
|||
from odoo.exceptions import UserError |
|||
|
|||
from .common import Common |
|||
|
|||
|
|||
class TestExternalSystemAdapter(Common): |
|||
|
|||
def setUp(self): |
|||
super(TestExternalSystemAdapter, self).setUp() |
|||
self.system = self.env.ref('base_external_system.external_system_os') |
|||
self.record = self.env['external.system.adapter'].new({ |
|||
'system_id': self.system.id, |
|||
}) |
|||
|
|||
def test_client_yields_client(self): |
|||
"""It should yield the client.""" |
|||
with self._mock_method('external_get_client') as magic: |
|||
with self.record.client() as client: |
|||
self.assertEqual(client, magic()) |
|||
|
|||
def test_client_destroys_client(self): |
|||
"""It should destroy the client after use.""" |
|||
with self._mock_method('external_destroy_client') as magic: |
|||
with self.record.client() as client: |
|||
self.assertFalse(magic.call_count) |
|||
magic.assert_called_once_with(client) |
|||
|
|||
def test_external_get_client_ensure_one(self): |
|||
"""It should assert singletons.""" |
|||
with self.assertRaises(ValueError): |
|||
self.env['external.system.adapter'].external_get_client() |
|||
|
|||
def test_external_destroy_client_ensure_one(self): |
|||
"""It should assert singletons.""" |
|||
with self.assertRaises(ValueError): |
|||
self.env['external.system.adapter'].external_destroy_client(None) |
|||
|
|||
def test_external_test_connection(self): |
|||
"""It should raise a UserError.""" |
|||
with self.assertRaises(UserError): |
|||
self.record.external_test_connection() |
@ -0,0 +1,40 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# Copyright 2017 LasLabs Inc. |
|||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). |
|||
|
|||
import os |
|||
|
|||
from .common import Common |
|||
|
|||
|
|||
class TestExternalSystemOs(Common): |
|||
|
|||
@classmethod |
|||
def setUpClass(cls): |
|||
"""Remember the working dir, just in case.""" |
|||
super(TestExternalSystemOs, cls).setUpClass() |
|||
cls.working_dir = os.getcwd() |
|||
|
|||
@classmethod |
|||
def tearDownClass(cls): |
|||
"""Set the working dir back to origin, just in case.""" |
|||
super(TestExternalSystemOs, cls).tearDownClass() |
|||
os.chdir(cls.working_dir) |
|||
|
|||
def setUp(self): |
|||
super(TestExternalSystemOs, self).setUp() |
|||
self.record = self.env.ref('base_external_system.external_system_os') |
|||
|
|||
def test_external_get_client_returns_os(self): |
|||
"""It should return the Pyhton OS module.""" |
|||
self.assertEqual(self.record.external_get_client(), os) |
|||
|
|||
def test_external_get_client_changes_directories(self): |
|||
"""It should change to the proper directory.""" |
|||
self.record.external_get_client() |
|||
self.assertEqual(os.getcwd(), self.record.remote_path) |
|||
|
|||
def test_external_destroy_client_changes_directory(self): |
|||
"""It should change back to the previous working directory.""" |
|||
self.record.external_destroy_client(None) |
|||
self.assertEqual(os.getcwd(), self.working_dir) |
@ -0,0 +1,108 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
|
|||
<!-- |
|||
Copyright 2017 LasLabs Inc. |
|||
License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). |
|||
--> |
|||
|
|||
<odoo> |
|||
|
|||
<record id="external_system_view_form" model="ir.ui.view"> |
|||
<field name="name">external.system.view.form</field> |
|||
<field name="model">external.system</field> |
|||
<field name="arch" type="xml"> |
|||
<form string="External System"> |
|||
<header> |
|||
<button name="action_test_connection" |
|||
type="object" |
|||
string="Test Connection" /> |
|||
</header> |
|||
<sheet> |
|||
<group name="data"> |
|||
<group name="group_server_data"> |
|||
<field name="name" /> |
|||
<field name="company_ids" widget="many2many_tags" /> |
|||
<field name="remote_path" /> |
|||
<field name="ignore_fingerprint" /> |
|||
</group> |
|||
<group name="group_connection_data"> |
|||
<field name="host" /> |
|||
<field name="port" /> |
|||
<field name="username" /> |
|||
<field name="password" widget="password" /> |
|||
<field name="system_type" /> |
|||
</group> |
|||
</group> |
|||
<notebook> |
|||
<page string="Keys"> |
|||
<group> |
|||
<group> |
|||
<field name="private_key" /> |
|||
</group> |
|||
<group> |
|||
<field name="fingerprint" /> |
|||
</group> |
|||
</group> |
|||
</page> |
|||
</notebook> |
|||
</sheet> |
|||
<footer /> |
|||
</form> |
|||
</field> |
|||
</record> |
|||
|
|||
<record id="external_system_view_tree" model="ir.ui.view"> |
|||
<field name="name">external.system.view.tree</field> |
|||
<field name="model">external.system</field> |
|||
<field name="arch" type="xml"> |
|||
<tree string="External Systems"> |
|||
<field name="name" /> |
|||
<field name="host" /> |
|||
<field name="port" /> |
|||
</tree> |
|||
</field> |
|||
</record> |
|||
|
|||
<record id="external_system_view_search" model="ir.ui.view"> |
|||
<field name="name">external.system.view.search</field> |
|||
<field name="model">external.system</field> |
|||
<field name="arch" type="xml"> |
|||
<search string="External Systems"> |
|||
|
|||
<field name="name" /> |
|||
<field name="company_ids" /> |
|||
<field name="host" /> |
|||
<field name="port" /> |
|||
<field name="username" /> |
|||
|
|||
<group expand="0" string="Group By"> |
|||
<filter string="Host" |
|||
domain="" |
|||
context="{'group_by': 'host'}" /> |
|||
<filter string="Port" |
|||
domain="" |
|||
context="{'group_by': 'port'}" /> |
|||
<filter string="Username" |
|||
domain="" |
|||
context="{'group_by': 'username'}" /> |
|||
</group> |
|||
|
|||
</search> |
|||
</field> |
|||
</record> |
|||
|
|||
<record id="external_system_action" model="ir.actions.act_window"> |
|||
<field name="name">External Systems</field> |
|||
<field name="res_model">external.system</field> |
|||
<field name="type">ir.actions.act_window</field> |
|||
<field name="view_type">form</field> |
|||
<field name="view_mode">tree,form</field> |
|||
</record> |
|||
|
|||
<menuitem id="menu_external_system" |
|||
name="External Systems" |
|||
parent="base.menu_custom" |
|||
action="external_system_action" |
|||
sequence="50" /> |
|||
|
|||
</odoo> |
Write
Preview
Loading…
Cancel
Save
Reference in new issue