From c3e5a28f0e5da5d89a7d348816d92f0696a323f9 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Thu, 12 Dec 2024 17:51:57 +0100 Subject: [PATCH] [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 | 276 +++++++++++ .../views/cross_connect_server_views.xml | 93 ++++ .../odoo/addons/cross_connect_client | 1 + setup/cross_connect_client/setup.py | 6 + 19 files changed, 1237 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 create mode 120000 setup/cross_connect_client/odoo/addons/cross_connect_client create mode 100644 setup/cross_connect_client/setup.py 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={ + "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 + + + + + + + + + + + +
diff --git a/setup/cross_connect_client/odoo/addons/cross_connect_client b/setup/cross_connect_client/odoo/addons/cross_connect_client new file mode 120000 index 0000000000..9142e33086 --- /dev/null +++ b/setup/cross_connect_client/odoo/addons/cross_connect_client @@ -0,0 +1 @@ +../../../../cross_connect_client \ No newline at end of file diff --git a/setup/cross_connect_client/setup.py b/setup/cross_connect_client/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/cross_connect_client/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)