diff --git a/endpoint/README.rst b/endpoint/README.rst new file mode 100644 index 0000000000..6f506b2410 --- /dev/null +++ b/endpoint/README.rst @@ -0,0 +1,102 @@ +======== +Endpoint +======== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:2da9c7b7ea950dc9d9b00ebf3d2464b02005513cfb12202d9bc8b4b329f2af70 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fweb--api-lightgray.png?logo=github + :target: https://github.com/OCA/web-api/tree/14.0/endpoint + :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-14-0/web-api-14-0-endpoint + :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=14.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Provide an endpoint framework allowing users to define their own custom endpoint. + +Thanks to endpoint mixin the endpoint records are automatically registered as real Odoo routes. + +You can easily code what you want in the code snippet. + +NOTE: for security reasons any kind of RPC call is blocked on endpoint records. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Go to "Technical -> Endpoints" and create a new endpoint. + +Known issues / Roadmap +====================== + +* add validation of request data +* add api docs generation +* handle multiple routes per endpoint + +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 +~~~~~~~ + +* Camptocamp + +Contributors +~~~~~~~~~~~~ + +* Simone Orsi + +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-simahawk| image:: https://github.com/simahawk.png?size=40px + :target: https://github.com/simahawk + :alt: simahawk + +Current `maintainer `__: + +|maintainer-simahawk| + +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/endpoint/__init__.py b/endpoint/__init__.py new file mode 100644 index 0000000000..03f8fe54a2 --- /dev/null +++ b/endpoint/__init__.py @@ -0,0 +1,4 @@ +from .hooks import post_init_hook +from . import controllers +from . import models +from . import tools diff --git a/endpoint/__manifest__.py b/endpoint/__manifest__.py new file mode 100644 index 0000000000..6a3afb0abb --- /dev/null +++ b/endpoint/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2021 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +{ + "name": "Endpoint", + "summary": """Provide custom endpoint machinery.""", + "version": "12.0.1.0.0", + "license": "LGPL-3", + "development_status": "Beta", + "author": "Camptocamp,Odoo Community Association (OCA)", + "maintainers": ["simahawk"], + "website": "https://github.com/OCA/edi", + "depends": ["endpoint_route_handler", "rpc_helper"], + "data": [ + "security/ir.model.access.csv", + "security/ir_rule.xml", + "views/endpoint_view.xml", + ], + "demo": ["demo/endpoint_demo.xml"], + "post_init_hook": "post_init_hook", +} diff --git a/endpoint/controllers/__init__.py b/endpoint/controllers/__init__.py new file mode 100644 index 0000000000..12a7e529b6 --- /dev/null +++ b/endpoint/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/endpoint/controllers/main.py b/endpoint/controllers/main.py new file mode 100644 index 0000000000..493cac478e --- /dev/null +++ b/endpoint/controllers/main.py @@ -0,0 +1,57 @@ +# Copyright 2021 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + + +import json + +from werkzeug.exceptions import NotFound + +from odoo import http +from odoo.http import Response, request + + +class EndpointControllerMixin: + def _handle_endpoint(self, env, model, endpoint_route, **params): + endpoint = self._find_endpoint(env, model, endpoint_route) + if not endpoint: + raise NotFound() + endpoint._validate_request(request) + result = endpoint._handle_request(request) + return self._handle_result(result) + + def _handle_result(self, result): + response = result.get("response") + if isinstance(response, Response): + # Full response already provided + return response + payload = result.get("payload", "") + status = result.get("status_code", 200) + headers = result.get("headers", {}) + return self._make_json_response(payload, headers=headers, status=status) + + # TODO: probably not needed anymore as controllers are automatically registered + def _make_json_response(self, payload, headers=None, status=200, **kw): + # TODO: guess out type? + data = json.dumps(payload) + if headers is None: + headers = {} + headers["Content-Type"] = "application/json" + if request._request_type == "http": + resp = request.make_response(data, headers=headers) + else: + resp = request._json_response(data) + resp.status = str(status) + return resp + + def _find_endpoint(self, env, model, endpoint_route): + return env[model]._find_endpoint(endpoint_route) + + def auto_endpoint(self, model, endpoint_route, **params): + """Default method to handle auto-generated endpoints""" + env = request.env + return self._handle_endpoint(env, model, endpoint_route, **params) + + +class EndpointController(http.Controller, EndpointControllerMixin): + pass diff --git a/endpoint/demo/endpoint_demo.xml b/endpoint/demo/endpoint_demo.xml new file mode 100644 index 0000000000..0d6e6ffdfb --- /dev/null +++ b/endpoint/demo/endpoint_demo.xml @@ -0,0 +1,86 @@ + + + + + Demo Endpoint 1 + /demo/one + GET + code + +result = {"response": Response("ok")} + + + + + Demo Endpoint 2 + /demo/as_demo_user + GET + public + + code + +result = {"response": Response("My name is: " + user.name)} + + + + + Demo Endpoint 3 + /demo/json_data + GET + public + + code + +result = {"payload": {"a": 1, "b": 2}} + + + + + Demo Endpoint 4 + /demo/raise_not_found + GET + public + + code + +raise werkzeug.exceptions.NotFound() + + + + + Demo Endpoint 5 + /demo/raise_validation_error + GET + public + + code + +raise exceptions.ValidationError("Sorry, you cannot do this!") + + + + + Demo Endpoint 6 + /demo/value_from_request + GET + public + + code + +result = {"response": Response(request.params.get("your_name", ""))} + + + + + Demo Endpoint 7 + /demo/bad_method + GET + code + public + + +result = {"payload": "Method used:" + request.httprequest.method} + + + + diff --git a/endpoint/hooks.py b/endpoint/hooks.py new file mode 100644 index 0000000000..0a514f15d4 --- /dev/null +++ b/endpoint/hooks.py @@ -0,0 +1,70 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +from odoo import SUPERUSER_ID, api, tools + +from odoo.addons.base.models.ir_model import query_insert + +_logger = logging.getLogger(__file__) + + +def post_init_hook(cr, registry): + env = api.Environment(cr, SUPERUSER_ID, {}) + _init_server_action(env) + + +def _init_server_action(env): + """Create server action if missing.""" + # This is actually a trick to work around this error: + # + # psycopg2.IntegrityError: null value in column "activity_user_type" + # violates not-null constraint + # + # which happens when `mail` is installed, + # since it adds this field as required in DB. + # + # We DO NOT want to depend on mail for this problem... + # hence, here we go with this crazy dance :S + # + # Moreover, we are forced to use a query for this + # because if you use `model.create` you get + # + # ValueError: Invalid field 'activity_user_type' on model 'ir.actions.server' + # + # because the field is not yet in the env if the mail modules is not loaded 1st. + xid = "endpoint.server_action_registry_sync" + rec = env.ref(xid, False) + if rec: + return + model = env.ref("endpoint.model_endpoint_endpoint") + values = { + "name": "Sync registry", + "type": "ir.actions.server", + "model_id": model.id, + "model_name": model.model, + "binding_model_id": model.id, + "binding_type": "action", + "usage": "ir_actions_server", + "state": "code", + "code": """ +records.filtered(lambda x: not x.registry_sync).write({"registry_sync": True}) +""", + } + if tools.sql.column_exists(env.cr, "ir_act_server", "activity_user_type"): + values["activity_user_type"] = "specific" + ids = query_insert(env.cr, "ir_act_server", [values]) + + # Finally add an xmlid + module, id_ = xid.split(".", 1) + env["ir.model.data"].create( + { + "name": id_, + "module": module, + "model": "ir.actions.server", + "res_id": ids[0], + "noupdate": True, + } + ) + _logger.info("Server action created") diff --git a/endpoint/i18n/endpoint.pot b/endpoint/i18n/endpoint.pot new file mode 100644 index 0000000000..362ce9e9b6 --- /dev/null +++ b/endpoint/i18n/endpoint.pot @@ -0,0 +1,270 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * endpoint +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.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: endpoint +#: code:addons/endpoint/models/endpoint_mixin.py:0 +#, python-format +msgid "'Exec as user' is mandatory for public endpoints." +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__active +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__active +msgid "Active" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_endpoint_search_view +msgid "All" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_endpoint_search_view +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Archived" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Auth" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__auth_type +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__auth_type +msgid "Auth Type" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Code" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Code Help" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__code_snippet +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__code_snippet +msgid "Code Snippet" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__code_snippet_docs +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__code_snippet_docs +msgid "Code Snippet Docs" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__company_id +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__company_id +msgid "Company" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__create_uid +msgid "Created by" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__create_date +msgid "Created on" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__csrf +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__csrf +msgid "Csrf" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__display_name +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__display_name +msgid "Display Name" +msgstr "" + +#. module: endpoint +#: model:ir.model,name:endpoint.model_endpoint_endpoint +msgid "Endpoint" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__endpoint_hash +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__endpoint_hash +msgid "Endpoint Hash" +msgstr "" + +#. module: endpoint +#: model:ir.model,name:endpoint.model_endpoint_mixin +msgid "Endpoint mixin" +msgstr "" + +#. module: endpoint +#: model:ir.actions.act_window,name:endpoint.endpoint_endpoint_act_window +#: model:ir.ui.menu,name:endpoint.endpoint_endpoint_menu +msgid "Endpoints" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__exec_as_user_id +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__exec_as_user_id +msgid "Exec As User" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__exec_mode +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__exec_mode +msgid "Exec Mode" +msgstr "" + +#. module: endpoint +#: code:addons/endpoint/models/endpoint_mixin.py:0 +#, python-format +msgid "Exec mode is set to `Code`: you must provide a piece of code" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__id +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__id +msgid "ID" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,help:endpoint.field_endpoint_endpoint__endpoint_hash +#: model:ir.model.fields,help:endpoint.field_endpoint_mixin__endpoint_hash +msgid "Identify the route with its main params" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint____last_update +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin____last_update +msgid "Last Modified on" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__write_date +msgid "Last Updated on" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Main" +msgstr "" + +#. module: endpoint +#: code:addons/endpoint/models/endpoint_mixin.py:0 +#, python-format +msgid "Missing handler for exec mode %s" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__name +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__name +msgid "Name" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,help:endpoint.field_endpoint_endpoint__registry_sync +#: model:ir.model.fields,help:endpoint.field_endpoint_mixin__registry_sync +msgid "" +"ON: the record has been modified and registry was not notified.\n" +"No change will be active until this flag is set to false via proper action.\n" +"\n" +"OFF: record in line with the registry, nothing to do." +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__registry_sync +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__registry_sync +msgid "Registry Sync" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "" +"Registry out of sync. Likely the record has been modified but not sync'ed " +"with the routing registry." +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Request" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__request_content_type +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__request_content_type +msgid "Request Content Type" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__request_method +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__request_method +msgid "Request Method" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__route +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__route +msgid "Route" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__route_group +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__route_group +msgid "Route Group" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__route_type +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__route_type +msgid "Route Type" +msgstr "" + +#. module: endpoint +#: model:ir.actions.server,name:endpoint.server_action_registry_sync +msgid "Sync registry" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_endpoint_search_view +msgid "To sync" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "" +"Use the action \"Sync registry\" to make changes effective once you are done" +" with edits and creates." +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,help:endpoint.field_endpoint_endpoint__route_group +#: model:ir.model.fields,help:endpoint.field_endpoint_mixin__route_group +msgid "Use this to classify routes together" +msgstr "" + +#. module: endpoint +#: code:addons/endpoint/models/endpoint_mixin.py:0 +#, python-format +msgid "code_snippet should return a dict into `result` variable." +msgstr "" diff --git a/endpoint/i18n/fr.po b/endpoint/i18n/fr.po new file mode 100644 index 0000000000..0b071e0912 --- /dev/null +++ b/endpoint/i18n/fr.po @@ -0,0 +1,271 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * endpoint +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: Automatically generated\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" + +#. module: endpoint +#: code:addons/endpoint/models/endpoint_mixin.py:0 +#, python-format +msgid "'Exec as user' is mandatory for public endpoints." +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__active +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__active +msgid "Active" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_endpoint_search_view +msgid "All" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_endpoint_search_view +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Archived" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Auth" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__auth_type +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__auth_type +msgid "Auth Type" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Code" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Code Help" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__code_snippet +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__code_snippet +msgid "Code Snippet" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__code_snippet_docs +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__code_snippet_docs +msgid "Code Snippet Docs" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__company_id +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__company_id +msgid "Company" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__create_uid +msgid "Created by" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__create_date +msgid "Created on" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__csrf +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__csrf +msgid "Csrf" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__display_name +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__display_name +msgid "Display Name" +msgstr "" + +#. module: endpoint +#: model:ir.model,name:endpoint.model_endpoint_endpoint +msgid "Endpoint" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__endpoint_hash +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__endpoint_hash +msgid "Endpoint Hash" +msgstr "" + +#. module: endpoint +#: model:ir.model,name:endpoint.model_endpoint_mixin +msgid "Endpoint mixin" +msgstr "" + +#. module: endpoint +#: model:ir.actions.act_window,name:endpoint.endpoint_endpoint_act_window +#: model:ir.ui.menu,name:endpoint.endpoint_endpoint_menu +msgid "Endpoints" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__exec_as_user_id +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__exec_as_user_id +msgid "Exec As User" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__exec_mode +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__exec_mode +msgid "Exec Mode" +msgstr "" + +#. module: endpoint +#: code:addons/endpoint/models/endpoint_mixin.py:0 +#, python-format +msgid "Exec mode is set to `Code`: you must provide a piece of code" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__id +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__id +msgid "ID" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,help:endpoint.field_endpoint_endpoint__endpoint_hash +#: model:ir.model.fields,help:endpoint.field_endpoint_mixin__endpoint_hash +msgid "Identify the route with its main params" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint____last_update +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin____last_update +msgid "Last Modified on" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__write_date +msgid "Last Updated on" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Main" +msgstr "" + +#. module: endpoint +#: code:addons/endpoint/models/endpoint_mixin.py:0 +#, python-format +msgid "Missing handler for exec mode %s" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__name +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__name +msgid "Name" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,help:endpoint.field_endpoint_endpoint__registry_sync +#: model:ir.model.fields,help:endpoint.field_endpoint_mixin__registry_sync +msgid "" +"ON: the record has been modified and registry was not notified.\n" +"No change will be active until this flag is set to false via proper action.\n" +"\n" +"OFF: record in line with the registry, nothing to do." +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__registry_sync +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__registry_sync +msgid "Registry Sync" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "" +"Registry out of sync. Likely the record has been modified but not sync'ed " +"with the routing registry." +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Request" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__request_content_type +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__request_content_type +msgid "Request Content Type" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__request_method +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__request_method +msgid "Request Method" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__route +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__route +msgid "Route" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__route_group +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__route_group +msgid "Route Group" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__route_type +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__route_type +msgid "Route Type" +msgstr "" + +#. module: endpoint +#: model:ir.actions.server,name:endpoint.server_action_registry_sync +msgid "Sync registry" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_endpoint_search_view +msgid "To sync" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "" +"Use the action \"Sync registry\" to make changes effective once you are done " +"with edits and creates." +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,help:endpoint.field_endpoint_endpoint__route_group +#: model:ir.model.fields,help:endpoint.field_endpoint_mixin__route_group +msgid "Use this to classify routes together" +msgstr "" + +#. module: endpoint +#: code:addons/endpoint/models/endpoint_mixin.py:0 +#, python-format +msgid "code_snippet should return a dict into `result` variable." +msgstr "" diff --git a/endpoint/i18n/it.po b/endpoint/i18n/it.po new file mode 100644 index 0000000000..ef1739c05d --- /dev/null +++ b/endpoint/i18n/it.po @@ -0,0 +1,271 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * endpoint +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: Automatically generated\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" + +#. module: endpoint +#: code:addons/endpoint/models/endpoint_mixin.py:0 +#, python-format +msgid "'Exec as user' is mandatory for public endpoints." +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__active +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__active +msgid "Active" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_endpoint_search_view +msgid "All" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_endpoint_search_view +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Archived" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Auth" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__auth_type +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__auth_type +msgid "Auth Type" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Code" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Code Help" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__code_snippet +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__code_snippet +msgid "Code Snippet" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__code_snippet_docs +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__code_snippet_docs +msgid "Code Snippet Docs" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__company_id +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__company_id +msgid "Company" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__create_uid +msgid "Created by" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__create_date +msgid "Created on" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__csrf +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__csrf +msgid "Csrf" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__display_name +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__display_name +msgid "Display Name" +msgstr "" + +#. module: endpoint +#: model:ir.model,name:endpoint.model_endpoint_endpoint +msgid "Endpoint" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__endpoint_hash +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__endpoint_hash +msgid "Endpoint Hash" +msgstr "" + +#. module: endpoint +#: model:ir.model,name:endpoint.model_endpoint_mixin +msgid "Endpoint mixin" +msgstr "" + +#. module: endpoint +#: model:ir.actions.act_window,name:endpoint.endpoint_endpoint_act_window +#: model:ir.ui.menu,name:endpoint.endpoint_endpoint_menu +msgid "Endpoints" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__exec_as_user_id +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__exec_as_user_id +msgid "Exec As User" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__exec_mode +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__exec_mode +msgid "Exec Mode" +msgstr "" + +#. module: endpoint +#: code:addons/endpoint/models/endpoint_mixin.py:0 +#, python-format +msgid "Exec mode is set to `Code`: you must provide a piece of code" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__id +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__id +msgid "ID" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,help:endpoint.field_endpoint_endpoint__endpoint_hash +#: model:ir.model.fields,help:endpoint.field_endpoint_mixin__endpoint_hash +msgid "Identify the route with its main params" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint____last_update +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin____last_update +msgid "Last Modified on" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__write_date +msgid "Last Updated on" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Main" +msgstr "" + +#. module: endpoint +#: code:addons/endpoint/models/endpoint_mixin.py:0 +#, python-format +msgid "Missing handler for exec mode %s" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__name +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__name +msgid "Name" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,help:endpoint.field_endpoint_endpoint__registry_sync +#: model:ir.model.fields,help:endpoint.field_endpoint_mixin__registry_sync +msgid "" +"ON: the record has been modified and registry was not notified.\n" +"No change will be active until this flag is set to false via proper action.\n" +"\n" +"OFF: record in line with the registry, nothing to do." +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__registry_sync +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__registry_sync +msgid "Registry Sync" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "" +"Registry out of sync. Likely the record has been modified but not sync'ed " +"with the routing registry." +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Request" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__request_content_type +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__request_content_type +msgid "Request Content Type" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__request_method +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__request_method +msgid "Request Method" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__route +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__route +msgid "Route" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__route_group +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__route_group +msgid "Route Group" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__route_type +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__route_type +msgid "Route Type" +msgstr "" + +#. module: endpoint +#: model:ir.actions.server,name:endpoint.server_action_registry_sync +msgid "Sync registry" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_endpoint_search_view +msgid "To sync" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "" +"Use the action \"Sync registry\" to make changes effective once you are done " +"with edits and creates." +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,help:endpoint.field_endpoint_endpoint__route_group +#: model:ir.model.fields,help:endpoint.field_endpoint_mixin__route_group +msgid "Use this to classify routes together" +msgstr "" + +#. module: endpoint +#: code:addons/endpoint/models/endpoint_mixin.py:0 +#, python-format +msgid "code_snippet should return a dict into `result` variable." +msgstr "" diff --git a/endpoint/models/__init__.py b/endpoint/models/__init__.py new file mode 100644 index 0000000000..e5ecde3f04 --- /dev/null +++ b/endpoint/models/__init__.py @@ -0,0 +1,2 @@ +from . import endpoint_mixin +from . import endpoint_endpoint diff --git a/endpoint/models/endpoint_endpoint.py b/endpoint/models/endpoint_endpoint.py new file mode 100644 index 0000000000..a7efb6edcb --- /dev/null +++ b/endpoint/models/endpoint_endpoint.py @@ -0,0 +1,9 @@ +from odoo import models + + +class EndpointEndpoint(models.Model): + """Define a custom endpoint.""" + + _name = "endpoint.endpoint" + _inherit = "endpoint.mixin" + _description = "Endpoint" diff --git a/endpoint/models/endpoint_mixin.py b/endpoint/models/endpoint_mixin.py new file mode 100644 index 0000000000..7803e089a0 --- /dev/null +++ b/endpoint/models/endpoint_mixin.py @@ -0,0 +1,217 @@ +# Copyright 2021 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import textwrap + +import werkzeug + +from odoo import _, api, exceptions, fields, http, models +from odoo.tools import misc +from odoo.tools.safe_eval import safe_eval + +from odoo.addons.rpc_helper.decorator import disable_rpc + +from ..tools import misc as custom_misc + + +@disable_rpc() # Block ALL RPC calls +class EndpointMixin(models.AbstractModel): + + _name = "endpoint.mixin" + _inherit = "endpoint.route.handler" + _description = "Endpoint mixin" + + exec_mode = fields.Selection(selection="_selection_exec_mode", required=True,) + code_snippet = fields.Text() + code_snippet_docs = fields.Text( + compute="_compute_code_snippet_docs", + default=lambda self: self._default_code_snippet_docs(), + ) + exec_as_user_id = fields.Many2one(comodel_name="res.users") + company_id = fields.Many2one("res.company", string="Company") + + def _selection_exec_mode(self): + return [("code", "Execute code")] + + def _compute_code_snippet_docs(self): + for rec in self: + rec.code_snippet_docs = textwrap.dedent(rec._default_code_snippet_docs()) + + @api.constrains("exec_mode") + def _check_exec_mode(self): + for rec in self: + rec._validate_exec_mode() + + def _validate_exec_mode(self): + validator = getattr(self, "_validate_exec__" + self.exec_mode, lambda x: True) + validator() + + def _validate_exec__code(self): + if not self._code_snippet_valued(): + raise exceptions.UserError( + _("Exec mode is set to `Code`: you must provide a piece of code") + ) + + @api.constrains("auth_type") + def _check_auth(self): + for rec in self: + if rec.auth_type == "public" and not rec.exec_as_user_id: + raise exceptions.UserError( + _("'Exec as user' is mandatory for public endpoints.") + ) + + def _default_code_snippet_docs(self): + return """ + Available vars: + + * env + * endpoint + * request + * datetime + * dateutil + * time + * user + * json + * Response + * werkzeug + * exceptions + + Must generate either an instance of ``Response`` into ``response`` var or: + + * payload + * headers + * status_code + + which are all optional. + + Use ``log`` function to log messages into ir.logging table. + """ + + def _get_code_snippet_eval_context(self, request): + """Prepare the context used when evaluating python code + + :returns: dict -- evaluation context given to tools.misc + """ + return { + "env": self.env, + "user": self.env.user, + "endpoint": self, + "request": request, + "datetime": misc.datetime, + "dateutil": misc.dateutil, + "time": misc.time, + "json": custom_misc.json, + "Response": http.Response, + "werkzeug": misc.wrap_module( + werkzeug, {"exceptions": ["NotFound", "BadRequest", "Unauthorized"]} + ), + "exceptions": misc.wrap_module( + exceptions, ["UserError", "ValidationError"] + ), + "log": self._code_snippet_log_func, + } + + def _code_snippet_log_func(self, message, level="info"): + # Almost barely copied from ir.actions.server + with self.pool.cursor() as cr: + cr.execute( + """ + INSERT INTO ir_logging + (create_date, create_uid, type, dbname, name, level, message, path, line, func) + VALUES (NOW() at time zone 'UTC', %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + self.env.uid, + "server", + self._cr.dbname, + __name__, + level, + message, + "endpoint", + self.id, + self.name, + ), + ) + + def _handle_exec__code(self, request): + if not self._code_snippet_valued(): + return {} + eval_ctx = self._get_code_snippet_eval_context(request) + snippet = self.code_snippet + safe_eval(snippet, eval_ctx, mode="exec", nocopy=True) + result = eval_ctx.get("result") + if not isinstance(result, dict): + raise exceptions.UserError( + _("code_snippet should return a dict into `result` variable.") + ) + return result + + def _code_snippet_valued(self): + snippet = self.code_snippet or "" + return bool( + [ + not line.startswith("#") + for line in (snippet.splitlines()) + if line.strip("") + ] + ) + + def _default_endpoint_options_handler(self): + return { + "klass_dotted_path": "odoo.addons.endpoint.controllers.main.EndpointController", + "method_name": "auto_endpoint", + "default_pargs": (self._name, self.route), + } + + def _validate_request(self, request): + http_req = request.httprequest + if self.request_method and self.request_method != http_req.method: + self._logger.error("_validate_request: MethodNotAllowed") + raise werkzeug.exceptions.MethodNotAllowed() + if ( + self.request_content_type + and self.request_content_type != http_req.content_type + ): + self._logger.error("_validate_request: UnsupportedMediaType") + raise werkzeug.exceptions.UnsupportedMediaType() + + def _get_handler(self): + try: + return getattr(self, "_handle_exec__" + self.exec_mode) + except AttributeError: + raise exceptions.UserError( + _("Missing handler for exec mode %s") % self.exec_mode + ) + + def _handle_request(self, request): + # Switch user for the whole process + self_with_user = self + if self.exec_as_user_id: + self_with_user = self.with_user(user=self.exec_as_user_id) + handler = self_with_user._get_handler() + try: + res = handler(request) + except self._bad_request_exceptions() as orig_exec: + self._logger.error("_validate_request: BadRequest") + raise werkzeug.exceptions.BadRequest() from orig_exec + return res + + def _bad_request_exceptions(self): + return (exceptions.UserError, exceptions.ValidationError) + + @api.model + def _find_endpoint(self, endpoint_route): + return self.sudo().search(self._find_endpoint_domain(endpoint_route), limit=1) + + def _find_endpoint_domain(self, endpoint_route): + return [("route", "=", endpoint_route)] + + def copy_data(self, default=None): + result = super().copy_data(default=default) + # `route` cannot be copied as it must me unique. + # Yet, we want to be able to duplicate a record from the UI. + for rec, data in zip(self, result): + if not data.get("route"): + data["route"] = f"{rec.route}/COPY_FIXME" + return result diff --git a/endpoint/readme/CONFIGURE.rst b/endpoint/readme/CONFIGURE.rst new file mode 100644 index 0000000000..0dc96770c1 --- /dev/null +++ b/endpoint/readme/CONFIGURE.rst @@ -0,0 +1 @@ +Go to "Technical -> Endpoints" and create a new endpoint. diff --git a/endpoint/readme/CONTRIBUTORS.rst b/endpoint/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..37423d21ee --- /dev/null +++ b/endpoint/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Simone Orsi +* Guillem Casassas diff --git a/endpoint/readme/DESCRIPTION.rst b/endpoint/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..1be3e06cdf --- /dev/null +++ b/endpoint/readme/DESCRIPTION.rst @@ -0,0 +1,7 @@ +Provide an endpoint framework allowing users to define their own custom endpoint. + +Thanks to endpoint mixin the endpoint records are automatically registered as real Odoo routes. + +You can easily code what you want in the code snippet. + +NOTE: for security reasons any kind of RPC call is blocked on endpoint records. diff --git a/endpoint/readme/ROADMAP.rst b/endpoint/readme/ROADMAP.rst new file mode 100644 index 0000000000..cdd636e791 --- /dev/null +++ b/endpoint/readme/ROADMAP.rst @@ -0,0 +1,3 @@ +* add validation of request data +* add api docs generation +* handle multiple routes per endpoint diff --git a/endpoint/security/ir.model.access.csv b/endpoint/security/ir.model.access.csv new file mode 100644 index 0000000000..2bfbff5df7 --- /dev/null +++ b/endpoint/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_endpoint_endpoint_edit,endpoint_endpoint edit,model_endpoint_endpoint,base.group_system,1,1,1,1 diff --git a/endpoint/security/ir_rule.xml b/endpoint/security/ir_rule.xml new file mode 100644 index 0000000000..a98daf8b7f --- /dev/null +++ b/endpoint/security/ir_rule.xml @@ -0,0 +1,11 @@ + + + + Endpoint Multi-company + + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + diff --git a/endpoint/static/description/icon.png b/endpoint/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/endpoint/static/description/icon.png differ diff --git a/endpoint/static/description/index.html b/endpoint/static/description/index.html new file mode 100644 index 0000000000..e1c2306fde --- /dev/null +++ b/endpoint/static/description/index.html @@ -0,0 +1,442 @@ + + + + + +Endpoint + + + +
+

Endpoint

+ + +

Beta License: LGPL-3 OCA/web-api Translate me on Weblate Try me on Runboat

+

Provide an endpoint framework allowing users to define their own custom endpoint.

+

Thanks to endpoint mixin the endpoint records are automatically registered as real Odoo routes.

+

You can easily code what you want in the code snippet.

+

NOTE: for security reasons any kind of RPC call is blocked on endpoint records.

+

Table of contents

+ +
+

Configuration

+

Go to “Technical -> Endpoints” and create a new endpoint.

+
+
+

Known issues / Roadmap

+
    +
  • add validation of request data
  • +
  • add api docs generation
  • +
  • handle multiple routes per endpoint
  • +
+
+
+

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

+
    +
  • Camptocamp
  • +
+
+ +
+

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:

+

simahawk

+

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/endpoint/tests/__init__.py b/endpoint/tests/__init__.py new file mode 100644 index 0000000000..6885a0f9ed --- /dev/null +++ b/endpoint/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_endpoint +from . import test_endpoint_controller diff --git a/endpoint/tests/common.py b/endpoint/tests/common.py new file mode 100644 index 0000000000..00567caa56 --- /dev/null +++ b/endpoint/tests/common.py @@ -0,0 +1,48 @@ +# Copyright 2021 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import contextlib + +from odoo.tests.common import SavepointCase, tagged +from odoo.tools import DotDict + +from odoo.addons.website.tools import MockRequest + + +@tagged("-at_install", "post_install") +class CommonEndpoint(SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._setup_env() + cls._setup_records() + + @classmethod + def _setup_env(cls): + cls.env = cls.env(context=cls._setup_context()) + + @classmethod + def _setup_context(cls): + return dict(cls.env.context, tracking_disable=True,) + + @classmethod + def _setup_records(cls): + pass + + @contextlib.contextmanager + def _get_mocked_request( + self, httprequest=None, extra_headers=None, request_attrs=None + ): + with MockRequest(self.env) as mocked_request: + mocked_request.httprequest = ( + DotDict(httprequest) if httprequest else mocked_request.httprequest + ) + headers = {} + headers.update(extra_headers or {}) + mocked_request.httprequest.headers = headers + request_attrs = request_attrs or {} + for k, v in request_attrs.items(): + setattr(mocked_request, k, v) + mocked_request.make_response = lambda data, **kw: data + yield mocked_request diff --git a/endpoint/tests/test_endpoint.py b/endpoint/tests/test_endpoint.py new file mode 100644 index 0000000000..4c05ebb803 --- /dev/null +++ b/endpoint/tests/test_endpoint.py @@ -0,0 +1,242 @@ +# Copyright 2021 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import json +import textwrap + +import mock +import psycopg2 +import werkzeug + +from odoo import exceptions +from odoo.tools.misc import mute_logger + +from .common import CommonEndpoint + + +class TestEndpoint(CommonEndpoint): + @classmethod + def _setup_records(cls): + super()._setup_records() + cls.endpoint = cls.env.ref("endpoint.endpoint_demo_1") + + @mute_logger("odoo.sql_db") + def test_endpoint_unique(self): + with self.assertRaises(psycopg2.IntegrityError): + self.env["endpoint.endpoint"].create( + {"name": "Endpoint", "route": "/demo/one", "exec_mode": "code"} + ) + + def test_endpoint_validation(self): + with self.assertRaisesRegex( + exceptions.UserError, r"you must provide a piece of code" + ): + self.env["endpoint.endpoint"].create( + { + "name": "Endpoint 2", + "route": "/demo/2", + "exec_mode": "code", + "request_method": "GET", + "auth_type": "user_endpoint", + } + ) + with self.assertRaisesRegex( + exceptions.UserError, r"Request content type is required for POST and PUT." + ): + self.env["endpoint.endpoint"].create( + { + "name": "Endpoint 3", + "route": "/demo/3", + "exec_mode": "code", + "code_snippet": "foo = 1", + "request_method": "POST", + "auth_type": "user_endpoint", + } + ) + with self.assertRaisesRegex( + exceptions.UserError, r"Request content type is required for POST and PUT." + ): + self.endpoint.request_method = "POST" + + def test_endpoint_find(self): + self.assertEqual( + self.env["endpoint.endpoint"]._find_endpoint("/demo/one"), self.endpoint + ) + + def test_endpoint_code_eval_full_response(self): + with self._get_mocked_request() as req: + result = self.endpoint._handle_request(req) + resp = result["response"] + self.assertEqual(resp.status, "200 OK") + self.assertEqual(resp.data, b"ok") + + def test_endpoint_code_eval_free_vals(self): + self.endpoint.write( + { + "code_snippet": textwrap.dedent( + """ + result = { + "payload": json.dumps({"a": 1, "b": 2}), + "headers": [("content-type", "application/json")] + } + """ + ) + } + ) + with self._get_mocked_request() as req: + result = self.endpoint._handle_request(req) + payload = result["payload"] + self.assertEqual(json.loads(payload), {"a": 1, "b": 2}) + + def test_endpoint_log(self): + self.endpoint.write( + { + "code_snippet": textwrap.dedent( + """ + log("ciao") + result = {"ok": True} + """ + ) + } + ) + with self._get_mocked_request() as req: + # just test that logging does not break + # as it creates a record directly via sql + # and we cannot easily check the result + self.endpoint._handle_request(req) + self.env.cr.execute("DELETE FROM ir_logging") + + @mute_logger("endpoint.endpoint", "odoo.modules.registry") + def test_endpoint_validate_request(self): + endpoint = self.endpoint.copy( + { + "route": "/wrong", + "request_method": "POST", + "request_content_type": "text/plain", + } + ) + with self.assertRaises(werkzeug.exceptions.UnsupportedMediaType): + with self._get_mocked_request(httprequest={"method": "POST"}) as req: + endpoint._validate_request(req) + with self.assertRaises(werkzeug.exceptions.MethodNotAllowed): + with self._get_mocked_request( + httprequest={"method": "GET"}, + extra_headers=[("Content-type", "text/plain")], + ) as req: + endpoint._validate_request(req) + + @mute_logger("odoo.modules.registry") + def test_routing(self): + route, info, __ = self.endpoint._get_routing_info() + self.assertEqual(route, "/demo/one") + self.assertEqual( + info, + { + "auth": "user_endpoint", + "methods": ["GET"], + "routes": ["/demo/one"], + "type": "http", + "csrf": False, + }, + ) + endpoint = self.endpoint.copy( + { + "route": "/new/one", + "request_method": "POST", + "request_content_type": "text/plain", + "auth_type": "public", + "exec_as_user_id": self.env.user.id, + } + ) + __, info, __ = endpoint._get_routing_info() + self.assertEqual( + info, + { + "auth": "public", + "methods": ["POST"], + "routes": ["/new/one"], + "type": "http", + "csrf": False, + }, + ) + # check prefix + type(endpoint)._endpoint_route_prefix = "/foo" + endpoint._compute_route() + __, info, __ = endpoint._get_routing_info() + self.assertEqual( + info, + { + "auth": "public", + "methods": ["POST"], + "routes": ["/foo/new/one"], + "type": "http", + "csrf": False, + }, + ) + type(endpoint)._endpoint_route_prefix = "" + + @mute_logger("odoo.modules.registry") + def test_unlink(self): + endpoint = self.endpoint.copy( + { + "route": "/delete/this", + "request_method": "POST", + "request_content_type": "text/plain", + "auth_type": "public", + "exec_as_user_id": self.env.user.id, + } + ) + endpoint._handle_registry_sync() + key = endpoint._endpoint_registry_unique_key() + reg = endpoint._endpoint_registry + self.assertEqual(reg._get_rule(key).route, "/delete/this") + endpoint.unlink() + self.assertEqual(reg._get_rule(key), None) + + @mute_logger("odoo.modules.registry") + def test_archive(self): + endpoint = self.endpoint.copy( + { + "route": "/enable-disable/this", + "request_method": "POST", + "request_content_type": "text/plain", + "auth_type": "public", + "exec_as_user_id": self.env.user.id, + } + ) + endpoint._handle_registry_sync() + self.assertTrue(endpoint.active) + key = endpoint._endpoint_registry_unique_key() + reg = endpoint._endpoint_registry + self.assertEqual(reg._get_rule(key).route, "/enable-disable/this") + endpoint.active = False + endpoint._handle_registry_sync() + self.assertEqual(reg._get_rule(key), None) + + def test_registry_sync(self): + endpoint = self.env["endpoint.endpoint"].create( + { + "name": "New", + "route": "/not/active/yet", + "exec_mode": "code", + "code_snippet": "foo = 1", + "request_method": "GET", + "auth_type": "user_endpoint", + } + ) + self.assertFalse(endpoint.registry_sync) + key = endpoint._endpoint_registry_unique_key() + reg = endpoint._endpoint_registry + self.assertEqual(reg._get_rule(key), None) + with mock.patch.object(type(self.env.cr), "after") as mocked: + endpoint.registry_sync = True + partial_func = mocked.call_args[0][1] + self.assertEqual(partial_func.args, ([endpoint.id],)) + self.assertEqual( + partial_func.func.__name__, "_handle_registry_sync_post_commit" + ) + + def test_duplicate(self): + endpoint = self.endpoint.copy() + self.assertTrue(endpoint.route.endswith("/COPY_FIXME")) diff --git a/endpoint/tests/test_endpoint_controller.py b/endpoint/tests/test_endpoint_controller.py new file mode 100644 index 0000000000..cec205d6d1 --- /dev/null +++ b/endpoint/tests/test_endpoint_controller.py @@ -0,0 +1,77 @@ +# Copyright 2021 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import json +import os +from unittest import skipIf + +from odoo.tests.common import HttpCase +from odoo.tools.misc import mute_logger + + +@skipIf(os.getenv("SKIP_HTTP_CASE"), "EndpointHttpCase skipped") +class EndpointHttpCase(HttpCase): + def setUp(self): + super(EndpointHttpCase, self).setUp() + # force sync for demo records + self.env["endpoint.endpoint"].search([])._handle_registry_sync() + + def tearDown(self): + self.env["ir.http"]._clear_routing_map() + super().tearDown() + + def test_call1(self): + response = self.url_open("/demo/one") + self.assertEqual(response.status_code, 401) + # Let's login now + self.authenticate("admin", "admin") + response = self.url_open("/demo/one") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b"ok") + + def test_call_route_update(self): + # Ensure that a route that gets updated is not available anymore + self.authenticate("admin", "admin") + endpoint = self.env.ref("endpoint.endpoint_demo_1") + endpoint.route += "/new" + # force sync + endpoint._handle_registry_sync() + response = self.url_open("/demo/one") + self.assertEqual(response.status_code, 404) + response = self.url_open("/demo/one/new") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b"ok") + # Archive it + endpoint.active = False + response = self.url_open("/demo/one/new") + self.assertEqual(response.status_code, 404) + endpoint.active = True + response = self.url_open("/demo/one/new") + self.assertEqual(response.status_code, 200) + + def test_call2(self): + response = self.url_open("/demo/as_demo_user") + self.assertEqual(response.content, b"My name is: Marc Demo") + + def test_call3(self): + response = self.url_open("/demo/json_data") + data = json.loads(response.content.decode()) + self.assertEqual(data, {"a": 1, "b": 2}) + + @mute_logger("endpoint.endpoint") + def test_call4(self): + response = self.url_open("/demo/raise_validation_error") + self.assertEqual(response.status_code, 400) + + def test_call5(self): + response = self.url_open("/demo/none") + self.assertEqual(response.status_code, 404) + + def test_call6(self): + response = self.url_open("/demo/value_from_request?your_name=JonnyTest") + self.assertEqual(response.content, b"JonnyTest") + + def test_call7(self): + response = self.url_open("/demo/bad_method", data="ok") + self.assertEqual(response.status_code, 405) diff --git a/endpoint/tools/__init__.py b/endpoint/tools/__init__.py new file mode 100644 index 0000000000..ab665e1dc9 --- /dev/null +++ b/endpoint/tools/__init__.py @@ -0,0 +1 @@ +from . import misc diff --git a/endpoint/tools/misc.py b/endpoint/tools/misc.py new file mode 100644 index 0000000000..b475c2ee4f --- /dev/null +++ b/endpoint/tools/misc.py @@ -0,0 +1,3 @@ +from odoo.tools.misc import wrap_module + +json = wrap_module(__import__("json"), ["loads", "dumps"]) diff --git a/endpoint/views/endpoint_view.xml b/endpoint/views/endpoint_view.xml new file mode 100644 index 0000000000..a3e2eed7bd --- /dev/null +++ b/endpoint/views/endpoint_view.xml @@ -0,0 +1,169 @@ + + + + + + + endpoint.mixin.form + endpoint.mixin + +
+
+ + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + endpoint.endpoint.form + endpoint.endpoint + + primary + +
+ + real +
+
+
+ + + endpoint.endpoint.search + endpoint.endpoint + + + + + + + + + + + + + + + + + endpoint.endpoint.tree + endpoint.endpoint + + + + + + + + + + + + + Endpoints + endpoint.endpoint + tree,form + [] + {'search_default_all': 1} + + + + Endpoints + + + + + + diff --git a/endpoint_route_handler/README.rst b/endpoint_route_handler/README.rst new file mode 100644 index 0000000000..abc7ddabf9 --- /dev/null +++ b/endpoint_route_handler/README.rst @@ -0,0 +1,179 @@ +====================== +Endpoint route handler +====================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:441f31f154913353d8a88d37067308bd8f9ec293c07b4ced67c52fd3cf82b5e6 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fweb--api-lightgray.png?logo=github + :target: https://github.com/OCA/web-api/tree/14.0/endpoint_route_handler + :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-14-0/web-api-14-0-endpoint_route_handler + :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=14.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Technical module that provides a base handler +for adding and removing controller routes on the fly. + +Can be used as a mixin or as a tool. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +As a mixin +~~~~~~~~~~ + +Use standard Odoo inheritance:: + + class MyModel(models.Model): + _name = "my.model" + _inherit = "endpoint.route.handler" + +Once you have this, each `my.model` record will generate a route. +You can have a look at the `endpoint` module to see a real life example. + +The options of the routing rules are defined by the method `_default_endpoint_options`. +Here's an example from the `endpoint` module:: + + def _default_endpoint_options_handler(self): + return { + "klass_dotted_path": "odoo.addons.endpoint.controllers.main.EndpointController", + "method_name": "auto_endpoint", + "default_pargs": (self.route,), + } + +As you can see, you have to pass the references to the controller class and the method to use +when the endpoint is called. And you can prepare some default arguments to pass. +In this case, the route of the current record. + + +As a tool +~~~~~~~~~ + +Initialize non stored route handlers and generate routes from them. +For instance:: + + route_handler = self.env["endpoint.route.handler.tool"] + endpoint_handler = MyController()._my_handler + vals = { + "name": "My custom route", + "route": "/my/custom/route", + "request_method": "GET", + "auth_type": "public", + } + new_route = route_handler.new(vals) + new_route._register_controller() + +You can override options and define - for instance - a different controller method:: + + options = { + "handler": { + "klass_dotted_path": "odoo.addons.my_module.controllers.SpecialController", + "method_name": "my_special_handler", + } + } + new_route._register_controller(options=options) + +Of course, what happens when the endpoint gets called +depends on the logic defined on the controller method. + +In both cases (mixin and tool) when a new route is generated or an existing one is updated, +the `ir.http.routing_map` (which holds all Odoo controllers) will be updated. + +You can see a real life example on `shopfloor.app` model. + +Known issues / Roadmap +====================== + +* add api docs helpers +* allow multiple HTTP methods on the same endpoint +* multiple values for route and methods + + keep the same in the ui for now, later own we can imagine a multi-value selection or just add text field w/ proper validation and cleanup + + remove the route field in the table of endpoint_route + + support a comma separated list of routes + maybe support comma separated list of methods + use only routing.routes for generating the rule + sort and freeze its values to update the endpoint hash + + catch dup route exception on the sync to detect duplicated routes + and use the endpoint_hash to retrieve the real record + (note: we could store more info in the routing information which will stay in the map) + + for customizing the rule behavior the endpoint the hook is to override the registry lookup + + make EndpointRule class overridable on the registry + +NOTE in v16 we won't care anymore about odoo controller +so the lookup of the controller can be simplified to a basic py obj that holds the routing info. + +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 +~~~~~~~ + +* Camptocamp + +Contributors +~~~~~~~~~~~~ + +* Simone Orsi + +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-simahawk| image:: https://github.com/simahawk.png?size=40px + :target: https://github.com/simahawk + :alt: simahawk + +Current `maintainer `__: + +|maintainer-simahawk| + +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/endpoint_route_handler/__init__.py b/endpoint_route_handler/__init__.py new file mode 100644 index 0000000000..a0cb2972d9 --- /dev/null +++ b/endpoint_route_handler/__init__.py @@ -0,0 +1,2 @@ +from . import models +from .post_init_hook import post_init_hook diff --git a/endpoint_route_handler/__manifest__.py b/endpoint_route_handler/__manifest__.py new file mode 100644 index 0000000000..418ec33dda --- /dev/null +++ b/endpoint_route_handler/__manifest__.py @@ -0,0 +1,15 @@ +# Copyright 2021 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +{ + "name": "Endpoint route handler", + "summary": """Provide mixin and tool to generate custom endpoints on the fly.""", + "version": "12.0.1.0.0", + "license": "LGPL-3", + "development_status": "Beta", + "author": "Camptocamp,Odoo Community Association (OCA)", + "maintainers": ["simahawk"], + "website": "https://github.com/OCA/edi", + "data": ["security/ir.model.access.csv"], + "post_init_hook": "post_init_hook", +} diff --git a/endpoint_route_handler/controllers/__init__.py b/endpoint_route_handler/controllers/__init__.py new file mode 100644 index 0000000000..12a7e529b6 --- /dev/null +++ b/endpoint_route_handler/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/endpoint_route_handler/controllers/main.py b/endpoint_route_handler/controllers/main.py new file mode 100644 index 0000000000..0bdd9f21a3 --- /dev/null +++ b/endpoint_route_handler/controllers/main.py @@ -0,0 +1,18 @@ +# Copyright 2021 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + + +import logging + +from werkzeug.exceptions import NotFound + +from odoo import http + +_logger = logging.getLogger(__file__) + + +class EndpointNotFoundController(http.Controller): + def auto_not_found(self, endpoint_route, **params): + _logger.error("Non registered endpoint for %s", endpoint_route) + raise NotFound() diff --git a/endpoint_route_handler/exceptions.py b/endpoint_route_handler/exceptions.py new file mode 100644 index 0000000000..4420a7b0e5 --- /dev/null +++ b/endpoint_route_handler/exceptions.py @@ -0,0 +1,7 @@ +# Copyright 2022 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + + +class EndpointHandlerNotFound(Exception): + """Raise when an endpoint handler is not found.""" diff --git a/endpoint_route_handler/i18n/endpoint_route_handler.pot b/endpoint_route_handler/i18n/endpoint_route_handler.pot new file mode 100644 index 0000000000..de1733d79e --- /dev/null +++ b/endpoint_route_handler/i18n/endpoint_route_handler.pot @@ -0,0 +1,197 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * endpoint_route_handler +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.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: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__active +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__active +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_sync_mixin__active +msgid "Active" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__auth_type +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__auth_type +msgid "Auth Type" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__create_uid +msgid "Created by" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__create_date +msgid "Created on" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__csrf +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__csrf +msgid "Csrf" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__display_name +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__display_name +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_sync_mixin__display_name +#: model:ir.model.fields,field_description:endpoint_route_handler.field_ir_http__display_name +msgid "Display Name" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__endpoint_hash +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__endpoint_hash +msgid "Endpoint Hash" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model,name:endpoint_route_handler.model_endpoint_route_handler +msgid "Endpoint Route handler" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model,name:endpoint_route_handler.model_endpoint_route_handler_tool +msgid "Endpoint Route handler tool" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model,name:endpoint_route_handler.model_endpoint_route_sync_mixin +msgid "Endpoint Route sync mixin" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model,name:endpoint_route_handler.model_ir_http +msgid "HTTP Routing" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__id +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__id +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_sync_mixin__id +#: model:ir.model.fields,field_description:endpoint_route_handler.field_ir_http__id +msgid "ID" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_handler__endpoint_hash +#: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_handler_tool__endpoint_hash +msgid "Identify the route with its main params" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler____last_update +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool____last_update +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_sync_mixin____last_update +#: model:ir.model.fields,field_description:endpoint_route_handler.field_ir_http____last_update +msgid "Last Modified on" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__write_date +msgid "Last Updated on" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__name +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__name +msgid "Name" +msgstr "" + +#. module: endpoint_route_handler +#: code:addons/endpoint_route_handler/models/endpoint_route_handler.py:0 +#, python-format +msgid "" +"Non unique route(s): %(routes)s.\n" +"Found in model(s): %(models)s.\n" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_handler__registry_sync +#: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_handler_tool__registry_sync +#: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_sync_mixin__registry_sync +msgid "" +"ON: the record has been modified and registry was not notified.\n" +"No change will be active until this flag is set to false via proper action.\n" +"\n" +"OFF: record in line with the registry, nothing to do." +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__registry_sync +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__registry_sync +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_sync_mixin__registry_sync +msgid "Registry Sync" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__request_content_type +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__request_content_type +msgid "Request Content Type" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__request_method +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__request_method +msgid "Request Method" +msgstr "" + +#. module: endpoint_route_handler +#: code:addons/endpoint_route_handler/models/endpoint_route_handler.py:0 +#, python-format +msgid "Request content type is required for POST and PUT." +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__route +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__route +msgid "Route" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__route_group +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__route_group +msgid "Route Group" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__route_type +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__route_type +msgid "Route Type" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_handler__route_group +#: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_handler_tool__route_group +msgid "Use this to classify routes together" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.constraint,message:endpoint_route_handler.constraint_endpoint_endpoint_endpoint_route_unique +#: model:ir.model.constraint,message:endpoint_route_handler.constraint_endpoint_mixin_endpoint_route_unique +#: model:ir.model.constraint,message:endpoint_route_handler.constraint_endpoint_route_handler_endpoint_route_unique +#: model:ir.model.constraint,message:endpoint_route_handler.constraint_endpoint_route_handler_tool_endpoint_route_unique +msgid "You can register an endpoint route only once." +msgstr "" + +#. module: endpoint_route_handler +#: code:addons/endpoint_route_handler/models/endpoint_route_handler.py:0 +#, python-format +msgid "`%s` uses a blacklisted routed = `%s`" +msgstr "" diff --git a/endpoint_route_handler/i18n/it.po b/endpoint_route_handler/i18n/it.po new file mode 100644 index 0000000000..c54aa1026d --- /dev/null +++ b/endpoint_route_handler/i18n/it.po @@ -0,0 +1,198 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * endpoint_route_handler +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: Automatically generated\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" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__active +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__active +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_sync_mixin__active +msgid "Active" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__auth_type +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__auth_type +msgid "Auth Type" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__create_uid +msgid "Created by" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__create_date +msgid "Created on" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__csrf +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__csrf +msgid "Csrf" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__display_name +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__display_name +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_sync_mixin__display_name +#: model:ir.model.fields,field_description:endpoint_route_handler.field_ir_http__display_name +msgid "Display Name" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__endpoint_hash +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__endpoint_hash +msgid "Endpoint Hash" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model,name:endpoint_route_handler.model_endpoint_route_handler +msgid "Endpoint Route handler" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model,name:endpoint_route_handler.model_endpoint_route_handler_tool +msgid "Endpoint Route handler tool" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model,name:endpoint_route_handler.model_endpoint_route_sync_mixin +msgid "Endpoint Route sync mixin" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model,name:endpoint_route_handler.model_ir_http +msgid "HTTP Routing" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__id +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__id +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_sync_mixin__id +#: model:ir.model.fields,field_description:endpoint_route_handler.field_ir_http__id +msgid "ID" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_handler__endpoint_hash +#: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_handler_tool__endpoint_hash +msgid "Identify the route with its main params" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler____last_update +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool____last_update +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_sync_mixin____last_update +#: model:ir.model.fields,field_description:endpoint_route_handler.field_ir_http____last_update +msgid "Last Modified on" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__write_date +msgid "Last Updated on" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__name +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__name +msgid "Name" +msgstr "" + +#. module: endpoint_route_handler +#: code:addons/endpoint_route_handler/models/endpoint_route_handler.py:0 +#, python-format +msgid "" +"Non unique route(s): %(routes)s.\n" +"Found in model(s): %(models)s.\n" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_handler__registry_sync +#: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_handler_tool__registry_sync +#: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_sync_mixin__registry_sync +msgid "" +"ON: the record has been modified and registry was not notified.\n" +"No change will be active until this flag is set to false via proper action.\n" +"\n" +"OFF: record in line with the registry, nothing to do." +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__registry_sync +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__registry_sync +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_sync_mixin__registry_sync +msgid "Registry Sync" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__request_content_type +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__request_content_type +msgid "Request Content Type" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__request_method +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__request_method +msgid "Request Method" +msgstr "" + +#. module: endpoint_route_handler +#: code:addons/endpoint_route_handler/models/endpoint_route_handler.py:0 +#, python-format +msgid "Request content type is required for POST and PUT." +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__route +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__route +msgid "Route" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__route_group +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__route_group +msgid "Route Group" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__route_type +#: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__route_type +msgid "Route Type" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_handler__route_group +#: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_handler_tool__route_group +msgid "Use this to classify routes together" +msgstr "" + +#. module: endpoint_route_handler +#: model:ir.model.constraint,message:endpoint_route_handler.constraint_endpoint_endpoint_endpoint_route_unique +#: model:ir.model.constraint,message:endpoint_route_handler.constraint_endpoint_mixin_endpoint_route_unique +#: model:ir.model.constraint,message:endpoint_route_handler.constraint_endpoint_route_handler_endpoint_route_unique +#: model:ir.model.constraint,message:endpoint_route_handler.constraint_endpoint_route_handler_tool_endpoint_route_unique +msgid "You can register an endpoint route only once." +msgstr "" + +#. module: endpoint_route_handler +#: code:addons/endpoint_route_handler/models/endpoint_route_handler.py:0 +#, python-format +msgid "`%s` uses a blacklisted routed = `%s`" +msgstr "" diff --git a/endpoint_route_handler/models/__init__.py b/endpoint_route_handler/models/__init__.py new file mode 100644 index 0000000000..2115e50307 --- /dev/null +++ b/endpoint_route_handler/models/__init__.py @@ -0,0 +1,4 @@ +from . import endpoint_route_sync_mixin +from . import endpoint_route_handler +from . import endpoint_route_handler_tool +from . import ir_http diff --git a/endpoint_route_handler/models/endpoint_route_handler.py b/endpoint_route_handler/models/endpoint_route_handler.py new file mode 100644 index 0000000000..b73a46cb8a --- /dev/null +++ b/endpoint_route_handler/models/endpoint_route_handler.py @@ -0,0 +1,254 @@ +# Copyright 2021 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import logging + +from odoo import _, api, exceptions, fields, models + +ENDPOINT_ROUTE_CONSUMER_MODELS = { + # by db +} + + +class EndpointRouteHandler(models.AbstractModel): + + _name = "endpoint.route.handler" + _inherit = "endpoint.route.sync.mixin" + _description = "Endpoint Route handler" + + name = fields.Char(required=True) + route = fields.Char( + required=True, + index=True, + compute="_compute_route", + readonly=False, + store=True, + copy=False, + ) + route_group = fields.Char(help="Use this to classify routes together") + route_type = fields.Selection(selection="_selection_route_type", default="http") + auth_type = fields.Selection( + selection="_selection_auth_type", default="user_endpoint" + ) + request_content_type = fields.Selection(selection="_selection_request_content_type") + # TODO: this is limiting the possibility of supporting more than one method. + request_method = fields.Selection( + selection="_selection_request_method", required=True + ) + # # TODO: validate params? Just for doc? Maybe use Cerberus? + # # -> For now let the implementer validate the params in the snippet. + # request_params = Serialized(help="TODO") + + endpoint_hash = fields.Char( + compute="_compute_endpoint_hash", help="Identify the route with its main params" + ) + csrf = fields.Boolean(default=False) + + # TODO: add flag to prevent route updates on save -> + # should be handled by specific actions + filter in a tree view + btn on form + + _sql_constraints = [ + ( + "endpoint_route_unique", + "unique(route)", + "You can register an endpoint route only once.", + ) + ] + + @api.constrains("route") + def _check_route_unique_across_models(self): + """Make sure routes are unique across all models. + + The SQL constraint above, works only on one specific model/table. + Here we check that routes stay unique across all models. + This is mostly to make sure admins know that the route already exists + somewhere else, because route controllers are registered only once + for the same path. + """ + # TODO: add tests registering a fake model. + # However, @simahawk tested manually and it works. + # TODO: shall we check for route existance in the registry instead? + all_models = self._get_endpoint_route_consumer_models() + routes = [x["route"] for x in self.read(["route"])] + clashing_models = [] + for model in all_models: + if model != self._name and self.env[model].sudo().search_count( + [("route", "in", routes)] + ): + clashing_models.append(model) + if clashing_models: + raise exceptions.UserError( + _( + "Non unique route(s): %(routes)s.\n" + "Found in model(s): %(models)s.\n" + ) + % {"routes": ", ".join(routes), "models": ", ".join(clashing_models)} + ) + + def _get_endpoint_route_consumer_models(self): + global ENDPOINT_ROUTE_CONSUMER_MODELS + if ENDPOINT_ROUTE_CONSUMER_MODELS.get(self.env.cr.dbname): + return ENDPOINT_ROUTE_CONSUMER_MODELS.get(self.env.cr.dbname) + models = [] + route_model = "endpoint.route.handler" + for model in self.env.values(): + if ( + model._name != route_model + and not model._abstract + and route_model in model._inherit + ): + models.append(model._name) + ENDPOINT_ROUTE_CONSUMER_MODELS[self.env.cr.dbname] = models + return models + + @property + def _logger(self): + return logging.getLogger(self._name) + + def _selection_route_type(self): + return [("http", "HTTP"), ("json", "JSON")] + + def _selection_auth_type(self): + return [("public", "Public"), ("user_endpoint", "User")] + + def _selection_request_method(self): + return [ + ("GET", "GET"), + ("POST", "POST"), + ("PUT", "PUT"), + ("DELETE", "DELETE"), + ] + + def _selection_request_content_type(self): + return [ + ("", "None"), + ("text/plain", "Text"), + ("text/csv", "CSV"), + ("application/json", "JSON"), + ("application/xml", "XML"), + ("application/x-www-form-urlencoded", "Form"), + ("application/json; charset=utf-8", "JSON_UTF8 (Deprecated)"), + ] + + @api.depends(lambda self: self._routing_impacting_fields()) + def _compute_endpoint_hash(self): + # Do not use read to be able to play this on NewId records too + # (NewId records are classified as missing in ACL check). + # values = self.read(self._routing_impacting_fields()) + values = [ + {fname: rec[fname] for fname in self._routing_impacting_fields()} + for rec in self + ] + for rec, vals in zip(self, values): + vals.pop("id", None) + rec.endpoint_hash = hash(tuple(vals.values())) + + def _routing_impacting_fields(self): + return ("route", "auth_type", "request_method") + + def _compute_route(self): + for rec in self: + rec.route = rec._clean_route() + + def write(self, vals): + if "route" not in vals: + return super().write(vals) + res = True + for rec in self: + new_vals = vals.copy() + cleaned_route = rec._clean_route(vals["route"]) + new_vals["route"] = cleaned_route + res = res and super(EndpointRouteHandler, rec).write(new_vals) + return res + + # TODO: move to something better? Eg: computed field? + # Shall we use the route_group? TBD! + _endpoint_route_prefix = "" + + def _clean_route(self, route_to_write=False): + route = (route_to_write or self.route or "").strip() + if not route.startswith("/"): + route = "/" + route + prefix = self._endpoint_route_prefix + if prefix and not route.startswith(prefix): + route = prefix + route + return route + + _blacklist_routes = ("/", "/web") # TODO: what else? + + @api.constrains("route") + def _check_route(self): + for rec in self: + if rec.route in self._blacklist_routes: + raise exceptions.UserError( + _("`%s` uses a blacklisted routed = `%s`") % (rec.name, rec.route) + ) + + @api.constrains("request_method", "request_content_type") + def _check_request_method(self): + for rec in self: + if rec.request_method in ("POST", "PUT") and not rec.request_content_type: + raise exceptions.UserError( + _("Request content type is required for POST and PUT.") + ) + + def _prepare_endpoint_rules(self, options=None): + return [rec._make_controller_rule(options=options) for rec in self] + + def _registered_endpoint_rule_keys(self): + return tuple([rec._endpoint_registry_unique_key() for rec in self]) + + def _endpoint_registry_unique_key(self): + return "{0._name}:{0.id}".format(self) + + # TODO: consider if useful or not for single records + def _register_single_controller(self, options=None, key=None, init=False): + """Shortcut to register one single controller.""" + rule = self._make_controller_rule(options=options, key=key) + self._endpoint_registry.update_rules([rule], init=init) + self._logger.debug( + "Registered controller %s (auth: %s)", self.route, self.auth_type + ) + + def _make_controller_rule(self, options=None, key=None): + key = key or self._endpoint_registry_unique_key() + route, routing, endpoint_hash = self._get_routing_info() + options = options or self._default_endpoint_options() + return self._endpoint_registry.make_rule( + # fmt: off + key, + route, + options, + routing, + endpoint_hash, + route_group=self.route_group + # fmt: on + ) + + def _default_endpoint_options(self): + options = {"handler": self._default_endpoint_options_handler()} + return options + + def _default_endpoint_options_handler(self): + self._logger.warning( + "No specific endpoint handler options defined for: %s, falling back to default", + self._name, + ) + base_path = "odoo.addons.endpoint_route_handler.controllers.main" + return { + "klass_dotted_path": f"{base_path}.EndpointNotFoundController", + "method_name": "auto_not_found", + "default_pargs": (self.route,), + } + + def _get_routing_info(self): + route = self.route + routing = dict( + type=self.route_type, + auth=self.auth_type, + methods=[self.request_method], + routes=[route], + csrf=self.csrf, + ) + return route, routing, self.endpoint_hash diff --git a/endpoint_route_handler/models/endpoint_route_handler_tool.py b/endpoint_route_handler/models/endpoint_route_handler_tool.py new file mode 100644 index 0000000000..f699457ffd --- /dev/null +++ b/endpoint_route_handler/models/endpoint_route_handler_tool.py @@ -0,0 +1,46 @@ +# Copyright 2023 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + + +from odoo import api, models + + +class EndpointRouteHandlerTool(models.TransientModel): + """Model meant to be used as a tool. + + From v15 on we cannot initialize AbstractModel using `new()` anymore. + Here we proxy the abstract model with a transient model so that we can initialize it + but we don't care at all about storing it in the DB. + """ + + # TODO: try using `_auto = False` + + _name = "endpoint.route.handler.tool" + _inherit = "endpoint.route.handler" + _description = "Endpoint Route handler tool" + + def _refresh_endpoint_data(self): + """Enforce refresh of route computed fields. + + Required for NewId records when using this model as a tool. + """ + self._compute_endpoint_hash() + self._compute_route() + + def _register_controllers(self, init=False, options=None): + if self: + self._refresh_endpoint_data() + return super()._register_controllers(init=init, options=options) + + def _unregister_controllers(self): + if self: + self._refresh_endpoint_data() + return super()._unregister_controllers() + + @api.model + def new(self, values=None, origin=None, ref=None): + values = values or {} # note: in core odoo they use `{}` as defaul arg :/ + res = super().new(values=values, origin=origin, ref=ref) + res._refresh_endpoint_data() + return res diff --git a/endpoint_route_handler/models/endpoint_route_sync_mixin.py b/endpoint_route_handler/models/endpoint_route_sync_mixin.py new file mode 100644 index 0000000000..01a997c65e --- /dev/null +++ b/endpoint_route_handler/models/endpoint_route_sync_mixin.py @@ -0,0 +1,113 @@ +# Copyright 2021 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import logging +from functools import partial + +from odoo import api, fields, models + +from ..registry import EndpointRegistry + +_logger = logging.getLogger(__file__) + + +class EndpointRouteSyncMixin(models.AbstractModel): + """Mixin to handle synchronization of custom routes to the registry. + + Consumers of this mixin gain: + + * handling of sync state + * sync helpers + * automatic registration of routes on boot + + Consumers of this mixin must implement: + + * `_prepare_endpoint_rules` to retrieve all the `EndpointRule` to register + * `_registered_endpoint_rule_keys` to retrieve all keys of registered rules + """ + + _name = "endpoint.route.sync.mixin" + _description = "Endpoint Route sync mixin" + + active = fields.Boolean(default=True) + registry_sync = fields.Boolean( + help="ON: the record has been modified and registry was not notified." + "\nNo change will be active until this flag is set to false via proper action." + "\n\nOFF: record in line with the registry, nothing to do.", + default=False, + copy=False, + ) + + def write(self, vals): + if any([x in vals for x in self._routing_impacting_fields() + ("active",)]): + # Mark as out of sync + vals["registry_sync"] = False + res = super().write(vals) + if vals.get("registry_sync"): + # NOTE: this is not done on create to allow bulk reload of the envs + # and avoid multiple env restarts in case of multiple edits + # on one or more records in a row. + self._add_after_commit_hook(self.ids) + return res + + @api.model + def _add_after_commit_hook(self, record_ids): + self.env.cr.after( + "commit", partial(self._handle_registry_sync_post_commit, record_ids), + ) + + def _handle_registry_sync(self, record_ids=None): + """Register and un-register controllers for given records.""" + record_ids = record_ids or self.ids + _logger.info("%s sync registry for %s", self._name, str(record_ids)) + records = self.browse(record_ids).exists() + records.filtered(lambda x: x.active)._register_controllers() + records.filtered(lambda x: not x.active)._unregister_controllers() + + def _handle_registry_sync_post_commit(self, record_ids=None): + """Handle registry sync after commit. + + When the sync is triggered as a post-commit hook + the env has been flushed already and the cursor committed, of course. + Hence, we must commit explicitly. + """ + self._handle_registry_sync(record_ids=record_ids) + self.env.cr.commit() # pylint: disable=invalid-commit + + @property + def _endpoint_registry(self): + return EndpointRegistry.registry_for(self.env.cr) + + def unlink(self): + if not self._abstract: + self._unregister_controllers() + return super().unlink() + + def _register_controllers(self, init=False, options=None): + if not self: + return + rules = self._prepare_endpoint_rules(options=options) + self._endpoint_registry.update_rules(rules, init=init) + _logger.debug( + "%s registered controllers: %s", + self._name, + ", ".join([r.route for r in rules]), + ) + + def _unregister_controllers(self): + if not self: + return + self._endpoint_registry.drop_rules(self._registered_endpoint_rule_keys()) + + def _routing_impacting_fields(self, options=None): + """Return list of fields that have impact on routing for current record.""" + raise NotImplementedError() + + def _prepare_endpoint_rules(self, options=None): + """Return list of `EndpointRule` instances for current record.""" + raise NotImplementedError() + + def _registered_endpoint_rule_keys(self): + """Return list of registered `EndpointRule` unique keys for current record.""" + raise NotImplementedError() diff --git a/endpoint_route_handler/models/ir_http.py b/endpoint_route_handler/models/ir_http.py new file mode 100644 index 0000000000..2a856ea5af --- /dev/null +++ b/endpoint_route_handler/models/ir_http.py @@ -0,0 +1,102 @@ +# Copyright 2021 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import logging +from itertools import chain + +import werkzeug + +from odoo import http, models, registry as registry_get + +from ..registry import EndpointRegistry + +_logger = logging.getLogger(__name__) + + +class IrHttp(models.AbstractModel): + _inherit = "ir.http" + + @classmethod + def _endpoint_route_registry(cls, cr): + return EndpointRegistry.registry_for(cr) + + @classmethod + def _generate_routing_rules(cls, modules, converters): + # Override to inject custom endpoint rules. + return chain( + super()._generate_routing_rules(modules, converters), + cls._endpoint_routing_rules(), + ) + + @classmethod + def _endpoint_routing_rules(cls): + """Yield custom endpoint rules""" + e_registry = cls._endpoint_route_registry(http.request.env.cr) + for endpoint_rule in e_registry.get_rules(): + _logger.debug("LOADING %s", endpoint_rule) + endpoint = endpoint_rule.endpoint + for url in endpoint_rule.routing["routes"]: + yield (url, endpoint, endpoint_rule.routing) + + @classmethod + def routing_map(cls): + # When the request cursor is used to instantiate the EndpointRegistry + # in the call to routing_map, the READ REPEATABLE isolation level + # will ensure that any value read from the DB afterwards, will be the + # same than when the first SELECT is executed. + # + # This is breaking the oauth flow as the oauth token that is written + # at the beggining of the oauth process cannot be read by the cursor + # computing the session token, which will read an old value. Therefore + # when the session security check is performed, the session token + # is outdated as the new session token is computed using an up to date + # cursor. + # + # By using a dedicated cursor to instantiate the EndpointRegistry, we + # ensure no read is performed on the database using the request cursor + # which will in turn use the updated value of the oauth token to compute + # the session token, and the security check will not fail. + registry = registry_get(http.request.env.cr.dbname) + with registry.cursor() as cr: + last_version = cls._get_routing_map_last_version(cr) + if not hasattr(cls, "_routing_map"): + _logger.debug( + "routing map just initialized, store last update for this env" + ) + # routing map just initialized, store last update for this env + cls._endpoint_route_last_version = last_version + elif cls._endpoint_route_last_version < last_version: + _logger.info("Endpoint registry updated, reset routing map") + cls._routing_map = {} + cls._rewrite_len = {} + cls._endpoint_route_last_version = last_version + return super().routing_map() + + @classmethod + def _get_routing_map_last_version(cls, cr): + return cls._endpoint_route_registry(cr).last_version() + + @classmethod + def _clear_routing_map(cls): + super()._clear_routing_map() + if hasattr(cls, "_endpoint_route_last_version"): + cls._endpoint_route_last_version = 0 + + @classmethod + def _auth_method_user_endpoint(cls): + """Special method for user auth which raises Unauthorized when needed. + + If you get an HTTP request (instead of a JSON one), + the standard `user` method raises `SessionExpiredException` + when there's no user session. + This leads to a redirect to `/web/login` + which is not desiderable for technical endpoints. + + This method makes sure that no matter the type of request we get, + a proper exception is raised. + """ + try: + cls._auth_method_user() + except http.SessionExpiredException: + raise werkzeug.exceptions.Unauthorized() diff --git a/endpoint_route_handler/post_init_hook.py b/endpoint_route_handler/post_init_hook.py new file mode 100644 index 0000000000..d706f0e9c0 --- /dev/null +++ b/endpoint_route_handler/post_init_hook.py @@ -0,0 +1,14 @@ +# Copyright 2022 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import logging + +from .registry import EndpointRegistry + +_logger = logging.getLogger(__name__) + + +def post_init_hook(cr, registry): + # this is the trigger that sends notifications when jobs change + _logger.info("Create table") + EndpointRegistry._setup_db(cr) diff --git a/endpoint_route_handler/readme/CONTRIBUTORS.rst b/endpoint_route_handler/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..37423d21ee --- /dev/null +++ b/endpoint_route_handler/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Simone Orsi +* Guillem Casassas diff --git a/endpoint_route_handler/readme/DESCRIPTION.rst b/endpoint_route_handler/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..66675612b6 --- /dev/null +++ b/endpoint_route_handler/readme/DESCRIPTION.rst @@ -0,0 +1,4 @@ +Technical module that provides a base handler +for adding and removing controller routes on the fly. + +Can be used as a mixin or as a tool. diff --git a/endpoint_route_handler/readme/ROADMAP.rst b/endpoint_route_handler/readme/ROADMAP.rst new file mode 100644 index 0000000000..7e79bf7f07 --- /dev/null +++ b/endpoint_route_handler/readme/ROADMAP.rst @@ -0,0 +1,23 @@ +* add api docs helpers +* allow multiple HTTP methods on the same endpoint +* multiple values for route and methods + + keep the same in the ui for now, later own we can imagine a multi-value selection or just add text field w/ proper validation and cleanup + + remove the route field in the table of endpoint_route + + support a comma separated list of routes + maybe support comma separated list of methods + use only routing.routes for generating the rule + sort and freeze its values to update the endpoint hash + + catch dup route exception on the sync to detect duplicated routes + and use the endpoint_hash to retrieve the real record + (note: we could store more info in the routing information which will stay in the map) + + for customizing the rule behavior the endpoint the hook is to override the registry lookup + + make EndpointRule class overridable on the registry + +NOTE in v16 we won't care anymore about odoo controller +so the lookup of the controller can be simplified to a basic py obj that holds the routing info. diff --git a/endpoint_route_handler/readme/USAGE.rst b/endpoint_route_handler/readme/USAGE.rst new file mode 100644 index 0000000000..b0c1581b10 --- /dev/null +++ b/endpoint_route_handler/readme/USAGE.rst @@ -0,0 +1,61 @@ +As a mixin +~~~~~~~~~~ + +Use standard Odoo inheritance:: + + class MyModel(models.Model): + _name = "my.model" + _inherit = "endpoint.route.handler" + +Once you have this, each `my.model` record will generate a route. +You can have a look at the `endpoint` module to see a real life example. + +The options of the routing rules are defined by the method `_default_endpoint_options`. +Here's an example from the `endpoint` module:: + + def _default_endpoint_options_handler(self): + return { + "klass_dotted_path": "odoo.addons.endpoint.controllers.main.EndpointController", + "method_name": "auto_endpoint", + "default_pargs": (self.route,), + } + +As you can see, you have to pass the references to the controller class and the method to use +when the endpoint is called. And you can prepare some default arguments to pass. +In this case, the route of the current record. + + +As a tool +~~~~~~~~~ + +Initialize non stored route handlers and generate routes from them. +For instance:: + + route_handler = self.env["endpoint.route.handler.tool"] + endpoint_handler = MyController()._my_handler + vals = { + "name": "My custom route", + "route": "/my/custom/route", + "request_method": "GET", + "auth_type": "public", + } + new_route = route_handler.new(vals) + new_route._register_controller() + +You can override options and define - for instance - a different controller method:: + + options = { + "handler": { + "klass_dotted_path": "odoo.addons.my_module.controllers.SpecialController", + "method_name": "my_special_handler", + } + } + new_route._register_controller(options=options) + +Of course, what happens when the endpoint gets called +depends on the logic defined on the controller method. + +In both cases (mixin and tool) when a new route is generated or an existing one is updated, +the `ir.http.routing_map` (which holds all Odoo controllers) will be updated. + +You can see a real life example on `shopfloor.app` model. diff --git a/endpoint_route_handler/registry.py b/endpoint_route_handler/registry.py new file mode 100644 index 0000000000..5096f2bfb1 --- /dev/null +++ b/endpoint_route_handler/registry.py @@ -0,0 +1,390 @@ +# Copyright 2021 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import importlib +import json +import logging +from functools import partial + +from psycopg2 import sql +from psycopg2.extensions import AsIs +from psycopg2.extras import execute_values + +from odoo import http, tools +from odoo.tools import DotDict + +from odoo.addons.base.models.ir_model import query_insert + +from .exceptions import EndpointHandlerNotFound + +_logger = logging.getLogger(__name__) + + +def query_multi_update(cr, table_name, rows, cols): + """Update multiple rows at once. + + :param `cr`: active db cursor + :param `table_name`: sql table to update + :param `rows`: list of dictionaries with write-ready values + :param `cols`: list of keys representing columns' names + """ + # eg: key=c.key, route=c.route + keys = sql.SQL(",").join([sql.SQL("{0}=c.{0}".format(col)) for col in cols]) + col_names = sql.SQL(",").join([sql.Identifier(col) for col in cols]) + template = ( + sql.SQL("(") + + sql.SQL(",").join([sql.SQL("%({})s".format(col)) for col in cols]) + + sql.SQL(")") + ) + query = sql.SQL( + """ + UPDATE {table} AS t SET + {keys} + FROM (VALUES {values}) + AS c({col_names}) + WHERE c.key = t.key + RETURNING t.key + """ + ).format( + table=sql.Identifier(table_name), + keys=keys, + col_names=col_names, + values=sql.Placeholder(), + ) + execute_values( + cr, query.as_string(cr._cnx), rows, template=template.as_string(cr._cnx), + ) + + +class EndpointRegistry: + """Registry for endpoints. + + Used to: + + * track registered endpoints + * retrieve routing rules to load in ir.http routing map + """ + + __slots__ = "cr" + _table = "endpoint_route" + # pylint: disable=W8105 + _columns = ( + # name, type, comment + ("key", "VARCHAR", ""), + ("route", "VARCHAR", ""), + ("opts", "text", ""), + ("routing", "text", ""), + ("endpoint_hash", "VARCHAR(32)", ""), + ("route_group", "VARCHAR(32)", ""), + ("updated_at", "TIMESTAMP NOT NULL DEFAULT NOW()", ""), + ) + + @classmethod + def registry_for(cls, cr): + return cls(cr) + + @classmethod + def wipe_registry_for(cls, cr): + cr.execute("TRUNCATE endpoint_route") + _logger.info("endpoint_route wiped") + + @classmethod + def _setup_db(cls, cr): + if not tools.sql.table_exists(cr, cls._table): + cls._setup_db_table(cr) + cls._setup_db_timestamp(cr) + cls._setup_db_version(cr) + _logger.info("endpoint_route table set up") + + @classmethod + def _setup_db_table(cls, cr): + """Create routing table and indexes""" + tools.sql.create_model_table(cr, cls._table) + for name, sql_type, _comment in cls._columns: + tools.sql.create_column(cr, cls._table, name, sql_type, _comment) + tools.sql.create_unique_index( + cr, "endpoint_route__key_uniq", cls._table, ["key"], + ) + tools.sql.add_constraint( + cr, + cls._table, + "endpoint_route__endpoint_hash_uniq", + "unique(endpoint_hash)", + ) + + @classmethod + def _setup_db_timestamp(cls, cr): + """Create trigger to update rows timestamp on updates""" + cr.execute( + """ + CREATE OR REPLACE FUNCTION endpoint_route_set_timestamp() + RETURNS TRIGGER AS $$ + BEGIN + NEW.updated_at = NOW(); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + """ + ) + cr.execute( + """ + CREATE TRIGGER trigger_endpoint_route_set_timestamp + BEFORE UPDATE ON %(table)s + FOR EACH ROW + EXECUTE PROCEDURE endpoint_route_set_timestamp(); + """, + {"table": AsIs(cls._table)}, + ) + + @classmethod + def _setup_db_version(cls, cr): + """Create sequence and triggers to keep track of routes' version""" + cr.execute( + """ + SELECT 1 FROM pg_class WHERE RELNAME = 'endpoint_route_version' + """ + ) + if not cr.fetchone(): + sql = """ + CREATE SEQUENCE endpoint_route_version INCREMENT BY 1 START WITH 1; + CREATE OR REPLACE FUNCTION increment_endpoint_route_version() + RETURNS TRIGGER AS $$ + BEGIN + PERFORM nextval('endpoint_route_version'); + RETURN NEW; + END; + $$ language plpgsql; + CREATE TRIGGER update_endpoint_route_version_trigger + BEFORE INSERT ON %(table)s + for each row execute procedure increment_endpoint_route_version(); + CREATE TRIGGER insert_endpoint_route_version_trigger + BEFORE UPDATE ON %(table)s + for each row execute procedure increment_endpoint_route_version(); + """ + cr.execute(sql, {"table": AsIs(cls._table)}) + + def __init__(self, cr): + self.cr = cr + + def get_rules(self, keys=None, where=None): + for row in self._get_rules(keys=keys, where=where): + yield EndpointRule.from_row(self.cr.dbname, row) + + def _get_rules(self, keys=None, where=None, one=False): + query = "SELECT * FROM endpoint_route" + pargs = () + if keys and not where: + query += " WHERE key IN %s" + pargs = (tuple(keys),) + elif where: + query += " " + where + self.cr.execute(query, pargs) + return self.cr.fetchone() if one else self.cr.fetchall() + + def _get_rule(self, key): + row = self._get_rules(keys=(key,), one=True) + if row: + return EndpointRule.from_row(self.cr.dbname, row) + + def _lock_rows(self, keys): + sql = "SELECT id FROM endpoint_route WHERE key IN %s FOR UPDATE" + self.cr.execute(sql, (tuple(keys),), log_exceptions=False) + + def _update(self, rows_mapping): + self._lock_rows(tuple(rows_mapping.keys())) + return query_multi_update( + self.cr, + self._table, + tuple(rows_mapping.values()), + EndpointRule._ordered_columns(), + ) + + def _create(self, rows_mapping): + return query_insert(self.cr, self._table, list(rows_mapping.values())) + + def get_rules_by_group(self, group): + rules = self.get_rules(where=f"WHERE route_group='{group}'") + return rules + + def update_rules(self, rules, init=False): + """Add or update rules. + + :param rule: list of instances of EndpointRule + :param force: replace rules forcedly + :param init: given when adding rules for the first time + """ + keys = [x.key for x in rules] + existing = {x.key: x for x in self.get_rules(keys=keys)} + to_create = {} + to_update = {} + for rule in rules: + if rule.key in existing: + to_update[rule.key] = rule.to_row() + else: + to_create[rule.key] = rule.to_row() + res = False + if to_create: + self._create(to_create) + res = True + if to_update: + self._update(to_update) + res = True + return res + + def drop_rules(self, keys): + self.cr.execute("DELETE FROM endpoint_route WHERE key IN %s", (tuple(keys),)) + return True + + def make_rule(self, *a, **kw): + return EndpointRule(self.cr.dbname, *a, **kw) + + def last_update(self): + self.cr.execute( + """ + SELECT updated_at + FROM endpoint_route + ORDER BY updated_at DESC + LIMIT 1 + """ + ) + res = self.cr.fetchone() + if res: + return res[0].timestamp() + return 0.0 + + def last_version(self): + self.cr.execute( + """ + SELECT last_value FROM endpoint_route_version + """ + ) + res = self.cr.fetchone() + if res: + return res[0] + return -1 + + +class EndpointRule: + """Hold information for a custom endpoint rule.""" + + __slots__ = ( + "_dbname", + "key", + "route", + "opts", + "endpoint_hash", + "routing", + "route_group", + ) + + def __init__( + self, dbname, key, route, options, routing, endpoint_hash, route_group=None + ): + self._dbname = dbname + self.key = key + self.route = route + self.options = options + self.routing = routing + self.endpoint_hash = endpoint_hash + self.route_group = route_group + + def __repr__(self): + # FIXME: use class name, remove key + return ( + f"<{self.__class__.__name__}: {self.key}" + + (f" #{self.route_group}" if self.route_group else "nogroup") + + ">" + ) + + @classmethod + def _ordered_columns(cls): + return [k for k in cls.__slots__ if not k.startswith("_")] + + @property + def options(self): + return DotDict(self.opts) + + @options.setter + def options(self, value): + """Validate options. + + See `_get_handler` for more info. + """ + assert "klass_dotted_path" in value["handler"] + assert "method_name" in value["handler"] + self.opts = value + + @classmethod + def from_row(cls, dbname, row): + key, route, options, routing, endpoint_hash, route_group = row[1:-1] + # TODO: #jsonb-ref + options = json.loads(options) + routing = json.loads(routing) + init_args = ( + dbname, + key, + route, + options, + routing, + endpoint_hash, + route_group, + ) + return cls(*init_args) + + def to_dict(self): + return {k: getattr(self, k) for k in self._ordered_columns()} + + def to_row(self): + row = self.to_dict() + for k, v in row.items(): + if isinstance(v, (dict, list)): + row[k] = json.dumps(v) + return row + + @property + def endpoint(self): + """Lookup http.Endpoint to be used for the routing map.""" + handler = self._get_handler() + pargs = self.handler_options.get("default_pargs", ()) + kwargs = self.handler_options.get("default_kwargs", {}) + method = partial(handler, *pargs, **kwargs) + return http.EndPoint(method, self.routing) + + @property + def handler_options(self): + return self.options.handler + + def _get_handler(self): + """Resolve endpoint handler lookup. + + `options` must contain `handler` key to provide: + + * the controller's klass via `klass_dotted_path` + * the controller's method to use via `method_name` + + Lookup happens by: + + 1. importing the controller klass module + 2. loading the klass + 3. accessing the method via its name + + If any of them is not found, a specific exception is raised. + """ + mod_path, klass_name = self.handler_options.klass_dotted_path.rsplit(".", 1) + try: + mod = importlib.import_module(mod_path) + except ImportError as exc: + raise EndpointHandlerNotFound(f"Module `{mod_path}` not found") from exc + try: + klass = getattr(mod, klass_name) + except AttributeError as exc: + raise EndpointHandlerNotFound(f"Class `{klass_name}` not found") from exc + method_name = self.handler_options.method_name + try: + method = getattr(klass(), method_name) + except AttributeError as exc: + raise EndpointHandlerNotFound( + f"Method name `{method_name}` not found" + ) from exc + return method diff --git a/endpoint_route_handler/security/ir.model.access.csv b/endpoint_route_handler/security/ir.model.access.csv new file mode 100644 index 0000000000..c070dc5621 --- /dev/null +++ b/endpoint_route_handler/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_endpoint_route_handler_tool_mngr_edit,endpoint_route_handler mngr edit,model_endpoint_route_handler,base.group_system,1,1,1,1 +access_endpoint_route_handler_tool_edit,endpoint_route_handler edit,model_endpoint_route_handler,,1,0,0,0 +access_endpoint_route_handler_mngr_edit,endpoint_route_handler_tool mngr edit,model_endpoint_route_handler_tool,base.group_system,1,1,1,1 +access_endpoint_route_handler_edit,endpoint_route_handler_tool edit,model_endpoint_route_handler_tool,,1,0,0,0 diff --git a/endpoint_route_handler/static/description/icon.png b/endpoint_route_handler/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/endpoint_route_handler/static/description/icon.png differ diff --git a/endpoint_route_handler/static/description/index.html b/endpoint_route_handler/static/description/index.html new file mode 100644 index 0000000000..c9bd50a4d8 --- /dev/null +++ b/endpoint_route_handler/static/description/index.html @@ -0,0 +1,518 @@ + + + + + +Endpoint route handler + + + +
+

Endpoint route handler

+ + +

Beta License: LGPL-3 OCA/web-api Translate me on Weblate Try me on Runboat

+

Technical module that provides a base handler +for adding and removing controller routes on the fly.

+

Can be used as a mixin or as a tool.

+

Table of contents

+ +
+

Usage

+
+

As a mixin

+

Use standard Odoo inheritance:

+
+class MyModel(models.Model):
+    _name = "my.model"
+    _inherit = "endpoint.route.handler"
+
+

Once you have this, each my.model record will generate a route. +You can have a look at the endpoint module to see a real life example.

+

The options of the routing rules are defined by the method _default_endpoint_options. +Here’s an example from the endpoint module:

+
+def _default_endpoint_options_handler(self):
+    return {
+        "klass_dotted_path": "odoo.addons.endpoint.controllers.main.EndpointController",
+        "method_name": "auto_endpoint",
+        "default_pargs": (self.route,),
+    }
+
+

As you can see, you have to pass the references to the controller class and the method to use +when the endpoint is called. And you can prepare some default arguments to pass. +In this case, the route of the current record.

+
+
+

As a tool

+

Initialize non stored route handlers and generate routes from them. +For instance:

+
+route_handler = self.env["endpoint.route.handler.tool"]
+endpoint_handler = MyController()._my_handler
+vals = {
+    "name": "My custom route",
+    "route": "/my/custom/route",
+    "request_method": "GET",
+    "auth_type": "public",
+}
+new_route = route_handler.new(vals)
+new_route._register_controller()
+
+

You can override options and define - for instance - a different controller method:

+
+options = {
+    "handler": {
+        "klass_dotted_path": "odoo.addons.my_module.controllers.SpecialController",
+        "method_name": "my_special_handler",
+    }
+}
+new_route._register_controller(options=options)
+
+

Of course, what happens when the endpoint gets called +depends on the logic defined on the controller method.

+

In both cases (mixin and tool) when a new route is generated or an existing one is updated, +the ir.http.routing_map (which holds all Odoo controllers) will be updated.

+

You can see a real life example on shopfloor.app model.

+
+
+
+

Known issues / Roadmap

+
    +
  • add api docs helpers

    +
  • +
  • allow multiple HTTP methods on the same endpoint

    +
  • +
  • multiple values for route and methods

    +
    +

    keep the same in the ui for now, later own we can imagine a multi-value selection or just add text field w/ proper validation and cleanup

    +

    remove the route field in the table of endpoint_route

    +

    support a comma separated list of routes +maybe support comma separated list of methods +use only routing.routes for generating the rule +sort and freeze its values to update the endpoint hash

    +

    catch dup route exception on the sync to detect duplicated routes +and use the endpoint_hash to retrieve the real record +(note: we could store more info in the routing information which will stay in the map)

    +

    for customizing the rule behavior the endpoint the hook is to override the registry lookup

    +

    make EndpointRule class overridable on the registry

    +
    +
  • +
+

NOTE in v16 we won’t care anymore about odoo controller +so the lookup of the controller can be simplified to a basic py obj that holds the routing info.

+
+
+

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

+
    +
  • Camptocamp
  • +
+
+ +
+

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:

+

simahawk

+

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/endpoint_route_handler/tests/__init__.py b/endpoint_route_handler/tests/__init__.py new file mode 100644 index 0000000000..e1ad88a9a0 --- /dev/null +++ b/endpoint_route_handler/tests/__init__.py @@ -0,0 +1,3 @@ +from . import test_registry +from . import test_endpoint +from . import test_endpoint_controller diff --git a/endpoint_route_handler/tests/common.py b/endpoint_route_handler/tests/common.py new file mode 100644 index 0000000000..9e2681ec5e --- /dev/null +++ b/endpoint_route_handler/tests/common.py @@ -0,0 +1,50 @@ +# Copyright 2021 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import contextlib + +from odoo.tests.common import SavepointCase, tagged +from odoo.tools import DotDict + +from odoo.addons.website.tools import MockRequest + + +@tagged("-at_install", "post_install") +class CommonEndpoint(SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._setup_env() + cls._setup_records() + cls.route_handler = cls.env["endpoint.route.handler"] + + @classmethod + def _setup_env(cls): + cls.env = cls.env(context=cls._setup_context()) + + @classmethod + def _setup_context(cls): + return dict(cls.env.context, tracking_disable=True,) + + @classmethod + def _setup_records(cls): + pass + + @contextlib.contextmanager + def _get_mocked_request( + self, env=None, httprequest=None, extra_headers=None, request_attrs=None + ): + with MockRequest(env or self.env) as mocked_request: + mocked_request.httprequest = ( + DotDict(httprequest) if httprequest else mocked_request.httprequest + ) + headers = {} + headers.update(extra_headers or {}) + mocked_request.httprequest.headers = headers + request_attrs = request_attrs or {} + for k, v in request_attrs.items(): + setattr(mocked_request, k, v) + mocked_request.make_response = lambda data, **kw: data + mocked_request.registry._init_modules = set() + yield mocked_request diff --git a/endpoint_route_handler/tests/fake_controllers.py b/endpoint_route_handler/tests/fake_controllers.py new file mode 100644 index 0000000000..bd4939739f --- /dev/null +++ b/endpoint_route_handler/tests/fake_controllers.py @@ -0,0 +1,29 @@ +# Copyright 2022 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import http + + +class CTRLFake(http.Controller): + # Shortcut for dotted path + _path = "odoo.addons.endpoint_route_handler.tests.fake_controllers.CTRLFake" + + def handler1(self, arg1, arg2=2): + return arg1, arg2 + + def handler2(self, arg1, arg2=2): + return arg1, arg2 + + def custom_handler(self, custom=None): + return f"Got: {custom}" + + +class TestController(http.Controller): + _path = "odoo.addons.endpoint_route_handler.tests.fake_controllers.TestController" + + def _do_something1(self, foo=None): + return f"Got: {foo}" + + def _do_something2(self, default_arg, foo=None): + return f"{default_arg} -> got: {foo}" diff --git a/endpoint_route_handler/tests/test_endpoint.py b/endpoint_route_handler/tests/test_endpoint.py new file mode 100644 index 0000000000..a62663dd10 --- /dev/null +++ b/endpoint_route_handler/tests/test_endpoint.py @@ -0,0 +1,176 @@ +# Copyright 2021 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +from contextlib import contextmanager + +import odoo +from odoo.tools import mute_logger + +from ..registry import EndpointRegistry +from .common import CommonEndpoint +from .fake_controllers import CTRLFake + + +@contextmanager +def new_rollbacked_env(): + # Borrowed from `component` + registry = odoo.registry(odoo.tests.common.get_db_name()) + uid = odoo.SUPERUSER_ID + cr = registry.cursor() + try: + yield odoo.api.Environment(cr, uid, {}) + finally: + cr.rollback() # we shouldn't have to commit anything + cr.close() + + +def make_new_route(env, **kw): + model = env["endpoint.route.handler.tool"] + vals = { + "name": "Test custom route", + "route": "/my/test/route", + "request_method": "GET", + } + vals.update(kw) + new_route = model.new(vals) + return new_route + + +class TestEndpoint(CommonEndpoint): + def tearDown(self): + self.env["ir.http"]._clear_routing_map() + EndpointRegistry.wipe_registry_for(self.env.cr) + super().tearDown() + + def test_as_tool_base_data(self): + new_route = make_new_route(self.env) + self.assertEqual(new_route.route, "/my/test/route") + first_hash = new_route.endpoint_hash + self.assertTrue(first_hash) + new_route.route += "/new" + self.assertNotEqual(new_route.endpoint_hash, first_hash) + + @mute_logger("odoo.addons.base.models.ir_http") + def test_as_tool_register_single_controller(self): + new_route = make_new_route(self.env) + options = { + "handler": { + "klass_dotted_path": CTRLFake._path, + "method_name": "custom_handler", + } + } + + with self._get_mocked_request(): + new_route._register_single_controller(options=options, init=True) + # Ensure the routing rule is registered + rmap = self.env["ir.http"].routing_map() + self.assertIn("/my/test/route", [x.rule for x in rmap._rules]) + + # Ensure is updated when needed + new_route.route += "/new" + with self._get_mocked_request(): + new_route._register_single_controller(options=options, init=True) + rmap = self.env["ir.http"]._clear_routing_map() + rmap = self.env["ir.http"].routing_map() + self.assertNotIn("/my/test/route", [x.rule for x in rmap._rules]) + self.assertIn("/my/test/route/new", [x.rule for x in rmap._rules]) + + @mute_logger("odoo.addons.base.models.ir_http") + def test_as_tool_register_controllers(self): + new_route = make_new_route(self.env) + options = { + "handler": { + "klass_dotted_path": CTRLFake._path, + "method_name": "custom_handler", + } + } + + with self._get_mocked_request(): + new_route._register_controllers(options=options, init=True) + # Ensure the routing rule is registered + rmap = self.env["ir.http"].routing_map() + self.assertIn("/my/test/route", [x.rule for x in rmap._rules]) + + # Ensure is updated when needed + new_route.route += "/new" + with self._get_mocked_request(): + new_route._register_controllers(options=options, init=True) + rmap = self.env["ir.http"]._clear_routing_map() + rmap = self.env["ir.http"].routing_map() + self.assertNotIn("/my/test/route", [x.rule for x in rmap._rules]) + self.assertIn("/my/test/route/new", [x.rule for x in rmap._rules]) + + @mute_logger("odoo.addons.base.models.ir_http") + def test_as_tool_register_controllers_dynamic_route(self): + route = "/my/app/" + new_route = make_new_route(self.env, route=route) + options = { + "handler": { + "klass_dotted_path": CTRLFake._path, + "method_name": "custom_handler", + } + } + + with self._get_mocked_request(): + new_route._register_controllers(options=options, init=True) + # Ensure the routing rule is registered + rmap = self.env["ir.http"].routing_map() + self.assertIn(route, [x.rule for x in rmap._rules]) + + +class TestEndpointCrossEnv(CommonEndpoint): + def setUp(self): + super().setUp() + self.env["ir.http"]._clear_routing_map() + EndpointRegistry.wipe_registry_for(self.env.cr) + + @mute_logger("odoo.addons.base.models.ir_http", "odoo.modules.registry") + def test_cross_env_consistency(self): + """Ensure route updates are propagated to all envs.""" + route = "/my/app/" + new_route = make_new_route(self.env, route=route) + options = { + "handler": { + "klass_dotted_path": CTRLFake._path, + "method_name": "custom_handler", + } + } + + env1 = self.env + reg = EndpointRegistry.registry_for(self.env.cr) + new_route._register_controllers(options=options) + + last_version0 = reg.last_version() + with self._get_mocked_request(): + with new_rollbacked_env() as env2: + # Load maps + env1["ir.http"].routing_map() + env2["ir.http"].routing_map() + self.assertEqual( + env1["ir.http"]._endpoint_route_last_version, last_version0 + ) + self.assertEqual( + env2["ir.http"]._endpoint_route_last_version, last_version0 + ) + rmap = self.env["ir.http"].routing_map() + self.assertIn(route, [x.rule for x in rmap._rules]) + rmap = env2["ir.http"].routing_map() + self.assertIn(route, [x.rule for x in rmap._rules]) + + # add new route + route = "/my/new/" + new_route = make_new_route(self.env, route=route) + new_route._register_controllers(options=options) + + rmap = self.env["ir.http"].routing_map() + self.assertIn(route, [x.rule for x in rmap._rules]) + rmap = env2["ir.http"].routing_map() + self.assertIn(route, [x.rule for x in rmap._rules]) + self.assertTrue( + env1["ir.http"]._endpoint_route_last_version > last_version0 + ) + self.assertTrue( + env2["ir.http"]._endpoint_route_last_version > last_version0 + ) + + # TODO: test unregister diff --git a/endpoint_route_handler/tests/test_endpoint_controller.py b/endpoint_route_handler/tests/test_endpoint_controller.py new file mode 100644 index 0000000000..919e893fb1 --- /dev/null +++ b/endpoint_route_handler/tests/test_endpoint_controller.py @@ -0,0 +1,68 @@ +# Copyright 2021 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import os +import unittest + +from odoo.tests.common import HttpCase + +from ..registry import EndpointRegistry +from .fake_controllers import TestController + + +@unittest.skipIf(os.getenv("SKIP_HTTP_CASE"), "EndpointHttpCase skipped") +class EndpointHttpCase(HttpCase): + def setUp(self): + super().setUp() + self.route_handler = self.env["endpoint.route.handler.tool"] + + def tearDown(self): + EndpointRegistry.wipe_registry_for(self.env.cr) + self.env["ir.http"]._clear_routing_map() + super().tearDown() + + def _make_new_route(self, options=None, **kw): + vals = { + "name": "Test custom route", + "request_method": "GET", + } + vals.update(kw) + new_route = self.route_handler.new(vals) + new_route._register_controllers(options=options) + return new_route + + def test_call(self): + options = { + "handler": { + "klass_dotted_path": TestController._path, + "method_name": "_do_something1", + } + } + self._make_new_route(route="/my/test/", options=options) + route = "/my/test/working" + response = self.url_open(route) + self.assertEqual(response.status_code, 401) + # Let's login now + self.authenticate("admin", "admin") + response = self.url_open(route) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b"Got: working") + + def test_call_advanced_endpoint_handler(self): + options = { + "handler": { + "klass_dotted_path": TestController._path, + "method_name": "_do_something2", + "default_pargs": ("DEFAULT",), + } + } + self._make_new_route(route="/my/advanced/test/", options=options) + route = "/my/advanced/test/working" + response = self.url_open(route) + self.assertEqual(response.status_code, 401) + # Let's login now + self.authenticate("admin", "admin") + response = self.url_open(route) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b"DEFAULT -> got: working") diff --git a/endpoint_route_handler/tests/test_registry.py b/endpoint_route_handler/tests/test_registry.py new file mode 100644 index 0000000000..36cf5c20e3 --- /dev/null +++ b/endpoint_route_handler/tests/test_registry.py @@ -0,0 +1,208 @@ +# Copyright 2021 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from psycopg2 import DatabaseError + +from odoo import http +from odoo.tests.common import SavepointCase, tagged +from odoo.tools import mute_logger + +from odoo.addons.endpoint_route_handler.exceptions import EndpointHandlerNotFound +from odoo.addons.endpoint_route_handler.registry import EndpointRegistry + +from .fake_controllers import CTRLFake + + +@tagged("-at_install", "post_install") +class TestRegistry(SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.reg = EndpointRegistry.registry_for(cls.env.cr) + + def setUp(self): + super().setUp() + EndpointRegistry.wipe_registry_for(self.env.cr) + + def tearDown(self): + EndpointRegistry.wipe_registry_for(self.env.cr) + super().tearDown() + + def _count_rules(self, groups=("test_route_handler",)): + # NOTE: use alwways groups to filter in your tests + # because some other module might add rules for testing. + self.env.cr.execute( + "SELECT COUNT(id) FROM endpoint_route WHERE route_group IN %s", (groups,) + ) + return self.env.cr.fetchone()[0] + + def test_registry_empty(self): + self.assertEqual(list(self.reg.get_rules()), []) + self.assertEqual(self._count_rules(), 0) + + def test_last_update(self): + self.assertEqual(self.reg.last_update(), 0.0) + rule1, rule2 = self._make_rules(stop=3) + last_update0 = self.reg.last_update() + self.assertTrue(last_update0 > 0) + rule1.options = { + "handler": {"klass_dotted_path": CTRLFake._path, "method_name": "handler2"} + } + # FIXME: to test timestamp we have to mock psql datetime. + # self.reg.update_rules([rule1]) + # last_update1 = self.reg.last_update() + # self.assertTrue(last_update1 > last_update0) + # rule2.options = { + # "handler": { + # "klass_dotted_path": CTRLFake._path, + # "method_name": "handler2", + # } + # } + # self.reg.update_rules([rule2]) + # last_update2 = self.reg.last_update() + # self.assertTrue(last_update2 > last_update1) + + def test_last_version(self): + last_version0 = self.reg.last_version() + self._make_rules(stop=3) + last_version1 = self.reg.last_version() + self.assertTrue(last_version1 > last_version0) + + def _make_rules(self, stop=5, start=1, **kw): + res = [] + for i in range(start, stop): + key = f"route{i}" + route = f"/test/{i}" + options = { + "handler": { + "klass_dotted_path": CTRLFake._path, + "method_name": "handler1", + } + } + routing = {"routes": []} + endpoint_hash = i + route_group = "test_route_handler" + rule = self.reg.make_rule( + key, route, options, routing, endpoint_hash, route_group=route_group, + ) + for k, v in kw.items(): + setattr(rule, k, v) + res.append(rule) + self.reg.update_rules(res) + return res + + def test_add_rule(self): + self._make_rules(stop=5) + self.assertEqual(self._count_rules(), 4) + self.assertEqual(self.reg._get_rule("route1").endpoint_hash, "1") + self.assertEqual(self.reg._get_rule("route2").endpoint_hash, "2") + self.assertEqual(self.reg._get_rule("route3").endpoint_hash, "3") + self.assertEqual(self.reg._get_rule("route4").endpoint_hash, "4") + + def test_get_rules(self): + self._make_rules(stop=4) + self.assertEqual(self._count_rules(), 3) + self.assertEqual( + [x.key for x in self.reg.get_rules()], ["route1", "route2", "route3"] + ) + self._make_rules(start=10, stop=14) + self.assertEqual(self._count_rules(), 7) + self.reg.get_rules() + self.assertEqual( + sorted([x.key for x in self.reg.get_rules()]), + sorted( + [ + "route1", + "route2", + "route3", + "route10", + "route11", + "route12", + "route13", + ] + ), + ) + + def test_update_rule(self): + rule1, rule2 = self._make_rules(stop=3) + self.assertEqual( + self.reg._get_rule("route1").handler_options.method_name, "handler1" + ) + self.assertEqual( + self.reg._get_rule("route2").handler_options.method_name, "handler1" + ) + rule1.options = { + "handler": {"klass_dotted_path": CTRLFake._path, "method_name": "handler2"} + } + rule2.options = { + "handler": {"klass_dotted_path": CTRLFake._path, "method_name": "handler3"} + } + self.reg.update_rules([rule1, rule2]) + self.assertEqual( + self.reg._get_rule("route1").handler_options.method_name, "handler2" + ) + self.assertEqual( + self.reg._get_rule("route2").handler_options.method_name, "handler3" + ) + + @mute_logger("odoo.sql_db") + def test_rule_constraints(self): + rule1, rule2 = self._make_rules(stop=3) + msg = ( + 'duplicate key value violates unique constraint "endpoint_route__key_uniq"' + ) + with self.assertRaisesRegex(DatabaseError, msg), self.env.cr.savepoint(): + self.reg._create({rule1.key: rule1.to_row()}) + msg = ( + "duplicate key value violates unique constraint " + '"endpoint_route__endpoint_hash_uniq"' + ) + with self.assertRaisesRegex(DatabaseError, msg), self.env.cr.savepoint(): + rule2.endpoint_hash = rule1.endpoint_hash + rule2.key = "key3" + self.reg._create({rule2.key: rule2.to_row()}) + + def test_drop_rule(self): + rules = self._make_rules(stop=3) + self.assertEqual(self._count_rules(), 2) + self.reg.drop_rules([x.key for x in rules]) + self.assertEqual(self._count_rules(), 0) + + def test_endpoint_lookup_ko(self): + options = { + "handler": { + "klass_dotted_path": "no.where.to.be.SeenKlass", + "method_name": "foo", + } + } + rule = self._make_rules(stop=2, options=options)[0] + with self.assertRaises(EndpointHandlerNotFound): + rule.endpoint # pylint: disable=pointless-statement + + def test_endpoint_lookup_ok(self): + rule = self._make_rules(stop=2)[0] + self.assertTrue(isinstance(rule.endpoint, http.EndPoint)) + self.assertEqual(rule.endpoint("one"), ("one", 2)) + + def test_endpoint_lookup_ok_args(self): + options = { + "handler": { + "klass_dotted_path": CTRLFake._path, + "method_name": "handler1", + "default_pargs": ("one",), + } + } + rule = self._make_rules(stop=2, options=options)[0] + self.assertTrue(isinstance(rule.endpoint, http.EndPoint)) + self.assertEqual(rule.endpoint(), ("one", 2)) + + def test_get_rule_by_group(self): + self.assertEqual(self._count_rules(), 0) + self._make_rules(stop=4, route_group="one") + self._make_rules(start=5, stop=7, route_group="two") + self.assertEqual(self._count_rules(groups=("one", "two")), 5) + rules = self.reg.get_rules_by_group("one") + self.assertEqual([rule.key for rule in rules], ["route1", "route2", "route3"]) + rules = self.reg.get_rules_by_group("two") + self.assertEqual([rule.key for rule in rules], ["route5", "route6"]) diff --git a/setup/endpoint/odoo/addons/endpoint b/setup/endpoint/odoo/addons/endpoint new file mode 120000 index 0000000000..2cda25b414 --- /dev/null +++ b/setup/endpoint/odoo/addons/endpoint @@ -0,0 +1 @@ +../../../../endpoint \ No newline at end of file diff --git a/setup/endpoint/setup.py b/setup/endpoint/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/endpoint/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/endpoint_route_handler/odoo/addons/endpoint_route_handler b/setup/endpoint_route_handler/odoo/addons/endpoint_route_handler new file mode 120000 index 0000000000..d67d70dd4d --- /dev/null +++ b/setup/endpoint_route_handler/odoo/addons/endpoint_route_handler @@ -0,0 +1 @@ +../../../../endpoint_route_handler \ No newline at end of file diff --git a/setup/endpoint_route_handler/setup.py b/setup/endpoint_route_handler/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/endpoint_route_handler/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)