Skip to content

Commit 0957406

Browse files
committed
[16.0][ADD] base_external_system_odoorpc
1 parent d2c92f4 commit 0957406

13 files changed

Lines changed: 366 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import models
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Copyright 2026 Therp BV <https://therp.nl>
2+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
3+
4+
{
5+
"name": "Base External System Odoo-rpc",
6+
"summary": "Connect to a remote Odoo instance via the odoorpc library.",
7+
"version": "16.0.1.0.0",
8+
"category": "Base",
9+
"website": "https://github.com/OCA/server-backend",
10+
"author": "Therp BV, " "Odoo Community Association (OCA)",
11+
"license": "AGPL-3",
12+
"application": False,
13+
"installable": True,
14+
"depends": ["base_external_system"],
15+
"external_dependencies": {"python": ["odoorpc"]},
16+
"data": [
17+
"demo/external_system_odoo_demo.xml",
18+
"security/ir.model.access.csv",
19+
"views/external_system_views.xml",
20+
],
21+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?xml version="1.0" encoding="utf-8" ?>
2+
<!--
3+
Copyright 2026 Therp BV <https://therp.nl>
4+
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
5+
-->
6+
<odoo>
7+
<record id="external_system_rpc" model="external.system">
8+
<field name="name">External connection via RPC</field>
9+
<field name="system_type">external.system.odoo</field>
10+
<field name="host">localhost</field>
11+
<field name="port">8069</field>
12+
<field name="db_name">odoo16</field>
13+
<field name="username">admin</field>
14+
<field name="password">admin</field>
15+
<field name="is_ssl">0</field>
16+
<field name="company_ids" eval="[(5, 0), (4, ref('base.main_company'))]" />
17+
</record>
18+
</odoo>
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from . import external_system
2+
from . import external_system_odoo
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Copyright 2026 Therp BV <https://therp.nl>.
2+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
3+
4+
from odoo import fields, models
5+
6+
7+
class ExternalSystem(models.Model):
8+
"""Extend base external system"""
9+
10+
_inherit = "external.system"
11+
12+
db_name = fields.Char(
13+
string="Database",
14+
help="Input database name",
15+
)
16+
is_ssl = fields.Boolean(
17+
string="SSL",
18+
help="Change protocol if SSL",
19+
)
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Copyright 2026 Therp BV <https://therp.nl>
2+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
3+
4+
from urllib.error import URLError
5+
6+
import odoorpc
7+
8+
from odoo import _, models
9+
from odoo.exceptions import ValidationError
10+
11+
12+
class ExternalSystemOdoo(models.Model):
13+
"""This is an Interface implementing the RPC module."""
14+
15+
_name = "external.system.odoo"
16+
_inherit = "external.system.adapter"
17+
_description = "External System RPC"
18+
19+
def external_get_client(self):
20+
"""Return a usable client representing the remote system."""
21+
self.ensure_one()
22+
return self._connect()
23+
24+
def external_destroy_client(self, client):
25+
"""Cleanup the client connection"""
26+
self.ensure_one()
27+
try:
28+
if client:
29+
client.logout()
30+
except Exception:
31+
pass
32+
return super().external_destroy_client(client)
33+
34+
def external_test_connection(self):
35+
"""Test connection in the UI."""
36+
self.ensure_one()
37+
try:
38+
with self.client() as odoo:
39+
user_model = odoo.env["res.users"]
40+
ids = user_model.search([("login", "=", "admin")])
41+
if not ids:
42+
raise ValidationError(
43+
_("Connected, but could not find admin user.")
44+
)
45+
user_model.read([ids[0]], ["name"])[0]
46+
except ValidationError:
47+
raise
48+
except Exception as e:
49+
raise ValidationError(_("Connection failed.\n\nDETAIL: %s") % e) from e
50+
51+
return super().external_test_connection()
52+
53+
def _connect(self):
54+
"""Return connection object"""
55+
self.ensure_one()
56+
if not all([self.host, self.port, self.db_name, self.username, self.password]):
57+
raise ValidationError(
58+
_(
59+
"Connection failed. Please make sure that all fields "
60+
"are filled: Database, Host, Port, Username, Password."
61+
)
62+
)
63+
try:
64+
odoo = odoorpc.ODOO(
65+
self.host,
66+
port=self.port,
67+
protocol="jsonrpc+ssl" if self.is_ssl else "jsonrpc",
68+
)
69+
except URLError as exc:
70+
raise ValidationError(
71+
_("Could not connect the Odoo server at %(host)s:%(port)s")
72+
% {"host": self.host, "port": self.port}
73+
) from exc
74+
odoo.login(self.db_name, self.username, self.password)
75+
return odoo
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
2+
access_external_system_odoo_admin,access_external_system_odoo_admin,model_external_system_odoo,base.group_system,1,1,1,1
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import test_odoorpc_adapter
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
# Copyright 2023 Therp BV
2+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
3+
4+
from odoo.exceptions import UserError, ValidationError
5+
from odoo.tests.common import TransactionCase
6+
7+
8+
class _FakeModel:
9+
def __init__(self, name):
10+
self._name = name
11+
self.search_calls = []
12+
self.read_calls = []
13+
14+
def search(self, domain, limit=None, order=None):
15+
self.search_calls.append((domain, limit, order))
16+
# Simulate "admin" found
17+
return [1]
18+
19+
def read(self, ids, fields=None):
20+
self.read_calls.append((ids, fields))
21+
return [{"id": ids[0], "name": "Admin"}]
22+
23+
24+
class _FakeEnv:
25+
def __init__(self):
26+
self._models = {}
27+
28+
def __getitem__(self, model_name):
29+
if model_name not in self._models:
30+
self._models[model_name] = _FakeModel(model_name)
31+
return self._models[model_name]
32+
33+
34+
class _FakeODOOClient:
35+
def __init__(self):
36+
self.logged_in = False
37+
self.logged_out = False
38+
self.login_calls = []
39+
self.env = _FakeEnv()
40+
41+
def login(self, db, username, password):
42+
self.login_calls.append((db, username, password))
43+
# simulate successful login
44+
self.logged_in = True
45+
46+
def logout(self):
47+
self.logged_out = True
48+
49+
50+
class _FakeOdooRPCModule:
51+
"""Fake `odoorpc` module replacement with an ODOO constructor."""
52+
53+
def __init__(self):
54+
self.created = []
55+
self.last_client = None
56+
57+
def ODOO(self, host, port=None, protocol=None):
58+
self.created.append((host, port, protocol))
59+
self.last_client = _FakeODOOClient()
60+
return self.last_client
61+
62+
63+
class TestBaseExternalSystemOdooRPC(TransactionCase):
64+
@classmethod
65+
def setUpClass(cls):
66+
super().setUpClass()
67+
cls.env = cls.env(su=True)
68+
69+
def _make_system(self, **overrides):
70+
vals = {
71+
"name": "Roetz 8",
72+
"system_type": "external.system.odoo",
73+
"host": "roetz-test.therp1.nl",
74+
"port": 443,
75+
"db_name": "roetz-testdatabase",
76+
"username": "admin",
77+
"password": "admin",
78+
"is_ssl": True,
79+
"company_ids": [(6, 0, [self.env.company.id])],
80+
}
81+
vals.update(overrides)
82+
return self.env["external.system"].create(vals)
83+
84+
def _patch_odoorpc(self):
85+
"""
86+
Patch the `odoorpc` module reference imported inside the adapter file.
87+
88+
Adapter file imports odoorpc at module import time, so we override that
89+
module-level name with our fake.
90+
"""
91+
import odoo.addons.base_external_system_odoorpc.models.external_system_odoo as mod
92+
93+
fake = _FakeOdooRPCModule()
94+
mod.odoorpc = fake
95+
return fake
96+
97+
def test_interface_is_created_and_points_to_adapter(self):
98+
sys = self._make_system()
99+
self.assertTrue(sys.interface, "System should have an auto-created interface")
100+
self.assertEqual(
101+
sys.interface._name,
102+
"external.system.odoo",
103+
"Interface should be the OdooRPC adapter model",
104+
)
105+
self.assertEqual(
106+
sys.interface.system_id.id,
107+
sys.id,
108+
"Interface should point back to the external.system via system_id",
109+
)
110+
111+
def test_external_get_client_success_ssl(self):
112+
sys = self._make_system(is_ssl=True)
113+
fake = self._patch_odoorpc()
114+
115+
client = sys.interface.external_get_client()
116+
# constructor called with jsonrpc+ssl
117+
self.assertEqual(fake.created[-1], (sys.host, sys.port, "jsonrpc+ssl"))
118+
# login called
119+
self.assertTrue(client.logged_in)
120+
self.assertEqual(
121+
client.login_calls[-1],
122+
(sys.db_name, sys.username, sys.password),
123+
)
124+
125+
def test_external_get_client_success_no_ssl(self):
126+
sys = self._make_system(is_ssl=False)
127+
fake = self._patch_odoorpc()
128+
129+
client = sys.interface.external_get_client()
130+
self.assertEqual(fake.created[-1], (sys.host, sys.port, "jsonrpc"))
131+
self.assertTrue(client.logged_in)
132+
133+
def test_external_get_client_missing_params_raises_validationerror(self):
134+
sys = self._make_system(db_name=False)
135+
self._patch_odoorpc()
136+
137+
with self.assertRaises(ValidationError):
138+
sys.interface.external_get_client()
139+
140+
def test_client_context_manager_logs_out(self):
141+
sys = self._make_system()
142+
fake = self._patch_odoorpc()
143+
144+
with sys.client() as client:
145+
self.assertTrue(client.logged_in)
146+
self.assertFalse(client.logged_out)
147+
148+
# after context, destroy_client should have logged out
149+
self.assertTrue(fake.last_client.logged_out)
150+
151+
def test_interface_connect_helper_calls_external_get_client(self):
152+
"""
153+
Your adapter keeps a legacy `_connect()` helper.
154+
This ensures it returns the same object and logs in.
155+
"""
156+
sys = self._make_system()
157+
fake = self._patch_odoorpc()
158+
159+
client = sys.interface._connect()
160+
self.assertIs(client, fake.last_client)
161+
self.assertTrue(client.logged_in)
162+
163+
def test_external_test_connection_raises_success_usererror(self):
164+
"""
165+
base_external_system's default external_test_connection raises UserError
166+
with a success message. Our adapter should:
167+
- open a client
168+
- call res.users search/read probe
169+
- then call super() which raises UserError
170+
- and logout on exit
171+
"""
172+
sys = self._make_system()
173+
fake = self._patch_odoorpc()
174+
175+
with self.assertRaises(UserError):
176+
sys.interface.external_test_connection()
177+
178+
self.assertIsNotNone(fake.last_client)
179+
180+
users = fake.last_client.env["res.users"]
181+
self.assertEqual(len(users.search_calls), 1)
182+
self.assertEqual(users.search_calls[0][0], [("login", "=", "admin")])
183+
self.assertEqual(len(users.read_calls), 1)
184+
self.assertEqual(users.read_calls[0][1], ["name"])
185+
186+
# And should have logged out due to adapter.client() finally block
187+
self.assertTrue(fake.last_client.logged_out)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?xml version="1.0" encoding="utf-8" ?>
2+
<odoo>
3+
4+
<record id="external_system_view_form_odoo" model="ir.ui.view">
5+
<field name="name">external.system.form.odoo</field>
6+
<field name="model">external.system</field>
7+
<field name="inherit_id" ref="base_external_system.external_system_view_form" />
8+
<field name="arch" type="xml">
9+
10+
<!-- Show Odoo-specific fields -->
11+
<xpath expr="//field[@name='remote_path']" position="after">
12+
<field
13+
name="db_name"
14+
attrs="{'invisible': [('system_type', '!=', 'external.system.odoo')]}"
15+
/>
16+
<field
17+
name="is_ssl"
18+
attrs="{'invisible': [('system_type', '!=', 'external.system.odoo')]}"
19+
/>
20+
</xpath>
21+
22+
<!-- Hide remote_path for Odoo RPC -->
23+
<xpath expr="//field[@name='remote_path']" position="attributes">
24+
<attribute name="attrs">
25+
{'invisible': [('system_type', '=', 'external.system.odoo')]}
26+
</attribute>
27+
</xpath>
28+
29+
</field>
30+
</record>
31+
32+
</odoo>

0 commit comments

Comments
 (0)