From 03a825e0556590796fdc825f1f35befc4beb9245 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Thu, 12 Dec 2024 18:02:59 +0100 Subject: [PATCH 01/10] [ADD] cross_connect_client --- cross_connect_client/README.rst | 114 +++++ cross_connect_client/__init__.py | 2 + cross_connect_client/__manifest__.py | 22 + cross_connect_client/controllers/__init__.py | 1 + .../controllers/cross_connect.py | 26 + cross_connect_client/models/__init__.py | 2 + .../models/cross_connect_server.py | 176 +++++++ cross_connect_client/models/res_groups.py | 24 + cross_connect_client/readme/CONTRIBUTORS.md | 1 + cross_connect_client/readme/DESCRIPTION.md | 2 + cross_connect_client/readme/USAGE.md | 23 + .../security/ir_model_access.xml | 17 + .../static/description/index.html | 450 ++++++++++++++++++ .../static/description/web_icon_data.png | Bin 0 -> 26321 bytes cross_connect_client/tests/__init__.py | 1 + .../tests/test_cross_connect_client.py | 277 +++++++++++ .../views/cross_connect_server_views.xml | 93 ++++ 17 files changed, 1231 insertions(+) create mode 100644 cross_connect_client/README.rst create mode 100644 cross_connect_client/__init__.py create mode 100644 cross_connect_client/__manifest__.py create mode 100644 cross_connect_client/controllers/__init__.py create mode 100644 cross_connect_client/controllers/cross_connect.py create mode 100644 cross_connect_client/models/__init__.py create mode 100644 cross_connect_client/models/cross_connect_server.py create mode 100644 cross_connect_client/models/res_groups.py create mode 100644 cross_connect_client/readme/CONTRIBUTORS.md create mode 100644 cross_connect_client/readme/DESCRIPTION.md create mode 100644 cross_connect_client/readme/USAGE.md create mode 100644 cross_connect_client/security/ir_model_access.xml create mode 100644 cross_connect_client/static/description/index.html create mode 100644 cross_connect_client/static/description/web_icon_data.png create mode 100644 cross_connect_client/tests/__init__.py create mode 100644 cross_connect_client/tests/test_cross_connect_client.py create mode 100644 cross_connect_client/views/cross_connect_server_views.xml diff --git a/cross_connect_client/README.rst b/cross_connect_client/README.rst new file mode 100644 index 0000000000..9fe0d561ee --- /dev/null +++ b/cross_connect_client/README.rst @@ -0,0 +1,114 @@ +==================== +Cross Connect Client +==================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:f10ceaed1b91df49c3a9b4e8acdef3d412d7ce50105f6f1ca752630fc1559d8e + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--auth-lightgray.png?logo=github + :target: https://github.com/OCA/server-auth/tree/16.0/cross_connect_client + :alt: OCA/server-auth +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-auth-16-0/server-auth-16-0-cross_connect_client + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/server-auth&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows this odoo instance users to connect directly on +another odoo instance where the module ``cross_connect_server`` is +installed. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +First of all after installing the module, you need to configure the +server connection. + +In order to do that, you need to go to the menu +``Settings > Technical > Cross Connect > Cross Connect Servers`` and +create a new server to connect to. + +Fill the fields with the server's information : + +- Url: The api root path (e.g. ``https://my-remote-odoo.com/api``) +- Api Key: The api-key from the ``cross_connect_server`` configuration + +Then click on the ``Sync Cross Connection`` button to check if the +connection is working and to sync the remote server's groups. + +After that, you will have to affect the remote groups to the local users +in order for them to be able to connect to the remote server. + +Once an user has a remote group, a new top level menu will appear in the +menu bar with the Cross Connect Server's name. Clicking on it will +redirect the user to the remote server logged in as the user. + +You can change each menu icon (for use with ``web_responsive`` for +instance) by setting the ``Web Icon Data`` in the server configuration. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Akretion + +Contributors +------------ + +- Florian Mounier florian.mounier@akretion.com + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +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. + +.. |maintainer-paradoxxxzero| image:: https://github.com/paradoxxxzero.png?size=40px + :target: https://github.com/paradoxxxzero + :alt: paradoxxxzero + +Current `maintainer `__: + +|maintainer-paradoxxxzero| + +This module is part of the `OCA/server-auth `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/cross_connect_client/__init__.py b/cross_connect_client/__init__.py new file mode 100644 index 0000000000..91c5580fed --- /dev/null +++ b/cross_connect_client/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/cross_connect_client/__manifest__.py b/cross_connect_client/__manifest__.py new file mode 100644 index 0000000000..76dc53a043 --- /dev/null +++ b/cross_connect_client/__manifest__.py @@ -0,0 +1,22 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Cross Connect Client", + "version": "16.0.1.0.0", + "author": "Akretion, Odoo Community Association (OCA)", + "summary": "Cross Connect Client allows to connect to a " + "Cross Connect Server enabled odoo instance.", + "category": "Tools", + "depends": ["server_environment"], + "website": "https://github.com/OCA/server-auth", + "data": [ + "security/ir_model_access.xml", + "views/cross_connect_server_views.xml", + ], + "maintainers": ["paradoxxxzero"], + "demo": [], + "installable": True, + "license": "AGPL-3", +} diff --git a/cross_connect_client/controllers/__init__.py b/cross_connect_client/controllers/__init__.py new file mode 100644 index 0000000000..0888dc1f38 --- /dev/null +++ b/cross_connect_client/controllers/__init__.py @@ -0,0 +1 @@ +from . import cross_connect diff --git a/cross_connect_client/controllers/cross_connect.py b/cross_connect_client/controllers/cross_connect.py new file mode 100644 index 0000000000..51975106d6 --- /dev/null +++ b/cross_connect_client/controllers/cross_connect.py @@ -0,0 +1,26 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import _ +from odoo.exceptions import UserError +from odoo.http import Controller, request, route + + +class CrossConnectController(Controller): + @route( + ["/cross_connect_server/"], + methods=["GET"], + type="http", + auth="public", + ) + def cross_connect( + self, + server_id, + **params, + ): + server = request.env["cross.connect.server"].sudo().browse(server_id) + if not server: + raise UserError(_("Server not found")) + + url = server._get_cross_connect_url() + return request.redirect(url, local=False) diff --git a/cross_connect_client/models/__init__.py b/cross_connect_client/models/__init__.py new file mode 100644 index 0000000000..d79d426eb1 --- /dev/null +++ b/cross_connect_client/models/__init__.py @@ -0,0 +1,2 @@ +from . import cross_connect_server +from . import res_groups diff --git a/cross_connect_client/models/cross_connect_server.py b/cross_connect_client/models/cross_connect_server.py new file mode 100644 index 0000000000..68d9ed0b19 --- /dev/null +++ b/cross_connect_client/models/cross_connect_server.py @@ -0,0 +1,176 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from urllib.parse import urlparse + +import requests + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class CrossConnectServer(models.Model): + _name = "cross.connect.server" + _description = "Cross Connect Server" + _inherit = "server.env.mixin" + + name = fields.Char( + required=True, compute="_compute_name", readonly=False, store=True + ) + server_url = fields.Char(required=True) + api_key = fields.Char( + required=True, + ) + group_ids = fields.One2many( + "res.groups", + inverse_name="cross_connect_server_id", + string="Cross Connect Server Groups", + readonly=True, + ) + menu_id = fields.Many2one( + "ir.ui.menu", + string="Menu", + help="Menu to display the Cross Connect Server in the menu", + compute="_compute_menu_id", + store=True, + ) + web_icon_data = fields.Binary( + compute="_compute_web_icon_data", inverse="_inverse_web_icon_data" + ) + + @api.depends("server_url") + def _compute_name(self): + for record in self: + if not record.name or record.name == "/": + try: + record.name = urlparse(record.server_url).hostname + except Exception: + record.name = "/" + + @api.depends("name", "group_ids") + def _compute_menu_id(self): + for record in self: + if not record.group_ids: + if record.menu_id: + if record.menu_id.action: + record.menu_id.action.unlink() + record.menu_id.unlink() + record.menu_id = False + continue + + menu_groups = self.env.ref("base.group_system") | record.group_ids + + if not record.menu_id: + action = self.env["ir.actions.act_url"].create( + { + "name": record.name, + "url": f"/cross_connect_server/{record.id}", + "target": "self", + } + ) + + record.menu_id = self.env["ir.ui.menu"].create( + { + "name": record.name, + "action": f"ir.actions.act_url,{action.id}", # noqa + "web_icon": "cross_connect_client,static/description/web_icon_data.png", + "groups_id": [(6, 0, menu_groups.ids)], + "sequence": 100, + } + ) + else: + record.menu_id.name = record.name + record.menu_id.groups_id = [(6, 0, menu_groups.ids)] + + @api.depends("menu_id") + def _compute_web_icon_data(self): + for record in self: + record.web_icon_data = record.menu_id.web_icon_data + + def _inverse_web_icon_data(self): + for record in self: + record.menu_id.web_icon_data = record.web_icon_data + + def _absolute_url_for(self, path): + return f"{self.server_url.rstrip('/')}/cross_connect/{path.lstrip('/')}" + + def _request(self, method, url, headers=None, data=None): + headers = headers or {} + headers["api-key"] = self.api_key + response = requests.request( + method, + self._absolute_url_for(url), + headers=headers, + json=data, + timeout=10, + ) + response.raise_for_status() + return response.json() + + def _get_cross_connect_url(self): + self.ensure_one() + groups = self.env.user.groups_id & self.group_ids + if not groups: + raise UserError(_("You are not allowed to access this server")) + + response = self._request( + "POST", + "/access", + data={ + "id": self.env.user.id, + "name": self.env.user.name, + "login": self.env.user.login, + "lang": self.env.user.lang, + "groups": [group.cross_connect_server_group_id for group in groups], + }, + ) + client_id = response.get("client_id") + token = response.get("token") + if not token: + raise UserError(_("Missing token")) + + return self._absolute_url_for(f"login/{client_id}/{token}") + + def _sync_groups(self): + self.ensure_one() + response = self._request("GET", "/sync") + remote_groups = response.get("groups", []) + # Removing groups that are not on the remote server + remote_groups_ids = {remote_group["id"] for remote_group in remote_groups} + self.group_ids.filtered( + lambda group: group.cross_connect_server_group_id not in remote_groups_ids + ).unlink() + + # Create or Update existing groups + for remote_group in remote_groups: + existing_group = self.group_ids.filtered( + lambda group: group.cross_connect_server_group_id == remote_group["id"] + ) + if existing_group: + existing_group.sudo().write( + { + "name": f"{self.name}: {remote_group['name']}", + "comment": remote_group["comment"], + } + ) + else: + self.env["res.groups"].sudo().create( + { + "cross_connect_server_id": self.id, + "cross_connect_server_group_id": remote_group["id"], + "name": f"{self.name}: {remote_group['name']}", + "comment": remote_group["comment"], + } + ) + + def action_sync(self): + for record in self: + record._sync_groups() + + def action_disable(self): + for record in self: + record.group_ids.unlink() + + @property + def _server_env_fields(self): + return {"api_key": {}} diff --git a/cross_connect_client/models/res_groups.py b/cross_connect_client/models/res_groups.py new file mode 100644 index 0000000000..64c95f1bb9 --- /dev/null +++ b/cross_connect_client/models/res_groups.py @@ -0,0 +1,24 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResGroups(models.Model): + _inherit = "res.groups" + + cross_connect_server_id = fields.Many2one( + "cross.connect.server", string="Originating Cross Connect Server" + ) + cross_connect_server_group_id = fields.Integer( + string="Originating Cross Connect Server Group ID" + ) + + _sql_constraints = [ + ( + "cross_connect_server_group_id_cross_connect_server_id_unique", + "unique (cross_connect_server_group_id, cross_connect_server_id)", + "Cross Connect Server Group ID must be unique per Cross Connect Server", + ) + ] diff --git a/cross_connect_client/readme/CONTRIBUTORS.md b/cross_connect_client/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..328a37da87 --- /dev/null +++ b/cross_connect_client/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Florian Mounier diff --git a/cross_connect_client/readme/DESCRIPTION.md b/cross_connect_client/readme/DESCRIPTION.md new file mode 100644 index 0000000000..b1f95a72ac --- /dev/null +++ b/cross_connect_client/readme/DESCRIPTION.md @@ -0,0 +1,2 @@ +This module allows this odoo instance users to connect directly on another odoo instance +where the module `cross_connect_server` is installed. diff --git a/cross_connect_client/readme/USAGE.md b/cross_connect_client/readme/USAGE.md new file mode 100644 index 0000000000..e16221aff0 --- /dev/null +++ b/cross_connect_client/readme/USAGE.md @@ -0,0 +1,23 @@ +First of all after installing the module, you need to configure the server connection. + +In order to do that, you need to go to the menu +`Settings > Technical > Cross Connect > Cross Connect Servers` and create a new server +to connect to. + +Fill the fields with the server's information : + +- Url: The api root path (e.g. `https://my-remote-odoo.com/api`) +- Api Key: The api-key from the `cross_connect_server` configuration + +Then click on the `Sync Cross Connection` button to check if the connection is working +and to sync the remote server's groups. + +After that, you will have to affect the remote groups to the local users in order for +them to be able to connect to the remote server. + +Once an user has a remote group, a new top level menu will appear in the menu bar with +the Cross Connect Server's name. Clicking on it will redirect the user to the remote +server logged in as the user. + +You can change each menu icon (for use with `web_responsive` for instance) by setting +the `Web Icon Data` in the server configuration. diff --git a/cross_connect_client/security/ir_model_access.xml b/cross_connect_client/security/ir_model_access.xml new file mode 100644 index 0000000000..56e3740c14 --- /dev/null +++ b/cross_connect_client/security/ir_model_access.xml @@ -0,0 +1,17 @@ + + + + + Cross Connect Server: Manager RW access + + + + + + + + diff --git a/cross_connect_client/static/description/index.html b/cross_connect_client/static/description/index.html new file mode 100644 index 0000000000..91e8634a6e --- /dev/null +++ b/cross_connect_client/static/description/index.html @@ -0,0 +1,450 @@ + + + + + +Cross Connect Client + + + +
+

Cross Connect Client

+ + +

Beta License: AGPL-3 OCA/server-auth Translate me on Weblate Try me on Runboat

+

This module allows this odoo instance users to connect directly on +another odoo instance where the module cross_connect_server is +installed.

+

Table of contents

+ +
+

Usage

+

First of all after installing the module, you need to configure the +server connection.

+

In order to do that, you need to go to the menu +Settings > Technical > Cross Connect > Cross Connect Servers and +create a new server to connect to.

+

Fill the fields with the server’s information :

+
    +
  • Url: The api root path (e.g. https://my-remote-odoo.com/api)
  • +
  • Api Key: The api-key from the cross_connect_server configuration
  • +
+

Then click on the Sync Cross Connection button to check if the +connection is working and to sync the remote server’s groups.

+

After that, you will have to affect the remote groups to the local users +in order for them to be able to connect to the remote server.

+

Once an user has a remote group, a new top level menu will appear in the +menu bar with the Cross Connect Server’s name. Clicking on it will +redirect the user to the remote server logged in as the user.

+

You can change each menu icon (for use with web_responsive for +instance) by setting the Web Icon Data in the server configuration.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Akretion
  • +
+
+ +
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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.

+

Current maintainer:

+

paradoxxxzero

+

This module is part of the OCA/server-auth project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/cross_connect_client/static/description/web_icon_data.png b/cross_connect_client/static/description/web_icon_data.png new file mode 100644 index 0000000000000000000000000000000000000000..db41199ed9f0412157de96382284240ac25551d6 GIT binary patch literal 26321 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 z +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from unittest.mock import Mock, patch + +from odoo.exceptions import UserError +from odoo.tests.common import HttpCase, TransactionCase + + +def _mock_json(data): + res = Mock() + res.json = lambda: data + return res + + +class TestCrossConnectClient(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.server = cls.env["cross.connect.server"].create( + { + "name": "Test Server", + "server_url": "http://test-server.example.com", + "api_key": "server-api-key", + } + ) + cls.env["cross.connect.server"].create( + { + "name": "Other Test Server", + "server_url": "http://other-test-server.example.com", + "api_key": "other-server-api-key", + } + ) + + def test_base(self): + self.assertFalse(self.server.group_ids) + self.assertFalse(self.server.menu_id) + self.assertFalse(self.server.web_icon_data) + + def test_name(self): + self.server.name = False + self.server.server_url = "//[" + self.assertEqual(self.server.name, "/") + self.server.server_url = "https://test.example.org" + self.assertEqual(self.server.name, "test.example.org") + + def test_absolute_url_for(self): + self.assertEqual( + self.server._absolute_url_for("test"), + "http://test-server.example.com/cross_connect/test", + ) + + self.assertEqual( + self.server._absolute_url_for("/test"), + "http://test-server.example.com/cross_connect/test", + ) + + self.server.server_url = "http://test-server.example.com/" + + self.assertEqual( + self.server._absolute_url_for("test"), + "http://test-server.example.com/cross_connect/test", + ) + + self.assertEqual( + self.server._absolute_url_for("/test"), + "http://test-server.example.com/cross_connect/test", + ) + + def test_menu(self): + self.assertFalse(self.server.menu_id) + self.server.group_ids = [(0, 0, {"name": "Test Group"})] + self.assertTrue(self.server.menu_id) + self.server.web_icon_data = b"YQ==" + self.assertEqual(self.server.menu_id.web_icon_data, b"YQ==") + self.server.action_disable() + self.assertFalse(self.server.menu_id) + + @patch("requests.request") + def test_sync(self, req): + req.return_value = _mock_json( + { + "groups": [ + {"id": 1, "name": "Test Group 1", "comment": "Comment 1"}, + {"id": 2, "name": "Test Group 2", "comment": "Comment 2"}, + ] + } + ) + + self.server.action_sync() + + req.assert_called_once_with( + "GET", + "http://test-server.example.com/cross_connect/sync", + headers={"api-key": "server-api-key"}, + json=None, + timeout=10, + ) + + self.assertEqual(len(self.server.group_ids), 2) + self.assertEqual(self.server.group_ids[0].name, "Test Server: Test Group 1") + self.assertEqual(self.server.group_ids[0].comment, "Comment 1") + self.assertEqual(self.server.group_ids[0].cross_connect_server_group_id, 1) + + self.assertEqual(self.server.group_ids[1].name, "Test Server: Test Group 2") + self.assertEqual(self.server.group_ids[1].comment, "Comment 2") + self.assertEqual(self.server.group_ids[1].cross_connect_server_group_id, 2) + + self.assertTrue(self.server.menu_id) + self.assertEqual(self.server.menu_id.name, "Test Server") + self.assertEqual( + self.server.menu_id.web_icon, + "cross_connect_client,static/description/web_icon_data.png", + ) + self.assertEqual( + self.server.menu_id.groups_id, + self.server.group_ids | self.env.ref("base.group_system"), + ) + self.assertTrue(self.server.menu_id.action.name, "Test Server") + self.assertEqual( + self.server.menu_id.action.url, f"/cross_connect_server/{self.server.id}" + ) + self.assertEqual(self.server.menu_id.action.target, "self") + + self.assertTrue(self.server.web_icon_data) + + @patch("requests.request") + def test_successive_sync(self, req): + req.return_value = _mock_json( + { + "groups": [ + {"id": 1, "name": "Test Group 1", "comment": "Comment 1"}, + {"id": 2, "name": "Test Group 2", "comment": "Comment 2"}, + ] + } + ) + + self.server.action_sync() + req.return_value = _mock_json( + { + "groups": [ + { + "id": 2, + "name": "Test Group 2 edited", + "comment": "Comment edited 2", + }, + {"id": 3, "name": "Test Group 3", "comment": "Comment 3"}, + ] + } + ) + + self.server.action_sync() + + self.assertEqual(len(self.server.group_ids), 2) + self.assertEqual( + self.server.group_ids[0].name, "Test Server: Test Group 2 edited" + ) + self.assertEqual(self.server.group_ids[0].comment, "Comment edited 2") + self.assertEqual(self.server.group_ids[0].cross_connect_server_group_id, 2) + + self.assertEqual(self.server.group_ids[1].name, "Test Server: Test Group 3") + self.assertEqual(self.server.group_ids[1].comment, "Comment 3") + self.assertEqual(self.server.group_ids[1].cross_connect_server_group_id, 3) + + self.assertTrue(self.server.menu_id) + self.assertTrue(self.server.web_icon_data) + + @patch("requests.request") + def test_get_cross_connect_url(self, req): + req.return_value = _mock_json( + { + "groups": [ + {"id": 1, "name": "Test Group 1", "comment": "Comment 1"}, + {"id": 2, "name": "Test Group 2", "comment": "Comment 2"}, + ] + } + ) + self.server.action_sync() + + user = self.env["res.users"].create({"name": "Test User", "login": "test_user"}) + group = self.server.group_ids[0] + user.write({"groups_id": [(4, group.id)]}) + + req.reset_mock() + req.return_value = _mock_json({"client_id": 1, "token": "test-token"}) + + self.assertEqual( + self.server.with_user(user).sudo()._get_cross_connect_url(), + "http://test-server.example.com/cross_connect/login/1/test-token", + ) + + req.assert_called_once_with( + "POST", + "http://test-server.example.com/cross_connect/access", + headers={"api-key": "server-api-key"}, + json={ + "id": user.id, + "name": "Test User", + "login": "test_user", + "lang": "en_US", + "groups": [group.cross_connect_server_group_id], + }, + timeout=10, + ) + req.reset_mock() + req.return_value = _mock_json({"client_id": 1}) + with self.assertRaisesRegex(UserError, "Missing token"): + self.server.with_user(user).sudo()._get_cross_connect_url() + + @patch("requests.request") + def test_get_cross_connect_url_bad_groups(self, req): + req.return_value = _mock_json( + { + "groups": [ + {"id": 1, "name": "Test Group 1", "comment": "Comment 1"}, + {"id": 2, "name": "Test Group 2", "comment": "Comment 2"}, + ] + } + ) + self.server.action_sync() + + user = self.env["res.users"].create({"name": "Test User", "login": "test_user"}) + + req.reset_mock() + req.return_value = _mock_json({"client_id": 1, "token": "test-token"}) + + with self.assertRaisesRegex( + UserError, "You are not allowed to access this server" + ): + self.server.with_user(user).sudo()._get_cross_connect_url() + + +class TestCrossConnectClientController(HttpCase): + def test_act_url_redirect(self): + server = self.env["cross.connect.server"].create( + { + "name": "Test Server", + "server_url": "http://test-server.example.com", + "api_key": "server-api-key", + } + ) + with patch( + "requests.request", + return_value=_mock_json( + { + "groups": [ + {"id": 1, "name": "Test Group 1", "comment": "Comment 1"}, + {"id": 2, "name": "Test Group 2", "comment": "Comment 2"}, + ] + } + ), + ): + server.action_sync() + + user = self.env["res.users"].create( + {"name": "Test User", "login": "test_user", "password": "user_pas$w0rd"} + ) + group = server.group_ids[0] + user.write({"groups_id": [(4, group.id)]}) + self.authenticate("test_user", "user_pas$w0rd") + with patch( + "requests.request", + return_value=_mock_json({"client_id": 1, "token": "test-token"}), + ): + resp = self.url_open(server.menu_id.action.url, allow_redirects=False) + resp.raise_for_status() + self.assertEqual(resp.status_code, 303) + self.assertEqual( + resp.headers["Location"], + "http://test-server.example.com/cross_connect/login/1/test-token", + ) + + def test_bad_server(self): + self.assertFalse(self.env["cross.connect.server"].search([])) + resp = self.url_open("/cross_connect_server/1", allow_redirects=False) + self.assertEqual(resp.status_code, 400) diff --git a/cross_connect_client/views/cross_connect_server_views.xml b/cross_connect_client/views/cross_connect_server_views.xml new file mode 100644 index 0000000000..9c317a1220 --- /dev/null +++ b/cross_connect_client/views/cross_connect_server_views.xml @@ -0,0 +1,93 @@ + + + + + Cross Connect Servers + cross.connect.server + tree,form + + + + cross.connect.server.form + cross.connect.server + +
+
+
+ + +
+

+ +

+
+ + + + + + + + + +
+
+
+
+ + + cross.connect.server.tree + cross.connect.server + + + + + + + + + + + +
From 2a0c42ea0951bf79f7ba694ef4f77e74317000cb Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Thu, 20 Mar 2025 12:39:03 +0100 Subject: [PATCH 02/10] [IMP] cross_connect_client: Handle redirect_url in controller --- cross_connect_client/controllers/cross_connect.py | 2 +- cross_connect_client/models/cross_connect_server.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/cross_connect_client/controllers/cross_connect.py b/cross_connect_client/controllers/cross_connect.py index 51975106d6..2018b843a5 100644 --- a/cross_connect_client/controllers/cross_connect.py +++ b/cross_connect_client/controllers/cross_connect.py @@ -22,5 +22,5 @@ def cross_connect( if not server: raise UserError(_("Server not found")) - url = server._get_cross_connect_url() + url = server._get_cross_connect_url(request.params.get("redirect_url")) return request.redirect(url, local=False) diff --git a/cross_connect_client/models/cross_connect_server.py b/cross_connect_client/models/cross_connect_server.py index 68d9ed0b19..a9f87ad235 100644 --- a/cross_connect_client/models/cross_connect_server.py +++ b/cross_connect_client/models/cross_connect_server.py @@ -107,7 +107,7 @@ def _request(self, method, url, headers=None, data=None): response.raise_for_status() return response.json() - def _get_cross_connect_url(self): + def _get_cross_connect_url(self, redirect_url=None): self.ensure_one() groups = self.env.user.groups_id & self.group_ids if not groups: @@ -122,6 +122,7 @@ def _get_cross_connect_url(self): "login": self.env.user.login, "lang": self.env.user.lang, "groups": [group.cross_connect_server_group_id for group in groups], + "redirect_url": redirect_url, }, ) client_id = response.get("client_id") From d1c817ca4aad055e23b85a03307890910c7f6afe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Wed, 26 Mar 2025 23:20:52 +0100 Subject: [PATCH 03/10] cross_connect_client: only pass redirect_url if set --- .../models/cross_connect_server.py | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/cross_connect_client/models/cross_connect_server.py b/cross_connect_client/models/cross_connect_server.py index a9f87ad235..0d8702fa53 100644 --- a/cross_connect_client/models/cross_connect_server.py +++ b/cross_connect_client/models/cross_connect_server.py @@ -113,18 +113,17 @@ def _get_cross_connect_url(self, redirect_url=None): if not groups: raise UserError(_("You are not allowed to access this server")) - response = self._request( - "POST", - "/access", - data={ - "id": self.env.user.id, - "name": self.env.user.name, - "login": self.env.user.login, - "lang": self.env.user.lang, - "groups": [group.cross_connect_server_group_id for group in groups], - "redirect_url": redirect_url, - }, - ) + data = { + "id": self.env.user.id, + "name": self.env.user.name, + "login": self.env.user.login, + "lang": self.env.user.lang, + "groups": [group.cross_connect_server_group_id for group in groups], + } + if redirect_url: + data["redirect_url"] = redirect_url + + response = self._request("POST", "/access", data=data) client_id = response.get("client_id") token = response.get("token") if not token: From 93c29694fb61d573f71bbc31df4fc35247fa7d91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Wed, 26 Mar 2025 23:23:40 +0100 Subject: [PATCH 04/10] cross_connect_client: by default open in a new windows as we change of domain name --- cross_connect_client/models/cross_connect_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cross_connect_client/models/cross_connect_server.py b/cross_connect_client/models/cross_connect_server.py index 0d8702fa53..2119121553 100644 --- a/cross_connect_client/models/cross_connect_server.py +++ b/cross_connect_client/models/cross_connect_server.py @@ -65,7 +65,7 @@ def _compute_menu_id(self): { "name": record.name, "url": f"/cross_connect_server/{record.id}", - "target": "self", + "target": "new", } ) From d986d95e80181ef39765171da7a59ff04a09117a Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Mon, 31 Mar 2025 18:04:52 +0200 Subject: [PATCH 05/10] [IMP] cross_connect_client: Require and send mail --- .../models/cross_connect_server.py | 4 +++ .../tests/test_cross_connect_client.py | 26 ++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/cross_connect_client/models/cross_connect_server.py b/cross_connect_client/models/cross_connect_server.py index 2119121553..fcea3a7855 100644 --- a/cross_connect_client/models/cross_connect_server.py +++ b/cross_connect_client/models/cross_connect_server.py @@ -113,10 +113,14 @@ def _get_cross_connect_url(self, redirect_url=None): if not groups: raise UserError(_("You are not allowed to access this server")) + if not self.env.user.email: + raise UserError(_("User email is required")) + data = { "id": self.env.user.id, "name": self.env.user.name, "login": self.env.user.login, + "email": self.env.user.email, "lang": self.env.user.lang, "groups": [group.cross_connect_server_group_id for group in groups], } diff --git a/cross_connect_client/tests/test_cross_connect_client.py b/cross_connect_client/tests/test_cross_connect_client.py index d9eac73c21..e7c96dda76 100644 --- a/cross_connect_client/tests/test_cross_connect_client.py +++ b/cross_connect_client/tests/test_cross_connect_client.py @@ -121,7 +121,7 @@ def test_sync(self, req): self.assertEqual( self.server.menu_id.action.url, f"/cross_connect_server/{self.server.id}" ) - self.assertEqual(self.server.menu_id.action.target, "self") + self.assertEqual(self.server.menu_id.action.target, "new") self.assertTrue(self.server.web_icon_data) @@ -178,7 +178,13 @@ def test_get_cross_connect_url(self, req): ) self.server.action_sync() - user = self.env["res.users"].create({"name": "Test User", "login": "test_user"}) + user = self.env["res.users"].create( + { + "name": "Test User", + "login": "test_user", + "email": "test@example.com", + } + ) group = self.server.group_ids[0] user.write({"groups_id": [(4, group.id)]}) @@ -198,6 +204,7 @@ def test_get_cross_connect_url(self, req): "id": user.id, "name": "Test User", "login": "test_user", + "email": "test@example.com", "lang": "en_US", "groups": [group.cross_connect_server_group_id], }, @@ -220,7 +227,13 @@ def test_get_cross_connect_url_bad_groups(self, req): ) self.server.action_sync() - user = self.env["res.users"].create({"name": "Test User", "login": "test_user"}) + user = self.env["res.users"].create( + { + "name": "Test User", + "login": "test_user", + "email": "test@example.com", + } + ) req.reset_mock() req.return_value = _mock_json({"client_id": 1, "token": "test-token"}) @@ -254,7 +267,12 @@ def test_act_url_redirect(self): server.action_sync() user = self.env["res.users"].create( - {"name": "Test User", "login": "test_user", "password": "user_pas$w0rd"} + { + "name": "Test User", + "login": "test_user", + "email": "test@example.com", + "password": "user_pas$w0rd", + } ) group = server.group_ids[0] user.write({"groups_id": [(4, group.id)]}) From ca79384bb0217983643be38e8537985983c1ea15 Mon Sep 17 00:00:00 2001 From: Florian da Costa Date: Thu, 3 Apr 2025 12:08:32 +0200 Subject: [PATCH 06/10] [IMP] Remove default value for the name of the server --- cross_connect_client/models/cross_connect_server.py | 13 ++----------- .../views/cross_connect_server_views.xml | 2 +- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/cross_connect_client/models/cross_connect_server.py b/cross_connect_client/models/cross_connect_server.py index fcea3a7855..f39209098e 100644 --- a/cross_connect_client/models/cross_connect_server.py +++ b/cross_connect_client/models/cross_connect_server.py @@ -1,7 +1,6 @@ # Copyright 2024 Akretion (http://www.akretion.com). # @author Florian Mounier # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from urllib.parse import urlparse import requests @@ -15,7 +14,8 @@ class CrossConnectServer(models.Model): _inherit = "server.env.mixin" name = fields.Char( - required=True, compute="_compute_name", readonly=False, store=True + required=True, + help="This name will be used for the new created app", ) server_url = fields.Char(required=True) api_key = fields.Char( @@ -38,15 +38,6 @@ class CrossConnectServer(models.Model): compute="_compute_web_icon_data", inverse="_inverse_web_icon_data" ) - @api.depends("server_url") - def _compute_name(self): - for record in self: - if not record.name or record.name == "/": - try: - record.name = urlparse(record.server_url).hostname - except Exception: - record.name = "/" - @api.depends("name", "group_ids") def _compute_menu_id(self): for record in self: diff --git a/cross_connect_client/views/cross_connect_server_views.xml b/cross_connect_client/views/cross_connect_server_views.xml index 9c317a1220..55d31e8a21 100644 --- a/cross_connect_client/views/cross_connect_server_views.xml +++ b/cross_connect_client/views/cross_connect_server_views.xml @@ -38,7 +38,7 @@ bg_color="bg-warning" attrs="{'invisible': [('group_ids', '!=', [])]}" /> -
+

From b2b8ff0d8006bff8252f938c90d8380f2041a634 Mon Sep 17 00:00:00 2001 From: Florian da Costa Date: Thu, 3 Apr 2025 15:16:23 +0200 Subject: [PATCH 07/10] [IMP] Avoid deleting the cross connect server groups when unactiving it We want to avoid deletion of the group because we would then loose all users configured on it. So we prefer to remove the link between the cross connect server and the group instead --- .../models/cross_connect_server.py | 18 +++++++++++++----- .../tests/test_cross_connect_client.py | 12 +----------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/cross_connect_client/models/cross_connect_server.py b/cross_connect_client/models/cross_connect_server.py index f39209098e..9543a5616b 100644 --- a/cross_connect_client/models/cross_connect_server.py +++ b/cross_connect_client/models/cross_connect_server.py @@ -49,7 +49,7 @@ def _compute_menu_id(self): record.menu_id = False continue - menu_groups = self.env.ref("base.group_system") | record.group_ids + menu_groups = record.group_ids if not record.menu_id: action = self.env["ir.actions.act_url"].create( @@ -134,13 +134,15 @@ def _sync_groups(self): remote_groups_ids = {remote_group["id"] for remote_group in remote_groups} self.group_ids.filtered( lambda group: group.cross_connect_server_group_id not in remote_groups_ids - ).unlink() + ).write({"cross_connect_server_id": False}) # Create or Update existing groups for remote_group in remote_groups: - existing_group = self.group_ids.filtered( - lambda group: group.cross_connect_server_group_id == remote_group["id"] + existing_group = self.env["res.groups"].search( + [("cross_connect_server_group_id", "=", remote_group["id"])] ) + if existing_group and not existing_group.cross_connect_server_id: + existing_group.write({"cross_connect_server_id": self.id}) if existing_group: existing_group.sudo().write( { @@ -164,8 +166,14 @@ def action_sync(self): def action_disable(self): for record in self: - record.group_ids.unlink() + record.group_ids.write({"cross_connect_server_id": False}) @property def _server_env_fields(self): return {"api_key": {}} + + def unlink(self): + for rec in self: + # deleting the groups will delete the menu and related action. + rec.group_ids.unlink() + return super().unlink() diff --git a/cross_connect_client/tests/test_cross_connect_client.py b/cross_connect_client/tests/test_cross_connect_client.py index e7c96dda76..c231f10f6c 100644 --- a/cross_connect_client/tests/test_cross_connect_client.py +++ b/cross_connect_client/tests/test_cross_connect_client.py @@ -38,13 +38,6 @@ def test_base(self): self.assertFalse(self.server.menu_id) self.assertFalse(self.server.web_icon_data) - def test_name(self): - self.server.name = False - self.server.server_url = "//[" - self.assertEqual(self.server.name, "/") - self.server.server_url = "https://test.example.org" - self.assertEqual(self.server.name, "test.example.org") - def test_absolute_url_for(self): self.assertEqual( self.server._absolute_url_for("test"), @@ -113,10 +106,7 @@ def test_sync(self, req): self.server.menu_id.web_icon, "cross_connect_client,static/description/web_icon_data.png", ) - self.assertEqual( - self.server.menu_id.groups_id, - self.server.group_ids | self.env.ref("base.group_system"), - ) + self.assertEqual(self.server.menu_id.groups_id, self.server.group_ids) self.assertTrue(self.server.menu_id.action.name, "Test Server") self.assertEqual( self.server.menu_id.action.url, f"/cross_connect_server/{self.server.id}" From 663550a008c73436987ca12c0d81a32439ac7b61 Mon Sep 17 00:00:00 2001 From: TB-Ph35 Date: Thu, 3 Apr 2025 19:25:40 +0200 Subject: [PATCH 08/10] [IMP] cross_connect_client: pre-commit execution --- cross_connect_client/README.rst | 10 +++++----- cross_connect_client/pyproject.toml | 3 +++ cross_connect_client/static/description/index.html | 6 +++--- 3 files changed, 11 insertions(+), 8 deletions(-) create mode 100644 cross_connect_client/pyproject.toml diff --git a/cross_connect_client/README.rst b/cross_connect_client/README.rst index 9fe0d561ee..c2afa8f9e2 100644 --- a/cross_connect_client/README.rst +++ b/cross_connect_client/README.rst @@ -17,13 +17,13 @@ Cross Connect Client :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--auth-lightgray.png?logo=github - :target: https://github.com/OCA/server-auth/tree/16.0/cross_connect_client + :target: https://github.com/OCA/server-auth/tree/18.0/cross_connect_client :alt: OCA/server-auth .. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png - :target: https://translation.odoo-community.org/projects/server-auth-16-0/server-auth-16-0-cross_connect_client + :target: https://translation.odoo-community.org/projects/server-auth-18-0/server-auth-18-0-cross_connect_client :alt: Translate me on Weblate .. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png - :target: https://runboat.odoo-community.org/builds?repo=OCA/server-auth&target_branch=16.0 + :target: https://runboat.odoo-community.org/builds?repo=OCA/server-auth&target_branch=18.0 :alt: Try me on Runboat |badge1| |badge2| |badge3| |badge4| |badge5| @@ -71,7 +71,7 @@ Bug Tracker Bugs are tracked on `GitHub Issues `_. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed -`feedback `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -109,6 +109,6 @@ Current `maintainer `__: |maintainer-paradoxxxzero| -This module is part of the `OCA/server-auth `_ project on GitHub. +This module is part of the `OCA/server-auth `_ project on GitHub. You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/cross_connect_client/pyproject.toml b/cross_connect_client/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/cross_connect_client/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/cross_connect_client/static/description/index.html b/cross_connect_client/static/description/index.html index 91e8634a6e..b17abdbd9d 100644 --- a/cross_connect_client/static/description/index.html +++ b/cross_connect_client/static/description/index.html @@ -369,7 +369,7 @@

Cross Connect Client

!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !! source digest: sha256:f10ceaed1b91df49c3a9b4e8acdef3d412d7ce50105f6f1ca752630fc1559d8e !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -

Beta License: AGPL-3 OCA/server-auth Translate me on Weblate Try me on Runboat

+

Beta License: AGPL-3 OCA/server-auth Translate me on Weblate Try me on Runboat

This module allows this odoo instance users to connect directly on another odoo instance where the module cross_connect_server is installed.

@@ -413,7 +413,7 @@

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed -feedback.

+feedback.

Do not contact contributors directly about support or help with technical issues.

@@ -441,7 +441,7 @@

Maintainers

promote its widespread use.

Current maintainer:

paradoxxxzero

-

This module is part of the OCA/server-auth project on GitHub.

+

This module is part of the OCA/server-auth project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

From dcada6feece999badfe3abcb08933c61d5cfd4f8 Mon Sep 17 00:00:00 2001 From: TB-Ph35 Date: Thu, 3 Apr 2025 19:25:40 +0200 Subject: [PATCH 09/10] [MIG] cross_connect_client: Migration to 18.0 --- cross_connect_client/__manifest__.py | 2 +- .../controllers/cross_connect.py | 3 +-- .../models/cross_connect_server.py | 12 ++++++------ .../tests/test_cross_connect_client.py | 4 +++- .../views/cross_connect_server_views.xml | 19 ++++++++----------- 5 files changed, 19 insertions(+), 21 deletions(-) diff --git a/cross_connect_client/__manifest__.py b/cross_connect_client/__manifest__.py index 76dc53a043..d4152ebcb0 100644 --- a/cross_connect_client/__manifest__.py +++ b/cross_connect_client/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Cross Connect Client", - "version": "16.0.1.0.0", + "version": "18.0.1.0.0", "author": "Akretion, Odoo Community Association (OCA)", "summary": "Cross Connect Client allows to connect to a " "Cross Connect Server enabled odoo instance.", diff --git a/cross_connect_client/controllers/cross_connect.py b/cross_connect_client/controllers/cross_connect.py index 2018b843a5..ea752f4cd6 100644 --- a/cross_connect_client/controllers/cross_connect.py +++ b/cross_connect_client/controllers/cross_connect.py @@ -1,7 +1,6 @@ # Copyright 2024 Akretion (http://www.akretion.com). # @author Florian Mounier # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo import _ from odoo.exceptions import UserError from odoo.http import Controller, request, route @@ -20,7 +19,7 @@ def cross_connect( ): server = request.env["cross.connect.server"].sudo().browse(server_id) if not server: - raise UserError(_("Server not found")) + raise UserError(request.env._("Server not found")) url = server._get_cross_connect_url(request.params.get("redirect_url")) return request.redirect(url, local=False) diff --git a/cross_connect_client/models/cross_connect_server.py b/cross_connect_client/models/cross_connect_server.py index 9543a5616b..f0b3dce29c 100644 --- a/cross_connect_client/models/cross_connect_server.py +++ b/cross_connect_client/models/cross_connect_server.py @@ -4,7 +4,7 @@ import requests -from odoo import _, api, fields, models +from odoo import api, fields, models from odoo.exceptions import UserError @@ -59,12 +59,12 @@ def _compute_menu_id(self): "target": "new", } ) - + icon = "cross_connect_client,static/description/web_icon_data.png" record.menu_id = self.env["ir.ui.menu"].create( { "name": record.name, "action": f"ir.actions.act_url,{action.id}", # noqa - "web_icon": "cross_connect_client,static/description/web_icon_data.png", + "web_icon": icon, "groups_id": [(6, 0, menu_groups.ids)], "sequence": 100, } @@ -102,10 +102,10 @@ def _get_cross_connect_url(self, redirect_url=None): self.ensure_one() groups = self.env.user.groups_id & self.group_ids if not groups: - raise UserError(_("You are not allowed to access this server")) + raise UserError(self.env._("You are not allowed to access this server")) if not self.env.user.email: - raise UserError(_("User email is required")) + raise UserError(self.env._("User email is required")) data = { "id": self.env.user.id, @@ -122,7 +122,7 @@ def _get_cross_connect_url(self, redirect_url=None): client_id = response.get("client_id") token = response.get("token") if not token: - raise UserError(_("Missing token")) + raise UserError(self.env._("Missing token")) return self._absolute_url_for(f"login/{client_id}/{token}") diff --git a/cross_connect_client/tests/test_cross_connect_client.py b/cross_connect_client/tests/test_cross_connect_client.py index c231f10f6c..d792ff8727 100644 --- a/cross_connect_client/tests/test_cross_connect_client.py +++ b/cross_connect_client/tests/test_cross_connect_client.py @@ -5,6 +5,7 @@ from odoo.exceptions import UserError from odoo.tests.common import HttpCase, TransactionCase +from odoo.tools import mute_logger def _mock_json(data): @@ -281,5 +282,6 @@ def test_act_url_redirect(self): def test_bad_server(self): self.assertFalse(self.env["cross.connect.server"].search([])) - resp = self.url_open("/cross_connect_server/1", allow_redirects=False) + with mute_logger("odoo.http"): + resp = self.url_open("/cross_connect_server/1", allow_redirects=False) self.assertEqual(resp.status_code, 400) diff --git a/cross_connect_client/views/cross_connect_server_views.xml b/cross_connect_client/views/cross_connect_server_views.xml index 55d31e8a21..91658b3d4b 100644 --- a/cross_connect_client/views/cross_connect_server_views.xml +++ b/cross_connect_client/views/cross_connect_server_views.xml @@ -8,7 +8,7 @@ Cross Connect Servers cross.connect.server - tree,form + list,form @@ -22,13 +22,13 @@ type="object" string="Sync Cross Connection" class="btn-primary" - attrs="{'invisible': ['|', ('server_url', '=', False), ('api_key', '=', False)]}" + invisible="not server_url or not api_key" />