diff --git a/easy_my_coop_api/models/external_id_mixin.py b/easy_my_coop_api/models/external_id_mixin.py index cd9193e..91e3b9b 100644 --- a/easy_my_coop_api/models/external_id_mixin.py +++ b/easy_my_coop_api/models/external_id_mixin.py @@ -2,6 +2,8 @@ # Robin Keunen # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from psycopg2 import IntegrityError + from odoo import api, fields, models @@ -9,6 +11,14 @@ class ExternalIdMixin(models.AbstractModel): _name = "external.id.mixin" _description = "External ID Mixin" + _sql_constraints = [ + ( + "_api_external_id_uniq", + "unique(_api_external_id)", + "API External ID must be unique!", + ) + ] + # do not access directly, always use get_api_external_id method _api_external_id = fields.Integer( string="External ID", index=True, required=False @@ -28,7 +38,7 @@ class ExternalIdMixin(models.AbstractModel): sequence = Sequence.search([("code", "=", code)]) if not sequence: sequence = Sequence.sudo().create( - {"name": code, "code": code, "number_next": 1} + {"name": code, "code": code, "number_next": 100} ) self.sudo().write({"external_id_sequence_id": sequence.id}) @@ -40,9 +50,21 @@ class ExternalIdMixin(models.AbstractModel): if not self.external_id_sequence_id: self.set_external_sequence() if not self._api_external_id: - self.sudo().write( - {"_api_external_id": self.external_id_sequence_id._next()} - ) + # pass already allocated ids + n = 100 + while True: + try: + self.sudo().write( + { + "_api_external_id": self.external_id_sequence_id._next() + } + ) + break + except IntegrityError as e: + if n > 0: + continue + else: + raise e return self._api_external_id diff --git a/easy_my_coop_connector/README.rst b/easy_my_coop_connector/README.rst new file mode 100644 index 0000000..d13f5ed --- /dev/null +++ b/easy_my_coop_connector/README.rst @@ -0,0 +1,65 @@ +====================== +Easy My Coop Connector +====================== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-coopiteasy%2Fvertical--cooperative-lightgray.png?logo=github + :target: https://github.com/coopiteasy/vertical-cooperative/tree/12.0/easy_my_coop_connector + :alt: coopiteasy/vertical-cooperative + +|badge1| |badge2| |badge3| + + Connect to Easy My Coop RESTful API. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Get an API token from the Eeasy My Copp API you wish to connect to +and setup a backend at +- Settings > Technical (debug mode) > EMC Backend + +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 smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Coop IT Easy SCRLfs + +Contributors +~~~~~~~~~~~~ + +* Coop IT Easy SCRLfs +* Robin Keunen + +Maintainers +~~~~~~~~~~~ + +This module is part of the `coopiteasy/vertical-cooperative `_ project on GitHub. + +You are welcome to contribute. diff --git a/easy_my_coop_connector/__init__.py b/easy_my_coop_connector/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/easy_my_coop_connector/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/easy_my_coop_connector/__manifest__.py b/easy_my_coop_connector/__manifest__.py new file mode 100644 index 0000000..e023e33 --- /dev/null +++ b/easy_my_coop_connector/__manifest__.py @@ -0,0 +1,26 @@ +# Copyright 2020 Coop IT Easy SCRL fs +# Robin Keunen +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +{ + "name": "Easy My Coop Connector", + "version": "12.0.0.0.1", + "depends": ["easy_my_coop"], + "author": "Coop IT Easy SCRLfs", + "category": "Connector", + "website": "www.coopiteasy.be", + "license": "AGPL-3", + "summary": """ + Connect to Easy My Coop RESTful API. + """, + "data": [ + "security/ir.model.access.csv", + "views/emc_backend.xml", + "views/emc_bindings.xml", + "views/actions.xml", + "views/menus.xml", + ], + "demo": ["demo/demo.xml"], + "installable": True, + "application": False, +} diff --git a/easy_my_coop_connector/demo/demo.xml b/easy_my_coop_connector/demo/demo.xml new file mode 100644 index 0000000..cea2a26 --- /dev/null +++ b/easy_my_coop_connector/demo/demo.xml @@ -0,0 +1,24 @@ + + + + + IWP backend + http://localhost:9876 + cbd07f57-c903-43b4-b668-436b3bec5f15 + + + + + + 37 + + + + + + 38 + + diff --git a/easy_my_coop_connector/models/__init__.py b/easy_my_coop_connector/models/__init__.py new file mode 100644 index 0000000..f5c72d9 --- /dev/null +++ b/easy_my_coop_connector/models/__init__.py @@ -0,0 +1,3 @@ +from . import emc_backend +from . import emc_bindings +from . import subscription_request diff --git a/easy_my_coop_connector/models/emc_backend.py b/easy_my_coop_connector/models/emc_backend.py new file mode 100644 index 0000000..2bf1f4d --- /dev/null +++ b/easy_my_coop_connector/models/emc_backend.py @@ -0,0 +1,84 @@ +# Copyright 2020 Coop IT Easy SCRL fs +# Robin Keunen +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import json +import logging + +import requests +from werkzeug.exceptions import NotFound + +from odoo import _, api, fields, models + +_logger = logging.getLogger(__name__) + + +class EMCBackend(models.Model): + _name = "emc.backend" + _description = "EMC Backend" + + name = fields.Char(string="Name", required=True) + location = fields.Char(string="Location") + api_key = fields.Char(string="API Key") + description = fields.Text(string="Description", required=False) + active = fields.Boolean(string="active", default=True) + + @api.multi + def http_get(self, url, params=None, headers=None): + self.ensure_one() + headers = self._add_api_key(headers) + if url.startswith("/"): + url = self.location + url + + return requests.get(url, params=params, headers=headers) + + @api.multi + def http_get_content(self, url, params=None, headers=None): + self.ensure_one() + response = self.http_get(url, params=params, headers=headers) + + if response.status_code == 200: + content = response.content.decode("utf-8") + return json.loads(content) + else: + # todo handle different codes (at least 404, 403, 500) + raise NotFound( + _("request returned status code %s" % response.status_code) + ) + + @api.multi + def http_post(self, url, data, headers=None): + self.ensure_one() + headers = self._add_api_key(headers) + if url.startswith("/"): + url = self.location + url + + return requests.post(url, json=data, headers=headers) + + @api.multi + def _add_api_key(self, headers): + self.ensure_one() + key_dict = {"API-KEY": self.api_key} + if headers: + headers.update(key_dict) + else: + headers = key_dict + return headers + + @api.multi + def action_ping(self): + self.ensure_one() + url = self.location + "/api/ping/test" + try: + response = requests.get(url) + except Exception as e: + _logger.error(e) + raise Warning(_("Failed to connect to backend: %s" % str(e))) + + if response.status_code == 200: + content = json.loads(response.content.decode("utf-8")) + raise Warning(_("Success: %s") % content["message"]) + else: + raise Warning( + _("Failed to connect to backend: %s" % str(response.content)) + ) diff --git a/easy_my_coop_connector/models/emc_bindings.py b/easy_my_coop_connector/models/emc_bindings.py new file mode 100644 index 0000000..c1e9981 --- /dev/null +++ b/easy_my_coop_connector/models/emc_bindings.py @@ -0,0 +1,48 @@ +# Copyright 2020 Coop IT Easy SCRL fs +# Robin Keunen +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + + +class EMCBinding(models.AbstractModel): + _name = "emc.binding" + _description = "EMC Binding (abstract)" + + backend_id = fields.Many2one( + comodel_name="emc.backend", string="EMC Backend", ondelete="restrict" + ) + external_id = fields.Integer(string="ID in Platform", index=True) + # odoo_id = fields.Many2one # implement in concrete class + + @api.model + def search_binding(self, backend, external_id): + return self.search( + [ + ("backend_id", "=", backend.id), + ("external_id", "=", external_id), + ] + ) + + +class SubscriptionRequestBinding(models.Model): + _name = "emc.binding.subscription.request" + _inherit = "emc.binding" + + internal_id = fields.Many2one( + comodel_name="subscription.request", + string="Internal ID", + required=True, + ) + + +class ProductTemplateBinding(models.Model): + _name = "emc.binding.product.template" + _inherit = "emc.binding" + + internal_id = fields.Many2one( + comodel_name="product.template", + string="Internal ID", + domain="[('is_share', '=', True)]", + required=True, + ) diff --git a/easy_my_coop_connector/models/subscription_request.py b/easy_my_coop_connector/models/subscription_request.py new file mode 100644 index 0000000..0d63953 --- /dev/null +++ b/easy_my_coop_connector/models/subscription_request.py @@ -0,0 +1,73 @@ +# Copyright 2020 Coop IT Easy SCRL fs +# Robin Keunen +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + +from .subscription_request_adapter import SubscriptionRequestAdapter + + +class SubscriptionRequest(models.Model): + _inherit = "subscription.request" + + source = fields.Selection(selection_add=[("emc_api", "Easy My Coop API")]) + binding_id = fields.One2many( + comodel_name="emc.binding.subscription.request", + inverse_name="internal_id", + string="Binding ID", + required=False, + ) + + @api.model + def fetch_subscription_requests(self, date_from=None, date_to=None): + SRBinding = self.env["emc.binding.subscription.request"] + + backend = self.env["emc.backend"].search([("active", "=", True)]) + backend.ensure_one() + + adapter = SubscriptionRequestAdapter(backend=backend) + requests_dict = adapter.search(date_from=date_from, date_to=date_to) + for request_dict in requests_dict["rows"]: + external_id = request_dict["id"] + request_values = adapter.to_write_values(request_dict) + sr_binding = SRBinding.search_binding(backend, external_id) + if sr_binding: # update request + sr_binding.internal_id.write(request_values) + else: + srequest = self.env["subscription.request"].create( + request_values + ) + SRBinding.create( + { + "backend_id": backend.id, + "external_id": external_id, + "internal_id": srequest.id, + } + ) + + @api.model + def backend_read(self, external_id): + SRBinding = self.env["emc.binding.subscription.request"] + + backend = self.env["emc.backend"].search([("active", "=", True)]) + backend.ensure_one() + + adapter = SubscriptionRequestAdapter(backend) + sr_data = adapter.read(external_id) + # todo 404 + + request_values = adapter.to_write_values(sr_data) + sr_binding = SRBinding.search_binding(backend, external_id) + + if sr_binding: # update request + srequest = sr_binding.internal_id.write(request_values) + else: + srequest = self.env["subscription.request"].create(request_values) + SRBinding.create( + { + "backend_id": backend.id, + "external_id": external_id, + "internal_id": srequest.id, + } + ) + return srequest diff --git a/easy_my_coop_connector/models/subscription_request_adapter.py b/easy_my_coop_connector/models/subscription_request_adapter.py new file mode 100644 index 0000000..2987f5a --- /dev/null +++ b/easy_my_coop_connector/models/subscription_request_adapter.py @@ -0,0 +1,92 @@ +# Copyright 2020 Coop IT Easy SCRL fs +# Robin Keunen +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from os.path import join + +from odoo import _ +from odoo.exceptions import UserError +from odoo.fields import Date + + +class SubscriptionRequestAdapter: + _model = "subscription.request" + _root = "api" + _service = "subscription-request" + + def __init__(self, backend): + self.backend = backend + + def get_url(self, args): + """args is a list of path elements + :return the complete route to the service + """ + return join("/", self._root, self._service, *args) + + def create(self): + # pylint: disable=method-required-super + raise NotImplementedError + + def search(self, date_from=None, date_to=None): + url = self.get_url([]) + params = {} + if date_from: + params.update({"date_from": Date.to_string(date_from)}) + if date_to: + params.update({"date_to": Date.to_string(date_to)}) + + sr_list = self.backend.http_get_content(url, params=params) + return sr_list + + def read(self, id_): + # pylint: disable=method-required-super + url = self.get_url([str(id_)]) + sr = self.backend.http_get_content(url) + return sr + + def update(self): + raise NotImplementedError + + def delete(self): + raise NotImplementedError + + def to_write_values(self, request): + """ + :return a writable dictionary of values from the dictionary + received from the api + """ + address = request["address"] + + Country = self.backend.env["res.country"] + country = Country.search([("code", "=", address["country"])]) + + ProductTemplateBinding = self.backend.env[ + "emc.binding.product.template" + ] + external_product_id = request["share_product"]["id"] + share_product_binding = ProductTemplateBinding.search_binding( + self.backend, external_product_id + ) + if not share_product_binding: + raise UserError( + _( + "No binding exists for share product %s. Please contact " + "system administrator " + ) + % request["share_product"]["name"] + ) + + return { + "email": request["email"], + "name": request["name"], + "date": request["date"], + "state": request["state"], + "lang": request["lang"], + "ordered_parts": request["ordered_parts"], + "address": address["street"], + "zip_code": address["zip_code"], + "city": address["city"], + "country_id": country.id, + "share_product_id": share_product_binding.internal_id.id, + "source": "emc_api", + } diff --git a/easy_my_coop_connector/readme/CONTRIBUTORS.rst b/easy_my_coop_connector/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000..f8672e6 --- /dev/null +++ b/easy_my_coop_connector/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Coop IT Easy SCRLfs +* Robin Keunen diff --git a/easy_my_coop_connector/readme/DESCRIPTION.rst b/easy_my_coop_connector/readme/DESCRIPTION.rst new file mode 100644 index 0000000..f462c6d --- /dev/null +++ b/easy_my_coop_connector/readme/DESCRIPTION.rst @@ -0,0 +1 @@ + Connect to Easy My Coop RESTful API. diff --git a/easy_my_coop_connector/readme/ROADMAP.rst b/easy_my_coop_connector/readme/ROADMAP.rst new file mode 100644 index 0000000..e69de29 diff --git a/easy_my_coop_connector/readme/USAGE.rst b/easy_my_coop_connector/readme/USAGE.rst new file mode 100644 index 0000000..82cc370 --- /dev/null +++ b/easy_my_coop_connector/readme/USAGE.rst @@ -0,0 +1,22 @@ +## Configuration + +Get an API token from the Easy My Coop API you wish to connect to +and setup a backend at +- Settings > Technical (debug mode) > EMC Backend + +## Concepts + +The models are heavily influenced by the odoo connector: + +- emc_backend + - connects to the API, sends HTTP requests, converts json to dictionaries +- _adapter + - uses the backend to send query for model it's responsible for. + - adapts the objects to api format dictionaries + - adapts the result dictionaries to writable dictionaries +- _binding + - links an internal record with an external record for each backend + +The is responsible for the orchestration of these components. + +In the current implementation, only one backend is allowed. diff --git a/easy_my_coop_connector/security/ir.model.access.csv b/easy_my_coop_connector/security/ir.model.access.csv new file mode 100644 index 0000000..5dc68a2 --- /dev/null +++ b/easy_my_coop_connector/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_emc_backend_administrator,access_emc_backend_administrator,model_emc_backend,base.group_system,1,1,1,1 +access_emc_binding_subscription_request_administrator,access_emc_binding_subscription_request_administrator,model_emc_binding_subscription_request,base.group_system,1,1,1,1 +access_emc_binding_product_template_administrator,access_emc_binding_product_template_administrator,model_emc_binding_product_template,base.group_system,1,1,1,1 diff --git a/easy_my_coop_connector/static/description/index.html b/easy_my_coop_connector/static/description/index.html new file mode 100644 index 0000000..3f77927 --- /dev/null +++ b/easy_my_coop_connector/static/description/index.html @@ -0,0 +1,423 @@ + + + + + + +Easy My Coop Connector + + + +
+

Easy My Coop Connector

+ + +

Beta License: AGPL-3 coopiteasy/vertical-cooperative

+
+Connect to Easy My Coop RESTful API.
+

Table of contents

+ +
+

Usage

+

Get an API token from the Eeasy My Copp API you wish to connect to +and setup a backend at +- Settings > Technical (debug mode) > EMC Backend

+
+
+

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 smashing it by providing a detailed and welcomed +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Coop IT Easy SCRLfs
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is part of the coopiteasy/vertical-cooperative project on GitHub.

+

You are welcome to contribute.

+
+
+
+ + diff --git a/easy_my_coop_connector/tests/__init__.py b/easy_my_coop_connector/tests/__init__.py new file mode 100644 index 0000000..4346b2f --- /dev/null +++ b/easy_my_coop_connector/tests/__init__.py @@ -0,0 +1 @@ +from . import test_subscription_request diff --git a/easy_my_coop_connector/tests/test_subscription_request.py b/easy_my_coop_connector/tests/test_subscription_request.py new file mode 100644 index 0000000..c61a196 --- /dev/null +++ b/easy_my_coop_connector/tests/test_subscription_request.py @@ -0,0 +1,101 @@ +# Copyright 2020 Coop IT Easy SCRL fs +# Robin Keunen +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + + +import datetime +import json +from unittest.mock import Mock, patch + +import requests + +from odoo.tests.common import TransactionCase + +NOT_FOUND_ERROR = {"name": "Not Found", "code": 404} +FORBIDDEN_ERROR = {"name": "Forbidden", "code": 403} +SERVER_ERROR = {"name": "Server Error", "code": 500} +NO_RESULT = {"count": 0, "rows": []} +SEARCH_RESULT = { + "count": 1, + "rows": [ + { + "id": 1, + "date": "2020-05-14", + "email": "manuel@demo.net", + "address": { + "city": "Brussels", + "street": "schaerbeekstraat", + "zip_code": "1111", + "country": "BE", + }, + "lang": "en_US", + "ordered_parts": 3, + "name": "Manuel Dublues", + "share_product": {"name": "Part B - Worker", "id": 38}, + "state": "draft", + } + ], +} +GET_RESULT = { + "id": 1, + "name": "Robin Des Bois", + "date": "2020-05-14", + "email": "manuel@demo.net", + "address": { + "city": "Brussels", + "street": "schaerbeekstraat", + "zip_code": "1111", + "country": "BE", + }, + "lang": "en_US", + "ordered_parts": 3, + "share_product": {"name": "Part B - Worker", "id": 38}, + "state": "draft", +} + + +def dict_to_dump(content): + return json.dumps(content).encode("utf-8") + + +class TestCase(TransactionCase): + def setUp(self): + super().setUp() + self.backend = self.browse_ref( + "easy_my_coop_connector.emc_backend_demo" + ) + + def test_search_requests(self): + SubscriptionRequest = self.env["subscription.request"] + SRBinding = self.env["emc.binding.subscription.request"] + + date_to = datetime.date.today() + date_from = date_to - datetime.timedelta(days=1) + + with patch.object(requests, "get") as mock_get: + mock_get.return_value = mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = dict_to_dump(SEARCH_RESULT) + + SubscriptionRequest.fetch_subscription_requests( + date_from=date_from, date_to=date_to + ) + + external_id = 1 + binding = SRBinding.search_binding(self.backend, external_id) + self.assertTrue( + bool(binding), + "no binding created when searching subscription requests", + ) + + srequest = binding.internal_id + self.assertEquals(srequest.name, "Manuel Dublues") + + with patch.object(requests, "get") as mock_get: + mock_get.return_value = mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = dict_to_dump(GET_RESULT) + + SubscriptionRequest.backend_read(external_id) + + self.assertEquals(srequest.name, "Robin Des Bois") diff --git a/easy_my_coop_connector/views/actions.xml b/easy_my_coop_connector/views/actions.xml new file mode 100644 index 0000000..97e2390 --- /dev/null +++ b/easy_my_coop_connector/views/actions.xml @@ -0,0 +1,25 @@ + + + + + + EMC Backend + emc.backend + tree,form + + + + Subscription Request Bindings + emc.binding.subscription.request + tree,form + + + + Share Product Bindings + emc.binding.product.template + tree,form + + diff --git a/easy_my_coop_connector/views/emc_backend.xml b/easy_my_coop_connector/views/emc_backend.xml new file mode 100644 index 0000000..27e52d8 --- /dev/null +++ b/easy_my_coop_connector/views/emc_backend.xml @@ -0,0 +1,47 @@ + + + + + emc_backend_view_form + emc.backend + +
+ +
+ +
+ + + + + +
+
+
+
+
+
+ + + emc_backend_view_tree + emc.backend + + + + + + + + +
diff --git a/easy_my_coop_connector/views/emc_bindings.xml b/easy_my_coop_connector/views/emc_bindings.xml new file mode 100644 index 0000000..7d92357 --- /dev/null +++ b/easy_my_coop_connector/views/emc_bindings.xml @@ -0,0 +1,62 @@ + + + + + emc_binding_subscription_request_view_form + emc.binding.subscription.request + +
+ + + + + + + +
+
+
+ + + emc_binding_subscription_request_view_tree + emc.binding.subscription.request + + + + + + + + + + + emc_binding_product_template_view_form + emc.binding.product.template + +
+ + + + + + + +
+
+
+ + + emc_binding_product_template_view_tree + emc.binding.product.template + + + + + + + + +
diff --git a/easy_my_coop_connector/views/menus.xml b/easy_my_coop_connector/views/menus.xml new file mode 100644 index 0000000..2c15069 --- /dev/null +++ b/easy_my_coop_connector/views/menus.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + +