diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..0cb8c43d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +# generated from manifests external_dependencies +oauthlib +requests-oauthlib diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 00000000..2cb24f43 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1 @@ +responses diff --git a/webservice/README.rst b/webservice/README.rst new file mode 100644 index 00000000..1a68888b --- /dev/null +++ b/webservice/README.rst @@ -0,0 +1,86 @@ +========== +WebService +========== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:44ac7b2e56db131da94f2a4ed85bb452eae8657d761490ffa97b08b268d6f9fe + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Production%2FStable-green.png + :target: https://odoo-community.org/page/development-status + :alt: Production/Stable +.. |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%2Fweb--api-lightgray.png?logo=github + :target: https://github.com/OCA/web-api/tree/17.0/webservice + :alt: OCA/web-api +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/web-api-17-0/web-api-17-0-webservice + :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/web-api&target_branch=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module creates WebService frameworks to be used globally + +**Table of contents** + +.. contents:: + :local: + +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 +------- + +* Creu Blanca +* Camptocamp + +Contributors +------------ + +- Enric Tobella +- Alexandre Fayolle + +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-etobella| image:: https://github.com/etobella.png?size=40px + :target: https://github.com/etobella + :alt: etobella + +Current `maintainer `__: + +|maintainer-etobella| + +This module is part of the `OCA/web-api `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/webservice/__init__.py b/webservice/__init__.py new file mode 100644 index 00000000..f64c35ff --- /dev/null +++ b/webservice/__init__.py @@ -0,0 +1,3 @@ +from . import components +from . import models +from . import controllers diff --git a/webservice/__manifest__.py b/webservice/__manifest__.py new file mode 100644 index 00000000..4523c8b1 --- /dev/null +++ b/webservice/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2020 Creu Blanca +# Copyright 2022 Camptocamp SA +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "WebService", + "summary": """Defines webservice abstract definition to be used generally""", + "version": "17.0.1.0.0", + "license": "AGPL-3", + "development_status": "Production/Stable", + "maintainers": ["etobella"], + "author": "Creu Blanca, Camptocamp, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/web-api", + "depends": ["component", "server_environment"], + "external_dependencies": {"python": ["requests-oauthlib", "oauthlib"]}, + "data": [ + "security/ir.model.access.csv", + "security/ir_rule.xml", + "views/webservice_backend.xml", + ], + "demo": [], +} diff --git a/webservice/components/__init__.py b/webservice/components/__init__.py new file mode 100644 index 00000000..4647bfb3 --- /dev/null +++ b/webservice/components/__init__.py @@ -0,0 +1,2 @@ +from . import base_adapter +from . import request_adapter diff --git a/webservice/components/base_adapter.py b/webservice/components/base_adapter.py new file mode 100644 index 00000000..5a76d62a --- /dev/null +++ b/webservice/components/base_adapter.py @@ -0,0 +1,20 @@ +# Copyright 2020 Creu Blanca +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import AbstractComponent + + +class BaseWebServiceAdapter(AbstractComponent): + _name = "base.webservice.adapter" + _collection = "webservice.backend" + _webservice_protocol = False + _usage = "webservice.request" + + @classmethod + def _component_match(cls, work, usage=None, model_name=None, **kw): + """Override to customize match. + + Registry lookup filtered by usage and model_name when landing here. + Now, narrow match to `_match_attrs` attributes. + """ + return kw.get("webservice_protocol") in (None, cls._webservice_protocol) diff --git a/webservice/components/request_adapter.py b/webservice/components/request_adapter.py new file mode 100644 index 00000000..24e18e90 --- /dev/null +++ b/webservice/components/request_adapter.py @@ -0,0 +1,247 @@ +# Copyright 2020 Creu Blanca +# Copyright 2022 Camptocamp SA +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import json +import logging +import time + +import requests +from oauthlib.oauth2 import BackendApplicationClient, WebApplicationClient +from requests_oauthlib import OAuth2Session + +from odoo.addons.component.core import Component + +_logger = logging.getLogger(__name__) + + +class BaseRestRequestsAdapter(Component): + _name = "base.requests" + _webservice_protocol = "http" + _inherit = "base.webservice.adapter" + + # TODO: url and url_params could come from work_ctx + def _request(self, method, url=None, url_params=None, **kwargs): + url = self._get_url(url=url, url_params=url_params) + new_kwargs = kwargs.copy() + new_kwargs.update( + { + "auth": self._get_auth(**kwargs), + "headers": self._get_headers(**kwargs), + "timeout": None, + } + ) + # pylint: disable=E8106 + request = requests.request(method, url, **new_kwargs) + request.raise_for_status() + return request.content + + def get(self, **kwargs): + return self._request("get", **kwargs) + + def post(self, **kwargs): + return self._request("post", **kwargs) + + def put(self, **kwargs): + return self._request("put", **kwargs) + + def _get_auth(self, auth=False, **kwargs): + if auth: + return auth + handler = getattr(self, "_get_auth_for_" + self.collection.auth_type, None) + return handler(**kwargs) if handler else None + + def _get_auth_for_user_pwd(self, **kw): + if self.collection.username and self.collection.password: + return self.collection.username, self.collection.password + return None + + def _get_headers(self, content_type=False, headers=False, **kwargs): + headers = headers or {} + result = { + "Content-Type": content_type or self.collection.content_type, + } + handler = getattr(self, "_get_headers_for_" + self.collection.auth_type, None) + if handler: + headers.update(handler(**kwargs)) + result.update(headers) + return result + + def _get_headers_for_api_key(self, **kw): + return {self.collection.api_key_header: self.collection.api_key} + + def _get_url(self, url=None, url_params=None, **kwargs): + if not url: + url = self.collection.url + elif not url.startswith(self.collection.url): + if not url.startswith("http"): + url = f"{self.collection.url.rstrip('/')}/{url.lstrip('/')}" + else: + # TODO: if url is given, we should validate the domain + # to avoid abusing a webservice backend for different calls. + pass + + url_params = url_params or kwargs + return url.format(**url_params) + + +class BackendApplicationOAuth2RestRequestsAdapter(Component): + _name = "oauth2.requests.backend.application" + _webservice_protocol = "http+oauth2-backend_application" + _inherit = "base.requests" + + def get_client(self, oauth_params: dict): + return BackendApplicationClient(client_id=oauth_params["oauth2_clientid"]) + + def __init__(self, *args, **kw): + super().__init__(*args, **kw) + # cached value to avoid hitting the database each time we need the token + self._token = {} + + def _is_token_valid(self, token): + """Validate given oauth2 token. + + We consider that a token in valid if it has at least 10% of + its valid duration. So if a token has a validity of 1h, we will + renew it if we try to use it 6 minutes before its expiration date. + """ + expires_at = token.get("expires_at", 0) + expires_in = token.get("expires_in", 3600) # default to 1h + now = time.time() + return now <= (expires_at - 0.1 * expires_in) + + @property + def token(self): + """Return a valid oauth2 token. + + The tokens are stored in the database, and we check if they are still + valid, and renew them if needed. + """ + if self._is_token_valid(self._token): + return self._token + backend = self.collection + with backend.env.registry.cursor() as cr: + cr.execute( + "SELECT oauth2_token FROM webservice_backend " + "WHERE id=%s " + "FOR NO KEY UPDATE", # prevent concurrent token fetching + (backend.id,), + ) + token_str = cr.fetchone()[0] or "{}" + token = json.loads(token_str) + if self._is_token_valid(token): + self._token = token + else: + new_token = self._fetch_new_token(old_token=token) + cr.execute( + "UPDATE webservice_backend " "SET oauth2_token=%s " "WHERE id=%s", + (json.dumps(new_token), backend.id), + ) + self._token = new_token + return self._token + + def _fetch_new_token(self, old_token): + # TODO: check if the old token has a refresh_token that can + # be used (and use it in that case) + oauth_params = self.collection.sudo().read( + [ + "oauth2_clientid", + "oauth2_client_secret", + "oauth2_token_url", + "oauth2_audience", + "redirect_url", + ] + )[0] + client = self.get_client(oauth_params) + with OAuth2Session(client=client) as session: + token = session.fetch_token( + token_url=oauth_params["oauth2_token_url"], + cliend_id=oauth_params["oauth2_clientid"], + client_secret=oauth_params["oauth2_client_secret"], + audience=oauth_params.get("oauth2_audience") or "", + ) + return token + + def _request(self, method, url=None, url_params=None, **kwargs): + url = self._get_url(url=url, url_params=url_params) + new_kwargs = kwargs.copy() + new_kwargs.update( + { + "headers": self._get_headers(**kwargs), + "timeout": None, + } + ) + client = BackendApplicationClient(client_id=self.collection.oauth2_clientid) + with OAuth2Session(client=client, token=self.token) as session: + # pylint: disable=E8106 + request = session.request(method, url, **new_kwargs) + request.raise_for_status() + return request.content + + +class WebApplicationOAuth2RestRequestsAdapter(Component): + _name = "oauth2.requests.web.application" + _webservice_protocol = "http+oauth2-web_application" + _inherit = "oauth2.requests.backend.application" + + def get_client(self, oauth_params: dict): + return WebApplicationClient( + client_id=oauth_params["oauth2_clientid"], + code=oauth_params.get("oauth2_autorization"), + redirect_uri=oauth_params["redirect_url"], + ) + + def _fetch_token_from_authorization(self, authorization_code): + oauth_params = self.collection.sudo().read( + [ + "oauth2_clientid", + "oauth2_client_secret", + "oauth2_token_url", + "oauth2_audience", + "redirect_url", + ] + )[0] + client = WebApplicationClient(client_id=oauth_params["oauth2_clientid"]) + + with OAuth2Session( + client=client, redirect_uri=oauth_params.get("redirect_url") + ) as session: + token = session.fetch_token( + oauth_params["oauth2_token_url"], + client_secret=oauth_params["oauth2_client_secret"], + code=authorization_code, + audience=oauth_params.get("oauth2_audience") or "", + include_client_id=True, + ) + return token + + def redirect_to_authorize(self, **authorization_url_extra_params): + """set the oauth2_state on the backend + :return: the webservice authorization url with the proper parameters + """ + # we are normally authenticated at this stage, so no need to sudo() + backend = self.collection + oauth_params = backend.read( + [ + "oauth2_clientid", + "oauth2_token_url", + "oauth2_audience", + "oauth2_authorization_url", + "oauth2_scope", + "redirect_url", + ] + )[0] + client = WebApplicationClient( + client_id=oauth_params["oauth2_clientid"], + ) + + with OAuth2Session( + client=client, + redirect_uri=oauth_params.get("redirect_url"), + ) as session: + authorization_url, state = session.authorization_url( + backend.oauth2_authorization_url, **authorization_url_extra_params + ) + backend.oauth2_state = state + return authorization_url diff --git a/webservice/controllers/__init__.py b/webservice/controllers/__init__.py new file mode 100644 index 00000000..005c729e --- /dev/null +++ b/webservice/controllers/__init__.py @@ -0,0 +1 @@ +from . import oauth2 diff --git a/webservice/controllers/oauth2.py b/webservice/controllers/oauth2.py new file mode 100644 index 00000000..b064b824 --- /dev/null +++ b/webservice/controllers/oauth2.py @@ -0,0 +1,63 @@ +# Copyright 2024 Camptocamp SA +# @author Alexandre Fayolle +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import json +import logging + +from oauthlib.oauth2.rfc6749 import errors +from werkzeug.urls import url_encode + +from odoo import http +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class OAuth2Controller(http.Controller): + @http.route( + "/webservice//oauth2/redirect", + type="http", + auth="public", + csrf=False, + ) + def redirect(self, backend_id, **params): + backend = request.env["webservice.backend"].browse(backend_id).sudo() + if backend.auth_type != "oauth2" or backend.oauth2_flow != "web_application": + _logger.error("unexpected backed config for backend %d", backend_id) + raise errors.MismatchingRedirectURIError() + expected_state = backend.oauth2_state + state = params.get("state") + if state != expected_state: + _logger.error("unexpected state: %s", state) + raise errors.MismatchingStateError() + code = params.get("code") + adapter = ( + backend._get_adapter() + ) # we expect an adapter that supports web_application + token = adapter._fetch_token_from_authorization(code) + backend.write( + { + "oauth2_token": json.dumps(token), + "oauth2_state": False, + } + ) + # after saving the token, redirect to the backend form view + uid = request.session.uid + user = request.env["res.users"].sudo().browse(uid) + cids = request.httprequest.cookies.get("cids", str(user.company_id.id)) + cids = [int(cid) for cid in cids.split(",")] + record_action = backend._get_access_action() + url_params = { + "model": backend._name, + "id": backend.id, + "active_id": backend.id, + "action": record_action.get("id"), + } + view_id = backend.get_formview_id() + if view_id: + url_params["view_id"] = view_id + + if cids: + url_params["cids"] = ",".join([str(cid) for cid in cids]) + url = "/web?#%s" % url_encode(url_params) + return request.redirect(url) diff --git a/webservice/i18n/fr.po b/webservice/i18n/fr.po new file mode 100644 index 00000000..a76baec9 --- /dev/null +++ b/webservice/i18n/fr.po @@ -0,0 +1,407 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * webservice +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2021-06-17 15:48+0000\n" +"Last-Translator: Yves Le Doeuff \n" +"Language-Team: none\n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n > 1;\n" +"X-Generator: Weblate 4.3.2\n" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__api_key +#: model:ir.model.fields.selection,name:webservice.selection__webservice_backend__auth_type__api_key +msgid "API Key" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__api_key_env_default +msgid "API Key Env Default" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__api_key_header +msgid "API Key header" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__api_key_header_env_default +msgid "API Key header Env Default" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__api_key_env_is_editable +msgid "Api Key Env Is Editable" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__api_key_header_env_is_editable +msgid "Api Key Header Env Is Editable" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_audience +msgid "Audience" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_audience_env_default +msgid "Audience Env Default" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__auth_type +msgid "Auth Type" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__auth_type_env_default +msgid "Auth Type Env Default" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__auth_type_env_is_editable +msgid "Auth Type Env Is Editable" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_authorization_url +msgid "Authorization URL" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_authorization_url_env_default +msgid "Authorization URL Env Default" +msgstr "" + +#. module: webservice +#: model:ir.model.fields.selection,name:webservice.selection__webservice_backend__oauth2_flow__backend_application +msgid "Backend Application (Client Credentials Grant)" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_clientid +msgid "Client ID" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_clientid_env_default +msgid "Client ID Env Default" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_client_secret +msgid "Client Secret" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_client_secret_env_default +msgid "Client Secret Env Default" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__company_id +msgid "Company" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__content_type +msgid "Content Type" +msgstr "Type de contenu" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__content_type_env_default +msgid "Content Type Env Default" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__content_type_env_is_editable +msgid "Content Type Env Is Editable" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__create_uid +msgid "Created by" +msgstr "Créé par" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__create_date +msgid "Created on" +msgstr "Crée le" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__display_name +msgid "Display Name" +msgstr "Afficher Nom" + +#. module: webservice +#: model:ir.model.fields.selection,name:webservice.selection__webservice_backend__content_type__application/x-www-form-urlencoded +msgid "Form" +msgstr "Formulaire" + +#. module: webservice +#: model:ir.model.fields.selection,name:webservice.selection__webservice_backend__protocol__http +msgid "HTTP Request" +msgstr "Requête HTTP" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__id +msgid "ID" +msgstr "ID" + +#. module: webservice +#: model:ir.model.fields.selection,name:webservice.selection__webservice_backend__content_type__application/json +msgid "JSON" +msgstr "JSON" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend____last_update +msgid "Last Modified on" +msgstr "Dernière modification le" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__write_uid +msgid "Last Updated by" +msgstr "Dernière mise à jour par" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__write_date +msgid "Last Updated on" +msgstr "Dernière mise à jour le" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__name +msgid "Name" +msgstr "Nom" + +#. module: webservice +#: model_terms:ir.ui.view,arch_db:webservice.webservice_backend_form_view +msgid "OAuth Authorize" +msgstr "" + +#. module: webservice +#: model:ir.model.fields.selection,name:webservice.selection__webservice_backend__auth_type__oauth2 +msgid "OAuth2" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_audience_env_is_editable +msgid "Oauth2 Audience Env Is Editable" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_authorization_url_env_is_editable +msgid "Oauth2 Authorization Url Env Is Editable" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_client_secret_env_is_editable +msgid "Oauth2 Client Secret Env Is Editable" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_clientid_env_is_editable +msgid "Oauth2 Clientid Env Is Editable" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_flow +msgid "Oauth2 Flow" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_flow_env_default +msgid "Oauth2 Flow Env Default" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_flow_env_is_editable +msgid "Oauth2 Flow Env Is Editable" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_scope +msgid "Oauth2 Scope" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_scope_env_default +msgid "Oauth2 Scope Env Default" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_scope_env_is_editable +msgid "Oauth2 Scope Env Is Editable" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_state +msgid "Oauth2 State" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_token +msgid "Oauth2 Token" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_token_url_env_is_editable +msgid "Oauth2 Token Url Env Is Editable" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__password +msgid "Password" +msgstr "Mot de passe" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__password_env_default +msgid "Password Env Default" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__password_env_is_editable +msgid "Password Env Is Editable" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__protocol +msgid "Protocol" +msgstr "Protocole" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__protocol_env_default +msgid "Protocol Env Default" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__protocol_env_is_editable +msgid "Protocol Env Is Editable" +msgstr "" + +#. module: webservice +#: model:ir.model.fields.selection,name:webservice.selection__webservice_backend__auth_type__none +msgid "Public" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__redirect_url +msgid "Redirect Url" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__server_env_defaults +msgid "Server Env Defaults" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__tech_name +msgid "Tech Name" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,help:webservice.field_webservice_backend__redirect_url +msgid "The redirect URL to be used as part of the OAuth2 authorisation flow" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_token_url +msgid "Token URL" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_token_url_env_default +msgid "Token URL Env Default" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,help:webservice.field_webservice_backend__tech_name +msgid "Unique name for technical purposes. Eg: server env keys." +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__url +msgid "Url" +msgstr "Url" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__url_env_default +msgid "Url Env Default" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__url_env_is_editable +msgid "Url Env Is Editable" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__username +msgid "Username" +msgstr "nom d'utilisateur :" + +#. module: webservice +#: model:ir.model.fields.selection,name:webservice.selection__webservice_backend__auth_type__user_pwd +msgid "Username & password" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__username_env_default +msgid "Username Env Default" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__username_env_is_editable +msgid "Username Env Is Editable" +msgstr "" + +#. module: webservice +#: model:ir.model.fields.selection,name:webservice.selection__webservice_backend__oauth2_flow__web_application +msgid "Web Application (Authorization Code Grant)" +msgstr "" + +#. module: webservice +#: model:ir.actions.act_window,name:webservice.webservice_backend_act_window +#: model:ir.model,name:webservice.model_webservice_backend +#: model:ir.ui.menu,name:webservice.webservice_backend_menu +msgid "WebService Backend" +msgstr "" + +#. module: webservice +#. odoo-python +#: code:addons/webservice/models/webservice_backend.py:0 +#, python-format +msgid "" +"Webservice '%(name)s' requires '%(auth_type)s' authentication. However, the " +"following field(s) are not valued: %(fields)s" +msgstr "" + +#. module: webservice +#: model:ir.model.fields.selection,name:webservice.selection__webservice_backend__content_type__application/xml +msgid "XML" +msgstr "XLM" + +#. module: webservice +#: model:ir.model.fields,help:webservice.field_webservice_backend__oauth2_state +msgid "" +"random key generated when authorization flow starts to ensure that no CSRF " +"attack happen" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,help:webservice.field_webservice_backend__oauth2_scope +#: model:ir.model.fields,help:webservice.field_webservice_backend__oauth2_scope_env_default +msgid "scope of the the authorization" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,help:webservice.field_webservice_backend__oauth2_token +msgid "the OAuth2 token (serialized JSON)" +msgstr "" diff --git a/webservice/i18n/it.po b/webservice/i18n/it.po new file mode 100644 index 00000000..989cba5f --- /dev/null +++ b/webservice/i18n/it.po @@ -0,0 +1,412 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * webservice +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-05-23 08:43+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.17\n" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__api_key +#: model:ir.model.fields.selection,name:webservice.selection__webservice_backend__auth_type__api_key +msgid "API Key" +msgstr "Chiave API" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__api_key_env_default +msgid "API Key Env Default" +msgstr "Chiave API predefinita dell'ambiente" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__api_key_header +msgid "API Key header" +msgstr "Intestazione chiave API" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__api_key_header_env_default +msgid "API Key header Env Default" +msgstr "Intestazione chiave API predefinita dell'ambiente" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__api_key_env_is_editable +msgid "Api Key Env Is Editable" +msgstr "La chiave API ambene è modificabile" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__api_key_header_env_is_editable +msgid "Api Key Header Env Is Editable" +msgstr "L'intestazione chiave API ambene è modificabile" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_audience +msgid "Audience" +msgstr "Audience" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_audience_env_default +msgid "Audience Env Default" +msgstr "Audience predefinito dell'ambiente" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__auth_type +msgid "Auth Type" +msgstr "Tipo autorizzazione" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__auth_type_env_default +msgid "Auth Type Env Default" +msgstr "Tipo autoizzazione predefinito dell'ambiente" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__auth_type_env_is_editable +msgid "Auth Type Env Is Editable" +msgstr "Il tipo autrizzazione ambiente è modificabile" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_authorization_url +msgid "Authorization URL" +msgstr "URL autorizzazione" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_authorization_url_env_default +msgid "Authorization URL Env Default" +msgstr "URL autorizzazione predefinito dell'ambiente" + +#. module: webservice +#: model:ir.model.fields.selection,name:webservice.selection__webservice_backend__oauth2_flow__backend_application +msgid "Backend Application (Client Credentials Grant)" +msgstr "Applicazione backend (concessione credenziali client)" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_clientid +msgid "Client ID" +msgstr "ID client" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_clientid_env_default +msgid "Client ID Env Default" +msgstr "ID client predefinito dell'ambiente" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_client_secret +msgid "Client Secret" +msgstr "Chiave segreta client" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_client_secret_env_default +msgid "Client Secret Env Default" +msgstr "Chiave csegreta client predefinita dell'ambiente" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__company_id +msgid "Company" +msgstr "Azienda" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__content_type +msgid "Content Type" +msgstr "Tipo di contenuto" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__content_type_env_default +msgid "Content Type Env Default" +msgstr "Tipo contenuto predefinito dell'ambiente" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__content_type_env_is_editable +msgid "Content Type Env Is Editable" +msgstr "Il tipo contenuto ambiente è modificabile" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__create_uid +msgid "Created by" +msgstr "Creato da" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__create_date +msgid "Created on" +msgstr "Creato il" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__display_name +msgid "Display Name" +msgstr "Nome visualizzato" + +#. module: webservice +#: model:ir.model.fields.selection,name:webservice.selection__webservice_backend__content_type__application/x-www-form-urlencoded +msgid "Form" +msgstr "Maschera" + +#. module: webservice +#: model:ir.model.fields.selection,name:webservice.selection__webservice_backend__protocol__http +msgid "HTTP Request" +msgstr "Richiesta HTTP" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__id +msgid "ID" +msgstr "ID" + +#. module: webservice +#: model:ir.model.fields.selection,name:webservice.selection__webservice_backend__content_type__application/json +msgid "JSON" +msgstr "JSON" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend____last_update +msgid "Last Modified on" +msgstr "Ultima modifica il" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__write_uid +msgid "Last Updated by" +msgstr "Ultimo aggiornamento di" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__write_date +msgid "Last Updated on" +msgstr "Ultimo aggiornamento il" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__name +msgid "Name" +msgstr "Nome" + +#. module: webservice +#: model_terms:ir.ui.view,arch_db:webservice.webservice_backend_form_view +msgid "OAuth Authorize" +msgstr "Autorizzazione OAuth" + +#. module: webservice +#: model:ir.model.fields.selection,name:webservice.selection__webservice_backend__auth_type__oauth2 +msgid "OAuth2" +msgstr "OAuth2" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_audience_env_is_editable +msgid "Oauth2 Audience Env Is Editable" +msgstr "Audience OAuth2 ambiente è modificabile" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_authorization_url_env_is_editable +msgid "Oauth2 Authorization Url Env Is Editable" +msgstr "URL autorizzazione OAuth2 è modificabile" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_client_secret_env_is_editable +msgid "Oauth2 Client Secret Env Is Editable" +msgstr "Chiave segreta OAuth client è modificabile" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_clientid_env_is_editable +msgid "Oauth2 Clientid Env Is Editable" +msgstr "ID client OAuth2 ambiente è modificabile" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_flow +msgid "Oauth2 Flow" +msgstr "Flusso OAuth2" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_flow_env_default +msgid "Oauth2 Flow Env Default" +msgstr "Flusso OAuth2 predefinito dell'ambiente" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_flow_env_is_editable +msgid "Oauth2 Flow Env Is Editable" +msgstr "Flusso OAuth2 ambiente è modificabile" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_scope +msgid "Oauth2 Scope" +msgstr "Ambito OAuth2" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_scope_env_default +msgid "Oauth2 Scope Env Default" +msgstr "Ambito OAuth2 predefinito dell'ambiente" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_scope_env_is_editable +msgid "Oauth2 Scope Env Is Editable" +msgstr "Ambito OAuth2 ambiente è modificabile" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_state +msgid "Oauth2 State" +msgstr "Stato OAuth2" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_token +msgid "Oauth2 Token" +msgstr "Token OAuth2" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_token_url_env_is_editable +msgid "Oauth2 Token Url Env Is Editable" +msgstr "URL token OAuth2 ambiente è modificabile" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__password +msgid "Password" +msgstr "Password" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__password_env_default +msgid "Password Env Default" +msgstr "Password predefinita dell'ambiente" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__password_env_is_editable +msgid "Password Env Is Editable" +msgstr "Password ambiente è modificabile" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__protocol +msgid "Protocol" +msgstr "Protcollo" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__protocol_env_default +msgid "Protocol Env Default" +msgstr "Protocollo predefinito dell'ambiente" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__protocol_env_is_editable +msgid "Protocol Env Is Editable" +msgstr "Protocollo ambiente è modificabile" + +#. module: webservice +#: model:ir.model.fields.selection,name:webservice.selection__webservice_backend__auth_type__none +msgid "Public" +msgstr "Pubblico" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__redirect_url +msgid "Redirect Url" +msgstr "URL inoltro" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__server_env_defaults +msgid "Server Env Defaults" +msgstr "Server predefinito dell'ambiente" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__tech_name +msgid "Tech Name" +msgstr "Nome tecnico" + +#. module: webservice +#: model:ir.model.fields,help:webservice.field_webservice_backend__redirect_url +msgid "The redirect URL to be used as part of the OAuth2 authorisation flow" +msgstr "" +"L'URL di inoltro da utilizzare come parte del flusso di autorizzazione OAuth2" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_token_url +msgid "Token URL" +msgstr "URL token" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_token_url_env_default +msgid "Token URL Env Default" +msgstr "URL token predefinito dell'ambiente" + +#. module: webservice +#: model:ir.model.fields,help:webservice.field_webservice_backend__tech_name +msgid "Unique name for technical purposes. Eg: server env keys." +msgstr "Nome univoco per motivi tecnici. Es: chiavi server ambiente." + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__url +msgid "Url" +msgstr "URL" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__url_env_default +msgid "Url Env Default" +msgstr "URL predefinito dell'ambiente" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__url_env_is_editable +msgid "Url Env Is Editable" +msgstr "URL ambiene è modificabile" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__username +msgid "Username" +msgstr "Nome utente" + +#. module: webservice +#: model:ir.model.fields.selection,name:webservice.selection__webservice_backend__auth_type__user_pwd +msgid "Username & password" +msgstr "Nome utente e password" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__username_env_default +msgid "Username Env Default" +msgstr "Nome utente predefinito dell'ambiente" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__username_env_is_editable +msgid "Username Env Is Editable" +msgstr "Nome utente ambiente è modificabile" + +#. module: webservice +#: model:ir.model.fields.selection,name:webservice.selection__webservice_backend__oauth2_flow__web_application +msgid "Web Application (Authorization Code Grant)" +msgstr "Applicazione Web (Concessione codice autorizzazione)" + +#. module: webservice +#: model:ir.actions.act_window,name:webservice.webservice_backend_act_window +#: model:ir.model,name:webservice.model_webservice_backend +#: model:ir.ui.menu,name:webservice.webservice_backend_menu +msgid "WebService Backend" +msgstr "Backend servizio web" + +#. module: webservice +#. odoo-python +#: code:addons/webservice/models/webservice_backend.py:0 +#, python-format +msgid "" +"Webservice '%(name)s' requires '%(auth_type)s' authentication. However, the " +"following field(s) are not valued: %(fields)s" +msgstr "" +"Il webservice '%(name)s' richiede autentIcazione '%(auth_type)s'. Comunque, " +"i seguenti campi non sono calorizzati: %(fields)s" + +#. module: webservice +#: model:ir.model.fields.selection,name:webservice.selection__webservice_backend__content_type__application/xml +msgid "XML" +msgstr "XML" + +#. module: webservice +#: model:ir.model.fields,help:webservice.field_webservice_backend__oauth2_state +msgid "" +"random key generated when authorization flow starts to ensure that no CSRF " +"attack happen" +msgstr "" +"chiave causale generata quando il flusso autorizzazione inizia per " +"assicurare che non si verifichi un accatto CSRF" + +#. module: webservice +#: model:ir.model.fields,help:webservice.field_webservice_backend__oauth2_scope +#: model:ir.model.fields,help:webservice.field_webservice_backend__oauth2_scope_env_default +msgid "scope of the the authorization" +msgstr "ambito dell'autorizzazione" + +#. module: webservice +#: model:ir.model.fields,help:webservice.field_webservice_backend__oauth2_token +msgid "the OAuth2 token (serialized JSON)" +msgstr "il token OAuth2 (JSON serializzato)" diff --git a/webservice/i18n/webservice.pot b/webservice/i18n/webservice.pot new file mode 100644 index 00000000..465f2bd3 --- /dev/null +++ b/webservice/i18n/webservice.pot @@ -0,0 +1,404 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * webservice +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__api_key +#: model:ir.model.fields.selection,name:webservice.selection__webservice_backend__auth_type__api_key +msgid "API Key" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__api_key_env_default +msgid "API Key Env Default" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__api_key_header +msgid "API Key header" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__api_key_header_env_default +msgid "API Key header Env Default" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__api_key_env_is_editable +msgid "Api Key Env Is Editable" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__api_key_header_env_is_editable +msgid "Api Key Header Env Is Editable" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_audience +msgid "Audience" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_audience_env_default +msgid "Audience Env Default" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__auth_type +msgid "Auth Type" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__auth_type_env_default +msgid "Auth Type Env Default" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__auth_type_env_is_editable +msgid "Auth Type Env Is Editable" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_authorization_url +msgid "Authorization URL" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_authorization_url_env_default +msgid "Authorization URL Env Default" +msgstr "" + +#. module: webservice +#: model:ir.model.fields.selection,name:webservice.selection__webservice_backend__oauth2_flow__backend_application +msgid "Backend Application (Client Credentials Grant)" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_clientid +msgid "Client ID" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_clientid_env_default +msgid "Client ID Env Default" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_client_secret +msgid "Client Secret" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_client_secret_env_default +msgid "Client Secret Env Default" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__company_id +msgid "Company" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__content_type +msgid "Content Type" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__content_type_env_default +msgid "Content Type Env Default" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__content_type_env_is_editable +msgid "Content Type Env Is Editable" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__create_uid +msgid "Created by" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__create_date +msgid "Created on" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__display_name +msgid "Display Name" +msgstr "" + +#. module: webservice +#: model:ir.model.fields.selection,name:webservice.selection__webservice_backend__content_type__application/x-www-form-urlencoded +msgid "Form" +msgstr "" + +#. module: webservice +#: model:ir.model.fields.selection,name:webservice.selection__webservice_backend__protocol__http +msgid "HTTP Request" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__id +msgid "ID" +msgstr "" + +#. module: webservice +#: model:ir.model.fields.selection,name:webservice.selection__webservice_backend__content_type__application/json +msgid "JSON" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend____last_update +msgid "Last Modified on" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__write_date +msgid "Last Updated on" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__name +msgid "Name" +msgstr "" + +#. module: webservice +#: model_terms:ir.ui.view,arch_db:webservice.webservice_backend_form_view +msgid "OAuth Authorize" +msgstr "" + +#. module: webservice +#: model:ir.model.fields.selection,name:webservice.selection__webservice_backend__auth_type__oauth2 +msgid "OAuth2" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_audience_env_is_editable +msgid "Oauth2 Audience Env Is Editable" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_authorization_url_env_is_editable +msgid "Oauth2 Authorization Url Env Is Editable" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_client_secret_env_is_editable +msgid "Oauth2 Client Secret Env Is Editable" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_clientid_env_is_editable +msgid "Oauth2 Clientid Env Is Editable" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_flow +msgid "Oauth2 Flow" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_flow_env_default +msgid "Oauth2 Flow Env Default" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_flow_env_is_editable +msgid "Oauth2 Flow Env Is Editable" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_scope +msgid "Oauth2 Scope" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_scope_env_default +msgid "Oauth2 Scope Env Default" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_scope_env_is_editable +msgid "Oauth2 Scope Env Is Editable" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_state +msgid "Oauth2 State" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_token +msgid "Oauth2 Token" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_token_url_env_is_editable +msgid "Oauth2 Token Url Env Is Editable" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__password +msgid "Password" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__password_env_default +msgid "Password Env Default" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__password_env_is_editable +msgid "Password Env Is Editable" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__protocol +msgid "Protocol" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__protocol_env_default +msgid "Protocol Env Default" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__protocol_env_is_editable +msgid "Protocol Env Is Editable" +msgstr "" + +#. module: webservice +#: model:ir.model.fields.selection,name:webservice.selection__webservice_backend__auth_type__none +msgid "Public" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__redirect_url +msgid "Redirect Url" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__server_env_defaults +msgid "Server Env Defaults" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__tech_name +msgid "Tech Name" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,help:webservice.field_webservice_backend__redirect_url +msgid "The redirect URL to be used as part of the OAuth2 authorisation flow" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_token_url +msgid "Token URL" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__oauth2_token_url_env_default +msgid "Token URL Env Default" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,help:webservice.field_webservice_backend__tech_name +msgid "Unique name for technical purposes. Eg: server env keys." +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__url +msgid "Url" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__url_env_default +msgid "Url Env Default" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__url_env_is_editable +msgid "Url Env Is Editable" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__username +msgid "Username" +msgstr "" + +#. module: webservice +#: model:ir.model.fields.selection,name:webservice.selection__webservice_backend__auth_type__user_pwd +msgid "Username & password" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__username_env_default +msgid "Username Env Default" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,field_description:webservice.field_webservice_backend__username_env_is_editable +msgid "Username Env Is Editable" +msgstr "" + +#. module: webservice +#: model:ir.model.fields.selection,name:webservice.selection__webservice_backend__oauth2_flow__web_application +msgid "Web Application (Authorization Code Grant)" +msgstr "" + +#. module: webservice +#: model:ir.actions.act_window,name:webservice.webservice_backend_act_window +#: model:ir.model,name:webservice.model_webservice_backend +#: model:ir.ui.menu,name:webservice.webservice_backend_menu +msgid "WebService Backend" +msgstr "" + +#. module: webservice +#. odoo-python +#: code:addons/webservice/models/webservice_backend.py:0 +#, python-format +msgid "" +"Webservice '%(name)s' requires '%(auth_type)s' authentication. However, the " +"following field(s) are not valued: %(fields)s" +msgstr "" + +#. module: webservice +#: model:ir.model.fields.selection,name:webservice.selection__webservice_backend__content_type__application/xml +msgid "XML" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,help:webservice.field_webservice_backend__oauth2_state +msgid "" +"random key generated when authorization flow starts to ensure that no CSRF " +"attack happen" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,help:webservice.field_webservice_backend__oauth2_scope +#: model:ir.model.fields,help:webservice.field_webservice_backend__oauth2_scope_env_default +msgid "scope of the the authorization" +msgstr "" + +#. module: webservice +#: model:ir.model.fields,help:webservice.field_webservice_backend__oauth2_token +msgid "the OAuth2 token (serialized JSON)" +msgstr "" diff --git a/webservice/models/__init__.py b/webservice/models/__init__.py new file mode 100644 index 00000000..c08fb9a9 --- /dev/null +++ b/webservice/models/__init__.py @@ -0,0 +1 @@ +from . import webservice_backend diff --git a/webservice/models/webservice_backend.py b/webservice/models/webservice_backend.py new file mode 100644 index 00000000..a9121121 --- /dev/null +++ b/webservice/models/webservice_backend.py @@ -0,0 +1,177 @@ +# Copyright 2020 Creu Blanca +# Copyright 2022 Camptocamp SA +# @author Simone Orsi +# @author Alexandre Fayolle +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging + +from odoo import _, api, exceptions, fields, models + +_logger = logging.getLogger(__name__) + + +class WebserviceBackend(models.Model): + _name = "webservice.backend" + _inherit = ["collection.base", "server.env.techname.mixin", "server.env.mixin"] + _description = "WebService Backend" + + name = fields.Char(required=True) + tech_name = fields.Char(required=True) + protocol = fields.Selection([("http", "HTTP Request")], required=True) + url = fields.Char(required=True) + auth_type = fields.Selection( + selection=[ + ("none", "Public"), + ("user_pwd", "Username & password"), + ("api_key", "API Key"), + ("oauth2", "OAuth2"), + ], + required=True, + ) + username = fields.Char(auth_type="user_pwd") + password = fields.Char(auth_type="user_pwd") + api_key = fields.Char(string="API Key", auth_type="api_key") + api_key_header = fields.Char(string="API Key header", auth_type="api_key") + oauth2_flow = fields.Selection( + [ + ("backend_application", "Backend Application (Client Credentials Grant)"), + ("web_application", "Web Application (Authorization Code Grant)"), + ], + readonly=False, + ) + oauth2_clientid = fields.Char(string="Client ID", auth_type="oauth2") + oauth2_client_secret = fields.Char(string="Client Secret", auth_type="oauth2") + oauth2_token_url = fields.Char(string="Token URL", auth_type="oauth2") + oauth2_authorization_url = fields.Char(string="Authorization URL") + oauth2_audience = fields.Char( + string="Audience" + # no auth_type because not required + ) + oauth2_scope = fields.Char(help="scope of the the authorization") + oauth2_token = fields.Char(help="the OAuth2 token (serialized JSON)") + redirect_url = fields.Char( + compute="_compute_redirect_url", + help="The redirect URL to be used as part of the OAuth2 authorisation flow", + ) + oauth2_state = fields.Char( + help="random key generated when authorization flow starts " + "to ensure that no CSRF attack happen" + ) + content_type = fields.Selection( + [ + ("application/json", "JSON"), + ("application/xml", "XML"), + ("application/x-www-form-urlencoded", "Form"), + ], + required=True, + ) + company_id = fields.Many2one("res.company", string="Company") + + @api.constrains("auth_type") + def _check_auth_type(self): + valid_fields = { + k: v for k, v in self._fields.items() if hasattr(v, "auth_type") + } + for rec in self: + if rec.auth_type == "none": + continue + _fields = [v for v in valid_fields.values() if v.auth_type == rec.auth_type] + missing = [] + for _field in _fields: + if not rec[_field.name]: + missing.append(_field) + if missing: + raise exceptions.UserError(rec._msg_missing_auth_param(missing)) + + def _msg_missing_auth_param(self, missing_fields): + def get_selection_value(fname): + return self._fields.get(fname).convert_to_export(self[fname], self) + + return _( + "Webservice '%(name)s' requires '%(auth_type)s' authentication. " + "However, the following field(s) are not valued: %(fields)s" + ) % { + "name": self.name, + "auth_type": get_selection_value("auth_type"), + "fields": ", ".join([f.string for f in missing_fields]), + } + + def _valid_field_parameter(self, field, name): + extra_params = ("auth_type",) + return name in extra_params or super()._valid_field_parameter(field, name) + + def call(self, method, *args, **kwargs): + _logger.debug("backend %s: call %s %s %s", self.name, method, args, kwargs) + response = getattr(self._get_adapter(), method)(*args, **kwargs) + _logger.debug("backend %s: response: \n%s", self.name, response) + return response + + def _get_adapter(self): + with self.work_on(self._name) as work: + return work.component( + usage="webservice.request", + webservice_protocol=self._get_adapter_protocol(), + ) + + def _get_adapter_protocol(self): + protocol = self.protocol + if self.auth_type.startswith("oauth2"): + protocol += f"+{self.auth_type}-{self.oauth2_flow}" + return protocol + + @api.depends("auth_type", "oauth2_flow") + def _compute_redirect_url(self): + get_param = self.env["ir.config_parameter"].sudo().get_param + base_url = get_param("web.base.url") + if base_url.startswith("http://"): + _logger.warning( + "web.base.url is configured in http. Oauth2 requires using https" + ) + base_url = base_url[len("http://") :] + if not base_url.startswith("https://"): + base_url = f"https://{base_url}" + for rec in self: + if rec.auth_type == "oauth2" and rec.oauth2_flow == "web_application": + rec.redirect_url = f"{base_url}/webservice/{rec.id}/oauth2/redirect" + else: + rec.redirect_url = False + + def button_authorize(self): + _logger.info("Button OAuth2 Authorize") + authorize_url = self._get_adapter().redirect_to_authorize() + _logger.info("Redirecting to %s", authorize_url) + return { + "type": "ir.actions.act_url", + "url": authorize_url, + "target": "self", + } + + @property + def _server_env_fields(self): + base_fields = super()._server_env_fields + webservice_fields = { + "protocol": {}, + "url": {}, + "auth_type": {}, + "username": {}, + "password": {}, + "api_key": {}, + "api_key_header": {}, + "content_type": {}, + "oauth2_flow": {}, + "oauth2_scope": {}, + "oauth2_clientid": {}, + "oauth2_client_secret": {}, + "oauth2_authorization_url": {}, + "oauth2_token_url": {}, + "oauth2_audience": {}, + } + webservice_fields.update(base_fields) + return webservice_fields + + def _compute_server_env(self): + # OVERRIDE: reset ``oauth2_flow`` when ``auth_type`` is not "oauth2", even if + # defined otherwise in server env vars + res = super()._compute_server_env() + self.filtered(lambda r: r.auth_type != "oauth2").oauth2_flow = None + return res diff --git a/webservice/pyproject.toml b/webservice/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/webservice/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/webservice/readme/CONTRIBUTORS.md b/webservice/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..0efd1962 --- /dev/null +++ b/webservice/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Enric Tobella \<\> +- Alexandre Fayolle \<\> diff --git a/webservice/readme/DESCRIPTION.md b/webservice/readme/DESCRIPTION.md new file mode 100644 index 00000000..db2c75a7 --- /dev/null +++ b/webservice/readme/DESCRIPTION.md @@ -0,0 +1 @@ +This module creates WebService frameworks to be used globally diff --git a/webservice/security/ir.model.access.csv b/webservice/security/ir.model.access.csv new file mode 100644 index 00000000..f8bb5166 --- /dev/null +++ b/webservice/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_webservice_backend_edit,webservice_backend edit,model_webservice_backend,base.group_system,1,1,1,1 diff --git a/webservice/security/ir_rule.xml b/webservice/security/ir_rule.xml new file mode 100644 index 00000000..367dfb6c --- /dev/null +++ b/webservice/security/ir_rule.xml @@ -0,0 +1,10 @@ + + + + webservice_backend multi-company + + + ['|', ('company_id','=',False), ('company_id', 'in', company_ids)] + + + diff --git a/webservice/static/description/icon.png b/webservice/static/description/icon.png new file mode 100644 index 00000000..3a0328b5 Binary files /dev/null and b/webservice/static/description/icon.png differ diff --git a/webservice/static/description/index.html b/webservice/static/description/index.html new file mode 100644 index 00000000..20438887 --- /dev/null +++ b/webservice/static/description/index.html @@ -0,0 +1,427 @@ + + + + + +WebService + + + +
+

WebService

+ + +

Production/Stable License: AGPL-3 OCA/web-api Translate me on Weblate Try me on Runboat

+

This module creates WebService frameworks to be used globally

+

Table of contents

+ +
+

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

+
    +
  • Creu Blanca
  • +
  • Camptocamp
  • +
+
+
+

Contributors

+ +
+
+

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:

+

etobella

+

This module is part of the OCA/web-api project on GitHub.

+

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

+
+
+
+ + diff --git a/webservice/tests/__init__.py b/webservice/tests/__init__.py new file mode 100644 index 00000000..4f39eaf7 --- /dev/null +++ b/webservice/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_oauth2 +from . import test_webservice diff --git a/webservice/tests/common.py b/webservice/tests/common.py new file mode 100644 index 00000000..aa5f3d72 --- /dev/null +++ b/webservice/tests/common.py @@ -0,0 +1,70 @@ +# Copyright 2020 Creu Blanca +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from contextlib import contextmanager +from unittest import mock +from urllib.parse import urlparse + +from requests import PreparedRequest, Session + +from odoo.tests.common import tagged + +from odoo.addons.component.tests.common import TransactionComponentCase + + +@tagged("-at_install", "post_install") +class CommonWebService(TransactionComponentCase): + @classmethod + def _setup_context(cls): + return dict( + cls.env.context, + tracking_disable=True, + test_queue_job_no_delay=True, + ) + + @classmethod + def _setup_env(cls): + cls.env = cls.env(context=cls._setup_context()) + + @classmethod + def _setup_records(cls): + pass + + @classmethod + def setUpClass(cls): + cls._super_send = Session.send + super().setUpClass() + cls._setup_env() + cls._setup_records() + + @classmethod + def _request_handler(cls, s: Session, r: PreparedRequest, /, **kw): + if urlparse(r.url).netloc in ("localhost.demo.odoo", "custom.url"): + return cls._super_send(s, r) + return super()._request_handler(s, r, **kw) + + +@contextmanager +def mock_cursor(cr): + # Preserve the original methods and attributes + org_close = cr.close + org_autocommit = cr._cnx.autocommit + org_commit = cr.commit + + try: + # Mock methods and attributes + cr.close = mock.Mock() + cr.commit = mock.Mock() + # Mocking the autocommit attribute + mock_autocommit = mock.PropertyMock(return_value=False) + type(cr._cnx).autocommit = mock_autocommit + + # Mock the cursor method to return the current cr + with mock.patch("odoo.sql_db.Connection.cursor", return_value=cr): + yield cr + + finally: + # Restore the original methods and attributes + cr.close = org_close + cr.commit = org_commit + # Restore the original autocommit property + type(cr._cnx).autocommit = org_autocommit diff --git a/webservice/tests/test_oauth2.py b/webservice/tests/test_oauth2.py new file mode 100644 index 00000000..fd64f087 --- /dev/null +++ b/webservice/tests/test_oauth2.py @@ -0,0 +1,333 @@ +# Copyright 2023 Camptocamp SA +# @author Alexandre Fayolle +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import json +import os +import time +from unittest import mock +from urllib.parse import quote + +import responses +from oauthlib.oauth2.rfc6749.errors import InvalidGrantError + +from odoo.tests.common import Form + +from odoo.addons.server_environment import server_env +from odoo.addons.server_environment.models import server_env_mixin + +from .common import CommonWebService, mock_cursor + + +class TestWebServiceOauth2BackendApplication(CommonWebService): + @classmethod + def _setup_records(cls): + res = super()._setup_records() + cls.url = "https://localhost.demo.odoo/" + os.environ["SERVER_ENV_CONFIG"] = "\n".join( + [ + "[webservice_backend.test_oauth2_back]", + "auth_type = oauth2", + "oauth2_flow = backend_application", + "oauth2_clientid = some_client_id", + "oauth2_client_secret = shh_secret", + f"oauth2_token_url = {cls.url}oauth2/token", + f"oauth2_audience = {cls.url}", + ] + ) + cls.webservice = cls.env["webservice.backend"].create( + { + "name": "WebService OAuth2", + "tech_name": "test_oauth2_back", + "auth_type": "oauth2", + "protocol": "http", + "url": cls.url, + "oauth2_flow": "backend_application", + "content_type": "application/xml", + "oauth2_clientid": "some_client_id", + "oauth2_client_secret": "shh_secret", + "oauth2_token_url": f"{cls.url}oauth2/token", + "oauth2_audience": cls.url, + } + ) + return res + + def test_get_adapter_protocol(self): + protocol = self.webservice._get_adapter_protocol() + self.assertEqual(protocol, "http+oauth2-backend_application") + + @responses.activate + def test_fetch_token(self): + now = time.time() + duration = 3600 + responses.add( + responses.POST, + f"{self.url}oauth2/token", + json={ + "access_token": "cool_token", + "token_type": "Bearer", + "expires_in": duration, + "expires_at": now + duration, + }, + ) + responses.add(responses.GET, f"{self.url}endpoint", body="OK") + with mock_cursor(self.env.cr): + result = self.webservice.call("get", url=f"{self.url}endpoint") + self.webservice.invalidate_recordset() + self.assertEqual(len(responses.calls), 2) + call_token = json.loads(responses.calls[0].response.content.decode()) + webs_token = json.loads(self.webservice.oauth2_token) + self.assertEqual(call_token["access_token"], webs_token["access_token"]) + self.assertEqual(call_token["token_type"], webs_token["token_type"]) + self.assertEqual(call_token["expires_in"], webs_token["expires_in"]) + self.assertAlmostEqual( + call_token["expires_at"], + webs_token["expires_at"], + delta=1, # Accept a diff of 1s + ) + self.assertEqual(responses.calls[1].response.content.decode(), "OK") + self.assertEqual(result.decode(), "OK") + + @responses.activate + def test_update_token(self): + now = time.time() + duration = 3600 + responses.add( + responses.POST, + f"{self.url}oauth2/token", + json={ + "access_token": "cool_token", + "expires_at": now + duration, + "expires_in": duration, + "token_type": "Bearer", + }, + ) + responses.add(responses.GET, f"{self.url}endpoint", body="OK") + self.webservice.oauth2_token = json.dumps( + { + "access_token": "old_token", + "expires_at": now + 10, # in the near future + "expires_in": duration, + "token_type": "Bearer", + } + ) + self.webservice.flush_model() + with mock_cursor(self.env.cr): + result = self.webservice.call("get", url=f"{self.url}endpoint") + self.env.cr.commit.assert_called_once_with() # one call with no args + self.webservice.invalidate_recordset() + self.assertEqual(len(responses.calls), 2) + call_token = json.loads(responses.calls[0].response.content.decode()) + webs_token = json.loads(self.webservice.oauth2_token) + self.assertEqual(call_token["access_token"], webs_token["access_token"]) + self.assertEqual(call_token["token_type"], webs_token["token_type"]) + self.assertEqual(call_token["expires_in"], webs_token["expires_in"]) + self.assertAlmostEqual( + call_token["expires_at"], + webs_token["expires_at"], + delta=1, # Accept a diff of 1s + ) + self.assertEqual(responses.calls[1].response.content.decode(), "OK") + self.assertEqual(result.decode(), "OK") + + @responses.activate + def test_update_token_with_error(self): + now = time.time() + duration = 3600 + responses.add( + responses.POST, + f"{self.url}oauth2/token", + json={"error": "invalid_grant", "error_description": "invalid grant"}, + status=404, + ) + responses.add(responses.GET, f"{self.url}endpoint", body="NOK", status=403) + self.webservice.oauth2_token = json.dumps( + { + "access_token": "old_token", + "expires_at": now + 10, # in the near future + "expires_in": duration, + "token_type": "Bearer", + } + ) + self.webservice.flush_model() + with mock_cursor(self.env.cr): + with self.assertRaises(InvalidGrantError): + self.webservice.call("get", url=f"{self.url}endpoint") + self.env.cr.commit.assert_not_called() + self.env.cr.close.assert_called_once_with() # one call with no args + self.webservice.invalidate_recordset() + self.assertEqual(len(responses.calls), 1) # ``GET`` is not executed + self.assertEqual(responses.calls[0].request.method, "POST") + self.assertEqual( + json.loads(responses.calls[0].response.content.decode()), + {"error": "invalid_grant", "error_description": "invalid grant"}, + ) + self.assertEqual( + json.loads(self.webservice.oauth2_token)["access_token"], + "old_token", + ) + + +class TestWebServiceOauth2WebApplication(CommonWebService): + @classmethod + def _setup_records(cls): + res = super()._setup_records() + cls.url = "https://localhost.demo.odoo/" + os.environ["SERVER_ENV_CONFIG"] = "\n".join( + [ + "[webservice_backend.test_oauth2_web]", + "auth_type = oauth2", + "oauth2_flow = web_application", + "oauth2_clientid = some_client_id", + "oauth2_client_secret = shh_secret", + f"oauth2_token_url = {cls.url}oauth2/token", + f"oauth2_audience = {cls.url}", + f"oauth2_authorization_url = {cls.url}authorize", + ] + ) + cls.webservice = cls.env["webservice.backend"].create( + { + "name": "WebService OAuth2", + "tech_name": "test_oauth2_web", + "auth_type": "oauth2", + "protocol": "http", + "url": cls.url, + "oauth2_flow": "web_application", + "content_type": "application/xml", + "oauth2_clientid": "some_client_id", + "oauth2_client_secret": "shh_secret", + "oauth2_token_url": f"{cls.url}oauth2/token", + "oauth2_audience": cls.url, + "oauth2_authorization_url": f"{cls.url}authorize", + } + ) + return res + + def test_get_adapter_protocol(self): + protocol = self.webservice._get_adapter_protocol() + self.assertEqual(protocol, "http+oauth2-web_application") + + def test_authorization_code(self): + action = self.webservice.button_authorize() + expected_action = { + "type": "ir.actions.act_url", + "target": "self", + "url": "https://localhost.demo.odoo/authorize?response_type=code&" + "client_id=some_client_id&" + f"redirect_uri={quote(self.webservice.redirect_url, safe='')}&state=", + } + self.assertEqual(action["type"], expected_action["type"]) + self.assertEqual(action["target"], expected_action["target"]) + self.assertTrue( + action["url"].startswith(expected_action["url"]), + f"Got url:\n{action['url']}\nexpected:\n{expected_action['url']}", + ) + + @responses.activate + def test_fetch_token_from_auth(self): + now = time.time() + duration = 3600 + expires_timestamp = now + duration + responses.add( + responses.POST, + self.webservice.oauth2_token_url, + json={ + "access_token": "cool_token", + "expires_at": expires_timestamp, + "expires_in": duration, + "token_type": "Bearer", + }, + ) + adapter = self.webservice._get_adapter() + token = adapter._fetch_token_from_authorization("some code") + self.assertEqual(len(responses.calls), 1) + self.assertEqual( + "cool_token", + json.loads(responses.calls[0].response.content.decode())["access_token"], + ) + self.assertEqual("cool_token", token["access_token"]) + + def test_oauth2_flow_compute_with_server_env(self): + """Check the ``compute`` method when updating server envs""" + ws = self.webservice + url = self.url + for auth_type, oauth2_flow in [ + (tp, fl) + for tp in ws._fields["auth_type"].get_values(ws.env) + for fl in ws._fields["oauth2_flow"].get_values(ws.env) + ]: + # Update env with current ``auth_type`` and ``oauth2_flow`` + with mock.patch.dict( + os.environ, + { + "SERVER_ENV_CONFIG": f""" +[webservice_backend.test_oauth2_web] +auth_type = {auth_type} +oauth2_flow = {oauth2_flow} +oauth2_clientid = some_client_id +oauth2_client_secret = shh_secret +oauth2_token_url = {url}oauth2/token +oauth2_audience = {url} +oauth2_authorization_url = {url}/authorize +""", + }, + ): + server_env_mixin.serv_config = server_env._load_config() # Reload vars + ws.invalidate_recordset() # Avoid reading from cache + if auth_type == "oauth2": + self.assertEqual(ws.oauth2_flow, oauth2_flow) + else: + self.assertFalse(ws.oauth2_flow) + + def test_oauth2_flow_compute_with_ui(self): + """Check the ``compute`` method when updating WS from UI""" + ws = self.webservice + url = self.url + form_xmlid = "webservice.webservice_backend_form_view" + for auth_type, oauth2_flow in [ + (tp, fl) + for tp in ws._fields["auth_type"].get_values(ws.env) + for fl in ws._fields["oauth2_flow"].get_values(ws.env) + ]: + next_ws_id = ws.sudo().search([], order="id desc", limit=1).id + 1 + # Create a new WS with each ``auth_type/oauth2_flow`` couple through UI + with Form(ws.browse(), form_xmlid) as ws_form: + # Common fields + ws_form.name = "WebService Test UI" + ws_form.tech_name = f"webservice_test_ui_{next_ws_id}" + ws_form.protocol = "http" + ws_form.url = url + ws_form.content_type = "application/xml" + ws_form.auth_type = auth_type + # Auth type specific fields + if auth_type == "api_key": + ws_form.api_key = "Test Api Key" + ws_form.api_key_header = "Test Api Key Header" + if auth_type == "oauth2": + ws_form.oauth2_flow = oauth2_flow + ws_form.oauth2_clientid = "Test Client ID" + ws_form.oauth2_client_secret = "Test Client Secret" + ws_form.oauth2_token_url = f"{url}oauth2/token" + if auth_type == "user_pwd": + ws_form.username = "Test Username" + ws_form.password = "Test Password" + ws = ws_form.save() + # Check that ``oauth2_flow`` is the expected one after creation only if the + # ``auth_type`` is "oauth2", else it should be False + self.assertEqual( + ws.oauth2_flow, oauth2_flow if ws.auth_type == "oauth2" else False + ) + # Change WS's ``auth_type`` through UI + with Form(ws, form_xmlid) as ws_form: + new_auth_type = "none" if ws.auth_type == "oauth2" else "oauth2" + ws_form.auth_type = new_auth_type + if new_auth_type == "oauth2": + ws_form.oauth2_flow = oauth2_flow + ws_form.oauth2_clientid = "Test Client ID" + ws_form.oauth2_client_secret = "Test Client Secret" + ws_form.oauth2_token_url = f"{url}oauth2/token" + ws = ws_form.save() + # Check that ``oauth2_flow`` is the expected one after update only if the + # ``auth_type`` is "oauth2", else it should be False + self.assertEqual( + ws.oauth2_flow, oauth2_flow if ws.auth_type == "oauth2" else False + ) diff --git a/webservice/tests/test_webservice.py b/webservice/tests/test_webservice.py new file mode 100644 index 00000000..3c7fc312 --- /dev/null +++ b/webservice/tests/test_webservice.py @@ -0,0 +1,216 @@ +# Copyright 2020 Creu Blanca +# Copyright 2022 Camptocamp SA +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import responses +from requests import auth +from requests import exceptions as http_exceptions + +from odoo import exceptions + +from .common import CommonWebService + + +class TestWebService(CommonWebService): + @classmethod + def _setup_records(cls): + res = super()._setup_records() + cls.url = "https://localhost.demo.odoo/" + cls.webservice = cls.env["webservice.backend"].create( + { + "name": "WebService", + "protocol": "http", + "url": cls.url, + "content_type": "application/xml", + "tech_name": "demo_ws", + "auth_type": "none", + } + ) + return res + + def test_web_service_not_found(self): + with self.assertRaises(http_exceptions.ConnectionError): + self.webservice.call("get") + + def test_auth_validation(self): + msg = ( + r"Webservice 'WebService' " + r"requires 'Username & password' authentication. " + r"However, the following field\(s\) are not valued: Username, Password" + ) + with self.assertRaisesRegex(exceptions.UserError, msg): + self.webservice.write( + { + "auth_type": "user_pwd", + } + ) + + msg = ( + r"Webservice 'WebService' " + r"requires 'Username & password' authentication. " + r"However, the following field\(s\) are not valued: Password" + ) + with self.assertRaisesRegex(exceptions.UserError, msg): + self.webservice.write({"auth_type": "user_pwd", "username": "user"}) + + msg = ( + r"Webservice 'WebService' " + r"requires 'API Key' authentication. " + r"However, the following field\(s\) are not valued: API Key, API Key header" + ) + with self.assertRaisesRegex(exceptions.UserError, msg): + self.webservice.write( + { + "auth_type": "api_key", + } + ) + + msg = ( + r"Webservice 'WebService' " + r"requires 'API Key' authentication. " + r"However, the following field\(s\) are not valued: API Key header" + ) + with self.assertRaisesRegex(exceptions.UserError, msg): + self.webservice.write( + { + "auth_type": "api_key", + "api_key": "foo", + } + ) + + @responses.activate + def test_web_service_get(self): + responses.add(responses.GET, self.url, body="{}") + result = self.webservice.call("get") + self.assertEqual(result, b"{}") + self.assertEqual(len(responses.calls), 1) + self.assertEqual( + responses.calls[0].request.headers["Content-Type"], "application/xml" + ) + + @responses.activate + def test_web_service_get_url_combine(self): + endpoint = "api/test" + responses.add(responses.GET, self.url + endpoint, body="{}") + result = self.webservice.call("get", url="api/test") + self.assertEqual(result, b"{}") + self.assertEqual(len(responses.calls), 1) + self.assertEqual( + responses.calls[0].request.headers["Content-Type"], "application/xml" + ) + + @responses.activate + def test_web_service_get_url_combine_full_url(self): + endpoint = "api/test" + responses.add(responses.GET, self.url + endpoint, body="{}") + result = self.webservice.call("get", url="https://localhost.demo.odoo/api/test") + self.assertEqual(result, b"{}") + self.assertEqual(len(responses.calls), 1) + self.assertEqual( + responses.calls[0].request.headers["Content-Type"], "application/xml" + ) + + @responses.activate + def test_web_service_post(self): + responses.add(responses.POST, self.url, body="{}") + result = self.webservice.call("post", data="demo_response") + self.assertEqual(result, b"{}") + self.assertEqual( + responses.calls[0].request.headers["Content-Type"], "application/xml" + ) + self.assertEqual(responses.calls[0].request.body, "demo_response") + + @responses.activate + def test_web_service_put(self): + responses.add(responses.PUT, self.url, body="{}") + result = self.webservice.call("put", data="demo_response") + self.assertEqual(result, b"{}") + self.assertEqual( + responses.calls[0].request.headers["Content-Type"], "application/xml" + ) + self.assertEqual(responses.calls[0].request.body, "demo_response") + + @responses.activate + def test_web_service_backend_username(self): + self.webservice.write( + {"auth_type": "user_pwd", "username": "user", "password": "pass"} + ) + responses.add(responses.GET, self.url, body="{}") + result = self.webservice.call("get") + self.assertEqual(result, b"{}") + self.assertEqual(len(responses.calls), 1) + self.assertEqual( + responses.calls[0].request.headers["Content-Type"], "application/xml" + ) + data = auth._basic_auth_str("user", "pass") + self.assertEqual(responses.calls[0].request.headers["Authorization"], data) + + @responses.activate + def test_web_service_username(self): + self.webservice.write( + {"auth_type": "user_pwd", "username": "user", "password": "pass"} + ) + responses.add(responses.GET, self.url, body="{}") + result = self.webservice.call("get", auth=("user2", "pass2")) + self.assertEqual(result, b"{}") + self.assertEqual(len(responses.calls), 1) + self.assertEqual( + responses.calls[0].request.headers["Content-Type"], "application/xml" + ) + data = auth._basic_auth_str("user2", "pass2") + self.assertEqual(responses.calls[0].request.headers["Authorization"], data) + + @responses.activate + def test_web_service_backend_api_key(self): + self.webservice.write( + {"auth_type": "api_key", "api_key": "123xyz", "api_key_header": "Api-Key"} + ) + responses.add(responses.POST, self.url, body="{}") + result = self.webservice.call("post") + self.assertEqual(result, b"{}") + self.assertEqual(len(responses.calls), 1) + self.assertEqual( + responses.calls[0].request.headers["Content-Type"], "application/xml" + ) + self.assertEqual(responses.calls[0].request.headers["Api-Key"], "123xyz") + + @responses.activate + def test_web_service_headers(self): + responses.add(responses.GET, self.url, body="{}") + result = self.webservice.call("get", headers={"demo_header": "HEADER"}) + self.assertEqual(result, b"{}") + self.assertEqual(len(responses.calls), 1) + self.assertEqual( + responses.calls[0].request.headers["Content-Type"], "application/xml" + ) + self.assertEqual(responses.calls[0].request.headers["demo_header"], "HEADER") + + @responses.activate + def test_web_service_call_args(self): + url = "https://custom.url" + responses.add(responses.POST, url, body="{}") + result = self.webservice.call( + "post", url=url, headers={"demo_header": "HEADER"} + ) + self.assertEqual(result, b"{}") + self.assertEqual(len(responses.calls), 1) + self.assertEqual( + responses.calls[0].request.headers["Content-Type"], "application/xml" + ) + self.assertEqual(responses.calls[0].request.headers["demo_header"], "HEADER") + + url = self.url + "custom/path" + self.webservice.url += "{endpoint}" + responses.add(responses.POST, url, body="{}") + result = self.webservice.call( + "post", + url_params={"endpoint": "custom/path"}, + headers={"demo_header": "HEADER"}, + ) + self.assertEqual(result, b"{}") + self.assertEqual(len(responses.calls), 2) + self.assertEqual( + responses.calls[0].request.headers["Content-Type"], "application/xml" + ) + self.assertEqual(responses.calls[0].request.headers["demo_header"], "HEADER") diff --git a/webservice/views/webservice_backend.xml b/webservice/views/webservice_backend.xml new file mode 100644 index 00000000..87156b3f --- /dev/null +++ b/webservice/views/webservice_backend.xml @@ -0,0 +1,150 @@ + + + + + + webservice.backend.form (in webservice) + webservice.backend + +
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + webservice.backend.search (in webservice) + webservice.backend + + + + + + + + + + + + webservice.backend.tree (in webservice) + webservice.backend + + + + + + + + + + + + + WebService Backend + webservice.backend + tree,form + [] + {} + + + + WebService Backend + + + + + +