From 73df734e2fe634e8ac7ae0388a4ed6a9ab554593 Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Wed, 20 Jan 2021 10:43:06 +0100 Subject: [PATCH 001/167] Update oca_dependencies.txt (queue and connector) --- oca_dependencies.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/oca_dependencies.txt b/oca_dependencies.txt index af3dfd41a6..3b0dcf76b9 100644 --- a/oca_dependencies.txt +++ b/oca_dependencies.txt @@ -3,3 +3,4 @@ reporting-engine queue community-data-files l10n-spain +connector From b8751faaf9b3479b829f2f9f8021a8fe16c50757 Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Fri, 23 Oct 2020 13:02:25 +0200 Subject: [PATCH 002/167] [ADD] connector_pms: new module --- connector_pms/README.rst | 83 +++ connector_pms/__init__.py | 6 + connector_pms/__manifest__.py | 32 + connector_pms/components/__init__.py | 11 + connector_pms/components/adapter.py | 149 +++++ connector_pms/components/binder.py | 8 + connector_pms/components/core.py | 11 + connector_pms/components/deleter.py | 9 + connector_pms/components/exporter.py | 69 +++ connector_pms/components/importer.py | 83 +++ connector_pms/components/mapper_export.py | 14 + connector_pms/components/mapper_import.py | 30 + connector_pms/components_custom/__init__.py | 7 + connector_pms/components_custom/binder.py | 235 ++++++++ connector_pms/components_custom/exporter.py | 317 ++++++++++ connector_pms/components_custom/importer.py | 244 ++++++++ connector_pms/components_custom/mapper.py | 127 ++++ connector_pms/data/queue_data.xml | 11 + connector_pms/models/__init__.py | 5 + connector_pms/models/common/__init__.py | 6 + connector_pms/models/common/backend.py | 66 +++ connector_pms/models/common/backend_type.py | 57 ++ connector_pms/models/common/binding.py | 110 ++++ connector_pms/models/pms_reservation.py | 17 + connector_pms/readme/CONTRIBUTORS.rst | 1 + connector_pms/readme/DESCRIPTION.rst | 6 + connector_pms/readme/USAGE.rst | 1 + connector_pms/security/ir.model.access.csv | 3 + connector_pms/static/description/icon.png | Bin 0 -> 9455 bytes connector_pms/static/description/index.html | 558 ++++++++++++++++++ .../views/channel_backend_type_views.xml | 70 +++ connector_pms/views/channel_backend_views.xml | 75 +++ connector_pms/views/channel_menus.xml | 12 + .../pms_availability_plan_rule_views.xml | 21 + .../views/pms_availability_plan_views.xml | 25 + .../views/pms_board_service_views.xml | 23 + connector_pms/views/pms_folio_views.xml | 21 + connector_pms/views/pms_reservation_views.xml | 18 + .../views/pms_room_type_class_views.xml | 28 + connector_pms/views/pms_room_type_views.xml | 21 + .../views/product_pricelist_views.xml | 21 + setup/connector_pms/odoo/addons/connector_pms | 1 + setup/connector_pms/setup.py | 6 + 43 files changed, 2618 insertions(+) create mode 100644 connector_pms/README.rst create mode 100644 connector_pms/__init__.py create mode 100644 connector_pms/__manifest__.py create mode 100644 connector_pms/components/__init__.py create mode 100644 connector_pms/components/adapter.py create mode 100644 connector_pms/components/binder.py create mode 100644 connector_pms/components/core.py create mode 100644 connector_pms/components/deleter.py create mode 100644 connector_pms/components/exporter.py create mode 100644 connector_pms/components/importer.py create mode 100644 connector_pms/components/mapper_export.py create mode 100644 connector_pms/components/mapper_import.py create mode 100644 connector_pms/components_custom/__init__.py create mode 100644 connector_pms/components_custom/binder.py create mode 100644 connector_pms/components_custom/exporter.py create mode 100644 connector_pms/components_custom/importer.py create mode 100644 connector_pms/components_custom/mapper.py create mode 100644 connector_pms/data/queue_data.xml create mode 100644 connector_pms/models/__init__.py create mode 100644 connector_pms/models/common/__init__.py create mode 100644 connector_pms/models/common/backend.py create mode 100644 connector_pms/models/common/backend_type.py create mode 100644 connector_pms/models/common/binding.py create mode 100644 connector_pms/models/pms_reservation.py create mode 100644 connector_pms/readme/CONTRIBUTORS.rst create mode 100644 connector_pms/readme/DESCRIPTION.rst create mode 100644 connector_pms/readme/USAGE.rst create mode 100644 connector_pms/security/ir.model.access.csv create mode 100644 connector_pms/static/description/icon.png create mode 100644 connector_pms/static/description/index.html create mode 100644 connector_pms/views/channel_backend_type_views.xml create mode 100644 connector_pms/views/channel_backend_views.xml create mode 100644 connector_pms/views/channel_menus.xml create mode 100644 connector_pms/views/pms_availability_plan_rule_views.xml create mode 100644 connector_pms/views/pms_availability_plan_views.xml create mode 100644 connector_pms/views/pms_board_service_views.xml create mode 100644 connector_pms/views/pms_folio_views.xml create mode 100644 connector_pms/views/pms_reservation_views.xml create mode 100644 connector_pms/views/pms_room_type_class_views.xml create mode 100644 connector_pms/views/pms_room_type_views.xml create mode 100644 connector_pms/views/product_pricelist_views.xml create mode 120000 setup/connector_pms/odoo/addons/connector_pms create mode 100644 setup/connector_pms/setup.py diff --git a/connector_pms/README.rst b/connector_pms/README.rst new file mode 100644 index 0000000000..3670a00fd0 --- /dev/null +++ b/connector_pms/README.rst @@ -0,0 +1,83 @@ +============= +PMS 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-OCA%2Fpms-lightgray.png?logo=github + :target: https://github.com/OCA/pms/tree/14.0/connector_pms + :alt: OCA/pms +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/pms-14-0/pms-14-0-connector_pms + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/293/14.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Base module for implement channel connectors + +Features: + + * Avaliability Management + * Odoo Connector + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +No configuration required. This is a 'tool' module, need be used with other modules. + +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 +~~~~~~~ + +* Eric Antones + +Contributors +~~~~~~~~~~~~ + +* Eric Antones + +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. + +This module is part of the `OCA/pms `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/connector_pms/__init__.py b/connector_pms/__init__.py new file mode 100644 index 0000000000..f41943ed22 --- /dev/null +++ b/connector_pms/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import components_custom +from . import components +from . import models diff --git a/connector_pms/__manifest__.py b/connector_pms/__manifest__.py new file mode 100644 index 0000000000..6ac174e6fe --- /dev/null +++ b/connector_pms/__manifest__.py @@ -0,0 +1,32 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "PMS Connector", + "summary": "Channel PMS connector Base", + "version": "14.0.1.0.0", + "license": "AGPL-3", + "development_status": "Alpha", + "category": "Connector", + "website": "https://github.com/OCA/pms", + "author": "Eric Antones ,Odoo Community Association (OCA)", + "depends": [ + "connector", + "pms", + ], + "data": [ + "data/queue_data.xml", + "security/ir.model.access.csv", + "views/channel_menus.xml", + "views/channel_backend_views.xml", + "views/channel_backend_type_views.xml", + "views/pms_room_type_views.xml", + "views/pms_room_type_class_views.xml", + "views/pms_board_service_views.xml", + "views/pms_folio_views.xml", + "views/pms_reservation_views.xml", + "views/product_pricelist_views.xml", + "views/pms_availability_plan_views.xml", + "views/pms_availability_plan_rule_views.xml", + ], +} diff --git a/connector_pms/components/__init__.py b/connector_pms/components/__init__.py new file mode 100644 index 0000000000..8d82aaa150 --- /dev/null +++ b/connector_pms/components/__init__.py @@ -0,0 +1,11 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import core +from . import adapter +from . import binder +from . import deleter +from . import exporter +from . import importer +from . import mapper_export +from . import mapper_import diff --git a/connector_pms/components/adapter.py b/connector_pms/components/adapter.py new file mode 100644 index 0000000000..102022a1bc --- /dev/null +++ b/connector_pms/components/adapter.py @@ -0,0 +1,149 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _ +from odoo.exceptions import ValidationError + +from odoo.addons.component.core import AbstractComponent + + +class ChannelAdapter(AbstractComponent): + _name = "channel.adapter" + _inherit = "base.backend.adapter.crud" + + def chunks(self, l, n): + """Yield successive n-sized chunks from lst.""" + for i in range(0, len(l), n): + yield l[i : i + n] + + def _filter(self, values, domain=None): + # TODO support for domains with 'or' clauses + # TODO refactor and optimize + if not domain: + return values + + values_filtered = [] + for record in values: + for elem in domain: + k, op, v = elem + if k not in record: + raise ValidationError(_("Key %s does not exist") % k) + if op == "=": + if record[k] != v: + break + elif op == "!=": + if record[k] == v: + break + elif op == ">": + if record[k] <= v: + break + elif op == "<": + if record[k] >= v: + break + elif op == ">=": + if record[k] < v: + break + elif op == "<=": + if record[k] > v: + break + elif op == "in": + if not isinstance(v, (tuple, list)): + raise ValidationError( + _("The value %s should be a list or tuple") % v + ) + if record[k] not in v: + break + elif op == "not in": + if not isinstance(v, (tuple, list)): + raise ValidationError( + _("The value %s should be a list or tuple") % v + ) + if record[k] in v: + break + else: + raise NotImplementedError("Operator '%s' not supported" % op) + else: + values_filtered.append(record) + + return values_filtered + + def _extract_domain_clauses(self, domain, fields): + if not isinstance(fields, (tuple, list)): + fields = [fields] + extracted, rest = [], [] + for clause in domain: + tgt = extracted if clause[0] in fields else rest + tgt.append(clause) + return extracted, rest + + def _convert_format(self, elem, mapper, path=""): + if isinstance(elem, dict): + for k, v in elem.items(): + current_path = "{}/{}".format(path, k) + # if current_path == '/boards': + # a=1 + if v == "": + elem[k] = None + continue + if isinstance(v, (tuple, list, dict)): + if isinstance(v, dict): + if current_path in mapper: + v2 = {} + for k1, v1 in v.items(): + new_value = mapper[current_path](k1) + v2[new_value] = v1 + v = elem[k] = v2 + self._convert_format(v, mapper, current_path) + elif isinstance(v, (str, int, float, bool)): + if current_path in mapper: + elem[k] = mapper[current_path](v) + else: + raise NotImplementedError("Type %s not implemented" % type(v)) + elif isinstance(elem, (tuple, list)): + for ch in elem: + self._convert_format(ch, mapper, path) + elif isinstance(elem, (str, int, float, bool)): + pass + else: + raise NotImplementedError("Type %s not implemented" % type(elem)) + + # def _convert_format(self, elem, mapper, path="", remove_string=''): + # if isinstance(elem, dict): + # keys_to_remove = [] + # for k, v in elem.items(): + # current_path = "{}/{}".format(path, k) + # if isinstance(v, (tuple, list, dict)): + # if isinstance(v, dict): + # if current_path in mapper: + # v2 = {} + # for k1, v1 in v.items(): + # new_value = mapper[current_path](k1) + # if new_value != remove_string: + # v2[new_value] = v1 + # v = elem[k] = v2 + # self._convert_format(v, mapper, current_path) + # elif isinstance(v, (str, int, float, bool)): + # if current_path in mapper: + # new_value = mapper[current_path](v) + # #new_value = mapper[current_path](elem[k]) + # if new_value == remove_string: + # keys_to_remove.append(k) + # else: + # elem[k] = new_value + # else: + # raise NotImplementedError("Type %s not implemented" % type(v)) + # for k in keys_to_remove: + # del elem[k] + # elif isinstance(elem, (tuple, list)): + # for ch in elem: + # self._convert_format(ch, mapper, path) + # elif isinstance(elem, (str, int, float, bool)): + # pass + # else: + # raise NotImplementedError("Type %s not implemented" % type(elem)) + + +class ChannelAdapterError(Exception): + def __init__(self, message, data=None): + super().__init__(message) + self.data = data or {} diff --git a/connector_pms/components/binder.py b/connector_pms/components/binder.py new file mode 100644 index 0000000000..af96f53f76 --- /dev/null +++ b/connector_pms/components/binder.py @@ -0,0 +1,8 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo.addons.component.core import AbstractComponent + + +class ChannelBinder(AbstractComponent): + _name = "channel.binder" + _inherit = "base.binder.custom" diff --git a/connector_pms/components/core.py b/connector_pms/components/core.py new file mode 100644 index 0000000000..d49ef66e1d --- /dev/null +++ b/connector_pms/components/core.py @@ -0,0 +1,11 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import AbstractComponent + + +class BaseChannelConnector(AbstractComponent): + _name = "base.channel.connector" + _inherit = "base.connector" + + _description = "Base Channel Connector Component" diff --git a/connector_pms/components/deleter.py b/connector_pms/components/deleter.py new file mode 100644 index 0000000000..3e9fb97703 --- /dev/null +++ b/connector_pms/components/deleter.py @@ -0,0 +1,9 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import AbstractComponent + + +class ChannelDeleter(AbstractComponent): + _name = "channel.deleter" + _inherit = ["base.deleter"] diff --git a/connector_pms/components/exporter.py b/connector_pms/components/exporter.py new file mode 100644 index 0000000000..c8bd312304 --- /dev/null +++ b/connector_pms/components/exporter.py @@ -0,0 +1,69 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from odoo.addons.component.core import AbstractComponent + +_logger = logging.getLogger(__name__) + + +class ChannelExporter(AbstractComponent): + """ Base exporter for Channel """ + + _name = "channel.exporter" + _inherit = "generic.exporter.custom" + + _usage = "direct.record.exporter" + + +class ChannelBatchExporter(AbstractComponent): + """The role of a BatchExporter is to search for a list of + items to import, then it can either import them directly or delay + the import of each item separately. + """ + + _name = "channel.batch.exporter" + _inherit = "base.exporter" + + def run(self, domain=None): + """ Run the batch synchronization """ + if not domain: + domain = [] + relation_model = self.binder_for(self.model._name).unwrap_model() + for relation in self.env[relation_model].search(domain): + self._export_record(relation) + + def _export_record(self, external_id): + """Export a record directly or delay the export of the record. + + Method to implement in sub-classes. + """ + raise NotImplementedError + + +class ChannelDirectBatchExporter(AbstractComponent): + """ Import the records directly, without delaying the jobs. """ + + _name = "channel.direct.batch.exporter" + _inherit = "channel.batch.exporter" + + _usage = "direct.batch.exporter" + + def _export_record(self, relation): + """ export the record directly """ + self.model.export_record(self.backend_record, relation) + + +class ChannelDelayedBatchExporter(AbstractComponent): + """ Delay import of the records """ + + _name = "channel.delayed.batch.exporter" + _inherit = "channel.batch.exporter" + + _usage = "delayed.batch.exporter" + + def _export_record(self, relation, job_options=None): + """ Delay the export of the records""" + delayable = self.model.with_delay(**job_options or {}) + delayable.export_record(self.backend_record, relation) diff --git a/connector_pms/components/importer.py b/connector_pms/components/importer.py new file mode 100644 index 0000000000..89f5cf6c56 --- /dev/null +++ b/connector_pms/components/importer.py @@ -0,0 +1,83 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from odoo.addons.component.core import AbstractComponent + +_logger = logging.getLogger(__name__) + + +class ChannelImporter(AbstractComponent): + """ Base importer for Channel """ + + _name = "channel.importer" + _inherit = "generic.importer.custom" + + _usage = "direct.record.importer" + + +class ChannelBatchImporter(AbstractComponent): + """The role of a BatchImporter is to search for a list of + items to import, then it can either import them directly or delay + the import of each item separately. + """ + + _name = "channel.batch.importer" + _inherit = "base.importer" + + # def run(self, domain=[]): + # """ Run the synchronization """ + # record_ids = self.backend_adapter.search(domain) + # for record_id in record_ids: + # self._import_record(record_id) + + def run(self, domain=None): + """ Run the synchronization """ + if domain is None: + domain = [] + records = self.backend_adapter.search_read(domain) + for rec in records: + self._import_record(rec[self.backend_adapter._id], external_data=rec) + + def _import_record(self, external_id, external_data=None): + """Import a record directly or delay the import of the record. + + Method to implement in sub-classes. + """ + raise NotImplementedError + + +class ChannelDirectBatchImporter(AbstractComponent): + """ Import the records directly, without delaying the jobs. """ + + _name = "channel.direct.batch.importer" + _inherit = "channel.batch.importer" + + _usage = "direct.batch.importer" + + def _import_record(self, external_id, external_data=None): + """ Import the record directly """ + if external_data is None: + external_data = {} + self.model.import_record( + self.backend_record, external_id, external_data=external_data + ) + + +class ChannelDelayedBatchImporter(AbstractComponent): + """ Delay import of the records """ + + _name = "channel.delayed.batch.importer" + _inherit = "channel.batch.importer" + + _usage = "delayed.batch.importer" + + def _import_record(self, external_id, external_data=None, job_options=None): + """ Delay the import of the records""" + if external_data is None: + external_data = {} + delayable = self.model.with_delay(**job_options or {}) + delayable.import_record( + self.backend_record, external_id, external_data=external_data + ) diff --git a/connector_pms/components/mapper_export.py b/connector_pms/components/mapper_export.py new file mode 100644 index 0000000000..39f63707be --- /dev/null +++ b/connector_pms/components/mapper_export.py @@ -0,0 +1,14 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import AbstractComponent + + +class ChannelMapperExport(AbstractComponent): + _name = "channel.mapper.export" + _inherit = ["base.export.mapper"] + + +class ChannelChildMapperExport(AbstractComponent): + _name = "channel.child.mapper.export" + _inherit = "base.map.child.export" diff --git a/connector_pms/components/mapper_import.py b/connector_pms/components/mapper_import.py new file mode 100644 index 0000000000..c9a436a47e --- /dev/null +++ b/connector_pms/components/mapper_import.py @@ -0,0 +1,30 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import AbstractComponent + + +class ChannelMapperImport(AbstractComponent): + _name = "channel.mapper.import" + _inherit = "base.import.mapper" + + +class ChannelChildMapperImport(AbstractComponent): + _name = "channel.child.mapper.import" + _inherit = "base.map.child.import" + + def get_all_items(self, mapper, items, parent, to_attr, options): + mapped = [] + for item in items: + map_record = mapper.map_record(item, parent=parent) + if self.skip_item(map_record): + continue + item_values = self.get_item_values(map_record, to_attr, options) + if item_values: + mapped.append(item_values) + return mapped + + def get_items(self, items, parent, to_attr, options): + mapper = self._child_mapper() + mapped = self.get_all_items(mapper, items, parent, to_attr, options) + return self.format_items(mapped) diff --git a/connector_pms/components_custom/__init__.py b/connector_pms/components_custom/__init__.py new file mode 100644 index 0000000000..172324a818 --- /dev/null +++ b/connector_pms/components_custom/__init__.py @@ -0,0 +1,7 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import binder +from . import importer +from . import exporter +from . import mapper diff --git a/connector_pms/components_custom/binder.py b/connector_pms/components_custom/binder.py new file mode 100644 index 0000000000..9b14b1a1fc --- /dev/null +++ b/connector_pms/components_custom/binder.py @@ -0,0 +1,235 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging + +from odoo import _, models +from odoo.exceptions import ValidationError + +from odoo.addons.component.core import AbstractComponent +from odoo.addons.connector.exception import InvalidDataError + +_logger = logging.getLogger(__name__) + + +class BinderCustom(AbstractComponent): + _name = "base.binder.custom" + _inherit = "base.binder" + + _internal_alt_id_field = "_internal_alt_id" + _external_alt_id_field = "_external_alt_id" + + def wrap_record(self, relation, force=False): + """Give the real record + + :param relation: Odoo real record for which we want to get its binding + :param force: if this is True and not binding found it creates an + empty binding + :return: binding corresponding to the real record or + empty recordset if the record has no binding + """ + if isinstance(relation, models.BaseModel): + relation.ensure_one() + else: + if not isinstance(relation, int): + raise InvalidDataError( + "The real record (relation) must be a " + "regular Odoo record or an id (integer)" + ) + relation = self.model.browse(relation) + if not relation: + raise InvalidDataError("The real record (relation) does not exist") + + if self.model._name == relation._name: + raise Exception( + _( + "The object '%s' is already wrapped, it's already a normal Odoo object. You can only unwrap " + "binding " + "objects" + ) + % (relation) + ) + + binding = self.model.with_context(active_test=False).search( + [ + (self._odoo_field, "=", relation.id), + (self._backend_field, "=", self.backend_record.id), + ] + ) + if not binding: + if force: + binding = self.model.create( + { + self._odoo_field: relation.id, + self._backend_field: self.backend_record.id, + } + ) + else: + binding = self.model + + if len(binding) > 1: + raise InvalidDataError("More than one binding found") + + return binding + + def _check_domain(self, domain): + for field, _, value in domain: + if isinstance(value, (list, tuple)): + for e in value: + if isinstance(e, (tuple, list, set, dict)): + raise ValidationError( + _( + "Wrong domain value type '%s' on value '%s' of field '%s'" + ) + % (type(e), e, field) + ) + + def _get_internal_record_domain(self, values): + return [(k, "=", v) for k, v in values.items()] + + def _get_internal_record_alt(self, model_name, values): + domain = self._get_internal_record_domain(values) + self._check_domain(domain) + return self.env[model_name].search(domain) + + def to_binding_from_external_key(self, mapper): + """ + :param mapper: + :return: binding with alternate external key + """ + internal_alt_id = getattr(self, self._internal_alt_id_field, None) + if internal_alt_id: + if isinstance(internal_alt_id, str): + internal_alt_id = [internal_alt_id] + all_values = mapper.values(for_create=True) + if any([x not in all_values for x in internal_alt_id]): + raise InvalidDataError( + "The alternative id (_internal_alt_id) '%s' must exist on mapper" + % internal_alt_id + ) + model_name = self.unwrap_model() + id_values = {x: all_values[x] for x in internal_alt_id} + record = self._get_internal_record_alt(model_name, id_values) + if len(record) > 1: + raise InvalidDataError( + "More than one internal records found. " + "The alternate internal id field '%s' is not unique" + % (internal_alt_id,) + ) + if record: + binding = self.wrap_record(record) + if not binding: + values = { + k: all_values[k] + for k in set(self.model._model_fields) & set(all_values) + } + if self._odoo_field in values: + if values[self._odoo_field] != record.id: + raise InvalidDataError( + "The id found on the mapper ('%i') " + "is not the one expected ('%i')" + % (values[self._odoo_field], record.id) + ) + else: + values[self._odoo_field] = record.id + binding = self.model.create(values) + _logger.debug("%d linked from Backend", binding) + return binding + + return self.model + + def _get_external_record_domain(self, values): + return [(k, "=", v) for k, v in values.items()] + + def _get_external_record_alt(self, values): + domain = self._get_external_record_domain(values) + adapter = self.component(usage="backend.adapter") + return adapter.search_read(domain) + + def to_binding_from_internal_key(self, relation): + """ + Given an odoo object (not binding object) without binding related + :param relation: odoo object, not a binding and without binding + :return: binding + """ + ext_alt_id = getattr(self, self._external_alt_id_field, None) + if not ext_alt_id: + return self.model + + if isinstance(ext_alt_id, str): + ext_alt_id = [ext_alt_id] + int_alt_id = getattr(self, self._internal_alt_id_field, None) + if not int_alt_id: + raise InvalidDataError( + "The alternative id (_external_alt_id) is not defined on binder" + ) + if isinstance(int_alt_id, str): + int_alt_id = [int_alt_id] + + export_mapper = self.component(usage="export.mapper") + mapper_external_data = export_mapper.map_record(relation) + id_fields = mapper_external_data._mapper.get_target_fields( + mapper_external_data, fields=ext_alt_id + ) + if not id_fields: + raise ValidationError( + _("External alternative id '%s' not found in export mapper") + % (ext_alt_id,) + ) + id_values = mapper_external_data.values(for_create=True, fields=id_fields) + record = self._get_external_record_alt(id_values) + if record: + if len(record) > 1: + raise InvalidDataError( + "More than one external records found. " + "The alternate external id field '%s' is not " + "unique in the backend" % (ext_alt_id,) + ) + record = record[0] + + adapter = self.component(usage="backend.adapter") + external_id = record[adapter._id] + + binding = self.wrap_record(relation) + if binding: + current_external_id = self.to_external(binding) + if not current_external_id: + self.bind(external_id, binding) + else: + if current_external_id != external_id: + raise InvalidDataError( + "Integrity error: The current external_id '%s' " + "should be the same as the one we are trying " + "to assign '%s'" % (current_external_id, external_id) + ) + _logger.debug("%d already binded to Backend", binding) + # return binding + else: + import_mapper = self.component(usage="import.mapper") + mapper_internal_data = import_mapper.map_record(record) + + binding_ext_fields = mapper_internal_data._mapper.get_target_fields( + mapper_internal_data, fields=self.model._model_fields + ) + importer = self.component(usage="direct.record.importer") + importer.run( + external_id, + external_data=record, + external_fields=binding_ext_fields, + ) + binding = self.to_internal(external_id) + + if not binding: + raise InvalidDataError( + "The binding with external id '%s' " + "not found and it should be" % external_id + ) + _logger.debug("%d linked to Backend", binding) + return binding + + return self.model + + +# TODO: naming the methods more intuitively +# TODO: unify both methods, they have a lot of common code +# TODO: extract parts to smaller and common methods reused by the main methods +# TODO: use .new instead of dicts on to_binding_from_internal_key diff --git a/connector_pms/components_custom/exporter.py b/connector_pms/components_custom/exporter.py new file mode 100644 index 0000000000..765c774df8 --- /dev/null +++ b/connector_pms/components_custom/exporter.py @@ -0,0 +1,317 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging +from contextlib import contextmanager + +import psycopg2 + +from odoo import _ + +from odoo.addons.component.core import AbstractComponent +from odoo.addons.connector.exception import IDMissingInBackend, RetryableJobError + +_logger = logging.getLogger(__name__) + + +class GenericExporterCustom(AbstractComponent): + """ Generic Synchronizer for exporting data from Odoo to a backend """ + + _name = "generic.exporter.custom" + _inherit = "base.exporter" + + _default_binding_field = None + + def __init__(self, working_context): + super(GenericExporterCustom, self).__init__(working_context) + self.binding = None + self.external_id = None + + def _should_import(self): + return False + + def _delay_import(self): + """Schedule an import of the record. + + Adapt in the sub-classes when the model is not imported + using ``import_record``. + """ + # force is True because the sync_date will be more recent + # so the import would be skipped + assert self.external_id + self.binding.with_delay().import_record( + self.backend_record, self.external_id, force=True + ) + + def _mapper_options(self): + return {"binding": self.binding} + + def run(self, relation, *args, **kwargs): + """Run the synchronization + + :param binding: binding record to export + """ + # get binding from real record + self.binding = self.binder.wrap_record(relation) + + # if not binding, try to link to existing external record with + # the same alternate key and create/update binding + if not self.binding or not self.binding.external_id: + self.binding = ( + self.binder.to_binding_from_internal_key(relation) or self.binding + ) + + # if still not binding, create an empty one + if not self.binding: + self.binding = self.binder.wrap_record(relation, force=True) + + self.external_id = self.binder.to_external(self.binding) + + try: + should_import = self._should_import() + except IDMissingInBackend: + self.external_id = None + should_import = False + if should_import: + self._delay_import() + + result = self._run(*args, **kwargs) + + self.binder.bind(self.external_id, self.binding) + # Commit so we keep the external ID when there are several + # exports (due to dependencies) and one of them fails. + # The commit will also release the lock acquired on the binding + # record + # if not odoo.tools.config["test_enable"]: + # self.env.cr.commit() # pylint: disable=E8102 + + self._after_export() + return result + + def _run(self, internal_fields=None): + """ Flow of the synchronization, implemented in inherited classes""" + assert self.binding + + if not self.external_id: + internal_fields = None # should be created with all the fields + + if self._has_to_skip(): + return + + # export the missing linked resources + self._export_dependencies() + + # prevent other jobs to export the same record + # will be released on commit (or rollback) + self._lock() + + map_record = self._map_data() + + # passing info to the mapper + opts = self._mapper_options() + + if self.external_id: + values = self._update_data(map_record, fields=internal_fields, **opts) + if not values: + return _("Nothing to export.") + self._update(values) + else: + values = self._create_data(map_record, fields=internal_fields, **opts) + if not values: + return _("Nothing to export.") + self.external_id = self._create(values) + + return _("Record exported with ID %s on Backend.") % self.external_id + + def _after_export(self): + """ Can do several actions after exporting a record on the backend """ + + def _lock(self): + """Lock the binding record. + + Lock the binding record so we are sure that only one export + job is running for this record if concurrent jobs have to export the + same record. + + When concurrent jobs try to export the same record, the first one + will lock and proceed, the others will fail to lock and will be + retried later. + + This behavior works also when the export becomes multilevel + with :meth:`_export_dependencies`. Each level will set its own lock + on the binding record it has to export. + + """ + sql = "SELECT id FROM %s WHERE ID = %%s FOR UPDATE NOWAIT" % self.model._table + try: + self.env.cr.execute(sql, (self.binding.id,), log_exceptions=False) + except psycopg2.OperationalError: + _logger.info( + "A concurrent job is already exporting the same " + "record (%s with id %s). Job delayed later.", + self.model._name, + self.binding.id, + ) + raise RetryableJobError( + "A concurrent job is already exporting the same record " + "(%s with id %s). The job will be retried later." + % (self.model._name, self.binding.id) + ) + + def _has_to_skip(self): + """ Return True if the export can be skipped """ + return False + + @contextmanager + def _retry_unique_violation(self): + """Context manager: catch Unique constraint error and retry the + job later. + + When we execute several jobs workers concurrently, it happens + that 2 jobs are creating the same record at the same time (binding + record created by :meth:`_export_dependency`), resulting in: + + IntegrityError: duplicate key value violates unique + constraint "my_backend_product_product_odoo_uniq" + DETAIL: Key (backend_id, odoo_id)=(1, 4851) already exists. + + In that case, we'll retry the import just later. + + .. warning:: The unique constraint must be created on the + for the same External record. + + """ + try: + yield + except psycopg2.IntegrityError as err: + if err.pgcode == psycopg2.errorcodes.UNIQUE_VIOLATION: + raise RetryableJobError( + "A database error caused the failure of the job:\n" + "%s\n\n" + "Likely due to 2 concurrent jobs wanting to create " + "the same record. The job will be retried later." % err + ) + else: + raise + + def _export_dependency( + self, + relation, + binding_model, + component_usage="direct.record.exporter", + binding_field=None, + binding_extra_vals=None, + always=False, + ): + """ + Export a dependency. The exporter class is a subclass of + ``GenericExporter``. If a more precise class need to be defined, + it can be passed to the ``exporter_class`` keyword argument. + + .. warning:: a commit is done at the end of the export of each + dependency. The reason for that is that we pushed a record + on the backend and we absolutely have to keep its ID. + + So you *must* take care not to modify the Odoo + database during an export, excepted when writing + back the external ID or eventually to store + external data that we have to keep on this side. + + You should call this method only at the beginning + of the exporter synchronization, + in :meth:`~._export_dependencies`. + + :param relation: record to export if not already exported + :type relation: :py:class:`odoo.models.BaseModel` + :param binding_model: name of the binding model for the relation + :type binding_model: str | unicode + :param component_usage: 'usage' to look for to find the Component to + for the export, by default 'record.exporter' + :type exporter: str | unicode + :param binding_field: name of the one2many field on a normal + record that points to the binding record + (default: my_backend_bind_ids). + It is used only when the relation is not + a binding but is a normal record. + :type binding_field: str | unicode + :binding_extra_vals: In case we want to create a new binding + pass extra values for this binding + :type binding_extra_vals: dict + """ + if not relation: + return + + binding = None + if not always: + rel_binder = self.binder_for(binding_model) + binding = rel_binder.wrap_record(relation) + if not binding or not binding.external_id: + binding = rel_binder.to_binding_from_internal_key(relation) + + if always or not binding: + exporter = self.component(usage=component_usage, model_name=binding_model) + exporter.run(relation) + + def _export_dependencies(self): + """ Export the dependencies for the record""" + return + + def _map_data(self): + """Returns an instance of + :py:class:`~odoo.addons.connector.components.mapper.MapRecord` + + """ + return self.mapper.map_record(self.binding) + + def _validate_create_data(self, data): + """Check if the values to import are correct + + Pro-actively check before the ``Model.create`` if some fields + are missing or invalid + + Raise `InvalidDataError` + """ + return + + def _validate_update_data(self, data): + """Check if the values to import are correct + + Pro-actively check before the ``Model.update`` if some fields + are missing or invalid + + Raise `InvalidDataError` + """ + return + + def _create_data(self, map_record, fields=None, **kwargs): + """ Get the data to pass to :py:meth:`_create` """ + return map_record.values(for_create=True, fields=fields, **kwargs) + + def _create(self, data): + """ Create the External record """ + # special check on data before export + self._validate_create_data(data) + # DISABLEDONDEV + print(">>>>>>>>>>>>>>CREATE", data) + if self.model._name in ( + "channel.wubook.pms.availability", + # "channel.wubook.pms.availability.plan", + ): + return self.backend_adapter.create(data) + # raise Exception("Unexpected Create!!") + # return self.backend_adapter.create(data) + + def _update_data(self, map_record, fields=None, **kwargs): + """ Get the data to pass to :py:meth:`_update` """ + return map_record.values(fields=fields, **kwargs) + + def _update(self, data): + """ Update an External record """ + assert self.external_id + # special check on data before export + self._validate_update_data(data) + # DISABLEDONDEV + print(">>>>>>>>>>>>>>WRITE", data) + # if self.model._name in ("channel.wubook.pms.availability.plan",): + # self.backend_adapter.write(self.external_id, data) + # raise Exception("Unexpected Write!!") + # self.backend_adapter.write(self.external_id, data) diff --git a/connector_pms/components_custom/importer.py b/connector_pms/components_custom/importer.py new file mode 100644 index 0000000000..8432b226c1 --- /dev/null +++ b/connector_pms/components_custom/importer.py @@ -0,0 +1,244 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging +from contextlib import contextmanager + +import psycopg2 + +from odoo import _ +from odoo.exceptions import ValidationError + +from odoo.addons.component.core import AbstractComponent +from odoo.addons.connector.exception import IDMissingInBackend +from odoo.addons.queue_job.exception import NothingToDoJob, RetryableJobError + +_logger = logging.getLogger(__name__) + + +class GenericImporterCustom(AbstractComponent): + """ Generic Synchronizer for importing data from backend to Odoo """ + + _name = "generic.importer.custom" + _inherit = "base.importer" + + @contextmanager + def _retry_unique_violation(self): + """Context manager: catch Unique constraint error and retry the + job later. + + When we execute several jobs workers concurrently, it happens + that 2 jobs are creating the same record at the same time (binding + record created by :meth:`_export_dependency`), resulting in: + + IntegrityError: duplicate key value violates unique + constraint "prestashop_product_template_openerp_uniq" + DETAIL: Key (backend_id, odoo_id)=(1, 4851) already exists. + + In that case, we'll retry the import just later. + + """ + try: + yield + except psycopg2.IntegrityError as err: + if err.pgcode == psycopg2.errorcodes.UNIQUE_VIOLATION: + raise RetryableJobError( + "A database error caused the failure of the job:\n" + "%s\n\n" + "Likely due to 2 concurrent jobs wanting to create " + "the same record. The job will be retried later." % err + ) + else: + raise + + def _import_dependency( + self, external_ids, binding_model, importer=None, adapter=None, always=False + ): + """Import a dependency. + + The importer class is a class or subclass of + :class:`Importer`. A specific class can be defined. + + :param external_ids: id or id's of the related bindings to import + :param binding_model: name of the binding model for the relation + :type binding_model: str | unicode + :param importer_component: component to use for import + By default: 'importer' + :type importer_component: Component + :param adapter_component: component to use for access to backend + By default: 'backend.adapter' + :type adapter_component: Component + :param always: if True, the record is updated even if it already + exists, note that it is still skipped if it has + not been modified on Backend since the last + update. When False, it will import it only when + it does not yet exist. + :type always: boolean + """ + if not external_ids: + return + if not isinstance(external_ids, (list, tuple, set)): + external_ids = [external_ids] + + if importer is None: + importer = self.component(usage=self._usage, model_name=binding_model) + + binder = self.binder_for(binding_model) + external_ids = list( + filter(lambda x: always or not binder.to_internal(x), external_ids) + ) + if len(external_ids) == 1: + try: + importer.run(external_ids[0]) + except NothingToDoJob: + _logger.info( + "Dependency import of %s(%s) has been ignored.", + binding_model._name, + external_ids[0], + ) + elif len(external_ids) > 1: + if adapter is None: + adapter = self.component( + usage="backend.adapter", model_name=binding_model + ) + if not hasattr(adapter, "_id"): + raise ValidationError(_("No Id field (_id) defined on backend")) + records = adapter.search_read([(adapter._id, "in", external_ids)]) + for rec in records: + external_id = rec[adapter._id] + try: + importer.run(external_id, external_data=rec) + except NothingToDoJob: + _logger.info( + "Dependency import of %s(%s) has been ignored.", + binding_model._name, + external_ids, + ) + + def _import_dependencies(self, external_data, external_fields): + """Import the dependencies for the record + + Import of dependencies can be done manually or by calling + :meth:`_import_dependency` for each dependency. + """ + return + + # def _force_binding(self, external_id, external_data=None): + # return + + def _after_import(self, binding): + return + + def _must_skip(self, binding): + """Hook called right after we read the data from the backend. + + If the method returns a message giving a reason for the + skipping, the import will be interrupted and the message + recorded in the job (if the import is called directly by the + job, not by dependencies). + + If it returns None, the import will continue normally. + + :returns: None | str | unicode + """ + return False + + def _mapper_options(self, binding): + return {"binding": binding} + + def _create(self, model, values): + """ Create the Internal record """ + # return model.create(values) + return model.with_context(connector_no_export=True).create(values) + + def _update(self, binding, values): + """ Update an Internal record """ + binding.with_context(connector_no_export=True).write(values) + + def run(self, external_id, external_data=None, external_fields=None): + if not external_data: + external_data = {} + lock_name = "import({}, {}, {}, {})".format( + self.backend_record._name, + self.backend_record.id, + self.work.model_name, + external_id, + ) + # Keep a lock on this import until the transaction is committed + # The lock is kept since we have detected that the informations + # will be updated into Odoo + self.advisory_lock_or_retry(lock_name, retry_seconds=10) + + if not external_data: + # read external data from Backend + external_data = self.backend_adapter.read(external_id) + if not external_data: + raise IDMissingInBackend( + _("Record with external_id '%s' does not exist in Backend") + % (external_id,) + ) + + # import the missing linked resources + self._import_dependencies(external_data, external_fields) + + # map_data + # this one knows how to convert backend data to odoo data + mapper = self.component(usage="import.mapper") + + # convert to odoo data + internal_data = mapper.map_record(external_data) + + # get_binding + # this one knows how to link Baclend/Odoo records + binder = self.component(usage="binder") + + # find if the external id already exists in odoo + binding = binder.to_internal(external_id) + + # if binding not exists, try to link existing internal object + if not binding: + binding = binder.to_binding_from_external_key(internal_data) + + # # force binding + # if not binding: + # binding = self._force_binding(external_id, external_data=external_data) + + # skip binding + skip = self._must_skip(binding) + if skip: + return skip + + # passing info to the mapper + opts = self._mapper_options(binding) + + if external_fields != []: + # persist data + if binding: + # if exists, we update it + values = internal_data.values(fields=external_fields, **opts) + self._update(binding, values) + # binding.with_context( + # connector_no_export=True, + # mail_create_nosubscribe=True, + # force_overbooking=True, + # ).write(values) + _logger.debug("%d updated from Backend %s", binding, external_id) + else: + # or we create it + values = internal_data.values( + for_create=True, fields=external_fields, **opts + ) + with self._retry_unique_violation(): + binding = self._create(self.model, values) + # binding = self.model.with_context( + # connector_no_export=True, + # mail_create_nosubscribe=True, + # force_overbooking=True, + # ).create(values) + _logger.debug("%d created from Backend %s", binding, external_id) + # finally, we bind both, so the next time we import + # the record, we'll update the same record instead of + # creating a new one + binder.bind(external_id, binding) + + # last update + self._after_import(binding) diff --git a/connector_pms/components_custom/mapper.py b/connector_pms/components_custom/mapper.py new file mode 100644 index 0000000000..05cdf5ea7b --- /dev/null +++ b/connector_pms/components_custom/mapper.py @@ -0,0 +1,127 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import collections +import logging + +from odoo import _ +from odoo.exceptions import ValidationError + +from odoo.addons.component.core import AbstractComponent +from odoo.addons.connector.components.mapper import m2o_to_external + +_logger = logging.getLogger(__name__) + + +class Mapper(AbstractComponent): + _inherit = "base.mapper" + + def _apply_with_options(self, map_record): + """ + Hack to allow having non required children field + """ + assert ( + self.options is not None + ), "options should be defined with '_mapping_options'" + _logger.debug("converting record %s to model %s", map_record.source, self.model) + + fields = self.options.fields + for_create = self.options.for_create + result = {} + for from_attr, to_attr in self.direct: + if isinstance(from_attr, collections.Callable): + attr_name = self._direct_source_field_name(from_attr) + else: + attr_name = from_attr + + if not fields or attr_name in fields: + value = self._map_direct(map_record.source, from_attr, to_attr) + result[to_attr] = value + + for meth, definition in self.map_methods: + mapping_changed_by = definition.changed_by + if not fields or ( + mapping_changed_by and mapping_changed_by.intersection(fields) + ): + if definition.only_create and not for_create: + continue + values = meth(map_record.source) + if not values: + continue + if not isinstance(values, dict): + raise ValueError( + "%s: invalid return value for the " + "mapping method %s" % (values, meth) + ) + result.update(values) + + for from_attr, to_attr, model_name in self.children: + if not fields or from_attr in fields: + if from_attr in map_record.source: + result[to_attr] = self._map_child( + map_record, from_attr, to_attr, model_name + ) + + return self.finalize(map_record, result) + + def get_target_fields(self, map_record, fields): + if not fields: + return [] + fields = set(fields) + result = {} + for from_attr, to_attr in self.direct: + if isinstance(from_attr, collections.Callable): + # attr_name = self._direct_source_field_name(from_attr) + # TODO + raise NotImplementedError + else: + if to_attr in fields: + if to_attr in result: + raise ValidationError(_("Field '%s' mapping defined twice")) + result[to_attr] = from_attr + + # TODO: create a new decorator to write the field mapping manually + # for meth, definition in self.map_methods: + # for mcb in definition.mapping: + # if mcb in fields: + # if to_attr in result: + # raise ValidationError("Field '%s' mapping defined twice") + # result[to_attr] = from_attr + + for from_attr, to_attr, _model_name in self.children: + if to_attr in fields: + if to_attr in result: + raise ValidationError(_("Field '%s' mapping defined twice")) + result[to_attr] = from_attr + + return list(set(result.values())) + + +# TODO: create a fix on OCA repo and remove this class +class ExportMapper(AbstractComponent): + _inherit = "base.export.mapper" + + def _map_direct(self, record, from_attr, to_attr): + """Apply the ``direct`` mappings. + + :param record: record to convert from a source to a target + :param from_attr: name of the source attribute or a callable + :type from_attr: callable | str + :param to_attr: name of the target attribute + :type to_attr: str + """ + if isinstance(from_attr, collections.Callable): + return from_attr(self, record, to_attr) + + value = record[from_attr] + if value is None: # we need to allow fields with value 0 + return False + + # Backward compatibility: when a field is a relation, and a modifier is + # not used, we assume that the relation model is a binding. + # Use an explicit modifier m2o_to_external in the 'direct' mappings to + # change that. + field = self.model._fields[from_attr] + if field.type == "many2one": + mapping_func = m2o_to_external(from_attr) + value = mapping_func(self, record, to_attr) + return value diff --git a/connector_pms/data/queue_data.xml b/connector_pms/data/queue_data.xml new file mode 100644 index 0000000000..b67c088994 --- /dev/null +++ b/connector_pms/data/queue_data.xml @@ -0,0 +1,11 @@ + + + + + + pms + + + + diff --git a/connector_pms/models/__init__.py b/connector_pms/models/__init__.py new file mode 100644 index 0000000000..09ecef794a --- /dev/null +++ b/connector_pms/models/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import common +from . import pms_reservation diff --git a/connector_pms/models/common/__init__.py b/connector_pms/models/common/__init__.py new file mode 100644 index 0000000000..f7d1d23e00 --- /dev/null +++ b/connector_pms/models/common/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import backend +from . import backend_type +from . import binding diff --git a/connector_pms/models/common/backend.py b/connector_pms/models/common/backend.py new file mode 100644 index 0000000000..8d28c81165 --- /dev/null +++ b/connector_pms/models/common/backend.py @@ -0,0 +1,66 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging + +from odoo import _, fields, models +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +class ChannelBackend(models.Model): + _name = "channel.backend" + _description = "Channel PMS Backend" + + name = fields.Char("Name", required=True) + + pms_property_id = fields.Many2one( + comodel_name="pms.property", + string="Property", + required=True, + ondelete="restrict", + ) + + user_id = fields.Many2one( + comodel_name="res.users", + string="User", + ondelete="restrict", + ) + + backend_type_id = fields.Many2one( + string="Type", + comodel_name="channel.backend.type", + required=True, + ondelete="restrict", + ) + + @property + def child_id(self): + self.ensure_one() + # TODO: move to computed field + model = self.env[self.backend_type_id.model_type_id.model]._main_model + child_backends = self.env[model].search( + [ + ("parent_id", "=", self.id), + ] + ) + if len(child_backends) > 1: + raise ValidationError( + _( + "Inconsistency detected. More than one " + "backend's child found for the same parent" + ) + ) + return child_backends + + def channel_config(self): + self.ensure_one() + # TODO: move to computed field + model = self.env[self.backend_type_id.model_type_id.model]._main_model + return { + "type": "ir.actions.act_window", + "res_model": model, + "views": [[False, "form"]], + "context": not self.child_id and {"default_parent_id": self.id}, + "res_id": self.child_id.id, + } diff --git a/connector_pms/models/common/backend_type.py b/connector_pms/models/common/backend_type.py new file mode 100644 index 0000000000..bf792abac4 --- /dev/null +++ b/connector_pms/models/common/backend_type.py @@ -0,0 +1,57 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +class ChannelBackendType(models.Model): + _name = "channel.backend.type" + _description = "Channel PMS Backend Type" + + name = fields.Char("Name", required=True) + + model_type_id = fields.Many2one( + comodel_name="ir.model", + string="Referenced Model Type", + required=True, + ondelete="cascade", + domain=lambda self: [ + ("model", "in", self._get_channel_backend_type_model_names()) + ], + ) + + @property + def child_id(self): + self.ensure_one() + child_backends = self.env[self.model_type_id.model].search( + [ + ("parent_id", "=", self.id), + ] + ) + if len(child_backends) > 1: + raise ValidationError( + _( + "Inconsistency detected. More than one " + "backend's child found for the same parent" + ) + ) + return child_backends + + @api.model + def _get_channel_backend_type_model_names(self): + res = [] + return res + + def channel_type_config(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "res_model": self.model_type_id.model, + "views": [[False, "form"]], + "context": not self.child_id and {"default_parent_id": self.id}, + "res_id": self.child_id.id, + } diff --git a/connector_pms/models/common/binding.py b/connector_pms/models/common/binding.py new file mode 100644 index 0000000000..f6ea0923dd --- /dev/null +++ b/connector_pms/models/common/binding.py @@ -0,0 +1,110 @@ +# Copyright 2021 Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + +# from odoo.addons.queue_job.job import job, related_action + + +class ChannelBinding(models.AbstractModel): + _name = "channel.binding" + _inherit = "external.binding" + _description = "Channel PMS Binding (abstract)" + + sync_date = fields.Datetime(readonly=True) + + external_id = fields.Integer(string="External ID", required=False) + + _sql_constraints = [ + ( + "channel_external_uniq", + "unique(backend_id, external_id)", + "A binding already exists with the same External (Channel) ID.", + ), + ( + "channel_internal_uniq", + "unique(backend_id, odoo_id)", + "A binding already exists with the same Internal (Odoo) ID.", + ), + ] + + # default methods + @api.model + def import_data(self, backend_record=None): + """ Prepare the batch import of records from Channel """ + return self.import_batch(backend_record=backend_record) + + @api.model + def export_data(self, backend_record=None): + """ Prepare the batch export records to Channel """ + return self.export_batch(backend_record=backend_record) + + # syncronizer import + @api.model + def import_batch(self, backend_record, domain=None, delayed=True): + """ Prepare the batch import of records modified on Channel """ + if not domain: + domain = [] + with backend_record.work_on(self._name) as work: + importer = work.component( + usage=delayed and "delayed.batch.importer" or "direct.batch.importer" + ) + return importer.run(domain=domain) + + @api.model + def import_record(self, backend_record, external_id, external_data=None): + """ Import Channel record """ + if not external_data: + external_data = {} + with backend_record.work_on(self._name) as work: + importer = work.component(usage="direct.record.importer") + return importer.run(external_id, external_data=external_data) + + # syncronizer export + @api.model + def export_batch(self, backend_record, domain=None, delayed=True): + """ Prepare the batch export of records modified on Odoo """ + if not domain: + domain = [] + with backend_record.work_on(self._name) as work: + exporter = work.component( + usage=delayed and "delayed.batch.exporter" or "direct.batch.exporter" + ) + return exporter.run(domain=domain) + + @api.model + def export_record(self, backend_record, relation): + """ Export Odoo record """ + with backend_record.work_on(self._name) as work: + exporter = work.component(usage="direct.record.exporter") + return exporter.run(relation) + + # existing binding synchronization + def resync_import(self): + for record in self: + # import + with record.backend_id.work_on(record._name) as work: + binder = work.component(usage="binder") + external_id = binder.to_external(self) + + func = record.import_record + if record.env.context.get("connector_delay"): + func = func.delay + + func(record.backend_id, external_id) + + return True + + def resync_export(self): + for record in self: + with record.backend_id.work_on(record._name) as work: + binder = work.component(usage="binder") + relation = binder.unwrap_binding(self) + + func = record.export_record + if record.env.context.get("connector_delay"): + func = func.delay + + func(record.backend_id, relation) + + return True diff --git a/connector_pms/models/pms_reservation.py b/connector_pms/models/pms_reservation.py new file mode 100644 index 0000000000..f7208cabce --- /dev/null +++ b/connector_pms/models/pms_reservation.py @@ -0,0 +1,17 @@ +# Copyright 2017-2018 Alexandre Díaz +# Copyright 2017 Dario Lodeiros +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging + +from odoo import fields, models + +_logger = logging.getLogger(__name__) + + +class PmsReservation(models.Model): + _inherit = "pms.reservation" + + ota_reservation_code = fields.Char( + string="OTA Reservation Code", + readonly=True, + ) diff --git a/connector_pms/readme/CONTRIBUTORS.rst b/connector_pms/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..a9e48ccda7 --- /dev/null +++ b/connector_pms/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Eric Antones diff --git a/connector_pms/readme/DESCRIPTION.rst b/connector_pms/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..a034faa694 --- /dev/null +++ b/connector_pms/readme/DESCRIPTION.rst @@ -0,0 +1,6 @@ +Base module for implement channel connectors + +Features: + + * Avaliability Management + * Odoo Connector diff --git a/connector_pms/readme/USAGE.rst b/connector_pms/readme/USAGE.rst new file mode 100644 index 0000000000..970d18f192 --- /dev/null +++ b/connector_pms/readme/USAGE.rst @@ -0,0 +1 @@ +No configuration required. This is a 'tool' module, need be used with other modules. diff --git a/connector_pms/security/ir.model.access.csv b/connector_pms/security/ir.model.access.csv new file mode 100644 index 0000000000..b58ca16f2f --- /dev/null +++ b/connector_pms/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_channel_backend_manager,channel backend manager,model_channel_backend,connector.group_connector_manager,1,1,1,1 +access_channel_backend_type_manager,channel backend type manager,model_channel_backend_type,connector.group_connector_manager,1,1,1,1 diff --git a/connector_pms/static/description/icon.png b/connector_pms/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z z?n?eq1XP>p-IM$Z^C;2L3itnbJZAip*Zo0aw2bs8@(s^~*8T9go!%dHcAz2lM;`yp zD=7&xjFV$S&5uDaiScyD?B-i1ze`+CoRtz`Wn+Zl&#s4&}MO{@N!ufrzjG$B79)Y2d3tBk&)TxUTw@QS0TEL_?njX|@vq?Uz(nBFK5Pq7*xj#u*R&i|?7+6# z+|r_n#SW&LXhtheZdah{ZVoqwyT{D>MC3nkFF#N)xLi{p7J1jXlmVeb;cP5?e(=f# zuT7fvjSbjS781v?7{)-X3*?>tq?)Yd)~|1{BDS(pqC zC}~H#WXlkUW*H5CDOo<)#x7%RY)A;ShGhI5s*#cRDA8YgqG(HeKDx+#(ZQ?386dv! zlXCO)w91~Vw4AmOcATuV653fa9R$fyK8ul%rG z-wfS zihugoZyr38Im?Zuh6@RcF~t1anQu7>#lPpb#}4cOA!EM11`%f*07RqOVkmX{p~KJ9 z^zP;K#|)$`^Rb{rnHGH{~>1(fawV0*Z#)}M`m8-?ZJV<+e}s9wE# z)l&az?w^5{)`S(%MRzxdNqrs1n*-=jS^_jqE*5XDrA0+VE`5^*p3CuM<&dZEeCjoz zR;uu_H9ZPZV|fQq`Cyw4nscrVwi!fE6ciMmX$!_hN7uF;jjKG)d2@aC4ropY)8etW=xJvni)8eHi`H$%#zn^WJ5NLc-rqk|u&&4Z6fD_m&JfSI1Bvb?b<*n&sfl0^t z=HnmRl`XrFvMKB%9}>PaA`m-fK6a0(8=qPkWS5bb4=v?XcWi&hRY?O5HdulRi4?fN zlsJ*N-0Qw+Yic@s0(2uy%F@ib;GjXt01Fmx5XbRo6+n|pP(&nodMoap^z{~q ziEeaUT@Mxe3vJSfI6?uLND(CNr=#^W<1b}jzW58bIfyWTDle$mmS(|x-0|2UlX+9k zQ^EX7Nw}?EzVoBfT(-LT|=9N@^hcn-_p&sqG z&*oVs2JSU+N4ZD`FhCAWaS;>|wH2G*Id|?pa#@>tyxX`+4HyIArWDvVrX)2WAOQff z0qyHu&-S@i^MS-+j--!pr4fPBj~_8({~e1bfcl0wI1kaoN>mJL6KUPQm5N7lB(ui1 zE-o%kq)&djzWJ}ob<-GfDlkB;F31j-VHKvQUGQ3sp`CwyGJk_i!y^sD0fqC@$9|jO zOqN!r!8-p==F@ZVP=U$qSpY(gQ0)59P1&t@y?5rvg<}E+GB}26NYPp4f2YFQrQtot5mn3wu_qprZ=>Ig-$ zbW26Ws~IgY>}^5w`vTB(G`PTZaDiGBo5o(tp)qli|NeV( z@H_=R8V39rt5J5YB2Ky?4eJJ#b`_iBe2ot~6%7mLt5t8Vwi^Jy7|jWXqa3amOIoRb zOr}WVFP--DsS`1WpN%~)t3R!arKF^Q$e12KEqU36AWwnCBICpH4XCsfnyrHr>$I$4 z!DpKX$OKLWarN7nv@!uIA+~RNO)l$$w}p(;b>mx8pwYvu;dD_unryX_NhT8*Tj>BTrTTL&!?O+%Rv;b?B??gSzdp?6Uug9{ zd@V08Z$BdI?fpoCS$)t4mg4rT8Q_I}h`0d-vYZ^|dOB*Q^S|xqTV*vIg?@fVFSmMpaw0qtTRbx} z({Pg?#{2`sc9)M5N$*N|4;^t$+QP?#mov zGVC@I*lBVrOU-%2y!7%)fAKjpEFsgQc4{amtiHb95KQEwvf<(3T<9-Zm$xIew#P22 zc2Ix|App^>v6(3L_MCU0d3W##AB0M~3D00EWoKZqsJYT(#@w$Y_H7G22M~ApVFTRHMI_3be)Lkn#0F*V8Pq zc}`Cjy$bE;FJ6H7p=0y#R>`}-m4(0F>%@P|?7fx{=R^uFdISRnZ2W_xQhD{YuR3t< z{6yxu=4~JkeA;|(J6_nv#>Nvs&FuLA&PW^he@t(UwFFE8)|a!R{`E`K`i^ZnyE4$k z;(749Ix|oi$c3QbEJ3b~D_kQsPz~fIUKym($a_7dJ?o+40*OLl^{=&oq$<#Q(yyrp z{J-FAniyAw9tPbe&IhQ|a`DqFTVQGQ&Gq3!C2==4x{6EJwiPZ8zub-iXoUtkJiG{} zPaR&}_fn8_z~(=;5lD-aPWD3z8PZS@AaUiomF!G8I}Mf>e~0g#BelA-5#`cj;O5>N Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 diff --git a/connector_pms/static/description/index.html b/connector_pms/static/description/index.html new file mode 100644 index 0000000000..26f0bd3436 --- /dev/null +++ b/connector_pms/static/description/index.html @@ -0,0 +1,558 @@ + + + + + + + PMS Connector + + + +
+

PMS Connector

+ + +

Beta + License: AGPL-3 OCA/pms + Translate me on Weblate + Try me on Runbot

+

Base module for implement channel connectors

+

Features:

+
+
    +
  • Avaliability Management
  • +
  • Odoo Connector
  • +
+
+

Table of contents

+ +
+

Usage

+

No configuration required. This is a ‘tool’ module, need be used with other modules.

+
+
+

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

+ +
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose + mission is to support the collaborative development of Odoo features and + promote its widespread use.

+

This module is part of the + OCA/pms + project on GitHub.

+

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

+
+
+
+ + diff --git a/connector_pms/views/channel_backend_type_views.xml b/connector_pms/views/channel_backend_type_views.xml new file mode 100644 index 0000000000..4824e7a756 --- /dev/null +++ b/connector_pms/views/channel_backend_type_views.xml @@ -0,0 +1,70 @@ + + + + + channel.backend.type.form + channel.backend.type + +
+
+ + +
+ +
+
+ + + + + + channel.backend.type.tree + channel.backend.type + + + + + + + + + + + channel.backend.tree + channel.backend + + + + + +